Compare commits

..
Author SHA1 Message Date
enricobuehler d2c6e1c9c0 Merge pull request '0.31.4 — the screens that stayed lit, the power menu that did nothing, and the console no browser would open' (#394) from worktree-release-0314 into main
audit / bun-audit (web) (push) Successful in 24s
audit / bun-audit (sdk) (push) Successful in 25s
audit / bun-audit (plugin-kit) (push) Successful in 26s
audit / docs-site-audit (push) Successful in 24s
audit / pnpm-audit (push) Successful in 12s
audit / cargo-audit (push) Successful in 43s
ci / rust-arm64 (push) Successful in 1m40s
ci / web (push) Successful in 1m25s
ci / docs-site (push) Successful in 1m11s
ci / bun-nix (push) Successful in 33s
ci / docs-drift (push) Successful in 39s
audit / license-gate (push) Successful in 6m4s
audit / c-abi-asan (push) Successful in 7m33s
audit / miri (push) Successful in 7m48s
android-screenshots / screenshots (push) Successful in 1m17s
ci / rust (push) Successful in 16m49s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m34s
android / android (push) Successful in 10m54s
decky / build-publish (push) Successful in 58s
arch / build-publish (push) Successful in 12m5s
sbom / sbom (push) Successful in 42s
linux-client-screenshots / screenshots (push) Successful in 6m18s
flatpak / build-publish (push) Successful in 7m39s
docker / builders-arm64cross (push) Successful in 14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 18s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 15s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m30s
docker / deploy-docs (push) Successful in 29s
nix / flake (push) Successful in 25m39s
deb / smoke-install (push) Successful in 3m41s
deb / build-publish (push) Successful in 6m45s
deb / build-publish-host (push) Successful in 5m29s
deb / build-publish-gamescope (push) Successful in 37s
deb / build-publish-client-arm64 (push) Successful in 1m28s
apple / distribute (push) Successful in 13m7s
apple / swift (push) Successful in 2m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m52s
apple / screenshots (push) Successful in 9m32s
windows-host / package (push) Successful in 12m0s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 26s
web-screenshots / screenshots (push) Successful in 6m16s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m12s
Reviewed-on: #394
2026-08-24 22:26:27 +00:00
enricobuehler 1d755ebeeb release: 0.31.4 — version bump, notes, CHANGELOG, Play notes
ci / bun-nix (pull_request) Successful in 39s
ci / docs-drift (pull_request) Successful in 39s
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m18s
apple / swift (pull_request) Successful in 2m12s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 4m59s
android / android (pull_request) Successful in 8m34s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 8m7s
nix / flake (pull_request) Successful in 8m13s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m56s
ci / rust (pull_request) Successful in 18m25s
21 commits since v0.31.3 (14 non-merge). Cut from origin/main c407f6a6 (#393
merged).

THE NUMBER: a patch, and the cleanest one in a while — NOTHING versioned moves.
WIRE_VERSION 2, C ABI 25 with include/punktfunk_core.h showing NO diff against
the v0.31.3 tag (nor against v0.31.2 or v0.31.1 — third release running), driver
protocol 6 / min 3 with pf-driver-proto unchanged, gamepad channel 3, plugin
index schema 1, host event schema 1 (events.rs no diff), gamescope +pfhdr8 with
no new patch files, SDK 0.1.6 and plugin-kit 0.4.4 both untouched. api/openapi.json
is CONTENT-identical — info.version is the entire diff, the first release since
v0.31.0 where the management API grows nothing. No lockfile moves, in any of the
five. Four feat commits, all additive and all client- or Linux-host-side; v0.31.3
and v0.31.1 each carried two feats as a patch on the same reasoning.

THE SHAPE: Linux-host, and the faults share a premise — a policy written against
ONE desktop that silently did nothing on the others. Topology::Exclusive on a
gamescope session was implemented as org_kde_kwin_dpms and reached only a live
KDE desktop, so a Game Mode box (the deployment whose TV the operator most wants
dark) got nothing: #389 is five commits closing that — a DRM floor for a box with
no desktop at all, sway and Hyprland arms, the managed route's own hold, and the
box-session free that was gated on is_steam_launch. The sleep:idle BLOCK
inhibitor refused every Suspend() for a stream's length including the operator's
own from Steam's power menu, and on a DM-stopping takeover polkit's allow_active
fallback found no session to elect and dropped all three power actions to
auth_admin_keep (#391). The web console served the LEGACY identity because every
launcher names that pair and none of them CAN choose, so browsers rejected it and
the tray's mgmt-pinned probe refused the handshake and labelled a healthy console
dead (#393). Plus the client's encode down-driver (#392), an installTap format
snapshot reaching the ObjC terminate handler (#387), the console's bitrate row
(#388), and a GTK front door for the console UI (#390).

TWO ENTRIES WORTH THE READER'S ATTENTION, both recorded as such:
  * The sway arm of #389 is NOT verified on glass — the lab has Bazzite, Nobara
    and SteamOS, no sway box. Hyprland, KDE and the DRM floor were all measured
    on real machines. The notes say so in "Thanks" rather than burying it.
  * #389's Hyprland arm shipped WRONG in its first cut and was corrected on glass
    (540e282e): the dpms dispatcher is a TOGGLE that ignores the state word, so
    the blind "send off, later send on" would have LIT an already-dark head at
    stream start and DARKENED a lit one at teardown — the exact failure the
    feature exists to prevent. Read -> act only if different -> verify.

DOCS FRESHNESS, per docs/releases/README.md step 1: #390 and #393 carried their
own docs-site updates (clients.md, and #389's two gamescope.md edits). Two facts
were left owed, both in gamescope.md and both the "stale sentence" class that
docs-drift cannot catch:
  * the darkening mechanism list still read "KDE, else DRM" and never learned
    about the sway and Hyprland arms from e0a82201;
  * the "could not deliver a dark screen" example was a box in Game Mode, which
    is now precisely the case that IS served by the DRM floor. The real remaining
    gap is GNOME/Mutter, so it says that instead.
Also: the punktfunk group grew a third grant this cycle (the three logind power
actions, via 49-punktfunk-power.rules), and the group's own callout block
enumerated only the usbip nodes — one sentence added there.
No new PUNKTFUNK_* variable this cycle (PUNKTFUNK_UI_TLS_CERT/KEY are
pre-existing and already in the baseline), no new host subcommand, and no install
command, repo URL or port change, so data/platforms.json and the website's
vendored copy need nothing.

VERIFIED HERE: scripts/ci/check-docs-drift.sh clean, and PROVEN LIVE rather than
assumed — a deliberate one-byte break of the openapi copy makes it fail, and it
passes again restored; scripts/ci/check-docs-links.sh clean; the android.yml Play
notes gate run verbatim, 481/500 characters and unique against every other
release's file; both openapi copies cmp identical and stamped 0.31.4; cargo fmt
--all --check clean; git diff v0.31.3..HEAD empty on include/punktfunk_core.h, on
crates/pf-driver-proto, on punktfunk-core/src/lib.rs, on punktfunk-host/src/events.rs
and on api/openapi.json, which is the direct evidence for those five version rows;
git diff v0.31.1..HEAD on the C header also empty, which is the "third release
running" claim; Cargo.lock's 36 workspace version strings moved with Cargo.toml
and zero 0.31.3 strings remain; 27 crate dirs / 39 workspace members, both
unchanged; notes voice scan clean (zero backticked terms above ## For developers,
and zero internal names on a scan for gamescope/polkit/logind/DPMS/CRTC/DRM/
systemd/kwin/mutter/wlroots/rustls/ioctl); CHANGELOG link pinned to
src/tag/v0.31.4.

NOT RUN HERE, and why: any punktfunk-host build, clippy or cargo test — the host
does not compile on macOS at all, and CI covers it; the web/ and docs-site/ bun
builds — nothing under web/ is touched by this commit and the docs-site edits are
prose in an existing .md; the Android unit tests — nothing here touches Kotlin.

LEFT AS A DECISION, not made here: the tag itself. Notes, CHANGELOG and Play text
are authored ahead of it exactly as docs/releases/README.md step 1 requires, so
whichever workflow wins the create race seeds a complete release body — but
pushing v0.31.4 fans out every build workflow, and that is step 2.
2026-08-24 23:34:38 +02:00
enricobuehler c407f6a6d9 The console served the identity nothing pins, and the tray called it dead (#393)
ci / bun-nix (push) Successful in 29s
ci / web (push) Successful in 1m12s
ci / docs-site (push) Successful in 1m27s
ci / rust-arm64 (push) Successful in 2m3s
ci / docs-drift (push) Successful in 2m8s
deb / build-publish-gamescope (push) Successful in 1m9s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 38s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
deb / build-publish-client-arm64 (push) Successful in 1m45s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 26s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 28s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 25s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
deb / build-publish (push) Successful in 4m53s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m17s
deb / build-publish-host (push) Successful in 5m42s
ci / rust (push) Successful in 7m17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m36s
docker / builders-arm64cross (push) Successful in 19s
docker / deploy-docs (push) Successful in 52s
android / android (push) Successful in 8m54s
arch / build-publish (push) Successful in 9m32s
deb / smoke-install (push) Successful in 4m10s
windows-host / package (push) Successful in 19m10s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 21s
nix / flake (push) Successful in 20m2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m30s
The web console never followed the identity split: every launcher names the legacy cert.pem/key.pem, so it served a CN-only, SAN-less RSA cert. Browsers reject that outright, and the tray's liveness probe — pinned to the native identity the mgmt API serves — could never complete the handshake, labelling a healthy console "not responding" beside a tooltip reading "idle".

Resolved at the Nitro entry, the one place every launcher routes through: prefer the native pair when both halves are present, usable and in the same directory; fall back to the legacy pair otherwise. The tray's console probe drops its pin rather than gaining a second one, and `serve` now mints the native identity before the legacy one.

Reviewed adversarially (no blockers; the one real finding — a pair that could be assembled from two different directories — fixed in 1e2b956d). All 8 checks green, including the native-only clippy gate. Verified end to end against a built server: both pairs → P-256 with SANs; legacy only → RSA as before; 0-byte native cert → clean fallback rather than a restart loop.
2026-08-24 20:55:06 +00:00
enricobuehler 1e2b956de6 fix(web,tray,host): review follow-ups — pair the halves, and stop two comments overclaiming
ci / docs-drift (pull_request) Successful in 25s
ci / bun-nix (pull_request) Successful in 26s
ci / docs-site (pull_request) Successful in 1m11s
ci / web (pull_request) Successful in 1m14s
ci / rust-arm64 (pull_request) Successful in 1m52s
android / android (pull_request) Successful in 5m43s
ci / rust (pull_request) Successful in 5m43s
nix / flake (pull_request) Successful in 6m34s
Review of the parent commit. One real defect, the rest accuracy.

The resolver could hand back a MISMATCHED pair, which is the one invariant its
own header promised it never would: `nativeCert` came from `dirname(cert)` and
`nativeKey` from `dirname(key)`, two independent directories, so
("/a/cert.pem", "/b/key.pem") resolved to /a/native-cert.pem + /b/native-key.pem
— two unrelated files presented as a pair. No shipped launcher splits them, but
the guard is one comparison and it is the whole point of the module.

Rewritten off a suffix test instead of `node:path`, which fixes two more things
in passing. `node:path` resolves per-RUNTIME, so a POSIX CI runner reads
`C:\ProgramData\punktfunk\cert.pem` as one long filename and never swaps — and
Windows, where windows/service.rs hands us exactly that, is the platform the CI
job can never exercise. The suffix test gives the same answer everywhere and is
now covered by a win32 case. It also leaves the prefix VERBATIM, where
`join(dirname(p), …)` normalised /a/b/../cert.pem into a different directory the
moment `b` was a symlink.

Existence is no longer enough: `pf_paths::write_secret_file` is
create+truncate+write rather than temp+rename, so a console starting mid-write
could adopt a 0-byte cert and leave `Bun.serve` throwing on every restart. Not
every launcher retries forever — the Steam Deck unit is `Restart=on-failure`
under the default rate limit, i.e. permanently dead. The check is now a
non-empty stat, mirroring the host's own `!c.trim().is_empty()`. Verified: with
native-cert.pem truncated to 0 bytes the console starts and serves the legacy
pair.

Two comments of mine overclaimed and are corrected rather than left to mislead:

  * serve() said "cert.pem existing implies the native pair does too". False on
    an upgraded host whose native clients pinned the legacy cert — load_or_adopt
    returns it and writes no native files at all. The ordering claim that IS
    true is narrower: whenever that call writes a native pair, it does so before
    cert.pem appears.
  * the tray said the console entry "always opens the same URL regardless of the
    probe". True of the menu entry, but win.rs gates the tray-icon single-click
    on console_up. Also notes that the Windows probe was never pinned to begin
    with (punktfunk_config_dir is None off Linux), so that half is a no-op.

Rest is doc drift the parent commit annotated in two launchers but not the other
four: web.env.example, README, web-run.cmd, and the ci.yml comment that still
said the web test step was "Scoped to server/".

18/18 web tests (was 14), biome and rustfmt clean, and the runtime check re-run
against a fresh build: both pairs -> P-256 with SANs; 0-byte native cert ->
legacy RSA, console still serving.
2026-08-24 22:45:47 +02:00
enricobuehler 49b5ffa2d8 fix(web,tray,host): the console served the identity nothing pins, and the tray called it dead
android / android (pull_request) Successful in 4m33s
ci / bun-nix (pull_request) Successful in 24s
ci / docs-drift (pull_request) Successful in 29s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m41s
ci / rust-arm64 (pull_request) Successful in 2m54s
nix / flake (pull_request) Successful in 6m58s
ci / rust (pull_request) Successful in 7m2s
A Linux operator saw "Open web console (not responding)" in the tray next to a
tooltip reading "idle", and the console would not load in a browser either.

The host has kept two identities since the identity split (crate::identity):
native-cert.pem/native-key.pem (P-256, real SANs — what the native QUIC plane,
the mgmt API and every native client pin) and the legacy cert.pem/key.pem (RSA,
CN=punktfunk, NO SAN, kept byte-stable for Moonlight). The web console never
followed the split. Every launcher — the systemd unit, the NixOS module, the
Windows service supervisor, web-run.cmd, the Steam Deck installer — still names
the LEGACY pair, and none of them CAN choose: `Environment=` has no "this file,
else that one". So the console served a certificate with no SAN at all, which
costs twice over:

  * browsers reject a CN-only cert outright (ERR_CERT_COMMON_NAME_INVALID), so
    the console the operator was told to open does not load;
  * the tray's loopback liveness probe reused the agent PINNED to the mgmt
    identity — the native cert — so rustls refused the handshake and a perfectly
    healthy console was labelled "not responding". The "idle" tooltip beside it
    is the proof: the same agent reached mgmt fine on the very same tick.

The entry is the one place every launcher routes through, so the choice is made
there: prefer the native sibling pair when both files exist, as a PAIR or not at
all (a native cert with the legacy key completes no handshake with anyone). A
host that never took the split has no native pair on disk and falls through
unchanged, as does an operator-supplied cert under any other name. This also
hands the bundled bun the smaller secret: on a default build key.pem is the
Moonlight pairing SIGNING key, native-key.pem is only a TLS key.

The tray's console probe loses its pin rather than gaining a second one. It is a
different server and there is no rule that it presents the mgmt certificate — an
operator fronting the console with their own LAN-CA cert would have hit this just
as squarely. The probe sends no credentials, reads no body, and decides only a
menu label, so there is nothing for a pin to protect.

`serve` now resolves the native identity BEFORE minting the legacy one. That
closes a first-run window where the console (which waits on cert.pem) could start
between the two writes and serve the SAN-less cert for the rest of the boot, and
it fixes a second latent fault: with cert.pem missing but native clients paired,
the old order let load_or_create mint a brand-new cert.pem that load_or_adopt
then adopted while logging that it was preserving their pins.

Verified against the built server: configured exactly as the shipped unit does
(PUNKTFUNK_UI_TLS_CERT=.../cert.pem), it now serves the P-256 cert with
DNS:localhost/IP:127.0.0.1; with the native pair removed it serves the RSA cert
as before. 14/14 web tests pass, biome and rustfmt clean.
2026-08-24 20:30:25 +02:00
enricobuehler d65b9f3b1b Merge pull request 'Exclusive topology left the box's own screen lit — honor it on every desktop we drive' (#389) from worktree-gamescope-exclusive-panel-off into main
ci / rust-arm64 (push) Successful in 2m22s
ci / web (push) Successful in 1m27s
ci / bun-nix (push) Successful in 26s
ci / docs-site (push) Successful in 1m45s
ci / docs-drift (push) Successful in 28s
deb / build-publish-gamescope (push) Successful in 53s
deb / build-publish-client-arm64 (push) Successful in 2m16s
ci / rust (push) Successful in 6m46s
deb / build-publish (push) Successful in 5m40s
deb / build-publish-host (push) Successful in 5m55s
arch / build-publish (push) Successful in 11m51s
android / android (push) Successful in 12m13s
deb / smoke-install (push) Successful in 3m5s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m49s
windows-host / package (push) Successful in 14m51s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m50s
2026-08-24 16:06:39 +00:00
enricobuehler cd0b53f8fe Merge pull request 'The encode down-driver stands down when the rate isn't the lever' (#392) from worktree-abr-encode-downdriver into main
android / android (push) Canceled after 26s
arch / build-publish (push) Canceled after 31s
ci / bun-nix (push) Successful in 25s
ci / docs-drift (push) Canceled after 7s
ci / web (push) Canceled after 38s
ci / rust-arm64 (push) Canceled after 37s
ci / docs-site (push) Canceled after 37s
ci / rust (push) Canceled after 38s
deb / build-publish (push) Canceled after 22s
deb / build-publish-gamescope (push) Canceled after 5s
deb / build-publish-host (push) Canceled after 10s
deb / build-publish-client-arm64 (push) Canceled after 3s
deb / smoke-install (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 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 18s
windows-host / winget-source (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 17s
apple / swift (push) Successful in 2m5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m4s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m15s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m56s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 14s
flatpak / build-publish (push) Successful in 8m41s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 35s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m47s
apple / distribute (push) Successful in 11m36s
apple / screenshots (push) Successful in 9m57s
Reviewed-on: #392
2026-08-24 16:06:13 +00:00
enricobuehler 00a9d16201 fix(abr): the encode stand-down re-probes instead of lasting the session
ci / bun-nix (pull_request) Successful in 27s
ci / docs-drift (pull_request) Successful in 38s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m22s
ci / rust-arm64 (pull_request) Successful in 1m58s
apple / swift (pull_request) Successful in 2m5s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m6s
android / android (pull_request) Successful in 5m0s
ci / rust (pull_request) Successful in 6m13s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m5s
Nothing this controller learns from evidence is permanent — both learned caps
re-probe on the CAP_REPROBE_WINDOWS_MIN ladder, and the clock-flush detector was
itself changed from "off for the rest of the session" to re-armable for exactly
this reason. The encode stand-down had no business being the exception.

What it answers is transient by nature: contention on the host's GPU ends when
the game exits to a menu, when the shader storm finishes, when the second app on
the card closes. What it silences is the only signal that can descend when the
encoder is genuinely past its compute knee on a link that shows nothing else. So
a permanent stand-down trades a bounded problem for an unbounded one — one
contended stretch would strip knee protection from every calm minute that
follows, including the ones where a climb can reach a rate the ASIC cannot hold.

A clean run now re-arms it, and the interval doubles each time the silence is
immediately re-earned, so a genuinely standing contention settles into a slow
poll rather than thrashing. The asymmetry is the whole argument: a too-eager
re-arm costs one ×0.7 and stands down again; a too-permanent silence costs the
protection outright.
2026-08-24 15:52:17 +02:00
enricobuehler 540e282e60 fix(hyprland): its dpms dispatcher is a TOGGLE, and the classic argv does not parse under Lua
ci / bun-nix (pull_request) Successful in 29s
ci / docs-drift (pull_request) Successful in 22s
ci / docs-site (pull_request) Successful in 1m45s
ci / web (pull_request) Successful in 1m48s
ci / rust-arm64 (pull_request) Successful in 2m45s
android / android (pull_request) Successful in 5m52s
ci / rust (pull_request) Successful in 7m40s
Verified on the NixOS VM (125, Hyprland 0.55.4) — and the arm I shipped for it in
the previous commit was wrong twice over. This is why it went on glass.

**The argv did not work at all.** `hyprctl dispatch dpms off <name>`, the form
this file's own probe notes use, dies on the Lua config manager: `dispatch` is
shorthand for `hl.dispatch(...)`, so the bare words are parsed as a Lua
expression —

    error: [string "return hl.dispatch(dpms off HDMI-A-1)"]:1:
           ')' expected near 'off'

The Lua spelling is `hl.dsp.dpms("off", "<name>")` (found by enumerating
`hl.dsp` through `hyprctl eval`, which only exists on that manager). A hyprlang
box wants the classic form, there is no stable probe for which manager is
loaded, and `hyprctl_dispatch` already catches the exit-0 rejections both
produce — so try classic, then Lua, and report both failures if neither lands.

**And the dispatcher is a TOGGLE that ignores the state word.** Measured:

    On  ==[ hl.dsp.dpms("on",  "HDMI-A-1") ]==>  Off   <- asked ON, got OFF
    Off ==[ hl.dsp.dpms("on",  "HDMI-A-1") ]==>  On
    Off ==[ hl.dsp.dpms{state="off", ...}  ]==>  On    <- asked OFF, got ON

Both spellings, positional and table. So the blind "send off, later send on" the
previous commit shipped would LIGHT an already-dark head at stream start and
DARKEN a lit one at teardown — the operator's screen left off after the stream,
precisely the failure this policy exists to prevent. (It bit me while probing:
a restore fired at an already-On head turned it off, which for a while looked
like "dpms on cannot restore on Hyprland".)

So `dpms_one` is read → act only if the state differs → verify, via
`hyprctl -j monitors all`'s `dpmsStatus` (measured to track the connector's
sysfs `dpms` exactly, in both states, and a dark monitor stays listed). That
shape is also correct where the call really is a set, so it is not conditional
on detecting the manager. It returns whether it CHANGED anything, and
`dpms_other_heads` records only those — a head already in the wanted state is
left alone, because "fixing" it would break it, and reporting it would have the
re-light toggle a head we never darkened.

The on-glass assertion is relaxed from "every connected head goes dark" to "at
least one did, and all are restored": this VM carries a virtio `Virtual-1`
beside the real `HDMI-A-1` and Hyprland manages only the latter, so the strict
form failed on a difference that is not a defect.

Verified end to end, real Rust through the real dispatcher on a live Hyprland:

    before: [("card0-HDMI-A-1", "On"), ("card1-Virtual-1", "On")]
    during: [("card0-HDMI-A-1", "Off"), ("card1-Virtual-1", "On")]
    after:  [("card0-HDMI-A-1", "On"), ("card1-Virtual-1", "On")]
    went dark: ["card0-HDMI-A-1"]
    test gamescope::tests::live_the_managed_hold_darkens_a_real_panel ... ok

The unmanaged `Virtual-1` is correctly untouched, and the test exercises the
double-acquire (reconnect) path too, so the toggle-safe release is covered.

cargo test -p pf-vdisplay 255 passed / 0 failed; clippy --all-targets clean;
fmt --check and check-docs-drift.sh clean. sway remains the one arm not verified
on glass — there is no sway box in the lab.
2026-08-24 15:29:24 +02:00
enricobuehler 7246f0fe60 fix(abr): the host-encode down-driver must stand down when the rate isn't the lever
apple / swift (pull_request) Successful in 2m17s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m12s
ci / bun-nix (pull_request) Successful in 27s
ci / docs-drift (pull_request) Successful in 18s
ci / docs-site (pull_request) Successful in 1m7s
ci / rust-arm64 (pull_request) Successful in 2m15s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m42s
android / android (pull_request) Successful in 6m25s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 7m45s
ci / rust (pull_request) Successful in 15m37s
A 1440p60 Lutris session ratcheted 57 → 5 Mbps over ten minutes on a link that
never dropped a packet — no keyframe asks, no flushes, a flat decoder, and the
host granting every rate it was asked for. Two things did it, both in the
client's encode down-driver.

The thresholds were absolute durations calibrated at 120 Hz ("~half a frame
budget", "≈1.5 × a frame budget"). At 60 Hz one frame is 16.7 ms, so an ordinary
one-frame encode hiccup cleared the SEVERE tier and took the immediate ×0.7
where the same hiccup at 120 Hz (8.3 ms) never reached it — which is exactly the
asymmetry the field log shows against 1440p120 sessions on the same host and
client. Size them in the session's own frame budget instead, plumbed from the
negotiated refresh and re-read on a mode switch.

And the signal fired for a cause the rate cannot fix. It exists to find the
encoder's compute knee, where cutting the rate cuts the work; when encode time
is held up by a game saturating the GPU that premise is false, the backoff
changes nothing, and `on_ack`'s baseline re-seed erases the evidence that
nothing improved. So remember the level each encode-attributed backoff fires at:
two in a row that fire no lower mean the rate is not the lever, and the signal
disarms for the session. Same shape as NOOP_CLOCK_FLUSHES_TO_DISARM. Loss, OWD,
decode and keyframe signals keep their full power throughout, and the host's own
climb refusal stays the backstop for a genuine knee.
2026-08-24 15:28:54 +02:00
enricobuehler e0a822016f feat(vdisplay): honor exclusive on sway and Hyprland too, not just KDE
ci / bun-nix (pull_request) Successful in 27s
ci / docs-drift (pull_request) Successful in 28s
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 2m32s
android / android (pull_request) Successful in 6m20s
ci / rust (pull_request) Successful in 16m32s
`exclusive` on a gamescope session meant "turn the box's own panels off", and
that only actually happened on two kinds of box: one running KDE (via
`org_kde_kwin_dpms`) and one running no desktop at all (via the DRM floor added
in the previous commit). A gamescope session on a **sway** or **Hyprland**
desktop got nothing — the desktop keeps driving its panels, and the DRM floor
cannot help because that compositor holds DRM master, so `SET_MASTER` is refused.

There is no cross-compositor DPMS protocol, so the module becomes a dispatcher,
each arm self-gating on its own IPC so a box only pays for the one that answers:

| desktop        | mechanism                                              |
|----------------|--------------------------------------------------------|
| KDE / KWin     | in-process `org_kde_kwin_dpms`, then `kscreen-doctor`   |
| sway (wlroots) | `swaymsg output <name> dpms off`                        |
| Hyprland       | `hyprctl dispatch dpms off <name>`                      |
| none at all    | `drm_dpms` — the CRTCs off over DRM                     |
| GNOME / Mutter | cannot be served (below)                                |

**`kwin_dpms` is renamed `panel_dpms`.** A module that darkens sway and Hyprland
should not be named after KWin, and this file has already paid for one stale
name: the comment authorising the DPMS skip on the other gamescope routes ("its
takeover already stopped the desktop") outlived the stop it named by three
commits and hid the whole defect. (The rename's sed also caught
`org_kde_kwin_dpms` itself — the vendored protocol's interface names, which are
not ours to rename — and those are restored.)

**DPMS, not the `disable` these backends already have.** Both wlroots and
Hyprland implement their OWN exclusive topology by disabling heads; that is
right when the streamed output lives on that compositor and has to become "the
desk", and wrong here — a gamescope spawn owns no output there, so disabling
would move the operator's workspaces and re-home their windows for a stream that
is not even on their compositor. It matters most on Hyprland, where this
module's own notes record that a disabled head has no clean undo: the only known
restore is re-reading the operator's whole config, which drops every runtime
override they set by hand. DPMS is a separate axis and round-trips cleanly.

Both arms reuse their backend's existing `heads_to_disable` filter with an EMPTY
`ours` — a gamescope session owns nothing to spare, but a concurrent wlroots
session's `HEADLESS-*` still must be spared or darkening blacks out that client's
stream. They address heads by NAME and report which ones actually changed, so the
re-light undoes exactly those.

⚠ **GNOME is a structural gap, not an omission.** Mutter exposes no DPMS to
clients; its own `exclusive` mechanism (`ApplyMonitorsConfig` omitting the
physicals) needs a virtual output of its own to keep enabled, which a gamescope
session does not have; and the DRM floor is refused because Mutter holds master.
The `warn!` now names that instead of saying nothing.

⚠ **The sway and Hyprland arms are NOT verified on glass.** The lab has Bazzite,
Nobara and SteamOS — no sway or Hyprland box. Their argv shapes and the
sibling-sparing filter are unit-tested and everything compiles clean, but the
commands themselves have not been run against a live compositor. Treat them as
unproven until someone streams from one.

Verified: `cargo test -p pf-vdisplay` 254 passed / 0 failed; clippy
--all-targets clean; fmt --check and check-docs-drift.sh clean. Re-ran BOTH
on-glass tests on the Nobara VM after the rename — the managed path falls
through KDE → sway → Hyprland → DRM correctly on a box with none of the first
three, and the panel still goes On → Off → On:

    test gamescope::tests::live_the_managed_hold_darkens_a_real_panel ... ok
    test drm_dpms::tests::live_the_panels_go_dark_and_come_back ... ok
2026-08-24 15:07:03 +02:00
enricobuehler b6938a9890 fix(gamescope): the managed route honors exclusive too — it is the one that matters most
ci / bun-nix (pull_request) Successful in 27s
ci / docs-drift (pull_request) Successful in 28s
ci / web (pull_request) Successful in 1m5s
ci / docs-site (pull_request) Successful in 1m32s
ci / rust-arm64 (pull_request) Successful in 2m13s
android / android (pull_request) Successful in 6m7s
ci / rust (pull_request) Successful in 7m3s
The previous commit left Managed without a darken hold and wrote down a reason:
its display reports `DisplayOwnership::SessionManaged`, and `registry::acquire`
returns for anything not `Owned` ABOVE its `take_topology_restore()` pickup, so a
hold registered the way the bare spawn does would never be released — and a panel
dark after every stream is worse than one left lit.

That reasoning was right and the conclusion was wrong. Managed is the recommended
route, it gives the best experience, and it is the ONLY way to serve a client its
own virtual output at its own mode — so it is the route where `exclusive` most
needs to mean something. "No release path on the registry" is an argument for
putting the release somewhere else, not for skipping the feature.

Somewhere else is `do_restore_tv_session`: the one teardown every managed path
funnels through, and already the home of the drop-in sweep for exactly this class
of reason ("must not leak past an early return"). The release goes at the very
TOP of it rather than beside that sweep, because the SteamOS branch returns above
it. Safe there: releasing re-lights, and every path below either hands the box
back or deliberately keeps a headless session on a box with no connected display
— nothing lit to darken in that case anyway.

The hold is a bool, not a count, and that is load-bearing. The managed SESSION is
what gets darkened, not each connect: it outlives client disconnects (the point
of `MANAGED_SESSION`) and a same-mode reconnect reuses it warm without a
relaunch. Acquiring per connect would ratchet `kwin_dpms`'s refcount up with no
matching releases and pin the operator's panel dark for the rest of the host's
life. The edges are split into pure `managed_darken_{acquire,release}_edge` — the
same shape as `kwin_dpms::Holds` and for the same reason — so the balance rule is
unit-tested without a live compositor: reconnect takes no second hold, the
unconditional release is idempotent, a full cycle re-arms, and `extend` /
`SharedDesktop` still take no hold at all.

Verified on the Nobara VM (123, Fedora 44) in the takeover state, against a real
connected head, driving the actual managed helpers rather than the pure edges:

    before: [("card0-HDMI-A-1", "On")]
    during: [("card0-HDMI-A-1", "Off")]     <- incl. a second acquire (reconnect)
    after:  [("card0-HDMI-A-1", "On")]
    test gamescope::tests::live_the_managed_hold_darkens_a_real_panel ... ok

The bare-spawn on-glass test still passes alongside it. (`mutter::live_mutter_
create_drop` fails on that box and is unrelated — it needs a GNOME session.)

`gamescope.md`'s per-model table said Managed does not darken a desktop beside
it; it now says what both owning routes actually do, and that neither needs root.

cargo test -p pf-vdisplay 252 passed / 0 failed; clippy --all-targets clean;
fmt --check and check-docs-drift.sh clean.
2026-08-24 14:48:07 +02:00
enricobuehler faf94087c5 feat(vdisplay): turn the box's own panel off over DRM, for a box with no desktop to ask
ci / bun-nix (pull_request) Successful in 25s
ci / docs-drift (pull_request) Successful in 25s
ci / web (pull_request) Successful in 1m5s
ci / rust-arm64 (pull_request) Successful in 1m42s
ci / docs-site (pull_request) Successful in 1m40s
android / android (pull_request) Successful in 5m8s
ci / rust (pull_request) Successful in 6m14s
`kwin_dpms` asks KWin for DPMS-off, which is the right answer whenever there is a
KDE desktop to ask. A box sitting in Game Mode has none — it runs gamescope and
no KWin — and that is *precisely* the deployment whose TV the operator wants
dark. So `exclusive` silently did nothing there, which is the field report.

Measured on the Nobara VM (123, Fedora 44) before writing any of this, because
the obvious cheaper answers all fail on real hardware:

* Getting the box's session out of the way is NOT enough. After the takeover
  idles it, `card0-HDMI-A-1` sits at `enabled=enabled dpms=On` indefinitely —
  when no client holds DRM master the kernel just keeps the CRTC configured.
  That refutes `gamescope.rs:1573` ("left the Deck in a headless session with its
  panel dark") as a general claim: that was a Deck's internal panel.
* `setterm --blank force` is unreachable: `/dev/tty1` is `crw------- root:tty`.
* The connector's `dpms` sysfs attribute is read-only even as root.
* `/sys/class/graphics/fb0/blank` DOES work (`dpms=Off`) — but needs root, and
  the takeover deliberately stopped needing privilege in 0.31.0. Not worth
  re-introducing a group requirement for.

What does work, unprivileged: `/dev/dri/cardN` carries a logind uaccess ACL for
whoever holds the active seat (`crw-rw----+`), and `SET_MASTER` succeeds while
nobody else is master — exactly the state the takeover has just produced. So
walk `GETRESOURCES`, `GETCRTC` each id, and `SETCRTC` with `fb_id=0,
mode_valid=0, count_connectors=0` on the ones actually driving something. The
connector goes `enabled=disabled dpms=Off`, the same end state `kscreen-doctor
--dpms off` reaches through KWin.

Two measured properties shrank the code. The darkness **survives DROP_MASTER**,
so mastership is handed straight back — a host still holding it would starve the
box's own gamescope when the restore relaunches its session, turning a dark panel
into a session that cannot start. And the re-light is **`close(fd)`**: the
kernel's last-close restores the console, so there is no saved mode to replay, no
restore that can half-fail, and crash safety is free (a dead host has its fds
closed by the kernel and the box lights up) — the same property `kwin_dpms` gets
from DPMS being non-persistent. Nothing to journal, nothing to sweep at startup.

`SET_MASTER` failing is also the natural guard for the Attach route: a card
already mastered by a live compositor is not ours, and darkening it would darken
the very picture being streamed.

Four ioctls, three `repr(C)` structs, one `unsafe` block. The ioctl numbers
encode their payload size (`0x40`, `0x68`), so the layouts are pinned by
`const _: () = assert!(...)` at compile time — a struct that drifts would have
the kernel read or write the wrong byte count on someone's TV — plus a test that
restates it greppably and pins the two field offsets the count/data-pass dance
depends on.

The on-glass test earned its place immediately. Everything else was green —
ABI tests, clippy, 251 unit tests, and a Python probe that had proven the ioctl
sequence itself — while the first Rust cut still left the panel lit: `darken_card`
closed its fd before returning and `darken` re-opened a fresh one to hold. That
close is the kernel's LAST close, which restores the console; the new fd held
nothing. It reported `darkened cards: ["card0"]` over a connector at
`enabled=enabled dpms=On`. The fd that does the work is now the fd that is held.

Verified on that VM with this exact binary, against a real connected head:

    before: [("card0-HDMI-A-1", "enabled", "On")]
    darkened cards: ["card0"]
    during: [("card0-HDMI-A-1", "disabled", "Off")]
    after:  [("card0-HDMI-A-1", "enabled", "On")]

cargo test -p pf-vdisplay 251 passed / 0 failed; clippy --all-targets clean
(incl. the workspace's undocumented_unsafe_blocks deny); fmt --check and
check-docs-drift.sh clean.
2026-08-24 13:53:54 +02:00
enricobuehler 46d9e0d20f Merge pull request 'The gamepad console had no door — only a command line' (#390) from worktree-console-ui-front-door into main
ci / bun-nix (push) Successful in 25s
ci / docs-site (push) Successful in 1m7s
ci / docs-drift (push) Successful in 25s
deb / build-publish-gamescope (push) Successful in 1m2s
ci / web (push) Successful in 2m41s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m8s
ci / rust-arm64 (push) Successful in 4m11s
deb / build-publish-client-arm64 (push) Successful in 2m7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 9s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m4s
deb / build-publish (push) Successful in 4m5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 56s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
deb / build-publish-host (push) Successful in 4m51s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m12s
docker / builders-arm64cross (push) Successful in 8s
deb / smoke-install (push) Successful in 2m31s
docker / deploy-docs (push) Successful in 32s
ci / rust (push) Successful in 10m15s
arch / build-publish (push) Successful in 10m34s
flatpak / build-publish (push) Successful in 7m35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m29s
nix / flake (push) Successful in 22m27s
2026-08-24 10:15:53 +00:00
enricobuehler 8e8451ca0c Merge pull request 'Sleeping or shutting the host down during a stream works again' (#391) from worktree-host-power-during-stream into main
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m34s
ci / bun-nix (push) Successful in 19s
android / android (push) Successful in 8m57s
ci / docs-drift (push) Successful in 22s
ci / rust-arm64 (push) Successful in 6m31s
deb / build-publish-gamescope (push) Successful in 1m1s
arch / build-publish (push) Successful in 8m18s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
windows-host / package (push) Successful in 14m48s
windows-host / winget-source (push) Skipped
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 9s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
deb / build-publish (push) Successful in 3m20s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 20s
deb / build-publish-client-arm64 (push) Successful in 2m58s
deb / build-publish-host (push) Successful in 4m34s
docker / builders-arm64cross (push) Successful in 12s
windows-host / canary-manifest (push) Successful in 31s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 2m3s
ci / rust (push) Successful in 11m40s
docker / deploy-docs (push) Successful in 36s
deb / smoke-install (push) Successful in 2m21s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 7m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m52s
2026-08-24 09:52:59 +00:00
enricobuehler 0e1bab019c feat(clients/linux): the console UI gets a front door, in the shell and in the app grid
ci / rust-arm64 (pull_request) Successful in 1m33s
ci / web (pull_request) Successful in 1m34s
ci / docs-site (pull_request) Successful in 1m13s
ci / bun-nix (pull_request) Successful in 27s
ci / docs-drift (pull_request) Successful in 31s
nix / flake (pull_request) Successful in 10m31s
ci / rust (pull_request) Successful in 28m5s
The gamepad console has shipped since the arch split, but on Linux the only way
in was `punktfunk-session --browse` (or `punktfunk-client --browse`, which execs
it) on a command line — no way to find a mode. The WinUI shell has had both doors
for a while; this is the GTK half.

Three entry points, one destination:

* A gamepad button in the hosts header (`input-gaming-symbolic`), left of the
  hamburger — the same placement WinUI gives it.
* "Console UI" in the main menu, so the mode has a searchable name and not just
  a tooltip.
* `io.unom.Punktfunk.Console.desktop` — a second launcher shipped by the deb,
  rpm, arch and flatpak packagings. It is what an app-grid search finds and what
  gets added to Steam as a non-Steam game. `Exec=punktfunk-client --browse
  --fullscreen` deliberately goes through the shell binary: that argv already
  execs the session, and it is the command flatpak's Exec rewrite expects.

The in-shell launch is a `gio::Subprocess` — `wait_check_async` lands the child's
exit on the GTK main loop with no thread and no channel, releases `busy`,
refreshes the host list (the console can pair hosts), and banners a non-zero
exit. That last part is also how a session built without its `ui` feature
surfaces: it prints "--browse needs the console UI" and exits non-zero.

Which is why the Nix build does NOT install the new desktop entry — it compiles
the session `--no-default-features`, so that launcher could only ever print an
error. Noted in packaging/nix/README.md next to the existing Skia caveat.
2026-08-24 11:41:58 +02:00
enricobuehler 7951d12b06 Merge pull request 'The console's bitrate row reaches 2 Gbps, steps finely at the bottom, and takes a typed rate' (#388) from worktree-bitrate-picker into main
ci / web (push) Successful in 2m22s
ci / bun-nix (push) Successful in 18s
ci / rust-arm64 (push) Successful in 3m8s
ci / docs-drift (push) Successful in 31s
ci / docs-site (push) Successful in 2m38s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m54s
deb / build-publish-gamescope (push) Successful in 2m5s
deb / build-publish (push) Successful in 4m19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
deb / build-publish-host (push) Successful in 5m2s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
deb / build-publish-client-arm64 (push) Failing after 2m44s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 9s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
android / android (push) Successful in 9m31s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 43s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m20s
arch / build-publish (push) Successful in 11m47s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m29s
ci / rust (push) Successful in 12m49s
flatpak / build-publish (push) Successful in 4m52s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 36s
deb / smoke-install (push) Canceled after 1m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1m19s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 5m47s
2026-08-24 09:37:34 +00:00
enricobuehler 4690a166ca Merge pull request 'The mic tap's format snapshot could abort the whole app' (#387) from worktree-fix-mic-tap-format-abort into main
apple / swift (push) Successful in 2m7s
ci / web (push) Successful in 2m3s
ci / rust-arm64 (push) Successful in 3m6s
ci / bun-nix (push) Successful in 19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 19s
ci / docs-drift (push) Successful in 24s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
ci / docs-site (push) Successful in 2m2s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
ci / rust (push) Canceled after 5m10s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 58s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 48s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 28s
docker / deploy-docs (push) Canceled after 0s
apple / distribute (push) Successful in 13m26s
apple / screenshots (push) Successful in 10m11s
2026-08-24 09:32:26 +00:00
enricobuehler 8c628b4e6c fix(gamescope): exclusive topology left the box's own screen lit under a dedicated game session
ci / rust-arm64 (pull_request) Successful in 2m20s
ci / web (pull_request) Successful in 2m17s
ci / bun-nix (pull_request) Successful in 24s
ci / docs-site (pull_request) Successful in 1m24s
ci / docs-drift (pull_request) Successful in 26s
android / android (pull_request) Successful in 13m20s
ci / rust (pull_request) Successful in 27m51s
Field report, Nobara, 2026-08-24: launching a game from the library with
`game_session=dedicated` and `topology: exclusive` left the operator's physical
screen on. Two independent causes, neither of which had a line in the log.

The first is ours and recent. `c2f5e91b` replaced the managed takeover's
display-manager STOP with an idle drop-in, deliberately, so the box keeps a DM
able to service the user's own "Switch to Desktop". That stop was also, as a
side effect, the only thing that took the box's own panel down. Nothing replaced
it, and the comment authorising the DPMS skip on this file's other routes — "its
takeover already stopped the desktop" — outlived the stop it named by three
commits, so the gap read as intentional. That comment is now the two reasons that
actually hold, written at the returns they belong to: Managed reports
`SessionManaged` and `registry::acquire` returns above its
`take_topology_restore()` pickup, so a hold taken there would never be released
(a panel left dark after every stream is worse than one left lit — `relight` is
written as "the last line of defence for a dark monitor" for that reason); and
Attach mirrors a gamescope that may be lighting the panel, so darkening it would
darken the picture being streamed.

The second is older and louder, and is what the report actually hit.
`stop_autologin_sessions` was reached only for a Steam launch — `is_steam_launch`
is `cmd.split_whitespace().next() == Some("steam")` — because freeing Steam's
single instance was the only requirement anyone had for it. But on a Game Mode
box that same session is the DRM master of the TV (`gamescope/heads.rs`), so a
NON-Steam library game left it running and the panel showed live Game Mode for
the whole stream. `exclusive` is a second, separate reason to want that session
out of the way, and it is now stated as one: `free_box_session_for_exclusive` is
pure and unit-tested over all four Steam x Exclusive combinations, including that
`extend` / the `SharedDesktop` preset ("never blank the real monitors") must
still leave the box strictly alone. Best-effort, unlike the Steam arm above it —
there it costs a dark screen, not the game, so it warns and continues.

Neither could be diagnosed from a log. `kwin_dpms::darken` is only ever reached
because the operator selected `exclusive`, so every decline is "you asked for
your screens off and they stayed on" — and the definitive not-KDE arm sat at
`debug!`. A box already in Game Mode has no KWin to answer DPMS at all, which is
precisely the box this fires on. It says so at `warn!` now, the same honesty
`relight` has always had for the dark direction.

`gamescope.md` claimed managed "deliberately takes the session over and blanks
the panel". It takes the session over; blanking is Topology's job and differs per
model, so the page now says which model can do what. The polkit/lingering block
above it is NOT stale — it is scoped to the restore path and pre-0.31.0 adoption,
and is left alone.

Verified: `cargo test -p pf-vdisplay` 249 passed / 0 failed, clippy --all-targets
clean, docs-drift gate clean. Not yet exercised against a live Nobara box — the
DPMS half cannot fire there by construction (no KWin in Game Mode), so what wants
measuring on the VM is whether releasing the DRM master leaves the connector dark
or merely blank-but-powered.
2026-08-24 11:23:45 +02:00
enricobuehler aef7f7877f feat(console-ui): the bitrate row reaches 2 Gbps, steps finely at the bottom, and takes a typed rate
ci / bun-nix (pull_request) Successful in 43s
ci / docs-drift (pull_request) Successful in 1m24s
ci / web (pull_request) Successful in 1m34s
ci / docs-site (pull_request) Successful in 1m34s
ci / rust-arm64 (pull_request) Successful in 2m26s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 8m2s
ci / rust (pull_request) Successful in 11m57s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m0s
android / android (pull_request) Successful in 12m15s
The gamepad shell's Bitrate picker has been seven rungs ending at 80 Mbps since the
console shipped, which is the ceiling a user just ran into — the GTK dialog beside it
has always gone to 3000 Mbit/s, so the two surfaces disagreed about what this machine
may ask for, and the console was the smaller of the two.

Three changes, one row:

- The ladder is 30 rungs, 1 Mbps to 2 Gbps. Tight at the bottom (1, 2, 3, 4, 5, 6, 8,
  10, 12, 15, 20, 25 …), where one rung decides whether a thin link is watchable, and
  coarse at the top, where a rung is noise. Rates at or above a gigabit read as Gbps.
- Y opens a typed rate on that row — digits, four of them, through the tray keyboard
  (or SDL text input, and Steam's own keyboard on a Deck) exactly like the add-host and
  pair fields. A goes on cycling the ladder everywhere, so the console's grammar is
  unchanged; the field is what the ladder cannot be, which is every number in between.
- A rate that is not a rung now steps to its NEIGHBOUR. The generic picker snaps a value
  it does not recognise to its first option, which on this row is Automatic: one nudge
  threw away a rate typed here or set by the desktop spinner.

The desktop dialog gets the same complaint's other half: its spinner steps 1 Mbit/s
instead of 5, so 3, 4 and 6 are reachable without typing.

`Screen::edit_key` now takes the context, because this is the first field that commits
into the settings store when it closes rather than holding text for a later action row.
2026-08-24 11:19:45 +02:00
enricobuehler f60b6e30e2 fix(clients/apple): the mic tap's format snapshot could abort the whole app
ci / bun-nix (pull_request) Successful in 27s
ci / docs-drift (pull_request) Successful in 1m4s
ci / docs-site (pull_request) Successful in 1m8s
ci / web (pull_request) Successful in 1m17s
ci / rust-arm64 (pull_request) Successful in 1m59s
apple / swift (pull_request) Successful in 2m16s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 9m38s
`installTap(onBus:bufferSize:format:)` validates a non-nil format against the
bus and raises an Objective-C exception on ANY mismatch. Swift cannot catch
that, so it reached the terminate handler and aborted the process — SIGABRT in
`AVAudioEngineGraph::InstallTapOnNode`, crashing macOS 0.31.0 at session start,
not at launch.

The format handed to the tap is necessarily read a moment earlier
(`input.outputFormat(forBus: 0)`), and on macOS the input can move underneath
it in that window: a device switch, a clock/rate change, or the
`kAudioOutputUnitProperty_CurrentDevice` swap `startCapture` itself performs two
lines before. The existing guard only rejected the 0 Hz / 0-channel case, which
is a different failure — a device that is absent, not one that changed.

Install with `format: nil` instead, which is the documented "use the bus's own
format" and makes the mismatch unrepresentable rather than merely unlikely.
The tap then has to follow the real format, so the rate-dependent pieces (mono
bus, resampler, both scratch buffers) move into a `MicChain` the tap rebuilds
when `buffer.format.sampleRate` differs — a chain pinned to a stale rate would
resample by the wrong ratio and pitch-shift the mic. That rebuild subsumes the
old grow-on-larger-quantum branch; the steady state still allocates nothing.

Tests cover the sizing arithmetic the rebuild rests on — including that
`staging` fits the UPWARD ratio, which silently truncates every packet on any
device below 48 kHz if it is sized for the input rate instead.
2026-08-24 11:16:07 +02:00
enricobuehler 4d155f4985 fix(packaging): a takeover that stops the display manager keeps Steam's power menu working
ci / web (pull_request) Successful in 1m53s
ci / bun-nix (pull_request) Successful in 30s
ci / docs-drift (pull_request) Successful in 28s
android / android (pull_request) Successful in 5m44s
ci / rust-arm64 (pull_request) Successful in 5m59s
ci / docs-site (pull_request) Successful in 5m44s
ci / rust (pull_request) Successful in 15m20s
Second half of "the power menu does nothing during a stream", and an
independent fault from the sleep veto: on the takeover flavors that STOP
the display manager, the box is left with no active local session, and
that is what logind's power actions are gated on.

logind ships `power-off`/`reboot`/`suspend` as `allow_active: yes`.
polkit decides "active" from the caller's own logind session and, for a
caller that has none — every `systemd --user` unit, which is exactly what
the managed gamescope session is — falls back to the user's elected
DISPLAY session. logind elects that only from `user`/`greeter` class
sessions, never from the user manager's own, so stopping the display
manager removes the last candidate and all three actions drop to
`auth_admin_keep`: an interactive password prompt, put to a
non-interactive caller, on a screen that is switched off.

On SteamOS-like boxes that is the call being refused. Steam does not ask
logind for "Shut Down" at all — it writes $STEAMOS_STEAM_SHUTDOWN_SENTINEL
and exits, and gamescope-session-plus runs a plain `poweroff` once Steam
is gone. During a stream that wrapper is ours, in the session-less
transient unit.

Measured on Bazzite, 2026-08-24: the identical
`pkcheck --action-id org.freedesktop.login1.power-off` from a
`systemd --user` unit answers authorized with sddm up, and
`auth_admin_keep` with sddm stopped — and answers authorized again, in
that same stopped state, with this rule installed.

Scope it to the (shipped-empty) `punktfunk` group, which is the same
group the takeover's own root helper authorizes on: a takeover that stops
a display manager cannot work without that helper, so this grants to
exactly the population the fault reaches. The three actions are the three
entries in Steam's power menu; the `-multiple-sessions` and
`-ignore-inhibit` variants are deliberately left out.
2026-08-24 11:00:24 +02:00
enricobuehler 4b5f0dac6b fix(host): a stream no longer vetoes the box's own sleep
Since b7a00137 the host has held a logind `sleep:idle` inhibitor in BLOCK
mode for the whole length of a stream, so a passive (video-only) viewer
could not have the box suspend out from under them. A block lock on
`sleep` refuses EVERY suspend, though, not just the one an idle timer
asks for: "Sleep" in Steam's Big Picture power menu reaches logind as the
same `Suspend()` call, and logind answers it with

    Operation inhibited by "Punktfunk" (PID …), reason is "a client is streaming".

Nothing in that UI surfaces a D-Bus error, so the menu entry simply does
nothing — for as long as anyone is watching the box. Reproduced on a
Bazzite host, 2026-08-24, by taking the same lock by hand.

Hold the veto only while the stream is QUIET. Any client input on either
plane drops it synchronously — releasing is a close(2) on the inhibitor
fd, no round trip, so a Sleep press cannot race it — and it is re-taken
only after 30 s of silence. That is the line the original justification
already drew ("remote INPUT resets the compositor's idle timers, but a
video-only viewer sends none"): a person choosing Sleep is, by
definition, sending input, and a passive viewer never does. The case the
lock was written for is unchanged — 30 s of nothing and the veto is back.

Both planes stamp the clock at their single input entry point: the native
input thread's channel recv (before the grant tests — a denied event
still means a person is there) and the GameStream control plane's three
decode arms, past the keepalive gate, because a keepalive is the one
thing a passive viewer does send.
2026-08-24 11:00:08 +02:00
enricobuehler 5e30805490 Merge pull request '0.31.3 — the launch that dropped, the refresh a TV never output, and the probe that choked the link' (#386) from worktree-release-0313-recut into main
audit / bun-audit (web) (push) Successful in 22s
audit / docs-site-audit (push) Successful in 22s
audit / bun-audit (sdk) (push) Successful in 25s
audit / bun-audit (plugin-kit) (push) Successful in 24s
audit / pnpm-audit (push) Successful in 15s
audit / cargo-audit (push) Successful in 42s
ci / rust-arm64 (push) Successful in 3m53s
audit / license-gate (push) Successful in 5m41s
audit / miri (push) Successful in 6m13s
ci / docs-site (push) Successful in 1m7s
ci / bun-nix (push) Successful in 24s
ci / web (push) Successful in 2m7s
ci / docs-drift (push) Successful in 28s
audit / c-abi-asan (push) Successful in 8m9s
android-screenshots / screenshots (push) Successful in 2m26s
ci / rust (push) Successful in 9m55s
sdk-publish / publish (push) Successful in 49s
linux-client-screenshots / screenshots (push) Successful in 4m29s
arch / build-publish (push) Successful in 9m52s
sbom / sbom (push) Successful in 39s
web-screenshots / screenshots (push) Successful in 6m50s
decky / build-publish (push) Successful in 1m4s
docker / builders-arm64cross (push) Successful in 13s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 46s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 12s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (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 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 53s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 29s
docker / deploy-docs (push) Successful in 21s
flatpak / build-publish (push) Successful in 4m46s
deb / smoke-install (push) Successful in 2m36s
deb / build-publish (push) Successful in 3m53s
deb / build-publish-host (push) Successful in 7m27s
deb / build-publish-gamescope (push) Successful in 49s
deb / build-publish-client-arm64 (push) Successful in 2m34s
android / android (push) Successful in 13m48s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 23m37s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 24m2s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m1s
apple / swift (push) Successful in 2m8s
apple / screenshots (push) Successful in 9m31s
windows-host / package (push) Successful in 13m9s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Successful in 35s
apple / distribute (push) Successful in 12m30s
nix / flake (push) Successful in 20m9s
2026-08-23 10:32:42 +00:00
enricobuehler 7312f0ddba chore(sdk): cut 0.1.6 — the rename route's types cannot reach a plugin until they ship
ci / docs-site (pull_request) Successful in 1m19s
apple / swift (pull_request) Successful in 2m9s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m57s
ci / bun-nix (pull_request) Successful in 34s
ci / docs-drift (pull_request) Successful in 33s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m41s
ci / rust-arm64 (pull_request) Successful in 4m11s
android / android (pull_request) Successful in 7m10s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m9s
nix / flake (pull_request) Successful in 8m34s
ci / rust (pull_request) Successful in 18m27s
The v0.31.3 CHANGELOG recorded this cut as a decision left open, on the same
reasoning v0.31.0 used for 0.1.5: a plugin resolves `@punktfunk/host` from the
registry, so types sitting in `sdk/` reach nobody until a version carries them.
#374 added `PATCH /clients/{fingerprint}`, `RenameClient` and
`PairedClient.label` to the management API and regenerated the client for them —
so without this cut the route exists on every 0.31.3 host and no plugin can call
it in a typed way.

ONE FILE is the whole diff since sdk-v0.1.5: `sdk/src/gen/punktfunk.ts`. It is
larger than the feature because regenerating it from the UNCHANGED committed spec
already produced a ~700-line diff — the checked-in copy had drifted from its own
pinned generator, and nothing in CI regenerates or verifies it (unlike
api/openapi.json and include/punktfunk_core.h, which are both gated). #374 landed
the clean regeneration rather than hand-patching generated code, and this cut
publishes it.

`SDK_VERSION` moves with `package.json`. It is a hand-maintained constant — the
build sets `rootDir: "src"` so it cannot import the manifest, and the runner ships
as one bundled `runner-cli.js` with no manifest beside it — and the runner
compares it against the SDK installed in the plugins tree to decide whether to
reinstall. Shipping 0.1.6 with the constant still reading 0.1.5 would publish the
types and then never deliver them. `version.test.ts` gates exactly that, which is
also what sdk-publish.yml's "Tag matches package version" step re-checks against
the tag.

GATES, all four steps sdk-publish.yml runs, in order and locally:
`bun install --frozen-lockfile --ignore-scripts` clean, `bun run typecheck`
clean, `bun test` 83 pass / 0 fail / 191 expect() calls across 12 files (the same
83 the 0.1.5 cut reported), `bun run build` clean. Nothing but the two version
sites and the two release documents is touched — no dist/ or lockfile churn
reached the tree.

`@punktfunk/plugin-kit` is deliberately NOT re-cut: nothing under plugin-kit/ has
moved since 0.4.4, which stays the registry's `latest`.

Tag `sdk-v0.1.6` on the merge commit, alongside `v0.31.3`. The two version
independently by design — sdk-publish.yml triggers on `sdk-v*` and the app's `v*`
tags never republish the SDK — so the shared commit is a convenience, not a
coupling.
2026-08-23 12:13:08 +02:00
enricobuehler e7ebaf591c release: 0.31.3 — version bump, notes, CHANGELOG, Play notes
ci / docs-site (pull_request) Successful in 1m18s
ci / web (pull_request) Successful in 1m50s
ci / bun-nix (pull_request) Successful in 29s
apple / swift (pull_request) Successful in 2m10s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / docs-drift (pull_request) Successful in 1m10s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m52s
ci / rust-arm64 (pull_request) Successful in 7m2s
android / android (pull_request) Canceled after 8m55s
ci / rust (pull_request) Canceled after 11m10s
nix / flake (pull_request) Canceled after 8m17s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 5m46s
41 commits since v0.31.2 (26 non-merge). Cut from origin/main f5931650 (#385
merged, main green).

THE NUMBER: a patch. One versioned surface moves and it moves additively — the
management API gains PATCH /clients/{fingerprint}, the RenameClient schema and
PairedClient.label, none of which existed before, so nothing that consumes the
API today changes shape. Everything else is where v0.31.2 left it: WIRE_VERSION
2, C ABI 25 with include/punktfunk_core.h showing NO diff against the v0.31.2 tag
(second release running), driver protocol 6 / min 3 with pf-driver-proto
unchanged, gamepad channel 3, plugin index schema 1, host event schema 1,
gamescope +pfhdr8 with no new patch files, SDK 0.1.5 and plugin-kit 0.4.4 both
untouched. Two feat commits (#374, #384), both additive; v0.31.1 carried two
feats as a patch on the same reasoning.

THE SHAPE: the faults share a family resemblance — a session degrading or ending
against something ordinary that nothing was checking. Steam's pre-launch trees
latching the game lease and their exit then read as the game's (#372); a
fullscreen game mode-setting the virtual display under both stream loops (#373);
the forced-keyframe coalesce window measured in frames rather than time (#377);
an Android TV negotiating the refresh its own console pin installed rather than
what the panel outputs (#378); a startup capacity probe large enough to
black-hole the link it was measuring (#379); a hand-back that never verified the
panel came back (#375); a half-minted audio devnode nothing afterwards
recognised (#381); and a failed compositor build that unlinked the working one it
never replaced (#382). Plus two Android input/present fixes (#376, #380), the
console's per-frame cost and its new resolution switch (#384, #385), one feature
(#374), and CI (#370, #383).

TWO ENTRIES WORTH THE READER'S ATTENTION, both recorded as such:
  * #375 ships WITHOUT a reproduction. Five scenarios across both distro families
    on real VMs all recovered cleanly and the first proposed mechanism was
    disproved on glass, so it closes the gap that lets any trigger end as a dark
    panel rather than guessing at one.
  * #380 is re-implemented from #371's diagnosis, and #371 is NOT merged. All
    three faults were real and correctly identified; each fix as sent reached
    further than the hardware that needed it. The notes credit the diagnosis.

DOCS FRESHNESS, per docs/releases/README.md step 1: #379, #380 and #384 carried
their own docs-site updates (configuration.md, input.md, client-settings.md). The
one fact left owed was naming a Moonlight device, whose canonical home is the
"Managing paired devices" section of docs-site/content/docs/pairing.md — a
paragraph goes there. No new PUNKTFUNK_* variable this cycle
(PUNKTFUNK_RECOVER_SESSION_CMD is pre-existing and already documented in
configuration.md and gamescope.md), no new host subcommand, and no install
command, repo URL or port change, so data/platforms.json and the website's
vendored copy need nothing.

VERIFIED HERE: scripts/ci/check-docs-drift.sh clean; scripts/ci/check-docs-links.sh
clean; the android.yml Play notes gate run verbatim, 444/500 characters and unique
against every other release's file; both openapi copies cmp identical and stamped
0.31.3; cargo fmt --all --check clean; cargo audit clean over all five Rust
lockfiles (h2 fixed in the commit below this one); cargo about --fail clean on the
host workspace; git diff v0.31.2..HEAD on include/punktfunk_core.h and on
crates/pf-driver-proto both empty, which is the direct evidence for those two
version rows; Cargo.lock's 36 workspace version strings moved with Cargo.toml;
notes voice scan clean (zero backticked terms above ## For developers) and the
CHANGELOG link pinned to src/tag/v0.31.3.

NOT RUN HERE, and why: any punktfunk-host build, clippy or cargo test — the host
does not compile on macOS at all, and CI covers it; the web/ and docs-site/ bun
builds — nothing under web/ is touched by this commit and the docs-site edit is
prose in an existing .md; the Android unit tests — nothing here touches Kotlin.

LEFT AS A DECISION, not made here: sdk/src/gen/punktfunk.ts changed in #374 (a
clean regeneration that also absorbed ~700 lines of pre-existing drift) but
@punktfunk/host is not re-cut, so the registry's 0.1.5 has no types for the new
route. Cut sdk-v0.1.6 if anything outside this repo needs them.
2026-08-23 12:01:35 +02:00
enricobuehler 010949fead fix(deps): h2 0.4.15 -> 0.4.18, closing RUSTSEC-2026-0258
`cargo audit` on the root lockfile went red on 2026-08-17, when RUSTSEC-2026-0258
was disclosed against h2 <= 0.4.15 (unbounded empty DATA frames; fixed in
0.4.16). audit.yml's cargo-audit job is BLOCKING and fires on every Cargo.lock
change, so the 0.31.3 version bump in the next commit would have taken it red on
merge regardless of this advisory's own timing.

h2 is transitive — no manifest in the workspace declares it — so this is a
lockfile-only change.

MINIMAL ON PURPOSE. `cargo update -p h2` reports "Locking 1 package" but also
rewrote nine unrelated entries from `windows-sys 0.61.2` to 0.52.0/0.59.0,
gratuitous resolver drift that would have changed what the Windows builds compile
against for no reason. That was discarded; the two h2 lines are applied directly
instead, and `cargo metadata --locked` accepts the result with nothing else
moving — which is the proof the resolver needed none of the rest.

VERIFIED: `cargo audit` over all five Rust lockfiles. The root one is now clean;
the other four already were. The two lines cargo-audit still prints
(`audiopus_sys`, `paste`) are *unmaintained* warnings, already allowed via
.cargo/audit.toml, and do not fail the job.

NOT REGENERATED, deliberately: THIRD-PARTY-NOTICES.txt still records h2 0.4.15.
scripts/gen-third-party-notices.sh walks the dependency closure of the machine it
runs on, and on macOS it DROPS nine crates — the rusqlite / libsqlite3-sys /
fallible-iterator / hashlink cluster, 575 -> 566 — because they are gated to
platforms this Mac is not. Committing that would remove attributions a Linux or
Windows build genuinely links, which the script's own header calls a legal
regression rather than an untidiness. Regenerate on Linux. Nothing in
.gitea/workflows diffs the checked-in copy, and build-deb.sh /
pack-host-installer.ps1 / punktfunk.spec / pack-msix.ps1 each regenerate it on
their own platform, so the shipped packages are accurate and this is cosmetic
drift in the in-repo copy only.
2026-08-23 11:54:14 +02:00
enricobuehler f5931650e0 Merge pull request 'main is red: the Android-only row list never learned about the new resolution switch' (#385) from worktree-console-tv-perf-safe-fixes into main
ci / web (push) Successful in 1m6s
ci / bun-nix (push) Successful in 35s
ci / docs-drift (push) Successful in 27s
ci / rust-arm64 (push) Successful in 1m55s
ci / docs-site (push) Successful in 1m41s
deb / build-publish-gamescope (push) Successful in 49s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
deb / build-publish-client-arm64 (push) Successful in 1m47s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
deb / build-publish (push) Successful in 4m48s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m50s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m36s
deb / build-publish-host (push) Successful in 5m15s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 27s
arch / build-publish (push) Successful in 9m29s
android / android (push) Successful in 9m34s
deb / smoke-install (push) Successful in 3m19s
flatpak / build-publish (push) Successful in 6m21s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m39s
ci / rust (push) Successful in 19m24s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m46s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m45s
Reviewed-on: #385
2026-08-23 09:51:00 +00:00
enricobuehler 519d004cab test(console-ui): the Android-only row list gains the new resolution switch
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / bun-nix (pull_request) Successful in 27s
ci / docs-site (pull_request) Successful in 1m17s
ci / web (pull_request) Successful in 1m54s
ci / docs-drift (pull_request) Successful in 24s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m37s
ci / rust (pull_request) Successful in 6m54s
android / android (pull_request) Successful in 7m2s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m11s
`platform_row_split_hides_only_the_other_platforms_concepts` pins the exact
ordered set of rows the desktop does not show, which is the point of it — a row
that silently changed platform is the regression it exists to catch. The new
switch is Android-only by design, so the expected list grows by one, between the
Controllers action row and the console-UI switch (it sits under Reduce motion,
earlier in the Interface tab than either).

Caught by CI on both the Linux and Windows legs, which run this crate's tests;
the row-COUNT assertion next to it was already updated and passed.
2026-08-23 11:43:10 +02:00
enricobuehler 46201fd9c3 Merge pull request 'The console redrew everything, every frame, at whatever resolution the panel handed it' (#384) from worktree-console-tv-perf-safe-fixes into main
ci / docs-drift (push) Successful in 31s
ci / web (push) Successful in 1m33s
ci / rust-arm64 (push) Successful in 1m36s
ci / docs-site (push) Successful in 1m32s
ci / bun-nix (push) Successful in 1m6s
deb / build-publish-gamescope (push) Successful in 2m14s
deb / build-publish-client-arm64 (push) Successful in 2m27s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 29s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 15s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 24s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 17s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
ci / rust (push) Failing after 5m31s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
deb / build-publish-host (push) Successful in 4m50s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m10s
docker / builders-arm64cross (push) Successful in 9s
deb / build-publish (push) Successful in 5m34s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 6m14s
docker / deploy-docs (push) Successful in 37s
arch / build-publish (push) Successful in 8m12s
android / android (push) Successful in 9m18s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m49s
deb / smoke-install (push) Successful in 4m35s
flatpak / build-publish (push) Successful in 6m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 11m28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 10m51s
Reviewed-on: #384
2026-08-23 09:33:41 +00:00
enricobuehler f320f4b465 feat(clients/android): "Reduce interface resolution", for the 4K boxes the console is slow on
ci / docs-drift (pull_request) Successful in 1m7s
ci / bun-nix (pull_request) Successful in 1m8s
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m23s
ci / rust-arm64 (pull_request) Successful in 1m53s
ci / rust (pull_request) Failing after 4m14s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m19s
android / android (pull_request) Successful in 5m36s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Failing after 7m36s
The console draws at whatever resolution the panel hands it, and on a 4K
television or projector that is four times the fragment work of 1080p on a
graphics chip built to decode and composite video rather than to draw a moving
interface. The reporter's two devices — a Fire TV Stick 4K Max and a Valerion
projector — are both premium products and both exactly this shape: the money
is in the light engine and the panel, and the SoC is a TV part. A premium 4K
box is MORE likely to want this than a cheap 1080p stick, which never had the
extra pixels to begin with.

So: an off-by-default switch in the controller-optimized settings, directly
under Reduce motion, because the two are the same kind of bargain — give up
some fidelity, get a smoother console. On, the buffer's long edge is capped at
1920 with `SurfaceHolder.setFixedSize` and the compositor scales it up for
free. Text goes a little softer. Nothing else changes.

Two things this had to get right, neither of which is obvious from the call:

`setFixedSize` shrinks the BUFFER and not the VIEW. Everything that speaks in
surface pixels therefore has to be scaled to match — the safe-area insets, the
design-unit scale, and the pointer coordinates, which a mouse still reports in
view pixels and which would otherwise land the cursor at twice its true
offset. The scale factor is one number applied to both axes, so the aspect
ratio survives exactly and no layout can stretch.

And the buffer is sized from the SurfaceView's own laid-out size, reported
back through `onSizeChanged`, rather than from `displayMetrics`. The two
normally agree, but `displayMetrics` has a long history of disagreeing with a
view's real size by a system bar depending on the version and on who is
hiding what, and a buffer whose aspect ratio does not match the rect it is
scaled into is a stretched interface. "Normally agree" is not something to
hang picture geometry on.

The pointer listeners are installed in `AndroidView`'s `factory`, which runs
once, so the factor reaches them through `rememberUpdatedState` — captured
directly it would freeze at its first-composition value (1, before any layout
has reported a size) and a mouse would be wrong for the rest of the session.
The same reason `platformUp` is already held that way.

⚠ This is the INTERFACE only and shares nothing with the stream. Picture size
is `effectiveMode`, off `Display.mode.physicalWidth` — a physical display
mode, not any surface's buffer — and picture scaling is the separate
`renderScale`. The two `SurfaceView`s are different views and this is the only
`setFixedSize` call in the client. The name keeps "interface" in it, and the
docs entry ends by pointing at Resolution and Bitrate, so that nobody turns
this on expecting a sharper stream.
2026-08-23 11:21:41 +02:00
enricobuehler 89eb031cd6 A dropped skia download read as a lint failure, and had no retry to survive on (#383)
ci / web (push) Successful in 1m25s
ci / docs-site (push) Successful in 1m22s
ci / bun-nix (push) Successful in 48s
ci / docs-drift (push) Successful in 28s
apple / swift (push) Successful in 2m4s
ci / rust-arm64 (push) Successful in 2m20s
deb / build-publish-gamescope (push) Successful in 28s
decky / build-publish (push) Successful in 1m2s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
deb / build-publish-client-arm64 (push) Successful in 1m38s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 37s
ci / rust (push) Successful in 6m38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m42s
docker / builders-arm64cross (push) Successful in 14s
deb / build-publish-host (push) Successful in 5m20s
deb / build-publish (push) Failing after 5m20s
docker / deploy-docs (push) Successful in 41s
arch / build-publish (push) Successful in 9m10s
android / android (push) Successful in 9m35s
deb / smoke-install (push) Successful in 2m31s
apple / distribute (push) Successful in 11m16s
apple / screenshots (push) Successful in 9m42s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m43s
skia-bindings pulls ~19 MB of prebuilt Skia from inside its build script with a
bare curl and no retry, and swallows a failed download into a from-source Skia
build the CI containers cannot complete — so a dropped transfer surfaced as
"Clippy (Android target) failed" with the real cause 1,800 lines up.

A retrying curl shim first on PATH covers it (skia-bindings already resumes and
caches the part-file, so a retry continues the transfer). The prose rule in
android.yml's env block is now a gate that fails on STARTING A FULL BUILD.
2026-08-23 08:24:57 +00:00
enricobuehler 2991001fe4 perf(console-ui,clients/android): the console re-shaped every string and raised a no-op layer, every frame
A field report of a sluggish console UI on a Fire TV Stick 4K Max and a
Valerion projector. The Skia shell is faster than the Compose one it replaced
per unit of work; it was doing far more work than anyone had counted, and all
of it on every frame whether or not anything had changed.

Four costs, none of which change a pixel:

`Fonts::paragraph` built a `ParagraphBuilder`, added its text and called
`layout()` on every call — the whole shaper, HarfBuzz and line breaking and
font fallback, for every string on screen, sixty times a second. It is now
built once per distinct (text, shape, weight, size, width, colour) and kept.
Position is deliberately not in the key, so a shelf that scrolls and a screen
that slides both re-use what they already shaped. Cold entries are dropped
once the map passes its ceiling, by the two frames that last drew them, so the
live set is what is on screen and paging a large library cannot grow it
forever. The loose `(TextAlign, Option<usize>)` pair became a `Para` tag on
the way past: those two were never independent, and it is half of a hash key
now.

`LayerEnv::paint` raised an unbounded `save_layer` unconditionally — including
on the settled path, where alpha is 1, the scale is 1 and the slide is 0. That
allocates an offscreen the size of the whole SURFACE and composites it back,
to apply an alpha of one, on every frame the console sat still. Skia does not
elide it: `SkCanvas::saveLayerAlphaf` forwards alpha >= 1 straight to
`saveLayer(bounds, nullptr)`, whose only early-out is an empty clip. On a 4K
panel that is a 33 MB render target per frame, against a Skia budget that is
64 MB on a 2 GB box — so it was evicting real work to do nothing. Dropping it
is pixel-identical rather than close: nothing in this crate draws with a blend
mode other than `SrcOver`, `SrcOver` is associative, and there is no LCD
subpixel text to gain or lose an isolation. `screens::home` had already
learned this one tile-deep; this is the same fix one level up.

The toast's layer was unbounded too, for a 34 dp pill. Everything inside it is
inside the pill, so it takes the pill's rect and some slack for the hairline.

`draw_clipped` measured its ellipsis fit by allocating a `String` per
character, for every over-long title on screen, every frame. It measures out
of a stack buffer now. The controller chip's string stopped being rebuilt
sixty times a second to say the same thing.

On the Android host, the render thread now takes the same priority lift the
decode thread has taken all along (`-8`, a band below the stream's `-10`, so
the two do not compete when the console is up mid-session). At default nice, a
TV box's scheduler is free to park the console's frame loop on a little core
behind background work, which reads as a UI that lags the remote.

And the thing that made this hard to answer in the first place: the console
logged its GLES version and its cache budget and never its render resolution
or its frame cost, so "it feels sluggish" could not be triaged from a log
bundle at all. It now names the surface size when it wraps one, and reports
mean and peak draw time once a minute. The window is timed around the draw and
not the swap — `eglSwapBuffers` blocks on vsync, so wall-clock per iteration
is always the panel period and says nothing.

What is deliberately NOT here is the biggest single lever on a 4K box: capping
the console's render resolution. That is a real quality trade on a panel
someone bought for its resolution, and it is not this commit's to make.
2026-08-23 10:22:58 +02:00
enricobuehler 19df33e0f7 Merge pull request 'A failed gamescope rebuild took HDR from boxes whose compositor still worked' (#382) from worktree-gamescope-rebuild-keeps-hdr into main
ci / web (push) Successful in 1m21s
ci / docs-site (push) Successful in 1m52s
ci / rust-arm64 (push) Successful in 2m41s
ci / docs-drift (push) Successful in 21s
ci / bun-nix (push) Successful in 1m40s
deb / build-publish-gamescope (push) Successful in 3m44s
deb / build-publish (push) Successful in 4m38s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 10s
deb / build-publish-client-arm64 (push) Successful in 1m36s
ci / rust (push) Successful in 8m32s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
deb / build-publish-host (push) Successful in 6m5s
android / android (push) Successful in 10m18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m20s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m39s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / builders-arm64cross (push) Successful in 15s
docker / deploy-docs (push) Successful in 43s
windows-host / package (push) Successful in 11m52s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 32s
deb / smoke-install (push) Successful in 3m36s
arch / build-publish (push) Successful in 16m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 8m38s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 8m30s
2026-08-23 08:06:53 +00:00
enricobuehler c920204184 Two audio-endpoint bugs found chasing the Sound Recording-tab hang (#381)
android / android (push) Canceled after 5s
arch / build-publish (push) Canceled after 6s
ci / rust (push) Canceled after 3s
ci / rust-arm64 (push) Canceled after 3s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
ci / docs-drift (push) Canceled after 0s
deb / build-publish (push) Canceled after 7s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows-host / winget-source (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 0s
windows-host / package (push) Canceled after 1m9s
windows-host / canary-manifest (push) Canceled after 0s
A host that died mid-mint left an orphan devnode and the next start minted a
duplicate; the registry stamp route reached for the Render hive even for capture
endpoints. Both reproduced and verified on the .173 Windows lab box.

The Recording-tab hang that prompted the investigation is NOT fixed — it did not
reproduce on .173, and nine candidate mechanisms were ruled out by direct
measurement. See the PR body for the disproof table.
2026-08-23 08:05:05 +00:00
enricobuehler 6f4613e146 fix(ci): a dropped skia download read as a lint failure, and had no retry to survive on
apple / swift (pull_request) Successful in 2m11s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m20s
ci / bun-nix (pull_request) Successful in 18s
ci / docs-drift (pull_request) Successful in 22s
ci / docs-site (pull_request) Successful in 1m11s
ci / rust-arm64 (pull_request) Successful in 3m31s
ci / rust (pull_request) Successful in 13m45s
android / android (pull_request) Successful in 5m52s
scripts/ci/retry.sh already wraps every single-shot network call in CI, for the
reason documented there: the runner box runs many jobs in parallel and its
network sheds packets under that load. One of the largest fetches in this
workspace was never wrappable that way - skia-bindings pulls ~19 MB of prebuilt
Skia per target from INSIDE its build script, with a bare 'curl -sS -f -L' and
no retry (build_support/binary_cache/utils.rs).

Measured on main 2026-08-22, android job:

  DOWNLOAD AND INSTALL FAILED: curl error code: "18"
  curl stderr: "curl: (18) end of response with 17054400 bytes missing"

2 MB of 19,057,024 arrived before git.unom.io closed the connection; the same
asset pulls fine from a dev box. skia-bindings then swallowed it - its
try_prepare_download falls through to STARTING A FULL BUILD, a from-source Skia
build the CI containers carry no deps for - so the job surfaced as
'Clippy (Android target) failed' with a Gradle stack trace and the real cause
1,800 lines above it.

* A retrying curl shim first on PATH is the only lever that reaches inside a
  build script, and the cheapest correct one: skia-bindings already passes
  '-C -' and caches the part-file under OUT_DIR/.cache, so a retry CONTINUES
  the truncated transfer rather than restarting it. --retry-all-errors is
  load-bearing: a truncated transfer is not an HTTP status, so plain --retry
  would let error 18 through.
* Wired into android.yml and both ci.yml rust jobs - pf-console-ui pulls
  skia-safe too, so ci/rust downloads Skia on any target-cache miss.
* The rule android.yml's env block states in prose ('Every ABI's log must show
  DOWNLOAD AND INSTALL SUCCEEDED') is now a gate that fails the job on
  STARTING A FULL BUILD, so a dropped prebuilt can never masquerade as a lint
  failure again.
2026-08-23 10:00:21 +02:00
enricobuehler 96f75f4e52 style: rustfmt the abandoned-devnode sweep
ci / rust-arm64 (pull_request) Successful in 1m20s
ci / bun-nix (pull_request) Successful in 20s
ci / docs-drift (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 1m3s
ci / web (pull_request) Successful in 3m33s
android / android (pull_request) Successful in 10m5s
ci / rust (pull_request) Successful in 9m45s
2026-08-23 09:49:49 +02:00
enricobuehler 3b08da11ff fix(gamescope): pin libdisplay-info to the vendored subproject, like wlroots
ci / web (pull_request) Successful in 1m55s
ci / rust-arm64 (pull_request) Successful in 3m27s
ci / bun-nix (pull_request) Successful in 31s
ci / docs-drift (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 2m2s
android / android (pull_request) Successful in 6m13s
ci / rust (pull_request) Successful in 6m44s
Caught on the SteamOS lab VM while verifying the previous commit end to end. With
libx11-xcb-dev added the build finally COMPLETED (652/652, banner "3.16.25-21-gb71a56c
+pfhdr8") — and then failed its on-glass check:

    punktfunk-gamescope: error while loading shared libraries:
    libdisplay-info.so.2: cannot open shared object file

Self-inflicted: the previous commit also took libdisplay-info-dev from the CI image's
list. gamescope vendors libdisplay-info as a submodule, but it is NOT in
force_fallback_for, so meson preferred the system lib the moment the build box had the
-dev package and linked it SHARED. SteamOS ships no libdisplay-info.so.2, so the binary
built, installed and printed its +pfhdr banner inside the distrobox and could not start
on the machine it exists for.

This is verbatim the wlroots trap the same comment block already documents ("starts fine
on the build host and dies with libwlroots-0.19.so ... anywhere else"), so it gets the
same remedy rather than a second one: libdisplay-info joins force_fallback_for. "Just
don't install the -dev package" does not hold — Debian, Fedora and Arch all have it and
anything can pull it in transitively, and the failure is silent right up to the on-glass
check that build-gamescope.sh happens to run.

Also drop libdisplay-info-dev from the Deck list (pointless once the fallback is pinned)
and record why that list must NOT be synced with ci/gamescope-trixie.Dockerfile: the CI
list targets a .deb that runs on Debian, this one cross-builds in trixie for SteamOS
glass. libx11-xcb-dev and libxkbcommon-x11-dev stay — SteamOS ships both sonames.

The on-glass check did its job here: it caught the bad binary, removed it and left the
box SDR rather than letting the host promise HDR it could not deliver.
2026-08-23 09:44:59 +02:00
enricobuehler 3ee88bb8cf Merge pull request 'acquireLatestImageAsync hands back a fence it already gave away' (#376) from worktree-asc-fdsan-acquire-fence into main
ci / rust-arm64 (push) Successful in 1m35s
ci / web (push) Successful in 1m11s
ci / bun-nix (push) Successful in 19s
ci / docs-site (push) Successful in 1m35s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
ci / docs-drift (push) Successful in 23s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m45s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m6s
ci / rust (push) Successful in 8m31s
android / android (push) Successful in 9m5s
docker / builders-arm64cross (push) Successful in 14s
docker / deploy-docs (push) Successful in 32s
Reviewed-on: #376
2026-08-23 07:42:10 +00:00
enricobuehler 4c5b97cfe4 fix(host,audio): the registry stamp route reached for the Render hive even for capture endpoints
ci / rust-arm64 (pull_request) Successful in 1m21s
ci / rust (pull_request) Failing after 2m50s
ci / web (pull_request) Successful in 1m6s
ci / bun-nix (pull_request) Successful in 22s
ci / docs-site (pull_request) Successful in 1m7s
ci / docs-drift (pull_request) Successful in 23s
android / android (pull_request) Canceled after 7m19s
write_stamps falls back to a raw-registry write when the property store
denies it. That fallback built its path from MMDEV_RENDER_PATH unconditionally,
so stamping the minted microphone's CAPTURE endpoint reached for
...\MMDevices\Audio\Render\{capture-guid}\Properties - a key that cannot
exist. RegOpenKeyExW then failed, write_stamps returned the error, and
stamp_identity degraded to 'keeps the driver's default name'.

Invisible to the pad program, whose endpoints are render-only, and invisible on
any box where the property store route succeeds (both field logs show
registry=[] on every stamp line, so neither reporter ever took this path). It
only bites where the property store is denied - exactly the boxes the ACL
repair exists for.

The hive now follows the direction the endpoint id encodes, with render as the
default for anything unrecognised. Unit-tested.
2026-08-23 09:40:01 +02:00
enricobuehler 773eea24d9 Merge pull request 'A DualSense's buttons all reach the stream, and its Mute button works' (#380) from worktree-dualsense-buttons-and-pad-routing into main
android / android (push) Canceled after 2m33s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 1m53s
ci / web (push) Canceled after 1m11s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
ci / docs-drift (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
2026-08-23 07:39:02 +00:00
enricobuehler 3b5c95959b Merge pull request 'The startup capacity probe stops black-holing constrained links' (#379) from worktree-abr-probe-target-from-stream-cap into main
android / android (push) Canceled after 20s
ci / rust (push) Canceled after 3s
ci / docs-drift (push) Canceled after 18s
ci / rust-arm64 (push) Canceled after 19s
ci / web (push) Canceled after 18s
ci / bun-nix (push) Canceled after 19s
ci / docs-site (push) Canceled after 18s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / swift (push) Successful in 2m11s
deb / build-publish-gamescope (push) Successful in 59s
deb / build-publish-client-arm64 (push) Successful in 2m12s
deb / build-publish (push) Successful in 4m10s
deb / build-publish-host (push) Successful in 5m5s
arch / build-publish (push) Successful in 8m14s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 10m53s
flatpak / build-publish (push) Successful in 9m0s
apple / distribute (push) Successful in 12m33s
deb / smoke-install (push) Successful in 2m48s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 5m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m28s
apple / screenshots (push) Successful in 9m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 23m0s
windows-host / package (push) Canceled after 9m47s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-23 07:38:49 +00:00
enricobuehler a2bc9a2bdc fix(steamdeck): a failed gamescope rebuild took HDR from boxes whose compositor still worked
ROOT CAUSE of "HDR stopped working after updating to 0.31.2" on a Deck source install.
Two defects, one symptom.

1. scripts/steamdeck/build-gamescope.sh has been UNBUILDABLE since 2026-08-13, when
   3ac4548c turned `-Denable_gamescope_wsi_layer=true` on. The layer needs x11-xcb, which
   Debian splits into its own libx11-xcb-dev; the distrobox apt list — last touched
   2026-07-31 — never got it. MEASURED on debian:trixie against that list verbatim,
   gamescope at the pinned 5fb8dce4:

       Run-time dependency x11-xcb found: NO (tried pkgconfig and cmake)
       src/layer/meson.build:3:14: ERROR: Dependency "x11-xcb" not found

   `meson setup` exits 1 with the list as it was and 0 with libx11-xcb-dev added, and
   build-punktfunk-gamescope.sh treats a missing layer as a hard error, so the whole build
   fails. ci/gamescope-trixie.Dockerfile walked into the identical trap one release later
   (1b28a7f7, v0.28.1) and asserts x11-xcb at image build; this list never got the same
   fix. Debian-family only: Arch's libx11 and Fedora's libX11-devel carry x11-xcb.pc.
   xkbcommon-x11 and libdisplay-info measured absent too, and are added with it.

2. The build-failure branch then called `unwire`, deleting PUNKTFUNK_GAMESCOPE_BIN from
   host.env. A failed build REPLACED NOTHING — the previously installed binary is still on
   disk and still passes `verifies`. So a rebuild that never landed took HDR away from a
   box that had been streaming it minutes earlier. The script warns into a log nobody reads
   and exits 0, the update reports success, and the host then resolves the distro's stock
   /usr/bin/gamescope at patch level 0 and fixes the session at 8-bit SDR in the Welcome —
   which the punktfunk/1 handshake cannot take back.

   A verifying binary now stays wired (and a box a previous run of this bug unwired gets
   re-wired). `unwire` happens only where the binary itself fails its on-glass check, which
   is the branch that also removes it.

Also promote the "no +pfhdr marker" line from DEBUG to INFO. The handshake already reports
capture_supports_hdr=false at INFO while the one line saying WHY sat a level below it —
that asymmetry is what made this field report expensive to answer.

Verified: the meson reproduction above (exit 1 -> exit 0); all three added package names
resolve on trixie and satisfy their pkg-config modules; the four states of the changed
branch exercised in isolation (working binary stays wired, stock/missing binary unwired,
previously-unwired box re-wired). Not verified on real SteamOS glass — the lab VM was
unreachable from this machine.
2026-08-23 09:28:56 +02:00
enricobuehler 4beee17953 fix(host,audio): merge the abandoned-devnode tests into the existing module
ci / bun-nix (pull_request) Successful in 29s
ci / web (pull_request) Successful in 1m8s
ci / docs-drift (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m35s
ci / rust (pull_request) Failing after 1m44s
ci / rust-arm64 (pull_request) Successful in 2m30s
android / android (pull_request) Successful in 5m48s
2026-08-23 09:08:57 +02:00
enricobuehler 4ad0055416 fix(host,audio): a host that died mid-mint left an orphan devnode, and the next start minted a duplicate
Minting an audio devnode is two PnP steps: SetupDiRegisterDeviceInfo makes it
real and bindable, then the owner marker goes into Device Parameters. A host
that dies between them - the 0.30.0 TLS-destructor abort did exactly this,
five times on one field box - leaves a registered, driver-bound, endpoint-
serving devnode carrying no marker.

Nothing resolved it afterwards. find_role_devnode matches on the marker, so
the next pass minted a SECOND devnode and the orphan stayed: a duplicate
'Punktfunk Speakers'/'Punktfunk Microphone' in the Sound zoo that no uninstall
removed, because devnode_cleanup is marker-matched too. A field box showed
exactly this shape - 'Punktfunk Speakers (3- Punktfunk)' beside an unstamped
'Punktfunk Speakers (4- Steam Streaming Speakers)' - in every wiring plan it
logged. Reproduced on .173 against the shipping 0.31.2 binary by clearing the
marker: ROOT\MEDIA\0005 was minted and 0004 was abandoned, still active and
still serving two live Punktfunk Microphone endpoints.

* minted.rs adopts before it mints. An unmarked ROOT\MEDIA\NNNN devnode
  carrying the role's Steam hardware id is re-marked and reused, so the
  endpoint GUID survives and no device-change broadcast is paid.
* devnode_cleanup sweeps the same shape, so orphans already on a box go at
  uninstall instead of outliving the product.

The instance prefix is what keeps both off Valve's own devices: Steam's
devnodes carry these hardware ids and are ROOT-enumerated too, but live under
ROOT\SteamStreamingSpeakers\* / ROOT\SteamStreamingMicrophone\*. Only
ROOT\MEDIA\* can come from our SetupDiCreateDeviceInfoW(DICD_GENERATE_ID).
is_abandoned_mint carries that rule with unit tests.
2026-08-23 09:03:52 +02:00
enricobuehler db9cd40079 The hand-back never checked that the panel came back, and a crashed host left game mode asleep (#375)
ci / bun-nix (push) Successful in 37s
ci / rust-arm64 (push) Successful in 1m31s
ci / web (push) Successful in 1m46s
ci / docs-site (push) Successful in 1m43s
ci / docs-drift (push) Successful in 1m48s
deb / build-publish-gamescope (push) Successful in 1m20s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 50s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
deb / build-publish-client-arm64 (push) Successful in 2m2s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m44s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m7s
docker / builders-arm64cross (push) Successful in 23s
deb / build-publish-host (push) Successful in 5m35s
ci / rust (push) Successful in 7m21s
docker / deploy-docs (push) Successful in 51s
android / android (push) Successful in 10m16s
arch / build-publish (push) Successful in 10m38s
deb / build-publish (push) Successful in 5m18s
deb / smoke-install (push) Successful in 4m30s
windows-host / package (push) Successful in 16m42s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 23s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m22s
Reproduced on both Bazzite 44.20260818 and Nobara f44: a host killed mid-takeover
left the box's Game Mode running `/usr/bin/sleep infinity` with the panel lit and
blank, permanently. Fixed and re-verified on both boxes against canary
0.32.0-0.ci15147.gc63e8cee, with no regression to the ordinary disconnect.

The hand-back also measures its own outcome now instead of trusting a systemd job
status, so any other route to a dark panel is caught and escalated rather than
logged as success.
2026-08-22 23:38:23 +00:00
enricobuehler cb07a8f983 fix(clients/android): a DualSense's buttons all reach the stream, and its Mute button works
ci / web (pull_request) Successful in 1m12s
ci / rust-arm64 (pull_request) Successful in 1m25s
ci / bun-nix (pull_request) Successful in 19s
ci / docs-drift (pull_request) Successful in 40s
ci / docs-site (pull_request) Successful in 1m17s
android / android (pull_request) Successful in 5m51s
ci / rust (pull_request) Successful in 5m57s
Three defects reported against a Bluetooth DualSense on a Fire TV Stick 4K Max,
re-implemented from #371's diagnosis. #371 itself should not be merged: all
three problems are real, but each fix lands somewhere that breaks more hardware
than it repairs.

1. Some buttons never reach the stream. Fire OS is reported to tag certain
   DualSense buttons SOURCE_KEYBOARD even though the keycodes are standard
   BUTTON_*, and MainActivity's `event.isFromSource(SOURCE_GAMEPAD)` gate then
   drops them. The event's source class is the platform's per-event guess; the
   DEVICE's is the fact. New `MainActivity.fromPad` widens to the device — but
   ONLY for `KeyEvent.isGamepadButton` keycodes. That exclusion is the whole
   safety of it: DPAD keycodes are a keyboard's arrow keys and BACK is a
   remote's way out of the stream, and both share their keycodes with a pad.
   `Gamepad.isPad` is untouched (source-class only) and no vendor-id or
   device-name matching is added anywhere — the field report records both pads
   being IDENTIFIED correctly; only their button positions were wrong.

2. Touchpad click and Mute were dropped. Both have wire bits (BTN_TOUCHPAD,
   BTN_MISC1) and no Android keycode, so GENERIC_SONY's `0x13d`/`0x13e` rows now
   borrow BUTTON_15/BUTTON_16 to carry them into `buttonBit`. Inside
   GENERIC_SONY and nowhere else: `0x13d`/`0x13e` are BTN_THUMBL/BTN_THUMBR —
   L3 and R3 — in the standard Linux mapping, and they mean touchpad and mute
   only inside the straight-through report order a driverless pad uses. A row in
   SONY_MODERN, or an override above `padMap(dev)`, costs every Xbox pad, Switch
   Pro, 8BitDo, Steam Deck and hid-playstation DualSense both stick clicks.
   `correct()`'s `genericKeyCode` guard stays exactly as it was.

3. Mute toggles the mic — once per press, and only on a pad that has one.
   Edge-triggered through the existing `completesChord` as the one-button chord
   it is: `onButton` still calls `slotButton(down = true)` on auto-repeat, so an
   unguarded check would flap the mic for as long as the button is held. Gated
   on a new `Slot.hasMuteButton`, because BTN_MISC1 is the wire's misc/QAM bit
   and `Sc2Device` puts a Steam Controller 2's QAM button on it — "any MISC1"
   would mute the microphone on every QAM press. Resolved at slot open from what
   each path knows: the report order for an InputDevice, the declared kind for a
   capture link. Under the "local" system-button policy a real mute button is
   exempt from the early return (that policy means the press stays with this
   device, which is exactly what the toggle does) and loses only its wire send;
   every other system button behaves as before.

Tests: `every other pad keeps L3 and R3 on those scancodes` is the regression
that matters and fails on #371's shape (verified by reproducing it). Plus the
rewritten touchpad/mute assertions, the guard's negative path — untested in
either direction until now, because every existing case fed `correct()` the
keycode `Generic.kl` would have produced — and the mute button's edge rule in
GamepadChordTest. `an Xbox pad at the standard positions keeps X, Y and its
shoulders` is kept.

Not yet verified on hardware: no Fire TV Stick 4K Max or DualSense here, and no
adb device attached. §1's premise (the SOURCE_KEYBOARD tagging) is therefore
unconfirmed — the change is a no-op if it does not hold.
2026-08-23 01:07:59 +02:00
enricobuehler c63e8cee39 fix(gamescope): the hand-back never checked that the panel came back, and a crashed host left game mode asleep
ci / bun-nix (pull_request) Successful in 28s
ci / docs-drift (pull_request) Successful in 1m5s
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m50s
ci / rust-arm64 (pull_request) Successful in 2m7s
ci / rust (pull_request) Successful in 7m5s
android / android (pull_request) Successful in 7m35s
Field reports on 0.31.x, Bazzite and Nobara: after disconnecting, the box's own
physical screen stays black.

I could not reproduce it (PR #375 has the full negative write-up: five scenarios
across both distro families on the real VMs, all recovering cleanly, and the
mechanism I first proposed disproved on glass). So this does not guess at the
trigger. It closes the gap that lets ANY trigger end as a dark panel, and fixes
the one black-screen path I could prove.

## The restore never checked its own work

`do_restore_tv_session` issues a lifecycle verb and logs what systemd said about
the JOB. "The job succeeded" and "the box shows a picture" are different
questions, and nothing in this file has ever asked the second one — the restore
walks away the moment the verb returns, so every way the box can end up dark
looks identical to success in the log.

So measure it. After the hand-back a detached watcher polls
`detect_active_session()`, whose `None` means no compositor of our uid is running
at all — exactly the symptom. If the box is still dark 25 s later it climbs a
ladder of remedies, each measured on both images (Bazzite 44.20260818, Nobara
f44, 2026-08-22):

1. STOP the autologin unit. Its login session's script is parked on
   `systemctl --user --wait start <unit>` on both images, so a stop releases that
   wait, the session exits, and `Relogin=true` logs back in — starting the unit
   inside a session with a seat. `stop`, not `restart`: a restart does NOT
   release the parked waiter (measured), which is why it cannot rescue a box the
   ordinary restart already failed to bring back.
2. Restart the display manager — what the pre-0.31.0 takeover did on every
   disconnect, and proven on the Bazzite VM to return the box to game mode.
3. `PUNKTFUNK_RECOVER_SESSION_CMD`, then an ERROR naming the command a human has
   to run.

Detached, and that is load-bearing: the restore holds `RESTORE_FLIGHT`, which a
reconnecting client must take before it can re-take the box, so watching for up
to a minute while holding it would put that wait in front of every reconnect.
The watcher also stands down the instant `takeover_live()` says a new takeover
armed — the box belongs to that stream now, and a remedy fired into it would be
a fresh bug. It runs after `clear_takeover()` so that check means "a client
reconnected" and not "our own takeover has not been filed yet".

Skipped on the shutdown path: `restore_takeover_now` runs inside `native.rs`'s
20 s `SHUTDOWN_RESTORE_GRACE`, and spending that grace watching would cost the
hand-back rather than check it. What covers a shutdown that left the box dark is
the next host start — which this commit also makes true.

## A crashed host left the box's game mode asleep, provably

`restore_takeover_on_startup` sweeps a leftover idle drop-in off the box and logs
that the box's "own Game Mode session would have started and then done nothing".
Removing the FILE does not touch the unit RUNNING under it: its `ExecStart` is
still the sleep, so it sits `active` drawing nothing. Nothing below that sweep
restarts it either — the takeover file may be absent, unparseable, or fail
`takeover_state_is_live`, and all three exits leave the box on a dark panel with
its game mode "running". Any host killed mid-takeover (SIGKILL, OOM, a yanked
update) lands exactly there, and it survives until someone reboots.

`hand_back_idled_units_after_crash` restarts those units, gated on the box
actually being dark so a user already in game mode or on a desktop is never
bounced, and only for ACTIVE instances — under a just-removed idle drop-in,
active means "running the sleep".

## Not changed

The `restart` verb on the ordinary restore path. It works on both distros
(measured), and 0.31.0 chose it deliberately for the idled unit. The `stop` idea
survives only as escalation rung 1, where it runs after the proven path has
already failed.

`listed_autologin_units` is factored out of `stop_autologin_sessions` so both
callers share it, and its column parsing — which decides whether a live gaming
session can be told from a dead leftover — finally has a test against real
`--plain` output from both images.
2026-08-23 01:02:27 +02:00
enricobuehler b670b5d844 Merge pull request 'A TV negotiated the refresh its menu pinned, not the one it outputs' (#378) from worktree-tv-refresh-mismatch into main
ci / web (push) Successful in 2m11s
ci / docs-site (push) Successful in 2m1s
ci / rust-arm64 (push) Successful in 2m45s
ci / docs-drift (push) Successful in 1m27s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 23s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 31s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 29s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 39s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 36s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 41s
ci / bun-nix (push) Successful in 33s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders-arm64cross (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m49s
docker / deploy-docs (push) Successful in 36s
ci / rust (push) Successful in 5m41s
android / android (push) Successful in 9m51s
2026-08-22 22:47:19 +00:00
enricobuehler 11abff5343 fix(client): size the capacity probe from the session, and re-anchor if the burst eats the video
apple / swift (pull_request) Successful in 2m10s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 7m31s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 8m20s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m21s
ci / bun-nix (pull_request) Successful in 24s
ci / docs-drift (pull_request) Successful in 26s
ci / docs-site (pull_request) Successful in 1m17s
ci / web (pull_request) Successful in 1m23s
ci / rust-arm64 (pull_request) Successful in 1m24s
ci / rust (pull_request) Successful in 5m47s
The startup link-capacity probe burst at a flat 2 Gbps on the reasoning that it
must measure the link and not itself. That reasoning is obsolete: the ABR
already clamps the measured ceiling to `stream_cap_kbps` (what this session's
mode + codec could plausibly use), so every bit measured above `cap / 0.7` is
discarded the moment it lands. The height bought a number nothing reads, and
paid bufferbloat for it — a constrained Wi-Fi link can black-hole under it
(measured on webOS: a 6 s probe timeout delaying first video to 14 s, and a
"successful" probe still reporting send_dropped=20211; the same shape is now
reported on a Fire TV Stick 4K Max).

Derive the target instead: `stream_cap_kbps × 2`, capped at the old 2 Gbps.
×2 is the smallest multiplier that can still prove the cap (the ceiling is
`delivered × 0.7`, so proving it needs `delivered ≥ cap × 1.43`), so this can
never cap anyone — a session whose mode justifies a high ceiling asks for a
high target by itself, and a mode `stream_ceiling_kbps` declines to size still
gets 2 Gbps. Deliberately not a platform `cfg!`: the constraint is the
session's, not Android's, and webOS has the same bug.

Second half of the black screen: if the burst takes the first keyframe down
with it, nothing re-requests one and the client sits on black until an
unrelated recovery path happens to fire. Ask for a keyframe at probe end when
no frame completed across the burst — compared against the count snapshotted
at the burst's leading edge rather than against 0, so it also covers a
mid-session embedder speed test that kills a running stream. One request per
probe, through the control task's coalescer, so it cannot IDR-storm.

`PUNKTFUNK_ABR_PROBE_KBPS` and its `> 0` filter are unchanged.
2026-08-23 00:45:42 +02:00
enricobuehler 064ea3de7d fix(android): a TV negotiated the refresh its MENU pinned, not the one it outputs
ci / bun-nix (pull_request) Successful in 29s
ci / docs-drift (pull_request) Successful in 39s
ci / docs-site (pull_request) Successful in 1m12s
ci / web (pull_request) Successful in 1m29s
ci / rust-arm64 (pull_request) Successful in 2m17s
ci / rust (pull_request) Successful in 5m7s
android / android (pull_request) Successful in 6m22s
Field report: on Android TV / Fire Stick, latency explodes whenever the client's
refresh differs from the host's, and setting the refresh by hand is the only
workaround.

The client was manufacturing that mismatch itself, in three steps:

  1. `MainActivity.onCreate` pins the panel to its highest-refresh mode for the
     console UI (`setConsoleHighRefreshRate(true)`) — unconditionally, TVs
     included. That pin exists for phone refresh governors (Nothing OS's LTPO
     logic among them) which cap third-party apps at 60 Hz. No TV has one.
  2. At connect, `nativeDisplayMode` resolves "Native" refresh from
     `display.mode` — which now reports the mode the MENU pinned, not the TV's
     real HDMI output. So the session negotiates (say) 120.
  3. `StreamScreen` releases the pin again on TV, by design: there the decoder's
     own `setFrameRate(CHANGE_FRAME_RATE_ALWAYS)` governs the HDMI mode. The
     panel falls back to 60 while the host is already serving 120.

A 120 fps stream on a 60 Hz output, by construction, on exactly the two form
factors in the report. Picking a refresh explicitly is precisely what bypasses
step 2, which is why that is the workaround people found. The mode comparator
sorts refresh before area, so the same pin could also drop a 4K TV to 1080p120
and negotiate the stream at that.

Fixed at the choke point: `resolveHighRefreshMode` returns early on a TV, leaving
`highRefreshModeId` at 0, which `setConsoleHighRefreshRate` already treats as a
no-op — so all three of its callers are covered by the one guard. A TV that
genuinely wants 120 still gets it by choosing it, driven by the native mode
switch, exactly as the TV path documents.

Also in the same chain: `nativeDisplayMode` TRUNCATED the panel rate, so a TV
reporting the fractional NTSC rates over HDMI (59.94, 29.97, 23.976) asked the
host for 59 / 29 / 23 — rates no display mode has, which the host serves by
clamping down to the highest it advertises at or below. Rounded now, which also
makes it agree with `MainActivity.streamPanelFps`; the two describe the same
panel and must not disagree.
2026-08-23 00:21:50 +02:00
enricobuehler ec278c0478 Merge pull request 'Floor the forced-keyframe coalesce window so a 120 fps session can't IDR-storm' (#377) from worktree-hevc-idr-storm-coalesce into main
ci / docs-site (push) Successful in 1m8s
ci / web (push) Successful in 1m17s
ci / bun-nix (push) Successful in 29s
ci / docs-drift (push) Successful in 31s
ci / rust-arm64 (push) Successful in 2m3s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 29s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 40s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
deb / build-publish-gamescope (push) Successful in 1m5s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 16s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 25s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 24s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 17s
deb / build-publish-client-arm64 (push) Successful in 1m43s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 31s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 31s
ci / rust (push) Successful in 7m15s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 17s
docker / deploy-docs (push) Successful in 38s
docker / builders-arm64cross (push) Successful in 18s
deb / build-publish (push) Successful in 5m17s
android / android (push) Successful in 9m45s
deb / build-publish-host (push) Successful in 5m48s
arch / build-publish (push) Successful in 9m20s
deb / smoke-install (push) Successful in 2m36s
windows-host / package (push) Successful in 13m10s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m41s
2026-08-22 22:00:55 +00:00
enricobuehler 5d91176500 fix(host): floor the forced-keyframe coalesce window so a 120 fps session can't IDR-storm
ci / rust-arm64 (pull_request) Successful in 2m24s
ci / web (pull_request) Successful in 2m2s
ci / docs-drift (pull_request) Successful in 27s
ci / bun-nix (pull_request) Successful in 26s
ci / rust (pull_request) Successful in 7m33s
ci / docs-site (pull_request) Successful in 1m57s
android / android (pull_request) Successful in 8m14s
The window was `frame_interval * 2`, which is 16.7 ms at 120 fps. A Moonlight
client that has lost decode sync re-asks for an IDR roughly every 30 ms, so the
gate never closed between requests and effectively every request became a full
keyframe.

Field log (AMD RX 7800 XT, Bazzite 44, 1080p120 HEVC over the GameStream plane):
1118 IDR requests in one 91 s session, 1115 honoured, only 3 coalesced — about
one full IDR every tenth frame at a 100 Mbps target. IDRs that size saturate the
send path, which causes the loss that prompts the next request, so the storm
sustains itself. It reads as stutter at a flat latency, because frames are being
lost rather than queued. The same session's H.264 leg (libav VAAPI, same
bitrate) took 2 IDR requests and was clean.

The window is a round-trip bound — how long until the client can receive and
decode the IDR it already asked for — so it needs an absolute floor rather than
a frame count. 100 ms matches the encoder-reset backoff in the same loop.

Simulated against the logged 30 ms request cadence, this cuts honoured IDRs over
a 91 s session from every request to roughly a quarter, while still recovering
promptly from a genuine loss event.
2026-08-22 23:49:33 +02:00
enricobuehler cf7baf3ba8 fix(client/android): acquireLatestImageAsync hands back a fence it already gave away
ci / bun-nix (pull_request) Successful in 31s
ci / docs-drift (pull_request) Successful in 35s
ci / web (pull_request) Successful in 1m17s
ci / docs-site (pull_request) Successful in 1m40s
ci / rust-arm64 (pull_request) Successful in 2m10s
ci / rust (pull_request) Successful in 7m18s
android / android (pull_request) Successful in 8m4s
Every pf-decode SIGABRT on the Shield is fdsan catching a double-close of the
acquire fence the ASC presenter passes to ASurfaceTransaction_setBuffer, in
three shapes: inside Fence::Fence(int) under setBuffer when the number had
already been re-owned ("fd N is owned by unique_fd, was expected to be
unowned"), at the end of Transaction::apply when the layer state is torn down,
and in Parcel::freeDataNoInit once the number churns.

The fence is not ours to give. AImageReader::acquireLatestImage drains with a
single int* out-param it overwrites per image, then releases each dropped image
with whatever that out-param currently holds — the successor's fence — and
returns the last value written. So as soon as a burst gives it two images to
collapse, the caller receives an fd the reader has already adopted and closed,
plus one leaked fd per extra drop. This is unfixed as of AOSP main, so the
newest-wins collapse has to happen on our side.

Drain both present intents with acquireNextImageAsync, whose fence is always a
fresh dup we exclusively own, and let latency pick the newest itself — the loop
the smoothing FIFO already ran. Superseded candidates drop as before: image back
to the pool, its own acquire fence closed. Reader drops now show up in `skipped`
instead of vanishing inside the reader lock.
2026-08-22 23:41:05 +02:00
enricobuehler 551d0c3294 Merge pull request 'The encoder follows a game-driven display mode change' (#373) from worktree-encoder-follow-mode-change into main
ci / bun-nix (push) Successful in 17s
ci / web (push) Successful in 1m8s
ci / rust-arm64 (push) Successful in 1m27s
ci / docs-site (push) Successful in 1m21s
ci / docs-drift (push) Successful in 30s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
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 23s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 21s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 2m7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m24s
deb / build-publish (push) Successful in 5m7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
deb / build-publish-host (push) Successful in 5m50s
docker / deploy-docs (push) Successful in 40s
docker / builders-arm64cross (push) Successful in 12s
ci / rust (push) Successful in 7m5s
arch / build-publish (push) Successful in 10m22s
android / android (push) Successful in 11m5s
deb / build-publish-gamescope (push) Failing after 11m3s
windows-host / package (push) Successful in 13m44s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 34s
deb / smoke-install (push) Successful in 5m31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 22m40s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 22m23s
Reviewed-on: #373
2026-08-22 21:33:42 +00:00
enricobuehler 7f77fa68af Merge pull request 'Stop the flatpak build updating runtimes it already has' (#370) from worktree-flatpak-deps-no-update into main
ci / bun-nix (push) Successful in 30s
ci / docs-drift (push) Successful in 31s
ci / docs-site (push) Successful in 1m5s
ci / web (push) Successful in 1m9s
ci / rust-arm64 (push) Successful in 1m28s
apple / swift (push) Successful in 2m10s
deb / build-publish-gamescope (push) Successful in 1m2s
deb / build-publish-client-arm64 (push) Successful in 1m43s
decky / build-publish (push) Successful in 1m7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 21s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 20s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 19s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 19s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
deb / build-publish (push) Successful in 4m23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m14s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m33s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
deb / build-publish-host (push) Successful in 6m31s
docker / deploy-docs (push) Successful in 45s
android / android (push) Successful in 9m2s
docker / builders-arm64cross (push) Successful in 15s
flatpak / build-publish (push) Successful in 5m15s
arch / build-publish (push) Successful in 10m36s
deb / smoke-install (push) Successful in 5m41s
apple / distribute (push) Successful in 13m29s
ci / rust (push) Canceled after 16m51s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 11m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 11m57s
apple / screenshots (push) Successful in 10m36s
Reviewed-on: #370
2026-08-22 21:16:05 +00:00
enricobuehler ece8b16a78 fix(gamestream): the encoder follows a game-driven display mode change
ci / docs-drift (pull_request) Successful in 27s
ci / bun-nix (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m12s
ci / web (pull_request) Successful in 1m58s
ci / rust-arm64 (pull_request) Successful in 2m13s
android / android (pull_request) Successful in 5m16s
ci / rust (pull_request) Successful in 6m20s
The GameStream twin of the native fix. A fullscreen game can mode-set the
virtual display mid-stream; the IDD-push capturer re-opens its ring at the
new mode, and `try_latest` then hands this loop a frame the encoder cannot
accept. Every submit fails, the submit ladder rebuilds the encoder IN
PLACE at the same configured size — which cannot converge on a size the
source has already left — and after five resets the stream ends, costing
the Moonlight client a full disconnect/reconnect.

Reopen at the delivered size instead, with the same bookkeeping the
capture-loss rebuild in this loop already does (ring depth, RFI caps,
forced IDR, in-flight numbering restart). A failed reopen spends the
shared `encoder_resets` budget at the existing exponential pace rather
than ending the stream on the first try — a mode-set leaves the driver
settling, which is what that backoff exists for.

`gs_bit_depth(frame.format)` is derived per open, so an HDR flip that
recreates the ring at P010 now re-opens at the right depth too.

The client is NOT told: GameStream has no mid-stream mode-change message,
so Moonlight decodes a bitstream that disagrees with the resolution it
configured its decoder from. That is the same bargain the first open in
this function already takes whenever the captured size differs from the
negotiated one (the monitor-mirror case, §7.3) — tolerant decoders re-init
off the SPS and scale; a strict one (Media Foundation on Xbox) may stall
and drop the session. Taking it here too is strictly better than the
alternative, which is ending every stream the moment a game changes mode.
The guard carries that note.
2026-08-22 22:25:09 +02:00
enricobuehler 2b91339cb8 Merge pull request 'A paired Moonlight device can be given a name, and the pad-silence theory is measured and dropped' (#374) from worktree-gamestream-pad-heartbeat into main
ci / bun-nix (push) Successful in 28s
ci / docs-drift (push) Successful in 44s
ci / web (push) Successful in 1m8s
ci / rust-arm64 (push) Successful in 1m30s
ci / docs-site (push) Successful in 1m50s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
deb / build-publish-client-arm64 (push) Successful in 1m35s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 13s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m32s
deb / build-publish-host (push) Successful in 4m44s
deb / build-publish-gamescope (push) Successful in 52s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 20s
arch / build-publish (push) Successful in 9m0s
android / android (push) Failing after 9m14s
docker / builders-arm64cross (push) Successful in 13s
docker / deploy-docs (push) Successful in 43s
deb / build-publish (push) Successful in 4m52s
deb / smoke-install (push) Successful in 2m52s
windows-host / package (push) Successful in 18m0s
windows-host / winget-source (push) Skipped
ci / rust (push) Successful in 18m30s
windows-host / canary-manifest (push) Successful in 35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m45s
2026-08-22 19:22:01 +00:00
enricobuehler ffa4577793 style(host): keep the IDR-anchor comment off the trailing position
ci / rust-arm64 (pull_request) Successful in 2m46s
ci / web (pull_request) Successful in 1m35s
ci / bun-nix (pull_request) Successful in 57s
ci / docs-site (pull_request) Successful in 1m21s
ci / docs-drift (pull_request) Successful in 39s
android / android (pull_request) Successful in 6m46s
ci / rust (pull_request) Successful in 20m2s
A trailing comment that long makes rustfmt treat the two comment lines
that follow it as a continuation of the same block and reflow them into a
hanging indent past column 60, which fails `cargo fmt --all --check`.
Put it on its own line above the statement instead.
2026-08-22 20:58:00 +02:00
enricobuehler 4a32c8fb36 test(mgmt): one config-dir override for the file, not one per test
ci / web (pull_request) Successful in 2m6s
ci / bun-nix (pull_request) Successful in 37s
ci / docs-site (pull_request) Successful in 1m48s
ci / docs-drift (pull_request) Successful in 4m18s
android / android (pull_request) Successful in 6m0s
ci / rust (pull_request) Successful in 8m49s
ci / rust-arm64 (pull_request) Successful in 13m14s
`ci / rust` failed the unsafe-hygiene gate: mgmt/tests.rs went to 6 process-global-API
mentions against a baseline of 3. The new rename test had copy-pasted the existing
`EnvGuard` + CONFIG_DIR_TEST_LOCK + tempdir dance, which is exactly the duplication gate C
exists to catch — its advice is to fix the call site rather than raise the baseline.

So there is now ONE `ConfigDirOverride` both tests use. It also makes the pairing harder to
get wrong than the copies were: the lock is a FIELD rather than a separate `_serial` binding
a test could forget, and since `Drop::drop` runs before any field drops, the environment is
restored while the guard still holds the lock.

Back to 3 mentions, and `sh scripts/ci/check-unsafe-hygiene.sh` reports all three gates clean.
Note the last one is a PROSE mention: the grep counts comments too (deliberately — "keep it
dumb and stable"), so the doc comment had to stop naming the function it warns about.

Not re-run on .173: the box went off-network mid-change. It does not need to be — this is
`mgmt/tests.rs`, which is not Windows-gated, so Linux CI compiles and runs it. The Windows-only
verification (clippy over the `cfg(target_os = "windows")` devtest change) was already done and
that file is untouched here.
2026-08-22 20:57:05 +02:00
enricobuehler 9bb8d84f12 fix(host): retry the mode-follow encoder reopen instead of ending the session
ci / rust-arm64 (pull_request) Successful in 2m26s
ci / docs-site (pull_request) Successful in 1m17s
ci / bun-nix (pull_request) Successful in 31s
ci / docs-drift (pull_request) Successful in 26s
ci / web (pull_request) Successful in 3m12s
android / android (pull_request) Successful in 9m29s
ci / rust (pull_request) Canceled after 4m46s
The reopen added in the previous commit bailed the session on the FIRST
failed `open_video`. That is worse than what it replaced: the mode-set
that triggers the reopen is exactly the kind of event that leaves the
driver settling, which is the transient the submit path's backoff already
exists for ("NVENC session open failing after a codec switch", 2026-07 —
no 8 ms retry could outlive it).

Spend the shared `encoder_resets` budget on it at the same exponential
pace (100 ms → 1.6 s), re-entering the follow-the-source guard each round.
The old encoder stays installed and mismatched meanwhile, so it simply
keeps failing submit until an open succeeds or the budget runs out — the
same ~3 s ceiling as before, but now every round is a real attempt at the
new mode instead of an in-place re-init that cannot converge.

Also tag the exhausted path accurately: it is an encoder REOPEN failure,
not a submit failure, and the session-end log prints that context.
2026-08-22 20:47:49 +02:00
enricobuehler f42aca690f fix(host): the encoder follows a game-driven display mode change
ci / web (pull_request) Successful in 1m49s
ci / docs-site (pull_request) Successful in 1m12s
ci / rust-arm64 (pull_request) Successful in 2m23s
ci / bun-nix (pull_request) Successful in 21s
ci / docs-drift (pull_request) Successful in 24s
android / android (pull_request) Successful in 6m57s
ci / rust (pull_request) Canceled after 5m5s
A fullscreen game can mode-set the virtual display mid-session with no
client Reconfigure. The IDD-push capturer already handles that — it
re-opens its ring at the new mode on a confirmed descriptor change — but
nothing re-opened the ENCODER, which is the one component that cannot
follow a resolution change in place.

Every submit then failed with "captured frame 1920x1080 != encoder
3840x2160", and the submit-error path only rebuilds the encoder IN PLACE
(Terminate + re-Init at the SAME configured size), which cannot fix a
size the source has already left. All five resets burned on it and the
video session ended ~3 s later, with audio still running — the client
sees a frozen picture and has to reconnect.

Field report 2026-08-22 (host 0.31.2, RX 6800 XT, AMF/HEVC 4K60):

  IDD push: display descriptor changed — recreating the ring at the new
    mode target_id=259 from=3840x2160 hdr=true to=1920x1080 hdr=true
  encoder submit failed — encoder rebuilt in place, forcing an IDR
    error=captured frame 1920x1080 != encoder 3840x2160 reset=1 max=5
  ... reset=5 max=5
  encoder did not recover after repeated in-place rebuilds — ending the
    video session ... resets=6

Track what the encoder was opened against and, when the source delivers
something else, re-open at the delivered size through the same
`open_video` path the client-initiated resize uses — then publish the new
mode to the client exactly as an accepted resize does, so its mode slot,
stats and aspect follow. PyroWave's Automatic rate is re-resolved for the
new mode (it is a per-mode bpp pin); H.26x rates stay with ABR.

Also covers a mid-session frame-format change (an HDR flip re-creates the
ring at a new format), which failed the same way.

The GameStream/Moonlight loop has the identical gap, left alone here: that
protocol has no mid-stream mode-change message, so following the source
there needs its own decision.
2026-08-22 20:35:25 +02:00
enricobuehler 34a02fdac5 Merge pull request 'Steam's pre-launch work was mistaken for the game, dropping the stream mid-launch' (#372) from worktree-steam-prelaunch-latch into main
ci / bun-nix (push) Successful in 51s
ci / web (push) Successful in 1m28s
ci / docs-drift (push) Successful in 32s
ci / docs-site (push) Successful in 1m27s
ci / rust-arm64 (push) Successful in 1m59s
deb / build-publish-gamescope (push) Successful in 1m15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 45s
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 11s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 2m34s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 23s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m15s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
deb / build-publish (push) Successful in 5m40s
deb / build-publish-host (push) Successful in 5m51s
ci / rust (push) Successful in 8m36s
arch / build-publish (push) Successful in 9m41s
android / android (push) Successful in 10m14s
docker / builders-arm64cross (push) Successful in 13s
docker / deploy-docs (push) Successful in 42s
deb / smoke-install (push) Successful in 7m27s
windows-host / package (push) Successful in 17m57s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 42s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m11s
2026-08-22 18:32:30 +00:00
enricobuehler 8670b412c7 fix(host): Steam's pre-launch work was mistaken for the game, dropping the stream mid-launch
ci / bun-nix (pull_request) Successful in 31s
ci / docs-drift (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m17s
ci / web (pull_request) Successful in 1m33s
ci / rust-arm64 (pull_request) Successful in 2m4s
android / android (pull_request) Successful in 6m6s
ci / rust (pull_request) Successful in 7m12s
A player had to launch Rocket League twice: the first launch streamed the
"Processing Vulkan shaders" dialog and then dropped, ten seconds in. The host
did that to itself.

`reaper SteamLaunch AppId=<appid>` is the *appid's* wrapper, not the game's.
Steam wraps its pre-launch work for a title in one too, so a launch is a chain
of appid-tagged trees and only the last is the game. The lease matched the
first tree two seconds in, and that single sighting latched it out of the start
phase (START_GRACE, five minutes, ending nothing) into the exit watch
(EXIT_CONFIRM, three seconds, ending the session). When the tree exited with
the game still starting, the watch called it the game exiting and closed the
connection with APP_EXITED.

Linux has nothing to catch that: `procscan::running_hint` is Windows-only, and
no provider reports runstate for Steam, so an appid scan with three seconds of
slack is the whole signal. (Steam's registry.vdf is not an option — RunningAppID
is no longer set on modern Steam Linux, and the per-app Running key is
unreliable.)

Two layers, because only one of them can be certain:

* The matcher now rejects a `SteamLaunch AppId=` reaper whose payload is
  `fossilize_replay` — Steam's shader replayer, never a game.
* A scan match must be seen continuously for SHIM_WINDOW before it latches.
  This is the rule already applied to a spawned child ("a launcher about to
  hand off looks exactly like the game for its first few seconds"); the scan
  side never had it. It bounds the pre-launch trees nobody has named yet, at
  the cost of a few seconds of GameRunning latency. Exit detection is
  untouched, and a provider report still latches immediately — that is the
  launcher's own statement, not an inference from a lookalike.

The log said `procs=1` and never which process, which is what made this
unclosable from a log alone; `procscan::names` puts that on the line.
2026-08-22 20:12:36 +02:00
enricobuehler 539ac2f2a5 feat(host,web): name a paired Moonlight device, because its certificate never will
android / android (pull_request) Successful in 9m22s
ci / rust (pull_request) Canceled after 0s
ci / rust-arm64 (pull_request) Canceled after 0s
ci / web (pull_request) Canceled after 0s
ci / docs-site (pull_request) Canceled after 0s
ci / bun-nix (pull_request) Canceled after 0s
ci / docs-drift (pull_request) Canceled after 0s
Reported from the field: "is there a possibility of renaming the moonlight paired
devices? as they're all named CN=NVidia Gamestream Client". They are, and it is not a
display bug — every moonlight-common-c client self-signs with that same fixed subject,
so the certificate carries no device identity at all. Until now the console listed that
string for every Moonlight row, which means a user with a phone, a TV and a Switch saw
three identical rows and had nothing but a fingerprint prefix to tell them apart — most
sharply when deciding which one to unpair.

The name is an operator-supplied label, stored host-side keyed by fingerprint:

  * `client-labels.json`, a SIDECAR to `paired.json` rather than a field inside it.
    `paired.json` is a bare `Vec<Vec<u8>>` of DERs, so giving it a shape would be a
    migration on the one file that decides who may connect — and a label is not part of
    that trust decision, so a corrupt or missing label file must never be able to lock
    anyone out. Same atomic temp-file + rename as `save_paired`.
  * `PATCH /api/v1/clients/{fingerprint}` sets or clears it; `GET /clients` grows a
    `label`. A whitespace-only body clears rather than storing a blank name, and only an
    already-paired fingerprint may be named (a label for an unknown one would be
    invisible and never cleaned up). Unpairing forgets the label, so the file cannot grow
    without bound and a re-pairing of the same certificate starts unnamed.
  * Scrubbing reuses `native_pairing::sanitize_device_name` rather than growing a second
    one: it already strips C0/C1 controls and Unicode bidi overrides and caps at 64.
    That is not cosmetic here — the label is the ONLY thing distinguishing two paired
    devices in the console, so an unscrubbed one could dress a stranger's device up as
    the operator's TV and be spared an unpair on that basis. For the same reason the new
    route takes the plugin/cert lanes of the DELETE beside it (neither may reach it),
    not the roster GET's read permission; the lane test now pins that.
  * Console: a pencil on Moonlight rows opens the existing `promptText` dialog seeded
    with the current label (not the `CN=…` fallback, or every rename would start by
    deleting boilerplate). Native rows keep their pairing-supplied name and get no
    pencil.

Test: one round trip through the API — name it, see it in the list, watch the bidi
override and the whitespace collapse get scrubbed, clear it two ways, reject a
malformed and an unpaired fingerprint, and assert the unpair forgot it on disk.

VERIFIED on .173 (the Windows box, since punktfunk-host does not build on macOS):
`cargo test -p punktfunk-host mgmt::` → 58 passed, including the new
`client_label_round_trips_scrubs_and_is_forgotten_on_unpair` and both guardrails that
caught this work in progress (`every_route_is_classified_for_the_plugin_and_cert_lanes`
and `openapi_document_is_complete_and_checked_in`). Web `tsc --noEmit` clean.

Two notes on the diff, both PRE-EXISTING and verified as such rather than assumed:
  * `sdk/src/gen/punktfunk.ts` is bigger than this feature. Regenerating it from the
    UNCHANGED committed spec already produces a ~700-line diff, i.e. the checked-in copy
    had drifted from its own pinned generator — nothing in CI regenerates or verifies
    it. This lands the clean regeneration rather than hand-patching generated code.
  * `api/openapi.json` was regenerated on Windows, not CI's Linux. Checked structurally
    before committing: the only differences are `PATCH /clients/{fingerprint}`, the
    `RenameClient` schema and `PairedClient.label` — no OS-driven drift.

Unrelated and NOT touched: `mgmt::tests::display_monitors_answers_even_with_no_compositor`
fails on Windows, at HEAD as well. It answers `compositor="windows", monitors=[],
error=null`, and the test's escape hatches only cover gamescope, an absent compositor or
an error. Either the test needs a Windows arm or Windows display enumeration is returning
nothing it should — that is a real question, so it is left for someone to answer rather
than papered over here.
2026-08-22 19:31:43 +02:00
enricobuehler f5a75d9edc test(devtest): drive a Windows HID pad through silence and back, to test what a Moonlight client actually does
Chasing "gamepad still dead on GameStream clients after dfcffcdd" (Artemis on
Android, Moonlight on a Switch; both report only mouse/touch working). dfcffcdd
moved this plane from the XUSB companion to the UMDF HID Xbox pad and was verified
by `cargo check` + `clippy` only, so nothing about it had ever run.

The suspicion this flag was built to test: `UhidManager` has a `heartbeat` whose
own doc says a UMDF pad "treats a multi-second input silence as an unplugged
controller", the native plane calls it every tick, and `SessionPads::pump_rumble`
does not. That asymmetry looked decisive because the two planes differ in exactly
the way that would expose it: punktfunk's own client re-sends every live pad's
snapshot every 100 ms unconditionally (`input_task.rs` refresh tick), so a native
pad is never silent, while moonlight-common-c sends a controller packet only on
CHANGE — an untouched pad emits nothing at all.

`--idle-after N` stops the state frames while still pumping; `--resume-after M`
starts them again, because enumeration surviving a silence proves nothing on its
own (a pad can stay listed and deliver no input) — what matters is whether a report
written after the silence still lands.

MEASURED on .173 (Win11 26200), and it does NOT reproduce: with `--xboxhid
--idle-after 12 --seconds 75`, the pad sat through 58 s of total input silence with
`SWD\PUNKTFUNK\PF_XBOX_0` at Status=OK and its promoted `HID\PUNKTFUNK&IG_00` child
still present the whole time. So the heartbeat gap is NOT the field bug, and the
one-line "add a heartbeat to the GameStream arm" fix this was going to justify is
not warranted — which is the point of landing the probe rather than the guess.

Also measured with the same binary, and worth recording because it IS real:
  * two LIVE processes wanting pad index 0 collide exactly as `PadCreateFault::
    IndexOwnedElsewhere` describes (`Global\pfds-boot-0`, ACCESS_DENIED because the
    mailbox DACL is SYSTEM+LocalService). dfcffcdd put BOTH input planes on that one
    name — before it, GameStream used `Global\pfxusb-boot-0` and the two could never
    collide — so the hazard is new, even if it is not what the reporter hit.
  * a clean release-then-retake does NOT collide: back-to-back runs at 0 s, 1 s and
    3 s gaps all created their pad, so an ordinary client reconnect is not the trigger.

Ruled out on the same box while here: the driver package (`pf_gamepad.inf` 08/18
declares all three Xbox hwids and the `xinputhid` promotion), stale drivers in the
field (the Windows updater is a full Inno Setup run that re-runs `driver install
--gamepad`), and access grants (a Moonlight fingerprint has no grants record, which
`control.rs` reads as GRANT_ALL).

Still open, and it needs a live session: .173 runs `PUNKTFUNK_HOST_CMD=serve`, i.e.
GameStream is switched OFF, so this box has never exercised the plane dfcffcdd
changed. That is how a compile-only fix reached users unexercised, and it is the
first thing to change before the next attempt.
2026-08-22 18:58:57 +02:00
enricobuehler 430499bdab ci(flatpak): trigger on the deps-check script too
ci / bun-nix (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m17s
ci / docs-drift (pull_request) Successful in 1m28s
apple / swift (pull_request) Successful in 2m15s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m6s
ci / rust-arm64 (pull_request) Successful in 2m59s
android / android (pull_request) Successful in 5m18s
ci / rust (pull_request) Successful in 7m49s
flatpak-deps-present.sh decides whether the job talks to Flathub at all, and
a push-paths filter that ignores it means a change to that decision ships
untested until the next unrelated client commit happens to rebuild. Same
reason .gitea/workflows/flatpak.yml is already listed.
2026-08-22 03:35:26 +02:00
enricobuehler 92578803c2 fix(ci): stop the flatpak build updating runtimes it already has
The flatpak job died on every attempt with

    Updating runtime/org.freedesktop.Sdk.Extension.rust-stable/x86_64/25.08
    Error: Failed to update org.freedesktop.Sdk.Extension.rust-stable: While
      pulling ... .filez: Server returned HTTP 404

dl.flathub.org was serving a 404 for one object of the then-current
rust-stable//25.08 commit. retry.sh burned all 10 attempts (~9 min) on the
same object, and flatpak-builder segfaulted on its own error path (rc=139),
so the wrapper could not tell a dead end from a load blip either.

Root cause is ours, not Flathub's: `--install-deps-only` does not install
what is missing, it UPDATES what is present. builder_manifest_install_dep()
branches on `flatpak info --show-commit <ref>` succeeding and runs
`flatpak update` for every already-installed dep, with no fallback to a
plain install when that update fails. ci/flatpak-ci.Dockerfile bakes the
entire runtime set, so that update was a pure no-op on a healthy run while
making every build depend on Flathub's health at that minute. Nothing wanted
the newer commit — the manifest pins a runtime VERSION, not a commit.

So ask first, and reach for Flathub only on a real miss. The check is
scripts/ci/flatpak-deps-present.sh (runtime + SDK at the manifest's exact
runtime-version, sdk-extensions by presence, since their version comes from
the SDK's metadata and any bump that moves them moves runtime-version too).
It fails OPEN: anything it cannot parse takes the full install path. Its
--self-test stubs `flatpak` and covers baked / cold / each dep missing /
wrong version / unreadable manifest.

Also drop --install-deps-from=flathub from the build step. Its comment
called it "a no-op safety net"; builder-main.c calls
builder_manifest_install_deps() whenever that flag is set, and
--install-deps-only only decides whether it exits afterwards, so the step
billed as offline was re-running the same update — and could only ever fire
if the prefetch step had already failed the job.

packaging/flatpak/build-flatpak.sh keeps --install-deps-from: a dev box
genuinely wants deps installed, and it has no baked image.
2026-08-22 03:34:51 +02:00
enricobuehler ca2ff7093a Merge pull request '0.31.2 — the address the host used, from three directions' (#369) from worktree-release-0312-prep into main
audit / docs-site-audit (push) Successful in 23s
audit / bun-audit (web) (push) Successful in 24s
audit / bun-audit (sdk) (push) Successful in 26s
audit / pnpm-audit (push) Successful in 19s
audit / bun-audit (plugin-kit) (push) Successful in 26s
audit / cargo-audit (push) Failing after 40s
ci / web (push) Successful in 1m21s
ci / docs-site (push) Successful in 1m13s
ci / bun-nix (push) Successful in 16s
ci / docs-drift (push) Successful in 21s
audit / license-gate (push) Successful in 4m57s
audit / miri (push) Successful in 6m17s
audit / c-abi-asan (push) Successful in 6m24s
android-screenshots / screenshots (push) Successful in 1m54s
ci / rust-arm64 (push) Successful in 1m38s
ci / rust (push) Successful in 8m29s
android / android (push) Successful in 11m48s
arch / build-publish (push) Successful in 9m23s
linux-client-screenshots / screenshots (push) Successful in 4m53s
sbom / sbom (push) Successful in 33s
web-screenshots / screenshots (push) Successful in 6m46s
decky / build-publish (push) Successful in 48s
docker / builders-arm64cross (push) Successful in 10s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 20s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 15s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m31s
docker / deploy-docs (push) Successful in 17s
nix / flake (push) Successful in 22m13s
deb / smoke-install (push) Successful in 2m52s
deb / build-publish (push) Successful in 5m12s
deb / build-publish-host (push) Successful in 5m39s
deb / build-publish-gamescope (push) Successful in 1m5s
deb / build-publish-client-arm64 (push) Successful in 2m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m49s
apple / distribute (push) Successful in 13m6s
apple / swift (push) Successful in 2m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m22s
apple / screenshots (push) Successful in 9m40s
windows-host / package (push) Successful in 13m30s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Successful in 28s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 5m57s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 9m45s
flatpak / build-publish (push) Failing after 8m56s
2026-08-21 20:50:22 +00:00
enricobuehler a2dc011200 release: 0.31.2 — version bump, notes, CHANGELOG, Play notes
apple / swift (pull_request) Successful in 2m17s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m59s
android / android (pull_request) Successful in 6m30s
nix / flake (pull_request) Successful in 6m38s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 8m5s
ci / docs-drift (pull_request) Successful in 29s
ci / bun-nix (pull_request) Successful in 32s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m9s
ci / rust-arm64 (pull_request) Successful in 1m25s
ci / rust (pull_request) Successful in 16m10s
10 commits since v0.31.1 (6 non-merge). Cut from origin/main 48eeae75 (#368
merged).

THE NUMBER: a patch, and unlike the last cut the version table does not even
have to argue for it. Nothing versioned moved — WIRE_VERSION 2, C ABI 25 with
include/punktfunk_core.h showing NO diff at all against the v0.31.1 tag (not
even a #define, unlike the last two releases), driver protocol 6 / min 3 with
pf-driver-proto unchanged, gamepad channel 3, plugin index schema 1, host event
schema 1, gamescope +pfhdr8 with no new patch files, SDK 0.1.5 and plugin-kit
0.4.4 both untouched. No `!` commit, no feat, no route added or removed, no
breaking change of any kind. Every non-merge commit is fix/refactor/test.

The cycle has a shape: three of the six non-merge commits are the same class of
fault — the host using the wrong local address — reached from three directions.
The data socket bound 0.0.0.0:0 and let routing pick the video source, which the
client's connected socket then dropped in-kernel (#367). Host::detect() froze the
advertised address at process start, so a cold boot that beat the network pinned
127.0.0.1 for the life of the process and broke both mDNS adverts, the Moonlight
session URL, the WoL mac record and HostInfo together (#366). And the firewall
rules guarding the ports those addresses point at admitted any program on the
machine (#368). The fourth is an Android regression from v0.31.1 (#365); the
remaining two are the refactor and test supporting #366.

api/openapi.json changes in DOCUMENTATION ONLY this time — two description
strings on HostInfo, no route, schema, required field or type — plus the stamp.
Re-stamped here, not regenerated: punktfunk-host does not build on macOS, and
#366 regenerated the document itself on a runner where
openapi_document_is_complete_and_checked_in actually executes. "0.31.1" appears
nowhere in either copy afterwards, and the two copies are byte-identical.

That description change is load-bearing rather than cosmetic, so it is called out
as a behaviour change in the CHANGELOG beside the firewall one: HostInfo.local_ip
was a field snapshotted at detect() and is now a method that re-reads per
request, so a consumer that cached it at startup was caching a value that could
be 127.0.0.1 forever.

The other behaviour change is the externally visible one: Windows service install
now scopes all five fixed-port rules to the listening executable while keeping
their localport=, so 5353 is punktfunk's alone and anything else on the machine
that was reachable on mDNS through our any-program rule needs its own. Fallbacks
are asymmetric on purpose — a fixed-port rule that cannot resolve its exe falls
back to the old wide form (a looser rule still streams), while the data-plane
rule skips (it has no port to fall back to, so a program-less version would not
be looser, it would be open).

Also in this commit, because a cut is when docs freshness bites:
docs-site/content/docs/ports.mdx. Its "Video needs nothing opened" bullet has
been wrong for Windows since v0.31.1 added the data-plane rule — it now says so
and names why (no fixed rule can cover a per-session ephemeral port). And the
Windows line gains a Callout for the 5353 change above, since that is the one
thing on this page a reader may have to act on. Callout shape copied from the
proven usage in plugins.mdx (no `title` prop — node_modules is not installed here
and fumadocs' prop surface could not be verified offline).

Play notes are Android-only per whatsnew/TEMPLATE.txt, which this cycle means the
#365 regression alone. The three host-side fixes are deliberately NOT in there:
updating the app does not fix any of them, so listing them on the store page
would promise something the download does not deliver.

Gates: cargo fmt --all --check clean; cargo metadata --offline ok with the
Cargo.lock diff versions-only (36/36); cargo test -p punktfunk-core --lib 273
passed; the C ABI harness PASSED reporting abi_version=25 (needed `brew install
opus` on this Mac to link — the first run failed on the missing library, not on
the code); cbindgen regenerated include/punktfunk_core.h during that build and it
came out byte-identical to the checked-in file AND to the v0.31.1 tag, which is a
stronger check on the ABI row than diffing it; scripts/ci/check-docs-drift.sh
clean; scripts/ci/check-docs-links.sh clean; the android.yml Play notes gate run
verbatim, 357/500 characters and unique; both openapi copies cmp identical and
stamped 0.31.2; notes voice scan clean (one backticked term in the whole file,
the `punktfunk-host service install` command, and the only technical vocabulary
sits inside `## For developers`).

Not run here, and why: clippy and any punktfunk-host build (does not compile on
macOS — CI covers it), and the Android unit tests (:kit: and :app: were run on
#365 itself; nothing in this commit touches Kotlin).

One judgement call left for the tag: SECURITY.md promises to credit a reporter in
the release notes when the fix is public, and the #368 commit records only "a
user on 2026-08-21" with no name. The notes credit them unnamed. If they want
their name on it, that is a one-line edit to docs/releases/v0.31.2.md before the
tag is pushed.
2026-08-21 20:04:23 +02:00
enricobuehler 48eeae7527 Merge pull request 'The fixed-port firewall rules were open to every program on the machine' (#368) from worktree-firewall-program-scoped-rules into main
ci / bun-nix (push) Successful in 26s
ci / docs-drift (push) Successful in 21s
ci / rust-arm64 (push) Successful in 1m28s
ci / web (push) Successful in 1m41s
ci / docs-site (push) Successful in 1m55s
deb / build-publish-gamescope (push) Successful in 1m39s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 46s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
deb / build-publish-client-arm64 (push) Successful in 2m34s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
deb / build-publish (push) Successful in 4m3s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 19s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 27s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 21s
deb / build-publish-host (push) Successful in 4m28s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 47s
docker / builders-arm64cross (push) Successful in 8s
arch / build-publish (push) Successful in 7m26s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m58s
ci / rust (push) Successful in 8m7s
docker / deploy-docs (push) Successful in 37s
deb / smoke-install (push) Successful in 2m35s
android / android (push) Successful in 10m56s
windows-host / package (push) Successful in 13m48s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m39s
2026-08-21 14:14:10 +00:00
enricobuehler 0df4ca957f fix(host,windows): the fixed-port firewall rules were open to every program on the machine
ci / docs-drift (pull_request) Successful in 29s
ci / bun-nix (pull_request) Successful in 1m19s
ci / web (pull_request) Successful in 1m27s
ci / docs-site (pull_request) Successful in 1m26s
ci / rust-arm64 (pull_request) Successful in 1m40s
ci / rust (pull_request) Successful in 5m38s
android / android (pull_request) Successful in 6m1s
`service install` added `dir=in action=allow` rules carrying only `localport=`,
which admit ANY process on the machine on those ports — GameStream
(47984/47989/47998-48010/48010), the native plane (9777), mgmt (47990), mDNS
(5353), and the console pair (47992/47993). Binding a high port on Windows
needs no elevation, so an unprivileged program could take any of them and be
reachable from the LAN simply by binding first — silently, because our rule is
precisely what suppresses the "Allow this app to communicate on…" prompt that
would otherwise be the only way in.

Scope every rule to the executable that actually listens on it, keeping the
ports: program AND port is strictly tighter than either alone. The host rules
name the host exe (resolved once and shared with the data-plane rule, which
already worked this way and is the pattern the rest now follow); the console
rules name the bundled `<app>/bun/bun.exe` the supervisor spawns.

The old argument for leaving them unscoped — "an install whose recorded exe
path later moves still has its fixed ports open" — does not hold: `service
install` re-runs this whole remove-then-add on every upgrade, so the path is
refreshed rather than left stale.

Fallbacks are deliberate and asymmetric. A fixed-port rule whose program can't
be resolved falls back to the old any-program form, because a looser rule still
streams and no rule is a black screen. The data-plane rule instead skips: it
has no `localport=` to fall back to, so a program-less version of it would not
be a looser rule but an open host.

One externally visible change, called out in installer output: 5353 is ours
alone now, so anything else on the machine that answered mDNS through
punktfunk's rule needs its own.

Reported by a user on 2026-08-21, after the source-IP fix in #367 resolved
their black screen.
2026-08-21 16:05:00 +02:00
enricobuehler 13aa11355e Merge pull request 'Video egressed from whichever interface routing picked, not the one the client dialed' (#367) from worktree-blackscreen-data-plane-source-ip into main
ci / bun-nix (push) Successful in 30s
ci / web (push) Successful in 1m2s
ci / docs-drift (push) Successful in 32s
ci / docs-site (push) Successful in 1m13s
ci / rust-arm64 (push) Successful in 1m28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
deb / build-publish-gamescope (push) Successful in 49s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 20s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
deb / build-publish-client-arm64 (push) Successful in 1m11s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 12s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m14s
docker / builders-arm64cross (push) Successful in 13s
docker / deploy-docs (push) Successful in 36s
deb / build-publish-host (push) Successful in 7m0s
ci / rust (push) Successful in 7m59s
arch / build-publish (push) Successful in 10m16s
android / android (push) Successful in 12m19s
windows-host / package (push) Successful in 13m16s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 26s
deb / build-publish (push) Failing after 13m45s
deb / smoke-install (push) Successful in 2m21s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m21s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m25s
2026-08-21 12:31:44 +00:00
enricobuehler e989d7457f fix(host): video egressed from whichever interface routing picked, not the one the client dialed
ci / web (pull_request) Successful in 1m29s
ci / rust-arm64 (pull_request) Successful in 1m48s
ci / bun-nix (pull_request) Successful in 39s
ci / docs-drift (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m41s
android / android (pull_request) Successful in 6m26s
ci / rust (pull_request) Successful in 15m39s
`bind_data_socket` bound `0.0.0.0:0`, so the kernel chose the video source
address from the routing table, independently of the address the client's
control connection actually arrived on. The client's data socket is
`connect`ed to the host IP it dialed, so its kernel drops every datagram from
any other source — before userspace, where nothing counts it.

On a host with two live paths to the client (Ethernet and Wi-Fi both up on the
same LAN; a VPN/overlay adapter claiming the route) that is a permanent black
screen with every gauge green: the hole-punch still arrives so the host logs
`punched=true`, `loss_ppm` stays 0 because there are no packets to see gaps
in, and QUIC — which quinn pins to the right local address — carries control,
audio and input perfectly. `from_socket_punch` already documents the mirror of
this assumption for the *client's* source IP; the host side was never checked.

Bind the data socket to `Connection::local_ip()` instead (unmapping an
IPv4-mapped v6 address so the socket can still `connect` to a v4 peer), and
fall back to the wildcard, loudly, when it is unavailable.

Two diagnostics, because this session's log could not answer the question:
- the `data plane bound` line now carries the socket's post-`connect` `local=`
  address — the source the kernel will actually stamp — and WARNs when it
  differs from the address the control plane arrived on.
- the black-screen ERROR no longer asserts "This is a PATH problem, not
  decode" and no longer names `punched=false` as *the* fingerprint. It fired
  with `punched=true` in the field, which contradicts its own advice and sent
  an investigation at the firewall. It now branches on what the bring-up line
  says, and admits its counter is incremented after decrypt and replay checks,
  so a session whose every datagram failed to open reports the same zero.
2026-08-21 14:06:48 +02:00
enricobuehler b05bb1dd48 A cold-booted host advertised 127.0.0.1 and never recovered (#366)
ci / docs-drift (push) Successful in 38s
ci / bun-nix (push) Successful in 1m14s
ci / web (push) Successful in 1m25s
ci / docs-site (push) Successful in 1m34s
ci / rust-arm64 (push) Successful in 2m2s
deb / build-publish-gamescope (push) Successful in 45s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 25s
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 14s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 13s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
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 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m17s
deb / build-publish-client-arm64 (push) Successful in 5m40s
ci / rust (push) Successful in 8m16s
arch / build-publish (push) Successful in 10m47s
android / android (push) Successful in 10m59s
deb / build-publish (push) Successful in 10m26s
deb / build-publish-host (push) Successful in 11m17s
docker / builders-arm64cross (push) Successful in 16s
docker / deploy-docs (push) Successful in 1m11s
deb / smoke-install (push) Successful in 3m54s
windows-host / package (push) Successful in 17m7s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 20m43s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 20m6s
`Host::detect()` snapshotted the LAN address once, at process start, and every
consumer read that frozen field for the life of the process. On a cold boot the
host outruns the network — the Windows service is `AutoStart` with no
dependencies — so the route probe failed with ENETUNREACH and the loopback
fallback stuck until someone restarted the host by hand.

Four surfaces broke off that one field: both mDNS adverts published 127.0.0.1 as
their A record, `session_url_xml()` handed Moonlight `rtsp://127.0.0.1:48010`,
`wol::wake_macs()` dropped the `mac` TXT record and silently disabled
Wake-on-LAN, and `HostInfo.local_ip` reported loopback to the console.

Fixed at the choke point: `primary_local_ip()` never returns loopback and falls
back to the first non-loopback interface address when no default route exists
yet; `Host::local_ip` re-reads instead of freezing; and a live mDNS advert
re-registers when the routed address changes. Also covers a changed DHCP lease
and a host moved between Wi-Fi and Ethernet.
2026-08-21 12:04:15 +00:00
enricobuehler 2898f6b049 test(host): cover the interface fallback the boot race actually takes
ci / bun-nix (pull_request) Successful in 42s
ci / docs-drift (pull_request) Successful in 43s
ci / web (pull_request) Successful in 1m1s
ci / docs-site (pull_request) Successful in 1m5s
ci / rust-arm64 (pull_request) Successful in 2m48s
ci / rust (pull_request) Successful in 5m30s
android / android (pull_request) Successful in 6m48s
The route probe needs a default route, which on a cold boot lands after the NIC
has its address; the fallback is what answers in between, and nothing exercised
it. Split it into `first_lan_ipv4` so a test can assert the one thing that
matters: it never hands back the loopback `get_if_addrs` also reports.
2026-08-21 13:34:44 +02:00
enricobuehler 4eb4e3465b refactor(host): end the mDNS re-announce loop with a channel, not a flag
std's mpsc doubles as the sleep and the stop signal: the loop times out every
IP_RECHECK to re-check the address, and the Advert dropping its sender wakes the
thread immediately instead of leaving it to notice a flag up to 10s later. Drops
the Arc<AtomicBool> and the Drop impl.
2026-08-21 13:32:08 +02:00
enricobuehler 44cd5bfd81 Merge pull request 'The button correction fired on pads that never needed it' (#365) from worktree-android-pad-mapping-regression into main
ci / bun-nix (push) Successful in 31s
ci / docs-drift (push) Successful in 44s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
ci / web (push) Successful in 1m23s
ci / docs-site (push) Successful in 1m34s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m27s
ci / rust-arm64 (push) Successful in 2m31s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 59s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m18s
docker / builders-arm64cross (push) Successful in 39s
docker / deploy-docs (push) Successful in 1m5s
ci / rust (push) Successful in 7m35s
android / android (push) Successful in 8m29s
2026-08-21 11:24:03 +00:00
enricobuehler 8977228a4b fix(host): a cold-booted host advertised 127.0.0.1 and never recovered
`Host::detect()` snapshotted the LAN address once, at process start, and every
consumer read that frozen field forever. On a cold boot the host wins the race
against the network — the Windows service is registered `AutoStart` with no
dependencies — so `primary_local_ip()`'s route probe to 8.8.8.8 failed with
ENETUNREACH and the loopback fallback stuck for the life of the process.
Restarting the host re-ran `detect()` on a live network, which is the workaround
users found.

Four surfaces broke together, all off that one field:

  * both mDNS adverts (`_punktfunk._udp`, `_nvstream._tcp`) published `127.0.0.1`
    as their A record — the address a client lists and dials;
  * `session_url_xml()` handed Moonlight `rtsp://127.0.0.1:48010` after /launch,
    so even a manually-added host could not stream;
  * `wol::wake_macs()` found no interface for loopback and dropped the `mac` TXT
    record, silently disabling Wake-on-LAN;
  * `HostInfo.local_ip` reported loopback to the web console.

Fixed at the choke point rather than per-caller:

  * `primary_local_ip()` never returns loopback or the unspecified address. When
    the route probe fails it falls back to the first non-loopback interface
    address, which exists as soon as the NIC is configured even if the default
    route is not installed yet — the common shape of the boot race.
  * `Host::local_ip` becomes a method that re-reads instead of a field that
    freezes. A `connect(2)` on an unconnected UDP socket sends no packets and
    costs nothing beside the HTTP response it is serialized into.
  * mDNS records are pushed, not polled, so a live advert re-registers when the
    routed address changes (`discovery::advertise_live`, shared by both service
    types). It polls the routed address rather than subscribing to the daemon's
    IpAdd events because the boot race usually resolves without one: the NIC
    often has its address before we register and only the route lands late.

This also covers the sibling cases that were never reported — DHCP handing out a
different lease, and a host moved between Wi-Fi and Ethernet.
2026-08-21 13:21:52 +02:00
enricobuehler 6a82a602a1 fix(clients/android): the button correction fired on pads that never needed it
ci / docs-drift (pull_request) Successful in 50s
ci / web (pull_request) Successful in 1m12s
ci / bun-nix (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 2m12s
ci / rust (pull_request) Successful in 5m27s
android / android (pull_request) Successful in 6m41s
Two field reports (2026-08-21), one shape: a GameSir G8+ and an Xbox Elite
Series 2 ("Xbox Wireless Controller" over Bluetooth) with X answering Y, Y
answering LB, and the two shoulders answering menu buttons — everything else
correct. That is not a stray mapping, it is exactly what `GENERIC_XBOX` does to
scancodes `0x133`/`0x134`/`0x136`/`0x137`, so the correction added yesterday was
firing on pads whose buttons were already where `Generic.kl` says they are.

It fired because it asked the wrong question. `hasKeys(BUTTON_C, BUTTON_Z)`
answers for what a device DECLARES, not what it reports: `hid-input` allocates
`BTN_A + n` straight through for every button in the descriptor, so BTN_C
(`0x132`) and BTN_Z (`0x135`) are set on ANY pad declaring six or more —
including a standard-layout pad that never presses either. The signal is
therefore identical on the pad that needs correcting and the pad that does not,
and no amount of tightening it could have separated them. It is the same pad
model in both reports: an Elite Series 2 needed the correction on a Fire TV and
another Elite Series 2 was broken by it here.

What does separate them is the axes. A HID gamepad describes its triggers either
as the Accelerator/Brake usages — which become `ABS_GAS`/`ABS_BRAKE`, names
Android has words for — or as two more generic axes on `ABS_Z`/`ABS_RZ`, which
it does not. A descriptor well-formed enough to name its triggers puts its
buttons at the standard positions too. It is also the firmware line on the pad in
the report: an Xbox Wireless Controller over Bluetooth reports GAS/BRAKE after
its firmware update and Z/Rz before it, and only the older one was ever wrong.

`padButtons` now takes `namedTriggers` and answers NATIVE whenever it is set —
no correction of any kind, on buttons or axes, for a pad Android already reads.
`padMap` computed that fact one line below and only ever spent it on the axes;
it now decides both. `hasKeys` stays for the narrower question it can answer —
WHICH straight-through order, once the axes have established there is one — where
a false positive costs nothing.

Moonlight decides it on the same fact (`ControllerHandler`, `gasRange == null`
beside the `"Xbox Wireless Controller"` name); yesterday's commit cited its
tables and then replaced its discriminator, which is where this came in.

Verified: `:kit:testDebugUnitTest` and `:app:testDebugUnitTest` green (16 cases
in PadButtonsTest, 3 new: the gate holds for every vendor/declaration
combination, the four reported buttons stay themselves, and the report-order
choice past the gate is unchanged), `:app:compileDebugKotlin` clean. The
DualSense report filed alongside these — Triangle dead in the client UI and in
the stream — is NOT explained by this and is not fixed here: a button that
reaches neither is one `buttonBit` maps to nothing, which no branch of the
correction produces for Triangle. The Controllers screen prints the raw scancode
and keycode of every press; that line off the reporter's pad will pin it.
2026-08-21 13:10:23 +02:00
enricobuehler fc3b2d0328 Merge pull request '0.31.1 notes: fold in #362, which is what makes the console's off switch work' (#364) from worktree-release-0311-foldin into main
ci / docs-site (push) Successful in 1m7s
ci / bun-nix (push) Successful in 18s
apple / swift (push) Successful in 2m9s
ci / docs-drift (push) Successful in 19s
android-screenshots / screenshots (push) Successful in 1m55s
ci / web (push) Successful in 8m14s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m27s
ci / rust-arm64 (push) Successful in 10m14s
deb / build-publish-gamescope (push) Successful in 1m2s
deb / build-publish (push) Successful in 4m33s
decky / build-publish (push) Successful in 42s
deb / build-publish-client-arm64 (push) Successful in 2m33s
ci / rust (push) Successful in 14m38s
arch / build-publish (push) Successful in 9m40s
apple / distribute (push) Successful in 13m35s
sbom / sbom (push) Successful in 25s
android / android (push) Successful in 11m19s
deb / build-publish-host (push) Successful in 9m2s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m42s
docker / builders-arm64cross (push) Successful in 13s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
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 9s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 9s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m22s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 35s
docker / deploy-docs (push) Successful in 20s
linux-client-screenshots / screenshots (push) Successful in 5m2s
deb / smoke-install (push) Successful in 2m40s
flatpak / build-publish (push) Successful in 6m39s
web-screenshots / screenshots (push) Successful in 6m25s
apple / screenshots (push) Successful in 9m53s
windows-host / package (push) Successful in 14m5s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Successful in 25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m38s
2026-08-20 20:37:56 +00:00
enricobuehler 1c1fd7d9bc release: 0.31.1 — fold in #362, which makes the console's off switch work
ci / bun-nix (pull_request) Successful in 30s
ci / docs-drift (pull_request) Successful in 28s
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m12s
ci / rust-arm64 (pull_request) Successful in 1m21s
ci / rust (pull_request) Successful in 14m33s
30 commits since v0.31.0 (19 non-merge). #362 merged onto main seconds
after the release merge (#363), so the notes as tagged would have been
wrong in the one way that matters: they lead `## New` with the console's
"Controller-optimized UI" switch, and cbd3d028 is the commit that makes
that switch do anything at all.

trust::Settings::extra is #[serde(flatten)], so the android.* keys are
TOP-LEVEL keys of the settings document. ConsoleJson wrote and read them
nested under an "extra" object, which serde filed under the literal key
"extra" — so no console row ever found android.gamepad_ui_enabled. Every
Android-only row rode the same dead path in 0.31.0: low latency, phone
rumble/gyro, SC2 and DualSense capture, and the console-UI mode picker.
That is a user-facing fix in its own right and is now in `## Fixed`, in
`## TL;DR`, and in the Play notes — it is the most useful 500 characters
an Android user gets from this release.

Also folded in: c9a76287, the focus halo and panel_highlight taking the
card's corner plus their own outset (a rect grown by d only stays
concentric if its radius grows by d too).

NOT folded in — deliberately: cargo audit is red on main with
RUSTSEC-2026-0258 (h2 0.4.15, unbounded empty DATA frames, fixed in
0.4.16, transitive via hyper). It predates this cut — the same job failed
on 669a1bc0 and on the v0.31.0 tag commit — so it is not a regression
here. `cargo update -p h2 --precise 0.4.16` bumps h2 in eleven lock lines
but ALSO rewrites several windows-sys references downward (0.61.2 ->
0.59.0/0.52.0) on the pinned 1.96.0 toolchain. Re-resolving the graph for
the Windows build inside a version bump that cannot be compiled for
Windows on this host is the wrong trade; it wants its own commit and its
own CI. Recorded in the CHANGELOG's verification section.

Cargo.lock is untouched by this commit.

Gates re-run: cargo fmt --all --check clean; scripts/ci/check-docs-drift.sh
clean; cargo test -p punktfunk-core --lib 273 passed; the Play notes gate
run verbatim, 440/500 characters and unique; both openapi copies cmp
identical and stamped 0.31.1; notes voice scan clean.
2026-08-20 22:37:01 +02:00
enricobuehler 1c60e641b3 Merge pull request 'The console's own settings never left the console, and the layer under a card missed its corners' (#362) from worktree-android-console-extra-keys into main
ci / rust-arm64 (push) Successful in 2m1s
ci / docs-site (push) Successful in 1m10s
ci / bun-nix (push) Successful in 21s
ci / web (push) Successful in 2m19s
ci / docs-drift (push) Successful in 19s
android / android (push) Successful in 11m5s
arch / build-publish (push) Successful in 11m27s
deb / build-publish-host (push) Failing after 6m8s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
deb / build-publish-gamescope (push) Successful in 3m51s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
deb / build-publish-client-arm64 (push) Successful in 1m56s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
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 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m16s
deb / build-publish (push) Successful in 9m24s
docker / builders-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 34s
flatpak / build-publish (push) Successful in 4m55s
deb / smoke-install (push) Successful in 5m3s
ci / rust (push) Successful in 22m4s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m56s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m37s
2026-08-20 20:03:42 +00:00
enricobuehler eda4b7ebd2 Merge pull request '0.31.1 — the video data port, the pad games could not see, and the buttons Android guessed wrong' (#363) from worktree-release-0311-prep into main
audit / cargo-audit (push) Failing after 2m1s
apple / swift (push) Successful in 2m5s
android / android (push) Canceled after 3m20s
audit / bun-audit (sdk) (push) Successful in 32s
arch / build-publish (push) Canceled after 3m1s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
ci / docs-drift (push) Canceled after 0s
audit / pnpm-audit (push) Successful in 21s
audit / docs-site-audit (push) Successful in 25s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 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 0s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 2m16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 47s
audit / bun-audit (plugin-kit) (push) Successful in 15s
audit / license-gate (push) Successful in 6m31s
audit / miri (push) Successful in 6m13s
audit / c-abi-asan (push) Successful in 7m41s
apple / distribute (push) Successful in 11m54s
audit / bun-audit (web) (push) Failing after 11m38s
windows-host / package (push) Successful in 14m11s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 38s
apple / screenshots (push) Successful in 11m12s
nix / flake (push) Successful in 26m2s
2026-08-20 20:00:14 +00:00
enricobuehler 730ac43169 release: 0.31.1 — version bump, notes, CHANGELOG, Play notes
ci / bun-nix (pull_request) Successful in 30s
ci / docs-drift (pull_request) Successful in 27s
apple / swift (pull_request) Failing after 1m40s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m39s
ci / docs-site (pull_request) Successful in 1m39s
ci / rust-arm64 (pull_request) Successful in 5m33s
android / android (pull_request) Successful in 7m19s
nix / flake (pull_request) Successful in 8m20s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m6s
ci / rust (pull_request) Successful in 25m12s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m47s
27 commits since v0.31.0 (17 non-merge). Cut from origin/main 2be444b3
(#361 merged).

THE NUMBER: a patch, on the user's call, and the version table does not
argue with it. Nothing versioned moved — WIRE_VERSION 2, C ABI 25 with
include/punktfunk_core.h differing from the v0.31.0 tag by one `#define`,
driver protocol 6 / min 3 (pf-driver-proto has no diff against the tag),
gamepad channel 3, plugin index schema 1, host event schema 1, gamescope
+pfhdr8 with no new patch files, SDK 0.1.5 untouched. No `!` commit and
no breaking change of any kind.

Two surfaces grow, both by pure addition, and neither forces the minor:
the management API gains PUT /library/provider/{provider}/running (#361,
already regenerated into both openapi copies there — this commit moves
only the stamp), and plugin-kit goes 0.4.3 -> 0.4.4 to export
ProviderClient.reportRunning from the package root. plugin-kit-v0.4.4 is
tagged AND published — it is the registry's `latest`; 0.4.2 is still
skipped there. A plugin that adopts neither is unaffected, and a 404 from
an older host means "this host tracks games by scanning".

The cycle is otherwise fix-shaped: fifteen fixes and two small features
— the console's own switch for turning itself off (which exists because
0.31.0 made the console the only interface Android shows with a pad
attached) and the provider running-report above.

The new control message (DeliveryReport, type byte 0x0B) is a `#define`,
not an ABI step — the same shape PipelineGap (0x0A) had at v0.30.0, and
it takes a free byte in its block rather than lengthening LossReport,
which is length-checked exactly.

Two behaviour changes are called out in the CHANGELOG rather than left
to be discovered: Windows `service install` now provisions a
program-scoped inbound UDP rule for the host exe (the black-picture
fix — no `localport=` rule can ever cover an ephemeral data port), and
the GameStream plane's default virtual-Xbox backend flips from the XUSB
companion to the UMDF HID pad, which is the correction of a default that
was only ever a module-name coincidence. Both revert with
PUNKTFUNK_XBOX_BACKEND=xusb / by removing the rule.

Also in this commit, because a cut is when it bites:
packaging/gamescope/PKGBUILD moves pfhdr7 -> pfhdr8. The banner has said
+pfhdr8 since patch 0010 and the host probes it for >= 8, but pacman
compares pkgver-pkgrel, read 3.16.25.pfhdr7-1 on both v0.30.0 and
v0.31.0, and offered Arch/SteamOS hosts no upgrade at all — so the
keyboard-layout fix never reached them. deb and rpm derive the version
from the binary banner and moved by themselves; Arch is the only channel
that hardcodes it. The v0.31.0 table flagged this as pre-existing.

api/openapi.json is RE-STAMPED here, not regenerated: punktfunk-host
does not build on macOS. #361 regenerated the document itself, with the
new route and its three schemas, on a runner where
openapi_document_is_complete_and_checked_in actually executes. This
commit moves one line (info.version, which utoipa fills from
CARGO_PKG_VERSION), and "0.31.0" appears nowhere else in either copy.

Gates: cargo fmt --all --check clean; cargo metadata --offline ok with
the Cargo.lock diff versions-only (36/36); cargo test -p punktfunk-core
--lib 273 passed; the C ABI harness passed reporting abi_version=25 —
it did NOT run on the v0.31.0 cut, so this is the first cut since ABI 25
where a C compiler has built the generated header;
scripts/ci/check-docs-drift.sh clean; the android.yml Play notes gate
run verbatim, 442/500 characters and unique; both openapi copies cmp
identical and stamped 0.31.1; notes voice scan clean.
2026-08-20 21:58:39 +02:00
117 changed files with 8210 additions and 643 deletions
+24 -1
View File
@@ -171,6 +171,13 @@ jobs:
- name: Rust Android targets (no-op unless the toolchain pin outran the image)
run: rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
# Must precede every cargo step below: skia-bindings' ~19 MB prebuilt download runs inside
# a build script with no retry, and a truncated transfer here does not surface as a network
# error — it silently becomes a from-source Skia build that dies in the container. See the
# script for the measured failure.
- name: curl with retries (skia-bindings' prebuilt fetch has none)
run: sh scripts/ci/install-retrying-curl.sh
# Same key namespace as ci.yml/deb.yml ON PURPOSE: identical Cargo.lock, identical
# CARGO_HOME layout (/usr/local/cargo), so the registry/git downloads dedupe with
# the rest of the fleet in the central cache. target/ is deliberately NOT cached
@@ -209,9 +216,25 @@ jobs:
# The task lints arm64-v8a AND armeabi-v7a, and reuses the build task's exact cargo-ndk
# environment — see the long note on `registerCargoNdkClippy` in kit/build.gradle.kts for why
# both pointer widths are load-bearing and why the environment must not be duplicated here.
# The `STARTING A FULL BUILD` check turns the manual rule in this workflow's `env:` block
# ("Every ABI's log must show DOWNLOAD AND INSTALL SUCCEEDED") into something that fails the
# job by itself. Without it a missed prebuilt reads as a Gradle stack trace with the real
# cause ~1,800 lines up — which is exactly how 2026-08-22 spent a week looking like a lint
# failure. This is the first cargo step in the job, so it catches the drop earliest.
#
# No pipefail: the runner is dash. Capture, then decide.
- name: Clippy (Android target, deny warnings)
working-directory: clients/android
run: ./gradlew :kit:cargoNdkClippy --stacktrace
run: |
set -e
rc=0
./gradlew :kit:cargoNdkClippy --stacktrace > /tmp/android-clippy.log 2>&1 || rc=$?
cat /tmp/android-clippy.log
if grep -q "STARTING A FULL BUILD" /tmp/android-clippy.log; then
echo "::error::skia-bindings did not get its prebuilt archive and started building Skia from source — the download was dropped (see DOWNLOAD AND INSTALL FAILED above). This is a fetch failure, not a lint failure."
exit 1
fi
exit $rc
# The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were
# running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app
+17 -3
View File
@@ -107,6 +107,12 @@ jobs:
# registry/git are download caches, target/ the incremental build. The target key
# carries the rustc version — resolved via `rustc --version` (below) rather than parsed
# from rust-toolchain.toml, so a pin bump there invalidates stale incremental state too.
# `pf-console-ui` pulls skia-safe, so a target-cache miss makes this job download a prebuilt
# Skia from the same no-retry build-script fetch that took the android job out on
# 2026-08-22, over the same load-shedding runner network. Cheap insurance; see the script.
- name: curl with retries (skia-bindings' prebuilt fetch has none)
run: sh scripts/ci/install-retrying-curl.sh
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
@@ -270,6 +276,12 @@ jobs:
- name: sccache (no-op once the image bakes it)
run: sh scripts/ci/ensure-sccache.sh
# `pf-console-ui` pulls skia-safe, so a target-cache miss makes this job download a prebuilt
# Skia from the same no-retry build-script fetch that took the android job out on
# 2026-08-22, over the same load-shedding runner network. Cheap insurance; see the script.
- name: curl with retries (skia-bindings' prebuilt fetch has none)
run: sh scripts/ci/install-retrying-curl.sh
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
@@ -358,9 +370,11 @@ jobs:
run: bun run build
- name: Typecheck
run: bun run lint
# Scoped to server/: the console's browser code has no test runner, but the gate that keeps a
# plugin's origin apart from the console's does — and its failure mode is a well-formed header
# that only a browser rejects, which nothing else here would catch.
# Scoped to server/ and nitro-entry/: the console's browser code has no test runner, but two
# gates here do — the one keeping a plugin's origin apart from the console's, whose failure
# mode is a well-formed header that only a browser rejects, and the one picking which of the
# host's two identities the console serves, whose failure mode is a cert no browser accepts.
# Neither would be caught anywhere else.
- name: Test
run: bun run test
+53 -9
View File
@@ -50,7 +50,10 @@ on:
- 'crates/pf-vaadec/**'
- 'packaging/flatpak/**'
- 'Cargo.lock'
# Both halves of this job's correctness, not of the bundle's content: a change to either
# can only be proven by a real run, and there is no other trigger that would give it one.
- '.gitea/workflows/flatpak.yml'
- 'scripts/ci/flatpak-deps-present.sh'
tags: ['v*']
workflow_dispatch:
@@ -270,12 +273,13 @@ jobs:
- name: Prefetch deps + sources (retried — the network phase, split off the build)
run: |
set -euo pipefail
# All of the job's heavy network I/O happens HERE, retried, so a dropped DNS lookup
# or TCP dial costs a backoff-retry instead of the whole (long) compile:
# 1) --install-deps-only pulls everything the manifest declares from Flathub: the
# GNOME 50 runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK
# extensions. (No codec extension: the client links no FFmpeg — see the
# manifest header.)
# 1) the Flathub deps the manifest declares — the GNOME 50 runtime/SDK + the
# rust-stable (//25.08, rustc 1.96) and llvm20 SDK extensions — but ONLY the ones
# genuinely MISSING; see the block below. (No codec extension: the client links no
# FFmpeg — see the manifest header.)
# 2) --download-only fetches every source (all crates in cargo-sources.json) into
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
# after a partial failure is safe and cheap.
@@ -288,9 +292,40 @@ jobs:
# for the mechanism.
# 10 attempts (~9min budget), matching the remote-add bootstrap above — same shared,
# load-sensitive runner, same flathub.org resolution path.
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
--install-deps-from=flathub --install-deps-only \
"$PWD/build-dir" "$MANIFEST"
#
# WHY THIS IS NOT AN UNCONDITIONAL `--install-deps-only` ANY MORE (2026-08-22):
# that flag does not install what is missing, it UPDATES what is present.
# builder_manifest_install_dep() branches on `flatpak info --show-commit <ref>` succeeding
# and runs `flatpak update` for every dep already installed — with no fallback to a
# plain install when that update fails — and ci/flatpak-ci.Dockerfile bakes
# the entire runtime set, so on a healthy run it was a pure no-op that nonetheless made
# every build depend on Flathub being healthy at that minute. It bit on 2026-08-22:
# Updating runtime/org.freedesktop.Sdk.Extension.rust-stable/x86_64/25.08
# Error: Failed to update org.freedesktop.Sdk.Extension.rust-stable: While pulling …
# .filez: Server returned HTTP 404
# dl.flathub.org served a 404 for one object of the then-current rust-stable//25.08
# commit, deterministically — all 10 retry.sh attempts died on the SAME object over
# ~9 min — and flatpak-builder SEGFAULTED on its own error path (rc=139), so retry.sh
# saw a crash rather than a clean "this will never work" either. The build never wanted
# that newer commit: the manifest pins a runtime VERSION, not a commit, and the baked
# one satisfies it. Updating bought nothing and imported an upstream outage.
#
# So: assert what the image already has, and reach for Flathub only on a real miss —
# the same "guard, don't install on top of a stale image" doctrine as the Tooling step.
# The check lives in scripts/ci/flatpak-deps-present.sh (run its --self-test after
# touching it): a bug in it that reports "satisfied" when it is not would build against
# whatever runtime happened to be lying around, which is worth more than an inline
# if-statement. It deliberately fails OPEN — anything it cannot parse takes the slow
# install path below.
if bash scripts/ci/flatpak-deps-present.sh "$MANIFEST"; then
echo "deps satisfied by the baked image — not touching Flathub"
flatpak list --user --columns=ref
else
echo "::warning::$MANIFEST declares deps punktfunk-flatpak-ci does not have — pulling from Flathub (~1.5 GB). Bump GNOME_VERSION/FREEDESKTOP_VERSION in ci/flatpak-ci.Dockerfile so this stays off the hot path."
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
--install-deps-from=flathub --install-deps-only \
"$PWD/build-dir" "$MANIFEST"
fi
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
--download-only --disable-updates \
"$PWD/build-dir" "$MANIFEST"
@@ -298,7 +333,17 @@ jobs:
- name: Build the flatpak (offline — deps + sources prefetched above)
run: |
# Everything is already local (state dir warmed by the prefetch step), so this long
# step needs no network; --install-deps-from stays as a no-op safety net.
# step needs no network.
#
# --install-deps-from=flathub USED to sit here, commented as "a no-op safety net". It
# was neither. builder-main.c calls builder_manifest_install_deps() whenever that flag
# is set — --install-deps-only only decides whether it EXITS afterwards — so this step
# re-ran the same `flatpak update` of the runtimes that killed the prefetch step on
# 2026-08-22 (Flathub HTTP 404 on a rust-stable//25.08 object; see there). A live pull
# of multi-GB runtimes is a strange thing to call a safety net in the step whose whole
# design is to be offline, and it could only ever fire if the prefetch step above had
# already failed the job. Dropped: the prefetch step is the one place that talks to
# Flathub, and it is the one place with retries.
#
# --disable-updates is LOAD-BEARING, not tidiness: without it this step was never
# actually offline. flatpak-builder runs the DOWNLOAD PHASE again as part of every
@@ -326,7 +371,6 @@ jobs:
flatpak-builder --user --force-clean --disable-rofiles-fuse \
--default-branch="$FLATPAK_BRANCH" \
--disable-updates \
--install-deps-from=flathub \
--repo="$PWD/repo" \
"$PWD/build-dir" "$MANIFEST"
+1423
View File
File diff suppressed because it is too large Load Diff
Generated
+38 -38
View File
@@ -1090,7 +1090,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"anyhow",
"pf-capture",
@@ -1222,7 +1222,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"pf-win-display",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -1959,9 +1959,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.15"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228"
dependencies = [
"atomic-waker",
"bytes",
@@ -2343,7 +2343,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.31.0"
version = "0.31.4"
[[package]]
name = "lazy_static"
@@ -2446,7 +2446,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"bindgen",
"cmake",
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"punktfunk-core",
]
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-bitstream"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"cros-codecs",
"tracing",
@@ -2975,7 +2975,7 @@ dependencies = [
[[package]]
name = "pf-capture"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"anyhow",
"ashpd",
@@ -2996,7 +2996,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"anyhow",
"ash",
@@ -3032,7 +3032,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"anyhow",
"ashpd",
@@ -3050,7 +3050,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"anyhow",
"ash",
@@ -3073,7 +3073,7 @@ dependencies = [
[[package]]
name = "pf-dxvadec"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3083,7 +3083,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"anyhow",
"ash",
@@ -3109,7 +3109,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"anyhow",
"libc",
@@ -3122,7 +3122,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"anyhow",
"pf-host-config",
@@ -3136,11 +3136,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.31.0"
version = "0.31.4"
[[package]]
name = "pf-inject"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"anyhow",
"ashpd",
@@ -3169,14 +3169,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"anyhow",
"ash",
@@ -3191,7 +3191,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"serde",
"serde_json",
@@ -3199,7 +3199,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"anyhow",
"aws-lc-rs",
@@ -3211,7 +3211,7 @@ dependencies = [
[[package]]
name = "pf-vaadec"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3220,7 +3220,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"anyhow",
"ashpd",
@@ -3253,7 +3253,7 @@ dependencies = [
[[package]]
name = "pf-vkdecode"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"ash",
"cros-codecs",
@@ -3264,7 +3264,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"pf-paths",
"punktfunk-core",
@@ -3275,7 +3275,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"anyhow",
"ash",
@@ -3487,7 +3487,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3497,7 +3497,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"android_logger",
"anyhow",
@@ -3521,7 +3521,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"anyhow",
"async-channel",
@@ -3538,7 +3538,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"log",
"pf-client-core",
@@ -3554,7 +3554,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"async-channel",
"mdns-sd",
@@ -3572,7 +3572,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"aes-gcm",
"cbindgen",
@@ -3605,7 +3605,7 @@ dependencies = [
[[package]]
name = "punktfunk-encode-worker"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"pf-encode",
"tracing",
@@ -3614,7 +3614,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"aes",
"aes-gcm",
@@ -3684,7 +3684,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3698,7 +3698,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"anyhow",
"ksni",
@@ -3722,7 +3722,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.31.0"
version = "0.31.4"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -65,7 +65,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.31.0"
version = "0.31.4"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
+97 -4
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.31.0"
"version": "0.31.4"
},
"paths": {
"/api/v1/client-logs": {
@@ -364,6 +364,77 @@
}
}
}
},
"patch": {
"tags": [
"clients"
],
"summary": "Rename a paired client",
"description": "Sets or clears the operator-visible display name for one paired Moonlight client. This is\npurely cosmetic — it touches no certificate and no trust decision — but it is the only way to\ntell paired devices apart: every moonlight-common-c client self-signs with the identical\nsubject `CN=NVIDIA GameStream Client`, so an unnamed list is a row of clones distinguishable\nonly by fingerprint. The name is stored beside the pairing store and survives host restarts;\nunpairing the device forgets it.",
"operationId": "renameClient",
"parameters": [
{
"name": "fingerprint",
"in": "path",
"description": "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RenameClient"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "The client as it now reads",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PairedClient"
}
}
}
},
"400": {
"description": "Malformed fingerprint",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No paired client with that fingerprint",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/compositors": {
@@ -6688,7 +6759,7 @@
},
"HostInfo": {
"type": "object",
"description": "Host identity and advertised capabilities (static for the life of the process).",
"description": "Host identity and advertised capabilities (static for the life of the process, except\n`local_ip`).",
"required": [
"hostname",
"uniqueid",
@@ -6734,7 +6805,7 @@
},
"local_ip": {
"type": "string",
"description": "Best-effort primary LAN IP."
"description": "Best-effort primary LAN IP, read fresh on every request — a host that started before its\nnetwork did (cold boot) reports `127.0.0.1` only until it actually has an address, and a\nhost that moves networks reports the new one. Poll it rather than caching it."
},
"os": {
"type": "string",
@@ -7375,6 +7446,14 @@
"description": "Lowercase hex SHA-256 of the client certificate DER — the client's stable id here.",
"example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
},
"label": {
"type": [
"string",
"null"
],
"description": "Operator-assigned display name for this device, if one has been set (`PATCH /clients/{fp}`).\n\nThis is the ONLY thing that can tell two paired Moonlight devices apart in a list, because\ntheir certificates cannot: see [`Self::subject`]. Absent until somebody names the device.",
"example": "Living Room TV"
},
"not_after_unix": {
"type": [
"integer",
@@ -7396,7 +7475,7 @@
"string",
"null"
],
"description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses."
"description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.\n\nDo not display this as a device name. Every moonlight-common-c client self-signs with that\nsame fixed subject, so it identifies the *protocol*, not the device — a list of paired\nphones, TVs and handhelds all read identically. [`Self::label`] is the field to show."
}
}
},
@@ -7949,6 +8028,20 @@
}
}
},
"RenameClient": {
"type": "object",
"description": "Body of `PATCH /clients/{fingerprint}` — the device's display name.",
"properties": {
"label": {
"type": [
"string",
"null"
],
"description": "The name to show for this device. `null` (or an empty/whitespace-only string) clears it and\nthe device goes back to being listed by fingerprint alone.\n\nScrubbed before storage by the same sanitizer the native plane runs on device names:\ncontrol characters and Unicode bidi overrides are stripped (they could make one paired\ndevice impersonate another in this very list), whitespace collapsed, and the result capped\nat 64 characters.",
"example": "Living Room TV"
}
}
},
"RunningTitle": {
"type": "object",
"description": "One running title in a provider's liveness report.",
@@ -932,6 +932,10 @@ private val TEST_BUTTONS = listOf(
"Select" to KeyEvent.KEYCODE_BUTTON_SELECT,
"Start" to KeyEvent.KEYCODE_BUTTON_START,
"Guide" to KeyEvent.KEYCODE_BUTTON_MODE,
// The two buttons Android has no keycode for, on the keycodes [Gamepad.buttonBit] borrows for
// them. Only a driverless Sony pad reaches these; every other controller leaves them dark.
"Touch" to KeyEvent.KEYCODE_BUTTON_15,
"Mute" to KeyEvent.KEYCODE_BUTTON_16,
"" to KeyEvent.KEYCODE_DPAD_UP,
"" to KeyEvent.KEYCODE_DPAD_DOWN,
"" to KeyEvent.KEYCODE_DPAD_LEFT,
@@ -515,8 +515,21 @@ class MainActivity : ComponentActivity() {
else -> KeyEvent.KEYCODE_DPAD_RIGHT
}
/** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */
/**
* Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate].
*
* NEVER on a TV, which leaves the id at `0` and makes every [setConsoleHighRefreshRate] call a
* no-op. The pin exists for phone refresh governors that cap third-party apps at 60 Hz; a TV has
* no such governor, and there it does active harm. `display.mode` is what [nativeDisplayMode]
* reads to resolve "Native" refresh at connect, so a menu-time pin makes the session negotiate
* the PINNED rate rather than the TV's real HDMI output — and [StreamScreen] then releases the
* pin on TV (the decoder's own mode switch governs there), dropping the panel back to 60 while
* the host is already serving 120. Every frame then waits out that mismatch, which is the
* "latency explodes unless I set the refresh by hand" field report: picking a refresh explicitly
* is precisely what bypasses the corrupted `nativeDisplayMode` answer.
*/
private fun resolveHighRefreshMode() {
if (isTvDevice(this)) return
@Suppress("DEPRECATION")
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
highRefreshModeId = disp?.supportedModes?.maxWithOrNull(
@@ -615,7 +628,7 @@ class MainActivity : ComponentActivity() {
// keyboard arrows and belong to the VK path below — and BACK, which is how a pad with
// no BUTTON_SELECT scancode delivers its Select: see [Gamepad.padButtonBit], which is
// why this asks it rather than `buttonBit`).
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
if (fromPad(event)) {
val bit = Gamepad.padButtonBit(Gamepad.padKeyCode(event), event.flags)
if (bit != 0) {
// The router forwards the bit on this device's own wire pad index and tracks held
@@ -697,7 +710,7 @@ class MainActivity : ComponentActivity() {
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are) — and, for a real
// pad, WHICH pad family, so the glyphs wear its lettering/shapes.
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
lastPadIsGamepad = fromPad(event)
if (lastPadIsGamepad) {
lastPadStyle = Gamepad.styleFor(event.device)
lastPadDeviceId = event.deviceId
@@ -705,7 +718,7 @@ class MainActivity : ComponentActivity() {
}
// The Controllers debug screen sees pad events before the navigation remap below.
padKeyProbe?.let { if (it(event)) return true }
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
if (fromPad(event)) {
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
// buttons to the navigation the focus system / back stack understand; D-pad *keys*
// already move focus on their own, so they fall through to super untouched. Read
@@ -728,6 +741,32 @@ class MainActivity : ComponentActivity() {
return super.dispatchKeyEvent(event)
}
/**
* Did this key event come from a controller — the question every pad branch here actually
* means when it asks `isFromSource(SOURCE_GAMEPAD)`.
*
* The event's source class is the platform's per-EVENT guess, and some boxes get it wrong:
* Fire OS is reported to deliver a Bluetooth DualSense's Triangle, touchpad and Mode/PS with
* standard `KEYCODE_BUTTON_*` keycodes but a SOURCE_KEYBOARD tag, and the plain gate then
* drops them before anything can map them. The DEVICE's source classes are the fact, so widen
* to the device — but only for keycodes that cannot be anything BUT a gamepad button.
*
* That restriction is the whole safety of this. [KeyEvent.isGamepadButton] is exactly the
* `KEYCODE_BUTTON_*` block — no `KEYCODE_DPAD_*`, no `KEYCODE_BACK` — and both exclusions are
* load-bearing: a keyboard's arrow keys share the D-pad keycodes and belong to the VK path
* ([Gamepad.buttonBit]), and a remote's or keyboard's BACK shares `KEYCODE_BACK` and has to
* keep leaving the stream, which for a device with no pad on it is the documented way out
* ([Gamepad.padButtonBit]). Widening on the device alone — or on its vendor id, which for
* `0x045E`/`0x054C` covers those vendors' keyboards and mice too — routes both into the pad
* branch and breaks them.
*
* The RAW keycode is what is asked: routing happens before [Gamepad.padKeyCode]'s correction,
* and both the raw and the corrected keycode are in this block for every button concerned.
*/
private fun fromPad(event: KeyEvent): Boolean =
event.isFromSource(InputDevice.SOURCE_GAMEPAD) ||
(KeyEvent.isGamepadButton(event.keyCode) && Gamepad.isPad(event.device))
/**
* `true` (back) / `false` (forward) when this key event is a MOUSE side button, null when it is
* anything else — including a remote's or keyboard's BACK, which must keep exiting the stream.
@@ -114,6 +114,22 @@ data class Settings(
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
*/
val gamepadUiEnabled: Boolean = true,
/**
* Draw the console UI at 1080p and let the display scale it up, instead of at the panel's own
* resolution. Off by default — this is a deliberate sharpness-for-smoothness trade, not
* something to impose on a device that does not need it.
*
* It exists for 4K TVs and projectors. Their graphics chips are chosen to decode and composite
* video, not to shade a UI, and are far slower than a phone's; at 4K every pass the console
* draws — the mesh backdrop above all — costs four times what it does at 1080p on hardware
* that is nowhere near four times faster. A "premium" 4K box is MORE likely to want this than
* a cheap 1080p stick, which never had the extra pixels to begin with.
*
* Read by [io.unom.punktfunk.console.SkiaConsoleShell], which applies it with
* `SurfaceHolder.setFixedSize` — the compositor then scales the smaller buffer up for free.
* The stream is untouched; that has its own `renderScale`.
*/
val reduceUiResolution: Boolean = false,
/**
* When [gamepadUiEnabled] actually takes over — the cross-client `gamepad_ui_mode` pair,
* mirroring the Apple client's `gamepadUIMode`: `"connected"` (default, and what the switch
@@ -329,6 +345,7 @@ class SettingsStore(context: Context) {
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
reduceUiResolution = prefs.getBoolean(K_REDUCE_UI_RES, false),
gamepadUiMode = prefs.getString(K_GAMEPAD_UI_MODE, GAMEPAD_UI_WHEN_CONNECTED)
?: GAMEPAD_UI_WHEN_CONNECTED,
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
@@ -373,6 +390,7 @@ class SettingsStore(context: Context) {
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
.putString(K_TOUCH_MODE, s.touchMode.name)
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
.putBoolean(K_REDUCE_UI_RES, s.reduceUiResolution)
.putString(K_GAMEPAD_UI_MODE, s.gamepadUiMode)
.putBoolean(K_LIBRARY, s.libraryEnabled)
.putString(K_UI_PALETTE, s.uiPalette)
@@ -415,6 +433,7 @@ class SettingsStore(context: Context) {
const val K_HUD = "stats_hud_enabled"
const val K_TOUCH_MODE = "touch_mode"
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
const val K_REDUCE_UI_RES = "reduce_ui_resolution"
const val K_GAMEPAD_UI_MODE = "gamepad_ui_mode"
const val K_LIBRARY = "library_enabled"
const val K_UI_PALETTE = "ui_palette"
@@ -478,7 +497,12 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
val mode = display.mode
val w = mode.physicalWidth
val h = mode.physicalHeight
val hz = mode.refreshRate.toInt().coerceAtLeast(1)
// ROUNDED, not truncated: TVs report the fractional NTSC rates over HDMI (59.94, 29.97,
// 23.976), and `toInt()` turns 59.94 into 59 — a rate no display mode anywhere has, which the
// host then serves by clamping DOWN to the highest mode it advertises at or below it. Rounding
// also keeps this agreeing with `MainActivity.streamPanelFps`, which already rounds; the two
// describe the same panel and must not disagree.
val hz = kotlin.math.round(mode.refreshRate).toInt().coerceAtLeast(1)
return Triple(maxOf(w, h), minOf(w, h), hz)
}
@@ -328,6 +328,7 @@ internal object ConsoleJson {
j.put("android.ds_capture", s.dsCapture)
j.put("android.gamepad_ui_mode", s.gamepadUiMode)
j.put("android.gamepad_ui_enabled", s.gamepadUiEnabled)
j.put("android.reduce_ui_resolution", s.reduceUiResolution)
// A store written by the nesting build carries the stale wrapper; drop it rather than
// round-trip a copy of these keys that nothing reads for the life of the install.
j.remove("extra")
@@ -386,6 +387,7 @@ internal object ConsoleJson {
gamepadUiMode = j.optString("android.gamepad_ui_mode", s.gamepadUiMode)
.ifEmpty { s.gamepadUiMode },
gamepadUiEnabled = j.optBoolean("android.gamepad_ui_enabled", s.gamepadUiEnabled),
reduceUiResolution = j.optBoolean("android.reduce_ui_resolution", s.reduceUiResolution),
)
}
}
@@ -26,6 +26,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
@@ -137,13 +138,53 @@ fun SkiaConsoleShell(
// Phone) still read a step too small in the hand: the floor is what sets the phone scale
// (the couch term only wins on tablets and TVs), so this is a phones-only bump.
val tv = remember { io.unom.punktfunk.isTvDevice(context) }
val scale = if (tv) 0f else {
val dm = context.resources.displayMetrics
val couch = minOf(dm.widthPixels, dm.heightPixels) / 800f
maxOf(couch, density.density * 0.75f).coerceIn(0.75f, 3f)
// The SurfaceView's own laid-out size, fed back by `onSizeChanged` below — deliberately not
// `displayMetrics`. The reduced buffer's aspect ratio has to match the RECT it is scaled into
// or the compositor stretches the whole interface, and while those two normally agree,
// `displayMetrics` has a long history of disagreeing with a view's real size by a system bar
// depending on the version and on who is currently hiding what. "Normally agree" is not
// something to hang picture geometry on. Zero until the first layout, which is exactly what
// `render` wants: the surface comes up at its natural size and is re-fixed a frame later.
var viewW by remember { mutableStateOf(0) }
var viewH by remember { mutableStateOf(0) }
// "Reduce interface resolution" (`Settings.reduceUiResolution`): cap the console's BUFFER at
// 1920 on its long edge and let the compositor scale it up to the panel. 1 means "draw at the
// panel's own resolution" — the setting is off, or the display is already at or under 1080p
// and there is nothing to give back.
//
// ONE factor on both axes, so the aspect ratio survives exactly and no layout can stretch.
// Everything else in this function that speaks in SURFACE pixels multiplies by it — the insets
// and design-unit scale just below, the pointer coordinates further down — because
// `setFixedSize` shrinks the buffer WITHOUT shrinking the view: a mouse still reports its
// position in view pixels, and handing those straight to a half-size surface would land the
// cursor at twice its true offset.
val render = if (!settings.reduceUiResolution) 1f else {
val long = maxOf(viewW, viewH)
if (long > 1920) 1920f / long else 1f
}
LaunchedEffect(handle, left, top, right, bottom, scale) {
if (handle != 0L) NativeBridge.nativeConsoleSetViewport(handle, left, top, right, bottom, scale)
// The pointer listeners below are installed in `factory`, which runs ONCE — capturing `render`
// directly would freeze them at its first-composition value (1, before the first layout has
// reported a size), and a mouse would keep reporting view pixels into a half-size surface for
// the rest of the session. Same reason `platformUp` is held this way.
val currentRender by rememberUpdatedState(render)
val dm = context.resources.displayMetrics
val scale = if (tv) 0f else {
val couch = minOf(dm.widthPixels, dm.heightPixels) / 800f
// `render` too: the design-unit scale is in SURFACE pixels, so shrinking the buffer without
// shrinking this would draw the type larger on screen than the same phone draws it today.
maxOf(couch, density.density * 0.75f).coerceIn(0.75f, 3f) * render
}
LaunchedEffect(handle, left, top, right, bottom, scale, render) {
if (handle != 0L) {
NativeBridge.nativeConsoleSetViewport(
handle,
left * render,
top * render,
right * render,
bottom * render,
scale,
)
}
}
// The pad, raw, before MainActivity's B→Back and stick→D-pad synthesis: face buttons and the
@@ -272,7 +313,9 @@ fun SkiaConsoleShell(
Box(Modifier.fillMaxSize()) {
AndroidView(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.onSizeChanged { viewW = it.width; viewH = it.height },
factory = { ctx ->
SurfaceView(ctx).apply {
// The console draws opaque, edge to edge; Compose overlays sit above it.
@@ -305,7 +348,8 @@ fun SkiaConsoleShell(
MotionEvent.ACTION_CANCEL -> 5
else -> return@setOnTouchListener false
}
NativeBridge.nativeConsolePointer(handle, kind, ev.x, ev.y, 0f)
// View pixels → SURFACE pixels (see `render` above).
NativeBridge.nativeConsolePointer(handle, kind, ev.x * currentRender, ev.y * currentRender, 0f)
if (ev.actionMasked == MotionEvent.ACTION_UP) v.performClick()
true
}
@@ -313,13 +357,27 @@ fun SkiaConsoleShell(
if (handle != 0L && ev.actionMasked == MotionEvent.ACTION_SCROLL &&
ev.isFromSource(InputDevice.SOURCE_CLASS_POINTER)
) {
NativeBridge.nativeConsolePointer(handle, 4, ev.x, ev.y, ev.getAxisValue(MotionEvent.AXIS_VSCROLL))
NativeBridge.nativeConsolePointer(handle, 4, ev.x * currentRender, ev.y * currentRender, ev.getAxisValue(MotionEvent.AXIS_VSCROLL))
true
} else false
}
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}
},
// Applied here rather than in `factory` so flipping the setting takes effect without
// leaving the console: `setFixedSize` re-creates the buffer and the render thread
// re-wraps it through the ordinary surfaceChanged path. `setSizeFromLayout` is the
// documented way back to "the view's own size" when the setting goes off again.
update = { view ->
if (render < 1f) {
view.holder.setFixedSize(
(viewW * render).roundToInt().coerceAtLeast(1),
(viewH * render).roundToInt().coerceAtLeast(1),
)
} else {
view.holder.setSizeFromLayout()
}
},
)
when (platformScreen) {
"licenses" -> ConsoleLicensesScreen(onBack = { platformScreen = null }, navActive = true)
@@ -282,6 +282,17 @@ object Gamepad {
* `KEYCODE_DPAD_*` are included but must only be routed here when the event is from a gamepad
* (a keyboard's arrow keys share these keycodes and belong to the VK path) — see MainActivity.
* L2/R2 are forwarded as the analog trigger axes, never as buttons.
*
* [BTN_TOUCHPAD] and [BTN_MISC1] have no Android keycode at all, so
* [PadButtons.GENERIC_SONY] BORROWS the last two rows of `Generic.kl`'s joystick block for
* them ([KEYCODE_BUTTON_15][KeyEvent.KEYCODE_BUTTON_15] / `_16`, evdev `BTN_BASE5`/`BTN_BASE6`)
* — see there. This table is global, so a device that genuinely presses one of those two
* emits the bit as well. That is the cost of the borrow, and it is why the borrow is at the
* TOP of the block rather than at `BUTTON_1`/`BUTTON_2`: those are a flight stick's trigger
* and thumb button, which any joystick-usage HID device reports, whereas reaching `BUTTON_15`
* takes a pad that declares fifteen. The residual case — a fifteen-button HOTAS whose button
* 16 also toggles the client's mic — is the one this leaves on the table; narrowing it
* further needs per-device knowledge the router does not have (see `GamepadRouter`).
*/
fun buttonBit(keyCode: Int): Int = when (keyCode) {
KeyEvent.KEYCODE_BUTTON_A -> BTN_A
@@ -295,6 +306,8 @@ object Gamepad {
KeyEvent.KEYCODE_BUTTON_START -> BTN_START
KeyEvent.KEYCODE_BUTTON_SELECT -> BTN_BACK
KeyEvent.KEYCODE_BUTTON_MODE -> BTN_GUIDE
KeyEvent.KEYCODE_BUTTON_15 -> BTN_TOUCHPAD // borrowed — see the KDoc
KeyEvent.KEYCODE_BUTTON_16 -> BTN_MISC1 // borrowed — see the KDoc
KeyEvent.KEYCODE_DPAD_UP -> BTN_DPAD_UP
KeyEvent.KEYCODE_DPAD_DOWN -> BTN_DPAD_DOWN
KeyEvent.KEYCODE_DPAD_LEFT -> BTN_DPAD_LEFT
@@ -366,15 +379,26 @@ object Gamepad {
// is immune to the layout file — the same reason [Keymap.toVk] reads `scanCode` for keyboards.
// Two things keep it from breaking a pad that already works:
//
// 1. The correction is applied ONLY when the delivered keycode is what `Generic.kl` would
// have said ([genericKeyCode]). A different keycode means a device-specific layout IS in
// force and already knows this pad better than we do, so we leave it alone.
// 2. Which report order to read is decided from what the DEVICE declares, never a model
// table: a pad numbering straight through claims BUTTON_C and BUTTON_Z ([PadButtons]),
// keycodes no real controller has a button for.
// 1. Nothing is corrected on a pad that names its triggers ([padButtons]). A descriptor
// well-formed enough to call them Accelerator/Brake puts its buttons at the standard
// positions too, and that is the fact — not the model — that separates the two firmwares
// of the SAME Xbox pad, only the older of which needs any of this.
// 2. Past that gate the correction still applies ONLY where the delivered keycode is what
// `Generic.kl` would have said ([genericKeyCode]). A different keycode means a
// device-specific layout IS in force and knows this pad better than we do.
//
// Moonlight carries the same two tables (`ControllerHandler`'s `isNonStandardDualShock4` /
// `isNonStandardXboxBtController`), which is why both pads work there on the same box.
// Moonlight carries the same two tables AND the same gate (`ControllerHandler`'s
// `isNonStandardDualShock4` / `isNonStandardXboxBtController`, the latter on `gasRange == null`),
// which is why both pads work there on the same box.
//
// The first cut of this asked `hasKeys(BUTTON_C, BUTTON_Z)` on its own, on the reasoning that a
// pad numbering straight through reaches keycodes no controller has a button for. It does — but
// so does every pad that merely DECLARES six buttons, because `hid-input` allocates `BTN_A + n`
// straight through for the whole descriptor whether or not the pad ever presses them. That fired
// the correction on pads Android was already reading correctly (2026-08-21: an Xbox pad
// answering X with Y, Y with LB, and both shoulders with a menu button), and it could not have
// done otherwise: the signal is identical on the firmware that needs correcting and the one that
// does not. Declaration is not report order. Only the axes tell them apart.
/** [MotionEvent] axis id meaning "this pad has no such axis" — see [PadMap]. */
const val AXIS_NONE = -1
@@ -393,9 +417,12 @@ object Gamepad {
/**
* A Sony pad numbering straight through with no kernel driver behind it: □ ✕ ○ △ L1 R1
* L2 R2 Create Options L3 R3 PS, i.e. `0x130`..`0x13c` in that order. The analog trigger
* value rides `AXIS_RX`/`AXIS_RY` on such a pad, so the digital L2/R2 fold to keycodes
* [buttonBit] deliberately drops — the wire carries the axis, never both.
* L2 R2 Create Options L3 R3 PS touchpad mute, i.e. `0x130`..`0x13e` in that order. The
* analog trigger value rides `AXIS_RX`/`AXIS_RY` on such a pad, so the digital L2/R2 fold
* to keycodes [buttonBit] deliberately drops — the wire carries the axis, never both.
*
* This order — and ONLY this order — is where `0x13d`/`0x13e` mean the touchpad click and
* the mute button. Everywhere else they are L3/R3.
*/
GENERIC_SONY,
@@ -442,7 +469,23 @@ object Gamepad {
0x13a -> KeyEvent.KEYCODE_BUTTON_THUMBL
0x13b -> KeyEvent.KEYCODE_BUTTON_THUMBR
0x13c -> KeyEvent.KEYCODE_BUTTON_MODE // PS
// 0x13d touchpad click / 0x13e mute: no wire button, dropped as before.
// Touchpad click and mute. The wire has bits for both ([BTN_TOUCHPAD] /
// [BTN_MISC1]) and Android has no keycode for either, so these two borrow
// BUTTON_15/BUTTON_16 to reach [buttonBit] — see its KDoc for the cost.
//
// ONLY here. `0x13d`/`0x13e` are BTN_THUMBL/BTN_THUMBR (L3/R3) in the standard
// Linux mapping — [genericKeyCode] says so itself — and they mean touchpad and
// mute purely because a driverless DualSense enumerates its buttons straight
// through in its own report order, which is what GENERIC_SONY IS. Hoisting
// this above `padMap(dev)` would put L3 on the touchpad and R3 on the mic for
// every Xbox pad, Switch Pro, 8BitDo, Steam Deck and `hid-playstation`
// DualSense on the couch. There is no scancode that means the same button on
// all pads; that is the entire reason this enum exists.
0x13d -> KeyEvent.KEYCODE_BUTTON_15 // touchpad click → BTN_TOUCHPAD
0x13e -> KeyEvent.KEYCODE_BUTTON_16 // mute → BTN_MISC1
// Unreachable with the guard above in force (it only lets `0x130`..`0x13e`
// through, and every one of those is now named), and KEYCODE_UNKNOWN is the
// safe answer if that ever changes.
else -> KeyEvent.KEYCODE_UNKNOWN
}
GENERIC_XBOX -> when (scan) {
@@ -526,22 +569,42 @@ object Gamepad {
private val padMaps = ConcurrentHashMap<String, PadMap>()
/**
* Which report order [dev]'s buttons follow, asked of the device rather than a model table.
* Which report order [dev]'s buttons follow — [namedTriggers] is whether the pad reports its
* triggers under a name Android knows (see [padMap]), and [declaresCZ] whether it declares
* BUTTON_C and BUTTON_Z.
*
* A pad numbering its HID buttons straight through reaches BUTTON_C and BUTTON_Z, keycodes
* that exist only as `Generic.kl` positions — no controller has a physical C or Z button, and
* a pad with a kernel driver behind it emits the modern Linux gamepad codes, which skip both.
* Declaring the pair is therefore the signature of a pad Android is guessing at.
* `namedTriggers` decides it, and a pad that has them is [PadButtons.NATIVE] whatever else it
* says. A HID gamepad describes its triggers either as the Accelerator/Brake usages, which
* become `ABS_GAS`/`ABS_BRAKE` and axis names Android has words for, or as two more generic
* axes on `ABS_Z`/`ABS_RZ`, which it does not — and a report descriptor well-formed enough to
* name its triggers puts its buttons at the standard positions too, the ones `Generic.kl`
* already reads correctly. It is the same fact Moonlight decides this on (`gasRange == null`
* beside the `"Xbox Wireless Controller"` name), and it is the one that separates the two
* firmwares of the SAME pad: an Xbox Wireless Controller over Bluetooth reports GAS/BRAKE
* after its firmware update and Z/Rz before it, and only the older one needs correcting.
*
* `declaresCZ` cannot make that call and must never be asked to. `hasKeys` answers for what a
* device DECLARES, not what it reports: `hid-input` allocates `BTN_A + n` straight through for
* every button in the descriptor, so BTN_C (`0x132`) and BTN_Z (`0x135`) are set on any pad
* declaring six or more — a standard-layout pad that never presses either included. Read alone
* it fired the correction on pads whose buttons were already right, which is how an Xbox pad
* came to answer X with Y and Y with LB (field reports, 2026-08-21). It stays as the narrower
* question it can answer — WHICH straight-through order, once `namedTriggers` has established
* there is one — where a false positive costs nothing.
*/
fun padButtons(dev: InputDevice): PadButtons {
fun padButtons(dev: InputDevice, namedTriggers: Boolean): PadButtons {
val has = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_C, KeyEvent.KEYCODE_BUTTON_Z, 0)
val straightThrough = has[0] && has[1]
return when {
straightThrough && dev.vendorId == VID_SONY -> PadButtons.GENERIC_SONY
straightThrough -> PadButtons.GENERIC_XBOX
dev.vendorId == VID_SONY -> PadButtons.SONY_MODERN
else -> PadButtons.NATIVE
}
return padButtons(namedTriggers, dev.vendorId == VID_SONY, declaresCZ = has[0] && has[1])
}
/** [padButtons]'s choice over plain facts — the seam its truth table is tested at (an
* [InputDevice] cannot be built off a device). */
fun padButtons(namedTriggers: Boolean, sony: Boolean, declaresCZ: Boolean): PadButtons = when {
namedTriggers -> PadButtons.NATIVE
declaresCZ && sony -> PadButtons.GENERIC_SONY
declaresCZ -> PadButtons.GENERIC_XBOX
sony -> PadButtons.SONY_MODERN
else -> PadButtons.NATIVE
}
/**
@@ -566,11 +629,11 @@ object Gamepad {
fun padMap(dev: InputDevice?): PadMap {
if (dev == null) return NATIVE_MAP
padMaps[dev.descriptor]?.let { return it }
val buttons = padButtons(dev)
fun has(a: Int) = axis(dev, a) != null
val named = (has(MotionEvent.AXIS_LTRIGGER) && has(MotionEvent.AXIS_RTRIGGER)) ||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_GAS)) ||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_THROTTLE))
val buttons = padButtons(dev, namedTriggers = named)
val rx = axis(dev, MotionEvent.AXIS_RX)
val hasRxRy = rx != null && has(MotionEvent.AXIS_RY)
// Whichever pair the fallback is about to pick, ask THAT one where it rests.
@@ -101,6 +101,18 @@ class GamepadRouter(
* the whole session. The capture-link pads carry the same flag on [ExternalPad].
*/
val motionReaches: Boolean = true,
/**
* Whether [Gamepad.BTN_MISC1] means a MUTE button on this particular pad — the one bit
* whose physical meaning differs per controller, and the gate on the mic toggle in
* [slotButton].
*
* A DualSense has one; a Steam Controller 2 puts its QAM button on the same wire bit
* (`Sc2Device`), and QAM must not mute anyone's microphone. Asked once at open, off the
* fact each path actually knows: the report order for an [InputDevice] (only
* [Gamepad.PadButtons.GENERIC_SONY] mints this bit there), the declared pad kind for a
* capture link.
*/
val hasMuteButton: Boolean = false,
) {
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
var held = 0
@@ -160,7 +172,8 @@ class GamepadRouter(
/**
* Invoked (main thread) each time the mic-mute chord ([MIC_CHORD], Select + Y) is COMPLETED on
* a pad — the couch equivalent of the stream's on-screen mute button, which a gamepad user
* a pad, or a pad's own mute button ([Gamepad.BTN_MISC1] — a DualSense's) is pressed — the
* couch equivalent of the stream's on-screen mute button, which a gamepad user
* cannot reach. `StreamScreen` wires it to the mute toggle. Unlike the exit chord this fires
* immediately: muting is the kind of thing you want to have already happened, and the on-screen
* indicator makes an accidental toggle self-evident. The buttons still go to the host — the
@@ -234,15 +247,40 @@ class GamepadRouter(
}
}
/**
* Is this bit's WIRE SEND kept with this device, though the bit is otherwise tracked normally?
*
* Exactly one is: a real mute button ([Slot.hasMuteButton]) under the "local" [systemForward]
* policy. It is tracked — the mic toggle in [slotButton] is edge-triggered off held state —
* but not forwarded, so every send site has to ask, including [releaseHeld]'s close-time
* flush, or a mute held across a disconnect would put a release on the wire for a press that
* never went out. Every other system button under that policy leaves [slotButton] at the top
* and never reaches a send at all.
*/
private fun localOnly(slot: Slot, bit: Int): Boolean =
!systemForward && bit == Gamepad.BTN_MISC1 && slot.hasMuteButton
/**
* One button transition on [slot] — the shared body behind [onButton] and an [ExternalPad]'s
* transitions: forward the wire event, track held state, arm/disarm the exit chord, and fire
* the instant chords ([MIC_CHORD], [STATS_CHORD]).
* the instant chords ([MIC_CHORD], [STATS_CHORD], and the mute button's own mic toggle).
*/
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
// Raw system buttons stay local under the "local" policy — no wire send and no held
// tracking, symmetric on both edges so nothing leaks into the chords either.
if (!systemForward && (bit == Gamepad.BTN_GUIDE || bit == Gamepad.BTN_MISC1)) return
// tracking, symmetric on both edges so nothing leaks into the chords either. A Steam
// Controller 2's QAM button is BTN_MISC1 and keeps exactly that behaviour.
//
// A real MUTE button ([Slot.hasMuteButton]) is deliberately exempt: that policy's own
// words are "keeps them entirely with this device", and toggling this device's microphone
// is precisely what a mute button does with itself. Returning here would have left the
// button present and silently dead under `local`, for a reason nobody would ever find. It
// loses its wire send instead (see [localOnly]) and keeps the held tracking the toggle's
// edge-trigger reads. It cannot leak into a chord — MISC1 is in none of them.
if (!systemForward &&
(bit == Gamepad.BTN_GUIDE || (bit == Gamepad.BTN_MISC1 && !slot.hasMuteButton))
) {
return
}
if (down) {
if (guideGesture && send) {
// A Select pressed ALONE is held back until it resolves: a tap (delivered
@@ -258,7 +296,7 @@ class GamepadRouter(
}
flushPendingSelect(slot)
}
if (send && forwarding) {
if (send && forwarding && !localOnly(slot, bit)) {
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
}
val wasHeld = slot.held
@@ -268,11 +306,26 @@ class GamepadRouter(
// Mic mute and the stats-tier cycle, each edge-triggered on the button that COMPLETES
// its chord (see [completesChord]) — the two meanings this client gives Select plus a
// face button. Both leave the press on the wire: the game still gets its buttons.
if (completesChord(wasHeld, bit, MIC_CHORD)) onMicChord?.invoke()
//
// A pad's own mute button is a second trigger for the SAME toggle, not a new
// mechanism — so it gets the same edge-trigger, expressed as the one-button chord it
// is. That is load-bearing rather than tidy: [onButton] deliberately still calls this
// with `down = true` on auto-repeat and suppresses only `send` (its repeatCount
// guard), so an unguarded `bit == BTN_MISC1` would flap the mic for as long as the
// button is held down.
//
// [Slot.hasMuteButton] is the other half, and it is not belt-and-braces: BTN_MISC1 is
// the wire's misc/QAM bit, and `Sc2Device` puts a Steam Controller 2's QAM button on
// it. Reading "any MISC1" as mute would mute the microphone on every QAM press.
if (completesChord(wasHeld, bit, MIC_CHORD) ||
(slot.hasMuteButton && completesChord(wasHeld, bit, Gamepad.BTN_MISC1))
) {
onMicChord?.invoke()
}
if (completesChord(wasHeld, bit, STATS_CHORD)) onStatsChord?.invoke()
} else {
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
if (!owned && send && forwarding) {
if (!owned && send && forwarding && !localOnly(slot, bit)) {
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
}
slot.held = slot.held and bit.inv()
@@ -543,7 +596,15 @@ class GamepadRouter(
// time. Cheap enough to ask unconditionally; the answer holds for the pad's lifetime.
val motionReaches = NativeBridge.nativePadMotionReaches(handle, pref)
if (forwarding && hasGyro && !motionReaches) onMotionUnreachable?.invoke()
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
// `DsDevice` raises BTN_MISC1 from the DualSense report's mute bit; `Sc2Device` raises the
// same bit from the Steam Controller 2's QAM button, which must not touch the microphone.
// The declared kind separates them (a DualShock 4 has no mute button either).
val hasMute = pref == Gamepad.PREF_DUALSENSE || pref == Gamepad.PREF_DUALSENSEEDGE
slots[syntheticId] = Slot(
index,
Gamepad.AxisMapper(handle, index),
hasMuteButton = hasMute,
)
return ExternalPad(syntheticId, index, motionReaches)
}
@@ -603,10 +664,15 @@ class GamepadRouter(
// Asked here, off the kind this pad just DECLARED — not off the session's resolved backend,
// which under Automatic answers for whichever pad happened to be active at dial time. Held
// for the slot's life; the sensor path reads it on every sample.
val map = Gamepad.padMap(dev)
val slot = Slot(
index,
Gamepad.AxisMapper(handle, index, Gamepad.padMap(dev)),
Gamepad.AxisMapper(handle, index, map),
NativeBridge.nativePadMotionReaches(handle, pref),
// The only route to BTN_MISC1 on this path is GENERIC_SONY's `0x13e` row, so the
// report order IS the answer — and unlike `pref` it survives the user pinning every
// pad to one type, which would otherwise cost a DualSense its mute button.
hasMuteButton = map.buttons == Gamepad.PadButtons.GENERIC_SONY,
)
slots[dev.id] = slot
// After the table holds the slot, so a listener that sends on this device the moment it is
@@ -652,7 +718,9 @@ class GamepadRouter(
var bits = slot.held
while (bits != 0) {
val bit = bits and -bits // lowest set bit
if (forwarding) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
if (forwarding && !localOnly(slot, bit)) {
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
}
bits = bits and bit.inv()
}
slot.held = 0
@@ -1,6 +1,7 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -155,6 +156,44 @@ class GamepadChordTest {
assertEquals(instantChords, pad.press(Gamepad.BTN_BACK))
}
/**
* A pad's own mute button (a DualSense's) is a second trigger for the mic toggle, and
* `slotButton` reads it through the SAME edge rule expressed as a one-button chord.
*
* That is not decoration. `onButton` deliberately still calls `slotButton(down = true)` on
* auto-repeat and suppresses only the wire send (its repeatCount guard), so a plain
* `bit == BTN_MISC1` would toggle the mic on every repeat — hold the button and the mic
* flaps. `completesChord` against a single-bit mask is exactly "a fresh press of it".
*
* The other half is which buttons must NOT reach it. `0x13e` is R3 on every pad but a
* driverless Sony one, so a mapping that leaked touchpad/mute meanings outside
* [Gamepad.PadButtons.GENERIC_SONY] would put the mic toggle on every R3 press in the house.
*
* `slotButton` ANDs this rule with `Slot.hasMuteButton`, because BTN_MISC1 is the wire's
* misc/QAM bit and a Steam Controller 2's QAM button rides it too. That term needs a live
* `Slot`, which needs an InputManager and a main Looper, so it is out of reach from here —
* the edge rule below is the half a unit test can hold.
*/
@Test
fun `the mute button toggles the mic once per press`() {
fun fires(wasHeld: Int, bit: Int) =
GamepadRouter.completesChord(wasHeld, bit, Gamepad.BTN_MISC1)
assertTrue("a fresh press must toggle", fires(0, Gamepad.BTN_MISC1))
assertFalse("auto-repeat re-fired the toggle", fires(Gamepad.BTN_MISC1, Gamepad.BTN_MISC1))
assertTrue(
"a press while other buttons are held is still a fresh press",
fires(Gamepad.BTN_A or Gamepad.BTN_BACK, Gamepad.BTN_MISC1),
)
for (other in listOf(
Gamepad.BTN_A, Gamepad.BTN_X, Gamepad.BTN_Y, Gamepad.BTN_BACK,
Gamepad.BTN_LS_CLICK, Gamepad.BTN_RS_CLICK, Gamepad.BTN_GUIDE, Gamepad.BTN_TOUCHPAD,
)) {
assertFalse("$other toggled the mic", fires(0, other))
assertFalse("$other toggled the mic under a held mute", fires(Gamepad.BTN_MISC1, other))
}
}
/** The chord bits are the wire's, so they must stay inside the 32-bit button mask. */
@Test
fun `chord masks are wire button bits`() {
@@ -64,12 +64,44 @@ class PadButtonsTest {
assertEquals(KeyEvent.KEYCODE_BUTTON_MODE, sony(0x13c)) // PS
}
/** The touchpad click and mute have no wire button; they must resolve to nothing, not to R3. */
/**
* The touchpad click and the mute button reach the wire, on the two bits that exist for them.
* Android has no keycode for either, so [Gamepad.PadButtons.GENERIC_SONY] borrows BUTTON_15
* and BUTTON_16 to carry them into [Gamepad.buttonBit] — the keycode is an implementation
* detail of that hop, the BIT is the contract, so both halves are pinned here.
*/
@Test
fun `a DualSense's touchpad and mute are dropped rather than mistaken`() {
assertEquals(KeyEvent.KEYCODE_UNKNOWN, sony(0x13d))
assertEquals(KeyEvent.KEYCODE_UNKNOWN, sony(0x13e))
assertEquals(0, Gamepad.buttonBit(sony(0x13d)))
fun `a DualSense's touchpad and mute reach their wire buttons`() {
assertEquals(KeyEvent.KEYCODE_BUTTON_15, sony(0x13d))
assertEquals(KeyEvent.KEYCODE_BUTTON_16, sony(0x13e))
assertEquals(Gamepad.BTN_TOUCHPAD, Gamepad.buttonBit(sony(0x13d)))
assertEquals(Gamepad.BTN_MISC1, Gamepad.buttonBit(sony(0x13e)))
}
/**
* The regression the touchpad/mute mapping is one hoist away from causing, and the reason it
* lives inside GENERIC_SONY rather than anywhere above `padMap(dev)`.
*
* `0x13d`/`0x13e` are `BTN_THUMBL`/`BTN_THUMBR` — L3 and R3 — in the standard Linux/AOSP
* mapping, which is what [Gamepad.genericKeyCode] says they are. They mean touchpad click and
* mute ONLY inside the straight-through enumeration a driverless Sony pad uses. Read as
* touchpad and mute anywhere else, every Xbox pad, Switch Pro, 8BitDo, Steam Deck and
* `hid-playstation` DualSense loses both stick clicks — and R3 starts toggling the microphone.
*/
@Test
fun `every other pad keeps L3 and R3 on those scancodes`() {
for (p in listOf(
Gamepad.PadButtons.NATIVE,
Gamepad.PadButtons.GENERIC_XBOX,
Gamepad.PadButtons.SONY_MODERN,
)) {
val l3 = p.correct(0x13d, Gamepad.genericKeyCode(0x13d))
val r3 = p.correct(0x13e, Gamepad.genericKeyCode(0x13e))
assertEquals("$p L3", KeyEvent.KEYCODE_BUTTON_THUMBL, l3)
assertEquals("$p R3", KeyEvent.KEYCODE_BUTTON_THUMBR, r3)
assertEquals("$p L3 bit", Gamepad.BTN_LS_CLICK, Gamepad.buttonBit(l3))
assertEquals("$p R3 bit", Gamepad.BTN_RS_CLICK, Gamepad.buttonBit(r3))
}
}
/** An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS. */
@@ -116,6 +148,30 @@ class PadButtonsTest {
)
}
/**
* The guard's NEGATIVE path — the half that decides anything.
*
* The cases above all deliver the keycode `Generic.kl` would have produced, so the guard is
* transparent in every one of them and the assertions would hold with it deleted. These are
* the ones that fail without it: a device-specific key layout answering something the table
* disagrees with, on a scancode the table has an opinion about. The layout wins — it knows
* this controller, and the table is only ever a guess about a pad nothing knew.
*/
@Test
fun `a device layout outranks the table on a scancode the table would have rewritten`() {
// `Generic.kl` calls 0x134 BUTTON_Y, and GENERIC_SONY/GENERIC_XBOX both rewrite that
// scancode to BUTTON_L1. A layout that says BUTTON_X must survive both.
for (p in listOf(Gamepad.PadButtons.GENERIC_SONY, Gamepad.PadButtons.GENERIC_XBOX)) {
assertEquals("$p", KeyEvent.KEYCODE_BUTTON_X, p.correct(0x134, KeyEvent.KEYCODE_BUTTON_X))
}
// And the two rows added for the touchpad and mute are no different: a pad whose layout
// resolved 0x13d itself keeps that answer rather than the borrowed BUTTON_15.
assertEquals(
KeyEvent.KEYCODE_BUTTON_1,
Gamepad.PadButtons.GENERIC_SONY.correct(0x13d, KeyEvent.KEYCODE_BUTTON_1),
)
}
/** Correcting twice is correcting once — the output is never itself a generic-layout answer. */
@Test
fun `correction is idempotent`() {
@@ -200,4 +256,55 @@ class PadButtonsTest {
assertEquals(generic, Gamepad.PadButtons.NATIVE.correct(scan, generic))
}
}
/**
* The regression that made this gate necessary (field reports, 2026-08-21): an Xbox Wireless
* Controller and a GameSir G8+, both with their buttons at the standard positions and both
* corrected anyway, because `hasKeys` says BUTTON_C and BUTTON_Z for any pad that DECLARES six
* buttons — `hid-input` allocates the whole descriptor `BTN_A + n` straight through whether the
* pad ever presses them or not. Naming the triggers is what tells the two apart.
*/
@Test
fun `a pad that names its triggers is never corrected, whatever it declares`() {
for (sony in listOf(false, true)) {
for (declaresCZ in listOf(false, true)) {
assertEquals(
Gamepad.PadButtons.NATIVE,
Gamepad.padButtons(namedTriggers = true, sony = sony, declaresCZ = declaresCZ),
)
}
}
}
/**
* The four buttons the field reports named, on a pad whose report order is already standard:
* X answering Y, Y answering LB, and both shoulders answering a menu button. NATIVE is what
* keeps them themselves — the correction tables are right for the pads they are for, and this
* is about not reaching one of them.
*/
@Test
fun `an Xbox pad at the standard positions keeps X, Y and its shoulders`() {
val native = Gamepad.PadButtons.NATIVE
assertEquals(KeyEvent.KEYCODE_BUTTON_X, native.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, native.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, native.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, native.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
// What the old heuristic did to each of them, kept here so the difference stays visible.
val wrong = Gamepad.PadButtons.GENERIC_XBOX
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, wrong.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, wrong.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, wrong.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
assertEquals(KeyEvent.KEYCODE_BUTTON_START, wrong.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
}
/** Past the gate, which straight-through order to read is still the question it always was. */
@Test
fun `an unnamed-trigger pad still resolves its report order`() {
fun order(sony: Boolean, declaresCZ: Boolean) =
Gamepad.padButtons(namedTriggers = false, sony = sony, declaresCZ = declaresCZ)
assertEquals(Gamepad.PadButtons.GENERIC_SONY, order(sony = true, declaresCZ = true))
assertEquals(Gamepad.PadButtons.GENERIC_XBOX, order(sony = false, declaresCZ = true))
assertEquals(Gamepad.PadButtons.SONY_MODERN, order(sony = true, declaresCZ = false))
assertEquals(Gamepad.PadButtons.NATIVE, order(sony = false, declaresCZ = false))
}
}
@@ -223,6 +223,7 @@ impl ConsoleHost {
let thread = std::thread::Builder::new()
.name("pf-console".into())
.spawn(move || {
boost_thread_priority();
let run = || -> Result<()> {
let console = Console::new(opts, entry, &thread_handles)?;
render_loop(console, thread_shared.clone(), thread_store)
@@ -249,6 +250,34 @@ impl ConsoleHost {
}
}
/// Best-effort: lift the console's render thread off the default nice band, the same way
/// `decode::setup::boost_thread_priority` lifts the decode thread. This thread IS the console's
/// frame loop — every menu press waits on it — and at default priority a TV box's scheduler is
/// free to park it on a little core behind whatever else the system is doing, which reads as a
/// UI that lags the remote. `-8` rather than the decode path's `-10`: a stream's frames are the
/// harder deadline, and the two should not compete when the console is up during a session.
///
/// Non-fatal if the platform refuses (the exact floor a foreground app may set is policy).
fn boost_thread_priority() {
// SAFETY: `gettid`/`setpriority` on the calling thread are always-safe syscalls; PRIO_PROCESS
// with a TID targets that one task on Linux — the idiom `Process.setThreadPriority` uses.
unsafe {
let tid = libc::gettid();
if libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -8) != 0 {
log::debug!(
"console: setpriority(-8) failed (non-fatal): {}",
std::io::Error::last_os_error()
);
}
}
}
/// How often the render loop reports what a frame is costing it. Nothing in a bug report from a
/// TV said whether the console was drawing at 4K or at 60 Hz, so "it feels sluggish" could not be
/// triaged from a log bundle at all — this is that missing line. One line a minute is cheap
/// enough to leave on for everyone, and the answer is only useful from the box that is slow.
const FRAME_REPORT: Duration = Duration::from_secs(60);
/// No input for this long = the console is being looked at, not used — halve the redraw
/// rate (`IDLE_FRAME_STEP` slept between swaps). 60 s keeps every interaction and its
/// afterglow at full smoothness and only calms a genuinely parked screen.
@@ -283,6 +312,9 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
// SurfaceView forever. Dying raises `Dead`, and Kotlin answers with the touch UI.
let mut gl_failures = 0u32;
const GL_FAILURE_LIMIT: u32 = 3;
// What a frame is costing, reported once a `FRAME_REPORT` window (see there).
let (mut frames, mut frame_time, mut frame_peak) = (0u32, Duration::ZERO, Duration::ZERO);
let mut report_at = Instant::now();
loop {
// Take everything queued. With no surface up, block until something arrives.
@@ -446,8 +478,17 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
skia = None;
match g.wrap_window(&egl, w, h) {
Ok(surf) => {
// The console's real render resolution — the one number a bug report
// from a TV never carried. A 4K panel is 4× the fragment work of 1080p
// for every pass the shell draws.
log::info!("console: drawing at {w}×{h}");
skia = Some((surf, w, h));
gl_failures = 0;
// Start the frame window here, not at loop entry: the console parks
// with no surface while a stream is up, and a window that had been
// open across that would report its first frame as "1 frame in 20 min".
(frames, frame_time, frame_peak, report_at) =
(0, Duration::ZERO, Duration::ZERO, Instant::now());
}
Err(e) => {
log::error!("console: {e:#}");
@@ -462,6 +503,11 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
insets,
scale,
};
// Around the DRAW only, not the swap: `eglSwapBuffers` blocks on vsync, so
// wall-clock per iteration is always ~the panel period and says nothing. What
// matters is how much of that period the shell spends building the frame —
// once that passes the period, the console is missing vsyncs.
let drew = Instant::now();
console.frame(
surf.canvas(),
&viewport,
@@ -470,6 +516,20 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
&pads,
);
g.context.flush_and_submit();
let cost = drew.elapsed();
frame_time += cost;
frame_peak = frame_peak.max(cost);
frames += 1;
if report_at.elapsed() >= FRAME_REPORT {
log::info!(
"console: {w}×{h}, {frames} frames in {:?} — {:.1} ms/frame mean, {:.1} ms peak",
report_at.elapsed(),
frame_time.as_secs_f64() * 1000.0 / f64::from(frames),
frame_peak.as_secs_f64() * 1000.0,
);
(frames, frame_time, frame_peak, report_at) =
(0, Duration::ZERO, Duration::ZERO, Instant::now());
}
if let Err(e) = s.swap() {
// The window went away under us; wait for the next surface.
log::warn!("console: {e:#} — dropping the surface");
@@ -21,6 +21,25 @@
//! handle early at worst reuses a buffer a touch soon (a visible tear), never a use-after-free. The
//! fences are the correctness of *timing*, not of memory — which is what lets this ship behind an
//! auto-fallback with the residual risk being visual, not a crash.
//!
//! **The acquire fence must come from `acquireNextImageAsync`, never `acquireLatestImageAsync`.**
//! `AImageReader::acquireLatestImage` (`NdkImageReader.cpp`, unfixed as of AOSP main) drains with
//! one `int*` out-param it overwrites per image, then releases each dropped image with whatever the
//! out-param currently holds — the *successor's* fence:
//!
//! ```text
//! acquireImageLocked(&prev, fd) → *fd = F1 (prev = img1)
//! acquireImageLocked(&next, fd) → *fd = F2 (next = img2; F1 overwritten and leaked)
//! prev->close(*fd) → reader adopts F2 as img1's release fence, then closes it
//! acquireImageLocked(&next, fd) → no buffer; leaves *fd alone
//! returns img2 with *fd = F2 ← already given away and closed
//! ```
//!
//! So the moment a burst gives it two images to collapse, the caller is handed a stale fd plus one
//! leaked fd per extra drop. Passing that stale fd to `setBuffer` transfers it to SurfaceFlinger,
//! which closes it again — an `fdsan` `SIGABRT` on the decode thread, either at `Fence::Fence(int)`
//! inside `setBuffer` (the number was already re-owned) or at the end of `Transaction::apply` when
//! the layer state is torn down. `AscBackend::drain_reader` therefore does newest-wins itself.
use ndk::hardware_buffer::HardwareBuffer;
use ndk::media::image_reader::{AcquireResult, Image, ImageFormat, ImageReader};
@@ -376,19 +395,24 @@ impl AscBackend {
true
}
/// Acquire newly rendered images out of the reader: latency keeps only the newest (older are
/// dropped back to the pool by `acquireLatest`); smooth keeps order up to capacity.
/// Acquire newly rendered images out of the reader: latency keeps only the newest (older ones
/// drop back to the pool as they are superseded); smooth keeps order up to capacity.
///
/// Both modes drain with `acquireNextImageAsync`, one image at a time. `acquireLatestImageAsync`
/// is the obvious newest-wins call and is NOT usable — see the acquire-fence note at the top of
/// this module.
fn drain_reader(&mut self) {
if self.fifo_capacity == 0 {
// Newest-wins: one acquire-latest collapses the whole burst to the freshest buffer.
if let Some(acq) = self.acquire(true) {
// Newest-wins: collapse the burst to the freshest buffer ourselves. Each superseded
// candidate drops here — its image returns to the pool, its own acquire fence closes.
while let Some(acq) = self.acquire() {
if self.candidate.replace(acq).is_some() {
self.skipped += 1; // an un-presented candidate was superseded
}
}
} else {
// Smooth: pull every ready image in order into the FIFO, evicting the oldest past cap.
while let Some(acq) = self.acquire(false) {
while let Some(acq) = self.acquire() {
self.fifo.push_back(acq);
while self.fifo.len() > self.fifo_capacity {
self.fifo.pop_front();
@@ -398,19 +422,13 @@ impl AscBackend {
}
}
/// Acquire one image (`latest` drops older, else FIFO) and pair its decode stamps + cadence due.
/// `None` when the reader is empty or a transient acquire error occurs.
fn acquire(&mut self, latest: bool) -> Option<Acquired> {
/// Acquire the next image and pair its decode stamps + cadence due. `None` when the reader is
/// empty or a transient acquire error occurs.
fn acquire(&mut self) -> Option<Acquired> {
// SAFETY: we never touch the image's pixels — the acquire fence is handed straight to
// SurfaceFlinger via `setBuffer`, which is exactly the "await before access" the async
// acquire requires.
let res = unsafe {
if latest {
self.reader.acquire_latest_image_async()
} else {
self.reader.acquire_next_image_async()
}
};
let res = unsafe { self.reader.acquire_next_image_async() };
let (image, fence) = match res {
Ok(AcquireResult::Image(pair)) => pair,
Ok(_) => return None, // no buffer available / max acquired
@@ -317,6 +317,12 @@ impl ImageReader {
/// If the returned file descriptor is not [`None`], it must be awaited before attempting to
/// access the [`Image`] returned.
///
/// **The returned fence is unsound whenever the platform actually drops an older image.**
/// `AImageReader::acquireLatestImage` reuses one out-param across the drain and releases each
/// dropped image with the *successor's* fence fd, so the fd handed back has already been given
/// to the reader (and closed by it) — adopting it here yields a double close and an `fdsan`
/// abort. Drain with [`ImageReader::acquire_next_image_async()`] and pick the newest yourself.
///
/// <https://developer.android.com/ndk/reference/group/media#aimagereader_acquirelatestimageasync>
#[cfg(feature = "api-level-26")]
#[doc(alias = "AImageReader_acquireLatestImageAsync")]
@@ -1479,39 +1479,28 @@ public final class SessionAudio {
"mic capture: \(Int(inFormat.sampleRate)) Hz, \(inChannels) ch, \(channelPlan)")
#endif
// Encode a single mono bus (folded from `inFormat` in the tap): the resampler goes
// Encode a single mono bus (folded from the tap's own buffer format): the resampler goes
// mono@inputSR the encoder's 48 kHz mono, so it handles the rate change and the
// wrong-channel downmix never happens. Mono end to end the host's decoder upmixes,
// so the old duplicate-into-stereo step only cost bits and cycles.
//
// `mono`/`staging` are the per-callback scratch buffers, preallocated HERE (grown only
// if a larger-than-expected device quantum ever arrives) the steady-state tap path
// allocates nothing.
// `chain` carries the rate-dependent pieces INCLUDING the per-callback scratch buffers,
// preallocated HERE for the rate the input currently reports the steady-state tap path
// allocates nothing. The tap rebuilds it if the device's real rate or quantum differ,
// which is the price of installing the tap with the bus's own format (see below).
let scratchFrames: AVAudioFrameCount = 8192
let stagingCapacity = { (frames: AVAudioFrameCount) -> AVAudioFrameCount in
AVAudioFrameCount(
(Double(frames) * 48_000 / inFormat.sampleRate).rounded(.up)) + 64
}
guard let monoFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32, sampleRate: inFormat.sampleRate,
channels: 1, interleaved: false),
let encoder = try? OpusEncoder(),
let resampler = AVAudioConverter(from: monoFormat, to: encoder.pcmFormat),
guard let encoder = try? OpusEncoder(),
var chain = Self.micChain(
rate: inFormat.sampleRate, frames: scratchFrames, to: encoder.pcmFormat),
let chunk = AVAudioPCMBuffer(
pcmFormat: encoder.pcmFormat, frameCapacity: encoder.framesPerPacket),
let monoScratch = AVAudioPCMBuffer(
pcmFormat: monoFormat, frameCapacity: scratchFrames),
let stagingScratch = AVAudioPCMBuffer(
pcmFormat: encoder.pcmFormat, frameCapacity: stagingCapacity(scratchFrames))
pcmFormat: encoder.pcmFormat, frameCapacity: encoder.framesPerPacket)
else {
log.error("Opus encoder unavailable — mic uplink disabled")
return false
}
// Tap-thread-confined state: fold into `mono`, resample into `staging`, accumulate in
// `fifo`, slice `framesPerPacket` (10 ms) chunks for the encoder.
var mono = monoScratch
var staging = stagingScratch
// Tap-thread-confined state: fold into `chain.mono`, resample into `chain.staging`,
// accumulate in `fifo`, slice `framesPerPacket` (10 ms) chunks for the encoder.
var fifo: [Float] = []
fifo.reserveCapacity(48_000)
var seq: UInt32 = 0
@@ -1533,22 +1522,32 @@ public final class SessionAudio {
// 480 frames = 10 ms, matching the packet duration. Advisory CoreAudio delivers the
// device quantum whatever we ask (the old 2048 request came back as 42.7 ms bursts, most
// of the uplink's latency) but where the system honors it, the tap fires per-packet.
input.installTap(onBus: 0, bufferSize: 480, format: inFormat) { buffer, _ in
// `format: nil` NOT the format read above. `installTap` validates a non-nil format
// against the bus and raises an Objective-C exception on any mismatch; Swift cannot catch
// that, so it aborts the process (SIGABRT in `AVAudioEngineGraph::InstallTapOnNode`). The
// format was necessarily read a moment EARLIER, and on macOS the input can move underneath
// it a device switch, a clock/rate change, or the `setDevice` swap `startCapture` itself
// performs two lines before this. `nil` means "whatever the bus emits", which is what the
// chain wants anyway, and the mismatch cannot arise by construction. The tap then follows
// the real format below.
input.installTap(onBus: 0, bufferSize: 480, format: nil) { buffer, _ in
if flag.isStopped { return }
let frames = Int(buffer.frameLength)
guard frames > 0, let src = buffer.floatChannelData else { return }
if frames > Int(mono.frameCapacity) {
// A quantum larger than the scratch (bufferSize is advisory both ways) regrow
// once to the new high-water mark; the steady state stays allocation-free.
guard let biggerMono = AVAudioPCMBuffer(
pcmFormat: monoFormat, frameCapacity: buffer.frameLength),
let biggerStaging = AVAudioPCMBuffer(
pcmFormat: encoder.pcmFormat,
frameCapacity: stagingCapacity(buffer.frameLength))
// Rebuild the rate-dependent chain when the device changes rate under a live tap
// (resampling by the old ratio would pitch-shift the mic), and when a quantum larger
// than the scratch arrives (`bufferSize` is advisory both ways) regrown once to the
// new high-water mark, so the steady state stays allocation-free.
if buffer.format.sampleRate != chain.monoFormat.sampleRate
|| buffer.frameLength > chain.mono.frameCapacity {
guard let rebuilt = Self.micChain(
rate: buffer.format.sampleRate,
frames: max(buffer.frameLength, scratchFrames),
to: encoder.pcmFormat)
else { return }
mono = biggerMono
staging = biggerStaging
chain = rebuilt
}
let mono = chain.mono, staging = chain.staging, resampler = chain.resampler
guard let dst = mono.floatChannelData?[0] else { return }
mono.frameLength = buffer.frameLength
@@ -1620,6 +1619,41 @@ public final class SessionAudio {
return true
}
/// The rate-dependent half of the mic chain: a mono bus at `rate`, the resampler from it onto
/// the encoder's 48 kHz mono, and the two scratch buffers sized for `frames`. Grouped so the
/// tap can swap all four together they are only ever valid as a set.
struct MicChain {
let monoFormat: AVAudioFormat
let resampler: AVAudioConverter
let mono: AVAudioPCMBuffer
let staging: AVAudioPCMBuffer
}
/// Build a `MicChain` for `rate`, or nil if the rate is unusable or an allocation fails.
/// Built once up front for the format the input reports, and again from the tap whenever the
/// device's real rate differs a macOS input can change rate under a live tap, and a chain
/// pinned to the old rate resamples by the wrong ratio (a pitch-shifted mic).
/// `internal` for unit testing: it needs no engine, device or permission.
static func micChain(
rate: Double, frames: AVAudioFrameCount, to pcmFormat: AVAudioFormat
) -> MicChain? {
// `staging` holds the resampled 48 kHz mono, so it must fit the UPWARD ratio from `rate`
// (a 44.1 kHz quantum grows by ~1.088); +64 covers the converter's own slack.
guard rate > 0, frames > 0,
let monoFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32, sampleRate: rate, channels: 1,
interleaved: false),
let resampler = AVAudioConverter(from: monoFormat, to: pcmFormat),
let mono = AVAudioPCMBuffer(pcmFormat: monoFormat, frameCapacity: frames),
let staging = AVAudioPCMBuffer(
pcmFormat: pcmFormat,
frameCapacity: AVAudioFrameCount(
(Double(frames) * 48_000 / rate).rounded(.up)) + 64)
else { return nil }
return MicChain(
monoFormat: monoFormat, resampler: resampler, mono: mono, staging: staging)
}
/// Fold `channels` of input (`floatChannelData` layout: `interleaved` one buffer strided by
/// channel count; else one buffer per channel) down to a single mono bus in `out` (`frames`
/// long). `pinned` (0-based, must be `< channels`) copies exactly that channel the fix for a
@@ -0,0 +1,61 @@
// The rate-dependent half of the mic chain (SessionAudio.micChain). The tap now installs with
// `format: nil` a non-nil format is validated against the bus and raises an Objective-C
// exception on mismatch, which Swift cannot catch, so it aborted the whole app (SIGABRT in
// AVAudioEngineGraph::InstallTapOnNode, reported against 0.31.0). With nil the tap follows
// whatever the bus emits, which means the chain has to be rebuildable at the device's real rate.
// This pins the sizing arithmetic that rebuild depends on, without an engine, device or mic grant.
#if !os(tvOS)
import AVFoundation
import XCTest
@testable import PunktfunkKit
final class AudioMicChainTests: XCTestCase {
/// The encoder's target: 48 kHz mono float what every chain resamples ONTO.
private let target = AVAudioFormat(
commonFormat: .pcmFormatFloat32, sampleRate: 48_000, channels: 1, interleaved: false)!
/// A chain is built at the device's rate, mono, and resamples onto the 48 kHz encoder format.
func testBuildsMonoChainAtDeviceRate() throws {
let chain = try XCTUnwrap(
SessionAudio.micChain(rate: 44_100, frames: 8192, to: target))
XCTAssertEqual(chain.monoFormat.sampleRate, 44_100)
XCTAssertEqual(chain.monoFormat.channelCount, 1)
XCTAssertEqual(chain.mono.frameCapacity, 8192)
XCTAssertEqual(chain.resampler.outputFormat.sampleRate, 48_000)
}
/// `staging` holds the resampled 48 kHz mono, so it must fit the UPWARD ratio the bug this
/// guards is a staging buffer sized for the input rate, which silently truncates every packet
/// when the device runs below 48 kHz.
func testStagingFitsUpwardResampleRatio() throws {
for rate in [8_000.0, 16_000, 44_100, 48_000, 96_000] {
let chain = try XCTUnwrap(
SessionAudio.micChain(rate: rate, frames: 1024, to: target))
let needed = (1024.0 * 48_000 / rate).rounded(.up)
XCTAssertGreaterThanOrEqual(
Double(chain.staging.frameCapacity), needed,
"staging too small to hold 1024 frames resampled from \(rate) Hz")
}
}
/// A rate the device cannot report is refused rather than producing a chain that would
/// divide by zero in the staging arithmetic. The tap treats nil as "skip this buffer".
func testRejectsUnusableRateAndEmptyQuantum() {
XCTAssertNil(SessionAudio.micChain(rate: 0, frames: 8192, to: target))
XCTAssertNil(SessionAudio.micChain(rate: -48_000, frames: 8192, to: target))
XCTAssertNil(SessionAudio.micChain(rate: 48_000, frames: 0, to: target))
}
/// The rebuild path: a device that switches 48 kHz 44.1 kHz under a live tap yields a chain
/// at the NEW rate. Resampling by the stale ratio is what pitch-shifts the mic.
func testRebuildFollowsNewRate() throws {
let first = try XCTUnwrap(SessionAudio.micChain(rate: 48_000, frames: 512, to: target))
let second = try XCTUnwrap(SessionAudio.micChain(rate: 44_100, frames: 512, to: target))
XCTAssertEqual(first.monoFormat.sampleRate, 48_000)
XCTAssertEqual(second.monoFormat.sampleRate, 44_100)
XCTAssertGreaterThan(second.staging.frameCapacity, first.staging.frameCapacity)
}
}
#endif
+51
View File
@@ -148,6 +148,11 @@ pub enum AppMsg {
ended: Option<String>,
tofu: bool,
},
/// Hand over to the gamepad console (`punktfunk-session --browse`) — the couch UI's
/// door from the desktop shell.
OpenConsole,
/// The console child exited; `Some` carries why it ended badly.
ConsoleExited(Option<String>),
/// Request-access Cancel: the child was killed; release busy quietly.
CancelPending,
/// The speed-test dialog resolved (either way) — release `busy`.
@@ -519,6 +524,51 @@ impl SimpleComponent for AppModel {
))),
}
}
AppMsg::OpenConsole => {
if std::mem::replace(&mut self.busy, true) {
return;
}
// The console owns the screen and the pads while it runs, so it takes `busy`
// like a stream does. `gio::Subprocess` is the GLib-native child: its
// `wait_check_async` lands the exit on this very main loop — no thread, no
// channel — and reports a non-zero exit as an error. That is also how a
// build without the session's `ui` feature (Nix) surfaces: the child prints
// "--browse needs the console UI" and exits non-zero, and we banner it.
let mut argv = vec![
std::ffi::OsString::from(crate::spawn::session_binary()),
"--browse".into(),
];
// Same knob a stream uses — the session also fullscreens itself on the Deck
// and under gamescope regardless.
if self.settings.borrow().fullscreen_on_stream {
argv.push("--fullscreen".into());
}
let argv: Vec<&std::ffi::OsStr> =
argv.iter().map(std::ffi::OsString::as_os_str).collect();
match gio::Subprocess::newv(&argv, gio::SubprocessFlags::NONE) {
Ok(child) => {
let sender = sender.clone();
child.wait_check_async(gio::Cancellable::NONE, move |res| {
sender.input(AppMsg::ConsoleExited(res.err().map(|e| e.to_string())));
});
}
Err(e) => {
self.busy = false;
self.hosts.emit(HostsMsg::ShowError(format!(
"Couldn't start the console UI — {e}"
)));
}
}
}
AppMsg::ConsoleExited(err) => {
self.busy = false;
// Quitting the console (B at its root) exits 0 and returns here silently.
if let Some(e) = err {
self.hosts
.emit(HostsMsg::ShowError(format!("Console UI ended — {e}")));
}
self.hosts.emit(HostsMsg::Refresh);
}
AppMsg::CancelPending => {
self.close_waiting();
self.busy = false;
@@ -1007,6 +1057,7 @@ fn install_actions(window: &adw::ApplicationWindow, sender: &ComponentSender<App
window.add_action(&add("shortcuts", || AppMsg::ShowShortcuts));
window.add_action(&add("about", || AppMsg::ShowAbout));
window.add_action(&add("add-host", || AppMsg::ShowAddHost));
window.add_action(&add("console", || AppMsg::OpenConsole));
}
/// The Keyboard Shortcuts window — the SESSION window's keys (the shell itself has
+9
View File
@@ -859,7 +859,14 @@ impl SimpleComponent for HostsPage {
rescan_btn.connect_clicked(move |_| sender.input(HostsMsg::Rescan));
}
header.pack_start(&rescan_btn);
// The couch UI's front door, beside the page's other actions (same placement the
// WinUI shell gives it). It was previously reachable only as `--browse` on the
// command line, which is no way to find a mode.
let console_btn = gtk::Button::from_icon_name("input-gaming-symbolic");
console_btn.set_tooltip_text(Some("Console UI — the controller-driven couch interface"));
console_btn.set_action_name(Some("win.console"));
let menu = gio::Menu::new();
menu.append(Some("Console UI"), Some("win.console"));
menu.append(Some("Preferences"), Some("win.preferences"));
menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts"));
menu.append(Some("About Punktfunk"), Some("win.about"));
@@ -869,7 +876,9 @@ impl SimpleComponent for HostsPage {
.primary(true)
.tooltip_text("Main menu")
.build();
// Packed after the menu so the hamburger stays rightmost (pack_end fills inward).
header.pack_end(&menu_btn);
header.pack_end(&console_btn);
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header);
+3 -1
View File
@@ -1250,7 +1250,9 @@ pub fn show_scoped(
"Above 1× supersamples for sharpness; below is lighter on the host",
&scale_names.iter().map(String::as_str).collect::<Vec<_>>(),
);
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
// 1 Mbit/s per step: the rungs that matter on a thin link are 3, 4, 6 — a 5-wide step
// could not name any of them, and typing was the only way to reach one.
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 1.0);
bitrate_row.set_title("Bitrate");
bitrate_row
.set_subtitle("Mbit/s · 0 = host default · a host card's menu has a network speed test");
+8 -1
View File
@@ -246,16 +246,22 @@ impl Screen {
match self {
Screen::AddHost(s) => s.text_input(text),
Screen::Pair(s) => s.text_input(text),
Screen::Settings(s) => s.text_input(text),
_ => {}
}
}
/// Raw key edits while a field is editing (Backspace repeats, Return = done).
/// Returns true when consumed.
pub(crate) fn edit_key(&mut self, key: crate::input::Key) -> bool {
///
/// Takes the context because a field can commit into the settings store on close —
/// the settings screen's typed bitrate does, where add-host and pair only hold text a
/// later action row reads.
pub(crate) fn edit_key(&mut self, key: crate::input::Key, ctx: &mut Ctx) -> bool {
match self {
Screen::AddHost(s) => s.edit_key(key),
Screen::Pair(s) => s.edit_key(key),
Screen::Settings(s) => s.edit_key(key, ctx),
_ => false,
}
}
@@ -265,6 +271,7 @@ impl Screen {
match self {
Screen::AddHost(s) => s.editing(),
Screen::Pair(s) => s.editing(),
Screen::Settings(s) => s.editing(),
_ => false,
}
}
+377 -17
View File
@@ -16,7 +16,9 @@ use crate::glyphs::{Hint, HintKey};
use crate::pointer::Pointer;
use crate::screens::{Ctx, Outbox, Screen};
use crate::theme::{fg, Fonts, W};
use crate::widgets::{ListMsg, MenuList, RowSpec, TabStrip, TAB_STRIP_H};
use crate::widgets::{
permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec, TabStrip, TAB_STRIP_H,
};
use pf_client_core::audio_format::{AUDIO_FORMATS, AUDIO_FORMAT_OPUS};
use pf_client_core::menu_nav::{MenuEvent, MenuPulse};
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
@@ -80,6 +82,12 @@ enum RowId {
/// beside the palette row for the same reason it does: both are presentation, and the
/// effect of stepping this one is visible on the backdrop behind it.
ReduceMotion,
/// Draw the console at 1080p and let the display scale it up, instead of at the panel's
/// own resolution. Android-only, and beside [`RowId::ReduceMotion`] on purpose: both are
/// "give up some fidelity for a smoother console", and this is the one that matters on a
/// 4K TV or projector, where every pass the shell draws costs four times what it does at
/// 1080p on a GPU that is not four times faster.
ReduceUiResolution,
/// How the game library arranges its titles — see `library::LibraryView`. The library
/// changes it in place now, from the bar over its own field, which is where an
/// arrangement you want to SEE the effect of belongs; this row stays because both
@@ -128,6 +136,7 @@ mod android_keys {
pub const DS_CAPTURE: &str = "android.ds_capture";
pub const GAMEPAD_UI_MODE: &str = "android.gamepad_ui_mode";
pub const GAMEPAD_UI: &str = "android.gamepad_ui_enabled";
pub const REDUCE_UI_RES: &str = "android.reduce_ui_resolution";
}
/// The Android console-UI mode's stored values (`GamepadUi.kt`).
@@ -247,6 +256,7 @@ const TABS: [(&str, &[RowId]); 7] = [
&[
RowId::Palette,
RowId::ReduceMotion,
RowId::ReduceUiResolution,
RowId::LibraryView,
RowId::LibraryCollections,
RowId::Stats,
@@ -280,7 +290,18 @@ const RESOLUTIONS: [(u32, u32); 6] = [
const REFRESH: [u32; 5] = [0, 30, 60, 90, 120];
/// Mirrors [`punktfunk_core::render_scale::PRESETS`] (and the desktop pickers).
const RENDER_SCALES: [f64; 9] = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
const BITRATES: [u32; 7] = [0, 5_000, 10_000, 20_000, 30_000, 50_000, 80_000];
/// The rungs left/right steps through, in kbps. Tight at the bottom, where one rung is the
/// difference between watchable and a slideshow on a thin link, and coarse at the top, where
/// a rung is noise; the ceiling is 2 Gbps. The list is deliberately long — a ladder no thumb
/// can walk to the value it wants is what the Y field is for.
const BITRATES: [u32; 30] = [
0, 1_000, 2_000, 3_000, 4_000, 5_000, 6_000, 8_000, 10_000, 12_000, 15_000, 20_000, 25_000,
30_000, 40_000, 50_000, 60_000, 80_000, 100_000, 125_000, 150_000, 200_000, 250_000, 300_000,
400_000, 500_000, 750_000, 1_000_000, 1_500_000, 2_000_000,
];
/// What the typed field accepts, in Mbps: the ladder's own ceiling. The host clamps to its
/// range anyway (500 kbps 8 Gbps), so this is about what a client should let you ask for.
const CUSTOM_MAX_MBPS: u32 = 2_000;
const COMPOSITORS: [(&str, &str); 5] = [
("auto", "Automatic"),
("kwin", "KWin"),
@@ -363,6 +384,14 @@ pub(crate) struct SettingsScreen {
/// can't create profiles (design §5.4: the desktop app does), so the list is stable
/// for the screen's lifetime.
profiles: Vec<(String, String)>,
/// The Bitrate row's typed rate in Mbps while Y has the field open — `None` the rest of
/// the time. Every other row on this screen is a list of options, and a ladder is the
/// right shape for a list; a bitrate is a NUMBER, and the one a link actually carries is
/// rarely a round rung. Y rather than A so the A-cycles-forward grammar holds everywhere.
custom_bitrate: Option<String>,
/// The tray keyboard the field types through, where the platform has no keyboard of its
/// own (on a Deck, Steam's keyboard types and ours never draws — same rule as add-host).
keyboard: Keyboard,
}
impl SettingsScreen {
@@ -380,6 +409,112 @@ impl SettingsScreen {
tab: 0,
tab_cursors: [0; TABS.len()],
profiles,
custom_bitrate: None,
keyboard: Keyboard::new(),
}
}
/// A text field is open — the run loop keeps SDL text input started, so a hardware
/// keyboard (and Steam's, on a Deck) types straight into it.
pub(crate) fn editing(&self) -> bool {
self.custom_bitrate.is_some()
}
/// Committed text from SDL. Digits only, four of them: 2000 Mbps is the ceiling.
pub(crate) fn text_input(&mut self, text: &str) {
for ch in text.chars() {
self.type_char(ch);
}
}
fn type_char(&mut self, ch: char) -> bool {
let Some(buf) = self.custom_bitrate.as_mut() else {
return false;
};
if !permits(Charset::Digits, ch) || buf.chars().count() >= 4 {
return false;
}
buf.push(ch);
true
}
fn backspace(&mut self) -> bool {
self.custom_bitrate.as_mut().and_then(String::pop).is_some()
}
/// Raw key edits while the field is open (Backspace repeats, Return/Escape are done).
pub(crate) fn edit_key(&mut self, key: crate::input::Key, ctx: &mut Ctx) -> bool {
use crate::input::Key as K;
if self.custom_bitrate.is_none() {
return false;
}
match key {
K::Backspace => {
self.backspace();
true
}
K::Return | K::Escape => {
self.commit_custom(ctx);
true
}
_ => false,
}
}
/// Close the field, storing what was typed. An empty field (or a typed `0`) leaves the
/// rate alone: a cleared field is an abandoned edit, and "let the host decide" is the
/// ladder's own first rung, not something to reach by deleting four digits.
fn commit_custom(&mut self, ctx: &mut Ctx) {
let Some(text) = self.custom_bitrate.take() else {
return;
};
let Ok(mbps) = text.parse::<u32>() else {
return;
};
if mbps == 0 {
return;
}
// The same rebase-then-save every other write here does: another writer may have
// stored the file while the keyboard was up.
*ctx.settings = ctx.store.load();
ctx.settings.bitrate_kbps = mbps.min(CUSTOM_MAX_MBPS) * 1000;
ctx.store.save(ctx.settings);
}
/// The field is modal while it is up: the tray takes the events, and closing commits.
fn custom_menu(&mut self, ev: MenuEvent, ctx: &mut Ctx) -> Option<MenuPulse> {
if ctx.deck {
// Steam's keyboard is doing the typing (text arrives through `text_input`); the
// pad is only here to say when it's done.
return match ev {
MenuEvent::Back | MenuEvent::Confirm => {
self.commit_custom(ctx);
Some(MenuPulse::Confirm)
}
_ => None,
};
}
let (msg, pulse) = self.keyboard.menu(ev);
match msg {
KeyMsg::Type(c) => {
if self.type_char(c) {
Some(MenuPulse::Move)
} else {
Some(MenuPulse::Boundary)
}
}
KeyMsg::Backspace => {
if self.backspace() {
Some(MenuPulse::Move)
} else {
Some(MenuPulse::Boundary)
}
}
KeyMsg::Done => {
self.commit_custom(ctx);
Some(MenuPulse::Confirm)
}
KeyMsg::None => pulse,
}
}
@@ -449,6 +584,27 @@ impl SettingsScreen {
/// Mouse/touch. The strip is checked first — its pills sit above the list and a press
/// there is never meant for a row.
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
if self.custom_bitrate.is_some() && !ctx.deck {
if !self.keyboard.covers(p) {
if p.press() {
self.commit_custom(ctx);
return true;
}
return false;
}
let (msg, _) = self.keyboard.pointer(p);
match msg {
KeyMsg::Type(c) => {
self.type_char(c);
}
KeyMsg::Backspace => {
self.backspace();
}
KeyMsg::Done => self.commit_custom(ctx),
KeyMsg::None => {}
}
return true;
}
if let Some(tab) = self.strip.pointer(p) {
self.show_tab(tab, ctx);
return true;
@@ -469,6 +625,9 @@ impl SettingsScreen {
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
if self.custom_bitrate.is_some() {
return self.custom_menu(ev, ctx);
}
match ev {
MenuEvent::Back => {
fx.pop();
@@ -480,6 +639,16 @@ impl SettingsScreen {
}
let ids = self.row_ids(ctx);
self.clamp_cursor(ids.len());
// Y on the Bitrate row opens the typed rate; on every other row it means nothing,
// and the hint bar only offers it where it does.
if ev == MenuEvent::Secondary {
return if ids.get(self.list.cursor) == Some(&RowId::Bitrate) {
self.custom_bitrate = Some(String::new());
Some(MenuPulse::Confirm)
} else {
None
};
}
let (msg, pulse) = self.list.menu(ev, ids.len());
self.apply_row(msg, pulse, &ids, ctx, fx)
}
@@ -582,6 +751,20 @@ impl SettingsScreen {
}
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
if self.custom_bitrate.is_some() {
if ctx.deck {
return vec![
Hint::new(HintKey::Key("STEAM + X"), "Keyboard"),
Hint::new(HintKey::Confirm, "Done"),
Hint::new(HintKey::Back, "Done"),
];
}
return vec![
Hint::new(HintKey::Confirm, "Type"),
Hint::new(HintKey::Tertiary, "Delete"),
Hint::new(HintKey::Back, "Done"),
];
}
let ids = self.row_ids(ctx);
// The shoulders always change section, so that hint leads on every row.
let mut hints = vec![Hint::new(HintKey::Shoulders, "Section")];
@@ -595,6 +778,12 @@ impl SettingsScreen {
Hint::new(HintKey::Confirm, "Open"),
Hint::new(HintKey::Back, "Done"),
],
// The one row with a value the ladder cannot name every version of.
Some(RowId::Bitrate) => vec![
Hint::new(HintKey::Adjust, "Adjust"),
Hint::new(HintKey::Secondary, "Type a rate"),
Hint::new(HintKey::Back, "Done"),
],
Some(_) => vec![
Hint::new(HintKey::Adjust, "Adjust"),
Hint::new(HintKey::Confirm, "Change"),
@@ -627,20 +816,49 @@ impl SettingsScreen {
k,
dt,
);
let seat = self
.keyboard
.seat(self.custom_bitrate.is_some() && !ctx.deck, dt);
let tray_h = if seat > 0.0 {
(Keyboard::tray_height() + 12.0) * k * seat
} else {
0.0
};
let list_rect = Rect::from_ltrb(
rect.left,
rect.top + strip_h as f32,
rect.right,
rect.bottom - detail_h as f32,
rect.bottom - detail_h as f32 - tray_h as f32,
);
let ids = self.row_ids(ctx);
self.clamp_cursor(ids.len());
let rows: Vec<RowSpec> = ids
let mut rows: Vec<RowSpec> = ids
.iter()
.map(|id| row_spec(*id, ctx, &self.profiles))
.collect();
self.list
.render(canvas, list_rect, &rows, fonts, k, dt, true);
// While the field is open the Bitrate row IS the field: it shows the digits typed so
// far and carries the caret, so the value being edited is where the value lives.
if let (Some(text), Some(i)) = (
self.custom_bitrate.as_ref(),
ids.iter().position(|id| *id == RowId::Bitrate),
) {
rows[i].value = Some(if text.is_empty() {
"Mbps".into()
} else {
format!("{text} Mbps")
});
rows[i].value_dim = text.is_empty();
rows[i].caret = true;
}
self.list.render(
canvas,
list_rect,
&rows,
fonts,
k,
dt,
self.custom_bitrate.is_none(),
);
let detail = ids
.get(self.list.cursor)
.copied()
@@ -652,9 +870,19 @@ impl SettingsScreen {
13.0 * k,
fg(0.55),
f64::from(rect.left) + f64::from(rect.width()) / 2.0,
f64::from(rect.bottom) - detail_h + 6.0 * k,
f64::from(rect.bottom) - detail_h - tray_h + 6.0 * k,
f64::from(rect.width()) * 0.8,
);
if seat > 0.0 {
self.keyboard.render(
canvas,
fonts,
f64::from(rect.width()),
f64::from(rect.bottom),
seat,
k,
);
}
}
}
@@ -685,6 +913,7 @@ fn row_on(id: RowId, platform: crate::platform::Platform) -> bool {
| RowId::DsCapture
| RowId::GamepadUi
| RowId::GamepadUiMode
| RowId::ReduceUiResolution
| RowId::Controllers
| RowId::Licenses
);
@@ -828,7 +1057,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
if s.bitrate_kbps == 0 {
"Automatic".into()
} else {
format!("{} Mbps", s.bitrate_kbps / 1000)
bitrate_label(s.bitrate_kbps)
},
),
RowId::Compositor => (
@@ -936,6 +1165,11 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
// Phrased as the thing that is ON, not as the suppression, so "On" means the
// reduction is in effect — the same way every other toggle on this screen reads.
RowId::ReduceMotion => (None, "Reduce motion", on_off(s.reduce_motion).into()),
RowId::ReduceUiResolution => (
None,
"Reduce interface resolution",
on_off(extra_bool(s, android_keys::REDUCE_UI_RES, false)).into(),
),
RowId::LibraryView => (
None,
"Library view",
@@ -1031,7 +1265,9 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
"The host renders larger or smaller than the stream mode and this window \
resamples above 1× supersamples, below saves bandwidth."
}
RowId::Bitrate => "Automatic uses the host's default (20 Mbps).",
RowId::Bitrate => {
"Automatic uses the host's default (20 Mbps). Y types an exact rate, up to 2 Gbps."
}
RowId::Compositor => {
"Which compositor drives the virtual output — honored only if available on the host."
}
@@ -1131,6 +1367,12 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
fades. Also the gentler choice on an OLED, where a still field can sit for \
hours."
}
RowId::ReduceUiResolution => {
"Draws the menus at 1080p and lets the display scale them up. Text goes a \
little softer; the console gets much smoother on a 4K TV or projector, whose \
graphics chip is far slower than the panel in front of it. Nothing about a \
stream changes this is the interface only."
}
RowId::LibraryView => {
"Shelf shows one cover at a time, big. Grid shows about eighteen at once — \
for when you already know what you are looking for. The library's own bar \
@@ -1199,6 +1441,26 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
}
}
/// A rate as the row says it: Mbps to a gigabit, Gbps above it, and a decimal only where
/// dropping one would print two different rates the same way (12.5 Mbps, 1.5 Gbps). Rates
/// off the ladder are real — the field below types them, and the desktop shells' free-form
/// spinner has always been able to store one.
fn bitrate_label(kbps: u32) -> String {
let unit = |v: f64, suffix: &str| {
if (v - v.round()).abs() < 0.05 {
format!("{} {suffix}", v.round())
} else {
format!("{v:.1} {suffix}")
}
};
let mbps = f64::from(kbps) / 1000.0;
if kbps >= 1_000_000 {
unit(mbps / 1000.0, "Gbps")
} else {
unit(mbps, "Mbps")
}
}
fn on_off(v: bool) -> &'static str {
if v {
"On"
@@ -1263,8 +1525,21 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
.map(|i| s.render_scale = RENDER_SCALES[i])
}
RowId::Bitrate => {
let cur = BITRATES.iter().position(|b| *b == s.bitrate_kbps);
step_option(cur, BITRATES.len(), delta, wrap).map(|i| s.bitrate_kbps = BITRATES[i])
// A typed rate (or one a desktop shell's spinner stored) sits BETWEEN rungs, and
// the generic step snaps a value it cannot find to the first option — which here
// is Automatic, i.e. one nudge throws the custom rate away. Step to the rung the
// thumb is heading for instead.
let stepped = match BITRATES.iter().position(|b| *b == s.bitrate_kbps) {
Some(i) => step_option(Some(i), BITRATES.len(), delta, wrap),
None if delta < 0 => BITRATES.iter().rposition(|b| *b < s.bitrate_kbps),
// Above the top rung there is nothing higher to step to; A (which wraps) still
// comes back round to Automatic.
None => BITRATES
.iter()
.position(|b| *b > s.bitrate_kbps)
.or(if wrap { Some(0) } else { None }),
};
stepped.map(|i| s.bitrate_kbps = BITRATES[i])
}
RowId::Compositor => step_str(&COMPOSITORS, &mut s.compositor, delta, wrap),
RowId::Codec => step_str(&CODECS, &mut s.codec, delta, wrap),
@@ -1396,6 +1671,9 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
step_option(cur, all.len(), delta, wrap).map(|i| s.ui_palette = all[i].id.to_string())
}
RowId::ReduceMotion => toggle(&mut s.reduce_motion, delta, wrap),
RowId::ReduceUiResolution => {
toggle_extra(s, android_keys::REDUCE_UI_RES, false, delta, wrap)
}
RowId::LibraryView => {
let all = &crate::library::LibraryView::ALL;
let cur = crate::library::LibraryView::parse(&s.library_view);
@@ -1994,10 +2272,14 @@ pub(super) mod tests {
assert_eq!(ctx.settings.mouse_mode, "capture");
}
/// A rate that is not a rung — typed on the row, or stored by a desktop shell's
/// free-form spinner — steps to its NEIGHBOUR. Every other picker here snaps an
/// unrecognised value to its first option, which on this row is Automatic: one nudge
/// would throw away the exact rate the user went to the trouble of typing.
#[test]
fn unknown_value_snaps_to_first() {
fn an_off_ladder_rate_steps_to_its_neighbour() {
let (mut settings, pads) = ctx_parts();
settings.bitrate_kbps = 12_345; // set via a desktop shell's free-form field
settings.bitrate_kbps = 12_345;
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &[],
@@ -2012,7 +2294,81 @@ pub(super) mod tests {
t: 0.0,
};
assert!(adjust(RowId::Bitrate, 1, false, &mut ctx));
assert_eq!(ctx.settings.bitrate_kbps, 0, "snapped to Automatic");
assert_eq!(ctx.settings.bitrate_kbps, 15_000, "the rung above");
ctx.settings.bitrate_kbps = 12_345;
assert!(adjust(RowId::Bitrate, -1, false, &mut ctx));
assert_eq!(ctx.settings.bitrate_kbps, 12_000, "the rung below");
// The ends still thud rather than wrap under left/right.
ctx.settings.bitrate_kbps = 2_000_000;
assert!(!adjust(RowId::Bitrate, 1, false, &mut ctx), "the ceiling");
// …and a rung it does know steps as it always did.
ctx.settings.bitrate_kbps = 5_000;
assert!(adjust(RowId::Bitrate, -1, false, &mut ctx));
assert_eq!(ctx.settings.bitrate_kbps, 4_000);
}
/// The typed rate: Y opens the field on the Bitrate row (and nowhere else), digits land
/// in it, and closing stores what was typed — clamped to the ceiling, because four
/// digits can ask for 9999 Mbps and no client should send that.
#[test]
fn a_typed_bitrate_is_stored_and_clamped() {
let (mut settings, pads) = ctx_parts();
let library = crate::library::LibraryShared::default();
// A snapshot store, not the file one: this test SAVES, and a unit test must not
// rewrite the machine's real settings file to prove it.
let store = crate::store::SnapshotStore::new(settings.clone(), Vec::new());
let mut ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
store: &store,
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
let mut s = SettingsScreen::with_profiles(Vec::new());
let mut fx = Outbox::default();
let ids = s.row_ids(&ctx);
s.list.cursor = ids
.iter()
.position(|id| *id == RowId::Bitrate)
.expect("the bitrate row");
s.menu(MenuEvent::Secondary, &mut ctx, &mut fx);
assert!(s.editing(), "Y opens the field");
s.text_input("13x7"); // digits only: the 'x' is refused, not typed
assert!(s.edit_key(crate::input::Key::Return, &mut ctx));
assert!(!s.editing(), "Return closes it");
assert_eq!(ctx.settings.bitrate_kbps, 137_000);
s.menu(MenuEvent::Secondary, &mut ctx, &mut fx);
s.text_input("99999"); // four digits fit; the fifth is refused
assert!(s.edit_key(crate::input::Key::Return, &mut ctx));
assert_eq!(
ctx.settings.bitrate_kbps, 2_000_000,
"clamped to the ceiling"
);
// An emptied field is an abandoned edit, not a request for Automatic.
s.menu(MenuEvent::Secondary, &mut ctx, &mut fx);
assert!(s.edit_key(crate::input::Key::Return, &mut ctx));
assert_eq!(ctx.settings.bitrate_kbps, 2_000_000, "left alone");
// Y is the bitrate row's alone — on a neighbour it does nothing at all.
s.list.cursor = 0;
s.menu(MenuEvent::Secondary, &mut ctx, &mut fx);
assert!(!s.editing());
}
#[test]
fn rates_read_in_the_biggest_round_unit() {
assert_eq!(bitrate_label(20_000), "20 Mbps");
assert_eq!(bitrate_label(12_500), "12.5 Mbps");
assert_eq!(bitrate_label(1_000_000), "1 Gbps");
assert_eq!(bitrate_label(1_500_000), "1.5 Gbps");
assert_eq!(bitrate_label(2_000_000), "2 Gbps");
}
/// The Profiles section trails the settings rows: one row per catalog profile whose
@@ -2155,6 +2511,9 @@ pub(super) mod tests {
RowId::Sc2Passthrough,
RowId::DsCapture,
RowId::Controllers,
// Between the Input tab's rows and the rest of Interface: this one sits under
// Reduce motion, which is earlier in that tab than the console-UI switch.
RowId::ReduceUiResolution,
RowId::GamepadUi,
RowId::GamepadUiMode,
RowId::Licenses,
@@ -2250,11 +2609,12 @@ pub(super) mod tests {
// 2026-08 sweep found them bridged but unreachable) later passes added, minus the
// game-library toggle: this screen never read it, and the library is offered on any
// paired host now.
// 35 desktop rows + the nine Android-only ones (design android-skia-console-port.md
// D3): seven `extra`-backed settings and two platform-screen action rows.
assert_eq!(seen.len(), 44, "{seen:?}");
// 35 desktop rows + the ten Android-only ones (design android-skia-console-port.md
// D3): eight `extra`-backed settings and two platform-screen action rows.
assert_eq!(seen.len(), 45, "{seen:?}");
assert!(seen.contains(&RowId::Palette));
assert!(seen.contains(&RowId::ReduceMotion));
assert!(seen.contains(&RowId::ReduceUiResolution));
assert!(seen.contains(&RowId::AudioFormat));
// The catalog rows belong to the trailing tab, which builds them at render time.
assert!(TABS[PROFILES_TAB].1.is_empty());
+13 -1
View File
@@ -1005,8 +1005,20 @@ impl Shell {
pub(crate) fn key(&mut self, key: crate::input::Key, shift: bool, repeat: bool) -> bool {
use crate::input::Key as S;
if self.editing() {
let mut ctx = Ctx {
hosts: &self.hosts,
library: &self.library,
settings: &mut self.settings,
store: &*self.store,
platform: self.platform,
pads: &self.pads,
deck: self.deck,
fallback_ui: self.fallback_ui,
device_name: &self.device_name,
t: self.t0.elapsed().as_secs_f64(),
};
if let Some(top) = self.stack.last_mut() {
if top.edit_key(key) {
if top.edit_key(key, &mut ctx) {
return true;
}
}
+12 -1
View File
@@ -163,8 +163,19 @@ impl Shell {
let bw = lead + tw + pad_x;
let bx = (w - bw) / 2.0;
let by = h - BOTTOM_BAND * k - bh - 8.0 * k + (1.0 - slide) * 12.0 * k;
canvas.save_layer_alpha_f(None, alpha);
let rect = Rect::from_xywh(bx as f32, by as f32, bw as f32, bh as f32);
// BOUNDED to the pill. Unbounded, `save_layer` allocates an offscreen the size of
// the whole SURFACE and composites it back — on a 4K TV that is a 33 MB render
// target raised and torn down every frame, for four seconds, to fade a 34 dp pill
// (and on a box whose whole Skia budget is 64 MB, it evicts real work to do it).
//
// Everything drawn inside is inside `rect`: the pill fill, `theme::panel`'s
// hairline ON that rect, the kind mark centred in it, and text that ends a `pad_x`
// short of its right edge. There is no blur to reach further, so the outset is
// slack for the stroke rather than a computed reach — `screens::home` needs 36 k
// for the same layer only because it wraps a σ = 10 k halo.
let bounds = rect.with_outset((12.0 * k as f32, 12.0 * k as f32));
canvas.save_layer_alpha_f(Some(bounds), alpha);
canvas.draw_rrect(
skia_safe::RRect::new_rect_xy(rect, (bh / 2.0) as f32, (bh / 2.0) as f32),
&fill(crate::theme::shade(0.6)),
+30 -5
View File
@@ -67,6 +67,8 @@ impl Shell {
}
None => dt,
};
// The shaped-paragraph cache's clock, before anything asks it to draw.
fonts.begin_frame();
self.sync();
// Publish the palette's ink before ANYTHING draws — every widget, glyph and panel in
// the crate reads it (see `theme::set_ink`), so a frame that skipped this would paint
@@ -80,10 +82,14 @@ impl Shell {
crate::theme::set_reduce_motion(reduce);
self.pads = pads.to_vec();
self.glyphs = GlyphStyle::from_pref(pad_pref);
self.chip = Some(pad.map_or_else(
|| "No controller — keyboard works too".to_string(),
str::to_owned,
));
// Compared before it is rebuilt: this string changes when someone plugs a controller
// in, and was being re-allocated 60 times a second to say so. (`pads` above is left
// alone — it is at most a handful of small structs, and `PadInfo` would have to grow a
// `PartialEq` in another crate to be worth the same treatment.)
let chip = pad.unwrap_or("No controller — keyboard works too");
if self.chip.as_deref() != Some(chip) {
self.chip = Some(chip.to_owned());
}
let (full_w, full_h) = (f64::from(viewport.width), f64::from(viewport.height));
let ins = viewport.insets;
@@ -353,7 +359,26 @@ impl LayerEnv<'_> {
scale: f64,
) -> Vec<(crate::glyphs::HintKey, Rect)> {
let canvas = self.canvas;
canvas.save_layer_alpha_f(None, alpha.clamp(0.0, 1.0) as f32);
// Only RAISE the layer when it carries something. A settled screen is painted at full
// alpha, unscaled and unslid, and an unbounded `save_layer` allocates an offscreen the
// size of the whole SURFACE and composites it back — so the console was paying for one
// full-screen offscreen on every frame it sat still, to apply an alpha of 1. Skia does
// not elide it either: `SkCanvas::saveLayerAlphaf` forwards alpha ≥ 1 straight to
// `saveLayer(bounds, nullptr)`, whose only early-out is an empty clip.
//
// Dropping the layer is pixel-identical rather than merely close: nothing in this crate
// draws with a blend mode other than `SrcOver`, and `SrcOver` is associative, so
// compositing the draws into a transparent layer and then over the backdrop lands on
// exactly the value drawing them straight onto the backdrop does. (It is also why the
// text stays grayscale-AA — no LCD subpixel text to gain or lose an isolation.) Same
// reasoning `screens::home` already bounds its per-tile layer by.
let layered = alpha < 0.999 || (scale - 1.0).abs() > 0.001 || dy.abs() > 0.001;
if layered {
canvas.save_layer_alpha_f(None, alpha.clamp(0.0, 1.0) as f32);
} else {
// Still a save: the transform below is undone by the same `restore`.
canvas.save();
}
canvas.translate((0.0, dy as f32));
let (cx, cy) = ((self.w / 2.0) as f32, (self.h / 2.0) as f32);
canvas.translate((cx, cy));
+168 -45
View File
@@ -7,12 +7,15 @@
use anyhow::{anyhow, Result};
use skia_safe::textlayout::{
FontCollection, ParagraphBuilder, ParagraphStyle, TextAlign, TextStyle, TypefaceFontProvider,
FontCollection, Paragraph, ParagraphBuilder, ParagraphStyle, TextAlign, TextStyle,
TypefaceFontProvider,
};
use skia_safe::{
gradient, Canvas, Color4f, Font, FontMgr, FontStyle, MaskFilter, Paint, PathEffect, Point,
RRect, Rect, TileMode, Typeface,
};
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
// --- Paint ----------------------------------------------------------------------------------
@@ -521,7 +524,7 @@ pub(crate) const EDGE_INSET: f64 = 24.0;
// --- Typography ---------------------------------------------------------------------------
/// Geist weights the console uses (matching the Apple client's `.geist(size, weight)`).
#[derive(Clone, Copy, PartialEq, Eq)]
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum W {
Regular,
Medium,
@@ -538,6 +541,111 @@ pub(crate) struct Fonts {
semibold: Typeface,
bold: Typeface,
collection: FontCollection,
/// Shaped paragraphs, keyed by everything that shapes one ([`ParaKey`]).
///
/// `Paragraph::layout` runs the whole shaper — HarfBuzz, line breaking, font fallback —
/// and the shell re-built every paragraph on screen from scratch EVERY frame, which on a
/// TV box is the largest CPU cost in the frame. Position is deliberately not part of the
/// key (`paint` takes it), so one shaped paragraph serves a string wherever it moves to:
/// a scrolling shelf and a screen transition both re-use it rather than re-shaping.
///
/// `RefCell` because every draw path here takes `&self` and the console's shell is
/// single-threaded by construction (one render thread owns it on all three ABIs).
paragraphs: RefCell<HashMap<ParaKey, Cached>>,
/// The frame counter [`Fonts::begin_frame`] bumps — the cache's liveness clock.
frame: Cell<u64>,
}
/// The three paragraph shapes the console draws. A single tag rather than a loose
/// `(TextAlign, Option<usize>)` pair because it is half of a hash key, and because those two
/// were never independent — every call site picks one of these three.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum Para {
/// Centred, wrapping freely.
Centered,
/// Left-aligned, wrapping freely.
Leading,
/// Left-aligned, clamped to one ellipsized line.
Heading,
}
impl Para {
/// The paragraph style this shape asks for: alignment, and the line clamp if it has one.
fn style(self) -> (TextAlign, Option<usize>) {
match self {
Para::Centered => (TextAlign::Center, None),
Para::Leading => (TextAlign::Left, None),
Para::Heading => (TextAlign::Left, Some(1)),
}
}
}
/// Everything [`shape`] bakes into a laid-out `Paragraph` — change any of it and the shaped
/// result differs, so all of it is in the key.
///
/// The floats ride as bits: the sizes and widths are all `k`-scaled, so they are never whole
/// numbers, and `f64`/`f32` are not `Hash`. Bit equality is the right test anyway — the same
/// `k` produces the same bits, and a different `k` must re-shape.
#[derive(PartialEq, Eq, Hash)]
struct ParaKey {
text: String,
kind: Para,
weight: W,
size: u64,
max_w: u32,
/// ARGB, as `[a, r, g, b]`.
color: [u8; 4],
}
/// One shaped paragraph and the frame it was last drawn on.
struct Cached {
para: Paragraph,
used: u64,
}
/// How many shaped paragraphs stay resident before the cold ones are dropped. A screen draws
/// well under this; the ceiling exists for the library, where paging a large catalogue walks
/// through thousands of titles and every one of them would otherwise be kept forever.
const PARA_CACHE_MAX: usize = 512;
/// Build and lay out one paragraph — the shaping [`Fonts::draw_paragraph`]'s cache exists to
/// do exactly once per distinct key.
///
/// A free function rather than a method because the cache hands it a `&ParaKey` borrowed out
/// of the map it is inserting into, which rules out holding `&self` across the call.
fn shape(collection: &FontCollection, key: &ParaKey) -> Paragraph {
let (align, clamp) = key.kind.style();
let mut style = ParagraphStyle::new();
style.set_text_align(align);
if let Some(lines) = clamp {
style.set_max_lines(lines);
style.set_ellipsis("\u{2026}");
}
let mut ts = TextStyle::new();
ts.set_font_families(&["Geist"]);
ts.set_font_size(f64::from_bits(key.size) as f32);
let [a, r, g, b] = key.color;
ts.set_color(skia_safe::Color::from_argb(a, r, g, b));
ts.set_font_style(match key.weight {
W::Regular => FontStyle::normal(),
W::Medium => FontStyle::new(
skia_safe::font_style::Weight::MEDIUM,
skia_safe::font_style::Width::NORMAL,
skia_safe::font_style::Slant::Upright,
),
W::SemiBold => FontStyle::new(
skia_safe::font_style::Weight::SEMI_BOLD,
skia_safe::font_style::Width::NORMAL,
skia_safe::font_style::Slant::Upright,
),
W::Bold => FontStyle::bold(),
});
style.set_text_style(&ts);
let mut builder = ParagraphBuilder::new(&style, collection.clone());
builder.add_text(&key.text);
let mut p = builder.build();
p.layout(f32::from_bits(key.max_w));
p
}
/// The Geist faces ride in the binary — the console must look right on a bare gamescope
@@ -574,6 +682,8 @@ pub(crate) fn build_fonts() -> Result<Fonts> {
semibold,
bold,
collection,
paragraphs: RefCell::new(HashMap::new()),
frame: Cell::new(0),
})
}
@@ -641,50 +751,59 @@ impl Fonts {
}
}
/// `clamp` caps the paragraph at that many lines and ellipsizes what doesn't fit; `None`
/// wraps freely. A heading has to clamp — an over-long one used to grow DOWNWARD into the
/// screen's content, which is why both other clients pin theirs to one line.
/// Start a frame — the paragraph cache's clock. Anything not drawn on this frame or the
/// one before it becomes a candidate for eviction, so the live set is exactly "what the
/// last two frames drew". The shell calls this once per `render_in`.
pub(crate) fn begin_frame(&self) {
self.frame.set(self.frame.get().wrapping_add(1));
}
/// Draw a shaped paragraph, building and laying it out only the first time this exact
/// (text, shape, weight, size, width, colour) is asked for — see [`Fonts::paragraphs`].
/// `at` is the paragraph's TOP-LEFT, and is deliberately not part of the key.
#[allow(clippy::too_many_arguments)]
fn paragraph(
fn draw_paragraph(
&self,
canvas: &Canvas,
text: &str,
kind: Para,
w: W,
size: f64,
color: Color4f,
align: TextAlign,
max_w: f64,
clamp: Option<usize>,
) -> skia_safe::textlayout::Paragraph {
let mut style = ParagraphStyle::new();
style.set_text_align(align);
if let Some(lines) = clamp {
style.set_max_lines(lines);
style.set_ellipsis("\u{2026}");
}
let mut ts = TextStyle::new();
ts.set_font_families(&["Geist"]);
ts.set_font_size(size as f32);
ts.set_color(color.to_color());
ts.set_font_style(match w {
W::Regular => FontStyle::normal(),
W::Medium => FontStyle::new(
skia_safe::font_style::Weight::MEDIUM,
skia_safe::font_style::Width::NORMAL,
skia_safe::font_style::Slant::Upright,
),
W::SemiBold => FontStyle::new(
skia_safe::font_style::Weight::SEMI_BOLD,
skia_safe::font_style::Width::NORMAL,
skia_safe::font_style::Slant::Upright,
),
W::Bold => FontStyle::bold(),
at: Point,
) {
let frame = self.frame.get();
// ponytail: the key owns its text, so a HIT still costs one small `String` allocation
// where a borrowed-key lookup would cost none. Deliberate — it is a rounding error
// against the shape it replaces, and the alternatives (hash-only keys, `hashbrown`'s
// raw entry) trade a real collision risk or a dependency for it. Revisit only if a
// profile ever puts this line on the board.
let key = ParaKey {
text: text.to_owned(),
kind,
weight: w,
size: size.to_bits(),
max_w: (max_w as f32).to_bits(),
color: {
// The 8-bit ARGB the paragraph actually bakes, not the `Color4f` it came
// from — two float colours that round to the same pixel share an entry.
let c = color.to_color();
[c.a(), c.r(), c.g(), c.b()]
},
};
let mut cache = self.paragraphs.borrow_mut();
let entry = cache.entry(key).or_insert_with_key(|k| Cached {
para: shape(&self.collection, k),
used: frame,
});
style.set_text_style(&ts);
let mut b = ParagraphBuilder::new(&style, self.collection.clone());
b.add_text(text);
let mut p = b.build();
p.layout(max_w as f32);
p
entry.used = frame;
entry.para.paint(canvas, at);
// Drop what the last two frames did not draw. Every entry still on screen is
// re-stamped above on the frame it appears in, so this only reaps strings that left.
if cache.len() > PARA_CACHE_MAX {
cache.retain(|_, c| c.used + 1 >= frame);
}
}
/// Centered, wrapping paragraph with `y` as its TOP edge (shaping + CJK fallback).
@@ -700,8 +819,8 @@ impl Fonts {
y: f64,
max_w: f64,
) {
let p = self.paragraph(text, w, size, color, TextAlign::Center, max_w, None);
p.paint(canvas, Point::new((cx - max_w / 2.0) as f32, y as f32));
let at = Point::new((cx - max_w / 2.0) as f32, y as f32);
self.draw_paragraph(canvas, text, Para::Centered, w, size, color, max_w, at);
}
/// [`centered`](Self::centered)'s LEFT-ALIGNED twin: `x` is the text's left edge, `y` its
@@ -719,8 +838,8 @@ impl Fonts {
y: f64,
max_w: f64,
) {
let p = self.paragraph(text, w, size, color, TextAlign::Left, max_w, None);
p.paint(canvas, Point::new(x as f32, y as f32));
let at = Point::new(x as f32, y as f32);
self.draw_paragraph(canvas, text, Para::Leading, w, size, color, max_w, at);
}
/// A screen's heading: left-aligned at `x`, top edge at `y`, clamped to ONE ellipsized
@@ -743,8 +862,8 @@ impl Fonts {
y: f64,
max_w: f64,
) {
let p = self.paragraph(text, w, size, color, TextAlign::Left, max_w, Some(1));
p.paint(canvas, Point::new(x as f32, y as f32));
let at = Point::new(x as f32, y as f32);
self.draw_paragraph(canvas, text, Para::Heading, w, size, color, max_w, at);
}
/// A single shaped line, middle-ellipsized to `max_w`, drawn at a baseline. For
@@ -770,8 +889,12 @@ impl Fonts {
let ell_w = font.measure_str(ell, None).0;
let mut fitted = String::new();
let mut used = 0.0f32;
// The char goes onto the stack to be measured, not into a fresh `String` per character:
// this runs for every over-long title on screen, every frame, and the allocation was
// the bulk of it. `encode_utf8` writes the same bytes `to_string` would have.
let mut buf = [0u8; 4];
for ch in text.chars() {
let cw = font.measure_str(ch.to_string().as_str(), None).0;
let cw = font.measure_str(&*ch.encode_utf8(&mut buf), None).0;
if used + cw + ell_w > max_w as f32 {
break;
}
+1 -1
View File
@@ -40,7 +40,7 @@ const SCROLL_HORIZONTAL: u32 = 1;
/// `wl_output.name` — the connector name we match the streamed head on — arrived in v4. Nothing
/// else we ask of an output needs more than v1, so a lower advert only costs us the names (and
/// with them the ability to aim absolute input; see [`index_named`]). Same constant, same reason,
/// as `pf_vdisplay`'s `kwin_dpms`.
/// as `pf_vdisplay`'s `panel_dpms`.
const WL_OUTPUT_MAX: u32 = 4;
/// One `wl_output` the compositor has advertised.
+18 -7
View File
@@ -867,14 +867,25 @@ mod kwin;
#[path = "vdisplay/linux/kwin_output_mgmt.rs"]
mod kwin_output_mgmt;
// DPMS control of the box's live KDE desktop (org_kde_kwin_dpms) — how a bare-spawn gamescope
// session honors `Topology::Exclusive`: the spawn is its own headless compositor, so the desktop's
// physical outputs can't be *disabled* (KWin refuses zero enabled outputs and no output there is
// ours) — they are put to DPMS-off for the stream instead, refcounted across concurrent spawns.
// Consumed by `gamescope` (best-effort, with kscreen fallback).
// DPMS control of the box's own physical panels — how a gamescope session (which owns no output on
// the box's desktop) honors `Topology::Exclusive`. Dispatches per desktop: KDE over
// org_kde_kwin_dpms, sway and Hyprland over their own IPC, and `drm_dpms` for a box with no
// desktop at all. GNOME is the one it cannot serve — Mutter exposes no DPMS to clients.
// The desktop's outputs can't be *disabled* the way the desktop backends do it (KWin refuses zero
// enabled outputs, and no output there is ours to keep), so DPMS-off is the honest translation:
// the desk is untouched, the panels just go dark. Refcounted across concurrent spawns; consumed by
// `gamescope` on both its owning routes, best-effort throughout.
#[cfg(target_os = "linux")]
#[path = "vdisplay/linux/kwin_dpms.rs"]
mod kwin_dpms;
#[path = "vdisplay/linux/panel_dpms.rs"]
mod panel_dpms;
// The compositor-independent half of the same policy: turn the CRTCs off over DRM directly, for a
// box with no desktop to ask (Game Mode runs gamescope and no KWin, and is exactly where the
// operator's TV is lit by the box itself). Reached from `panel_dpms`'s "not KDE" arm, which is what
// owns the refcount and the hold.
#[cfg(target_os = "linux")]
#[path = "vdisplay/linux/drm_dpms.rs"]
mod drm_dpms;
#[cfg(target_os = "windows")]
#[path = "vdisplay/windows/manager.rs"]
@@ -0,0 +1,368 @@
//! Compositor-independent panel darkening over DRM — how a box with **no desktop compositor**
//! honors [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
//!
//! [`crate::panel_dpms`] asks KWin to turn the panels off, which is the right answer whenever there
//! is a KDE desktop to ask. There often isn't. A box sitting in **Game Mode** runs gamescope and no
//! KWin at all, so that path declines — and Game Mode is precisely the deployment where the
//! operator's TV is lit by the box itself. Measured on the Nobara VM (2026-08-24): after the
//! takeover idles the box's gaming session, `card0-HDMI-A-1` sits at `enabled=enabled dpms=On`
//! indefinitely. Nothing blanks on its own — when no client holds DRM master the kernel simply
//! keeps the CRTC configured, and fbcon owns it.
//!
//! So ask the kernel directly. The sequence, all of it measured on that box:
//!
//! 1. `open("/dev/dri/cardN")` — permitted for the ordinary session user, because logind puts a
//! **uaccess ACL** on the node for whoever holds the active seat (`crw-rw----+`). No root, no
//! polkit, no group: this is the same access every local compositor gets.
//! 2. `DRM_IOCTL_SET_MASTER` — succeeds while no one else is master, which is exactly the state the
//! takeover has just produced by idling the box's session. If it FAILS, someone else is driving
//! that card (a live compositor, a foreign gamescope) and we decline: darkening a panel out from
//! under its owner is not ours to do, and on the Attach route it would darken the very picture
//! being streamed.
//! 3. `DRM_IOCTL_MODE_GETRESOURCES` (count pass, then data pass) for the CRTC ids, and
//! `DRM_IOCTL_MODE_SETCRTC` with `fb_id = 0, mode_valid = 0, count_connectors = 0` on each one
//! that is actually driving something. That is a modeset to "off": the connector goes
//! `enabled=disabled dpms=Off`, which is the same end state `kscreen-doctor --dpms off` reaches
//! through KWin.
//! 4. `DRM_IOCTL_DROP_MASTER`, and **keep the fd open**.
//!
//! Step 4 is the part worth reading twice. The darkness **survives dropping master** (measured), so
//! we hand mastering rights straight back — the box's own gamescope must be able to take the card
//! when the restore relaunches its session, and a host still holding master would starve it. What
//! holds the panel dark is the open fd, not the mastership.
//!
//! **The re-light is `close(fd)`, and that is the whole of it.** The kernel's last-close handling
//! restores the console and the panel comes back lit (measured: `enabled=enabled dpms=On` within
//! 2 s of the close). There is no saved mode to replay and no restore that can half-fail — which
//! also means **crash safety comes free**, the same property [`crate::panel_dpms`] gets from DPMS
//! being non-persistent: a host that dies holding this has its fds closed by the kernel, and the
//! box lights up. Nothing to journal, nothing to sweep at startup. (Contrast the Windows
//! `pnp_disable_monitors` path, which needs a recovery journal precisely because its disable
//! survives everything.)
//!
//! Best-effort throughout, like every other arm of this policy: a box with no `/dev/dri` at all, a
//! card whose master is held by someone else, or a card with nothing lit simply contributes
//! nothing and the stream proceeds.
use std::fs::File;
use std::os::fd::AsRawFd;
use std::path::Path;
// ---------------------------------------------------------------- the kernel ABI
//
// `include/uapi/drm/drm.h` and `drm_mode.h`. Hand-declared rather than pulled from a crate: this is
// four ioctls and three plain-old-data structs, and the const asserts below pin every layout that
// could drift. `_IO('d', nr)` / `_IOWR('d', nr, T)` encoded by hand — the sizes are in the names.
/// `DRM_IOCTL_SET_MASTER` — `_IO('d', 0x1e)`.
const DRM_IOCTL_SET_MASTER: libc::c_ulong = 0x641e;
/// `DRM_IOCTL_DROP_MASTER` — `_IO('d', 0x1f)`.
const DRM_IOCTL_DROP_MASTER: libc::c_ulong = 0x641f;
/// `DRM_IOCTL_MODE_GETRESOURCES` — `_IOWR('d', 0xA0, drm_mode_card_res)`, 64-byte payload.
const DRM_IOCTL_MODE_GETRESOURCES: libc::c_ulong = 0xC040_64A0;
/// `DRM_IOCTL_MODE_GETCRTC` — `_IOWR('d', 0xA1, drm_mode_crtc)`, 104-byte payload.
const DRM_IOCTL_MODE_GETCRTC: libc::c_ulong = 0xC068_64A1;
/// `DRM_IOCTL_MODE_SETCRTC` — `_IOWR('d', 0xA2, drm_mode_crtc)`, 104-byte payload.
const DRM_IOCTL_MODE_SETCRTC: libc::c_ulong = 0xC068_64A2;
#[repr(C)]
#[derive(Default)]
struct DrmModeCardRes {
fb_id_ptr: u64,
crtc_id_ptr: u64,
connector_id_ptr: u64,
encoder_id_ptr: u64,
count_fbs: u32,
count_crtcs: u32,
count_connectors: u32,
count_encoders: u32,
min_width: u32,
max_width: u32,
min_height: u32,
max_height: u32,
}
#[repr(C)]
#[derive(Clone, Copy)]
struct DrmModeModeinfo {
clock: u32,
hdisplay: u16,
hsync_start: u16,
hsync_end: u16,
htotal: u16,
hskew: u16,
vdisplay: u16,
vsync_start: u16,
vsync_end: u16,
vtotal: u16,
vscan: u16,
vrefresh: u32,
flags: u32,
type_: u32,
name: [u8; 32],
}
#[repr(C)]
#[derive(Clone, Copy)]
struct DrmModeCrtc {
set_connectors_ptr: u64,
count_connectors: u32,
crtc_id: u32,
fb_id: u32,
x: u32,
y: u32,
gamma_size: u32,
mode_valid: u32,
mode: DrmModeModeinfo,
}
// The ioctl numbers above encode their payload size (0x40 = 64, 0x68 = 104). If a struct here ever
// disagrees with that, the kernel reads or writes the wrong number of bytes — so pin it at compile
// time rather than discovering it as a corrupted modeset on someone's TV.
const _: () = assert!(std::mem::size_of::<DrmModeCardRes>() == 0x40);
const _: () = assert!(std::mem::size_of::<DrmModeModeinfo>() == 68);
const _: () = assert!(std::mem::size_of::<DrmModeCrtc>() == 0x68);
impl Default for DrmModeCrtc {
fn default() -> Self {
// SAFETY: both structs are `repr(C)` plain old data — integers and a `[u8; 32]`, no
// padding invariants, no pointers that must be valid, and no `Drop`. An all-zero value is
// a legal instance, and is exactly what the ioctls want for "no connectors, no mode".
unsafe { std::mem::zeroed() }
}
}
/// One card we have darkened: the open fd is the hold. Dropping this closes it, and the kernel
/// re-lights — see the module docs.
pub struct DrmDarken {
/// Kept solely for its `Drop`. The panel stays dark exactly as long as these are open.
_cards: Vec<File>,
/// Which `/dev/dri/cardN` we actually turned something off on — logging only.
pub darkened: Vec<String>,
}
/// `ioctl(fd, req, &mut arg)` for the modeset structs, returning the raw `errno` on failure.
///
/// Split out so each call site is one line and there is exactly one `unsafe` block to justify
/// instead of five near-identical ones.
fn ioctl<T>(fd: libc::c_int, req: libc::c_ulong, arg: &mut T) -> std::io::Result<()> {
// SAFETY: `fd` is an open DRM node owned by the caller for the whole call; `req` is one of the
// five `_IO`/`_IOWR` codes declared above, each paired with the `T` its size field names (the
// const asserts pin that); and `arg` is a live, uniquely-borrowed, `repr(C)` value of that
// exact type, so the kernel's read/write of `size_of::<T>()` bytes stays inside it.
let rc = unsafe { libc::ioctl(fd, req, arg as *mut T) };
if rc < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
}
}
/// Turn every lit CRTC on every DRM card off, and hold them off. `None` when nothing was darkened
/// — no cards, none masterable, or none lit — and therefore nothing to restore.
pub fn darken() -> Option<DrmDarken> {
let mut cards = Vec::new();
let mut darkened = Vec::new();
for entry in std::fs::read_dir("/dev/dri").ok()?.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
// `cardN` only: `renderD*` is the render node (no modesetting at all) and `by-path/` is a
// directory of symlinks to the same nodes.
if !name.starts_with("card") {
continue;
}
match darken_card(&path) {
// Masterable, but nothing on this card was lit. Its fd is dropped here, which is
// correct: we changed nothing, so there is nothing to hold.
Ok((_, 0)) => {}
Ok((card, n)) => {
tracing::debug!(card = name, crtcs = n, "DRM: CRTCs off");
darkened.push(name.to_string());
// ⚠ HOLD THE FD THAT DID THE WORK. Closing it and re-opening does not survive the
// round trip: the close is the kernel's LAST close on that device, which restores
// the console and re-lights the panel — the fresh fd then holds nothing. Measured
// on the Nobara VM 2026-08-24, where exactly that shape reported `darkened
// cards: ["card0"]` while the connector sat at `enabled=enabled dpms=On`.
cards.push(card);
}
Err(why) => tracing::debug!(card = name, %why, "DRM: not ours to darken"),
}
}
if darkened.is_empty() {
return None;
}
Some(DrmDarken {
_cards: cards,
darkened,
})
}
/// Darken one card, returning the open fd **and** how many CRTCs were actually turned off.
///
/// The fd comes back with the count because the caller MUST keep this exact one to hold the panel
/// dark: closing it is the kernel's last close on the device, which restores the console. A card
/// that reports 0 can have its fd dropped freely — nothing was changed to undo.
fn darken_card(path: &Path) -> std::io::Result<(File, usize)> {
let card = File::options().read(true).write(true).open(path)?;
let fd = card.as_raw_fd();
// Someone else driving this card (a live compositor, a foreign gamescope) ⇒ not ours. This is
// also what keeps the Attach route honest without needing to know about it here.
ioctl(fd, DRM_IOCTL_SET_MASTER, &mut 0u64)?;
// Count pass: every pointer NULL, the kernel fills in the counts.
let mut res = DrmModeCardRes::default();
ioctl(fd, DRM_IOCTL_MODE_GETRESOURCES, &mut res)?;
let n = res.count_crtcs as usize;
if n == 0 {
let _ = ioctl(fd, DRM_IOCTL_DROP_MASTER, &mut 0u64);
return Ok((card, 0));
}
// Data pass: hand back a buffer sized by that count and ask again.
let mut ids = vec![0u32; n];
let mut res = DrmModeCardRes {
crtc_id_ptr: ids.as_mut_ptr() as u64,
count_crtcs: n as u32,
..Default::default()
};
ioctl(fd, DRM_IOCTL_MODE_GETRESOURCES, &mut res)?;
// The kernel may report FEWER than the count pass promised (a hotplug between the two); it
// never reports more than the buffer we sized, so trust the second count.
ids.truncate(res.count_crtcs as usize);
let mut off = 0usize;
for id in ids {
let mut crtc = DrmModeCrtc {
crtc_id: id,
..Default::default()
};
if ioctl(fd, DRM_IOCTL_MODE_GETCRTC, &mut crtc).is_err() {
continue;
}
// Only touch a CRTC that is actually driving a display. Disabling an already-dark one is a
// harmless no-op, but counting it would make the log claim a panel went off that never was
// on — and that verdict is the whole point of reporting a count at all.
if crtc.mode_valid == 0 && crtc.fb_id == 0 {
continue;
}
// The modeset to "off": no framebuffer, no mode, no connectors.
let mut disable = DrmModeCrtc {
crtc_id: id,
..Default::default()
};
if ioctl(fd, DRM_IOCTL_MODE_SETCRTC, &mut disable).is_ok() {
off += 1;
}
}
// Hand mastering back immediately: the darkness does not depend on holding it (measured), and
// the box's own gamescope needs to be able to take this card when the restore relaunches its
// session. Keeping it would turn a dark panel into a session that cannot start.
let _ = ioctl(fd, DRM_IOCTL_DROP_MASTER, &mut 0u64);
Ok((card, off))
}
#[cfg(test)]
mod tests {
use super::{DrmModeCardRes, DrmModeCrtc, DrmModeModeinfo};
/// The layouts the ioctl numbers encode. The `const` asserts above already fail the BUILD on
/// drift; this restates them as a test so the reason is greppable from a failure, and pins the
/// two field offsets the count/data-pass dance actually depends on.
#[test]
fn the_abi_structs_match_the_ioctl_payload_sizes() {
assert_eq!(std::mem::size_of::<DrmModeCardRes>(), 0x40, "_IOWR 0x40");
assert_eq!(std::mem::size_of::<DrmModeModeinfo>(), 68);
assert_eq!(std::mem::size_of::<DrmModeCrtc>(), 0x68, "_IOWR 0x68");
// `crtc_id_ptr` is the second u64 — the field the data pass points at its id buffer. A
// reorder here would hand the kernel the framebuffer-id pointer instead.
assert_eq!(std::mem::offset_of!(DrmModeCardRes, crtc_id_ptr), 8);
assert_eq!(std::mem::offset_of!(DrmModeCardRes, count_crtcs), 36);
// `mode` must sit right after the seven u32s, or SETCRTC reads a mode we never wrote.
assert_eq!(std::mem::offset_of!(DrmModeCrtc, mode), 36);
}
/// ON GLASS. Darken this box's panels for real and read the verdict back out of sysfs.
///
/// Run it on a box with a **connected head and no compositor holding the card** — i.e. exactly
/// the takeover state this module exists for. On the Nobara VM:
///
/// ```sh
/// # idle the box's gaming session first (what stop_autologin_sessions does), then:
/// ./pf_vdisplay-<hash> --ignored --nocapture drm_dpms
/// ```
///
/// Skips itself (rather than failing) when nothing was ours to darken, because that is the
/// honest outcome on a dev box with a live desktop — the card is already mastered.
#[test]
#[ignore = "on glass: needs a connected head and no compositor holding /dev/dri/card*"]
fn live_the_panels_go_dark_and_come_back() {
fn connectors() -> Vec<(String, String, String)> {
let mut v = Vec::new();
let Ok(rd) = std::fs::read_dir("/sys/class/drm") else {
return v;
};
for e in rd.flatten() {
let p = e.path();
let rd = |f: &str| {
std::fs::read_to_string(p.join(f))
.map(|s| s.trim().to_string())
.unwrap_or_default()
};
if rd("status") == "connected" {
v.push((
e.file_name().to_string_lossy().into_owned(),
rd("enabled"),
rd("dpms"),
));
}
}
v.sort();
v
}
let before = connectors();
println!("before: {before:?}");
assert!(
!before.is_empty(),
"no connected head — this test needs one to mean anything"
);
let Some(hold) = super::darken() else {
println!("nothing was ours to darken (card already mastered?) — skipping");
return;
};
println!("darkened cards: {:?}", hold.darkened);
std::thread::sleep(std::time::Duration::from_secs(2));
let during = connectors();
println!("during: {during:?}");
drop(hold);
std::thread::sleep(std::time::Duration::from_secs(2));
let after = connectors();
println!("after: {after:?}");
// The claim: every head that was lit went dark, and every one of them came back.
for (name, en, dpms) in &during {
assert_eq!(dpms, "Off", "{name} should be DPMS-off while held ({en})");
}
assert_eq!(
after, before,
"dropping the hold must restore exactly the state we found"
);
}
/// A zeroed `DrmModeCrtc` IS the disable request — that is the only thing `Default` is for
/// here, so a change that made it non-zero would silently stop disabling anything.
#[test]
fn the_default_crtc_is_the_disable_request() {
let c = DrmModeCrtc::default();
assert_eq!(c.fb_id, 0, "a framebuffer would keep the CRTC lit");
assert_eq!(
c.mode_valid, 0,
"a valid mode would re-modeset, not disable"
);
assert_eq!(c.count_connectors, 0);
assert_eq!(c.set_connectors_ptr, 0);
}
}
@@ -71,7 +71,7 @@ pub struct GamescopeDisplay {
/// ran `apply_input_env`); `create` then falls through to the bare spawn, the safe default.
route: Option<crate::GamescopeRoute>,
/// The topology-restore action the bare-spawn `create` prepared under `Topology::Exclusive` —
/// the release of this display's [`crate::kwin_dpms`] darken hold — pending pickup by the
/// the release of this display's [`crate::panel_dpms`] darken hold — pending pickup by the
/// registry via [`VirtualDisplay::take_topology_restore`], so it runs at the display's
/// teardown (§6.1) and never before.
pending_restore: Option<Box<dyn FnOnce() + Send>>,
@@ -176,6 +176,63 @@ const SWITCH_HONOR_GRACE: Duration = Duration::from_secs(120);
/// [`restore_takeover_on_startup`] is what covers a host that died holding one.
static IDLE_DROPIN_ARMED: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
/// Whether the MANAGED route currently holds a [`crate::panel_dpms`] darken hold for
/// `Topology::Exclusive`.
///
/// The managed route cannot register its release the way a bare spawn does. A spawn reports
/// `DisplayOwnership::Owned`, so `registry::acquire` picks its `take_topology_restore()` up and
/// runs it at teardown; managed reports `SessionManaged`, and that function returns for anything
/// not `Owned` **above** the pickup — deliberately, because this module owns the managed
/// lifecycle instead. So this module owns the release too: [`do_restore_tv_session`], the one
/// teardown every managed path funnels through.
///
/// A plain bool rather than a count because the managed SESSION is what is darkened, not each
/// connect: it survives client disconnects (that is the whole point of [`MANAGED_SESSION`]), and a
/// same-mode reconnect reuses it warm without a relaunch. Acquiring per connect would ratchet
/// `panel_dpms`'s refcount up with no matching releases and pin the panel dark for the host's life.
static MANAGED_DARKEN_HELD: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
/// The 0→1 edge: should this call actually take a `panel_dpms` hold? Pure, and split from
/// [`managed_darken_acquire`] so the balance rule is testable without a live compositor — the same
/// shape as `panel_dpms::Holds::acquire_edge`, and for the same reason.
fn managed_darken_acquire_edge(held: &mut bool, exclusive: bool) -> bool {
if !exclusive || *held {
return false;
}
*held = true;
true
}
/// The 1→0 edge: should this call actually release one?
fn managed_darken_release_edge(held: &mut bool) -> bool {
if !*held {
return false;
}
*held = false;
true
}
/// Take the managed route's darken hold, once, if `exclusive` and we don't already hold one.
fn managed_darken_acquire(exclusive: bool) {
let mut held = MANAGED_DARKEN_HELD
.lock()
.unwrap_or_else(|e| e.into_inner());
if managed_darken_acquire_edge(&mut held, exclusive) {
crate::panel_dpms::acquire_stream_darken();
}
}
/// Drop it, if held. Idempotent, because it is called unconditionally from the restore — which is
/// exactly what makes it safe to put above every early return there.
fn managed_darken_release() {
let mut held = MANAGED_DARKEN_HELD
.lock()
.unwrap_or_else(|e| e.into_inner());
if managed_darken_release_edge(&mut held) {
crate::panel_dpms::release_stream_darken();
}
}
/// A pending debounced TV-session restore: the instant [`do_restore_tv_session`] should fire after
/// the last client disconnect. A reconnect inside the window clears it (and reuses the still-warm
/// managed session), so we never stop+relaunch gamescope per connect — that per-connect teardown is
@@ -367,6 +424,38 @@ fn takeover_state_is_live(state: &TakeoverState) -> bool {
|| state.forced_screen_env
}
/// Restart the box's own autologin gaming session(s) after a leftover idle drop-in was swept off
/// a host that died holding one ([`restore_takeover_on_startup`]).
///
/// Gated on the box actually being dark ([`box_session_live`]): if the user is already in game mode
/// or on a desktop, the drop-in we removed was inert and bouncing their session would be the bug.
/// Only an ACTIVE instance is restarted — under a just-removed idle drop-in, active means "running
/// the sleep"; an inactive one is a leftover the display manager will handle on its own.
fn hand_back_idled_units_after_crash() {
if box_session_live() {
return; // something is already drawing — the drop-in was inert
}
let units: Vec<String> = listed_autologin_units()
.into_iter()
.filter(|(_, active)| active == "active")
.map(|(unit, _)| unit)
.collect();
if units.is_empty() {
return;
}
tracing::warn!(
?units,
"gamescope: the box's Game Mode is running the dead host's idle placeholder and its panel \
is dark restarting it"
);
for unit in &units {
if let RestoreVerb::Failed(why) = issue_restore_verb(&["restart", unit]) {
tracing::error!(unit, status = %why, "gamescope: could not restart it");
}
}
ensure_box_session_or_escalate(&units);
}
/// On host startup, restore the TV's gaming session if a previous host instance took it over and
/// crashed before restoring (`design/gamemode-and-dedicated-sessions.md` A3). Loads the persisted
/// [`TakeoverState`] into the statics and schedules a restore after a short reconnect grace (so a
@@ -399,6 +488,13 @@ pub fn restore_takeover_on_startup() {
"gamescope: removed a leftover idle drop-in from a previous host instance — the box's \
own Game Mode session would have started and then done nothing"
);
// Removing the FILE does not touch the unit RUNNING under it. That unit's `ExecStart` was
// replaced with a sleep, so it is `active` and drawing nothing, and nothing below will
// restart it: the takeover file may be absent, unparseable, or not `takeover_state_is_live`
// — and all three of those exits used to leave the box sitting on a dark panel with its
// Game Mode "running". A host killed mid-stream (SIGKILL, OOM, a yanked update) lands
// exactly there, and on glass it is indistinguishable from broken hardware. Hand it back.
hand_back_idled_units_after_crash();
}
let Ok(bytes) = std::fs::read(takeover_state_path()) else {
return; // no takeover file — clean start
@@ -498,7 +594,7 @@ impl VirtualDisplay for GamescopeDisplay {
// The DPMS darken-hold release the bare-spawn `create` registered (Exclusive topology
// only). The registry stores it on this display's entry and runs it at teardown — which,
// for gamescope, is the display's OWN teardown: every spawn is its own group, and the
// cross-session ordering lives in `kwin_dpms`'s refcount, not in the group float.
// cross-session ordering lives in `panel_dpms`'s refcount, not in the group float.
self.pending_restore.take()
}
@@ -544,8 +640,32 @@ impl VirtualDisplay for GamescopeDisplay {
// also what the ladder's own default arm picks.
None => (None, None),
};
// `Topology::Exclusive` means the operator asked for the box's own screens to go dark for
// the stream. Resolved ONCE, above every route's return, so the managed hold below and the
// "free the box's session" decision and the bare spawn's darken at the end of this
// function can never disagree within a single create.
let exclusive = crate::effective_topology() == crate::policy::Topology::Exclusive;
if let Some(client) = session_env {
return create_managed_session(&client, mode, self.hdr);
let out = create_managed_session(&client, mode, self.hdr)?;
// Managed is the route that matters most here: it is the recommended one, it gives the
// best experience, and it is the ONLY way to serve a client its own virtual output at
// its own mode. So `exclusive` has to mean something on it.
//
// Its takeover idles the box's autologin session, which stops that session DRIVING the
// panel — but measured on the Nobara VM (2026-08-24), that alone leaves the connector
// at `enabled=enabled dpms=On` indefinitely: with no DRM master the kernel just keeps
// the CRTC configured. Turning it off is [`crate::panel_dpms`]'s job, and on a Game Mode
// box (no KWin) that lands in its DRM arm — which needs no compositor and no privilege.
//
// The hold canNOT ride `self.pending_restore` the way the bare spawn's does: this
// route reports `DisplayOwnership::SessionManaged`, and `registry::acquire` returns for
// anything not `Owned` ABOVE its `take_topology_restore()` pickup, so that hold would
// never be released — and a panel dark after every stream is worse than one left lit.
// Hence [`managed_darken_acquire`] / [`managed_darken_release`], balanced against
// [`do_restore_tv_session`] instead: the one teardown every managed path funnels
// through, and the same place the drop-in sweep lives for the same reason.
managed_darken_acquire(exclusive);
return Ok(out);
}
// Attach to an already-running gamescope (a foreign / externally-launched session) instead
// of spawning our own: capture its node AND inject into its EIS socket.
@@ -562,6 +682,10 @@ impl VirtualDisplay for GamescopeDisplay {
.context("PUNKTFUNK_GAMESCOPE_NODE must be a node id or 'auto'")?
};
point_injector_at_eis();
// ⚠ NO darken hold here either, and this one is policy: attach MIRRORS a gamescope
// that may itself be lighting the physical panel, so honoring `exclusive` by
// darkening it would darken the very picture being streamed. `exclusive` cannot be
// served on this route; the operator's lever is to pick a model that owns a display.
tracing::info!(node_id, "gamescope: attaching to existing PipeWire node");
// ATTACH = mirror a foreign gamescope we don't own → External (no keep-alive/reuse).
return Ok(VirtualOutput {
@@ -592,7 +716,8 @@ impl VirtualDisplay for GamescopeDisplay {
// NO instance free — and then collided with the box's own autologin/desktop Steam, which
// is precisely the collision this block exists to prevent.
let app = resolved_spawn_app(self.cmd.as_deref());
if app.as_deref().is_some_and(is_steam_launch) {
let steam = app.as_deref().is_some_and(is_steam_launch);
if steam {
// A dedicated launch NEEDS Steam's single instance — no attach degrade exists here, so
// a mask-fragile-DM box without takeover privilege fails with the actionable error.
stop_autologin_sessions()
@@ -600,6 +725,24 @@ impl VirtualDisplay for GamescopeDisplay {
// B1b: a Steam running in a plain DESKTOP session (GNOME/KDE) holds the instance just
// the same, and the autologin stop above can't see it — free it too, or fail loudly.
free_desktop_steam()?;
} else if free_box_session_for_exclusive(steam, exclusive) {
// B1c: a NON-Steam launch has no single instance to free, and used to leave the box's
// gaming session completely untouched. On a Game Mode box that session IS the DRM
// master of the TV (`gamescope/heads.rs`), so under Exclusive it went on lighting the
// panel with live Game Mode for the whole stream — the loudest half of the Nobara field
// report, and never a 0.31.0 regression: this path has always been Steam-gated.
//
// Best-effort, unlike the Steam arm above: freeing the session is what MAKES the panel
// dark here, not what makes the launch possible, so a box that refuses costs the
// operator their dark screen and not their game. The restore is the same machinery
// either way (`STOPPED_AUTOLOGIN` → `schedule_restore_tv_session`).
if let Err(why) = stop_autologin_sessions() {
tracing::warn!(
%why,
"exclusive topology: could not free the box's gaming session, so its own \
display keeps whatever it is showing for this stream"
);
}
}
// A5: a per-spawn instance id addresses this spawn's log + node discovery, so two coexisting
// bare-spawns (a kept lingering one + a fresh one) never parse each other's node id from a
@@ -646,16 +789,19 @@ impl VirtualDisplay for GamescopeDisplay {
// the physicals outright, but that door is closed here (KWin refuses zero enabled outputs,
// and no output on that desktop is ours to leave enabled) — so the desktop's panels go to
// DPMS-off instead, best-effort and self-gating (a box with no KDE desktop declines
// quietly inside `kwin_dpms`). Placed AFTER the spawn succeeded, so a failed create never
// blanks the user's screen. The hold is refcounted in `kwin_dpms` rather than floated
// quietly inside `panel_dpms`). Placed AFTER the spawn succeeded, so a failed create never
// blanks the user's screen. The hold is refcounted in `panel_dpms` rather than floated
// through the registry's group restore, because every gamescope spawn is its own group
// (`registry::group_key`) — the float alone would re-light the panel when the FIRST of two
// concurrent spawns ends, under the second's still-live stream. Skipped for Managed (its
// takeover already stopped the desktop) and Attach (it mirrors a gamescope that may itself
// be driving the physical panel) — both returned earlier in this function.
if crate::effective_topology() == crate::policy::Topology::Exclusive {
crate::kwin_dpms::acquire_stream_darken();
self.pending_restore = Some(Box::new(crate::kwin_dpms::release_stream_darken));
// concurrent spawns ends, under the second's still-live stream. Managed takes the same
// hold at its own return above, through [`managed_darken_acquire`] rather than this field
// (its display is not registry-owned, so there is no `take_topology_restore` pickup to
// ride). Only Attach still skips, and for a reason that survives: it mirrors a gamescope
// that may itself be driving the physical panel, so darkening it would darken the very
// picture being streamed.
if exclusive {
crate::panel_dpms::acquire_stream_darken();
self.pending_restore = Some(Box::new(crate::panel_dpms::release_stream_darken));
}
// Bare SPAWN: we own the nested gamescope process → registry-poolable (keep-alive-able).
Ok(VirtualOutput::owned(
@@ -2984,6 +3130,48 @@ fn replay_switch_under_restored_dm(dm: &str) {
}
}
/// The box's autologin gaming instances and their ACTIVE state, as `(unit, active)` pairs — the
/// `--plain` columns are UNIT LOAD ACTIVE SUB DESCRIPTION, so the state is the third.
///
/// An unanswered query reads as "none listed", which is the safe direction for both callers: the
/// takeover then frees nothing rather than killing a session it could not see properly, and the
/// crash hand-back restarts nothing rather than bouncing one.
fn listed_autologin_units() -> Vec<(String, String)> {
let Ok(out) = crate::proc::output_within(
Command::new("systemctl").args([
"--user",
"list-units",
"--type=service",
"--all",
"--no-legend",
"--plain",
"gamescope-session-plus@*.service",
]),
UNIT_QUERY_BUDGET,
) else {
return Vec::new();
};
parse_listed_units(&String::from_utf8_lossy(&out.stdout))
}
/// [`listed_autologin_units`]'s parser (the unit-testable core). Which column the ACTIVE state is
/// in decides whether the takeover can tell a live gaming session from a dead leftover, and
/// getting that wrong is silent in both directions — a live session read as dead leaves Steam
/// holding the instance our own launch then collides with, and a dead one read as live idles a
/// session nobody was in.
fn parse_listed_units(stdout: &str) -> Vec<(String, String)> {
stdout
.lines()
.filter_map(|l| {
let mut cols = l.split_whitespace();
let unit = cols.next()?;
let active = cols.nth(1).unwrap_or("");
(unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service"))
.then(|| (unit.to_string(), active.to_string()))
})
.collect()
}
/// Stop every autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
/// single-instance Steam is free for our own host-managed session. Records the units so
/// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient
@@ -3011,33 +3199,9 @@ fn replay_switch_under_restored_dm(dm: &str) {
/// The ORDER is therefore load-bearing and not a style choice: stop the DM, bail if it did not
/// land, and only then mask. A mask laid before a stop that never arrives is the storm.
fn stop_autologin_sessions() -> Result<()> {
let Ok(out) = crate::proc::output_within(
Command::new("systemctl").args([
"--user",
"list-units",
"--type=service",
"--all",
"--no-legend",
"--plain",
"gamescope-session-plus@*.service",
]),
UNIT_QUERY_BUDGET,
) else {
return Ok(());
};
// `(unit, ACTIVE state)` — the `--plain` columns are UNIT LOAD ACTIVE SUB DESCRIPTION.
let listed: Vec<(String, String)> = String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| {
let mut cols = l.split_whitespace();
let unit = cols.next()?;
let active = cols.nth(1).unwrap_or("");
(unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service"))
.then(|| (unit.to_string(), active.to_string()))
})
.collect();
let listed = listed_autologin_units();
if listed.is_empty() {
return Ok(()); // nothing autologged in — Steam is already free
return Ok(()); // nothing autologged in (or the query failed) — Steam is already free
}
let dm = display_manager_unit();
// Only a LIVE instance holds Steam / justifies touching the DM. A loaded-but-inactive
@@ -3439,7 +3603,13 @@ pub fn restore_takeover_now() {
}
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = None; // doing it right here
tracing::info!("gamescope: host is shutting down — restoring the box's own session first");
do_restore_tv_session();
// `verify: false` — the escalation ladder waits up to a minute, and this runs inside
// `native.rs`'s 20 s `SHUTDOWN_RESTORE_GRACE`, after which `exit(0)` runs no destructors.
// Spending that grace watching instead of restoring would COST the hand-back, not check it.
// The next host start is what covers a shutdown that left the box dark
// ([`restore_takeover_on_startup`], which now hands the box back rather than only sweeping the
// drop-in off it).
do_restore_tv_session(false);
}
/// What a bounded `systemctl --user` lifecycle verb on the RESTORE path actually did. Three states,
@@ -3503,11 +3673,179 @@ fn connected_connector_under(base: &std::path::Path) -> bool {
})
}
/// How long a hand-back waits for the box to show something on its own panel before it starts
/// escalating. Generous on purpose: the unit's `ExecStart` is a whole gamescope + Steam start, and
/// on a cold box that is not quick — while a false escalation costs the user a session bounce.
const HANDBACK_GRACE: Duration = Duration::from_secs(25);
/// How long each escalation rung gets. Shorter than [`HANDBACK_GRACE`]: by the time a rung runs,
/// the ordinary start has already had its full grace and not delivered.
const HANDBACK_RUNG_GRACE: Duration = Duration::from_secs(15);
/// Poll slice for the two waits above.
const HANDBACK_POLL: Duration = Duration::from_millis(500);
/// Is ANYTHING driving the box's own panel right now — its game mode, or a desktop it switched to?
///
/// [`super::detect_active_session`] answers precisely the question the symptom asks: it reports the
/// running compositor of our uid, and [`super::ActiveKind::None`] means nothing is drawing
/// anywhere. Only sound AFTER `stop_session(SESSION_UNIT)` has killed our own managed session —
/// that kill is a synchronous SIGKILL ([`kill_unit`]), so by the restore's escalation point our
/// gamescope cannot still be answering for the box.
fn box_session_live() -> bool {
super::detect_active_session().kind != super::ActiveKind::None
}
/// Poll [`box_session_live`] until it is true or `grace` runs out. [`HandbackWait::Superseded`]
/// means a client reconnected and took the box over again — the hand-back we were checking is moot,
/// and every remedy below would now be fighting a live stream for the box's session.
enum HandbackWait {
Live,
Superseded,
TimedOut,
}
fn wait_for_box_session(grace: Duration) -> HandbackWait {
let deadline = Instant::now() + grace;
loop {
if takeover_live() {
return HandbackWait::Superseded;
}
if box_session_live() {
return HandbackWait::Live;
}
if Instant::now() >= deadline {
return HandbackWait::TimedOut;
}
std::thread::sleep(HANDBACK_POLL);
}
}
/// **The hand-back's last line of defence for a dark panel**, and the only part of this file that
/// checks whether the restore it just performed actually WORKED.
///
/// Everything above issues a lifecycle verb and reports what systemd said about the JOB. That is
/// not the same question as "does the box show a picture again", and the gap between the two is
/// where every "my screen stays black after disconnecting" report lives — including ones whose
/// trigger nobody has reproduced. So stop inferring the outcome and measure it: if nothing is
/// driving the panel a full [`HANDBACK_GRACE`] after the hand-back, climb a ladder of remedies,
/// each of which is a mechanism measured on both distro families (Bazzite `44.20260818`, Nobara
/// f44, 2026-08-22), and say loudly at every rung what is happening.
///
/// 1. **`stop` the autologin unit.** Its login session's script is parked on
/// `systemctl --user --wait start <unit>` (verified on both images), so stopping the unit
/// releases that wait, the session exits, and `Relogin=true` logs straight back in — starting
/// the unit inside a fresh login session with a seat. `stop`, never `restart`: a restart does
/// NOT release the parked waiter (measured), which is exactly why it cannot rescue a box the
/// ordinary restart already failed to bring back.
/// 2. **Restart the display manager.** What the pre-0.31.0 takeover did on every disconnect, and
/// proven to return the box to game mode. Needs privilege, so it can honestly fail.
/// 3. **`PUNKTFUNK_RECOVER_SESSION_CMD`**, then an ERROR naming the command a human must run.
///
/// **Detached**, and that is not incidental. The restore runs under [`RESTORE_FLIGHT`], which a
/// reconnecting client must take before it can re-take the box; watching for up to a minute while
/// holding it would put that whole wait in front of every reconnect. So the caller fires this and
/// returns, and the watcher stands down by itself the moment [`takeover_live`] says a new takeover
/// armed — the box belongs to that stream now, and a remedy fired into it would be the bug.
/// Call it AFTER `clear_takeover()`, or the very first poll reads our own finished takeover as a
/// new one and stands down immediately.
///
/// A box that was already fine costs one [`box_session_live`] call and the thread exits.
fn ensure_box_session_or_escalate(units: &[String]) {
let units: Vec<String> = units.to_vec();
std::thread::spawn(move || handback_watch(&units));
}
fn handback_watch(units: &[String]) {
match wait_for_box_session(HANDBACK_GRACE) {
HandbackWait::Live => {
tracing::info!(
"gamescope: the box is driving its own panel again — hand-back complete"
);
return;
}
HandbackWait::Superseded => return,
HandbackWait::TimedOut => {}
}
tracing::warn!(
secs = HANDBACK_GRACE.as_secs(),
units = ?units,
"gamescope: NOTHING is driving the box's panel {}s after the hand-back — its screen is \
dark. Escalating: stopping the autologin unit so the display manager relogins into a \
session with a seat",
HANDBACK_GRACE.as_secs()
);
// Rung 1 — release the login session's parked `--wait start` and let the DM relogin.
for unit in units {
if let RestoreVerb::Failed(why) = issue_restore_verb(&["stop", unit]) {
tracing::warn!(unit, status = %why, "gamescope: could not stop the autologin unit");
}
}
match wait_for_box_session(HANDBACK_RUNG_GRACE) {
HandbackWait::Live => {
tracing::info!(
"gamescope: the display manager relogged the box into its own session — panel back"
);
return;
}
HandbackWait::Superseded => return,
HandbackWait::TimedOut => {}
}
// Rung 2 — put the display manager itself through a restart.
if let Some(dm) = display_manager_unit() {
tracing::warn!(
%dm,
"gamescope: the box is still dark — restarting its display manager"
);
match restore_display_manager(&dm) {
Ok(()) => match wait_for_box_session(HANDBACK_RUNG_GRACE) {
HandbackWait::Live => {
tracing::info!(%dm, "gamescope: the display manager brought the box back");
return;
}
HandbackWait::Superseded => return,
HandbackWait::TimedOut => {}
},
Err(why) => tracing::warn!(
%dm,
shape = why.shape(),
reason = %why,
"gamescope: could not restart the display manager"
),
}
}
// Rung 3 — the operator's own escape hatch, then say what is left to do by hand.
if crate::try_recover_session() {
tracing::warn!(
"gamescope: fired PUNKTFUNK_RECOVER_SESSION_CMD to bring the box's session back"
);
return;
}
tracing::error!(
units = ?units,
"gamescope: the box has NO session driving its panel and every automatic remedy failed — \
its screen stays dark until someone runs `systemctl --user restart <unit>` for one of \
these, or `sudo systemctl restart display-manager.service`. Set \
PUNKTFUNK_RECOVER_SESSION_CMD to let the host do this itself"
);
}
/// Tear down our host-managed session (freeing Steam) and restart the autologin gaming session(s)
/// we stopped on connect — so the TV returns to gaming mode when no one is streaming. Invoked by
/// [`start_restore_worker`] once the debounce deadline passes; takes the stopped-unit list so a
/// cancelled+reconnected window keeps the list for a later real restore.
fn do_restore_tv_session() {
fn do_restore_tv_session(verify: bool) {
// Give the box its screens back FIRST, above every early return below — including the SteamOS
// ones, which is why this sits at the very top rather than beside the drop-in sweep that
// follows the same "must not leak past a return" rule. The managed route's `exclusive` darken
// has no registry restore to ride (`DisplayOwnership::SessionManaged` returns above
// `take_topology_restore`), so this call is its ONLY release — leaking it would leave the
// operator's panel dark for the rest of the host's life.
//
// Safe this early: releasing re-lights, and every path below either hands the box back or
// deliberately keeps a headless session on a box with no connected display (nothing lit to
// darken there anyway). Idempotent, so the paths that reach the restore twice cost nothing.
managed_darken_release();
// SteamOS: we reconfigured `gamescope-session.target` headless via a drop-in. Restore = remove
// the drop-in + restart the target (back to the physical panel) — unless the user switched to a
// desktop session meanwhile, in which case drop the override and leave the desktop alone.
@@ -3574,6 +3912,9 @@ fn do_restore_tv_session() {
),
}
clear_takeover(); // A3: consumed — after the restart, not before it
if verify {
ensure_box_session_or_escalate(&[STEAMOS_SESSION_TARGET.to_string()]);
}
return;
}
}
@@ -3699,14 +4040,14 @@ fn do_restore_tv_session() {
}
// (The idle drop-in is already gone — removed above every early return, so the restarts
// below bring the box's real session back rather than another idle one.)
for unit in units {
for unit in &units {
// Checked, not discarded: this call and the SteamOS `restart` above were the two places
// that logged an unconditional success over a thrown-away exit status. A `--user start`
// fails for reasons an operator can act on (the unit is masked, its start limit tripped),
// and the DM branch thirty lines up already shows the shape — say what happened.
// `restart`, not `start`: the idle takeover leaves the unit ACTIVE, and `start` on an
// active unit is a no-op that would report success over a session still running nothing.
match issue_restore_verb(&["restart", &unit]) {
match issue_restore_verb(&["restart", unit]) {
RestoreVerb::Done => tracing::info!(
unit,
"restored the TV's autologin gaming session (debounce elapsed, no client)"
@@ -3731,6 +4072,12 @@ fn do_restore_tv_session() {
}
}
clear_takeover(); // A3: consumed — and only now, with the restarts actually issued
// …and CHECK that the restart above actually put a picture back on the box's panel, rather
// than trusting the job status to mean that. AFTER `clear_takeover`, which is what makes a
// later `takeover_live()` mean "a client reconnected" — see [`ensure_box_session_or_escalate`].
if verify {
ensure_box_session_or_escalate(&units);
}
}
/// Host-lifetime worker that fires a pending [`schedule_restore_tv_session`] once its debounce
@@ -3767,7 +4114,10 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
}
};
if still_due {
do_restore_tv_session();
// The disconnect restore: verified. This is the path the field reports
// are about, it is on a worker thread with no deadline over it, and a box
// left dark here stays dark until someone walks up to it.
do_restore_tv_session(true);
}
}
}
@@ -4960,6 +5310,20 @@ fn is_steam_launch(cmd: &str) -> bool {
cmd.split_whitespace().next() == Some("steam")
}
/// Should a bare-spawn launch free the box's own gaming session when it is NOT a Steam launch?
///
/// Two different requirements reach the same call. A **Steam** launch frees it because it must —
/// the single instance is not shareable — and that arm fails the create when it can't. **Exclusive
/// topology** frees it because the operator asked for the box's screens to go dark, and on a Game
/// Mode box that session is the DRM master of the physical panel; that arm is best-effort.
///
/// Pure so the gate is testable without systemd: the bug it closes was a policy question
/// (`is_steam_launch` standing in for "does the box's session need to get out of the way"), not a
/// systemd one.
fn free_box_session_for_exclusive(steam: bool, exclusive: bool) -> bool {
!steam && exclusive
}
/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch
/// (`steam steam://rungameid/<id>`, produced by `library::command_for`) gets `-gamepadui` inserted
/// so the nested Steam is Big Picture — the identity gamescope's `--steam` integration is built
@@ -5332,14 +5696,15 @@ mod tests {
use super::{
any_output_size_is, cancel_pending_restore, cgroup_is_punktfunk_owned,
classify_output_size, connected_connector_under, display_manager_unit_under, dm_plan,
game_hz, gamescope_output_size, hdr_args, idle_dropin_body, idle_dropin_path,
install_idle_dropin, is_steam_launch, mask_unit, missing_flags, mode_mismatch,
nested_wrapper_script, our_wsi_layer_dir, plan_bind, release_autologin_mask,
remove_idle_dropin, script_hardcodes_gamescope, sentinel_advanced, shape_dedicated_command,
switch_ends_mask_window, takeover_state_is_live, unmask_unit, xwayland_refusal_marker,
BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind, TakeoverState, WsiPlan,
AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, PENDING_RESTORE, RESTORE_FLIGHT,
STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR,
free_box_session_for_exclusive, game_hz, gamescope_output_size, hdr_args, idle_dropin_body,
idle_dropin_path, install_idle_dropin, is_steam_launch, managed_darken_acquire_edge,
managed_darken_release_edge, mask_unit, missing_flags, mode_mismatch,
nested_wrapper_script, our_wsi_layer_dir, parse_listed_units, plan_bind,
release_autologin_mask, remove_idle_dropin, script_hardcodes_gamescope, sentinel_advanced,
shape_dedicated_command, switch_ends_mask_window, takeover_state_is_live, unmask_unit,
xwayland_refusal_marker, BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind,
TakeoverState, WsiPlan, AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, PENDING_RESTORE,
RESTORE_FLIGHT, STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR,
};
use std::time::{Duration, Instant};
@@ -5538,6 +5903,39 @@ mod tests {
/// drop-in APPENDS the sleep to the box's own session command and both run — the takeover
/// would then be fighting the very Steam it set out to free, and nothing on the box would say
/// why. Pins the reset, its order, and that the resolved `sleep` is the one that gets run.
/// The `--plain` column the ACTIVE state lives in, pinned against real `systemctl --user
/// list-units` output from both distro families. Read the wrong column and a live gaming
/// session looks dead (Steam stays held, and our launch collides with it) or a dead leftover
/// looks live (the takeover idles a session nobody was in) — both silent on glass.
#[test]
fn listed_units_take_the_active_column_not_the_load_column() {
// Bazzite 44.20260818 and Nobara f44, verbatim (unit / LOAD / ACTIVE / SUB / description).
let out = "gamescope-session-plus@ogui-steam.service loaded active running Gamescope Session Plus\n\
gamescope-session-plus@steam.service loaded inactive dead Gamescope Session Plus\n";
assert_eq!(
parse_listed_units(out),
vec![
(
"gamescope-session-plus@ogui-steam.service".to_string(),
"active".to_string()
),
(
"gamescope-session-plus@steam.service".to_string(),
"inactive".to_string()
),
]
);
// `loaded` is the LOAD column and must never be mistaken for the state — that is the
// off-by-one this pins.
assert!(parse_listed_units(out).iter().all(|(_, a)| a != "loaded"));
// Anything that is not one of our template's instances is not ours to touch.
assert!(
parse_listed_units("plasma-plasmashell.service loaded active running Shell\n")
.is_empty()
);
assert!(parse_listed_units("").is_empty());
}
#[test]
fn idle_dropin_replaces_exec_start_rather_than_appending() {
let body = idle_dropin_body("/usr/bin/sleep");
@@ -5634,6 +6032,126 @@ mod tests {
assert!(!ran.contains("reinstall"), "{ran}");
}
/// ON GLASS. The MANAGED route's hold, driven against the real `panel_dpms`/`drm_dpms` stack —
/// the wiring the pure edge test above cannot see. Run it in the takeover state (the box's
/// gaming session idled, so nothing holds DRM master), on a box with a connected head:
///
/// ```sh
/// ./pf_vdisplay-<hash> --ignored --nocapture the_managed_hold_darkens_a_real_panel
/// ```
#[test]
#[ignore = "on glass: needs a connected head and no compositor holding /dev/dri/card*"]
fn live_the_managed_hold_darkens_a_real_panel() {
fn lit() -> Vec<(String, String)> {
let mut v = Vec::new();
let Ok(rd) = std::fs::read_dir("/sys/class/drm") else {
return v;
};
for e in rd.flatten() {
let p = e.path();
let f = |n: &str| {
std::fs::read_to_string(p.join(n))
.map(|s| s.trim().to_string())
.unwrap_or_default()
};
if f("status") == "connected" {
v.push((e.file_name().to_string_lossy().into_owned(), f("dpms")));
}
}
v.sort();
v
}
let before = lit();
println!("before: {before:?}");
assert!(
!before.is_empty(),
"needs a connected head to mean anything"
);
super::managed_darken_acquire(true);
std::thread::sleep(std::time::Duration::from_secs(2));
let during = lit();
println!("during: {during:?}");
// A reconnect must not take a second hold — if it did, the release below would leave the
// panel dark. This is the failure the pure test models; here it is against the real
// refcount.
super::managed_darken_acquire(true);
super::managed_darken_release();
std::thread::sleep(std::time::Duration::from_secs(2));
let after = lit();
println!("after: {after:?}");
let went_dark: Vec<&String> = during
.iter()
.zip(&before)
.filter(|((_, now), (_, was))| was == "On" && now == "Off")
.map(|((n, _), _)| n)
.collect();
if went_dark.is_empty() {
println!("nothing was ours to darken (card already mastered?) — skipping");
return;
}
// Deliberately "at least one went dark", not "all did": a box can carry a connected head
// the live compositor does not manage. The Hyprland VM has a virtio `Virtual-1` beside the
// real `HDMI-A-1`, and only the latter is Hyprland's to darken — asserting all of them
// would fail on a difference that is not a defect. What must hold is that the mechanism
// darkened something real, and that the release put every head back exactly as found.
println!("went dark: {went_dark:?}");
assert_eq!(after, before, "the release must restore what we found");
}
#[test]
fn the_managed_darken_hold_is_taken_once_and_released_once() {
// The managed SESSION is what gets darkened, not each connect — it outlives client
// disconnects and a same-mode reconnect reuses it warm. So a reconnect must NOT take a
// second hold: `panel_dpms`'s refcount would ratchet up with no matching release and pin
// the operator's panel dark for the rest of the host's life.
let mut held = false;
assert!(managed_darken_acquire_edge(&mut held, true), "0→1 darkens");
assert!(!managed_darken_acquire_edge(&mut held, true), "reconnect");
assert!(!managed_darken_acquire_edge(&mut held, true));
// The restore calls the release unconditionally, above every early return — so it has to
// be idempotent, or a path that reaches the restore twice would release a hold it does
// not have and drop someone else's.
assert!(managed_darken_release_edge(&mut held), "1→0 re-lights");
assert!(!managed_darken_release_edge(&mut held), "already released");
assert!(!managed_darken_release_edge(&mut held));
// And it re-arms: a later stream on the same host lifetime darkens again.
assert!(managed_darken_acquire_edge(&mut held, true));
assert!(managed_darken_release_edge(&mut held));
// Not exclusive ⇒ never a hold, so the restore's unconditional release stays a no-op.
// This is what makes `extend` / `SharedDesktop` ("never blank the real monitors") mean
// what they say on the managed route.
let mut held = false;
assert!(!managed_darken_acquire_edge(&mut held, false));
assert!(!held);
assert!(!managed_darken_release_edge(&mut held));
}
#[test]
fn exclusive_frees_the_box_session_for_a_non_steam_launch_too() {
// The bug: `is_steam_launch` was standing in for "does the box's own session need to get
// out of the way", and those are two different questions. A non-Steam library game under
// `exclusive` left the box's Game Mode gamescope holding DRM master on the TV, so the
// operator's screen showed live Game Mode for the whole stream (Nobara, 2026-08-24).
assert!(free_box_session_for_exclusive(false, true));
// A Steam launch is already handled by the arm above this one — and that arm is the
// FAILING one (the single instance is not optional), so this gate must not also fire and
// free the session a second time.
assert!(!free_box_session_for_exclusive(true, true));
// Not exclusive: the operator did not ask for their screens to go dark, so a non-Steam
// launch must keep leaving the box's session strictly alone. This is what makes `extend`
// and the `SharedDesktop` preset ("never blank the real monitors") mean what they say.
assert!(!free_box_session_for_exclusive(false, false));
assert!(!free_box_session_for_exclusive(true, false));
}
#[test]
fn dm_plan_idles_any_dm_that_drove_a_live_session() {
// A live gaming session behind a DM: idle it, whatever the flavor. Neither of the two
@@ -483,7 +483,12 @@ fn gamescope_patch_level() -> u32 {
cursor composited into the capture stream"
);
} else {
tracing::debug!(
// INFO, not DEBUG: this is the whole reason a box streams SDR, and the branch above
// announces the good news at INFO. A field report ("HDR stopped working after the
// update") cost a deep dive because the handshake's `capture_supports_hdr=false` was
// visible at INFO while the ONE line saying why sat a level below it. Fires once per
// process — the answer is cached in `LEVEL`.
tracing::info!(
bin = %gamescope_bin(),
"gamescope has no {PFHDR_MARKER} marker — sessions on this backend stay 8-bit SDR \
with a host-composited cursor (install punktfunk-gamescope for HDR)"
@@ -539,6 +539,123 @@ fn heads_to_disable(heads: &[crate::monitors::PhysicalMonitor], ours: &str) -> V
.collect()
}
/// DPMS every head that is not ours and not a sibling's off (or back on), for a **gamescope**
/// session honoring `Topology::Exclusive` — see [`crate::panel_dpms`].
///
/// Distinct from [`disable_other_heads`], which is what the *Hyprland backend's own* exclusive
/// topology does, and deliberately so on this compositor above all: disabling a Hyprland head is
/// the operation whose only known undo is re-reading the operator's whole config
/// ([`restore_heads`]), dropping every runtime override they set by hand. DPMS is a separate axis
/// — this module's own notes record `dispatch dpms on <name>` failing to re-enable a *disabled*
/// head for exactly that reason — so off/on round-trips cleanly and touches nothing else.
///
/// A gamescope spawn owns no Hyprland output, hence the empty `ours`; a concurrent session's
/// `HEADLESS-*` is still spared by [`heads_to_disable`]'s `managed` filter.
///
/// Returns the heads actually changed, so the re-light undoes exactly those.
pub(crate) fn dpms_other_heads(on: bool) -> Vec<String> {
let Ok(heads) = list_monitors() else {
return Vec::new();
};
let mut changed = Vec::new();
for name in heads_to_disable(&heads, "") {
match dpms_one(&name, on) {
// Only a head THIS call moved is recorded: one already in the wanted state was left
// alone (the dispatcher toggles, so "fixing" it would break it), and reporting it as
// changed would have the re-light toggle a head we never darkened.
Ok(true) => changed.push(name),
Ok(false) => {}
Err(e) => tracing::warn!(
output = %name, error = %format!("{e:#}"),
"hyprland: could not DPMS this monitor for `topology: exclusive`"
),
}
}
changed
}
/// The DPMS state Hyprland reports for `name` right now — `hyprctl -j monitors all`'s
/// `dpmsStatus`. `None` when the monitor is not listed or the field is missing.
///
/// Measured on 0.55.4: this tracks the hardware exactly (`dpmsStatus:true` ⇔ the connector's sysfs
/// `dpms=On`), in both states, and a DPMS-off monitor stays listed. It is the readback
/// [`dpms_one`] is built around.
fn monitor_dpms(name: &str) -> Option<bool> {
let raw = hyprctl(&["-j", "monitors", "all"]).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
parsed
.as_array()?
.iter()
.find(|m| m.get("name").and_then(|v| v.as_str()) == Some(name))?
.get("dpmsStatus")?
.as_bool()
}
/// Put ONE monitor into `want_on`, reporting whether this call actually changed it.
///
/// ⚠ **The dispatcher is a TOGGLE, not a set** — measured on 0.55.4 (Lua) 2026-08-24, and the
/// single most important fact in this function. It ignores the state word entirely:
///
/// ```text
/// On ==[ hl.dsp.dpms("on", "HDMI-A-1") ]==> Off <- asked for ON, got OFF
/// Off ==[ hl.dsp.dpms("on", "HDMI-A-1") ]==> On
/// Off ==[ hl.dsp.dpms{state="off", ...} ]==> On <- asked for OFF, got ON
/// ```
///
/// So a blind "off" LIGHTS an already-dark head, and a blind "on" at teardown DARKENS a lit one —
/// the operator's screen left off after the stream, which is the failure this whole policy exists
/// to avoid. Hence read → act only if it differs → verify. That shape is also correct on a
/// config manager where the call really is a set, so it is not conditional on detecting which.
///
/// The SPELLING differs too. The classic `hyprctl dispatch dpms off <name>` does not work on the
/// Lua manager at all: `dispatch` is shorthand for `hl.dispatch(...)`, so the bare words parse as
/// a Lua expression and it dies with `')' expected near 'off'`. A hyprlang box (0.56.2 was probed
/// as one) wants the classic form. There is no stable probe for which manager is loaded, and
/// [`hyprctl_dispatch`] already catches the exit-0 rejections both produce — so try classic, then
/// Lua, and report both failures if neither lands.
///
/// ⚠ **Never omit the monitor name.** `hl.dsp.dpms("on")` answers `ok` and toggles *something*;
/// with a name it is at least addressed at the head we mean.
fn dpms_one(name: &str, want_on: bool) -> Result<bool> {
if monitor_dpms(name) == Some(want_on) {
return Ok(false); // already where we want it — toggling would break it
}
let classic =
match hyprctl_dispatch(&["dispatch", "dpms", if want_on { "on" } else { "off" }, name]) {
Ok(()) => None,
Err(e) => {
let lua = lua_dpms_expr(name, want_on);
match hyprctl_dispatch(&["dispatch", &lua]) {
Ok(()) => None,
Err(lua_err) => Some(format!("hyprlang: {e:#}; lua: {lua_err:#}")),
}
}
};
if let Some(why) = classic {
bail!("neither dispatch form was accepted for {name} — {why}");
}
// Verify, because a toggle that fired against a state we misread is worse than one that did
// not fire at all.
match monitor_dpms(name) {
Some(now) if now == want_on => Ok(true),
Some(now) => bail!(
"hyprland accepted the dpms dispatch for {name} but it is now dpmsStatus={now}, \
wanted {want_on} (the dispatcher toggles the readback disagreed with reality)"
),
None => bail!("hyprland stopped listing {name} after its dpms dispatch"),
}
}
/// The Lua-config-manager spelling of a per-monitor DPMS. Pure, so a test pins the shape — the
/// quoting is the whole trick, and an unquoted argument is exactly what the classic form gets
/// wrong on that manager.
fn lua_dpms_expr(name: &str, on: bool) -> String {
format!(
"hl.dsp.dpms(\"{}\", \"{name}\")",
if on { "on" } else { "off" }
)
}
/// Disable every non-managed head for an `exclusive` session, returning the ones actually disabled
/// (the input to [`restore_heads`]). Best-effort per head: one that refuses costs exclusivity on
/// that screen, not the session.
@@ -1388,6 +1505,22 @@ fn portal_thread(
mod tests {
use super::*;
/// The Lua config manager parses a `dispatch` argument as a Lua expression, so the monitor
/// name and the state must both be QUOTED — an unquoted `dpms off HDMI-A-1` is what dies with
/// `')' expected near 'off'` on 0.55.4. Pinning the shape here because the quoting is the
/// entire difference between working and silently doing nothing.
#[test]
fn the_lua_dpms_expression_quotes_both_arguments() {
assert_eq!(
lua_dpms_expr("HDMI-A-1", false),
r#"hl.dsp.dpms("off", "HDMI-A-1")"#
);
assert_eq!(lua_dpms_expr("DP-2", true), r#"hl.dsp.dpms("on", "DP-2")"#);
// The monitor name is never omitted: the no-name form answers `ok` and TOGGLES on 0.55.4,
// which would flip a just-restored head back off.
assert!(lua_dpms_expr("DP-2", true).contains("\"DP-2\""));
}
#[test]
fn version_tag_parses_release_and_dev_builds() {
assert_eq!(parse_version_tag("v0.55.0"), Some((0, 55, 0)));
@@ -1,23 +1,54 @@
//! DPMS control of the box's live KDE desktop (`org_kde_kwin_dpms`) — how a bare-spawn gamescope
//! session honors [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
//! Turning the box's OWN physical panels off — how a gamescope session honors
//! [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
//!
//! A bare spawn is its OWN headless compositor: nothing on that route touches the desktop the box
//! is showing, so on a KDE machine the physical panel keeps displaying the (idle) desktop for the
//! whole stream — while the same `exclusive` policy on the KWin route turns the physicals off
//! outright. The KWin route's mechanism is closed to us here: KWin refuses an output configuration
//! with ZERO enabled outputs, and a gamescope session has no KWin output of its own to leave
//! enabled. DPMS is the honest translation of `exclusive` for this route — the desktop stays
//! exactly where it is (no topology churn, no window re-homing), the panels go dark, and any
//! LOCAL input wakes them, which is the right answer for a desktop someone can walk up to.
//! Stream input never wakes them: it is injected into the nested gamescope's own EIS socket and
//! does not pass through KWin.
//! A gamescope session is its own compositor: nothing on either owning route (bare spawn, managed
//! takeover) touches the desktop the box is showing, so the physical panel keeps displaying the
//! (idle) desktop for the whole stream — while the same `exclusive` policy on a *desktop* backend
//! turns the physicals off outright. That backend's mechanism is closed to us here: a compositor
//! refuses an output configuration with ZERO enabled outputs, and a gamescope session has no
//! output of its own on that desktop to leave enabled.
//!
//! Driven in-process over the compositor's own Wayland (`Connection::connect_to_env`, the same
//! stack as [`crate::kwin_output_mgmt`] and for the same reason: `kscreen-doctor` rides a separate
//! libkscreen/KDED layer that can be wedged while KWin itself answers fine), with a
//! `kscreen-doctor --dpms` shell-out fallback. Best-effort everywhere — a box with no Wayland
//! session, or a non-KDE desktop, declines quietly and the stream proceeds with the panel lit,
//! exactly as before this module existed.
//! DPMS is the honest translation. The desk stays exactly where it is — no topology churn, no
//! workspace moves, no window re-homing — the panels just go dark, and any LOCAL input wakes them,
//! which is the right answer for a desktop someone can walk up to. Stream input never wakes them:
//! it is injected into the nested gamescope's own EIS socket and never reaches the desktop.
//!
//! **There is no cross-compositor DPMS protocol**, so this module is a dispatcher. In order, each
//! arm self-gating so a box only pays for the one that answers:
//!
//! | desktop | mechanism |
//! |---|---|
//! | KDE / KWin | in-process `org_kde_kwin_dpms`, then a `kscreen-doctor --dpms` shell-out |
//! | sway (wlroots) | `swaymsg output <name> dpms off` ([`crate::wlroots::dpms_other_heads`]) |
//! | Hyprland | its dpms dispatcher, read-modify-verify ([`crate::hyprland::dpms_other_heads`]) |
//! | none at all | [`crate::drm_dpms`] — the CRTCs off over DRM, no compositor needed |
//! | GNOME / Mutter | **cannot be served** — see below |
//!
//! KDE is driven in-process over the compositor's own Wayland (`Connection::connect_to_env`, the
//! same stack as [`crate::kwin_output_mgmt`] and for the same reason: `kscreen-doctor` rides a
//! separate libkscreen/KDED layer that can be wedged while KWin itself answers fine). sway and
//! Hyprland are driven through their own native IPC, which is how [`crate::wlroots`] and
//! [`crate::hyprland`] already drive them — no second layer to be wedged, so no in-process twin
//! is warranted.
//!
//! Neither of those two is as simple as "send the off command", and the Hyprland one especially
//! is not: its dpms dispatcher is a **toggle** that ignores the state word (measured on 0.55.4 —
//! asking for `on` turned a lit head OFF), and the classic argv does not even parse under its Lua
//! config manager. [`crate::hyprland::dpms_other_heads`] carries the full account; the contract
//! this module depends on is only that each arm returns **the heads it actually changed**, so the
//! re-light moves exactly those and never a head it did not darken.
//!
//! The DRM arm is not an afterthought: a box sitting in Game Mode runs gamescope and NO desktop
//! compositor, and it is *exactly* the deployment whose TV the operator wants dark.
//!
//! ⚠ **GNOME is the one gap, and it is structural.** Mutter exposes no DPMS to clients at all, and
//! its `exclusive` mechanism (an `ApplyMonitorsConfig` that omits the physicals) needs a virtual
//! output of its own to keep enabled — which a gamescope session, being its own compositor, does
//! not have. The DRM floor cannot cover it either: Mutter holds DRM master, so `SET_MASTER` is
//! refused. [`darken`] says so at `warn!` rather than failing silently.
//!
//! This module owns the refcount and the hold for every arm — see [`Darkened`] for how each is
//! undone.
//!
//! **The hold is refcounted here, NOT floated through the registry's per-group restore.** Every
//! gamescope spawn is its own display group (`registry::group_key` — deliberately, they are
@@ -433,6 +464,16 @@ enum Darkened {
/// The `kscreen-doctor --dpms off` fallback ran (it takes no per-output address, so the
/// re-light is the symmetric `--dpms on`).
Kscreen,
/// sway (wlroots) turned these outputs off — `swaymsg output <name> dpms off`. Addressed by
/// connector name, so the re-light undoes exactly the heads we changed and never a sibling's.
Sway(Vec<String>),
/// Hyprland turned these monitors off — `hyprctl dispatch dpms off <name>`. Same per-name
/// discipline as [`Darkened::Sway`], and the same reason.
Hyprland(Vec<String>),
/// No desktop to ask, so [`crate::drm_dpms`] turned the CRTCs off over DRM directly. The
/// re-light is a `drop` — the hold IS a set of open `/dev/dri/cardN` fds, and the kernel
/// re-lights on last close. Nothing to replay, and crash-safe for the same reason.
Drm(crate::drm_dpms::DrmDarken),
}
/// The host-wide darken hold — refcounted like `sleep_inhibit`: the 0→1 edge darkens, the 1→0
@@ -505,9 +546,42 @@ pub fn release_stream_darken() {
}
}
/// The non-KDE desktops we can ask, in preference order. Each self-gates on its own IPC being
/// reachable — `wlroots::dpms_other_heads` shells out to `swaymsg`, which needs `SWAYSOCK`;
/// Hyprland's needs `HYPRLAND_INSTANCE_SIGNATURE` — so a box only ever pays for the one that
/// answers, and a box running neither falls straight through.
///
/// Both address heads BY NAME and report back the ones they actually changed, so the re-light
/// undoes exactly those and never a concurrent session's headless output.
///
/// **GNOME is absent on purpose.** Mutter exposes no DPMS to clients at all, and its `exclusive`
/// mechanism (`ApplyMonitorsConfig` omitting the physicals) needs a virtual output of its own to
/// keep enabled — which a gamescope spawn, being its own compositor, does not have. There is
/// nothing to call; the `warn!` at the end of [`darken`] names it rather than failing silently.
fn non_kde_desktop_darken() -> Option<Darkened> {
let sway = crate::wlroots::dpms_other_heads(false);
if !sway.is_empty() {
tracing::info!(
outputs = ?sway,
"sway: desktop outputs off for the exclusive gamescope stream"
);
return Some(Darkened::Sway(sway));
}
let hypr = crate::hyprland::dpms_other_heads(false);
if !hypr.is_empty() {
tracing::info!(
outputs = ?hypr,
"hyprland: desktop monitors off for the exclusive gamescope stream"
);
return Some(Darkened::Hyprland(hypr));
}
None
}
/// The 0→1 darken: in-process over `org_kde_kwin_dpms` first, `kscreen-doctor --dpms off` as the
/// wedged-compositor fallback. `None` = nothing was darkened (no desktop, not KDE, panels already
/// off, or every arm declined) — and therefore nothing to restore.
/// wedged-compositor fallback, then the other desktops, then DRM. `None` = nothing was darkened
/// (no desktop that answers, panels already off, or every arm declined) — and therefore nothing to
/// restore.
fn darken() -> Option<Darkened> {
match Session::open("darken") {
Ok(mut s) => {
@@ -526,8 +600,49 @@ fn darken() -> Option<Darkened> {
}
}
// Definitive "not KDE" / "no desktop": no fallback can do better (kscreen-doctor drives
// the same KDE-only machinery), so decline quietly — already logged by `open`.
Err(OpenFailure::NoDpmsGlobal) | Err(OpenFailure::Connect(_)) => None,
// the same KDE-only machinery). Declining is still right — but NOT quietly. [`darken`] is
// only ever reached because the operator selected `Topology::Exclusive`, so every decline
// here is "you asked for your screens off and they stayed on", which is a verdict and not
// a routine state. It sat at `debug!` in `open`, and that silence is what made the Nobara
// field report (2026-08-24) undiagnosable: no line anywhere named the panel. Same
// discipline as [`relight`], which has always said so when it gave up — a lit panel under
// `exclusive` deserves the honesty a dark one already got.
Err(e @ (OpenFailure::NoDpmsGlobal | OpenFailure::Connect(_))) => {
// Not KDE. Try the other desktops we drive, then the compositor-independent floor.
// Each arm self-gates on its own IPC being reachable, so the order is just preference
// and a box only ever pays for the ones that answer.
if let Some(d) = non_kde_desktop_darken() {
return Some(d);
}
match crate::drm_dpms::darken() {
Some(d) => {
tracing::info!(
cards = ?d.darkened,
"DRM: the box's own CRTCs are off for the exclusive gamescope stream (no \
desktop compositor to ask a session in Game Mode has none)"
);
Some(Darkened::Drm(d))
}
// Nothing on this box was ours to darken: no desktop that answers, and then no
// `/dev/dri` card that was ours either — every one already mastered by someone
// else (a live compositor, including the gamescope an Attach route is mirroring,
// which must NOT be darkened), or nothing lit. Say so: `darken` is only ever
// reached because the operator selected `Topology::Exclusive`, so this is "you
// asked for your screens off and they stayed on" — a verdict, not a routine
// state. It sat at `debug!` in `open`, and that silence is what made the Nobara
// field report (2026-08-24) undiagnosable: no line anywhere named the panel.
None => {
tracing::warn!(
%e,
"exclusive topology asked for the box's own screens to go dark: no \
desktop compositor on this box could be asked (GNOME/Mutter exposes no \
DPMS to clients), and no DRM card was ours to turn off either the \
panel stays as it is for this stream"
);
None
}
}
}
// A live session that stopped answering: the standalone tool rides a different stack
// (libkscreen/KDED) and may still get through — the same rationale as `kwin.rs`'s
// kscreen fallbacks, honest-verdict discipline included.
@@ -594,6 +709,41 @@ fn relight(d: Darkened) {
);
}
}
// Per-name, so exactly the heads we darkened come back and a sibling's headless output is
// never switched on by us. A head the operator unplugged meanwhile just fails its one
// command and says so — the others still re-light.
Darkened::Sway(outputs) => {
let back = crate::wlroots::dpms_other_heads(true);
if back.is_empty() {
tracing::error!(
?outputs,
"sway: could NOT re-light the desktop outputs — they stay dark until local \
input or `swaymsg output '*' dpms on`"
);
} else {
tracing::info!(outputs = ?back, "sway: desktop outputs back on");
}
}
Darkened::Hyprland(outputs) => {
let back = crate::hyprland::dpms_other_heads(true);
if back.is_empty() {
tracing::error!(
?outputs,
"hyprland: could NOT re-light the desktop monitors — they stay dark until \
local input or `hyprctl dispatch dpms on`"
);
} else {
tracing::info!(outputs = ?back, "hyprland: desktop monitors back on");
}
}
// The one arm that cannot fail: the hold IS the open fds, so dropping it closes them and
// the kernel's last-close restores the console. No ioctl to be refused, no saved mode to
// replay — which is why this path needs no "could NOT re-light" line of its own.
Darkened::Drm(d) => {
let cards = d.darkened.clone();
drop(d);
tracing::info!(?cards, "DRM: the box's own CRTCs released — panel back on");
}
}
}
@@ -491,6 +491,45 @@ fn disable_argv(name: &str) -> [&str; 3] {
["output", name, "disable"]
}
/// The `swaymsg` argv that DPMS-es `name` off or on. Same noun-first shape as [`disable_argv`],
/// and a different axis from it: `dpms off` leaves the output enabled and configured (its
/// workspaces do not move, no window is re-homed) and merely stops driving the panel.
fn dpms_argv(name: &str, on: bool) -> [&str; 4] {
["output", name, "dpms", if on { "on" } else { "off" }]
}
/// DPMS every head that is not ours and not a sibling's off (or back on), for a **gamescope**
/// session honoring `Topology::Exclusive` — see [`crate::panel_dpms`].
///
/// Distinct from [`disable_other_heads`], which is what the *wlroots backend's own* exclusive
/// topology does. A gamescope spawn is its own compositor and owns no sway output, so there is
/// nothing here to promote to "the desk" and nothing to focus — and disabling the operator's
/// outputs would move their workspaces around for a stream that is not even on this compositor.
/// DPMS is the honest translation: the desk stays exactly as it is, the panels just go dark.
///
/// Reuses [`heads_to_disable`]'s filter with an empty `ours`, so a concurrent wlroots session's
/// `HEADLESS-*` output is spared for the same reason it is there — blanking it would black out
/// that client's stream.
///
/// Returns the heads actually changed, so the re-light can undo exactly those. Best-effort per
/// head, like its neighbour: one that refuses costs a lit screen, not the stream.
pub(crate) fn dpms_other_heads(on: bool) -> Vec<String> {
let Ok(heads) = list_monitors() else {
return Vec::new();
};
let mut changed = Vec::new();
for name in heads_to_disable(&heads, "") {
match swaymsg(&dpms_argv(&name, on)) {
Ok(_) => changed.push(name),
Err(e) => tracing::warn!(
output = %name, error = %format!("{e:#}"),
"wlroots: could not DPMS this output for `topology: exclusive`"
),
}
}
changed
}
/// The `swaymsg` argv that re-enables `name`. sway keeps a disabled output's configuration, so a
/// bare `enable` restores the mode/position/scale it had — there is no need to replay the rule the
/// way the Hyprland twin's `reload` does.
@@ -1060,6 +1099,30 @@ mod tests {
assert_eq!(heads_to_disable(&heads, ours), vec!["DP-1", "HDMI-A-1"]);
}
/// `dpms` is a different sway verb from `disable`, and the difference is the whole point of
/// the gamescope arm: `disable` moves workspaces and re-homes windows on the operator's desk,
/// `dpms off` leaves the desk alone and only stops driving the panel. Four tokens, not three —
/// sway spells it `output <name> dpms on|off`.
#[test]
fn dpms_is_a_separate_verb_from_disable() {
assert_eq!(dpms_argv("DP-1", false), ["output", "DP-1", "dpms", "off"]);
assert_eq!(dpms_argv("DP-1", true), ["output", "DP-1", "dpms", "on"]);
assert_eq!(disable_argv("DP-1"), ["output", "DP-1", "disable"]);
}
/// The gamescope DPMS arm reuses the disable filter with an EMPTY `ours`: a gamescope spawn
/// owns no sway output, so nothing of ours needs sparing — but a concurrent wlroots session's
/// `HEADLESS-*` still must be, or darkening would black out that client's stream.
#[test]
fn the_gamescope_dpms_arm_still_spares_a_sibling_headless() {
let heads = [
head("DP-1", true),
head("HEADLESS-1", true),
head("DP-3", false),
];
assert_eq!(heads_to_disable(&heads, ""), vec!["DP-1"]);
}
/// A box with no physical output (the CI/headless posture) has nothing to disable, so no
/// restore is prepared and teardown touches nothing.
#[test]
+484 -5
View File
@@ -19,7 +19,10 @@
//! fat LAN never surfaces as loss/OWD/decode. Paired with the host's own climb refusal (a
//! behind-cadence host acks climbs at the current rate) and short-ack cap learning
//! ([`BitrateController::on_ack`]), this is what stops an Automatic session from driving the
//! encoder off a cliff the network could carry.
//! encoder off a cliff the network could carry. It is also the one signal that can fire for a
//! reason the rate cannot fix (contention on the host's GPU), so it stands itself down when
//! backing off stops helping, and a later clean run re-probes it — see
//! [`ENCODE_NOOP_BACKOFFS_TO_DISARM`].
//!
//! AIMD shape: a SEVERE window (an unrecoverable frame, a flush, ≥6 % loss, or a decode-latency
//! excursion far past baseline) backs off ×0.7 immediately; ordinary congestion
@@ -126,10 +129,49 @@ const PROVEN_HEADROOM_DEN: u32 = 2;
/// encode_us inflated by its retrieve-queue depth (~a frame), so an absolute budget threshold
/// would read permanently-red and drive the rate to the floor; a rise above the session's own
/// baseline survives that offset. ~half a 120 Hz frame budget of standing rise is real.
///
/// A FRAME BUDGET, not a fixed duration — the two constants here are the 120 Hz values, used
/// only until [`set_frame_budget`](BitrateController::set_frame_budget) supplies the session's
/// own (see [`BitrateController::encode_thresholds`]). Left absolute they encode a 120 Hz
/// assumption into every session: at 60 Hz one frame is 16.7 ms, so an ordinary one-frame encode
/// hiccup clears the SEVERE tier and takes the immediate ×0.7 where the same hiccup at 120 Hz
/// (8.3 ms) does not even reach it. That asymmetry is a field report — a 1440p60 session ratcheted
/// to the floor while 1440p120 sessions on the same host and client climbed to their shape ceiling.
const ENCODE_RISE_US: i64 = 4_000;
/// Host-encode latency this far above baseline (≈1.5 × a 120 Hz budget) is SEVERE — the encode
/// queue is growing past the knee; skip the two-window confirmation.
/// queue is growing past the knee; skip the two-window confirmation. Frame-budget-scaled like
/// [`ENCODE_RISE_US`].
const ENCODE_SEVERE_US: i64 = 12_000;
/// Consecutive encode-attributed backoffs that did NOT bring host encode time down before the
/// encode down-driver is disarmed for the session.
///
/// The signal's whole premise is that encode time is a function of the rate the controller can
/// actuate: it exists to find the encoder's compute knee, where cutting the rate cuts the work.
/// When the rise comes from something else on the GPU — a game saturating the card, which is
/// exactly when the host is also behind cadence — the premise is false. The backoff changes
/// nothing, the signal fires again, and [`on_ack`](BitrateController::on_ack)'s baseline re-seed
/// erases the evidence that nothing improved, so the controller ratchets to the floor pulling the
/// one lever that cannot work (the field case: 57 → 5 Mbps over ten minutes with zero packet loss,
/// zero keyframe asks and a flat decoder).
///
/// So: remember the level each encode-attributed backoff fired at, and when the next one fires no
/// lower, count it. Two in a row means the rate is not what is driving encode time here — stop
/// letting it drive. Same shape as the clock-flush detector's
/// [`crate::client::frame_channel::NOOP_CLOCK_FLUSHES_TO_DISARM`]: a signal whose remedy is
/// demonstrably doing nothing should stand down rather than repeat forever.
///
/// Two, not one: a single pair of backoffs at a similar level is also what a real knee looks like
/// while the rate is still above it, and the knee is the case this signal was built for.
///
/// And a stand-down, never a permanent disarm. Nothing this controller learns from evidence is
/// permanent — both caps re-probe, and the clock-flush detector was itself changed from "off for
/// the session" to re-armable for exactly this reason. GPU contention is transient by nature (the
/// game exits to a menu, the shader storm ends), while what it silences is the only signal that
/// can descend when the encoder is past its knee on a link that shows nothing. So a clean run
/// re-arms it on the [`CAP_REPROBE_WINDOWS_MIN`] ladder, doubling each time the silence is
/// immediately re-earned. The loss, OWD, decode and keyframe signals keep their full power
/// throughout, and the host's own climb refusal stays the backstop for a genuine knee.
const ENCODE_NOOP_BACKOFFS_TO_DISARM: u32 = 2;
/// Clean windows parked at a learned cap before re-probing above it, and the ceiling that
/// interval backs off to.
///
@@ -329,6 +371,30 @@ pub(crate) struct BitrateController {
/// baseline like the decode signal. Cleared whenever OUR OWN rate decrease changes the
/// encode regime (see [`on_ack`](Self::on_ack)) and on a mode switch.
encode_means: VecDeque<i64>,
/// This session's frame budget in µs (one refresh interval), the unit the encode thresholds
/// are expressed in — see [`encode_thresholds`](Self::encode_thresholds). `None` = the mode
/// was never plumbed in, and the 120 Hz constants stand exactly as before.
frame_budget_us: Option<i64>,
/// The window mean host-encode latency (µs) that drove the last encode-attributed backoff;
/// `0` = none yet, or the streak was broken by a backoff something else drove.
encode_backoff_us: i64,
/// Consecutive encode-attributed backoffs after which encode time did NOT come down (see
/// [`ENCODE_NOOP_BACKOFFS_TO_DISARM`]).
encode_noop_backoffs: u32,
/// The encode down-driver is stood down: its rises are not answering the rate, so they
/// neither mark a window bad nor teach a baseline. Lifted by a clean run (see
/// `encode_reprobe_after`) or a mode switch — never permanent, like every other piece of
/// evidence-learned state here.
encode_disarmed: bool,
/// Clean windows since the stand-down, against `encode_reprobe_after`.
encode_disarm_clean_windows: u32,
/// Clean windows the stand-down must survive before the signal is re-armed. Doubles each
/// time a re-armed signal is immediately silenced again, so a standing contention settles
/// into a slow poll instead of thrashing ([`CAP_REPROBE_WINDOWS_MIN`]).
encode_reprobe_after: u32,
/// A stand-down has been lifted at least once, so the next one is re-silencing something the
/// re-probe already tried — the trigger for backing that clock off.
encode_rearmed: bool,
/// The host-taught rate cap (§ABR overdrive): latched when the host acks BELOW what we
/// asked twice consecutively at the same value — its encoder's codec-level ceiling, or a
/// climb refusal while host encode can't hold cadence. Kept apart from `ceiling_kbps` so
@@ -427,6 +493,13 @@ impl BitrateController {
owd_means: VecDeque::with_capacity(BASELINE_WINDOWS),
decode_means: VecDeque::with_capacity(BASELINE_WINDOWS),
encode_means: VecDeque::with_capacity(BASELINE_WINDOWS),
frame_budget_us: None,
encode_backoff_us: 0,
encode_noop_backoffs: 0,
encode_disarmed: false,
encode_disarm_clean_windows: 0,
encode_reprobe_after: CAP_REPROBE_WINDOWS_MIN,
encode_rearmed: false,
host_cap_kbps: None,
last_requested_kbps: None,
short_ack_kbps: 0,
@@ -485,6 +558,33 @@ impl BitrateController {
self.stream_cap_kbps = Some(kbps);
}
/// Teach the controller this session's refresh rate, so the encode thresholds can be sized in
/// FRAME BUDGETS rather than the 120 Hz durations they were calibrated at (see
/// [`ENCODE_RISE_US`]). Ignored for a nonsense rate — the defaults are the old behavior, which
/// is the right answer when the mode is not known.
pub(crate) fn set_frame_budget(&mut self, refresh_hz: u32) {
if refresh_hz > 0 {
self.frame_budget_us = Some(1_000_000 / refresh_hz as i64);
}
}
/// `(rise, severe)` for the host-encode signal: half a frame budget and one and a half of
/// them, the shape [`ENCODE_RISE_US`] documents, against this session's actual budget.
///
/// Scales with the SESSION REFRESH, not with the rate the source actually delivers. A game
/// rendering below refresh stretches the real budget further still (the host stretches its own
/// cadence deadline by exactly that, `cadence_budget`), so a sub-refresh source can still
/// present a one-frame hiccup above the severe tier — that residue is what
/// [`ENCODE_NOOP_BACKOFFS_TO_DISARM`] is for. Deliberately not chased here: the client would
/// have to infer the source period from arrival cadence, which is the same jitter the signal
/// is trying to read through.
fn encode_thresholds(&self) -> (i64, i64) {
match self.frame_budget_us {
Some(budget) => (budget / 2, budget * 3 / 2),
None => (ENCODE_RISE_US, ENCODE_SEVERE_US),
}
}
/// The host's [`crate::quic::BitrateChanged`] ack: its clamp is authoritative for what the
/// encoder now targets, and any ack proves the host renegotiates (resets the silence counter).
///
@@ -603,6 +703,16 @@ impl BitrateController {
self.owd_means.clear();
self.decode_means.clear();
self.encode_means.clear();
// The encode down-driver's disarm is mode-scoped like everything else here: the new mode
// is a different amount of encode work per frame, so a rate that could not move encode
// time under the old one says nothing about this one. Re-arm and let it prove itself
// again. (The caller re-sizes the frame budget for the new refresh alongside this.)
self.encode_disarmed = false;
self.encode_backoff_us = 0;
self.encode_noop_backoffs = 0;
self.encode_disarm_clean_windows = 0;
self.encode_reprobe_after = CAP_REPROBE_WINDOWS_MIN;
self.encode_rearmed = false;
self.proven_kbps = 0;
}
@@ -684,11 +794,17 @@ impl BitrateController {
// frame describe what reached the CLIENT, and they mean the same thing however little
// flowed — the periodic-capture-stall case (see [`STARVED_DELIVERY_DIV`]) still backs off
// on one window, as its tests require.
//
// Withheld the same way once the signal has DISARMED itself (see
// [`ENCODE_NOOP_BACKOFFS_TO_DISARM`]): a rise the rate has twice failed to answer is not
// evidence about the rate, so it must neither mark a window bad nor teach a baseline.
let (encode_rise_us, encode_severe_us) = self.encode_thresholds();
let encode_usable = !starved && !self.encode_disarmed;
let (encode_bad, encode_severe) = score_baseline(
&mut self.encode_means,
encode_mean_us.filter(|_| !starved),
ENCODE_RISE_US,
ENCODE_SEVERE_US,
encode_mean_us.filter(|_| encode_usable),
encode_rise_us,
encode_severe_us,
);
// SEVERE = the user already saw damage (an unrecoverable frame, a jump-to-live flush, a
// deep decode-latency excursion, a window spent begging for keyframes) or loss far past
@@ -782,6 +898,42 @@ impl BitrateController {
}
}
}
// The encode down-driver's stand-down re-probes on the same clock, for the same reason
// the two caps do: it is EVIDENCE, not a spec limit. What silenced it — a game
// saturating the GPU, a shader-compile storm, another app on the card — is exactly the
// sort of thing that ENDS mid-session, and what it silences is the only signal that can
// descend when the encoder is genuinely past its compute knee on a link that shows
// nothing. Left permanent, one contended stretch would strip that protection from every
// later minute of the session, including the calm ones where a climb can reach a rate
// the ASIC cannot hold.
//
// A clean run is the cheapest moment to ask again: nothing else is unhappy, so if the
// rate still cannot move encode time, two more no-op backoffs stand it down again at a
// bounded cost — while the doubling interval keeps a genuinely standing contention from
// thrashing. The asymmetry decides it: a too-eager re-arm costs one ×0.7, a too-permanent
// silence costs the knee protection outright.
if self.encode_disarmed {
if bad {
self.encode_disarm_clean_windows = 0;
} else {
self.encode_disarm_clean_windows += 1;
if self.encode_disarm_clean_windows >= self.encode_reprobe_after {
self.encode_disarmed = false;
self.encode_rearmed = true;
self.encode_disarm_clean_windows = 0;
// Re-arm on a FRESH baseline and with no streak carried over: the level the
// old backoffs fired at describes a regime that has since been clean for
// seconds, so it is not the reference the next one should be judged against.
self.encode_backoff_us = 0;
self.encode_noop_backoffs = 0;
self.encode_means.clear();
tracing::debug!(
after_windows = self.encode_reprobe_after,
"adaptive bitrate: re-arming the encode down-driver after a clean run"
);
}
}
}
let cooled = self
.last_change
.is_none_or(|t| now.duration_since(t) >= CHANGE_COOLDOWN);
@@ -875,6 +1027,56 @@ impl BitrateController {
} else {
self.decode_backoff_kbps = 0;
}
// Encode attribution (see [`ENCODE_NOOP_BACKOFFS_TO_DISARM`]): did the LAST
// encode-driven backoff buy anything? Judged from the level this one fires at, not
// from the baseline — `on_ack` re-seeded that after the last decrease, so the firing
// level is the only surviving record of what encode time did in between. Network
// distress disqualifies the attribution: loss, a flush or a dropped frame explain the
// backoff without the encoder, and cutting the rate genuinely is the remedy for those.
let encode_attributed = (encode_severe || encode_bad)
&& dropped == 0
&& !flushed
&& loss_ppm < HEAVY_LOSS_PPM;
if let Some(mean) = encode_mean_us.filter(|_| encode_attributed) {
if self.encode_backoff_us > 0
&& mean >= self.encode_backoff_us.saturating_sub(encode_rise_us)
{
// Fired again no lower than last time: the ×0.7 in between did nothing.
self.encode_noop_backoffs += 1;
if self.encode_noop_backoffs >= ENCODE_NOOP_BACKOFFS_TO_DISARM {
// Re-silencing something the re-probe had already lifted means the
// contention is STANDING, not the transient the re-probe exists to ride
// out — back its clock off, exactly as both learned caps do.
self.encode_reprobe_after = if self.encode_rearmed {
self.encode_reprobe_after
.saturating_mul(2)
.min(CAP_REPROBE_WINDOWS_MAX)
} else {
CAP_REPROBE_WINDOWS_MIN
};
self.encode_disarmed = true;
self.encode_disarm_clean_windows = 0;
self.encode_means.clear();
tracing::info!(
at_kbps = self.current_kbps,
encode_mean_us = mean,
noop_backoffs = self.encode_noop_backoffs,
rearm_after_windows = self.encode_reprobe_after,
"adaptive bitrate: host encode time is not answering the rate — \
standing the encode down-driver down until a clean run re-probes it \
(loss, OWD, decode and keyframe signals keep driving)"
);
}
} else {
self.encode_noop_backoffs = 0;
}
self.encode_backoff_us = mean;
} else {
// Something else drove this one: the encode streak is broken, and the level the
// next encode-driven backoff would have to beat no longer means anything.
self.encode_backoff_us = 0;
self.encode_noop_backoffs = 0;
}
self.climb_since_backoff = false;
let next = ((self.current_kbps as u64 * 7 / 10) as u32).max(self.floor_kbps);
self.bad_windows = 0;
@@ -2178,6 +2380,283 @@ mod tests {
}
}
/// One encode-attributed choke: re-seed the baseline `on_ack` cleared, then present `level`
/// again — the shape of an encoder held up by something the last ×0.7 did nothing about.
/// Four seed windows is under [`CLEAN_WINDOWS_TO_INCREASE`], so no cycle can climb its way
/// out from under the test.
fn encode_choke(
c: &mut BitrateController,
start: Instant,
tick: &mut u32,
level: i64,
) -> Option<u32> {
for _ in 0..BASELINE_MIN_WINDOWS {
let at = ticks(start, *tick);
*tick += 1;
// Seed windows are clean by construction; ack a climb if the controller takes one, so
// the helper stays usable in tests that leave climb headroom below the ceiling.
if let Some(k) = c.on_window(
at,
0,
0,
Some(10_000),
None,
Some(7_000),
1_000_000,
false,
0,
) {
c.on_ack(k);
}
}
let at = ticks(start, *tick);
*tick += 1;
c.on_window(
at,
0,
0,
Some(10_000),
None,
Some(level),
1_000_000,
false,
0,
)
}
/// `n` clean windows carrying no encode sample, acking any climb the controller takes.
fn clean_run(c: &mut BitrateController, start: Instant, tick: &mut u32, n: u32) {
for _ in 0..n {
let at = ticks(start, *tick);
*tick += 1;
if let Some(k) = c.on_window(at, 0, 0, Some(10_000), None, None, 1_000_000, false, 0) {
c.on_ack(k);
}
}
}
/// Drive the field ratchet: encode-attributed backoffs at a level the ×0.7s never move, until
/// the signal stands down.
fn disarm_encode(c: &mut BitrateController, start: Instant, tick: &mut u32) {
for _ in 0..=ENCODE_NOOP_BACKOFFS_TO_DISARM {
let verdict = encode_choke(c, start, tick, 20_000);
c.on_ack(verdict.expect("an unanswered encode rise must back off"));
}
assert!(c.encode_disarmed);
}
#[test]
fn a_stood_down_encode_signal_re_arms_after_a_clean_run() {
// The stand-down is EVIDENCE, not a spec limit, and what it answers — contention on the
// host's GPU — is exactly the sort of thing that ends mid-session. Left permanent, one
// contended stretch would strip the knee down-driver from every calm minute that follows,
// including the ones where a climb can reach a rate the ASIC cannot hold.
let mut c = BitrateController::new(20_000);
let start = Instant::now();
let mut tick = 0;
disarm_encode(&mut c, start, &mut tick);
assert_eq!(c.encode_reprobe_after, CAP_REPROBE_WINDOWS_MIN);
// A short clean spell is not enough — the re-probe is a run, not a blip.
clean_run(&mut c, start, &mut tick, CAP_REPROBE_WINDOWS_MIN - 1);
assert!(c.encode_disarmed);
clean_run(&mut c, start, &mut tick, 1);
assert!(!c.encode_disarmed);
// And it really drives again: a fresh excursion backs the rate off.
assert!(encode_choke(&mut c, start, &mut tick, 40_000).is_some());
}
#[test]
fn a_standing_contention_backs_the_re_arm_clock_off() {
// A re-armed signal silenced again means the contention is STANDING, not the transient
// the re-probe rides out. Same answer both caps give: poll it slowly rather than either
// giving up forever or thrashing every twelve seconds.
//
// Started high enough that two full ratchets stay clear of the floor — a rate pinned at
// `FLOOR_KBPS` stops backing off at all, which would starve the second stand-down of the
// backoffs it is counted from.
let mut c = BitrateController::new(200_000);
let start = Instant::now();
let mut tick = 0;
disarm_encode(&mut c, start, &mut tick);
clean_run(&mut c, start, &mut tick, CAP_REPROBE_WINDOWS_MIN);
assert!(!c.encode_disarmed);
// Re-armed, and the contention is still there.
disarm_encode(&mut c, start, &mut tick);
assert_eq!(c.encode_reprobe_after, CAP_REPROBE_WINDOWS_MIN * 2);
}
#[test]
fn a_bad_window_restarts_the_re_arm_run() {
// The re-probe wants a genuinely quiet stretch: a window the network spoiled says nothing
// about whether the encoder would answer the rate now, so the run starts over.
let mut c = BitrateController::new(20_000);
let start = Instant::now();
let mut tick = 0;
disarm_encode(&mut c, start, &mut tick);
clean_run(&mut c, start, &mut tick, CAP_REPROBE_WINDOWS_MIN - 1);
let at = ticks(start, tick);
tick += 1;
// A flush: severe, so it also costs a ×0.7 — and it resets the clean run behind it.
assert!(c
.on_window(at, 0, 0, Some(10_000), None, None, 1_000_000, true, 0)
.is_some());
clean_run(&mut c, start, &mut tick, CAP_REPROBE_WINDOWS_MIN - 1);
assert!(c.encode_disarmed, "the spoiled window must restart the run");
clean_run(&mut c, start, &mut tick, 1);
assert!(!c.encode_disarmed);
}
#[test]
fn the_encode_thresholds_follow_the_session_frame_budget() {
// The 1440p60-vs-1440p120 field asymmetry. One frame of encode delay is 8.3 ms at 120 Hz
// and 16.7 ms at 60 Hz, so against FIXED thresholds the 60 Hz session takes the immediate
// ×0.7 for the same physical hiccup the 120 Hz one shrugs off. Sized in frame budgets,
// both treat it the same way: ordinary, and confirmed by a second window.
let excursion = 23_700; // 7 ms baseline + ~one 60 Hz frame
let mut hz120 = BitrateController::new(20_000);
hz120.set_frame_budget(120);
let mut tick = 0;
let start = Instant::now();
assert_eq!(
encode_choke(&mut hz120, start, &mut tick, excursion),
Some(14_000),
"at 120 Hz that is ~2.8 frame budgets over baseline — severe, one window"
);
let mut hz60 = BitrateController::new(20_000);
hz60.set_frame_budget(60);
let mut tick = 0;
assert_eq!(
encode_choke(&mut hz60, start, &mut tick, excursion),
None,
"the same excursion is ~1 frame budget at 60 Hz — bad, but not severe"
);
// Confirmed by a second window, it still backs off — the signal is not weakened, only
// re-scaled.
let at = ticks(start, tick + 1);
assert_eq!(
hz60.on_window(
at,
0,
0,
Some(10_000),
None,
Some(excursion),
1_000_000,
false,
0
),
Some(14_000)
);
}
#[test]
fn unactuatable_encode_rises_disarm_the_down_driver() {
// The field ratchet (2026-08-22): a game saturating the GPU holds host encode time up,
// the client reads it as the compute knee, and every ×0.7 changes nothing — 57 Mbps to
// the floor over ten minutes with zero loss, zero keyframe asks and a flat decoder.
// `on_ack` re-seeds the encode baseline after each decrease, so nothing in the signal
// itself ever notices that the backoffs are not working. The firing LEVEL does.
let mut c = BitrateController::new(20_000);
let start = Instant::now();
let mut tick = 0;
// First one is a legitimate knee sample — nothing has been learned yet.
assert_eq!(encode_choke(&mut c, start, &mut tick, 20_000), Some(14_000));
c.on_ack(14_000);
// Fires again no lower: the first ×0.7 bought nothing. One no-op is not a verdict — a
// real knee still above the current rate looks exactly like this.
assert_eq!(encode_choke(&mut c, start, &mut tick, 20_000), Some(9_800));
c.on_ack(9_800);
assert_eq!(c.encode_noop_backoffs, 1);
assert!(!c.encode_disarmed);
// Twice in a row ⇒ the rate is not the lever. This backoff still lands (the window was
// judged before the verdict), and it is the last one this signal drives until a clean run
// re-probes it (`a_stood_down_encode_signal_re_arms_after_a_clean_run`).
assert_eq!(encode_choke(&mut c, start, &mut tick, 20_000), Some(6_860));
c.on_ack(6_860);
assert!(c.encode_disarmed);
// The ratchet stops: the same excursion no longer moves the rate…
assert_eq!(encode_choke(&mut c, start, &mut tick, 20_000), None);
// …and the session climbs back out instead of parking at the floor.
c.set_ceiling(200_000);
assert!(
run_clean(&mut c, start, tick, 8).is_some_and(|k| k > 6_860),
"a disarmed encode signal must not keep the session pinned"
);
}
#[test]
fn an_encode_backoff_that_helps_keeps_the_down_driver_armed() {
// The knee this signal was built for: the ×0.7 lands nearer it and encode time genuinely
// comes down, so the next excursion is a fresh event rather than evidence that the rate
// is the wrong lever. Nothing here may disarm.
let mut c = BitrateController::new(20_000);
let start = Instant::now();
let mut tick = 0;
assert_eq!(encode_choke(&mut c, start, &mut tick, 40_000), Some(14_000));
c.on_ack(14_000);
assert_eq!(encode_choke(&mut c, start, &mut tick, 22_000), Some(9_800));
c.on_ack(9_800);
assert_eq!(c.encode_noop_backoffs, 0);
assert!(!c.encode_disarmed);
}
#[test]
fn a_network_driven_backoff_breaks_the_encode_streak() {
// Loss, a flush or a dropped frame explain a backoff without the encoder — and cutting
// the rate genuinely IS the remedy for those. Such a window must not count toward the
// disarm, even when encode time happens to be elevated in it too.
let mut c = BitrateController::new(20_000);
let start = Instant::now();
let mut tick = 0;
assert_eq!(encode_choke(&mut c, start, &mut tick, 20_000), Some(14_000));
c.on_ack(14_000);
assert_eq!(c.encode_backoff_us, 20_000);
// Re-seed so the encode signal is live again…
for _ in 0..BASELINE_MIN_WINDOWS {
let at = ticks(start, tick);
tick += 1;
assert_eq!(
c.on_window(
at,
0,
0,
Some(10_000),
None,
Some(7_000),
1_000_000,
false,
0
),
None
);
}
// …then a window carrying BOTH an encode excursion and a jump-to-live flush. The flush is
// the explanation, so the encode streak resets rather than advancing toward a disarm.
let at = ticks(start, tick);
assert_eq!(
c.on_window(
at,
0,
0,
Some(10_000),
None,
Some(20_000),
1_000_000,
true,
0
),
Some(9_800)
);
assert_eq!(c.encode_backoff_us, 0);
assert_eq!(c.encode_noop_backoffs, 0);
assert!(!c.encode_disarmed);
}
#[test]
fn env_max_mbps_caps_every_learned_ceiling() {
// PUNKTFUNK_ABR_MAX_MBPS=50 (injected — `new` reads the env exactly once, at
+11
View File
@@ -97,6 +97,12 @@ pub(super) async fn run_pump(args: WorkerArgs) {
negotiated.bit_depth,
negotiated.chroma_format,
);
// This session's frame budget, the unit the ABR's host-encode thresholds are expressed in
// (see [`crate::abr::BitrateController::encode_thresholds`]). The NEGOTIATED refresh, not the
// requested one — `mode_slot` still holds the request until the connect handshake seeds it,
// and a host that answered 60 to a 120 ask is exactly the session that must not be scored
// against a 120 Hz budget.
let refresh_hz = negotiated.mode.refresh_hz;
// Seed the live offset with the connect-time estimate BEFORE the embedder can observe the
// client (ready_tx): clock_offset_now_ns() never reads a pre-handshake 0 on a skewed pair.
clock_offset.store(negotiated.clock_offset_ns, Ordering::Relaxed);
@@ -179,6 +185,9 @@ pub(super) async fn run_pump(args: WorkerArgs) {
// Control task (see [`control_task`]): the handshake stream stays open for mid-stream
// renegotiation, speed tests, clock re-sync, and clipboard metadata.
// The data pump re-reads the accepted mode when `mode_gen` moves, to re-size the ABR's
// frame-budget-scaled encode thresholds for the new refresh.
let mode_slot_pump = mode_slot.clone();
tokio::spawn(
control_task::ControlTask {
ctrl_rx,
@@ -271,6 +280,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
resolved_bitrate_kbps,
negotiated_codec,
stream_cap_kbps,
refresh_hz,
mode_slot: mode_slot_pump,
};
let _ = tokio::task::spawn_blocking(move || pump.run()).await;
+111 -8
View File
@@ -44,6 +44,12 @@ pub(super) struct DataPump {
/// [`crate::abr::stream_ceiling_kbps`]) — the bound the probe-measured link ceiling is held
/// to. Computed where the negotiated geometry lives, so this module stays codec-agnostic.
pub(super) stream_cap_kbps: u32,
/// The negotiated refresh, which sets the frame budget the ABR sizes its host-encode
/// thresholds against (see [`crate::abr::BitrateController::set_frame_budget`]).
pub(super) refresh_hz: u32,
/// The accepted mode, written by the control task on a mode switch — read when `mode_gen`
/// moves so the frame budget follows the new refresh.
pub(super) mode_slot: Arc<Mutex<crate::config::Mode>>,
}
impl DataPump {
@@ -69,6 +75,8 @@ impl DataPump {
resolved_bitrate_kbps,
negotiated_codec,
stream_cap_kbps,
refresh_hz,
mode_slot: pump_mode_slot,
} = self;
pin_thread_user_interactive(); // feeds the frame channel → the user-interactive video pump
register_hot_tid(&pump_hot_tids); // this thread does UDP receive + FEC reassembly — hint it
@@ -121,6 +129,10 @@ impl DataPump {
// no inter-coded stream benefits from — the field session walked to 657 Mbps for 1440p120
// and drove the client's decode latency from 0.8 ms to 10 ms getting there.
abr.set_stream_cap(stream_cap_kbps);
// Size the host-encode thresholds in this session's frame budgets rather than the 120 Hz
// durations they were calibrated at — a 60 Hz session otherwise takes the SEVERE
// one-window ×0.7 on an ordinary one-frame encode hiccup.
abr.set_frame_budget(refresh_hz);
// Startup link-capacity probe (Automatic sessions): the controller's ceiling is the
// negotiated start rate — the conservative 20 Mbps default, historically a box Automatic
// could NEVER climb out of. One speed-test burst shortly after the stream settles
@@ -128,18 +140,31 @@ impl DataPump {
// becomes the climb ceiling and slow start does the rest. Old hosts decline (all-zero
// reply) or never answer (timeout clears the state so LossReports resume) — either way
// the ceiling stays negotiated, exactly the old behavior. PUNKTFUNK_ABR_PROBE=0 opts out.
// `PUNKTFUNK_ABR_PROBE_KBPS` lowers the burst target (unset/0/garbage → the 2 Gbps
// default): the target is deliberately far above any plausible link so the burst measures
// the link and not itself, but on links the burst DISTURBS that backfires — a constrained
// Wi-Fi link can black-hole under 2 Gbps (measured on webOS: the probe hitting the 6 s
// timeout delayed first video to 14 s, and a "successful" one still reported
// send_dropped=20211), and a 2-3 core TV client starves decoding the firehose. An
// embedder that caps its own speed test wants this capped to match.
// The burst target is DERIVED from `stream_cap_kbps`, not set "far above any plausible
// link". It used to be a flat 2 Gbps on that reasoning — the burst must measure the link
// and not itself but the ABR already discards every bit measured above what the session
// could use: `set_ceiling` clamps to the stream cap set a few lines up, so everything past
// `stream_cap_kbps / 0.7` is thrown away the moment it lands. All that height bought was
// bufferbloat for a number nothing reads, and on links the burst DISTURBS it backfires — a
// constrained Wi-Fi link can black-hole under 2 Gbps (measured on webOS: the probe hitting
// the 6 s timeout delayed first video to 14 s, and a "successful" one still reported
// send_dropped=20211; the same shape is reported on a Fire TV Stick 4K Max), and a 2-3
// core TV client starves decoding the firehose.
//
// ×2 is the smallest multiplier that still PROVES the cap: the measured ceiling is
// `delivered × 0.7`, so reaching `stream_cap_kbps` needs `delivered ≥ cap × 1.43` and the
// rest is margin. Deriving it this way cannot cap anyone — a session whose mode and codec
// justify a high ceiling asks for a correspondingly high target by itself, and a mode we
// cannot size (`stream_ceiling_kbps` → `u32::MAX`) still gets the old 2 Gbps. It also
// fixes webOS and every other constrained client, not just the box that reported it.
//
// `PUNKTFUNK_ABR_PROBE_KBPS` overrides the target outright (unset/0/garbage → the derived
// one). An embedder that caps its own speed test wants this capped to match.
let capacity_probe_kbps: u32 = std::env::var("PUNKTFUNK_ABR_PROBE_KBPS")
.ok()
.and_then(|v| v.trim().parse::<u32>().ok())
.filter(|&v| v > 0)
.unwrap_or(2_000_000);
.unwrap_or_else(|| probe_target_kbps(stream_cap_kbps));
const CAPACITY_PROBE_MS: u32 = 800;
const CAPACITY_PROBE_DELAY: Duration = Duration::from_secs(2);
const CAPACITY_PROBE_TIMEOUT: Duration = Duration::from_secs(6);
@@ -154,6 +179,9 @@ impl DataPump {
// in; the embedder path had neither, so an unanswered request wedged the report tick and a
// finished one left the ABR window anchored before the burst.
let mut was_probing = false;
// `frames_completed` as the burst began, so the probe-end block below can ask "did ANY
// frame survive this burst" rather than only "has one ever arrived" — see there.
let mut frames_at_probe_start: u64 = 0;
// The window this closes is discarded outright: no LossReport, no standing-latency close,
// no ABR feed. Two causes, both of them "this window's signals describe something other
// than the link, and one bogus congestion verdict here ends slow start for good":
@@ -289,6 +317,24 @@ impl DataPump {
last_report = Instant::now();
discard_abr_window = true;
flush_in_window = false;
// …and if the burst swallowed the video with it, re-anchor the decoder. This runs
// on EVERY probe end — a successful one, a timed-out one, an embedder "Test
// connection" — and the frame-count guard is what makes it a no-op the rest of the
// time: a burst the link couldn't hold can take the keyframe down with it, and
// then nothing re-requests one, so the client sits on black until some unrelated
// recovery path happens to fire. That is the reported Fire TV / webOS black
// screen. Compared against the count SNAPSHOTTED at the burst's leading edge
// rather than against 0: at startup the two are the same test, but this one also
// catches a burst that kills an already-running stream (an embedder speed test
// mid-session), which the cumulative counter never could. At most one request per
// probe, and it funnels through the control task's coalescer like the other two
// emitters in this file, so it cannot IDR-storm.
if st.frames_completed == frames_at_probe_start {
let _ = ctrl_tx.try_send(CtrlRequest::Keyframe);
tracing::warn!(
"no frame survived the capacity probe — requested a keyframe to re-anchor"
);
}
}
// Arm a watchdog on the leading edge of ANY probe, so a host that silently ignores
// `ProbeRequest` (an old build — anticipated, see the capacity-probe timeout below)
@@ -296,6 +342,7 @@ impl DataPump {
if !was_probing && probe_active {
let burst = Duration::from_millis(pump_probe.lock().unwrap().duration_ms as u64);
probe_watchdog = Some(Instant::now() + burst + CAPACITY_PROBE_TIMEOUT);
frames_at_probe_start = st.frames_completed;
}
if !probe_active {
probe_watchdog = None;
@@ -503,6 +550,10 @@ impl DataPump {
if mg != seen_mode_gen {
seen_mode_gen = mg;
abr.on_mode_switch();
// The frame budget is a property of the MODE: a switch that changes the
// refresh changes what one frame of encode time costs, and the encode
// thresholds are sized in those.
abr.set_frame_budget(pump_mode_slot.lock().unwrap().refresh_hz);
}
if let Some(acked) = bitrate_ack.lock().unwrap().take() {
abr.on_ack(acked);
@@ -797,6 +848,18 @@ fn should_report_delivery(packets_received: u64, confirmed: &mut bool) -> bool {
owed
}
/// The capacity probe's burst target for a session bounded at `stream_cap_kbps`, in kbps — the
/// default `PUNKTFUNK_ABR_PROBE_KBPS` overrides. See the probe's comment in the pump for why it is
/// derived rather than fixed: `BitrateController::set_ceiling` clamps the measurement to the
/// stream cap, so every bit measured above `cap / 0.7` is discarded, and bursting for it only
/// buys bufferbloat. ×2 clears that `1.43×` bar with margin.
///
/// `u32::MAX` in (a mode [`crate::abr::stream_ceiling_kbps`] declines to size) keeps the historic
/// 2 Gbps, which is also the ceiling on the whole derivation: this can only ever lower the target.
fn probe_target_kbps(stream_cap_kbps: u32) -> u32 {
stream_cap_kbps.saturating_mul(2).min(2_000_000)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -836,6 +899,40 @@ mod tests {
}
}
/// The burst has to be big enough to PROVE the stream cap and no bigger. Anything the burst
/// measures above `cap / 0.7` is discarded by `BitrateController::set_ceiling` (pinned by
/// `abr::tests::the_stream_bound_clamps_a_learned_ceiling_only`) and paid for in bufferbloat.
#[test]
fn the_probe_target_proves_the_stream_cap_without_overshooting_it() {
// Real modes, from the smallest a session runs to the largest — including 1440p120, the
// field session that walked to 657 Mbps and taught the ABR the cap in the first place.
for (w, h, hz, codec, depth) in [
(1280, 720, 60, crate::quic::CODEC_HEVC, 8),
(1920, 1080, 60, crate::quic::CODEC_H264, 8),
(2560, 1440, 120, crate::quic::CODEC_HEVC, 8),
(3840, 2160, 120, crate::quic::CODEC_HEVC, 10),
] {
let cap = crate::abr::stream_ceiling_kbps(w, h, hz, codec, depth, 0);
let target = probe_target_kbps(cap);
// Enough: a link that delivers the whole burst measures `delivered × 0.7`, and that
// has to reach the cap or the session can never climb to what its mode allows.
assert!(
target.saturating_mul(7) / 10 >= cap,
"{w}x{h}@{hz}: a {target} kbps burst cannot prove a {cap} kbps cap"
);
// …and no more: a target that overshoots what the clamp keeps is pure bufferbloat.
// (The old flat 2 Gbps overshot 1440p120 by 6×.)
assert!(
target <= cap.saturating_mul(2),
"{w}x{h}@{hz}: {target} kbps chases capacity the clamp discards"
);
}
// A mode `stream_ceiling_kbps` declines to size (`u32::MAX`) keeps the historic 2 Gbps,
// which is also the hard ceiling on the derivation — it can only ever lower the target.
assert_eq!(probe_target_kbps(u32::MAX), 2_000_000);
assert_eq!(probe_target_kbps(1_500_000), 2_000_000);
}
#[test]
fn a_pipeline_gap_is_taken_exactly_once() {
let slot = AtomicU32::new(0);
@@ -974,6 +1071,12 @@ mod tests {
resolved_bitrate_kbps: 20_000,
negotiated_codec: crate::quic::CODEC_HEVC,
stream_cap_kbps: 100_000,
refresh_hz: 60,
mode_slot: Arc::new(Mutex::new(crate::config::Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
})),
};
let started = Instant::now();
let pump_thread = std::thread::spawn(move || pump.run());
@@ -26,17 +26,26 @@
use super::{audio_control, audio_probe, minted, pad_endpoint as pe};
use anyhow::Result;
use windows::Win32::Devices::DeviceAndDriverInstallation::SetupDiEnumDeviceInfo;
use windows::Win32::Devices::DeviceAndDriverInstallation::{
SetupDiEnumDeviceInfo, SPDRP_HARDWAREID,
};
/// The `Device Parameters` REG_DWORD each punktfunk-minted devnode family stamps on itself. The
/// VALUE is what differs per family; presence of the NAME is "this one is ours", which is all a
/// sweep needs.
const OWNER_MARKERS: [&str; 3] = [
pub(crate) const OWNER_MARKERS: [&str; 3] = [
pe::PAD_INDEX_VALUE,
minted::ROLE_MARKER,
audio_probe::PROBE_MARKER,
];
/// The Steam streaming hardware ids every audio devnode this product mints is created with —
/// the second half of the ABANDONED-devnode test in [`owned_devnodes`].
const MINTED_HWIDS: [&str; 2] = [
"ROOT\\SteamStreamingSpeakers",
"ROOT\\SteamStreamingMicrophone",
];
/// What one sweep removed. `endpoint_records` is counted separately from `devnodes` because the
/// registry half is best-effort by design — see [`delete_endpoint_record`].
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
@@ -117,11 +126,91 @@ fn owned_devnodes() -> Result<Vec<String>> {
.any(|m| pe::read_devparam_dword(&set, &did, m).is_some())
{
out.push(inst);
continue;
}
// ABANDONED: `ROOT\MEDIA\NNNN` carrying one of our minting hardware ids but no marker at
// all — a devnode registered by a host that died before the marker write landed. It is
// still bound and still serving endpoints, so leaving it behind is the "uninstalling
// punktfunk left Sound settings full of Punktfunk devices forever" report all over again.
//
// The instance prefix is what makes this safe, and it is NOT redundant with
// [`is_removable_instance`]: Steam's own devnodes carry these very hardware ids and are
// ROOT-enumerated too, but live under `ROOT\SteamStreamingSpeakers\*` /
// `ROOT\SteamStreamingMicrophone\*`. Only `ROOT\MEDIA\*` can have come from our
// `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)`.
if is_abandoned_mint(
&inst,
&pe::devnode_multi_sz_prop(&set, &did, SPDRP_HARDWAREID),
) {
out.push(inst);
}
}
Ok(out)
}
/// The ABANDONED-devnode test, split out from the PnP enumeration so the rule that keeps this
/// sweep off VALVE'S OWN devices is checkable without a live devinfo set. See [`owned_devnodes`].
fn is_abandoned_mint(instance_id: &str, hwids: &[String]) -> bool {
instance_id
.to_ascii_uppercase()
.starts_with("ROOT\\MEDIA\\")
&& MINTED_HWIDS
.iter()
.any(|want| hwids.iter().any(|h| h.eq_ignore_ascii_case(want)))
}
#[cfg(test)]
mod abandoned_tests {
use super::is_abandoned_mint;
fn hw(s: &str) -> Vec<String> {
vec![s.to_string()]
}
#[test]
fn adopts_our_own_unmarked_devnodes() {
// What a host that died mid-mint leaves behind, either role.
assert!(is_abandoned_mint(
r"ROOT\MEDIA\0004",
&hw(r"ROOT\SteamStreamingMicrophone")
));
assert!(is_abandoned_mint(
r"ROOT\MEDIA\0002",
&hw(r"ROOT\SteamStreamingSpeakers")
));
// PnP casing is not guaranteed on either half.
assert!(is_abandoned_mint(
r"root\media\0009",
&hw(r"root\steamstreamingspeakers")
));
}
#[test]
fn never_matches_valves_own_devices() {
// THE safety rule: Steam's devnodes carry the very same hardware ids and are ROOT-
// enumerated too — only the instance prefix separates them from ours.
assert!(!is_abandoned_mint(
r"ROOT\STEAMSTREAMINGMICROPHONE\0000",
&hw(r"ROOT\SteamStreamingMicrophone")
));
assert!(!is_abandoned_mint(
r"ROOT\STEAMSTREAMINGSPEAKERS\0000",
&hw(r"ROOT\SteamStreamingSpeakers")
));
}
#[test]
fn never_matches_other_vendors_or_real_hardware() {
// VB-Cable mints ROOT\MEDIA devnodes too — a different hardware id is all that saves it.
assert!(!is_abandoned_mint(r"ROOT\MEDIA\0000", &hw("VBAudioVACWDM")));
assert!(!is_abandoned_mint(
r"HDAUDIO\FUNC_01&VEN_10EC&DEV_0897",
&hw(r"ROOT\SteamStreamingSpeakers")
));
assert!(!is_abandoned_mint(r"ROOT\MEDIA\0001", &[]));
}
}
/// A devnode this sweep is allowed to remove: ROOT-enumerated, i.e. software-created.
///
/// Every devnode we mint comes from `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)` on the MEDIA
@@ -275,13 +275,22 @@ fn ensure_role(role: Role) -> Result<(String, String, Option<String>)> {
let (hwid, inf) = discover_driver(role.needle(), role.inf_name())?;
let devnode = match find_role_devnode(role)? {
Some(inst) => inst,
None => {
let inst = pe::create_media_devnode(role.desc(), &hwid, |set, did| {
pe::write_devparam_dword(set, did, ROLE_MARKER, role.value())
})?;
tracing::info!(role = role.label(), devnode = %inst, "minted an audio devnode");
inst
}
// Before minting a SECOND devnode, reclaim an abandoned one. Minting is two PnP steps
// (register, then mark), and a host that dies between them — the 0.30.0 teardown abort
// did exactly this, five times on one box — leaves a registered, driver-bound, endpoint-
// serving devnode that carries no marker. Nothing then resolves it: the next pass mints
// a fresh one and the orphan lingers as a duplicate "Punktfunk Speakers"/"Punktfunk
// Microphone" in the Sound zoo, invisible to the marker-matched uninstall sweep.
None => match adopt_orphan_devnode(role, &hwid)? {
Some(inst) => inst,
None => {
let inst = pe::create_media_devnode(role.desc(), &hwid, |set, did| {
pe::write_devparam_dword(set, did, ROLE_MARKER, role.value())
})?;
tracing::info!(role = role.label(), devnode = %inst, "minted an audio devnode");
inst
}
},
};
pe::bind_driver(&hwid, &inf)?;
@@ -531,6 +540,61 @@ fn find_role_devnode(role: Role) -> Result<Option<String>> {
Ok(None)
}
/// Reclaim an ABANDONED punktfunk devnode for `role`, re-marking it so it resolves normally from
/// here on; `None` when there is nothing to adopt (the ordinary first-mint path).
///
/// The shape adopted is `ROOT\MEDIA\NNNN` + the role's Steam hardware id + NO owner marker.
/// That triple can only be ours: `ROOT\MEDIA\NNNN` is what
/// `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)` on the MEDIA class yields, and STEAM'S OWN
/// devnodes are enumerated under `ROOT\SteamStreamingSpeakers\*` /
/// `ROOT\SteamStreamingMicrophone\*` — they carry the same hardware id but never that instance
/// prefix, which is precisely what keeps this from adopting (and later sweeping) Steam's devices.
/// A marker of ANY family is left alone: it is a live devnode, ours but spoken for.
///
/// Which family the orphan came from does not matter. Every one is a plain instance of the same
/// Valve driver; roles are ours to assign, and re-marking it here is what makes the assignment
/// stick across restarts.
fn adopt_orphan_devnode(role: Role, hwid: &str) -> Result<Option<String>> {
use windows::Win32::Devices::DeviceAndDriverInstallation::{
SetupDiEnumDeviceInfo, SPDRP_HARDWAREID,
};
let set = pe::media_class_devs()?;
for i in 0.. {
let mut did = pe::devinfo_data();
// SAFETY: live set; `did` is a live out-param with cbSize set.
if unsafe { SetupDiEnumDeviceInfo(set.0, i, &mut did) }.is_err() {
break; // ERROR_NO_MORE_ITEMS
}
let Some(inst) = pe::instance_id(&set, &did) else {
continue;
};
if !inst.to_ascii_uppercase().starts_with("ROOT\\MEDIA\\") {
continue;
}
if !pe::devnode_multi_sz_prop(&set, &did, SPDRP_HARDWAREID)
.iter()
.any(|h| h.eq_ignore_ascii_case(hwid))
{
continue;
}
if super::devnode_cleanup::OWNER_MARKERS
.iter()
.any(|m| pe::read_devparam_dword(&set, &did, m).is_some())
{
continue;
}
pe::write_devparam_dword(&set, &mut did, ROLE_MARKER, role.value())?;
tracing::warn!(
role = role.label(),
devnode = %inst,
"adopted an abandoned audio devnode — one of ours whose owner marker never landed \
(a host that died mid-mint). Re-marked and reused instead of minting a duplicate"
);
return Ok(Some(inst));
}
Ok(None)
}
/// Find the (exact hardware id, INF path) for one of Steam's streaming drivers: prefer any
/// installed devnode whose hardware-id list contains `needle` (its `oemNN.inf` is the driver
/// Windows already trusts), else fall back to Steam's driver directory. Shared with the
@@ -1192,6 +1192,17 @@ fn grant_system_full_control(subkey_path: &str) -> Result<()> {
result
}
/// The MMDevices hive an endpoint's record lives in, chosen by the direction its id encodes
/// (`{0.0.1.…}` = capture, anything else = render). Render is the safe default: it is what every
/// non-capture id resolves to, and the pad program only ever has render endpoints.
fn mmdev_path_for(endpoint_id: &str) -> &'static str {
if endpoint_id.starts_with(CAPTURE_ENDPOINT_ID_PREFIX) {
MMDEV_CAPTURE_PATH
} else {
MMDEV_RENDER_PATH
}
}
/// The raw-registry stamp route: repair the Properties key ACL, then write the serialized
/// values (see [`reg_registry_value`]). Values written here are STORED but possibly not
/// SERVED until an AudioEndpointBuilder restart — the caller's read-back decides.
@@ -1199,7 +1210,14 @@ fn registry_stamp(endpoint_id: &str, stamps: &[&Stamp]) -> Result<()> {
use winreg::enums::HKEY_LOCAL_MACHINE;
use winreg::RegKey;
let guid = endpoint_guid_part(endpoint_id)?;
let path = format!(r"{MMDEV_RENDER_PATH}\{guid}\Properties");
// The hive follows the endpoint's DIRECTION. This was hardcoded to Render, which is
// invisible for the pad program (its endpoints are render-only) but wrong for the minted
// provider, which stamps the virtual microphone's CAPTURE endpoint through the same
// writer: the fallback then reached for `…\Render\{capture-guid}\Properties`, a key that
// cannot exist, so every registry-route stamp of a capture endpoint failed on a box where
// the property store was denied — silently, since the caller degrades to "keeps the
// driver's default name".
let path = format!(r"{}\{guid}\Properties", mmdev_path_for(endpoint_id));
grant_system_full_control(&path)
.with_context(|| format!("make {path} writable (registry stamp route)"))?;
let key = RegKey::predef(HKEY_LOCAL_MACHINE)
@@ -2109,6 +2127,23 @@ fn pad_capture_thread(
mod tests {
use super::*;
/// The registry stamp route must reach for the hive matching the endpoint's DIRECTION —
/// it was hardcoded to Render, so a capture endpoint's fallback stamp could never land.
#[test]
fn registry_stamp_hive_follows_the_endpoint_direction() {
assert_eq!(
mmdev_path_for("{0.0.1.00000000}.{2753f927-2093-4ab4-aa90-9d880e959128}"),
MMDEV_CAPTURE_PATH,
"the minted microphone's capture endpoint records under Capture"
);
assert_eq!(
mmdev_path_for("{0.0.0.00000000}.{5da9b5c9-8a10-4b54-8cf6-ce02b8354f16}"),
MMDEV_RENDER_PATH,
);
// Anything unrecognised keeps the old behaviour rather than inventing a hive.
assert_eq!(mmdev_path_for("nonsense"), MMDEV_RENDER_PATH);
}
/// The serialized container blob for pad 0 must be byte-for-byte the on-glass-measured
/// value, and byte 23 must be the pad index.
#[test]
+47 -1
View File
@@ -573,6 +573,29 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
// (device_type 3, the MI_02-promoted identity) — watch Steam claim it live.
let edge = args.iter().any(|a| a == "--edge");
let deck = args.iter().any(|a| a == "--deck");
// `--idle-after N` drives normally for N seconds, then STOPS sending state frames while still
// pumping. That is Moonlight's cadence: moonlight-common-c sends a controller packet only on
// CHANGE, so an untouched pad produces no wire events at all. The native plane never sees this
// because punktfunk's own client re-sends every live pad's snapshot every 100 ms (the
// `input_task.rs` refresh tick) — which is exactly why a manager that needs a periodic re-emit
// can look healthy on one plane and die on the other.
let idle_after: u64 = args
.iter()
.skip_while(|a| *a != "--idle-after")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
// `--resume-after M` ends the silence at M seconds and drives again. That is the half that
// actually answers the question: enumeration surviving a silence proves nothing, because a pad
// can stay listed and still deliver no input. What matters is whether a report written AFTER
// the silence still reaches a consumer — check it with `win-input-matrix --watch` while this
// runs, and watch whether the timestamps start advancing again.
let resume_after: u64 = args
.iter()
.skip_while(|a| *a != "--resume-after")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let extra_buttons: u32 = if edge || deck {
punktfunk_core::input::gamepad::BTN_PADDLE1 | punktfunk_core::input::gamepad::BTN_PADDLE2
} else {
@@ -612,6 +635,9 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
$label
);
let deadline = Instant::now() + Duration::from_secs(secs);
let started = Instant::now();
let mut announced_silence = false;
let mut announced_resume = false;
let (mut i, mut last) = (0i32, Instant::now());
while Instant::now() < deadline {
mgr.pump(
@@ -620,7 +646,27 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
),
|o| println!(" hid output from game: {o:?}"),
);
if last.elapsed() >= Duration::from_millis(400) {
let el = started.elapsed();
let resumed =
resume_after != 0 && el >= Duration::from_secs(resume_after.max(idle_after));
let silent =
idle_after != 0 && el >= Duration::from_secs(idle_after) && !resumed;
if silent && !announced_silence {
announced_silence = true;
println!(
" --- going SILENT (no more state frames, still pumping) at {}s ---",
idle_after
);
}
if resumed && !announced_resume {
announced_resume = true;
println!(
" --- RESUMING state frames at {}s (after {}s of silence) ---",
resume_after,
resume_after.saturating_sub(idle_after)
);
}
if !silent && last.elapsed() >= Duration::from_millis(400) {
last = Instant::now();
i += 1;
let buttons = if i % 2 == 0 {
+104 -29
View File
@@ -25,7 +25,9 @@
use anyhow::{Context, Result};
use mdns_sd::{ServiceDaemon, ServiceInfo};
use std::collections::HashMap;
use std::net::IpAddr;
use std::net::{IpAddr, Ipv4Addr};
use std::sync::mpsc;
use std::time::Duration;
/// The native-protocol mDNS service type. Clients browse this to find punktfunk/1 hosts.
pub const NATIVE_SERVICE: &str = "_punktfunk._udp.local.";
@@ -81,9 +83,78 @@ pub(crate) fn dns_label(name: &str) -> String {
}
}
/// Holds the mDNS daemon; dropping it unregisters the service.
/// Holds the mDNS daemon; dropping it unregisters the service and stops the re-announce loop.
pub struct Advert {
_daemon: ServiceDaemon,
/// Never sent on. Dropping it disconnects the channel the re-announce thread waits on, which
/// wakes that thread immediately and ends it — so an `Advert` takes its loop with it instead
/// of leaving one behind polling for a service nobody advertises.
_stop: mpsc::Sender<()>,
}
/// How often a live advert re-checks the address it is announcing.
const IP_RECHECK: Duration = Duration::from_secs(10);
/// The address to advertise right now — loopback only while the machine still has none.
fn current_ip() -> IpAddr {
crate::gamestream::primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
}
/// Register `build(ip)` for the host's current address, and re-register it whenever that address
/// changes. Shared by both adverts ([`advertise_native`] and [`crate::gamestream::mdns`]).
///
/// mDNS records are PUSHED, not polled: whatever address was true at `register()` keeps being
/// announced until something registers a newer one. The host process comes up during boot, which
/// on a cold start is before the machine has an address — so the first registration could be
/// `127.0.0.1`, and it stayed that way until the host was restarted by hand. `mdns-sd` documents a
/// second `register()` of the same fullname as an update, so re-announcing is just calling it
/// again.
///
/// Polls the *routed* address rather than subscribing to the daemon's `IpAdd` events, because the
/// boot race usually resolves without one: the NIC often has its address before we register and
/// only the default route lands late, so no interface event ever fires.
pub(crate) fn advertise_live(
service: &'static str,
build: impl Fn(IpAddr) -> Result<ServiceInfo> + Send + 'static,
) -> Result<Advert> {
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
let registered = current_ip();
daemon
.register(build(registered)?)
.with_context(|| format!("register {service} mDNS service"))?;
let (stop_tx, stop_rx) = mpsc::channel::<()>();
let bg_daemon = daemon.clone();
std::thread::spawn(move || {
let mut announced = registered;
// Doubles as the sleep: times out every `IP_RECHECK` to re-check, and returns
// `Disconnected` the moment the `Advert` drops its sender, which ends the loop.
while matches!(
stop_rx.recv_timeout(IP_RECHECK),
Err(mpsc::RecvTimeoutError::Timeout)
) {
let now = current_ip();
if now == announced {
continue;
}
match build(now)
.and_then(|info| bg_daemon.register(info).context("re-register mDNS service"))
{
Ok(()) => {
tracing::info!(service, from = %announced, to = %now, "host address changed — re-announced");
announced = now;
}
// Leave the previous record standing and retry next tick rather than going dark.
Err(e) => {
tracing::warn!(service, error = %format!("{e:#}"), "mDNS re-announce failed");
}
}
}
});
Ok(Advert {
_daemon: daemon,
_stop: stop_tx,
})
}
/// Advertise the native host on the LAN. `fingerprint` is the host cert SHA-256 (lowercase hex);
@@ -95,7 +166,6 @@ pub struct Advert {
#[allow(clippy::too_many_arguments)]
pub fn advertise_native(
hostname: &str,
ip: IpAddr,
port: u16,
fingerprint: &str,
require_pairing: bool,
@@ -103,14 +173,17 @@ pub fn advertise_native(
mgmt_port: Option<u16>,
os_chain: &str,
) -> Result<Advert> {
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
// `hostname` is the DISPLAY name (the instance label clients read back); the A-record target
// has to be a legal DNS name, hence the separate sanitized label.
let host_name = format!("{}.local.", dns_label(hostname));
let mut props: HashMap<String, String> = HashMap::new();
props.insert("proto".into(), NATIVE_PROTO.into());
props.insert("fp".into(), fingerprint.to_string());
props.insert(
// Owned, because the record is rebuilt whenever the host's address changes — see
// [`advertise_live`]. Everything except the address (and the MACs derived from it) is fixed,
// so it is computed once here and moved into the builder.
let instance = hostname.to_string();
let mut fixed: HashMap<String, String> = HashMap::new();
fixed.insert("proto".into(), NATIVE_PROTO.into());
fixed.insert("fp".into(), fingerprint.to_string());
fixed.insert(
"pair".into(),
if require_pairing {
"required"
@@ -119,31 +192,14 @@ pub fn advertise_native(
}
.into(),
);
props.insert("id".into(), uniqueid.to_string());
fixed.insert("id".into(), uniqueid.to_string());
if let Some(mgmt) = mgmt_port {
props.insert("mgmt".into(), mgmt.to_string());
fixed.insert("mgmt".into(), mgmt.to_string());
}
// `os` — advisory OS-identity chain for the client's host-card icon (see module doc).
if !os_chain.is_empty() {
props.insert("os".into(), os_chain.to_string());
fixed.insert("os".into(), os_chain.to_string());
}
// `mac` — the host's wake-capable NIC MAC(s), comma-separated `aa:bb:cc:dd:ee:ff`, routed NIC
// first. A client persists these while the host is awake so it can send a Wake-on-LAN magic
// packet to wake it later (when it's asleep and no longer advertising). Unauthenticated like
// the rest of the advert, but a wrong MAC only makes a wake fail — the magic packet is inert
// and the cert fingerprint still gates the actual connection. Omitted when none can be read.
let macs = crate::wol::wake_macs(ip);
if !macs.is_empty() {
props.insert("mac".into(), macs.join(","));
}
// Detect & warn (never modifies) if the routed NIC isn't armed to wake — the usual reason WoL
// silently fails.
crate::wol::warn_if_not_armed(ip);
let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props)
.context("build native mDNS ServiceInfo")?;
daemon
.register(service)
.context("register native mDNS service")?;
tracing::info!(
service = "_punktfunk._udp",
port,
@@ -151,7 +207,26 @@ pub fn advertise_native(
pair = if require_pairing { "required" } else { "optional" },
"native punktfunk/1 mDNS advertising"
);
Ok(Advert { _daemon: daemon })
advertise_live(NATIVE_SERVICE, move |ip| {
let mut props = fixed.clone();
// `mac` — the host's wake-capable NIC MAC(s), comma-separated `aa:bb:cc:dd:ee:ff`, routed
// NIC first. A client persists these while the host is awake so it can send a
// Wake-on-LAN magic packet to wake it later (when it's asleep and no longer advertising).
// Unauthenticated like the rest of the advert, but a wrong MAC only makes a wake fail —
// the magic packet is inert and the cert fingerprint still gates the actual connection.
// Omitted when none can be read, which is what a host that came up before its network did
// used to report forever.
let macs = crate::wol::wake_macs(ip);
if !macs.is_empty() {
props.insert("mac".into(), macs.join(","));
}
// Detect & warn (never modifies) if the routed NIC isn't armed to wake — the usual reason
// WoL silently fails. Re-checked on an address change because the routed NIC may be a
// different one now.
crate::wol::warn_if_not_armed(ip);
ServiceInfo::new(NATIVE_SERVICE, &instance, &host_name, ip, port, props)
.context("build native mDNS ServiceInfo")
})
}
#[cfg(test)]
+106 -2
View File
@@ -590,6 +590,9 @@ fn watch(
// ---- Phase 1: wait for the game to show up. ----
let start_deadline = spawned_at + START_GRACE;
// How long the scan has *continuously* seen something for this title — the scan-side twin of
// [`SHIM_WINDOW`]. See `scan_settled` below for what it is protecting against.
let mut seen_since: Option<Instant> = None;
loop {
if cancelled() {
return;
@@ -713,12 +716,39 @@ fn watch(
&& (child.is_some() || spawned.is_some())
&& spawned_at.elapsed() >= SHIM_WINDOW;
let live = scanner.find(&shared.spec, shared.launch_stamp);
// The same rule for what the *scan* finds, and for the same reason. A store's launch is a
// chain of process trees, and the ones that run before the game carry the signals the game
// carries: Steam wraps its shader pre-caching and its Proton prefix work in the very
// `reaper SteamLaunch AppId=<appid>` the game gets, so the first poll of a launch can match
// a tree that was never the game.
//
// Latching on one poll is what costs, because the two phases are patient in opposite ways.
// This one waits [`START_GRACE`] — five minutes — and ending it never ends the session.
// Phase 2 waits [`EXIT_CONFIRM`] — three seconds — and ending it *does*. A single sighting
// flips the lease from the first to the second, permanently; when that tree then exits with
// the real game not yet started, the stream drops mid-launch. On Linux that ended a Rocket
// League session 10 s after launch, while Steam was still compiling its shaders, and the
// player had to launch a second time to get one that stayed up (field report 2026-08-22).
//
// Requiring the sighting to persist buys that back for a few seconds of `GameRunning`
// latency and nothing else — exit detection is untouched. ⚠ It is a window, not a proof: a
// pre-launch tree that outlives the window still latches. Signals sharp enough to tell one
// from the other belong in [`crate::procscan`] (where Steam's shader job is already excluded
// by name); this bounds what no signal caught.
let scan_settled = if live.is_empty() {
seen_since = None;
false
} else {
seen_since.get_or_insert_with(Instant::now).elapsed() >= SHIM_WINDOW
};
// A provider saying so is as good as seeing it — better, for a title there is nothing to
// see: it is the launcher that started the game telling us it did. This is the only way a
// [`LeaseKind::Reported`] lease ever leaves this phase, and for a `Matched` one it just
// gets there sooner than the scan would.
// gets there sooner than the scan would. Not gated by the window above: a report is the
// launcher's own statement about the game, not an inference from a process that resembles
// it, so there is nothing to wait out.
let said_running = reported().is_some_and(|l| l.running);
if !live.is_empty() || child_alive || said_running {
if scan_settled || child_alive || said_running {
known = live.clone();
publish(&live);
shared.was_running.store(true, Ordering::Relaxed);
@@ -731,6 +761,8 @@ fn watch(
title = %shared.game.title,
kind = kind.as_str(),
procs = live.len(),
// Which processes, not just how many: see [`crate::procscan::names`].
names = ?crate::procscan::names(&live),
"the launched game is running"
);
break;
@@ -2019,6 +2051,78 @@ mod tests {
);
}
/// 🛑 The 2026-08-22 field report: a **pre-launch** process tree must not be mistaken for the
/// game.
///
/// Steam wraps its shader pre-caching in the same `SteamLaunch AppId=` reaper the game itself
/// gets, so the first poll of a launch matches a tree that was never the game. What shipped
/// latched on that single sighting: the lease left the start phase immediately, and when the
/// compile finished and that tree exited — with Rocket League still starting — the exit watch
/// called it the game exiting and closed the session with `APP_EXITED`, 10 s after launch. On
/// the player's screen the stream dropped mid-"Processing Vulkan shaders"; their workaround was
/// to launch the game twice.
///
/// The scanner now knows Steam's replayer by name ([`crate::procscan`]). This pins the bound
/// behind that: a matched process that does not outlive [`SHIM_WINDOW`] never arms the exit
/// watch, whatever it was — which is what covers the pre-launch trees nobody has named yet.
///
/// Ignored by default: it outlives the shim window and then waits out [`EXIT_CONFIRM`], ~11 s.
#[cfg(target_os = "linux")]
#[test]
#[ignore = "drives a real process for ~11s (shim window + exit confirmation)"]
fn a_pre_launch_tree_that_exits_never_ends_the_session() {
use std::sync::atomic::AtomicUsize;
// The stand-in has to keep the name `sleep`: coreutils is a multi-call binary that
// dispatches on `argv[0]`, and under any other name it exits instantly — which would pass
// this test for entirely the wrong reason. (Same trap as the live matcher test in
// [`crate::procscan`].)
let td = tempfile::tempdir().expect("tempdir");
let stand_in = td.path().join("sleep");
std::fs::copy("/bin/sleep", &stand_in).expect("copy a stand-in pre-launch binary");
let launch_stamp = launch_clock();
// Alive for less than the shim window — Steam's shader job, in miniature.
let mut child = std::process::Command::new(&stand_in)
.arg("3")
.spawn()
.expect("spawn the fake pre-launch tree");
// Reaped on its own thread: a zombie keeps its `/proc` entry with an unchanged start time,
// so the scan would call it alive forever and the exit under test never happen.
std::thread::spawn(move || {
let _ = child.wait();
});
static PRE_EXITS: AtomicUsize = AtomicUsize::new(0);
PRE_EXITS.store(0, Ordering::SeqCst);
let lease = open(
LeaseRequest {
launch_stamp,
// No child and no pid: the scan is the only signal, which is the field-report shape
// (`steam steam://rungameid/…` had already handed off and exited).
..req("steam:pre-launch", DetectSpec::dir(td.path()), false)
},
Box::new(|| {
PRE_EXITS.fetch_add(1, Ordering::SeqCst);
}),
);
let shared = lease.shared();
assert!(matches!(shared.kind(), LeaseKind::Matched));
std::thread::sleep(SHIM_WINDOW + EXIT_CONFIRM + Duration::from_secs(3));
assert_eq!(
PRE_EXITS.load(Ordering::SeqCst),
0,
"a tree that ran before the game must not end the session when it exits — this is the \
field report"
);
assert_ne!(
shared.state(),
GameState::Exited,
"the game never started, so nothing of it can have exited"
);
}
/// The whole point of the module, against a real process: a `Child` lease sees its game running,
/// notices when it exits, and reports that exit exactly once.
///
@@ -821,6 +821,7 @@ fn on_receive(
// session without the GAMEPAD grant never creates a uinput node or a pad-audio streamer,
// because the creating event never arrives.
if let Some(gp) = super::gamepad::decode(&pt) {
crate::sleep_inhibit::note_input();
if permitted(grants, GrantClass::Gamepad, drops) {
pads.handle(&gp);
}
@@ -831,6 +832,7 @@ fn on_receive(
// pen drives this session's virtual tablet; touch forwards as ordinary wire touches.
// Pointer-class by construction (the plane tag decides, like the native pen plane).
if let Some(p) = super::input::decode_pointer(&pt) {
crate::sleep_inhibit::note_input();
if permitted(grants, GrantClass::Pointer, drops) {
pointer.apply(&p, |ev| {
let _ = inj_tx.send(ev);
@@ -862,6 +864,10 @@ fn on_receive(
if events.is_empty() {
return; // keepalive / QoS / unhandled input kind
}
// A Moonlight guest is driving the box — drop any standing suspend veto so their own "Sleep"
// reaches logind (see `sleep_inhibit`). Past the `is_empty` gate on purpose: a keepalive is
// the one thing a passive viewer DOES send, and it must not read as someone being there.
crate::sleep_inhibit::note_input();
// Forward to the dedicated injector thread (it opens the backend on the first event and
// coalesces redundant motion) — each event past one mask test against the exhaustive
+18 -21
View File
@@ -3,37 +3,34 @@
use super::Host;
use anyhow::{Context, Result};
use mdns_sd::{ServiceDaemon, ServiceInfo};
use mdns_sd::ServiceInfo;
use std::collections::HashMap;
/// Holds the mDNS daemon; dropping it unregisters the service.
pub struct Advert {
_daemon: ServiceDaemon,
}
// One `Advert` for both service types: holds the mDNS daemon plus the re-announce loop that
// keeps the record pointed at the host's current address.
use crate::discovery::Advert;
const SERVICE: &str = "_nvstream._tcp.local.";
pub fn advertise(host: &Host) -> Result<Advert> {
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
// Instance name = the display name (what Moonlight lists); A-record target = the sanitized
// DNS label, so a free-text `PUNKTFUNK_HOST_NAME` can't produce an illegal record.
let host_name = format!("{}.local.", crate::discovery::dns_label(&host.hostname));
// No TXT records are required for Moonlight discovery; it resolves the A record and then
// GETs /serverinfo for capabilities.
let props: HashMap<String, String> = HashMap::new();
let service = ServiceInfo::new(
"_nvstream._tcp.local.",
&host.hostname,
&host_name,
host.local_ip,
host.http_port,
props,
)
.context("build mDNS ServiceInfo")?;
daemon.register(service).context("register mDNS service")?;
let instance = host.hostname.clone();
let port = host.http_port;
tracing::info!(
service = "_nvstream._tcp",
port = host.http_port,
port,
host = %host_name,
"mDNS advertising"
);
Ok(Advert { _daemon: daemon })
// The advertised address is supplied per-registration so the record follows the host onto a
// network that only came up after boot — see [`crate::discovery::advertise_live`].
crate::discovery::advertise_live(SERVICE, move |ip| {
// No TXT records are required for Moonlight discovery; it resolves the A record and then
// GETs /serverinfo for capabilities.
let props: HashMap<String, String> = HashMap::new();
ServiceInfo::new(SERVICE, &instance, &host_name, ip, port, props)
.context("build mDNS ServiceInfo")
})
}
+220 -18
View File
@@ -138,7 +138,6 @@ pub struct Host {
pub hostname: String,
/// Stable per-host id (persisted), echoed in serverinfo + matched on pairing.
pub uniqueid: String,
pub local_ip: IpAddr,
pub http_port: u16,
pub https_port: u16,
/// OS identity chain (`windows` | `macos` | `linux[/<family>][/<id>]`), advertised in the
@@ -155,13 +154,25 @@ impl Host {
Ok(Host {
hostname: hostname_string(),
uniqueid: load_or_create_uniqueid()?,
local_ip: primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)),
http_port: HTTP_PORT,
https_port: HTTPS_PORT,
os_chain: os.chain.clone(),
os_name: os.pretty.clone(),
})
}
/// Best-effort primary LAN IP, re-read on every call.
///
/// Deliberately NOT a field: [`Host::detect`] runs as the host process starts, which on a cold
/// boot is before the machine has an address at all, and a snapshot taken there used to stick
/// for the life of the process — the host then advertised itself over mDNS as `127.0.0.1`,
/// handed Moonlight an `rtsp://127.0.0.1` session URL, and dropped its Wake-on-LAN MAC record,
/// until someone restarted it by hand. Reading live costs a `connect(2)` on an unconnected UDP
/// socket (no packets are sent), which is nothing beside the HTTP responses it is serialized
/// into. Loopback here means "still no LAN address", not a stale one.
pub fn local_ip(&self) -> IpAddr {
primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
}
}
/// The stream parameters a client passes at `/launch`, shared with the RTSP + media stages.
@@ -408,6 +419,29 @@ pub fn serve(
// The shared streaming-stats recorder: one handle for the mgmt API, the GameStream encode loop
// (via `AppState`), and the native punktfunk/1 loops (passed to `native::serve`).
let stats = crate::stats_recorder::StatsRecorder::new(crate::stats_recorder::default_dir());
// The native plane always runs, so the shared native-pairing handle (linking the QUIC ceremony
// and the management API) always exists.
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(None, None, false)
.context("native pairing store")?,
);
// The identity the native QUIC plane and the mgmt API present (the identity split): P-256 on
// hosts no native client ever pinned, the legacy RSA cert otherwise — resolved ONCE here so
// the two planes cannot race the first-run adoption. See `crate::identity`.
//
// Resolved BEFORE the legacy GameStream identity below, and that order is load-bearing twice
// over. (1) The web console gates its start on `cert.pem` existing and then serves the native
// pair sitting next to it (web/nitro-entry/tls-paths.mjs); minting the legacy pair first
// leaves a first-run window where the console starts, finds no native pair, and serves the
// SAN-less RSA cert no browser accepts — for the rest of that boot. Running first closes that
// window: whenever this call WRITES a native pair, it has done so before `cert.pem` appears.
// (It does not write one on an upgraded host whose native clients pinned the legacy cert —
// there the console correctly falls back to that same legacy pair.) (2) In the degenerate case
// (native clients paired, but the cert they pinned is gone from disk) the old order let
// `load_or_create` mint a BRAND-NEW cert.pem that `load_or_adopt` then adopted while logging
// that it was preserving their pins — stranding them silently. Reading the dir first means
// that case reaches the branch written for it.
let native_ident = crate::identity::load_or_adopt(&np).context("native host identity")?;
#[cfg(feature = "gamestream")]
let state = {
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
@@ -415,24 +449,14 @@ pub fn serve(
};
#[cfg(not(feature = "gamestream"))]
let state = Arc::new(AppState::new(host, stats.clone()));
// The native plane always runs, so the shared native-pairing handle (linking the QUIC ceremony
// and the management API) always exists.
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(None, None, false)
.context("native pairing store")?,
);
// WP13: hand the GameStream planes the grants registry — the nvhttp launch surface and the
// ENet control thread resolve a Moonlight fingerprint's mask against the same registry the
// native plane enforces (design §8: it keys on fingerprint hex and serves both stores).
let _ = state.access.set(np.clone());
// The identity the native QUIC plane and the mgmt API present (the identity split): P-256 on
// hosts no native client ever pinned, the legacy RSA cert otherwise — resolved ONCE here so
// the two planes cannot race the first-run adoption. See `crate::identity`.
let native_ident = crate::identity::load_or_adopt(&np).context("native host identity")?;
tracing::info!(
hostname = %state.host.hostname,
uniqueid = %state.host.uniqueid,
ip = %state.host.local_ip,
ip = %state.host.local_ip(),
native_port = native.port,
require_pairing = native.require_pairing,
gamestream,
@@ -656,10 +680,43 @@ fn load_or_create_uniqueid() -> Result<String> {
/// Best-effort primary LAN IP: open a UDP socket "toward" a public address and read the
/// local address the OS would route through. No packets are actually sent.
fn primary_local_ip() -> Option<IpAddr> {
let sock = UdpSocket::bind("0.0.0.0:0").ok()?;
sock.connect("8.8.8.8:80").ok()?;
sock.local_addr().ok().map(|a| a.ip())
///
/// Returns `None` — never loopback — when the machine has no LAN address yet, so callers have to
/// decide what "unknown" means instead of silently inheriting `127.0.0.1`. During a cold boot the
/// route probe fails outright (the host outruns DHCP: the Windows service is `AutoStart` with no
/// network dependency), so it falls back to the first non-loopback interface address, which the
/// NIC has as soon as it is configured even if the default route is not installed yet.
pub(crate) fn primary_local_ip() -> Option<IpAddr> {
let routed = UdpSocket::bind("0.0.0.0:0")
.and_then(|sock| {
sock.connect("8.8.8.8:80")?;
sock.local_addr()
})
.ok()
.map(|a| a.ip())
.filter(|ip| usable_lan_ip(*ip));
routed.or_else(first_lan_ipv4)
}
/// First reachable IPv4 an interface holds, ignoring the routing table entirely.
///
/// Split out because this is the branch the boot race actually takes, and the one nothing would
/// otherwise exercise: the route probe above needs a default route, which lands *after* the NIC
/// has its address on a cold boot. Between those two moments the old code had no answer and fell
/// back to loopback for good.
fn first_lan_ipv4() -> Option<IpAddr> {
if_addrs::get_if_addrs()
.ok()?
.into_iter()
.map(|i| i.ip())
.find(|ip| ip.is_ipv4() && usable_lan_ip(*ip))
}
/// Is `ip` an address a client could actually reach this host on? Loopback and the unspecified
/// address are both "we don't know yet" dressed up as an answer, and advertising either is the
/// boot race that made a freshly-restarted host publish itself as `127.0.0.1`.
fn usable_lan_ip(ip: IpAddr) -> bool {
!ip.is_loopback() && !ip.is_unspecified()
}
/// Where the paired-client allow-list persists (survives host restarts, like Sunshine).
@@ -716,6 +773,106 @@ pub(crate) fn save_paired(paired: &[Vec<u8>]) {
}
}
/// Where the operator's per-client display labels persist, keyed by certificate fingerprint.
///
/// A SIDECAR to [`paired_path`] rather than a field inside it, for two reasons. `paired.json` is a
/// bare `Vec<Vec<u8>>` of certificate DERs — giving it a shape would be a migration on the one file
/// that decides who may connect — and a label is not part of that trust decision, so a corrupt or
/// missing label file must never be able to lock anybody out. Losing this file loses names, nothing
/// else.
///
/// Why labels have to exist at all: every moonlight-common-c client self-signs with the SAME
/// subject (`CN=NVIDIA GameStream Client`), so the certificate carries no device identity
/// whatsoever. Without an operator-supplied name, a list of five paired devices is five identical
/// rows and the only way to tell them apart — or to know which one to unpair — is the fingerprint.
fn labels_path() -> Option<std::path::PathBuf> {
Some(pf_paths::config_dir().join("client-labels.json"))
}
/// Serializes the read-modify-write in [`set_client_label`]. Two concurrent renames would
/// otherwise race on a whole-file rewrite and silently drop one of the two names.
static LABELS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Load the fingerprint → label map (empty on first run, unreadable file, or parse failure — a
/// label is cosmetic, so every failure degrades to "no names" and never to an error).
pub(crate) fn load_client_labels() -> std::collections::BTreeMap<String, String> {
let Some(path) = labels_path() else {
return Default::default();
};
let Ok(raw) = std::fs::read(&path) else {
return Default::default();
};
serde_json::from_slice(&raw).unwrap_or_else(|e| {
tracing::warn!(error = %e, "client-labels.json unreadable — listing clients without names");
Default::default()
})
}
/// Set (`Some`) or clear (`None`) one client's label, persisted atomically. Returns the stored
/// label. Fingerprints are normalized to lowercase hex so a rename and a later lookup agree
/// regardless of how the caller cased the path parameter.
pub(crate) fn set_client_label(fp_hex: &str, label: Option<&str>) -> Option<String> {
let _guard = LABELS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let fp = fp_hex.to_ascii_lowercase();
let mut labels = load_client_labels();
let stored = match label {
Some(l) => {
let clean = crate::native_pairing::sanitize_device_name(l, &fp);
labels.insert(fp, clean.clone());
Some(clean)
}
None => {
labels.remove(&fp);
None
}
};
save_client_labels(&labels);
stored
}
/// Drop the labels of fingerprints that are no longer paired. Called from the unpair paths so the
/// file cannot grow without bound as devices come and go, and so a re-pairing of the same
/// certificate starts unnamed rather than inheriting a stranger's name.
pub(crate) fn retain_client_labels(still_paired: &[Vec<u8>]) {
use sha2::{Digest, Sha256};
let _guard = LABELS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let live: std::collections::BTreeSet<String> = still_paired
.iter()
.map(|der| hex::encode(Sha256::digest(der)))
.collect();
let mut labels = load_client_labels();
let before = labels.len();
labels.retain(|fp, _| live.contains(fp));
if labels.len() != before {
save_client_labels(&labels);
}
}
/// Persist the label map — same atomic temp-file + rename as [`save_paired`], so a crash mid-write
/// cannot truncate it.
fn save_client_labels(labels: &std::collections::BTreeMap<String, String>) {
let Some(path) = labels_path() else { return };
if let Some(dir) = path.parent() {
let _ = pf_paths::create_private_dir(dir);
}
let bytes = match serde_json::to_vec(labels) {
Ok(b) => b,
Err(e) => {
tracing::warn!(error = %e, "serializing client labels failed");
return;
}
};
let tmp = path.with_extension("json.tmp");
if let Err(e) = pf_paths::write_secret_file(&tmp, &bytes) {
tracing::warn!(error = %e, "persisting client labels failed (temp write)");
return;
}
if let Err(e) = std::fs::rename(&tmp, &path) {
tracing::warn!(error = %e, "persisting client labels failed (rename)");
let _ = std::fs::remove_file(&tmp);
}
}
#[cfg(test)]
mod host_name_tests {
use super::sanitize_display_name;
@@ -740,6 +897,52 @@ mod host_name_tests {
}
}
#[cfg(test)]
mod local_ip_tests {
use super::{first_lan_ipv4, primary_local_ip, usable_lan_ip};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
#[test]
fn loopback_and_unspecified_are_never_advertisable() {
// The bug: a host that started before its network did advertised these as its address and
// kept doing so for the life of the process.
for unusable in [
IpAddr::V4(Ipv4Addr::LOCALHOST),
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
IpAddr::V6(Ipv6Addr::LOCALHOST),
IpAddr::V6(Ipv6Addr::UNSPECIFIED),
] {
assert!(
!usable_lan_ip(unusable),
"{unusable} must not be advertised"
);
}
for usable in [
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 173)),
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)),
IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1)),
] {
assert!(usable_lan_ip(usable), "{usable} is reachable and must pass");
}
}
#[test]
fn probe_reports_no_address_rather_than_loopback() {
// Holds on a networked box and on an isolated CI runner alike: either we found a real LAN
// address, or we admit we have none. `None` is what lets `Host::local_ip()` and the mDNS
// advert keep retrying instead of freezing a wrong answer in place.
assert!(primary_local_ip().is_none_or(usable_lan_ip));
}
#[test]
fn interface_fallback_never_offers_loopback() {
// The branch a cold boot takes, before the default route exists. It may legitimately find
// nothing (a machine with no NIC up, e.g. an isolated CI container) — what it must never
// do is hand back the loopback that `get_if_addrs` also reports.
assert!(first_lan_ipv4().is_none_or(usable_lan_ip));
}
}
#[cfg(test)]
mod session_tests {
use super::*;
@@ -748,7 +951,6 @@ mod session_tests {
let host = Host {
hostname: "test-host".into(),
uniqueid: "deadbeef".into(),
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
http_port: HTTP_PORT,
https_port: HTTPS_PORT,
os_chain: "linux".into(),
@@ -250,7 +250,7 @@ async fn h_launch(
fps = session.fps,
rikeyid = session.rikeyid,
"launch — session created; RTSP at rtsp://{}:{RTSP_PORT}",
st.host.local_ip
st.host.local_ip()
);
xml(session_url_xml(&st, "gamesession")).into_response()
}
@@ -405,7 +405,7 @@ fn gamestream_admission(
fn session_url_xml(st: &AppState, tag: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n<sessionUrl0>rtsp://{}:{RTSP_PORT}</sessionUrl0>\n<{tag}>1</{tag}>\n</root>\n",
st.host.local_ip
st.host.local_ip()
)
}
@@ -485,13 +485,11 @@ fn error_xml() -> String {
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr};
fn test_state() -> Arc<AppState> {
let host = super::super::Host {
hostname: "t".into(),
uniqueid: "id".into(),
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
http_port: HTTP_PORT,
https_port: HTTPS_PORT,
os_chain: "linux".into(),
@@ -39,7 +39,7 @@ pub fn serverinfo_xml(host: &Host, https: bool, paired: bool) -> String {
uniqueid = host.uniqueid,
https_port = host.https_port,
http_port = host.http_port,
local_ip = host.local_ip,
local_ip = host.local_ip(),
)
}
@@ -205,7 +205,6 @@ mod tests {
let host = Host {
hostname: "test".into(),
uniqueid: "uid".into(),
local_ip: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
http_port: 47989,
https_port: 47984,
os_chain: "linux".into(),
+121 -3
View File
@@ -1111,6 +1111,21 @@ fn spawn_sender(
use crate::send_pacing::percentile;
/// How long to ignore further keyframe requests after emitting one.
///
/// The window bounds IDR emission in TIME, so it needs an absolute floor rather than a frame
/// count: it has to outlast the round trip in which the client receives and decodes the IDR it
/// already asked for. The original `frame_interval * 2` closes long before that at high refresh —
/// 16.7 ms at 120 fps, while a Moonlight client under loss re-asks every ~30 ms — so every request
/// passed the gate and the stream became ~32 full IDRs/s, whose bulk causes the very loss that
/// prompts the next request. That storm sustains itself and reads as stutter at a flat latency
/// (field log, AMD RX 7800 XT / Bazzite 44 HEVC, 2026-08-22: 1118 requests, 1115 honoured, 3
/// coalesced). 100 ms matches the encoder-reset backoff below and is about one IDR's service time
/// on a saturated link.
fn keyframe_coalesce_window(frame_interval: Duration) -> Duration {
(frame_interval * 2).max(Duration::from_millis(100))
}
/// The encode → packetize loop, over a borrowed capturer. Sending runs on a dedicated thread
/// (see [`spawn_sender`]) so a send spike can never stall capture/encode.
#[allow(clippy::too_many_arguments)]
@@ -1194,6 +1209,11 @@ fn stream_body(
// also fails safe when nobody tells it, but pass the REAL depth: `idd_depth` is configurable
// and a deeper ring is free pipelining the fallback would forfeit.
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
// What `enc` was opened against. The capture source can change size/format UNDER this loop with
// nothing negotiating it (see the follow-the-source guard below); tracked so the loop can notice.
// Both sites that swap `enc` re-bind `frame` with it, so this is always
// `(frame.format, frame.width, frame.height)` right after one.
let mut enc_src = (frame.format, frame.width, frame.height);
// FEC overhead percent (Sunshine default 20). Override with PUNKTFUNK_FEC_PCT (0 = data-only).
let fec_pct: u8 = std::env::var("PUNKTFUNK_FEC_PCT")
.ok()
@@ -1273,9 +1293,9 @@ fn stream_body(
// RFI (VAAPI/AMD — `supports_rfi=false`) each one becomes a full IDR, so an un-coalesced request
// stream turns EVERY frame into a 4K IDR, saturates the send path, and collapses the session
// instead of recovering. One fresh IDR already resolves all pending loss, so after emitting one
// we ignore further keyframe requests for a short in-flight window (~2 frames). NVENC
// ref-invalidation (cheap, no IDR spike) is never rate-limited — only full keyframes are.
let keyframe_coalesce = frame_interval * 2;
// we ignore further keyframe requests for the in-flight window below. NVENC ref-invalidation
// (cheap, no IDR spike) is never rate-limited — only full keyframes are.
let keyframe_coalesce = keyframe_coalesce_window(frame_interval);
let mut last_keyframe: Option<Instant> = None;
// A frame dropped at the pipeline head (below) breaks the reference chain for the following
// P-frames: the client never receives it, but the encoder advanced its references past it, and —
@@ -1362,6 +1382,7 @@ fn stream_body(
.context("reopen encoder after rebuild")?;
// A rebuilt encoder starts unconfigured — same reason as the first open above.
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
enc_src = (frame.format, frame.width, frame.height);
supports_rfi = enc.caps().supports_rfi;
enc.request_keyframe();
last_keyframe = Some(Instant::now());
@@ -1375,6 +1396,82 @@ fn stream_body(
}
}
let t_cap = tick.elapsed();
// Follow an AUTONOMOUS source mode change — one nothing negotiated. The IDD-push capturer
// re-opens its ring on a confirmed display-descriptor change (a fullscreen game mode-setting
// the virtual display, or an HDR flip changing the format), and the encoder is the one
// component that cannot follow a resolution change in place. Every `submit` below then
// refuses the frame ("captured WxH != encoder AxB"), and the submit ladder only rebuilds the
// encoder IN PLACE — at the SAME configured size — which cannot fix a size the source has
// already left, so all five resets burn on it and the stream ends (native/stream.rs carried
// the identical gap; a 2026-08-22 field report hit it there at 4K→1080p).
//
// GameStream has no mid-stream mode-change message, so the client is NOT told: Moonlight
// decodes a bitstream that disagrees with the resolution it configured its decoder from.
// That is the same bargain the first open above already takes whenever the captured size
// differs from the negotiated one (the monitor-mirror case) — tolerant decoders re-init off
// the SPS and scale, a strict one (Media Foundation on Xbox) may stall and drop the session.
// Taking it here too is strictly better than the alternative, which is ending every stream
// the moment a game changes mode.
if enc_src != (frame.format, frame.width, frame.height) {
match encode::open_video(
cfg.codec,
frame.format,
frame.width,
frame.height,
cfg.fps,
cfg.bitrate_kbps as u64 * 1000,
frame.is_cuda(),
// Derived from the delivered format, so an HDR flip re-opens at the right depth.
gs_bit_depth(frame.format),
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0 — see the first open
cursor_blend, // same capture cursor mode — see the first open
cfg.slices, // client slicing ceiling — see the first open
) {
Ok(e) => {
tracing::info!(
from = %format!("{}x{} {:?}", enc_src.1, enc_src.2, enc_src.0),
to = %format!("{}x{} {:?}", frame.width, frame.height, frame.format),
negotiated = ?(cfg.width, cfg.height),
"gamestream: the capture source changed mode mid-stream — reopened the \
encoder at the delivered size (the client is not told; a strict decoder \
may not follow see the note at this guard)"
);
enc = e;
enc_src = (frame.format, frame.width, frame.height);
// A rebuilt encoder starts unconfigured — same reasons as the first open.
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
supports_rfi = enc.caps().supports_rfi;
enc.request_keyframe();
last_keyframe = Some(Instant::now());
// The old encoder died with its in-flight submissions — their AUs will never
// arrive, so the numbering prediction restarts at `au_seq` (same reasoning as
// the capture rebuild above). Restart the stall clock for the fresh encoder and
// give it the full reset budget.
enc_inflight = 0;
encoder_resets = 0;
last_au_at = Instant::now();
}
Err(e) => {
// Don't spend the stream on the FIRST failed open: the mode-set that triggered
// this is exactly the kind of event that leaves the driver settling, which is
// what the submit ladder's backoff exists for. Spend the shared reset budget at
// the same exponential pace, re-entering this guard each round — the old encoder
// stays installed and mismatched meanwhile, so it simply keeps failing submit.
encoder_resets += 1;
if encoder_resets > MAX_ENCODER_RESETS {
return Err(e).context("reopen encoder at the source's new mode");
}
let backoff = frame_interval
.max(Duration::from_millis(100u64 << (encoder_resets - 1).min(4)));
tracing::warn!(error = %format!("{e:#}"), reset = encoder_resets,
max = MAX_ENCODER_RESETS,
"gamestream: reopening the encoder at the source's new mode failed — retrying");
next_frame = Instant::now() + backoff;
std::thread::sleep(backoff);
continue;
}
}
}
// Honor a client recovery request. Prefer reference-frame invalidation (the encoder
// re-references an older still-valid frame — no costly IDR spike); if the encoder can't
// invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe.
@@ -1716,6 +1813,27 @@ mod tests {
assert_eq!(t.game.title, "/opt/game/run");
}
/// The coalesce window must bound forced IDRs in time, not in frames. A frame-scaled window
/// vanishes exactly where it matters most — at high refresh, where a client's recovery spam
/// arrives far slower than two frame intervals and so passes the gate every time.
#[test]
fn keyframe_coalesce_window_outlasts_a_clients_request_cadence() {
// The observed storm: a 120 fps session against a client re-asking every ~30 ms. The
// pre-floor window was 16.7 ms, so every request became a full IDR.
let at_120 = keyframe_coalesce_window(Duration::from_secs_f64(1.0 / 120.0));
assert!(
at_120 >= Duration::from_millis(100),
"120 fps window {at_120:?} does not outlast a ~30 ms request cadence"
);
// 60 fps was under the floor too (33.3 ms), which is why this is not a 120-only fix.
assert!(keyframe_coalesce_window(Duration::from_secs_f64(1.0 / 60.0)) >= at_120);
// A slow stream keeps the frame-scaled window — the floor only ever raises it.
assert_eq!(
keyframe_coalesce_window(Duration::from_millis(200)),
Duration::from_millis(400)
);
}
/// End-to-end check of the send thread: batches pushed on the channel arrive, complete and
/// byte-identical, at a peer socket via the paced sendmmsg path.
#[test]
+8 -1
View File
@@ -55,7 +55,14 @@ pub struct DetectSpec {
/// Steam appid, for titles Steam itself installed (never for non-Steam shortcuts, whose reaper
/// appid semantics differ — those carry an [`exe`](Self::exe) instead). On Linux this is the
/// sharpest signal available: Steam wraps every launch — native or Proton — in
/// `reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's.
/// `reaper SteamLaunch AppId=<appid>`.
///
/// ⚠ That reaper is the *appid's*, not the game's. Steam wraps its **pre-launch** work for a
/// title in one too — shader pre-caching most visibly — so a launch is a chain of reaper trees
/// and only the last of them is the game. Reading the first as the game is what dropped a
/// stream 10 s into a Rocket League launch, mid-shader-compile (field report 2026-08-22); the
/// shader job is excluded by name in [`crate::procscan`], and [`crate::gamelease`] waits out a
/// window before believing any of them.
pub steam_appid: Option<u32>,
/// A launcher-stamped environment marker.
pub env_marker: Option<EnvMarker>,
+2 -1
View File
@@ -328,7 +328,8 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
clients::list_paired_clients,
clients::unpair_all_clients
))
.routes(routes!(clients::unpair_client));
// DELETE and PATCH share `/clients/{fingerprint}` — one `routes!`, same rule as above.
.routes(routes!(clients::unpair_client, clients::rename_client));
// The GameStream PIN flow exists only when the compat planes do (WP19) — a native-only
// build's API (and its OpenAPI document) simply has no such endpoints.
#[cfg(feature = "gamestream")]
+104 -4
View File
@@ -11,7 +11,17 @@ pub(crate) struct PairedClient {
#[schema(example = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")]
fingerprint: String,
/// Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.
///
/// Do not display this as a device name. Every moonlight-common-c client self-signs with that
/// same fixed subject, so it identifies the *protocol*, not the device — a list of paired
/// phones, TVs and handhelds all read identically. [`Self::label`] is the field to show.
subject: Option<String>,
/// Operator-assigned display name for this device, if one has been set (`PATCH /clients/{fp}`).
///
/// This is the ONLY thing that can tell two paired Moonlight devices apart in a list, because
/// their certificates cannot: see [`Self::subject`]. Absent until somebody names the device.
#[schema(example = "Living Room TV")]
label: Option<String>,
/// Certificate validity start (unix seconds).
not_before_unix: Option<i64>,
/// Certificate validity end (unix seconds).
@@ -55,27 +65,112 @@ pub(crate) async fn list_paired_clients(
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
Json(ders.iter().map(|der| client_info(der)).collect())
// One read of the label sidecar for the whole list, not one per row.
let labels = crate::gamestream::load_client_labels();
Json(ders.iter().map(|der| client_info(der, &labels)).collect())
}
pub(crate) fn client_info(der: &[u8]) -> PairedClient {
pub(crate) fn client_info(
der: &[u8],
labels: &std::collections::BTreeMap<String, String>,
) -> PairedClient {
let fingerprint = hex::encode(Sha256::digest(der));
let label = labels.get(&fingerprint).cloned();
match x509_parser::parse_x509_certificate(der) {
Ok((_, x509)) => PairedClient {
fingerprint,
subject: Some(x509.subject().to_string()),
not_before_unix: Some(x509.validity().not_before.timestamp()),
not_after_unix: Some(x509.validity().not_after.timestamp()),
label,
fingerprint,
},
Err(_) => PairedClient {
fingerprint,
subject: None,
not_before_unix: None,
not_after_unix: None,
label,
fingerprint,
},
}
}
/// Body of `PATCH /clients/{fingerprint}` — the device's display name.
#[derive(Deserialize, ToSchema)]
pub(crate) struct RenameClient {
/// The name to show for this device. `null` (or an empty/whitespace-only string) clears it and
/// the device goes back to being listed by fingerprint alone.
///
/// Scrubbed before storage by the same sanitizer the native plane runs on device names:
/// control characters and Unicode bidi overrides are stripped (they could make one paired
/// device impersonate another in this very list), whitespace collapsed, and the result capped
/// at 64 characters.
#[schema(example = "Living Room TV")]
label: Option<String>,
}
/// Rename a paired client
///
/// Sets or clears the operator-visible display name for one paired Moonlight client. This is
/// purely cosmetic — it touches no certificate and no trust decision — but it is the only way to
/// tell paired devices apart: every moonlight-common-c client self-signs with the identical
/// subject `CN=NVIDIA GameStream Client`, so an unnamed list is a row of clones distinguishable
/// only by fingerprint. The name is stored beside the pairing store and survives host restarts;
/// unpairing the device forgets it.
#[utoipa::path(
patch,
path = "/clients/{fingerprint}",
tag = "clients",
operation_id = "renameClient",
params(
("fingerprint" = String, Path,
description = "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)")
),
request_body = RenameClient,
responses(
(status = OK, description = "The client as it now reads", body = PairedClient),
(status = BAD_REQUEST, description = "Malformed fingerprint", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = NOT_FOUND, description = "No paired client with that fingerprint", body = ApiError),
)
)]
pub(crate) async fn rename_client(
State(st): State<Arc<MgmtState>>,
Path(fingerprint): Path<String>,
Json(body): Json<RenameClient>,
) -> Response {
if fingerprint.len() != 64 || !fingerprint.bytes().all(|b| b.is_ascii_hexdigit()) {
return api_error(
StatusCode::BAD_REQUEST,
"fingerprint must be the 64-char hex SHA-256 of the client certificate DER",
);
}
// Only name a device that is actually paired: a label for an unknown fingerprint would be
// invisible (nothing lists it) and would sit in the file forever, since the unpair cleanup
// only ever removes labels whose device WAS paired.
let paired = st.app.paired.lock().unwrap_or_else(|e| e.into_inner());
let Some(der) = paired
.iter()
.find(|der| hex::encode(Sha256::digest(der)).eq_ignore_ascii_case(&fingerprint))
.cloned()
else {
return api_error(
StatusCode::NOT_FOUND,
"no paired client with that fingerprint",
);
};
drop(paired);
// An all-whitespace name is a cleared name, not a device called " ": the sanitizer would
// otherwise turn it into the "device <fp8>" fallback and the row would look renamed.
let wanted = body
.label
.as_deref()
.map(str::trim)
.filter(|l| !l.is_empty());
crate::gamestream::set_client_label(&fingerprint, wanted);
let labels = crate::gamestream::load_client_labels();
(StatusCode::OK, Json(client_info(&der, &labels))).into_response()
}
/// Unpair a client
///
/// Removes the client's certificate from the pairing store (persisted — the removal survives a
@@ -119,6 +214,9 @@ pub(crate) async fn unpair_client(
// restart, which now also matters below: a resurrected pairing would silently
// re-open the control port.
crate::gamestream::save_paired(&paired);
// Forget this device's display name with it, so the file can't grow without bound and a
// later re-pairing of the same certificate starts unnamed.
crate::gamestream::retain_client_labels(&paired);
drop(paired);
// Revocation reaches a LIVE session too: a mid-stream client whose pairing was just
// removed must not keep streaming until it chooses to leave. Clearing the launch makes
@@ -187,6 +285,8 @@ pub(crate) async fn unpair_all_clients(State(st): State<Arc<MgmtState>>) -> Resp
// Persist under the lock, as the single unpair does: a pairing resurrected by a restart would
// silently re-open the control port.
crate::gamestream::save_paired(&paired);
// Nothing is paired any more, so no label can still belong to anyone.
crate::gamestream::retain_client_labels(&paired);
drop(paired);
// A mid-stream client must not keep streaming once its pairing is gone. Clearing the launch
// makes the ENet control thread send the standard TERMINATION+disconnect. (An owner-less
+6 -3
View File
@@ -23,13 +23,16 @@ pub(crate) struct Health {
abi_version: u32,
}
/// Host identity and advertised capabilities (static for the life of the process).
/// Host identity and advertised capabilities (static for the life of the process, except
/// `local_ip`).
#[derive(Serialize, ToSchema)]
pub(crate) struct HostInfo {
hostname: String,
/// Stable per-host id (persisted across restarts), matched on pairing.
uniqueid: String,
/// Best-effort primary LAN IP.
/// Best-effort primary LAN IP, read fresh on every request — a host that started before its
/// network did (cold boot) reports `127.0.0.1` only until it actually has an address, and a
/// host that moves networks reports the new one. Poll it rather than caching it.
local_ip: String,
/// `punktfunk-host` crate version.
version: String,
@@ -324,7 +327,7 @@ pub(crate) async fn get_host_info(State(st): State<Arc<MgmtState>>) -> Json<Host
Json(HostInfo {
hostname: h.hostname.clone(),
uniqueid: h.uniqueid.clone(),
local_ip: h.local_ip.to_string(),
local_ip: h.local_ip().to_string(),
version: env!("PUNKTFUNK_VERSION").into(),
abi_version: punktfunk_core::ABI_VERSION,
app_version: APP_VERSION.into(),
+186 -22
View File
@@ -47,7 +47,6 @@ use axum::body::Body;
use axum::http::StatusCode;
use http_body_util::BodyExt;
use sha2::{Digest, Sha256};
use std::net::{IpAddr, Ipv4Addr};
use std::sync::atomic::Ordering;
use tower::ServiceExt;
@@ -73,7 +72,6 @@ fn test_state() -> Arc<AppState> {
let host = Host {
hostname: "test-host".into(),
uniqueid: "deadbeef".into(),
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
http_port: HTTP_PORT,
https_port: HTTPS_PORT,
os_chain: "linux/arch/steamos".into(),
@@ -821,6 +819,54 @@ async fn status_reflects_runtime_state() {
assert!(!body.to_string().contains("gcm"));
}
/// Point `PUNKTFUNK_CONFIG_DIR` at a throwaway tempdir for the body of a test, and put the previous
/// value back on drop even if an assertion panics.
///
/// ONE of these for the whole file on purpose. Mutating the process environment is safe to call and
/// unsound from a live multithreaded process, so `check-unsafe-hygiene.sh` (gate C) holds this file
/// to a fixed count of such call sites — and counts plain prose mentions too, deliberately, since
/// its grep is the contract. A second test that copy-pastes the dance trips it, which is exactly
/// what it is for. This also bundles the serialization: the lock is a FIELD, so it cannot be
/// forgotten, and `Drop::drop` runs before any field drops, meaning the environment is restored
/// while this still holds the lock.
struct ConfigDirOverride {
tmp: tempfile::TempDir,
prev: Option<std::ffi::OsString>,
_serial: std::sync::MutexGuard<'static, ()>,
}
impl ConfigDirOverride {
fn new() -> ConfigDirOverride {
let _serial = crate::identity::CONFIG_DIR_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let prev = std::env::var_os("PUNKTFUNK_CONFIG_DIR");
// SAFETY: `_serial` holds CONFIG_DIR_TEST_LOCK, which serializes every test in this binary
// that reads or writes this variable.
unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", tmp.path()) };
ConfigDirOverride { tmp, prev, _serial }
}
/// The throwaway config dir itself — used verbatim by `pf_paths`, with no `punktfunk`
/// subdirectory appended.
fn path(&self) -> &std::path::Path {
self.tmp.path()
}
}
impl Drop for ConfigDirOverride {
fn drop(&mut self) {
match self.prev.take() {
// SAFETY: `self._serial` is still alive here (fields drop after `Drop::drop`), so this
// runs under the same serialization as the `set_var` in `new`.
Some(v) => unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", v) },
// SAFETY: as above.
None => unsafe { std::env::remove_var("PUNKTFUNK_CONFIG_DIR") },
}
}
}
// Holding `CONFIG_DIR_TEST_LOCK` across the awaits is the POINT: the env override must cover
// the whole test body, and `#[tokio::test]` is a single-threaded runtime — nothing else can
// need the executor while we hold it.
@@ -830,26 +876,7 @@ async fn paired_clients_list_and_unpair() {
// Unpair PERSISTS (save_paired → paired.json in the config dir), so point the config dir
// at a throwaway tempdir — this test must never rewrite the dev box's real pairing store.
// The guard restores the previous value even if an assertion below panics.
struct EnvGuard(Option<std::ffi::OsString>);
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.0.take() {
// SAFETY: dropped while this test still holds CONFIG_DIR_TEST_LOCK, which
// serializes every test that writes or reads this variable in the binary.
Some(v) => unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", v) },
// SAFETY: as above.
None => unsafe { std::env::remove_var("PUNKTFUNK_CONFIG_DIR") },
}
}
}
let _serial = crate::identity::CONFIG_DIR_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let _env = EnvGuard(std::env::var_os("PUNKTFUNK_CONFIG_DIR"));
// SAFETY: `_serial` holds CONFIG_DIR_TEST_LOCK (taken above), serializing every test that
// writes or reads this variable in the binary.
unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", tmp.path()) };
let tmp = ConfigDirOverride::new();
let state = test_state();
let app = test_app(state.clone(), None);
@@ -1003,6 +1030,137 @@ async fn paired_clients_list_and_unpair() {
assert_eq!(body["unpaired"], 0);
}
/// Renaming a paired Moonlight client: the round trip, the scrub, the clear, and the cleanup.
///
/// Worth a test because the label is the ONLY thing that distinguishes two paired Moonlight
/// devices — their certificates all carry the same subject — so "the name silently didn't stick"
/// is indistinguishable from "the device is the other one" in the console.
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn client_label_round_trips_scrubs_and_is_forgotten_on_unpair() {
let tmp = ConfigDirOverride::new();
let state = test_state();
let app = test_app(state.clone(), None);
let stand_in = crate::identity::ephemeral().unwrap();
let (_, pem) = x509_parser::pem::parse_x509_pem(stand_in.cert_pem.as_bytes()).unwrap();
let der = pem.contents.clone();
let fingerprint = hex::encode(Sha256::digest(&der));
{
let mut p = state.paired.lock().unwrap();
p.clear();
p.push(der.clone());
}
let patch = |fp: String, body: serde_json::Value| {
axum::http::Request::patch(format!("/api/v1/clients/{fp}"))
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap()
};
// Unnamed until somebody names it — the field is absent, not an empty string.
let (_, body) = send(&app, get_req("/api/v1/clients")).await;
assert!(body[0]["label"].is_null());
// Name it (uppercase fingerprint must match too — the path is documented case-insensitive).
let (status, body) = send(
&app,
patch(
fingerprint.to_uppercase(),
serde_json::json!({ "label": "Living Room TV" }),
),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["label"], "Living Room TV");
let (_, body) = send(&app, get_req("/api/v1/clients")).await;
assert_eq!(body[0]["label"], "Living Room TV");
// The scrub runs: a bidi override could make one paired device read like another in the very
// list an operator uses to decide what to unpair, and the whitespace collapse keeps the name
// one line. (`\u{202E}` = RIGHT-TO-LEFT OVERRIDE.)
let (_, body) = send(
&app,
patch(
fingerprint.clone(),
serde_json::json!({ "label": " Deck\u{202E}evil\n\nx " }),
),
)
.await;
assert_eq!(body["label"], "Deckevil x");
// Whitespace-only clears rather than storing a device called " " (or the sanitizer's
// "device <fp8>" fallback, which would look like a successful rename).
let (_, body) = send(
&app,
patch(fingerprint.clone(), serde_json::json!({ "label": " " })),
)
.await;
assert!(body["label"].is_null());
// …and an explicit null clears too.
send(
&app,
patch(
fingerprint.clone(),
serde_json::json!({ "label": "Bedroom" }),
),
)
.await;
let (_, body) = send(
&app,
patch(fingerprint.clone(), serde_json::json!({ "label": null })),
)
.await;
assert!(body["label"].is_null());
// Malformed fingerprint → 400; unknown-but-well-formed → 404 (naming a device that is not
// paired would write a label nothing can ever list or clean up).
assert_eq!(
send(
&app,
patch("zz".into(), serde_json::json!({ "label": "x" }))
)
.await
.0,
StatusCode::BAD_REQUEST
);
assert_eq!(
send(
&app,
patch("aa".repeat(32), serde_json::json!({ "label": "x" }))
)
.await
.0,
StatusCode::NOT_FOUND
);
// Unpairing forgets the name: it must not survive to be inherited by a later re-pairing of
// the same certificate.
send(
&app,
patch(
fingerprint.clone(),
serde_json::json!({ "label": "Living Room TV" }),
),
)
.await;
let del = axum::http::Request::delete(format!("/api/v1/clients/{fingerprint}"))
.body(Body::empty())
.unwrap();
assert_eq!(send(&app, del).await.0, StatusCode::NO_CONTENT);
let on_disk: std::collections::BTreeMap<String, String> =
std::fs::read(tmp.path().join("client-labels.json"))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
assert!(
!on_disk.contains_key(&fingerprint),
"unpair must forget the device's label, got {on_disk:?}"
);
}
#[cfg(feature = "gamestream")]
#[tokio::test]
async fn submit_pin_validates_and_requires_pending_pairing() {
@@ -1380,6 +1538,12 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
// roster's read permission must never carry over to emptying it.
("DELETE", "/api/v1/clients", false, false),
("DELETE", "/api/v1/clients/{fingerprint}", false, false),
// Renaming is cosmetic but NOT harmless, so it takes the same lanes as removal rather than
// the roster's read permission: the label is the only thing distinguishing one paired
// Moonlight device from another in the console, so anything that could set it could dress
// its own device up as the operator's TV — and be trusted, or spared an unpair, on that
// basis. Sharing a path with the plugin-forbidden DELETE, it needs its own row anyway.
("PATCH", "/api/v1/clients/{fingerprint}", false, false),
("GET", "/api/v1/native/clients", true, false),
("DELETE", "/api/v1/native/clients", false, false),
(
+104 -7
View File
@@ -156,9 +156,33 @@ pub struct Punktfunk1Options {
/// the client's reported address, no hole-punch"; `false` (random port, or a busy fixed port) means
/// "hole-punch". The socket is held from the handshake through streaming — no drop-then-rebind
/// window in which a concurrent session could steal a fixed port.
fn bind_data_socket(data_port: Option<u16>) -> std::io::Result<(std::net::UdpSocket, bool)> {
///
/// `local_ip` is the address the client's QUIC connection was RECEIVED on (`Connection::local_ip`),
/// and binding to it is load-bearing on a multi-homed host. The client's data socket is
/// `connect`ed to the host IP it dialed, so its kernel accepts video only from THAT source
/// address; a wildcard bind here lets the routing table pick the egress interface independently of
/// the one the control plane arrived on, and the two differ whenever a host has two paths to the
/// client — Ethernet and Wi-Fi both up on the same LAN is the everyday case. Every video datagram
/// is then dropped by the client's kernel before userspace: nothing counts it, `loss_ppm` stays 0
/// (no packets, no gaps), the hole-punch still arrives so the host logs `punched=true`, and the
/// control plane — which quinn pins to the right local address — stays perfectly healthy. That is
/// the "connects fine, black screen forever" shape with every gauge green, and it is invisible on
/// both ends. `None` (platform can't report it) or a bind failure falls back to the wildcard.
fn bind_data_socket(
data_port: Option<u16>,
local_ip: Option<std::net::IpAddr>,
) -> std::io::Result<(std::net::UdpSocket, bool)> {
// An IPv4-mapped v6 local address (dual-stack endpoint) must be unmapped before it can bind a
// socket that will `connect` to a v4 peer — the families have to match.
let local_ip = local_ip.map(|ip| match ip {
std::net::IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, std::net::IpAddr::V4),
v4 => v4,
});
let wildcard = |ip: Option<std::net::IpAddr>| {
ip.unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED))
};
if let Some(p) = data_port.filter(|p| *p != 0) {
match std::net::UdpSocket::bind(("0.0.0.0", p)) {
match std::net::UdpSocket::bind((wildcard(local_ip), p)) {
Ok(sock) => return Ok((sock, true)),
Err(e) => tracing::warn!(
data_port = p,
@@ -168,7 +192,23 @@ fn bind_data_socket(data_port: Option<u16>) -> std::io::Result<(std::net::UdpSoc
),
}
}
Ok((std::net::UdpSocket::bind("0.0.0.0:0")?, false))
match std::net::UdpSocket::bind((wildcard(local_ip), 0)) {
Ok(sock) => Ok((sock, false)),
// The control plane arrived on this address moments ago, so a failure here means it just
// went away (an adapter dropped mid-handshake). The wildcard still reaches a client the
// routing table can route to — degraded, not dead — so take it and say why.
Err(e) if local_ip.is_some() => {
tracing::warn!(
local_ip = ?local_ip,
error = %e,
"could not bind the data plane to the address the control connection arrived on \
falling back to the wildcard. On a multi-homed host video may now egress from \
a different interface than the client dialed, which it silently drops."
);
Ok((std::net::UdpSocket::bind("0.0.0.0:0")?, false))
}
Err(e) => Err(e),
}
}
/// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API.
@@ -365,7 +405,6 @@ pub(crate) async fn serve(
match crate::gamestream::Host::detect() {
Ok(h) => crate::discovery::advertise_native(
&h.hostname,
h.local_ip,
opts.port,
&fingerprint_hex(&fingerprint),
opts.require_pairing,
@@ -2057,6 +2096,10 @@ async fn serve_session(
// stages ride the same per-session trace; resizes write their totals into the shared slot.
let bringup_dp = bringup.clone();
let resize_ms_dp = resize_ms.clone();
// The address the control connection arrived on, for the data plane's source-address check
// below — the one comparison that distinguishes "the client is filtering our video" from
// "the video never left". Captured here because the send loop runs on a blocking thread.
let control_local_ip = conn.local_ip();
let result: Result<()> = async {
let stream_thread = tokio::task::spawn_blocking(move || -> Result<()> {
// Bring up the (already-bound) data-plane socket. Default: hole-punch — wait briefly
@@ -2091,15 +2134,44 @@ async fn serve_session(
}
};
bringup_dp.mark("punch_done");
// Post-`connect`, `local_addr` reports the source address the kernel will actually
// stamp on every video datagram — the number that has to match the host IP the client
// dialed, because its data socket is connected and its kernel drops anything else
// before userspace. Logged unconditionally: a black-screen report is unanswerable
// without it (this session's showed only the port).
let local = transport.local_addr().ok();
tracing::info!(
%client_udp,
udp_port,
direct,
punched,
local = ?local,
"data plane bound (direct=true → fixed --data-port, streaming to the reported \
address with no hole-punch; else punched=true the client's observed source, \
false no punch seen, the reported address)"
);
// A video source address that isn't the one the control plane arrived on means the
// client will discard every datagram we send, however healthy this end looks.
if let (Some(l), Some(c)) = (local.map(|a| a.ip()), control_local_ip) {
let c = match c {
std::net::IpAddr::V6(v6) => {
v6.to_ipv4_mapped().map_or(c, std::net::IpAddr::V4)
}
v4 => v4,
};
if !l.is_unspecified() && l != c {
tracing::warn!(
video_source_ip = %l,
control_local_ip = %c,
"the video data plane egresses from a DIFFERENT host address than the one \
this client connected to its data socket is connected to the address it \
dialed, so its kernel drops every video datagram before userspace: black \
screen, zero reported loss, healthy control plane. Usual cause is two \
live paths to the client (Ethernet and Wi-Fi both up on the same LAN, or \
a VPN/overlay adapter claiming the route)"
);
}
}
// A punch that never arrives is not a routine fallback — it is the fingerprint of a
// data port the client cannot reach INBOUND, and every client punches (5/s for the
// first three seconds, then every two). Video then goes to an address the client only
@@ -2515,7 +2587,7 @@ mod tests {
// No fixed port (and the explicit-0 alias) → a random ephemeral port, and NOT direct: the
// caller hole-punches.
for req in [None, Some(0)] {
let (sock, direct) = bind_data_socket(req).expect("bind random data socket");
let (sock, direct) = bind_data_socket(req, None).expect("bind random data socket");
assert!(!direct, "req={req:?} must hole-punch, not stream direct");
assert_ne!(sock.local_addr().unwrap().port(), 0);
}
@@ -2532,13 +2604,14 @@ mod tests {
.port();
// A free fixed port binds exactly it, in DIRECT mode (no hole-punch).
let (held, direct) = bind_data_socket(Some(free)).expect("bind fixed data socket");
let (held, direct) = bind_data_socket(Some(free), None).expect("bind fixed data socket");
assert!(direct, "a fixed --data-port must stream direct");
assert_eq!(held.local_addr().unwrap().port(), free);
// While it's held, a second session on the same fixed port can't bind it → it must fall
// back to a random port + hole-punch rather than fail (so concurrency never regresses).
let (fallback, direct2) = bind_data_socket(Some(free)).expect("busy fixed port falls back");
let (fallback, direct2) =
bind_data_socket(Some(free), None).expect("busy fixed port falls back");
assert!(!direct2, "a busy fixed port must fall back to hole-punch");
assert_ne!(
fallback.local_addr().unwrap().port(),
@@ -2547,6 +2620,30 @@ mod tests {
);
}
/// The multi-homed black screen: video must egress from the address the client's control
/// connection arrived on, because the client's data socket is connected to the host address it
/// dialed and its kernel drops every datagram from any other source — silently, before
/// userspace, so nothing on either end counts it. A wildcard bind here lets the routing table
/// choose a different interface whenever the host has two paths to the client.
#[test]
fn data_socket_binds_the_address_the_control_plane_arrived_on() {
let loopback = std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
let (sock, direct) =
bind_data_socket(None, Some(loopback)).expect("bind pinned data socket");
assert!(!direct);
assert_eq!(sock.local_addr().unwrap().ip(), loopback);
// An IPv4-mapped v6 local address (a dual-stack QUIC endpoint reports one) has to be
// unmapped, or the socket binds v6 and can never `connect` to the v4 client.
let mapped = std::net::IpAddr::V6(std::net::Ipv4Addr::LOCALHOST.to_ipv6_mapped());
let (sock, _) = bind_data_socket(None, Some(mapped)).expect("bind mapped data socket");
assert_eq!(sock.local_addr().unwrap().ip(), loopback);
// No reported local address (platform can't say) keeps the old wildcard behaviour.
let (sock, _) = bind_data_socket(None, None).expect("bind wildcard data socket");
assert!(sock.local_addr().unwrap().ip().is_unspecified());
}
/// Freeze the gamepad wire contract: every button bit + axis id pinned to its exact value in
/// `punktfunk_core::input::gamepad` — the single source both the punktfunk/1 native wire and the
/// GameStream/Limelight wire read from (they are one and the same). Renumbering a bit in core
@@ -780,7 +780,9 @@ pub(super) async fn negotiate(
// bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed
// `--data-port` yields `direct = true` (stream straight to the client's reported address,
// no punch-wait); otherwise a random ephemeral port + hole-punch.
let (data_sock, direct) = bind_data_socket(data_port)?;
// Bound to the address THIS connection arrived on, not the wildcard: the client only accepts
// video from the host IP it dialed (see `bind_data_socket`).
let (data_sock, direct) = bind_data_socket(data_port, conn.local_ip())?;
let udp_port = data_sock.local_addr()?.port();
// The session's video geometry (see the `shard_payload` field below). Resolved before the
+9 -1
View File
@@ -962,7 +962,15 @@ pub(super) fn input_thread(
} else {
pads.feedback_poll_interval()
};
match rx.recv_timeout(poll) {
let arrived = rx.recv_timeout(poll);
// Every plane's input funnels through here, so this is where the box learns someone is
// driving it: any arrival drops a standing suspend veto, so the next press being "Sleep"
// in Steam's power menu reaches logind instead of being refused (see `sleep_inhibit`).
// Stamped before the grant tests below — a denied event still means a person is there.
if arrived.is_ok() {
crate::sleep_inhibit::note_input();
}
match arrived {
// Rich input (touchpad / motion) is applied the moment it arrives; the single channel
// wakes for gyro samples instead of making them wait out the feedback poll interval.
// Guarded on the pad grant like every gamepad arm below — see the `grants` parameter.
+124 -5
View File
@@ -1875,6 +1875,14 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
mut cur_display_gen,
built_bitrate,
) = pipe;
// What `enc` was opened against. The capture source can change format/size UNDER this loop with
// no client `Reconfigure` at all — the IDD-push capturer re-opens its ring on a confirmed
// display-descriptor change (a fullscreen game mode-setting the virtual display, an HDR flip) —
// and every backend's `submit` then refuses the frame. Tracked so the loop can FOLLOW the
// source (see the guard in the submit path) instead of dying against an error no in-place
// encoder reset can fix. Every site below that swaps `enc` re-binds `frame` with it, so this is
// always `(frame.format, frame.width, frame.height)` immediately after one.
let mut enc_src = (frame.format, frame.width, frame.height);
// The display exists now, so the portal has answered: settle the cursor plan against what it
// actually negotiated rather than what this session asked for (see `settle_portal_cursor`).
// `mut`: every capture-loss rebuild re-runs `create`, hence re-negotiates.
@@ -2613,6 +2621,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
);
cur_mode = new_mode;
next = std::time::Instant::now();
enc_src = (frame.format, frame.width, frame.height);
// H2/H3: the backend may have honored a different mode than requested — KWin caps
// a virtual output's refresh, or Windows pf-vdisplay rejects a resolution its
// running monitor doesn't advertise and the host falls back to the actual display
@@ -2695,6 +2704,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
trace.as_ref(),
true,
) {
enc_src = (frame.format, frame.width, frame.height);
// The owed AUs died with the old encoder — same bookkeeping as a resize.
inflight.clear();
last_au_at = std::time::Instant::now();
@@ -3034,11 +3044,19 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
counted here, so the picture is black and every keyframe we force is \
wasted. The control plane is healthy (this report arrived on it), so \
the session looks alive: audio, input and the library keep working. \
This is a PATH problem, not decode check that inbound UDP to this \
host's per-session data port is allowed (the 'data plane bound' line \
above shows `punched=false` when the client's hole-punch never \
arrived, which is the fingerprint), and that no other host or \
firewall is intercepting it"
READ THE 'data plane bound' LINE ABOVE it says which leg failed, \
and this line cannot. `punched=false`: the client's hole-punch never \
arrived, so inbound UDP to this host's per-session data port is \
blocked open it (the ports are ephemeral, so the rule must be \
program-scoped, not port-scoped). `punched=true`: inbound is FINE and \
the failure is on the return leg compare that line's `local=` \
source address against the host address this client dialed, because \
its data socket is connected and its kernel silently drops video from \
any other source. If those match, the datagrams left this host \
correctly and the client either never received them (a hop on the \
path) or received them and could not open them: this counter is \
incremented AFTER decrypt and replay checks, so a session whose every \
datagram failed to open reports exactly this same zero"
);
} else if matches_client_recovery_cooldown(period) {
if client_rx == u32::MAX {
@@ -3380,6 +3398,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
interval = new_interval;
cur_node_id = new_node_id;
cur_display_gen = new_display_gen;
enc_src = (frame.format, frame.width, frame.height);
// The rebuild re-ran `create`, so the portal answered again — possibly a different
// backend's portal (the retarget above), possibly with a different verdict. Settle
// the cursor plan against THIS display, exactly as bring-up did: the retarget arm
@@ -3642,6 +3661,106 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// exactly that volume, so host apps already tone-mapped the content into it and the honest
// mastering description IS the client's panel. (The IDD capturer only knows the generic
// baseline; if the driver ever forwards per-content IDDCX_HDR10_METADATA, prefer that here.)
// Follow an AUTONOMOUS source change — one no client `Reconfigure` announced. The IDD-push
// capturer re-opens its ring on a confirmed display-descriptor change: a fullscreen game
// mode-setting the virtual display (2026-08-22 field report: a 4K60 HEVC session, the game
// switched the display to 1080p mid-play), or an HDR flip changing the frame format. The
// encoder is the one component that cannot follow that in place (same note as
// `try_inplace_resize`), so every `submit` below refuses the frame — and the submit-error
// path only rebuilds the encoder IN PLACE, at the SAME configured size, which cannot fix a
// size the source has already left. All five resets burn on it and the session ends while
// audio keeps running. Reopen at what the source actually delivers instead; the client
// learns the new mode from the `Reconfigured` below and its decoder from the opening IDR.
if enc_src != (frame.format, frame.width, frame.height) {
let actual = delivered_mode(frame.width, frame.height, interval);
// Same per-mode pin the client-initiated resize re-resolves: PyroWave's Automatic rate
// IS a function of the mode, so carrying the old one across a source-driven mode change
// hands it the wrong operating point. H.26x rates are mode-independent (ABR owns them),
// and an explicit client rate is never second-guessed.
let src_kbps = if bitrate_auto && plan.codec == crate::encode::Codec::PyroWave {
resolve_bitrate_kbps_for(plan.codec, 0, &actual, plan.chroma, plan.bit_depth)
} else {
bitrate_kbps
};
let opened = crate::encode::open_video(
plan.codec,
frame.format,
frame.width,
frame.height,
actual.refresh_hz,
src_kbps as u64 * 1000,
frame.is_cuda(),
bit_depth,
plan.chroma,
plan.cursor_blend,
plan.max_slices,
)
.with_context(|| {
format!(
"the capture source changed to {}x{} {:?} mid-session and the encoder could not \
be reopened at it",
frame.width, frame.height, frame.format
)
});
let mut new_enc = match opened {
Ok(e) => e,
Err(e) => {
// Don't spend the session on the FIRST failed open. The mode-set that triggered
// this is exactly the kind of event that leaves the driver settling — the same
// transient the submit path's backoff exists for ("NVENC session open failing
// after a codec switch", 2026-07) — so spend the shared reset budget on it at
// the same exponential pace, re-entering this guard each round. The old encoder
// is still installed and still mismatched; it simply keeps failing submit until
// an open succeeds or the budget runs out.
encoder_resets += 1;
if encoder_resets > MAX_ENCODER_RESETS {
return Err(e).context("encoder reopen at the source's new mode");
}
let backoff = std::cmp::max(
interval,
std::time::Duration::from_millis(100u64 << (encoder_resets - 1).min(4)),
);
tracing::warn!(error = %format!("{e:#}"), reset = encoder_resets,
max = MAX_ENCODER_RESETS,
"reopening the encoder at the source's new mode failed — retrying");
next = std::time::Instant::now() + backoff;
std::thread::sleep(backoff);
continue;
}
};
if let Some(c) = plan.wire_chunk {
new_enc.set_wire_chunking(c);
}
// A rebuilt encoder starts with the ring bound unset — re-report it, as every other
// rebuild site does, or an in-place backend can encode a texture the capturer has
// already rotated and overwritten.
new_enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
tracing::info!(
from = %format!("{}x{} {:?}", enc_src.1, enc_src.2, enc_src.0),
to = %format!("{}x{} {:?}", frame.width, frame.height, frame.format),
"the capture source changed mode mid-session with no client reconfigure — reopened \
the encoder at the delivered size"
);
enc = new_enc;
enc_src = (frame.format, frame.width, frame.height);
adopt_built_bitrate(&mut bitrate_kbps, src_kbps, &live_bitrate, &retarget_tx);
// The owed AUs died with the old encoder — same bookkeeping as a resize.
inflight.clear();
last_au_at = std::time::Instant::now();
encoder_resets = 0;
// A fresh encoder opens on an IDR — anchor the cooldown.
last_forced_idr = Some(std::time::Instant::now());
// The client's mode slot still says the old size, and its stats/aspect follow it.
// Publish what it is really decoding now, exactly as an accepted resize does.
live_mode.store(
pack_mode(actual.width, actual.height, actual.refresh_hz),
Ordering::Relaxed,
);
let _ = reconfig_result_tx.send(Reconfigured {
accepted: true,
mode: actual,
});
}
let hdr_meta = capturer.hdr_meta().map(|m| client_hdr.unwrap_or(m));
enc.set_hdr_meta(hdr_meta);
let mut resend_meta = hdr_meta != last_hdr_meta;
+20
View File
@@ -87,6 +87,26 @@ pub fn resolve(pid: u32) -> Option<ProcRef> {
}
}
/// Short names for the processes a lease adopted, in `procs` order.
///
/// Diagnostics only — nothing decides anything on these, and they are deliberately not part of
/// [`ProcRef`], which is compared for equality. They exist because a launch that adopted the game
/// and a launch that adopted a *pre-launch* tree logged identically (`procs=1`), which is what left
/// the 2026-08-22 field report unclosable from its log: the one question worth asking of that line
/// is which process the lease latched onto.
pub fn names(procs: &[ProcRef]) -> Vec<String> {
#[cfg(any(target_os = "linux", windows))]
{
let scanner = Scanner::system();
procs.iter().map(|p| scanner.name_of(*p)).collect()
}
#[cfg(not(any(target_os = "linux", windows)))]
{
let _ = procs;
Vec::new()
}
}
/// An out-of-band opinion on whether a spec's game is still running, independent of the process scan.
///
/// Consulted **only to veto** declaring a game gone — never to declare it running, and never as the
+67 -1
View File
@@ -126,6 +126,14 @@ impl Scanner {
Some(ProcRef { pid, start })
}
/// This process's `comm` — its short name, as `ps` shows it. Diagnostics only (see
/// [`super::names`]); `?` for a process that has already gone, which is routine.
pub fn name_of(&self, p: ProcRef) -> String {
std::fs::read_to_string(self.root.join(p.pid.to_string()).join("comm"))
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "?".into())
}
/// Which of `procs` are still the same live processes — pid present **and** start time unchanged,
/// so a recycled pid is never reported alive (rule 2).
pub fn alive(&self, procs: &[ProcRef]) -> Vec<ProcRef> {
@@ -180,16 +188,29 @@ impl Scanner {
if let Some(tok) = steam_tok {
// Both tokens together, exact-matched, so `AppId=57` never satisfies appid 570 and
// Steam's own (non-reaper) helper steps aren't mistaken for the game.
//
// …with one exception, because the reaper is *not* only the game's: Steam wraps its
// shader pre-caching for a title in the same `SteamLaunch AppId=<appid>` reaper it
// wraps the game in, so that job satisfies this recipe exactly while the game has
// not started yet. Adopting it points the lease at a tree that exits when the
// compile finishes, which reads as the game exiting — on Linux that dropped a
// Rocket League stream 10 s into a launch, mid-"Processing Vulkan shaders", and the
// player had to launch a second time to get a session that stayed up (field report
// 2026-08-22). The payload names itself: `fossilize_replay` is Steam's replayer and
// is never a game.
let mut launch = false;
let mut appid = false;
let mut shader = false;
for arg in cmdline.split(|&b| b == 0) {
if arg == b"SteamLaunch" {
launch = true;
} else if arg == tok.as_bytes() {
appid = true;
} else if program_name(arg) == b"fossilize_replay" {
shader = true;
}
}
if launch && appid {
if launch && appid && !shader {
return true;
}
}
@@ -247,6 +268,15 @@ impl Scanner {
}
}
/// The last `/`-separated component of an argv entry — the program's own name, when the entry is a
/// path to one. Bytes rather than `str` because an argv entry is not required to be UTF-8.
fn program_name(arg: &[u8]) -> &[u8] {
match arg.iter().rposition(|&b| b == b'/') {
Some(i) => &arg[i + 1..],
None => arg,
}
}
/// Read a `/proc` blob with a hard size cap (see [`MAX_PROC_BLOB`]). `None` when the process vanished
/// or the file is unreadable — both routine during a scan.
fn read_capped(path: &Path) -> Option<Vec<u8>> {
@@ -472,6 +502,42 @@ mod tests {
assert_eq!(pids(s.find(&DetectSpec::steam(57), None)), vec![31]);
}
/// The 2026-08-22 field report: Steam's **shader pre-caching** runs under the game's own
/// `SteamLaunch AppId=` reaper, so it satisfies the appid recipe while the game has not started.
///
/// Adopting it is what dropped a Rocket League stream 10 s into a launch — the lease called that
/// tree the game, and its exit (the compile finishing) the game exiting. The reaper's payload is
/// the whole tell, and it is only ever Steam's replayer.
#[test]
fn steam_shader_pre_caching_is_not_the_game() {
let td = fake_proc_root(
1000.0,
&[
// The shader job for this very appid — the game is still being brought up.
FakeProc::new(35, 50_000).cmdline(&[
"/home/p/.steam/ubuntu12_32/reaper",
"SteamLaunch",
"AppId=252950",
"--",
"/home/p/.steam/steamapps/common/SteamLinuxRuntime/fossilize_replay",
"/home/p/.steam/steamapps/shadercache/252950/fozpipelinesv6/steamapprun_pipeline_cache.foz",
]),
// The game itself, same appid, same reaper. This one IS the game.
FakeProc::new(36, 50_000).cmdline(&[
"/home/p/.steam/ubuntu12_32/reaper",
"SteamLaunch",
"AppId=252950",
"--",
"/home/p/.steam/steamapps/common/Proton/proton",
"waitforexitandrun",
"/home/p/.steam/steamapps/common/rocketleague/RocketLeague.exe",
]),
],
);
let s = scanner(td.path());
assert_eq!(pids(s.find(&DetectSpec::steam(252_950), None)), vec![36]);
}
#[test]
fn matches_env_marker_by_exact_value_or_presence() {
let td = fake_proc_root(
@@ -120,6 +120,14 @@ impl Scanner {
Some(ProcRef { pid, start })
}
/// This process's image file name. Diagnostics only (see [`super::names`]); `?` for a process
/// that has already gone or cannot be opened, which is routine.
pub fn name_of(&self, p: ProcRef) -> String {
process_start_and_image(p.pid)
.and_then(|(_, image)| image.file_name().map(|n| n.to_string_lossy().into_owned()))
.unwrap_or_else(|| "?".into())
}
/// Which of `procs` are still the same live processes — pid present **and** creation time
/// unchanged, so a recycled pid is never reported alive (rule 2). Windows reuses pids briskly, so
/// this check is what makes signalling a remembered pid safe at all.
+195 -14
View File
@@ -1,19 +1,56 @@
//! Session-scoped suspend/idle inhibition: while at least one client is streaming, the host
//! holds a logind `sleep:idle` BLOCK inhibitor so the box doesn't auto-suspend out from under a
//! passive viewer. Remote INPUT resets the compositor's idle timers, but a video-only viewer
//! sends none — observed live on a SteamOS Game-Mode host, which s2idled mid-stream-day and
//! dropped off the network (and, in a VM with GPU passthrough, never woke again). Refcounted
//! across planes (native sessions + GameStream media): the first hold acquires, the last drop
//! releases. Best-effort — no logind (containers, non-systemd boxes) logs once and streams on.
//! Off Linux this is a no-op: macOS/Windows hosts manage their own power assertions.
//! Session-scoped suspend/idle inhibition: while at least one client is streaming **and is not
//! sending input**, the host holds a logind `sleep:idle` BLOCK inhibitor so the box doesn't
//! auto-suspend out from under a passive viewer. Remote INPUT resets the compositor's idle timers,
//! but a video-only viewer sends none — observed live on a SteamOS Game-Mode host, which s2idled
//! mid-stream-day and dropped off the network (and, in a VM with GPU passthrough, never woke
//! again). Refcounted across planes (native sessions + GameStream media): the first hold acquires,
//! the last drop releases. Best-effort — no logind (containers, non-systemd boxes) logs once and
//! streams on. Off Linux this is a no-op: macOS/Windows hosts manage their own power assertions.
//!
//! **The quiet gate is the point, and it is not an optimisation.** A `block` lock on `sleep`
//! refuses EVERY suspend, not just the idle timer's: "Sleep" in Steam's Big Picture power menu
//! reaches logind as exactly the same `Suspend()` call, and logind answers the person who pressed
//! it with `Operation inhibited by "Punktfunk" (…), reason is "a client is streaming"` — silently,
//! because nothing in that UI surfaces a D-Bus error. Held unconditionally for the length of a
//! stream (as it was from 2026-07-22 to this commit), the lock made a host impossible to put to
//! sleep from the machine's own screen for as long as anyone was watching it. Reproduced verbatim
//! on a Bazzite box, 2026-08-24.
//!
//! So the veto is held only while the stream is QUIET. Any client input ([`note_input`]) drops it
//! **synchronously** — releasing is a `close(2)` on the inhibitor fd, no round trip, so a Sleep
//! press cannot race it — and it is re-taken only after [`QUIET_BEFORE_VETO`] of silence. That is
//! the same line the original justification already drew ("a video-only viewer sends none"): a
//! person choosing Sleep is, by definition, sending input, and a passive viewer never does.
//!
//! What this deliberately does NOT cover is a local suspend request typed at a box that a passive
//! viewer is streaming from — the veto is still standing, so it is still refused. That case wants
//! a person-vs-timer signal we do not have, and the remote viewer's claim on the box is at least
//! arguable. `ponytail:` if it turns up in the field, the lever is a config knob, not a heuristic.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
/// How long a stream must go without client input before the host vetoes suspend on its behalf.
/// Comfortably under every idle-suspend timer worth catching (Steam's shortest offer is 5 min,
/// KDE's default 10), and long enough that a menu press followed by a slow "are you sure?" cannot
/// re-arm the veto mid-decision.
const QUIET_BEFORE_VETO: Duration = Duration::from_secs(30);
/// How often [`watch`] re-checks the quiet time. Only the RE-ARM edge waits for a tick — the
/// release edge is synchronous in [`note_input`] — so this bounds nothing a user can feel.
#[cfg(target_os = "linux")]
const WATCH_TICK: Duration = Duration::from_secs(5);
/// RAII share of the host-wide inhibitor — hold one per live session/stream.
pub struct StreamHold(());
struct State {
count: u32,
/// Whether [`watch`] is running. Its exit is the 1→0 edge, so without this flag a session that
/// ends and restarts inside one tick would leave two watchers racing for the same fd slot.
#[cfg(target_os = "linux")]
watching: bool,
/// The logind inhibitor pipe fd — inhibition lasts exactly as long as it stays open.
#[cfg(target_os = "linux")]
fd: Option<ashpd::zbus::zvariant::OwnedFd>,
@@ -25,18 +62,65 @@ fn state() -> &'static Mutex<State> {
Mutex::new(State {
count: 0,
#[cfg(target_os = "linux")]
watching: false,
#[cfg(target_os = "linux")]
fd: None,
})
})
}
/// Take a share; the underlying inhibitor is acquired on the 0→1 edge.
/// Monotonic ms since first use — a plain `AtomicU64` clock the input path can stamp with one
/// relaxed store, which `Instant` itself is too fat to be.
fn now_ms() -> u64 {
static EPOCH: OnceLock<Instant> = OnceLock::new();
EPOCH.get_or_init(Instant::now).elapsed().as_millis() as u64
}
static LAST_INPUT_MS: AtomicU64 = AtomicU64::new(0);
/// Whether a veto is standing right now. Read once per input event, so it is what keeps
/// [`note_input`] off the mutex on the hot path.
static VETOING: AtomicBool = AtomicBool::new(false);
/// Whether the stream has been quiet long enough to veto suspend on the viewer's behalf.
fn quiet_for(last_input_ms: u64, now_ms: u64) -> bool {
now_ms.saturating_sub(last_input_ms) >= QUIET_BEFORE_VETO.as_millis() as u64
}
/// Client input arrived on any plane — the person at the other end is driving this box, so no
/// suspend veto may be standing when their next button press is "Sleep".
///
/// Called per decoded input event (keyboard, pointer, pad, pen, motion): one relaxed store, plus a
/// relaxed load that only ever takes the lock on the rare edge where a veto is actually standing.
pub fn note_input() {
LAST_INPUT_MS.store(now_ms(), Ordering::Relaxed);
if VETOING.load(Ordering::Relaxed) {
release("the client is sending input again — a deliberate suspend now reaches logind");
}
}
/// Take a share. The underlying inhibitor is NOT acquired here: the `watch` thread takes it once
/// the stream has been quiet for [`QUIET_BEFORE_VETO`], and never while someone is driving the box.
pub fn hold() -> StreamHold {
// A fresh stream gets the full quiet window before anything is vetoed, so an ordinary connect
// costs zero D-Bus round trips.
LAST_INPUT_MS.store(now_ms(), Ordering::Relaxed);
let mut st = state().lock().unwrap_or_else(|e| e.into_inner());
st.count += 1;
#[cfg(target_os = "linux")]
if st.count == 1 && st.fd.is_none() {
st.fd = acquire();
if !st.watching {
st.watching = true;
drop(st);
if let Err(e) = std::thread::Builder::new()
.name("punktfunk-sleep-veto".into())
.spawn(watch)
{
state().lock().unwrap_or_else(|e| e.into_inner()).watching = false;
tracing::warn!(
error = %e,
"could not start the sleep-veto watcher — the box may auto-suspend under a \
passive (video-only) viewer"
);
}
}
StreamHold(())
}
@@ -46,12 +130,72 @@ impl Drop for StreamHold {
let mut st = state().lock().unwrap_or_else(|e| e.into_inner());
st.count = st.count.saturating_sub(1);
#[cfg(target_os = "linux")]
if st.count == 0 && st.fd.take().is_some() {
tracing::info!("released the sleep/idle inhibitor (no live sessions)");
if st.count == 0 {
release_locked(&mut st, "no live sessions");
}
}
}
/// Drop any standing veto. Closing the fd is all it takes — no D-Bus, so this is safe to call from
/// the input path.
#[cfg(target_os = "linux")]
fn release(why: &str) {
let mut st = state().lock().unwrap_or_else(|e| e.into_inner());
release_locked(&mut st, why);
}
#[cfg(not(target_os = "linux"))]
fn release(_why: &str) {}
#[cfg(target_os = "linux")]
fn release_locked(st: &mut State, why: &str) {
if st.fd.take().is_some() {
VETOING.store(false, Ordering::Relaxed);
tracing::info!(why, "released the sleep/idle inhibitor");
}
}
/// Own the veto's arm/disarm edges for as long as any session lives.
///
/// Acquiring is the only expensive edge (a thread spawn + a D-Bus round trip), so it happens here
/// rather than on the input path, and outside the lock — a `note_input` on a hot input stream must
/// never queue behind a logind call.
#[cfg(target_os = "linux")]
fn watch() {
loop {
std::thread::sleep(WATCH_TICK);
{
let mut st = state().lock().unwrap_or_else(|e| e.into_inner());
if st.count == 0 {
st.watching = false; // the last `Drop` already released the fd
return;
}
if st.fd.is_some() {
// Belt for the nanosecond window in which input lands after the re-check below but
// before `VETOING` is published: that press releases nothing, so catch it here
// rather than leave a veto standing over a live viewer.
if !quiet_for(LAST_INPUT_MS.load(Ordering::Relaxed), now_ms()) {
release_locked(&mut st, "the client is sending input again");
}
continue;
}
if !quiet_for(LAST_INPUT_MS.load(Ordering::Relaxed), now_ms()) {
continue;
}
}
let Some(fd) = acquire() else {
continue; // no logind / refused — `acquire` said so once, don't spin on it
};
let mut st = state().lock().unwrap_or_else(|e| e.into_inner());
if st.count == 0 || !quiet_for(LAST_INPUT_MS.load(Ordering::Relaxed), now_ms()) {
drop(fd); // raced: the stream ended, or the viewer came back — never veto for those
continue;
}
st.fd = Some(fd);
VETOING.store(true, Ordering::Relaxed);
}
}
/// One logind `Inhibit` call on a dedicated plain thread — zbus's blocking API must not run on
/// a tokio worker (its internal `block_on` panics there), and callers of [`hold`] may be either.
/// The join blocks the caller for the D-Bus round-trip (~ms), which every call site tolerates.
@@ -94,7 +238,44 @@ fn acquire() -> Option<ashpd::zbus::zvariant::OwnedFd> {
.ok()
.flatten();
if fd.is_some() {
tracing::info!("holding a logind sleep/idle inhibitor while clients stream");
tracing::info!(
quiet_s = QUIET_BEFORE_VETO.as_secs(),
"holding a logind sleep/idle inhibitor — this stream has gone quiet"
);
}
fd
}
#[cfg(test)]
mod tests {
use super::*;
/// The whole fix in one assertion: a stream that is being driven must never be vetoing, or the
/// "Sleep" the viewer just picked in Steam's power menu is refused with no visible reason.
#[test]
fn a_driven_stream_is_never_vetoed_but_a_quiet_one_is() {
let quiet_ms = QUIET_BEFORE_VETO.as_millis() as u64;
assert!(!quiet_for(1_000, 1_000), "input this instant is not quiet");
assert!(
!quiet_for(1_000, 1_000 + quiet_ms - 1),
"one ms short of the window still counts as driven"
);
assert!(
quiet_for(1_000, 1_000 + quiet_ms),
"the window elapsed — a passive viewer gets the veto"
);
// The clock starts at zero, so an un-stamped stream would look infinitely quiet: `hold`
// seeds it precisely so a connect never vetoes before anyone could have pressed anything.
assert!(quiet_for(0, quiet_ms), "an unseeded clock reads as quiet");
}
#[test]
fn note_input_stamps_the_clock() {
note_input();
let stamped = LAST_INPUT_MS.load(Ordering::Relaxed);
assert!(
!quiet_for(stamped, now_ms()),
"input just arrived — the veto must not be armable"
);
}
}
+23 -15
View File
@@ -770,8 +770,24 @@ fn web_setup(args: &[String]) -> Result<()> {
// (security-review 2026-08-05 H-3). Same host, same certificate, different port — which is
// what makes it a different origin to the browser while staying same-site for the session
// cookie. Without this rule, plugin interfaces simply do not load from another device.
// Both rules are scoped to the bundled bun binary that actually listens on them, not left
// open to any program: a port-only `dir=in action=allow` rule admits whatever binds the port
// first, needs no elevation to do so, and suppresses the Windows prompt that would otherwise
// be the only way in (see `service::fw_add_rule_args`). The console child is
// `<app>/bun/bun.exe` — the same path `service.rs`'s supervisor spawns — so the rule follows
// it. If that binary isn't there, fall back to the port-only rule rather than leaving the
// console unreachable, and say which happened.
let fw_profile =
crate::service::firewall_profile_arg(crate::service::allow_public_network(args)?);
let bun = app_dir.join("bun").join("bun.exe");
let program = bun.exists().then_some(bun.as_path());
if program.is_none() {
eprintln!(
"warning: {} not found — the console firewall rules stay open to any program on those \
ports instead of only the console",
bun.display()
);
}
for (name, port) in [
("Punktfunk web console (TCP 47992)", "47992"),
("Punktfunk plugin UIs (TCP 47993)", "47993"),
@@ -786,21 +802,13 @@ fn web_setup(args: &[String]) -> Result<()> {
&format!("name={name}"),
],
);
if !run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"add",
"rule",
&format!("name={name}"),
"dir=in",
"action=allow",
"protocol=TCP",
&format!("localport={port}"),
fw_profile,
],
) {
if !crate::service::run_netsh(&crate::service::fw_add_rule_args(
name,
"TCP",
Some(port),
program,
fw_profile,
)) {
eprintln!("warning: could not add the firewall rule for TCP {port}");
}
}
+171 -50
View File
@@ -1091,7 +1091,10 @@ fn spawn_web(cfg: &WebConfig, data: &Path, job: HANDLE) -> Result<Child> {
// The /api proxy hop to the host's loopback HTTPS mgmt API. The host's self-signed cert is
// accepted only inside the proxy code (per-request TLS), never process-wide.
("PUNKTFUNK_MGMT_URL", mgmt_url),
// Serve HTTPS with the host's own identity cert; mark the session cookie Secure.
// Serve HTTPS with the host's own identity cert; mark the session cookie Secure. Names the
// LEGACY pair — the console prefers the native sibling when it exists
// (web/nitro-entry/tls-paths.mjs), which is also what the gate above ends up waiting for:
// `serve` resolves the native identity before minting this one.
(
"PUNKTFUNK_UI_TLS_CERT",
data.join("cert.pem").to_string_lossy().into_owned(),
@@ -1550,14 +1553,79 @@ pub(crate) fn allow_public_network(args: &[String]) -> Result<bool> {
Ok(fw_public_marker().exists())
}
/// Build the `netsh advfirewall firewall add rule` argument vector for one inbound allow rule.
///
/// `program` is the whole point of this helper existing. A `dir=in action=allow` rule carrying only
/// `localport=` admits **any process on the machine** on those ports, and binding a high port on
/// Windows needs no elevation — so such a rule is a standing hole that any unprivileged program can
/// step into simply by binding first, and it does so *silently*, because our rule is exactly what
/// suppresses the "Allow this app to communicate on…" prompt Windows would otherwise raise (that
/// prompt is the UAC gate; without a matching rule there is no way in without one). Naming the
/// owning executable keeps the ports open for punktfunk and no one else. Reported by a user on
/// 2026-08-21, and correct: the fixed rules were the last any-program ones we shipped.
///
/// `ports` stays alongside it rather than being replaced by it — program AND port is strictly
/// tighter than either alone, and it is only ever dropped where the port genuinely cannot be known
/// in advance ([`add_data_plane_firewall_rule`], whose port is ephemeral per session).
///
/// `None` for `program` reproduces the old any-program rule, and every caller falls back to it
/// rather than skipping the rule when it cannot resolve its executable: a looser rule still streams,
/// no rule at all is a black screen.
pub(crate) fn fw_add_rule_args(
name: &str,
proto: &str,
ports: Option<&str>,
program: Option<&std::path::Path>,
profile: &str,
) -> Vec<String> {
let mut args: Vec<String> = ["advfirewall", "firewall", "add", "rule"]
.iter()
.map(|s| s.to_string())
.collect();
args.push(format!("name={name}"));
args.push("dir=in".into());
args.push("action=allow".into());
args.push(format!("protocol={proto}"));
if let Some(p) = ports {
args.push(format!("localport={p}"));
}
if let Some(exe) = program {
args.push(format!("program={}", exe.display()));
}
args.push(profile.to_string());
args
}
/// [`run_quiet`] for an arg vector built by [`fw_add_rule_args`].
pub(crate) fn run_netsh(args: &[String]) -> bool {
let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
run_quiet("netsh", &borrowed)
}
/// Inbound firewall rules for the streaming + mgmt ports (best-effort; logs but never fails the
/// install). Scoped by [`firewall_profile_arg`]: Domain + Private by default, all profiles when
/// `allow_public`. TCP 47990 is deliberate: `serve` binds the mgmt/library REST API to all interfaces
/// so paired clients can browse the game library over mTLS, and off-loopback `mgmt::require_auth`
/// exposes only the read-only status/library allowlist to a paired client cert — the bearer-token
/// admin surface stays loopback-only regardless of the bind — so opening it adds no admin exposure.
/// `allow_public`, and — since 2026-08-21 — to this host executable, so the ports below are open to
/// punktfunk rather than to anything on the machine that binds them first (see
/// [`fw_add_rule_args`]). TCP 47990 is deliberate: `serve` binds the mgmt/library REST API to all
/// interfaces so paired clients can browse the game library over mTLS, and off-loopback
/// `mgmt::require_auth` exposes only the read-only status/library allowlist to a paired client cert
/// — the bearer-token admin surface stays loopback-only regardless of the bind — so opening it adds
/// no admin exposure.
fn add_firewall_rules(allow_public: bool) {
let profile = firewall_profile_arg(allow_public);
// Resolved once and shared with the data-plane rule below. `service install` re-runs this whole
// remove-then-add on every upgrade, so a path recorded here cannot go stale behind a moved
// install — which is what previously argued for leaving these rules unscoped.
let exe = match std::env::current_exe() {
Ok(p) => Some(p),
Err(e) => {
eprintln!(
"warning: could not resolve the host executable path ({e}) — the rules below stay \
open to any program on those ports, and the per-session data-plane rule is skipped"
);
None
}
};
// (name suffix, protocol, ports). 47990 = mgmt/library (LAN = read-only, paired-cert only); the
// rest are the GameStream (47984/47989/48010, 47998-48010) + native (9777) + mDNS (5353) ports.
let rules = [
@@ -1566,28 +1634,35 @@ fn add_firewall_rules(allow_public: bool) {
];
for (suffix, proto, ports) in rules {
let name = format!("Punktfunk {suffix}");
let ok = run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"add",
"rule",
&format!("name={name}"),
"dir=in",
"action=allow",
&format!("protocol={proto}"),
&format!("localport={ports}"),
profile,
],
);
let ok = run_netsh(&fw_add_rule_args(
&name,
proto,
Some(ports),
exe.as_deref(),
profile,
));
if ok {
println!("Firewall rule added: {name} ({ports}) [{profile}]");
let scope = match &exe {
Some(p) => format!(" for {}", p.display()),
None => String::new(),
};
println!("Firewall rule added: {name} ({ports}{scope}) [{profile}]");
} else {
eprintln!("warning: could not add firewall rule '{name}' (add it manually if needed)");
}
}
add_data_plane_firewall_rule(profile);
add_data_plane_firewall_rule(profile, exe.as_deref());
// 5353 is now ours alone. Anything else on this machine that answered mDNS through the old
// any-program rule needs its own — say so, because it is the one externally visible change.
// Only when the scoping actually happened: with no exe path these rules are still wide open,
// and claiming otherwise in installer output is worse than saying nothing.
if exe.is_some() {
println!(
"Note: these rules are scoped to the punktfunk host executable, so they no longer open \
those ports to every program on this machine. Another mDNS/GameStream application \
that relied on punktfunk's rules to be reachable now needs a rule of its own."
);
}
if !allow_public {
println!(
"Note: streaming ports are open on Private/Domain networks only. On a network Windows \
@@ -1613,35 +1688,29 @@ const FW_DATA_PLANE_RULE: &str = "Punktfunk UDP (data plane)";
///
/// Program-scoped rather than a pinned port: it covers whatever port the session picks, needs no
/// second rule when the range moves, and cannot collide with another host (a pinned data port in
/// 47998-48010 would land on Sunshine/Apollo's GameStream range). The port rules above are kept as
/// they are — an install whose recorded exe path later moves still has its fixed ports open.
fn add_data_plane_firewall_rule(profile: &str) {
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
eprintln!(
"warning: could not resolve the host executable path ({e}) — skipping the \
data-plane firewall rule; streams may show a black picture behind a healthy \
connection on networks that need the client's hole-punch to open the path"
);
return;
}
/// 47998-48010 would land on Sunshine/Apollo's GameStream range). This rule is the pattern the
/// fixed-port rules above now follow too — it is only the `localport=` they keep and this one
/// cannot have.
///
/// `exe` is resolved once by the caller and shared; `None` means it could not be resolved, and this
/// rule is skipped rather than widened, because a program-less "any inbound UDP on any port" rule is
/// not a looser version of this — it is an open host.
fn add_data_plane_firewall_rule(profile: &str, exe: Option<&std::path::Path>) {
let Some(exe) = exe else {
eprintln!(
"warning: no host executable path — skipping the data-plane firewall rule; streams may \
show a black picture behind a healthy connection on networks that need the client's \
hole-punch to open the path"
);
return;
};
let ok = run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"add",
"rule",
&format!("name={FW_DATA_PLANE_RULE}"),
"dir=in",
"action=allow",
"protocol=UDP",
&format!("program={}", exe.to_string_lossy()),
profile,
],
);
let ok = run_netsh(&fw_add_rule_args(
FW_DATA_PLANE_RULE,
"UDP",
None,
Some(exe),
profile,
));
if ok {
println!(
"Firewall rule added: {FW_DATA_PLANE_RULE} (any UDP port for {}) [{profile}]",
@@ -1872,3 +1941,55 @@ fn maybe_boot_loop_rollback(restarts: u32, attempted: &mut bool) {
Err(e) => tracing::error!(error = %e, "failed to spawn the rollback installer"),
}
}
#[cfg(test)]
mod firewall_tests {
use super::*;
use std::path::Path;
/// Every fixed-port rule must carry BOTH `program=` and `localport=`. Dropping the program
/// scope is the regression that matters: the rule still works, streaming still works, and the
/// only visible difference is that any unprivileged process on the machine can bind those
/// ports and be reachable from the LAN without ever raising a Windows prompt.
#[test]
fn fixed_port_rules_are_scoped_to_the_program_and_the_ports() {
let exe = Path::new(r"C:\Program Files\Punktfunk\punktfunk-host.exe");
let args = fw_add_rule_args(
"Punktfunk UDP",
"UDP",
Some("47998-48010,9777,5353"),
Some(exe),
"profile=domain,private",
);
assert!(args.contains(&format!("program={}", exe.display())));
assert!(args.contains(&"localport=47998-48010,9777,5353".to_string()));
assert!(args.contains(&"dir=in".to_string()));
assert!(args.contains(&"action=allow".to_string()));
assert!(args.contains(&"profile=domain,private".to_string()));
assert_eq!(&args[..4], &["advfirewall", "firewall", "add", "rule"]);
}
/// The data plane is the one rule that legitimately has no port: its socket binds `0.0.0.0:0`
/// per session. It must therefore never lose its program scope — a program-less "any inbound
/// UDP on any port" rule is not a looser version of this rule, it is an open host.
#[test]
fn the_data_plane_rule_has_a_program_but_no_port() {
let exe = Path::new(r"C:\Program Files\Punktfunk\punktfunk-host.exe");
let args = fw_add_rule_args(FW_DATA_PLANE_RULE, "UDP", None, Some(exe), "profile=any");
assert!(args.contains(&format!("program={}", exe.display())));
assert!(
!args.iter().any(|a| a.starts_with("localport=")),
"the per-session data port is ephemeral — pinning one would close the others"
);
}
/// An unresolvable executable falls back to the old any-program rule rather than to no rule:
/// a looser rule still streams, a missing one is a black screen. Pinned so the fallback stays
/// deliberate rather than becoming an accident.
#[test]
fn a_missing_program_falls_back_to_the_port_only_rule() {
let args = fw_add_rule_args("Punktfunk TCP", "TCP", Some("47990"), None, "profile=any");
assert!(!args.iter().any(|a| a.starts_with("program=")));
assert!(args.contains(&"localport=47990".to_string()));
}
}
+20 -3
View File
@@ -208,7 +208,24 @@ fn poll_loop(
// that proves the server is answering, and the agent below refuses redirects so the probe is
// exactly one round trip. (A 302 still counts as up via the `Status` arm in `probe_console`.)
let console_url = format!("https://127.0.0.1:{web_port}/login");
let agent = agent(load_pin());
// Named, not `agent`: shadowing the fn (as this did while there was only one agent) would make
// the second call below resolve to this binding instead.
let mgmt_agent = agent(load_pin());
// The console probe gets its OWN, UNPINNED agent. It is a different server from the mgmt API
// and there is no rule that it presents the same certificate: it served the legacy `cert.pem`
// while mgmt served the native one (the identity split), so the pinned agent refused the
// handshake and every identity-split host showed "Open web console (not responding)" over a
// perfectly healthy console — next to a tooltip reading "idle", because the same agent reached
// mgmt fine (field report 2026-08-24). An operator fronting the console with their own LAN-CA
// cert would have hit it just as squarely, so the coupling goes rather than the symptom.
//
// Nothing is lost by dropping the pin: this probe sends no credentials, reads no body, and
// decides only presentation — the menu entry's label, plus whether a tray-icon click opens
// the console or the menu (win.rs). A port-squatter could flip that, but the entry itself is
// unconditional and opens the same URL either way, and no browser ever pinned this cert. On
// Windows the probe was never pinned to begin with: `punktfunk_config_dir` returns None there,
// so `load_pin` was already None.
let console_agent = agent(None);
let mut last: Option<(TrayStatus, bool)> = None;
// When the summary became unreachable while the service was running (grace anchor).
// Runs for the process lifetime (the tray exits by process exit; nothing to unwind).
@@ -220,7 +237,7 @@ fn poll_loop(
loop {
let svc = probe_service();
let summary = if svc == ServiceState::Running {
let s = fetch_summary(&agent, &summary_url());
let s = fetch_summary(&mgmt_agent, &summary_url());
match s {
Some(_) => unreachable_since = None,
None if unreachable_since.is_none() => unreachable_since = Some(Instant::now()),
@@ -233,7 +250,7 @@ fn poll_loop(
};
let grace_expired = unreachable_since.is_some_and(|t| t.elapsed() >= START_GRACE);
let status = map_status(&svc, summary, grace_expired);
let console_up = if probe_console(&agent, &console_url) {
let console_up = if probe_console(&console_agent, &console_url) {
console_misses = 0;
true
} else {
+10
View File
@@ -252,6 +252,16 @@ the route where there are no face buttons to press, such as an Android TV remote
names whichever your device has; the Apple TV carries it in ordinary Settings next to **Show it**
instead, so it's reachable from the Siri Remote.
**Reduce interface resolution** — *default: off.* Android only, in the controller-optimized
settings. Draws the menus at 1080p and lets the display scale them up, instead of drawing at the
panel's own resolution. Text goes a little softer; the interface gets much smoother. It is for 4K
televisions and projectors, whose graphics chips are built to decode and composite video rather
than to draw a moving interface, and are far slower than the ones in phones — at 4K every part of
the interface costs four times what it does at 1080p, on hardware nowhere near four times faster.
A premium 4K box is *more* likely to want this than a cheap 1080p stick, which never had the extra
pixels in the first place. Nothing about a stream changes: picture quality is
[**Resolution** and **Bitrate**](#video), and this is the interface only.
## Overlay
**Statistics overlay** — *default: Normal.* Four tiers — Off, Compact, Normal, Detailed — each a
+9
View File
@@ -79,6 +79,15 @@ punktfunk launch <host-ref> # start a session, waking the host firs
punktfunk-client --connect <host>:9777 # the older flag — still supported for existing scripts
```
**Couch mode.** The same client also has a **gamepad console UI**: a full-screen, controller-driven
front end with the host list, PIN pairing, settings, Wake-on-LAN and the game library, streaming in
its own window. Three ways in — the **gamepad button** in the shell's header bar (also *Main menu →
Console UI*), the separate **Punktfunk Console** launcher the packages install alongside the app, or:
```sh
punktfunk-client --browse --fullscreen
```
The client also updates itself (`punktfunk-client --check-update` / `--apply-update`) — see
[Keeping a client up to date](/docs/install-client#keeping-a-client-up-to-date).
+1 -1
View File
@@ -282,7 +282,7 @@ table, where client and host read the *same* variable name for their own half of
| `PUNKTFUNK_PRESENTER` | `arrival` | Turn the frame-pacing engine off for this run: frames present the instant they decode, exactly as they did before the **Prioritize** setting existed. A diagnostic — if a pacing change is suspected of causing judder or added delay, this switches it off without reinstalling anything. Linux and Windows clients. |
| `PUNKTFUNK_VRR_FIFO` | `1` | Force the display mode used to follow a **variable-refresh (VRR / FreeSync / G-Sync)** screen, on graphics drivers too old to offer the modern one. You almost certainly don't need this: where the driver supports the modern mode — which is what **Follow variable refresh rate** in [client settings](/docs/client-settings#video) uses — following the panel is already automatic and costs almost nothing. On an older driver the only way to follow the panel is a mode that measured roughly 27 ms *worse* on a fixed-refresh screen, so it stays off unless you ask for it, and it's only worth asking if you genuinely have a VRR screen and play fullscreen. Check the Detailed [stats overlay](/docs/stats): `vrr yes` means the panel really is following the stream. Linux and Windows clients. |
| `PUNKTFUNK_PRESENT_DEBUG` | `1` | Log the presenter's own 1-second summary (display mode, buffer drops, pacing counters) every second, even when nothing is going wrong. Without it the line appears only when there is something to report. |
| `PUNKTFUNK_ABR_PROBE_KBPS` | kbps, e.g. `900000` | The startup link-capacity probe's burst target (default 2 Gbps — deliberately above any plausible link so the burst measures the link, not itself). Lower it on links the burst shouldn't slam, or when the measured ceiling comes out wrong for your setup. |
| `PUNKTFUNK_ABR_PROBE_KBPS` | kbps, e.g. `90000` | The startup link-capacity probe's burst target. By default it's derived from the session — twice what your resolution, refresh rate and codec could plausibly use, which is the most the climb ceiling is ever allowed to reach — and capped at 2 Gbps. Lower it further on links the burst shouldn't slam, or when the measured ceiling comes out wrong for your setup. |
| `PUNKTFUNK_ABR_PROBE` | `0` | Skip the startup link-capacity probe entirely. The adaptive-bitrate climb ceiling then stays at the negotiated starting rate — a blunt instrument; prefer `PUNKTFUNK_ABR_MAX_MBPS`. |
| `PUNKTFUNK_ABR_MAX_MBPS` | Mbps, e.g. `300` | Hard cap on the adaptive bitrate's climb ceiling, whatever the startup probe measured. The escape hatch when adaptive sessions keep climbing past what your client's **decoder** can sustain (periodic hitch + "receive backlog stopped draining" in the client log). An explicit bitrate setting still bypasses ABR entirely. |
+24 -1
View File
@@ -62,6 +62,10 @@ desktop session, so Steam's own "Switch to Desktop" hung until a reboot.
> sudo usermod -aG punktfunk "$USER" # then log out and back in
> ```
>
> It also keeps the box's own power menu working: on a takeover flavour that *stops* the display
> manager, the box has no active local session for the length of the stream, and Shut Down /
> Restart / Sleep are refused unless you are in this group.
>
> Your package created the group at install time and put **nobody** in it, on purpose: it also
> gates the usbip nodes the virtual Steam Deck pad attaches through, and writing those can present
> arbitrary emulated USB hardware — so joining stays a deliberate act, on a machine you trust.
@@ -136,7 +140,26 @@ PUNKTFUNK_CAPTURE_MONITOR=HDMI-A-1
The host then attaches to the session's own composited output: nothing is stopped, nothing is
relaunched, no mode is imposed, and what you see is exactly what is on the TV. That is the
difference from **managed**, which deliberately takes the session over and blanks the panel.
difference from **managed**, which deliberately takes the session over and relaunches it headless,
so the box's own session stops driving that panel.
Whether the panel then goes *dark* is the **Topology** setting's job, not the model's — see
[Virtual displays](/docs/virtual-displays#topology). What `exclusive` can do differs by model:
- **Managed** and **bare spawn** — the box's own gaming session is moved out of the way so it
stops driving the panel, and then the panel is actually turned **off** for the stream and
restored at teardown. The host asks whichever desktop is running: KDE goes through KWin's DPMS,
sway and Hyprland through their own; a box already in Game Mode has no desktop to ask, so the
host turns the CRTCs off over DRM itself. None of these needs root — the DRM path rides the same
seat access every local compositor gets.
- **Attach** — nothing is darkened, and cannot be: this model streams the panel the box is
driving, so turning it off would turn off the picture.
Under `extend` or `primary` none of this happens and your screens are left alone. If `exclusive`
asked for a dark screen and the host could not deliver one — **GNOME** is the case that cannot be
served, because Mutter offers apps no way to turn a screen off and holds the graphics card itself
so the DRM route is refused — it says so in the log rather than leaving you guessing at a lit
screen.
Only the one head the session drives is listed — a nested or headless gamescope (including the
per-session ones the host spawns itself) has none of its own, so the picker is empty there. Full
+7 -3
View File
@@ -44,9 +44,13 @@ from the [stats overlay](/docs/stats), so it shows even with stats off.
The mute lasts for that stream only — the next session starts unmuted; nothing is written to your
settings. With **Stream microphone** off in [client settings](/docs/client-settings#audio) the
shortcut does nothing and no badge appears. **Linux and Windows** clients only (a Steam Deck stream
is the Linux client, so an attached keyboard gets the chord); on Apple and Android turn **Stream
microphone** off in settings instead.
shortcut does nothing and no badge appears.
The **keyboard** chord is **Linux and Windows** only (a Steam Deck stream is the Linux client, so an
attached keyboard gets it). On **Android** a controller can reach the same toggle: **Select + Y**,
and on a DualSense the pad's own **Mute** button does it too — one toggle per press, and the badge
is the same. On **Apple** clients there is no shortcut; turn **Stream microphone** off in settings
instead.
Alt-Tabbing away releases input on its own and takes it back when you return. A release you asked
for with the chord stays released until you opt back in. Either way, keys and buttons you were
+6
View File
@@ -52,6 +52,12 @@ The console lists every paired device with its access (and a live countdown for
From there you can change the level, extend or cut the expiry, or **remove** the device — removing
revokes it immediately, even mid-session. Re-pairing a removed device is just the PIN ceremony again.
**Naming a Moonlight device.** Every Moonlight-compatible client identifies itself with the same
built-in name, so several of them look identical in the list. Use the pencil on the row to give it
one of your own ("Living room TV") — the name is stored on the host, so every browser sees it, and
removing the device forgets it. Devices paired with Punktfunk's own apps send a real name already
and have no pencil.
Can't pair at all? [Troubleshooting → Pairing is rejected](/docs/troubleshooting#pairing-is-rejected--the-client-cant-connect).
## How it works, briefly
+15 -3
View File
@@ -17,9 +17,12 @@ list; the install guides quote the one or two lines that apply to each distro.
one](/docs/web-console#two-ports-not-one)).
- **`punktfunk-gamestream`** is needed only once you turn on Moonlight compat
(`PUNKTFUNK_GAMESTREAM=1` in `host.env` — [Moonlight](/docs/moonlight)).
- **Video needs nothing opened.** The data plane uses an ephemeral UDP port the *client* opens with a
hole-punch; the host streams back through the path the client opened, so only outbound UDP has to
be allowed (the default in both ufw and firewalld).
- **Video needs nothing opened on Linux.** The data plane uses an ephemeral UDP port the *client*
opens with a hole-punch; the host streams back through the path the client opened, so only
outbound UDP has to be allowed (the default in both ufw and firewalld). **Windows is the
exception** — it drops the client's hole-punch, which is why `service install` adds an inbound UDP
rule scoped to the host executable rather than to a port number (no fixed rule can cover a port
chosen fresh each session).
## Enabling the profiles
@@ -40,6 +43,15 @@ Stock Arch and Debian ship no firewall; Ubuntu installs ufw but leaves it inacti
and most Fedora-family spins run firewalld; CachyOS enables ufw. On **NixOS** the module's
`openFirewall = true` does all of this; on **Windows** the installer registers the rules.
<Callout type="warn">
**Windows: the rules are scoped to Punktfunk from 0.31.2 on.** Each rule names the executable that
listens on it as well as the port, so those ports are open to Punktfunk rather than to anything on
the machine that binds them first — before 0.31.2 they named only the port. The one thing this can
change for you is **5353**: if something else on that PC relied on Punktfunk's rule to answer
discovery, it now needs a rule of its own. `service install` re-applies the rules on every upgrade,
so a normal update is enough.
</Callout>
## Moving a port
Two are configurable, and both are how you share a machine with another streaming host — see
+97 -4
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.31.0"
"version": "0.31.4"
},
"paths": {
"/api/v1/client-logs": {
@@ -364,6 +364,77 @@
}
}
}
},
"patch": {
"tags": [
"clients"
],
"summary": "Rename a paired client",
"description": "Sets or clears the operator-visible display name for one paired Moonlight client. This is\npurely cosmetic — it touches no certificate and no trust decision — but it is the only way to\ntell paired devices apart: every moonlight-common-c client self-signs with the identical\nsubject `CN=NVIDIA GameStream Client`, so an unnamed list is a row of clones distinguishable\nonly by fingerprint. The name is stored beside the pairing store and survives host restarts;\nunpairing the device forgets it.",
"operationId": "renameClient",
"parameters": [
{
"name": "fingerprint",
"in": "path",
"description": "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RenameClient"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "The client as it now reads",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PairedClient"
}
}
}
},
"400": {
"description": "Malformed fingerprint",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No paired client with that fingerprint",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/compositors": {
@@ -6688,7 +6759,7 @@
},
"HostInfo": {
"type": "object",
"description": "Host identity and advertised capabilities (static for the life of the process).",
"description": "Host identity and advertised capabilities (static for the life of the process, except\n`local_ip`).",
"required": [
"hostname",
"uniqueid",
@@ -6734,7 +6805,7 @@
},
"local_ip": {
"type": "string",
"description": "Best-effort primary LAN IP."
"description": "Best-effort primary LAN IP, read fresh on every request — a host that started before its\nnetwork did (cold boot) reports `127.0.0.1` only until it actually has an address, and a\nhost that moves networks reports the new one. Poll it rather than caching it."
},
"os": {
"type": "string",
@@ -7375,6 +7446,14 @@
"description": "Lowercase hex SHA-256 of the client certificate DER — the client's stable id here.",
"example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
},
"label": {
"type": [
"string",
"null"
],
"description": "Operator-assigned display name for this device, if one has been set (`PATCH /clients/{fp}`).\n\nThis is the ONLY thing that can tell two paired Moonlight devices apart in a list, because\ntheir certificates cannot: see [`Self::subject`]. Absent until somebody names the device.",
"example": "Living Room TV"
},
"not_after_unix": {
"type": [
"integer",
@@ -7396,7 +7475,7 @@
"string",
"null"
],
"description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses."
"description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.\n\nDo not display this as a device name. Every moonlight-common-c client self-signs with that\nsame fixed subject, so it identifies the *protocol*, not the device — a list of paired\nphones, TVs and handhelds all read identically. [`Self::label`] is the field to show."
}
}
},
@@ -7949,6 +8028,20 @@
}
}
},
"RenameClient": {
"type": "object",
"description": "Body of `PATCH /clients/{fingerprint}` — the device's display name.",
"properties": {
"label": {
"type": [
"string",
"null"
],
"description": "The name to show for this device. `null` (or an empty/whitespace-only string) clears it and\nthe device goes back to being listed by fingerprint alone.\n\nScrubbed before storage by the same sanitizer the native plane runs on device names:\ncontrol characters and Unicode bidi overrides are stripped (they could make one paired\ndevice impersonate another in this very list), whitespace collapsed, and the result capped\nat 64 characters.",
"example": "Living Room TV"
}
}
},
"RunningTitle": {
"type": "object",
"description": "One running title in a provider's liveness report.",
+55
View File
@@ -0,0 +1,55 @@
Wire-compatible with 0.31.x — everything you have already paired keeps working, and you can update one side at a time. Nothing here changes how a host and a client agree on what to send each other, so an old client on a new host, or the other way round, streams exactly as it does today.
This is a fix release, and the largest thing in it has been wrong on every Windows host since there have been Windows hosts: the port your video actually travels on was never opened in the firewall, so on any network that needs your client to knock first, the connection succeeded, every health signal stayed green, and the picture never arrived. Two more are about controllers that were connected, correctly identified, and doing the wrong thing anyway — Moonlight-compatible sessions on Windows were building the one kind of virtual controller Steam and most games cannot see, and on a Fire TV a DualSense's L2 was arriving as L1. The rest is Android: a console that could not be dismissed, a picture that sat in the top-left corner, and a television remote that could not reach Settings or your library. There is one new thing too — a library add-on can now tell your host which of its games are running, which is how a game your host cannot find on disk finally ends its own session.
## TL;DR
- **Windows: streams that connected and then stayed black.** The per-session video port was never covered by any firewall rule, on any Windows host, ever. Update with the installer and the rule is added for you — see *Before you update*.
- **Windows: controllers did nothing in Moonlight-compatible sessions.** Every client, every device, same result. Those sessions built a virtual controller most games cannot enumerate; they now build the same one ordinary Punktfunk sessions do.
- **Android: controllers Android has no layout for pressed the wrong buttons.** A DualSense or an Xbox Elite Series 2 over Bluetooth on a Fire TV had L2 arriving as L1, with Circle and R1 doing nothing at all.
- **Android: the controller console would not go away, and the picture sat in the corner.** Both fixed — and the console now carries its own switch for turning itself off.
- **A television remote can reach Settings and your library again.** Both were on controller face buttons a remote does not have.
- **Android: every setting that only exists in the controller console did nothing when you changed it.** Low latency, the phone's own rumble and motion, controller capture — all saved to somewhere nothing read back.
- **Nobara hosts: "Switch to Desktop" during a stream threw you straight back into the stream.** And on Steam Deck-style hosts, a mid-stream switch could leave Gaming Mode unable to start afterwards.
- **NixOS: the host installed a compositor that was not there.** The package shipped the small launcher and deleted the program it launches.
- **Games your host cannot see on disk can now end their own session.** A library add-on can tell the host which of its titles are running, which is the missing half for anything launched by handing off to another program — emulated titles, manually added ones.
## Before you update
- **Windows hosts: run the installer rather than replacing the program by hand.** The black-picture fix is a new firewall rule, and it is added by the host's own service-install step, which the installer runs for you on every update. If you run the host some other way, run `punktfunk-host service install` once as an administrator — or add an inbound rule for `punktfunk-host.exe` yourself. Uninstalling Punktfunk removes the rule again. Nothing on the client side needs doing.
- **NixOS hosts: expect a rebuild.** The compositor is now pinned to the exact version every other packaging channel ships, rather than to whatever the packages you happened to have carried, so its build inputs change. The binary cache added in 0.31.0 covers it if you have it configured.
- **Add-on authors: `@punktfunk/plugin-kit` 0.4.4 is what you depend on for the running-report above.** The call and its types were reachable only through a deep import path before, so nothing could reach them from the package root. Nothing else in the add-on contract moved.
- **Arch and SteamOS hosts: this release finally offers you a compositor upgrade you have been owed since 0.30.0.** The package's declared version had been left behind while the program inside it moved on, so your package manager saw nothing to do — which is why non-US keyboard layouts kept typing US characters on those machines even after the fix shipped.
## New
- **A library add-on can tell your host which of its games are running.** Your host normally works out whether a game is still going by looking for it on the machine — which needs something recognisable there to look for. A title that Playnite launches by handing off to another program has none: an emulated game, a manually added one, anything whose add-on records no folder. So the host never noticed those ending, your session stayed open on a game that had already exited, and anything set to happen when a game ends did not. The add-on knew the whole time, and can now say so. It is deliberately hard to get stuck on: a report expires unless it is repeated, so an add-on that crashes or is uninstalled stops speaking for your games within a minute and your host goes back to looking for itself. **This is the host's half.** It does something for you once an add-on sends those reports — the Playnite one is the first and updates separately, on its own schedule.
- **The controller console can switch itself off.** On an Android phone or tablet the console's own Settings now carries a Controller-optimized UI switch, so you can leave the console from inside it and land back on the touch interface immediately. Until now that switch existed only in the touch settings — which someone stuck inside the console had no way to reach, and since 0.31.0 the console is the only interface Android shows when a controller is attached. It appears only where switching off has somewhere to land: not on a television, and not on the desktop console, where turning it off would leave you with nothing.
## Improved
- **A host can tell a silent connection from a perfect one.** Packet loss is a proportion of what arrived, so a link delivering nothing and a flawless link both reported zero loss — and the host read the silence as perfection, complete with confident wording about the client's network in the log. Clients now also report how much they have actually received, so a host that is sending into a void says so, and names the video path rather than blaming the client. A client too old to answer gets a warning that says as much instead of a guess.
- **The Connected controllers page can be trusted again.** It read your button presses the same wrong way the stream did, so it agreed with the bug and confirmed a mistranslated pad as correct. It now shows what the controller actually reported alongside what Android made of it, and names the layout it resolved — which is also what makes an unrecognised pad fixable from a bug report, without the hardware in hand.
## Fixed
- **A Windows host could stream into a black screen with nothing anywhere saying why.** One field host sent 1,919 frames of video that were never seen while its own log blamed the client's network. The cause: the firewall rules Punktfunk installs cover fixed port numbers, but the video itself travels on a port chosen fresh for each session — a port no such rule can ever cover. So Windows dropped the client's opening knock on **every session on every Windows host**, including ones that worked; those worked only because nothing on the path needed the client to open the way first. When something did — many home routers, most mobile networks, anything doing address translation in front of the host — the control connection stayed perfectly healthy and the video went nowhere. The host now installs a rule that follows the program instead of the port number, so whichever port a session picks is covered. It is scoped to the program deliberately: pinning a port would have collided with Sunshine and Apollo on machines running both.
- **Controllers did nothing at all in Moonlight-compatible sessions on Windows**, reported across every client and every device someone tried, which is what showed it was not a client problem. Windows has two ways to present a virtual Xbox controller, and only one of them is visible to Steam, to games using the common input libraries, and to the Windows Game Controllers panel. Ordinary Punktfunk sessions moved to that one over a year of reports; Moonlight-compatible sessions kept the invisible one purely because both were reached by the same internal name. They now make the same choice, and switching them both back is still one setting if you need it.
- **Controllers pressed the wrong buttons on devices Android has no layout for.** Reported from a Fire TV Stick 4K Max with a DualSense and an Xbox Elite Series 2, both over Bluetooth, both identified by name and both wrong under the fingers: L2 arrived as L1, and Circle and R1 were dropped entirely. Android names a controller's buttons from a file matched to that exact model, and when there is no such file it falls back to guessing by the order the buttons appear in the controller's own report — which is only right if that order happens to match. Neither pad has a file on a Fire TV. Punktfunk now reads the button's position in the controller's report directly, which is what the controller means rather than what the guess made of it, and does so only where the guess was in play, so a device that already worked is untouched. Triggers get the same treatment: on a pad Android never mapped they sit on raw axes, which is why pulling a trigger could swing the right stick instead. Whether a trigger rests at zero or at the bottom of its range is now measured from the device rather than assumed. **Not yet confirmed on the reporter's own hardware** — the fix is proven against what those two controllers report, but a Fire TV is the test that settles it, so please say if yours still misbehaves. One thing stays broken and cannot be fixed here: the Xbox pad's Guide button is delivered by Android as the Home key, which it never passes to an app.
- **The controller console could not be dismissed on some Android phones.** Turning it off is a matter of no controller being attached, and the app decided that by asking whether a device claimed to be a gamepad — which is the right question for routing a button press and the wrong one for knowing a pad is in the room. Manufacturer game-mode overlays and gaming-phone shoulder triggers make that claim without being controllers, and one of them was enough to pin the console on forever, because a pad that was never there can never be unplugged. The app now also asks whether the hardware is there behind the claim — a stick, a directional pad or real face buttons — and the new switch above is the guaranteed way out either way.
- **Changing an Android-only setting inside the controller console did nothing.** Low latency, the phone's own rumble and motion controls, the controller capture switches and the console's own display mode could each be changed in the console, and each quietly went nowhere: the console filed those settings one level deeper than the settings file keeps them, so nothing ever read one back. The row showed its own default, your change came back as the value it had just been handed, and nothing downstream ever heard that anything had moved — which is also why the new off switch above needed this fixed before it could work at all. Settings written by the previous build carry a dead wrapper; it is discarded the next time anything is saved rather than followed around for the life of the install.
- **The glow behind a focused card was squarer than the card.** The halo grows the card by a few units on every side but kept the card's own corner radius, and a shape grown outward only stays parallel if its corners grow with it — so the two arcs stopped sharing a centre and the corners read as a badly drawn outline instead of light spilling out from behind. Every card in the console goes through that path: the home tiles, the library grid, the coverflow and the collections deck.
- **The picture sat in the top-left corner of an Android screen.** The video layer took the size of the view once, at the moment it was created — and the stream screen hides the system bars and expands into the display cutout a frame or two later, each of which grows the view underneath a layer that never hears about it. The size is now read fresh for every frame, which also means rotating the phone and multi-window both simply work.
- **Settings and your game library could not be reached with a television remote.** A remote has a directional pad, OK and Back, and the console had put Settings and the library shelf on controller face buttons it does not have — so on an Android TV neither could be opened at all. Pressing down on the home row now opens Settings, and the library has joined each machine's own options menu, which is where the documentation had been telling you to find it all along. The on-screen hints name whichever route the device in your hand actually has.
- **On Nobara, "Switch to Desktop" during a stream threw you straight back into the stream.** The switch takes the picture away, Punktfunk reads that as a problem and rebuilds the session, and the rebuild put Gaming Mode back over the desktop that was trying to start. 0.31.0 changed how the host takes Gaming Mode over and left nothing watching for the switch, which Steam Deck and Bazzite machines never noticed because they follow it another way — Nobara has neither. Also fixed on both: a mid-stream switch used to leave Gaming Mode replaced by a placeholder, so the machine's own "Return to Gaming Mode" afterwards started something that did nothing. Both routes now hand the machine back intact.
- **On NixOS the host installed a compositor consisting only of its launcher.** The packaging trims the installed programs down to the one that is needed and had been matching it by name — but the program with that name is a small launcher that sets things up and then runs the real compositor, which the trim deleted. What shipped was a launcher pointing at nothing. That is the true cause of the version banner printing nothing, the marker "missing from the binary", and every HDR-related NixOS failure chased alongside them. Separately, NixOS was the only channel not pinning which compositor version it patches, so a package update could and did break the build outright — and since HDR is on by default, that failure landed on anyone enabling the host at all.
## Thanks
Every fix above came from someone describing precisely what did not happen. The Windows black screen was found in two field logs from a host that looked healthy in every respect; the dead controllers in Moonlight-compatible sessions were reported with the detail that made them findable, that it reproduced on every client and device tried; the Fire TV report named which button arrived as which; and one Android user sent two reports in a day that turned out to be the same mistake made twice. Thank you — that is what makes a fault findable rather than merely believable.
## For developers
Protocol, ABI, driver and embedder detail — including the version table — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.31.1/CHANGELOG.md).
The short version: the streaming protocol, the embedding interface, the driver protocol and the gamepad channel are all exactly where 0.31.0 left them, so nothing needs rebuilding, re-pairing or re-packaging in any direction. The management API and the add-on toolkit each gain one thing by pure addition — the running-report route above, and the toolkit call for it in 0.4.4 — and an add-on that ignores both keeps working unchanged. One control message is added to the wire — clients reporting how much they have received, which is what lets a host tell a dead video path from a clean one — but it takes a spare message number rather than changing an existing message, and an older host on the other end ignores it after one note in its log.
+42
View File
@@ -0,0 +1,42 @@
Wire-compatible with 0.31.x — everything you have already paired keeps working, and you can update one side at a time. Nothing here changes how a host and a client agree on what to send each other, so an old client on a new host, or the other way round, streams exactly as it does today.
This is a fix release, and most of it continues the hunt 0.31.1 started: a stream that connects, reports itself perfectly healthy at every gauge, and shows you a black screen. Two more causes end here, and both are about which of your host's addresses it used — video that left the host by whichever network connection the machine happened to prefer rather than the one your client actually dialled, and a host that started up faster than its own network and then spent the rest of the day telling everyone to connect to an address that only ever means "this machine". The other half is Windows security: the firewall rules Punktfunk installs named ports but not programs, which left those ports open to anything running on that PC — they name Punktfunk now, and that is the one change here that can affect another program on the same machine. On Android, the controller fix from 0.31.1 turned out to be firing on controllers that never needed it, breaking buttons that had been correct all along.
## TL;DR
- **A host with two ways to reach your client streamed into a black screen.** Ethernet and Wi-Fi both connected, or a VPN adapter installed, was enough: the video left by whichever one the machine preferred, and your client discarded every packet of it. Nothing else about the session was wrong, which is exactly why it was so hard to see.
- **A host that started before its network was ready never recovered.** It advertised an address meaning "this machine", so clients listed it and could not connect to it, Moonlight-compatible sessions could not stream, and Wake-on-LAN silently stopped working. Restarting the host was the only cure; now there is nothing to cure.
- **Windows: Punktfunk's firewall rules were open to every program on your PC.** They named only port numbers, so any program — with no administrator rights and no prompt — could take one of those ports and be reachable from your network through a rule meant for Punktfunk. **Read *Before you update*: one thing on that machine may now need a rule of its own.**
- **Android: 0.31.1's controller fix broke controllers that were already correct.** On a GameSir G8+ and an Xbox Elite Series 2 over Bluetooth, X answered Y, Y answered the left shoulder, and both shoulders answered menu buttons.
- **Your host now follows its own address when it changes** — a new lease from your router, or a machine moved between Wi-Fi and Ethernet — instead of announcing the address it had at startup forever.
## Before you update
- **Windows hosts: run the installer rather than replacing the program by hand.** The firewall fix rewrites the rules Punktfunk installs, and that happens in the host's own service-install step, which the installer runs for you on every update. If you run the host some other way, run `punktfunk-host service install` once as an administrator. Skipping it leaves the old wide-open rules in place; nothing on the client side needs doing.
- **Windows hosts: something else on that machine may need its own firewall rule now.** Punktfunk's rule for the discovery port (5353) used to be open to every program, so anything else on that PC that answers discovery — another streaming host, a media server, a printer or scanner utility — could have been reachable through Punktfunk's rule without ever having one of its own. That ends with this release. If something else on the machine stops being discoverable after you update, give it its own rule. The installer prints a note saying exactly this while it works.
## Improved
- **Your host keeps up with its own address instead of freezing it at startup.** The address a host publishes for clients to dial was worked out once, when the host process started, and then never looked at again. Now it is re-read as things change and the published address is updated to match — so a new lease from your router, or a laptop host carried from Wi-Fi to Ethernet, no longer leaves your clients dialling somewhere the host has not been for hours. This is the general form of the cold-boot fix below, and it covers the cases nobody had got round to reporting yet.
- **When a stream does go black, the host's log now tells you the truth about it.** The message it printed used to name a cause with real confidence — and was wrong often enough to send people to the wrong place entirely, including at least one investigation that went to the firewall while the actual fault was the network card. It no longer asserts a cause it cannot know, and it now records which network connection the video is actually leaving by, and says so plainly when that is not the one the client arrived on. That single line is what turns the black screen above from a mystery into something a log answers.
## Fixed
- **A host with more than one live path to your client sent the video down the wrong one, and you got a black screen with every indicator green.** Two network connections up on the same network — the very common Ethernet-and-Wi-Fi-both-on — or a VPN or overlay adapter that claims to know a better route, and the host let the machine choose which one the video left by. That choice was made with no reference at all to how your client had reached it. Your client only listens for video from the address it dialled, so it threw away everything arriving from the other one, in the part of the system that counts nothing and reports nothing. Meanwhile the connection was made, sound and controller input flowed perfectly, and the host's own loss figure sat at zero — because loss is measured over packets that arrived, and none did. The host now sends the video from the same address the client reached it on.
- **A host that started before its network was ready advertised itself as unreachable, and stayed that way until it was restarted.** Cold-booting a machine is a race, and the host wins it: it starts without waiting for the network, asks which address it should publish, gets no answer because there is no network yet, and falls back to the address that means "this machine and nothing else". Then it kept that answer for the entire life of the process. Everything that reads that address broke together — the host appeared in your client's list but could not be connected to, Moonlight-compatible sessions were handed the same useless address after launching a game, Wake-on-LAN quietly stopped working because the host could no longer identify the network hardware to record for it, and the web console displayed the wrong address to anyone who looked. Users found the workaround themselves, which was to restart the host once the machine had settled. The host now refuses that fallback answer entirely: if the usual method cannot say which address to use, it takes the first real network address it can find, which exists as soon as the network card is configured — well before the machine finishes working out how to route anything.
- **Windows: the firewall rules Punktfunk installs opened those ports to every program on the machine, not to Punktfunk.** Each rule named a port and nothing else, and a rule like that admits whatever is listening on that port — Punktfunk or otherwise. Nothing about it required administrator rights to exploit: taking a high-numbered port on Windows needs no privileges at all, so any program that started first could sit on one of Punktfunk's ports and be reachable from your whole network. Worse, it happened without any of the usual signs, because the pop-up asking whether to let a program communicate on your network is precisely what a matching rule suppresses — Punktfunk's rule was answering that question on another program's behalf. Every rule now names the program that is genuinely meant to be listening on it, and keeps the port restriction as well, so both must match. The affected ports were the streaming, discovery, management and console ports. If Punktfunk cannot work out its own location on disk it keeps the old broader rule rather than leaving you with no rule at all, since a rule that is too generous still streams and a missing one is a black screen.
- **Android: controllers that had always worked started pressing the wrong buttons.** This is a regression from 0.31.1, reported the same day on a GameSir G8+ and an Xbox Elite Series 2: X answered Y, Y answered the left shoulder, and the two shoulder buttons answered menu buttons, with everything else correct. 0.31.1 fixed controllers Android has no layout file for by reading each button's position in the controller's own report instead of trusting Android's guess — the right fix, applied to too many controllers. It decided which controllers needed it by asking what the device *claimed* to have, and that claim turns out to be true of any controller with six or more buttons, including every controller that was already perfectly correct. So it corrected pads that needed no correcting, and moved their buttons off the marks. It is now decided on the triggers instead: a controller that describes its triggers properly is one Android has a real layout for, and it is left completely alone — no correction to its buttons or its sticks. That is the same signal Moonlight uses for the same decision, and it matches the reports precisely, down to the fact that the very same model needed correcting on a Fire TV and was broken by it here: an Xbox Wireless Controller describes its triggers one way after a firmware update and the other way before it, and only the older one was ever wrong.
## Known issue
- **A DualSense with a dead Triangle button is not fixed here.** It was reported alongside the two controllers above and looks related, but it is not the same fault — Triangle reaching neither the stream nor Punktfunk's own controller display is a different failure from a button arriving as the wrong one, and nothing in the fix above produces it. The Connected controllers page prints exactly what each press reports; that line from an affected pad is what will pin it down.
## Thanks
Every fix in this release came from someone reporting what actually happened rather than what they assumed. Both black-screen causes were found in field logs from hosts that looked entirely healthy — and the firewall hole was reported by a user on the same day, immediately after the first of those fixes cleared their black screen and left them looking at the rules. The Android controller regression came back within a day of the release that caused it, from two people who named which button answered which, which is the difference between a report that can be fixed and one that can only be believed. Thank you.
## For developers
Protocol, ABI, driver and embedder detail — including the version table — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.31.2/CHANGELOG.md).
The short version: nothing versioned moves at all. The streaming protocol, the embedding interface, the driver protocol, the gamepad channel and the add-on contract are exactly where 0.31.1 left them, no message or function changed shape, and no header, package or plugin needs rebuilding, re-pairing or re-publishing in any direction. Two things worth knowing: on Windows the firewall rules provisioned at install are now scoped to the executable that listens on each port, which is the one change here that can affect another program on the same machine; and the host's reported address in the management API is now read fresh on every request instead of being fixed for the life of the process, so poll it rather than caching it.
+50
View File
@@ -0,0 +1,50 @@
Wire-compatible with 0.31.x — everything you have already paired keeps working, and you can update one side at a time. Nothing here changes how a host and a client agree on what to send each other, so an old client on a new host, or the other way round, streams exactly as it does today.
This is a fix release about streams that ended, froze, stuttered or never arrived while everything involved was doing something perfectly ordinary. Launching a Steam game that had shaders to process dropped the stream about ten seconds in, so people learned to launch everything twice. A fullscreen game that picks its own screen resolution mid-play froze the picture on a Windows host and ended the video a few seconds later with the sound still running. On an Android TV or a Fire Stick the app was quietly asking your host for a frame rate your television does not actually output, which is where the latency people had been working around by hand was coming from. And on a slower connection the very first thing a client does — a quick burst to measure what the link can carry — was big enough to choke the link it was measuring, delaying the picture by many seconds or losing it entirely. There is new work too: your Moonlight devices can be given names, and a 4K television or projector can now run the on-screen interface at a lower resolution to keep it smooth.
## TL;DR
- **A Steam game with shaders to process dropped the stream about ten seconds into launching it.** You watched the "Processing Vulkan shaders" dialog, lost the stream, reconnected and launched again — and the second launch worked, which is why this looked like bad luck rather than a bug.
- **Android TV and Fire Stick: the app negotiated a frame rate your TV does not output.** Setting the refresh rate by hand was the known workaround; it is no longer needed, and the latency it was papering over is gone.
- **A slow first picture, or none at all, on a constrained connection.** The startup speed test was so large it could black-hole the very link it was measuring — one case took fourteen seconds to show video. It is now sized to the session, and if the test does swallow the opening frame the client asks for a new one instead of sitting on black.
- **Windows: a game that changed your screen resolution mid-stream froze the picture and then ended the session.** The sound carried on throughout, which is exactly what makes this look like a problem at the client's end.
- **Fire TV: a DualSense had buttons that never reached the game**, and its touchpad click and Mute button did nothing. Mute now mutes your microphone.
- **New:** name your Moonlight devices instead of a list of identical rows, and — on a 4K TV or projector — **Reduce interface resolution** for a smoother on-screen interface.
## Before you update
- **Steam Deck, and only if you installed the host from source: re-run your update after taking this release.** A source install builds a patched compositor, and that build has been failing since mid-August because of a missing system package. The failure was silent — it reported success, and quietly dropped back to the system's own compositor, which is why HDR disappeared on boxes that had been streaming it minutes earlier. The missing package is added here, so the next build succeeds. Nothing to do on a packaged install.
## New
- **Give your Moonlight-paired devices names.** This is not a display bug being fixed: every Moonlight-compatible client identifies itself with the same built-in name, so it says which *app* is connecting and nothing about which device. Until now that name was all the console could show, and someone who had paired a phone, a television and a handheld saw three rows reading identically. Each Moonlight row now has a pencil next to it — name it "Living room TV", and that is what the list says from then on, including when you are choosing which device to remove. Devices paired with Punktfunk's own apps already send a real name and are left alone. Names live on the host, so every browser you open the console in sees the same ones, and removing a device forgets its name.
- **Reduce interface resolution, for a 4K television or projector.** The on-screen interface is drawn at whatever resolution the panel hands it, and on a 4K set that is four times the work of 1080p on a chip built to decode video rather than to draw a moving interface — which is why the premium 4K boxes are the ones that feel sluggish, not the cheap 1080p sticks that never had the extra pixels. The new switch sits directly under Reduce motion, because it is the same kind of bargain: text goes a little softer, the interface gets smoother. It is off by default. **It changes the interface only and does nothing to your stream** — picture quality is still Resolution and Bitrate, which are separate settings and untouched by this.
## Improved
- **The on-screen interface got substantially cheaper to draw, on every device.** Independently of the switch above, it was doing a surprising amount of work on every single frame whether or not anything had changed: re-measuring and re-laying-out every piece of text on screen sixty times a second, and allocating a full-screen scratch image to apply an effect that did nothing whenever the interface was sitting still. On a 4K panel that scratch image alone was larger than the memory budget the whole interface is allowed on a 2 GB box, so it was evicting real work in order to do nothing. Both are gone, and the result is pixel-for-pixel identical. The interface also now gets a scheduling priority just below the stream's, so a TV box cannot park it behind background work and leave it lagging your remote.
- **When the interface is slow, the logs can now say so.** It recorded which graphics version it had and how much memory it was allowed, and never what resolution it was drawing at or how long a frame took — so "it feels sluggish" could not be looked into from a log bundle at all. It now reports both.
## Fixed
- **Launching a Steam game dropped the stream while it was still starting, so you had to launch it twice.** Reported on Rocket League: the stream showed the "Processing Vulkan shaders" dialog and then ended about ten seconds in, every time, with a second launch working fine. The host was doing this to itself. Steam does its preparation work for a game — processing shaders, most visibly — under the same marker it uses for the game itself, so a launch is a short chain of things that all look like your game, and only the last one is. The host accepted the first one, and from that moment it was no longer waiting for a game to start but watching for one to exit; when the preparation step finished a few seconds later, that was read as the game exiting and the session was closed. Two things change. The shader step is now recognised for what it is and never mistaken for a game. And anything else must be seen continuously for a few seconds before the host will believe it is your game — the rule it already applied to programs a launcher starts, now applied to what it finds by looking. The cost is a few seconds' delay before the host says a game is running; nothing about detecting a game *exiting* changes, so a game you quit still ends the session as promptly as before.
- **Android TV and Fire Stick: the app asked your host for a frame rate the television does not output, and the latency went through the roof.** People had already found the workaround — set the refresh rate by hand — without knowing what it was working around. The app pins the panel to its highest refresh rate while you are in the on-screen interface; that exists for phones whose systems otherwise cap apps at 60, and no television needs it. But when the stream started, the app read the panel's *pinned* rate rather than what the TV genuinely outputs over HDMI, negotiated the session at that — and then released the pin, because on a TV the video decoder is what should be driving the HDMI mode. The result was a 120-frame stream arriving at a 60 Hz output, by construction, on exactly the two kinds of device in the reports. The pin is no longer applied on a television at all. A TV that really can do 120 still gets it by choosing it. In the same chain: a TV that reports the fractional broadcast rates (59.94, 29.97, 23.976) had them cut down to 59, 29 and 23 — rates no display actually has — and they are rounded properly now.
- **A slow first picture, or a black screen, on a constrained connection.** Before any video, a client sends a short burst to work out how much the link can carry. That burst was a fixed, very large size on the reasoning that it should measure the link rather than itself — but the result is capped afterwards to what the session could plausibly use, so everything above that was measured and immediately thrown away. What it bought was nothing; what it cost was a flooded link. On a constrained Wi-Fi connection it could black-hole outright: one measured case spent six seconds timing out and took fourteen seconds to show any video, and the same shape came in from a Fire TV Stick 4K Max. The burst is now sized from what the session can actually use, which can never come out lower than what is needed to prove the ceiling. And the second half of the black screen is closed too: if the burst takes the opening frame down with it, the client now asks for another one instead of waiting for some unrelated recovery to happen along.
- **Windows: a game that changed your screen resolution during a stream froze the picture and then ended the session.** Reported from a 4K session where the game switched the display to 1080p while it ran. A fullscreen game is allowed to choose its own resolution, and your host followed it — but the part of the host that compresses the picture cannot change size while it is running, and it was being rebuilt over and over at the size the game had already left. After about three seconds of that, the video ended while the sound kept playing, so you were left with a frozen picture, working audio and no option but to reconnect. The host now rebuilds at the size the game actually chose and tells your client about the new one, exactly as it does when *you* change the resolution from the client. The same fix covers a game that switches HDR on or off mid-play, which failed in the same way. If a rebuild does not take the first time — a display that has just changed mode is often still settling — it is retried for the same few seconds rather than the session being given up on immediately.
- **The same resolution change in a Moonlight-compatible session ended it too, and now does not.** One caveat worth knowing, because it is a real trade: the protocol Moonlight speaks has no way for a host to announce a resolution change once a stream is running, so your client is not told. Most clients notice from the picture itself and adjust; a strict one — Media Foundation on Xbox is the known example — may stall instead and need reconnecting. That is the same bargain these sessions already take whenever the host's picture and the client's request disagree, and it is strictly better than what it replaces, which was every such stream ending.
- **Moonlight-compatible sessions stuttered at high frame rates, and the host was doing it to itself.** When a client loses its place in the video it asks the host for a complete picture to start again from, and the host is supposed to ignore repeat requests that arrive too quickly. The gap it waited for was measured in frames rather than in time, which at 120 frames a second is about a sixtieth of a second — far shorter than the time a client needs to ask, receive and decode — so the requests never looked like repeats and nearly all of them were honoured. One field session recorded 1,118 such requests in 91 seconds and honoured 1,115: a complete picture roughly every tenth frame, each one large enough to saturate the connection, causing the loss that prompted the next request. It reads as heavy stutter while every latency figure stays flat, because frames are being lost rather than delayed. It also looked like a codec fault, because the same session's H.264 stream — encoded by a different part of the host — asked twice in the whole session and was completely clean. The host now waits a fixed tenth of a second before honouring another request. The field case was a 120-frame session, but the old window was too short at 60 as well, so this is not only a fix for high-refresh displays.
- **Fire TV: a DualSense had buttons that never reached the game, and its touchpad click and Mute button did nothing.** Three separate faults on one controller, all reported together over Bluetooth. Some of its buttons were being labelled by the system as coming from a keyboard rather than a controller, and the app was dropping them on that basis — it now trusts what the *device* is rather than the system's per-press guess, and only ever for keycodes that are genuinely controller buttons, so a remote's Back button and a keyboard's arrow keys are untouched. The touchpad click and the Mute button had nowhere to go at all and were simply discarded; both now travel to your game. And Mute genuinely mutes your microphone, once per press — held down, it no longer flickers the microphone on and off — on controllers that actually have the button.
- **Android: the app could crash outright while playing, most often on an NVIDIA Shield.** The system call the app used to pick up the newest video frame hands back a resource it has already given away when more than one frame arrives at once, which the system's own safety check then catches by killing the app. It is a bug in Android that is still unfixed upstream, so the app stops using that call and picks the newest frame itself.
- **Linux: after disconnecting, the box's own screen could stay black.** Reported on both Bazzite and Nobara. The hand-back at the end of a session asked the system to bring the desktop session back and then walked away the moment the request was accepted — but "the request was accepted" and "the screen is showing something" are different questions, and nothing had ever asked the second one, so every way of ending up dark looked identical to success. It now checks: if the box is still dark twenty-five seconds after the hand-back, it works through a ladder of increasingly firm remedies, each of which was measured on real machines of both families, and if it still cannot fix it, it says exactly what a human should run. This is not a guess at one trigger — the specific fault people reported could not be reproduced. It closes the gap that lets *any* trigger end as a dark panel.
- **Windows: duplicate "Punktfunk Speakers" and "Punktfunk Microphone" devices piled up in your sound settings.** Creating one of these is two steps, and a host that died between them left behind a fully working device with no ownership mark on it. Nothing ever recognised that afterwards, so the next start created a second one and the stray outlived it — and because uninstalling also went by the ownership mark, uninstalling did not remove it either. One field machine showed exactly this. The host now recognises a stray from a previous run and adopts it instead of creating another, and uninstalling sweeps up ones already on the machine. Separately, on machines where the usual naming route is blocked, the microphone's name was being written to a location that only exists for speakers, so it silently kept the driver's default name.
- **Steam Deck: HDR stopped working after updating to 0.31.2 on a source install.** Two faults with one symptom. The build of the patched compositor had been failing since mid-August on a missing system package — added here — and the failure path then went on to *unlink the compositor that was already installed and working*. A build that never produced anything replaced nothing, so removing the perfectly good previous one meant the host fell back to the system's own compositor and fixed the session at 8-bit, which cannot be taken back once a session has started. A failed build now leaves the working installation alone.
## Thanks
Almost everything above came from someone reporting exactly what they saw and on what — the game they launched and the dialog it hung on, the two 4K boxes that felt slow, the make of controller and which button did nothing, the card and the frame rate, the fourteen seconds before a picture appeared. Two entries are worth calling out for a different reason. The Linux black-screen fix ships *without* a reproduction: five scenarios were run across both distributions on real machines, the mechanism first proposed was disproved, and rather than guess, the fix closes the gap that lets any cause end the same way. And the DualSense work was re-implemented from a contributor's diagnosis rather than merged as sent — all three faults were real and correctly identified, but each proposed fix reached further than the hardware that needed it. The diagnosis was the hard part and it was right. Thank you.
## For developers
Protocol, ABI, driver and embedder detail — including the version table — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.31.3/CHANGELOG.md).
The short version: nothing versioned moves. The streaming protocol, the embedding interface, the driver protocol, the gamepad channel and the add-on contract are exactly where 0.31.2 left them — `include/punktfunk_core.h` has no diff at all against the v0.31.2 tag — and no header, package or plugin needs rebuilding, re-pairing or re-publishing in any direction. The one surface that grows is the management API, additively: a `PATCH /api/v1/clients/{fingerprint}` route sets or clears a paired client's label, and `GET /clients` gains a `label` field alongside the existing certificate subject. Nothing existing changed shape, so a consumer that ignores both is unaffected. The TypeScript SDK is re-cut as `@punktfunk/host` 0.1.6 so an add-on can actually reach the generated types for that route; the add-on toolkit is unchanged. One dependency moves for a security advisory (`h2`, lockfile-only), and one behaviour worth knowing about if you integrate: the host now reports a game as running a few seconds later than it used to when it identifies that game by scanning processes rather than by a plugin's own report.
+48
View File
@@ -0,0 +1,48 @@
Wire-compatible with 0.31.x — everything you have already paired keeps working, and you can update one side at a time. Nothing here changes how a host and a client agree on what to send each other, so an old client on a new host, or the other way round, streams exactly as it does today.
This is a fix release about the machine you stream *from* behaving itself while you are not in the room with it. Asking for your screens off during a stream only ever worked on a box running a KDE desktop — a Steam Deck, or anything else sitting in Game Mode, kept its television lit for the whole session, playing your game to an empty room. That works now on every kind of Linux box, including ones with no desktop at all. Putting that box to sleep or shutting it down from Steam's own power menu had stopped working during a stream, in two separate ways, and works again. On the client side, a long session on a connection that was never the problem could slide from 57 Mbps down to 5 and stay there; the Mac app could quit outright the moment a stream started; and the web console served a certificate browsers refuse, so the page you are told to open did not load, while on Linux the tray insisted the console was dead the whole time it was running perfectly. There is a little new work too: the gamepad console's bitrate reaches 2 Gbps and takes a number you type, and on Linux there is finally a way into that console that is not a command line.
## TL;DR
- **"Turn my screens off while I stream" now actually turns them off.** It only ever worked on a KDE desktop. A Steam Deck or any other box in Game Mode ignored it silently, which is the deployment where you most want it. It works there, and on sway and Hyprland desktops too. GNOME is the one that still cannot be served, and the host now says so instead of leaving you guessing.
- **Steam's power menu did nothing during a stream. Sleep, Restart and Shut Down all work again.** Two unrelated faults with one symptom: your host was refusing every suspend request for the length of a stream, including yours, and on some setups the box lost the permission to power itself off at all while it was streaming.
- **A long session could slide to the worst picture quality on a perfectly good connection.** One 1440p session walked from 57 Mbps down to 5 over ten minutes without dropping a single packet, and never recovered. Both causes are fixed.
- **Mac: the app could quit the instant a stream started.** Not at launch — at the moment you connected, which made it look like the host's fault.
- **The web console would not open in a browser**, and on Linux the tray labelled it "not responding" while it was serving fine.
- **New:** the gamepad console's bitrate goes to 2 Gbps, steps finely at the low end, and takes a typed number; on Linux the gamepad console gets a button, a menu entry and its own app-grid launcher.
## Before you update
- **Only if your host takes the box over by stopping its login screen, and you want Steam's power menu to work:** be in the punktfunk group. That is the same group the takeover already needs to do its job, so if the takeover works for you today you are already in it and there is nothing to do. If you set that up some other way, join it — the docs page titled Steam / gamescope carries the one-line command — because the permission this release adds is granted to that group and nobody else, deliberately.
## New
- **Bitrate in the gamepad console reaches 2 Gbps, and you can type a rate.** The picker had seven rungs stopping at 80 Mbps since the console shipped, which someone ran straight into — and the desktop app beside it has always gone far higher, so the two disagreed about what your own machine was allowed to ask for. There are 30 rungs now, from 1 Mbps to 2 Gbps: tight at the bottom, where a single step decides whether a thin connection is watchable, and coarse at the top, where a step is noise. Press Y on that row to type a rate instead, using the same on-screen keyboard as the add-host and pairing fields, including Steam's own keyboard on a Deck. And a rate that is not one of the rungs — typed here, or set from the desktop app — now steps to its neighbour instead of being thrown away and snapped back to Automatic by a single nudge. The desktop app gets the other half of the same complaint: its bitrate box steps 1 Mbps at a time instead of 5, so 3, 4 and 6 are reachable without typing.
- **Linux: a front door to the gamepad console.** The couch interface has shipped for a while, but on Linux the only way to reach it was a command-line switch, which is not something you find by looking. There are three ways in now: a gamepad button in the app's host list, a "Console UI" entry in its menu, and a separate launcher in your applications grid that you can also add to Steam as a non-Steam game so it opens straight from the couch. All three land in the same place. This comes with the packaged builds; a from-source Nix build still does not include the separate launcher, because that build leaves the console interface out.
## Improved
- **The adaptive bitrate control no longer gives up on a whole session.** Part of the picture-quality fix below: when your client decided the host's encoder was the bottleneck and stopped listening to that signal, it stopped listening for the rest of the session. But the thing that provokes it is temporary by nature — a game hammering the graphics card, a shader storm, a second program on the same card — and all of those end. It now starts listening again after a clean stretch, and if the noise comes straight back it backs off for longer each time rather than flapping.
- **When your host cannot turn a screen off, it says so.** Every one of those refusals means "you asked for your screens off and they stayed on", and the most common one was being recorded at a level nobody reads. It is now a warning, with the reason.
## Fixed
- **Your screens stayed lit through the whole stream, on every Linux box except a KDE desktop.** Reported on Nobara: launching a game from the library with screens-off selected left the operator's monitor showing live Game Mode for the entire session. Three separate things were wrong. The setting was implemented by asking the desktop to turn its screens off — right when there *is* a desktop to ask, and a box in Game Mode has none, which is precisely the box whose television you want dark. That case is now handled by switching the display off through the graphics hardware directly, which needs no extra privileges and cannot leave a screen dark if the host crashes, because the screen comes back the moment the host lets go. A sway or Hyprland desktop was not served either, and now is, through each one's own controls, taking care to leave any screen it did not darken alone rather than "fixing" one that was already off. And separately, the part of the host that moves the box's own gaming session out of the way only did so for a Steam launch, because freeing Steam was the only reason anyone had ever needed it — so a non-Steam game from your library left that session running and driving the television regardless. Wanting your screens off is now a second, independent reason to move it aside. **GNOME remains a genuine gap, not an oversight:** it offers applications no way to turn a screen off, and holds the graphics card itself so the direct route is refused. The host warns instead of pretending.
- **The recommended way of streaming a Game Mode box ignored the setting entirely.** The route that gives the best experience — and the only one that can serve your client its own screen at its own resolution — was the one route that never darkened anything, because there was nowhere safe to undo it and a screen left dark after every stream is worse than one left lit. The undo now lives in the single teardown every one of those sessions passes through, so it is honoured. A reconnect does not darken twice, and a session that keeps running between connections stays dark rather than flickering.
- **Steam's power menu did nothing during a stream, and neither did Sleep.** Two independent faults, one symptom, both on Linux. Your host holds a lock for the length of a stream so the box cannot suspend out from under someone who is only watching and never touching anything — but that lock refuses *every* suspend, including the one you asked for by pressing Sleep, and nothing in Steam's interface shows the refusal, so the entry simply did nothing for as long as anyone was connected. The lock is now held only while the stream is genuinely quiet: any input from any client drops it immediately, so a Sleep press cannot lose the race, and it comes back after 30 seconds of silence. The case it was written for is unchanged — a passive viewer sends nothing, and the box still will not sleep under them. The second fault is Shut Down and Restart, on the takeover setups that stop the box's login screen: doing that leaves the box with no signed-in session, and the system's rules only allow powering off from one. All three entries quietly became a password prompt — asked of something that cannot answer, on a screen that is switched off. The packages now ship a rule granting exactly those three actions, to the punktfunk group and to nobody else, and only those three: a box with a second person logged in still asks first.
- **A long session slid to the lowest picture quality on a connection that never lost a packet.** Reported from a 1440p60 session that ratcheted 57 Mbps down to 5 over ten minutes, with the host granting every rate it was asked for and nothing at all going wrong on the link. Two causes, both in the part of your client that watches how long the host is taking to compress each frame. Its thresholds were tuned on a 120-frames-a-second session, where one frame is 8 milliseconds; on a 60-frame session a frame is twice that, so an ordinary one-frame hiccup cleared the "something is badly wrong" bar that the identical hiccup at 120 never reached. They are now measured against the session's own frame budget. And the signal was firing for something cutting the rate cannot fix: it exists to find the point where the encoder runs out of compute, where less rate means less work — but when a game is saturating the graphics card that premise is false, the reduction changes nothing, and it would fire again a moment later. Two reductions in a row that buy no improvement now stand the signal down. Everything else your client watches — packet loss, delay, decoding, and the host's own refusal to climb — keeps its full authority throughout, so a real encoder limit is still caught.
- **Mac: the app could quit outright the moment a stream started.** Not at launch, which is what made it look like a host problem. Setting up your microphone takes a snapshot of the input device's audio format and then hands that snapshot back a moment later, and on a Mac the input can change underneath in that window — you switch devices, something changes the sample rate, or the app itself switches to the device you chose two lines earlier. Any mismatch was fatal and could not be caught. It now asks the system to use whatever format the device actually has, so there is no snapshot to go stale, and follows the device if it changes rate mid-stream instead of resampling by the wrong ratio and pitch-shifting your voice.
- **The web console would not load in a browser, and the tray called it dead.** A Linux operator saw "Open web console (not responding)" in the tray, beside a tooltip that said the host was idle — which was the clue, because that tooltip proved the host was answering perfectly on the very same check. Your host has kept two identities for a while now: a modern one that everything current uses, and an older one kept unchanged so Moonlight-compatible clients keep working. The web console never followed that split, and none of the things that start it could choose — so it served the old identity, which no browser will accept, and the page you were told to open failed to load. Meanwhile the tray was checking on the console using the *modern* identity, so its check was refused and a completely healthy console was labelled as not responding. The console now serves the modern identity whenever the host has one, and the tray's check no longer insists on any particular one, because it reads nothing, sends nothing, and decides only what a menu entry says. A host that predates the split, or one where you supplied your own certificate, is unaffected and behaves exactly as before.
- **Hyprland: the request to turn a screen off is a toggle, and would have turned a dark screen back on.** Found on a live Hyprland box rather than in review, and worth saying because the first version of this fix was wrong in both directions: the command ignores whether you asked for on or off and simply flips whatever the screen is currently doing. So "turn it off now, turn it back on later" would have lit a screen that was already dark at the start of a stream, and darkened a lit one at the end — the operator's screen left off after streaming, which is the exact failure this whole feature exists to avoid. Your host now reads the screen's real state first, acts only if it differs, and checks afterwards that it worked. A screen already in the state you wanted is left completely alone.
## Thanks
Every fix above started as somebody saying exactly what they saw. The monitor still showing Game Mode through a whole stream, and which kind of game they had launched. The power menu that did nothing, on which box. The 1440p session whose quality walked downhill for ten minutes while every other number stayed flat — that report came with the measurements that separated the two causes, which is the hard half. The bitrate ceiling somebody hit and pointed out was lower than the app right next to it. The Mac that quit on connect. And the tray that said "not responding" beside a tooltip reading "idle" — noticing that those two things contradicted each other is what found the bug. Thank you.
One caveat we would rather write down than let you discover: the sway arm of the screens-off work has not been run against a real sway session — there is no sway machine in the lab. Hyprland, KDE, and the no-desktop route were all measured on real hardware. If you stream from sway, we would like to hear how it goes.
## For developers
Protocol, ABI, driver and embedder detail — including the version table — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.31.4/CHANGELOG.md).
The short version: **nothing versioned moves, at all.** The streaming protocol, the embedding interface, the driver protocol, the gamepad channel, the add-on contract and the management API are exactly where 0.31.3 left them — the embedding header is byte-identical for the third release running, and the API description changes only its stamped version number. No header, package, plugin or integration needs rebuilding, re-pairing or re-publishing in any direction, and the add-on toolkit and TypeScript package are both untouched. Two behaviour changes are worth knowing about if you integrate: the host's sleep veto is now conditional on stream quiet rather than held for the whole session, and the client's encode-attributed backoff both re-arms after standing down and sizes its thresholds from the negotiated refresh rather than in absolute milliseconds.
+4
View File
@@ -0,0 +1,4 @@
• Fixes every console-only setting doing nothing when you changed it — low latency, rumble, motion and controller capture all went nowhere.
• Fixes controllers pressing the wrong buttons on boxes Android has no layout for — on a Fire TV a DualSense's L2 arrived as L1.
• The controller console can now be switched off from inside it, and a TV remote can reach Settings and your library.
• The picture no longer sits in the top-left corner.
+2
View File
@@ -0,0 +1,2 @@
• Fixes controllers pressing the wrong buttons after the last update — on a GameSir G8+ and an Xbox Elite Series 2 over Bluetooth, X answered Y and both shoulders answered menu buttons.
• The button correction from the last release now applies only to controllers Android has no layout for, so a pad that worked before this update is left exactly as it was.
+4
View File
@@ -0,0 +1,4 @@
• Fixes the big latency jump on Android TV and Fire Stick — the app was asking your host for a frame rate your TV doesn't actually output. Setting the refresh by hand is no longer needed.
• A DualSense on Fire TV: buttons that never reached your game now do, and Mute mutes your mic.
• Fixes an app crash while streaming, most often on NVIDIA Shield.
• Smoother interface on 4K TVs and projectors, plus a new Reduce interface resolution switch.
+3
View File
@@ -0,0 +1,3 @@
• Fixes a long stream sliding down to the worst picture quality on a connection that was never the problem — and staying there. One session walked from 57 Mbps to 5 over ten minutes without losing a packet.
• Bitrate now reaches 2 Gbps instead of stopping at 80 Mbps, and steps finely at the low end where one step decides whether a thin connection is watchable.
• Press Y on the Bitrate row to type an exact rate. A rate that isn't a preset step no longer snaps back to Automatic.
+8
View File
@@ -235,6 +235,11 @@ package_punktfunk-host() {
"$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
sed -i 's#/usr/libexec/punktfunk/pf-dm-helper#/usr/lib/punktfunk/pf-dm-helper#' \
"$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
# ...and the other half of stopping a display manager: with it stopped the box has no active
# local session, so logind's power actions fall to auth_admin_keep and Steam's power menu goes
# quiet mid-stream. No path annotation to rewrite — a .rules file names a group, not a binary.
install -Dm0644 "$R/packaging/linux/49-punktfunk-power.rules" \
"$pkgdir/usr/share/polkit-1/rules.d/49-punktfunk-power.rules"
# Web-console-triggered updates (host-update-from-web-console.md §7): root helper + oneshot
# unit + group-scoped polkit rule. Same no-libexec relocation as pf-dm-helper, with the
# unit's ExecStart rewritten to match. On pacman the helper additionally requires the
@@ -360,6 +365,9 @@ package_punktfunk-client() {
"$pkgdir/usr/share/icons/hicolor/scalable/apps/io.unom.Punktfunk.svg"
install -Dm0644 "$R/packaging/linux/io.unom.Punktfunk.desktop" \
"$pkgdir/usr/share/applications/io.unom.Punktfunk.desktop"
# Second launcher, straight into the gamepad console (`--browse`) — the couch entry point.
install -Dm0644 "$R/packaging/linux/io.unom.Punktfunk.Console.desktop" \
"$pkgdir/usr/share/applications/io.unom.Punktfunk.Console.desktop"
# DualSense hidraw access (full pad fidelity through SDL's HIDAPI driver).
install -Dm0644 "$R/scripts/70-punktfunk-client.rules" \
"$pkgdir/usr/lib/udev/rules.d/70-punktfunk-client.rules"
+4
View File
@@ -75,6 +75,10 @@ install -Dm0755 "$SESSION_BIN" "$STAGE/usr/bin/punktfu
install -Dm0755 "$CLI_BIN" "$STAGE/usr/bin/punktfunk"
install -Dm0644 packaging/linux/io.unom.Punktfunk.desktop \
"$STAGE/usr/share/applications/io.unom.Punktfunk.desktop"
# Second launcher, straight into the gamepad console (`--browse`): the couch entry point a
# TV/HTPC user picks from the app grid, and what gets added to Steam as a non-Steam game.
install -Dm0644 packaging/linux/io.unom.Punktfunk.Console.desktop \
"$STAGE/usr/share/applications/io.unom.Punktfunk.Console.desktop"
# The app icon the desktop entry (and the About dialog) name. Without it the launcher falls
# back to a generic monitor glyph, which is what shipped until now.
install -Dm0644 packaging/linux/icons/hicolor/scalable/apps/io.unom.Punktfunk.svg \
+4
View File
@@ -100,6 +100,10 @@ done
install -Dm0755 scripts/pf-dm-helper "$STAGE/usr/libexec/punktfunk/pf-dm-helper"
install -Dm0644 scripts/io.unom.punktfunk.dm-helper.policy \
"$STAGE/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
# ...and the other half of stopping one: with the DM stopped the box has no active local session,
# so logind's power actions fall to auth_admin_keep and Steam's power menu goes quiet mid-stream.
install -Dm0644 packaging/linux/49-punktfunk-power.rules \
"$STAGE/usr/share/polkit-1/rules.d/49-punktfunk-power.rules"
# vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads).
install -Dm0644 scripts/punktfunk-modules.conf "$STAGE/usr/lib/modules-load.d/punktfunk.conf"
# UDP socket-buffer tuning (32 MB) — without it the kernel clamps the host's SO_SNDBUF to ~416 KB
+5
View File
@@ -350,6 +350,11 @@ modules:
# Desktop entry (renamed to the app id; Exec is the in-sandbox binary).
- install -Dm0644 packaging/flatpak/io.unom.Punktfunk.desktop
${FLATPAK_DEST}/share/applications/io.unom.Punktfunk.desktop
# Second launcher, straight into the gamepad console (`--browse`) — the couch entry
# point. Shared with the deb/rpm/arch (there is nothing sandbox-specific about it);
# flatpak's export rewrites Exec into `flatpak run --command=punktfunk-client …`.
- install -Dm0644 packaging/linux/io.unom.Punktfunk.Console.desktop
${FLATPAK_DEST}/share/applications/io.unom.Punktfunk.Console.desktop
# AppStream metainfo (required for a well-formed flatpak / Software listings).
- install -Dm0644 packaging/flatpak/io.unom.Punktfunk.metainfo.xml
${FLATPAK_DEST}/share/metainfo/io.unom.Punktfunk.metainfo.xml
+6 -1
View File
@@ -19,7 +19,7 @@ pkgname=punktfunk-gamescope
# bump it with the marker so pacman sees a new version when only our patches moved.
_gsver=3.16.25
_gsrev=5fb8dce4a09d0a68d097b9faf9513782106bc843
pkgver="${_gsver}.pfhdr7"
pkgver="${_gsver}.pfhdr8"
# 2: patch 0006 (never destroy the Vulkan device/output at exit). No capability moved, so the
# `.pfhdrN` level deliberately stays put — see README.md.
# 3: pin moved 8c676c39 -> 5fb8dce4 (3.16.25-1 -> 3.16.25-11), which brings upstream's own
@@ -45,6 +45,11 @@ pkgver="${_gsver}.pfhdr7"
# racing steamcompmgr's vulkan_screenshot on the same device — a SIGSEGV precisely in the linger
# window, so a kept display was dead and reconnect lost the game session. No capability the host
# probes for, but "reconnect lost my game" triage has to read the difference off the banner.
#
# pfhdr8 / rel 1: patch 0010 gives the seat's stub keyboard the compiled `XKB_DEFAULT_*` keymap, so
# a session follows the box's configured layout instead of typing US characters. The host PROBES the
# banner for this one (`>= 8`), so the level HAS to move here too — leaving it at pfhdr7 made pacman
# see no upgrade at all, and every Arch/SteamOS host kept a compositor the probe rejects.
pkgrel=1
pkgdesc="gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming"
arch=('x86_64' 'aarch64')
@@ -109,6 +109,16 @@ echo "==> configuring"
# (gamescope's own meson.build hard-errors if libliftoff/vkroots are missing from this list, so
# all three go together.)
#
# **libdisplay-info is in the list for exactly the wlroots reason**, learned the hard way on the
# SteamOS VM 2026-08-23: it is a vendored submodule too, so a build box that merely HAS
# libdisplay-info-dev makes meson link it SHARED, and the binary then dies on SteamOS with
# `libdisplay-info.so.2: cannot open shared object file` — it builds, it installs, it prints its
# +pfhdr banner in the box, and build-gamescope.sh's on-glass check is the only thing between that
# and a host promising HDR it cannot deliver. Debian trixie has the -dev package, Fedora and Arch
# have it too, and any of them can pull it in transitively, so "don't install it" is not a fix
# that holds. Pinning the fallback makes the outcome the same everywhere, which is the whole
# point of this list.
#
# The C++ runtime goes STATIC for the same reason wlroots does: this binary is built on a ROLLING
# distro and has to start on a FROZEN one. Arch's gcc (16.1.1 when this was written) makes the
# compositor require `GLIBCXX_3.4.35`, and SteamOS 3.8.16 ships libstdc++ 3.4.34 — so the published
@@ -124,7 +134,7 @@ export LDFLAGS="${LDFLAGS:-} -static-libstdc++ -static-libgcc"
meson setup "$BUILD" "$SRCDIR" \
--prefix="$PREFIX" \
--buildtype=release \
-Dforce_fallback_for="libliftoff,vkroots,wlroots${EXTRA_FALLBACK:+,$EXTRA_FALLBACK}" \
-Dforce_fallback_for="libliftoff,vkroots,wlroots,libdisplay-info${EXTRA_FALLBACK:+,$EXTRA_FALLBACK}" \
-Dpipewire=enabled \
-Denable_tests=false \
-Denable_openvr_support=false \
+41
View File
@@ -0,0 +1,41 @@
// Members of the `punktfunk` group may power the box off, restart it, or put it to sleep even when
// the box has no ACTIVE LOCAL SESSION — which is exactly the state a display-manager takeover
// leaves it in for the length of a stream.
//
// Why this is needed at all: logind ships `power-off`/`reboot`/`suspend` as `allow_active: yes`,
// and polkit decides "active" from the caller's own logind session — falling back to the user's
// elected DISPLAY session when the caller has none, which is every `systemd --user` unit including
// the managed gamescope session. A takeover that STOPS the display manager removes that session
// (logind elects a display session only from `user`/`greeter` class ones, never from the user
// manager's), the fallback then finds nothing, and all three actions become `auth_admin_keep`: an
// interactive password prompt, asked of a non-interactive caller, on a screen that is switched off.
// Nothing surfaces the refusal, so the symptom is a power menu that does nothing at all.
//
// That menu is the reason this file exists. On SteamOS-like boxes Steam does not call logind for
// "Shut Down" — it writes `$STEAMOS_STEAM_SHUTDOWN_SENTINEL` and exits, and `gamescope-session-plus`
// runs a plain `poweroff` once Steam is gone. During a stream that wrapper is OURS, running in the
// session-less transient unit, so its `poweroff` is the call polkit refuses. Measured on Bazzite,
// 2026-08-24: the identical `pkcheck --action-id org.freedesktop.login1.power-off` from a
// `systemd --user` unit answers authorized with the display manager up, and `auth_admin_keep` with
// it stopped.
//
// The group ships EMPTY and joining it is a deliberate act. It is the same group the takeover's own
// root helper (`io.unom.punktfunk.dm-helper`) authorizes on, and a takeover that stops a display
// manager cannot work without that helper — so this grants to exactly the population the fault
// reaches, and to nobody else.
//
// Scope notes: the three actions are the three entries in Steam's power menu, so a grant that stops
// there authorizes "use the power menu on the box you are streaming from" and nothing else. The
// `-multiple-sessions` variants are deliberately NOT granted — a box with a second USER logged in
// still asks before it powers off under them — and neither are the `-ignore-inhibit` ones, which
// would let this override somebody else's block inhibitor rather than just our absent session.
polkit.addRule(function (action, subject) {
if (
(action.id == "org.freedesktop.login1.power-off" ||
action.id == "org.freedesktop.login1.reboot" ||
action.id == "org.freedesktop.login1.suspend") &&
subject.isInGroup("punktfunk")
) {
return polkit.Result.YES;
}
});
@@ -0,0 +1,14 @@
[Desktop Entry]
Type=Application
Name=Punktfunk Console
Comment=Controller-driven couch interface browse hosts and stream with a gamepad
# The shell already execs `punktfunk-session --browse` for this argv, so the shortcut goes
# through the same binary the main entry uses (which is also what flatpak's Exec rewrite
# expects). A couch UI is fullscreen; the session fullscreens itself on the Deck and under
# gamescope anyway.
Exec=punktfunk-client --browse --fullscreen
Icon=io.unom.Punktfunk
Terminal=false
Categories=Network;Game;
Keywords=streaming;remote;game;gamepad;controller;couch;console;bigpicture;
StartupNotify=true
+4 -1
View File
@@ -410,7 +410,10 @@ The shell exports an
session binary's *optional* on-glass stats overlay is absent, and the **GTK shell
(`punktfunk-client`) is skia-free and fully featured.** Re-adding it means teaching skia-bindings
to consume a prebuilt Skia offline (a fixed-output derivation of the rust-skia tarball) or a
vendored from-source Skia build — a tracked follow-up.
vendored from-source Skia build — a tracked follow-up. For the same reason this build does **not** install
`io.unom.Punktfunk.Console.desktop` (the deb/rpm/arch/flatpak couch launcher): it runs
`--browse`, which needs that feature, so the entry would be a launcher that only prints an
error.
- **`nix flake check` does NOT check the NixOS module — that is why `module-check.nix` exists.**
For `nixosModules`, nix forces the value and asserts it is a lambda taking an open attribute set,
+5
View File
@@ -667,6 +667,11 @@ in
HOST = "0.0.0.0";
# Serve HTTPS with the host's own identity cert (the anchor native clients already pin) and
# mark the session cookie Secure. The host's `serve` writes these PEMs.
#
# These name the LEGACY pair; the server prefers the native sibling
# (native-cert.pem/native-key.pem) when it exists, because a generated unit cannot express
# "this file, else that one" any more than the hand-written one can. The choice is made in
# web/nitro-entry/tls-paths.mjs — keep this in step with scripts/punktfunk-web.service.
PUNKTFUNK_UI_TLS_CERT = "%h/.config/punktfunk/cert.pem";
PUNKTFUNK_UI_TLS_KEY = "%h/.config/punktfunk/key.pem";
PUNKTFUNK_UI_SECURE = "1";
+9
View File
@@ -350,6 +350,10 @@ install -Dm0644 scripts/alsa-ucm2/USB-Audio/Punktfunk/DualSense-PS5-Haptic-HiFi.
# polkit rule. The helper derives the DM unit itself — callers can't name arbitrary units.
install -Dm0755 scripts/pf-dm-helper %{buildroot}%{_libexecdir}/punktfunk/pf-dm-helper
install -Dm0644 scripts/io.unom.punktfunk.dm-helper.policy %{buildroot}%{_datadir}/polkit-1/actions/io.unom.punktfunk.dm-helper.policy
# ...and the other half of stopping a display manager: with it stopped the box has no active local
# session, so logind's own power actions fall to auth_admin_keep and Steam's power menu goes quiet
# mid-stream. Scoped to the same (shipped-empty) punktfunk group the helper above gates on.
install -Dm0644 packaging/linux/49-punktfunk-power.rules %{buildroot}%{_datadir}/polkit-1/rules.d/49-punktfunk-power.rules
# vhci-hcd autoload — the usbip transport that makes the virtual Steam Deck controller a
# real USB device (Steam Input only adopts those; the UHID fallback is invisible to Steam).
@@ -423,6 +427,9 @@ install -Dm0755 target/release/punktfunk-session %{buildroot}%{_bindir}/punktfun
install -Dm0755 target/release/punktfunk %{buildroot}%{_bindir}/punktfunk
install -Dm0644 packaging/linux/io.unom.Punktfunk.desktop \
%{buildroot}%{_datadir}/applications/io.unom.Punktfunk.desktop
# Second launcher, straight into the gamepad console (`--browse`) — the couch entry point.
install -Dm0644 packaging/linux/io.unom.Punktfunk.Console.desktop \
%{buildroot}%{_datadir}/applications/io.unom.Punktfunk.Console.desktop
# The app icon the desktop entry (and the About dialog) name. Without it the launcher falls
# back to a generic monitor glyph, which is what shipped until now.
install -Dm0644 packaging/linux/icons/hicolor/scalable/apps/io.unom.Punktfunk.svg \
@@ -612,6 +619,7 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/
%{_libexecdir}/punktfunk/pf-update
%{_unitdir}/punktfunk-update.service
%{_datadir}/polkit-1/rules.d/49-punktfunk-update.rules
%{_datadir}/polkit-1/rules.d/49-punktfunk-power.rules
%{_datadir}/polkit-1/actions/io.unom.punktfunk.dm-helper.policy
%{_prefix}/lib/modules-load.d/punktfunk.conf
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
@@ -640,6 +648,7 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/
%{_bindir}/punktfunk-session
%{_bindir}/punktfunk
%{_datadir}/applications/io.unom.Punktfunk.desktop
%{_datadir}/applications/io.unom.Punktfunk.Console.desktop
%{_datadir}/icons/hicolor/scalable/apps/io.unom.Punktfunk.svg
%{_udevrulesdir}/70-punktfunk-client.rules
%{_prefix}/lib/sysctl.d/99-punktfunk-client-net.conf
+111
View File
@@ -0,0 +1,111 @@
# shellcheck shell=bash
# Does this box already have every Flathub dep a flatpak manifest declares?
# bash scripts/ci/flatpak-deps-present.sh <manifest.yml> -> exit 0 = yes, 1 = no
# bash scripts/ci/flatpak-deps-present.sh --self-test -> run the asserts below
#
# WHY THIS EXISTS: flatpak.yml used to prefetch deps with `flatpak-builder --install-deps-only`,
# which does NOT mean "install what is missing". builder_manifest_install_dep() branches on
# `flatpak info --show-commit <ref>` succeeding and runs `flatpak update` for every dep that IS
# installed (a failed update is fatal there — it never falls back to install) — and
# ci/flatpak-ci.Dockerfile bakes the whole runtime set, so on a healthy run that flag did nothing
# except make the build depend on Flathub being up at that minute. On 2026-08-22 it took the job
# down: dl.flathub.org returned HTTP 404 for one .filez object of the then-current
# rust-stable//25.08 commit, identically on all 10 retry.sh attempts (~9 min), and flatpak-builder
# segfaulted on its own error path (rc=139) so the retry wrapper could not tell a dead end from a
# blip. Nothing about the build wanted that newer commit: the manifest pins a runtime VERSION, not
# a commit, and the baked one satisfies it.
#
# So the workflow asks this first and only reaches for Flathub on a real miss.
#
# FAILS OPEN, deliberately: an unreadable/unexpected manifest reports "not present" (1), so the
# caller does the full install. Silently skipping the install on a manifest we stopped
# understanding is how you build against the wrong runtime.
set -uo pipefail
deps_present() {
local manifest="$1" runtime rt_ver sdk exts e
runtime=$(sed -n 's/^runtime: *//p' "$manifest" | head -1)
rt_ver=$(sed -n 's/^runtime-version: *//p' "$manifest" | tr -d "\"'" | head -1)
sdk=$(sed -n 's/^sdk: *//p' "$manifest" | head -1)
exts=$(sed -n '/^sdk-extensions:/,/^[^ #-]/p' "$manifest" | sed -n 's/^ *- *//p')
[ -n "$runtime" ] && [ -n "$rt_ver" ] && [ -n "$sdk" ] && [ -n "$exts" ] || return 1
flatpak info --user "$runtime//$rt_ver" >/dev/null 2>&1 || return 1
flatpak info --user "$sdk//$rt_ver" >/dev/null 2>&1 || return 1
# Extensions are checked for PRESENCE, not version: flatpak-builder resolves their version from
# the SDK's own metadata (it prints "Dependency Extension: … 25.08"), never from the manifest.
# Any bump that moves them moves runtime-version too, which the two checks above already catch.
for e in $exts; do
flatpak info --user "$e" >/dev/null 2>&1 || return 1
done
}
self_test() {
local rc fails=0 full
# NOT `local`: the EXIT trap fires after this function has returned.
SELFTEST_TMP=$(mktemp -d) || return 1
trap 'rm -rf "$SELFTEST_TMP"' EXIT
local tmp="$SELFTEST_TMP"
cat > "$tmp/ok.yml" <<'YML'
runtime: org.gnome.Platform
runtime-version: '50'
sdk: org.gnome.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
- org.freedesktop.Sdk.Extension.llvm20
command: punktfunk-client
YML
# A manifest this script cannot read (the fail-open case).
printf 'app-id: io.unom.Punktfunk\n' > "$tmp/unparseable.yml"
# Stub `flatpak`: $INSTALLED is the newline-separated set of refs it admits to having.
mkdir -p "$tmp/bin"
cat > "$tmp/bin/flatpak" <<'STUB'
#!/usr/bin/env bash
# only `flatpak info --user <ref>` is exercised here
# args are: info --user <ref>
[ "$1" = info ] || exit 0
printf '%s\n' "$INSTALLED" | grep -qxF "$3"
STUB
chmod +x "$tmp/bin/flatpak"
PATH="$tmp/bin:$PATH"
check() { # <expected rc> <label> <installed set> <manifest>
INSTALLED="$3" deps_present "$4"; rc=$?
if [ "$rc" != "$1" ]; then
echo "FAIL: $2 (expected rc=$1, got $rc)" >&2; fails=$((fails + 1))
else
echo "ok: $2"
fi
}
full='org.gnome.Platform//50
org.gnome.Sdk//50
org.freedesktop.Sdk.Extension.rust-stable
org.freedesktop.Sdk.Extension.llvm20'
check 0 "everything baked -> skip Flathub" "$full" "$tmp/ok.yml"
check 1 "cold box -> install" "" "$tmp/ok.yml"
check 1 "runtime missing -> install" "${full/org.gnome.Platform\/\/50/x}" "$tmp/ok.yml"
check 1 "sdk missing -> install" "${full/org.gnome.Sdk\/\/50/x}" "$tmp/ok.yml"
# The regression that started all this: llvm20 fine, rust-stable not.
check 1 "one sdk-extension missing -> install" "${full/*.rust-stable/x}" "$tmp/ok.yml"
# A runtime installed at ANOTHER version must not pass just because the name matches.
check 1 "runtime at the wrong version" 'org.gnome.Platform//51
org.gnome.Sdk//51
org.freedesktop.Sdk.Extension.rust-stable
org.freedesktop.Sdk.Extension.llvm20' "$tmp/ok.yml"
check 1 "unreadable manifest -> fail open" "$full" "$tmp/unparseable.yml"
[ "$fails" = 0 ] || { echo "$fails check(s) failed" >&2; return 1; }
echo "all checks passed"
}
case "${1:---help}" in
--self-test) self_test ;;
--help|-h) sed -n '2,4p' "$0"; exit 2 ;;
*) deps_present "$1" ;;
esac
+62
View File
@@ -0,0 +1,62 @@
#!/bin/sh
# Put a retrying `curl` first on PATH for the rest of the job.
#
# WHY THIS EXISTS: `scripts/ci/retry.sh` already wraps every single-shot network command in CI,
# for the reason documented there — the runner box runs many jobs in parallel and its network
# drops packets under that load. But one of the biggest fetches in this workspace is NOT ours to
# wrap: skia-bindings downloads ~19 MB of prebuilt Skia per target from inside its build script,
# with a bare `curl -sS -f -L` and no retry at all (build_support/binary_cache/utils.rs).
#
# When that transfer truncates the job does not fail with a network error. skia-bindings'
# `try_prepare_download` swallows it, prints `DOWNLOAD AND INSTALL FAILED`, and falls through to
# `STARTING A FULL BUILD` — a from-source Skia build that the CI containers carry no deps for.
# What the operator sees is a Gradle stack trace under "Clippy (Android target)" with the real
# cause 1,800 lines up. Measured on main 2026-08-22:
#
# DOWNLOAD AND INSTALL FAILED: curl error code: "18"
# curl stderr: "curl: (18) end of response with 17054400 bytes missing"
#
# (19,057,024 bytes on the wire; it got 2 MB before git.unom.io closed the connection. The same
# asset pulls fine from a dev box, so this is the load-shedding retry.sh was written for.)
#
# A shim is the only lever that reaches inside a build script. It is also the cheapest correct
# one: skia-bindings already passes `-C -` (resume) and caches the part-file under
# OUT_DIR/.cache, so a retry CONTINUES the truncated transfer instead of restarting it.
#
# Applies to every curl in the job, which is what we want — the workspace's other build-script
# fetches are single-shot too.
#
# POSIX sh on purpose: Gitea's act_runner executes a step's `run:` under `sh -e` (dash) inside
# the Linux job containers — see the shader-gate note in ci.yml for what assuming bash cost.
#
# Usage: sh scripts/ci/install-retrying-curl.sh
set -e
# Resolve the REAL curl before the shim is on PATH, and bake the absolute path into the shim —
# a shim that re-resolves `curl` by name would exec itself.
real_curl=$(command -v curl || true)
if [ -z "$real_curl" ]; then
echo "::warning::no curl on PATH — skipping the retrying-curl shim"
exit 0
fi
# RUNNER_TEMP (not /usr/local/bin): the job containers run as root but the macOS runner is a
# persistent host where a system dir is neither writable nor ours to litter.
shim_dir="${RUNNER_TEMP:-/tmp}/pf-retrying-curl"
mkdir -p "$shim_dir"
# --retry-all-errors is what makes this cover error 18: a truncated transfer is a *transfer*
# failure, not an HTTP status, so plain --retry (which only retries transient HTTP codes and
# connection errors) would let it through. Needs curl >= 7.71; the CI images are well past it.
cat > "$shim_dir/curl" <<EOF
#!/bin/sh
exec $real_curl --retry 5 --retry-delay 3 --retry-all-errors "\$@"
EOF
chmod +x "$shim_dir/curl"
if [ -n "${GITHUB_PATH:-}" ]; then
echo "$shim_dir" >> "$GITHUB_PATH"
echo "retrying curl installed: $shim_dir/curl -> $real_curl"
else
echo "::warning::GITHUB_PATH unset — shim written to $shim_dir but not on PATH"
fi
+8 -1
View File
@@ -2,7 +2,8 @@
#
# Installed by the punktfunk-web .deb to /usr/lib/systemd/user/. AUTO-WIRED — no env editing:
# it sources the host's mgmt token + the generated login password, serves HTTPS (HTTP/1.1 over TLS)
# with the host's own identity cert (~/.config/punktfunk/{cert,key}.pem), and points the /api proxy
# with the host's own identity cert (~/.config/punktfunk/native-{cert,key}.pem, falling back to the
# legacy {cert,key}.pem — see the PUNKTFUNK_UI_TLS_CERT note below), and points the /api proxy
# at the host's loopback HTTPS mgmt API. The self-signed cert is accepted only for that loopback hop,
# scoped inside the proxy code (Bun per-request TLS) — no process-wide NODE_TLS_REJECT_UNAUTHORIZED.
# Enable per user:
@@ -39,6 +40,12 @@ Environment=HOST=0.0.0.0
# Serve HTTPS (HTTP/1.1 over TLS) with the host's own identity cert; mark the
# session cookie Secure. The host's `serve` writes these PEMs; if absent at start the unit fails and
# Restart retries (same as the mgmt-token wait above) rather than silently serving plain HTTP.
#
# These name the LEGACY pair and the server prefers the native sibling
# (native-cert.pem/native-key.pem) whenever it exists — `Environment=` cannot express "this file,
# else that one", so the choice is made in web/nitro-entry/tls-paths.mjs, which is the one place
# every launcher routes through. Don't "fix" these to the native names: a host that never took the
# identity split has no native pair, and the fallback lives on the other side of this handoff.
Environment=PUNKTFUNK_UI_TLS_CERT=%h/.config/punktfunk/cert.pem
Environment=PUNKTFUNK_UI_TLS_KEY=%h/.config/punktfunk/key.pem
Environment=PUNKTFUNK_UI_SECURE=1
+32 -3
View File
@@ -68,6 +68,22 @@ log "Building punktfunk-gamescope (HDR 10-bit capture; ~5-10 min, best-effort)"
# the two lists in step). Provisioned here, not in install.sh's main pass, so a dep problem can
# only ever cost this feature. glm/stb come in as meson wraps; wlroots/libliftoff/vkroots/
# libdisplay-info are vendored submodules — none of those need packages.
#
# ⚠ The last two names are the WSI LAYER's, and x11-xcb's absence is why this leg failed on every
# Deck from 2026-08-13 (3ac4548c turned `-Denable_gamescope_wsi_layer=true` on) until it was
# noticed as "HDR stopped working after an update". It does NOT fail the compositor build — it
# fails layer/meson.build, and build-punktfunk-gamescope.sh treats a missing layer as a hard
# error, so the whole build exits non-zero. ci/gamescope-trixie.Dockerfile walked into the
# identical trap one release later (1b28a7f7, v0.28.1) and now asserts x11-xcb at image build;
# this list never got the same fix.
#
# ⚠ Do NOT "sync this list with the CI image". That one is for a .deb that RUNS on Debian; this
# one builds in trixie for a binary that must run on SteamOS. Taking libdisplay-info-dev from it
# (tried on the lab VM, 2026-08-23) built, installed and printed its +pfhdr banner in the box —
# then died on glass with `libdisplay-info.so.2: cannot open shared object file`, because meson
# had preferred the system lib over gamescope's vendored submodule and linked it SHARED. The
# durable fix is the force_fallback_for pin in build-punktfunk-gamescope.sh, next to wlroots;
# the package has no reason to be here. Only add a name whose soname SteamOS itself ships.
if ! distrobox enter "$BOX" -- bash -lc '
set -e
export DEBIAN_FRONTEND=noninteractive
@@ -85,7 +101,8 @@ sudo apt-get install -y -qq --no-install-recommends \
libvulkan-dev libglm-dev libpixman-1-dev libeis-dev \
libavif-dev libdecor-0-dev hwdata libluajit-5.1-dev \
libpipewire-0.3-dev libspa-0.2-dev libsdl2-dev \
xwayland liblcms2-dev >/dev/null
xwayland liblcms2-dev \
libx11-xcb-dev libxkbcommon-x11-dev >/dev/null
' ; then
warn "could not provision gamescope build deps in '$BOX' — sessions stay SDR (re-run update.sh to retry)"
exit 0
@@ -94,8 +111,20 @@ if ! distrobox enter "$BOX" -- bash -lc "
set -e
bash '$PKGDIR/build-punktfunk-gamescope.sh' --prefix \"\$HOME/.local\" --no-setcap
"; then
warn "punktfunk-gamescope failed to build — sessions stay SDR (re-run update.sh to retry)"
unwire
# A failed build REPLACED NOTHING — the previously installed binary is untouched on disk. If it
# still passes the on-glass check it is the very binary that was streaming HDR before this run,
# so keep it wired and say it is stale. Unwiring here took HDR away from boxes whose compositor
# still worked, on an update that changed nothing about it (field report: HDR "lost" going to
# 0.31.2, host.env silently missing PUNKTFUNK_GAMESCOPE_BIN afterwards). `unwire` belongs only
# where the binary itself fails `verifies` — the else branch at the bottom, which also removes
# it. `wire` re-arms a box a previous run of this bug already unwired.
if verifies; then
warn "punktfunk-gamescope failed to build — keeping the installed $("$GS_BIN" --version 2>&1 | head -1) (stale; re-run update.sh to retry)"
wire
else
warn "punktfunk-gamescope failed to build and none is installed — sessions stay SDR (re-run update.sh to retry)"
unwire
fi
exit 0
fi

Some files were not shown because too many files have changed in this diff Show More