Compare commits

...
Author SHA1 Message Date
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 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 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 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 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 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
65 changed files with 4003 additions and 277 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
+12
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
+548
View File
@@ -12,6 +12,554 @@ with the version table of the release you are moving to, then read **Breaking ch
---
## v0.31.3
41 commits since v0.31.2 (26 non-merge), counted at the tip this was cut from.
**One versioned surface moves, and additively: the management API.** `WIRE_VERSION` stays **2**, the
C ABI stays **25**`include/punktfunk_core.h` is **byte-identical to the v0.31.2 tag**, as it was
to v0.31.1 — and so do the driver protocol, the gamepad channel, the plugin index schema and the
host event schema. `pf-driver-proto` shows no diff. No `#[repr(C)]` struct moves and no C function
changes signature, so an embedder takes this release without recompiling anything.
`api/openapi.json` gains **one route and one field** (`PATCH /clients/{fingerprint}`, the
`RenameClient` schema, and `PairedClient.label`); nothing existing changes shape, so a consumer that
ignores both is unaffected. **`@punktfunk/host` is re-cut to 0.1.6** so a plugin can actually reach
the generated types for that route; `@punktfunk/plugin-kit` stays at 0.4.4. One dependency moves,
lockfile-only, for a security advisory.
The cycle is fix-shaped and the faults share a family resemblance: **a session degrading or ending
against something ordinary that nothing was checking**. The host mistaking Steam's pre-launch trees
for the game and then reading their exit as the game's (#372); a fullscreen game mode-setting the
virtual display under both stream loops, which no in-place encoder rebuild can converge on (#373);
the forced-keyframe coalesce window measured in frames rather than time (#377); an Android TV
negotiating the refresh its own console pin had 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 that nothing
afterwards recognised (#381); and a failed compositor build that unlinked the working one it never
replaced (#382). Alongside: 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).
### Versions
| | v0.31.2 | v0.31.3 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged. No message added, removed or re-shaped |
| C ABI | 25 | **25** | unchanged. `include/punktfunk_core.h` has **no diff at all** against the v0.31.2 tag — the second release running |
| Rust edition | 2024 | **2024** | unchanged |
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
| Workspace crate dirs | 27 | **27** | unchanged (39 `[workspace] members`, also unchanged) |
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3); `pf-driver-proto` shows no diff against the v0.31.2 tag |
| Windows virtual-gamepad channel | 3 | **3** | unchanged. #374 exercises the UMDF HID pad through `devtest` but changes no backend |
| Plugin index schema | 1 | **1** | unchanged |
| Host event schema | 1 | **1** | unchanged (`punktfunk-host/src/events.rs`) |
| `api/openapi.json` | 0.31.2 | **0.31.3** | **additive**: one route (`PATCH /clients/{fingerprint}`), one schema (`RenameClient`), one response field (`PairedClient.label`), plus the `info.version` stamp. Regenerated in #374 on a runner where `openapi_document_is_complete_and_checked_in` executes; **re-stamped** here, not regenerated — `punktfunk-host` does not build on macOS. `api/` and `docs-site/public/` are byte-identical to each other |
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files, `packaging/gamescope/PKGBUILD` still declares `pfhdr8`. #382 fixes the Deck **source** build, not the patch set |
| `@punktfunk/host` (SDK) | 0.1.5 | **0.1.6** | **cut**, for the generated client `sdk/src/gen/punktfunk.ts` — it carries `PATCH /clients/{fingerprint}`, `RenameClient` and `PairedClient.label`, and a plugin resolves `@punktfunk/host` from the registry, so those types reach nobody until a version ships them. `SDK_VERSION` moves with `package.json`; see the drift note at the end |
| `@punktfunk/plugin-kit` | 0.4.4 | **0.4.4** | unchanged; nothing under `plugin-kit/` moved. 0.4.4 remains the registry's `latest` |
### ⚠ Breaking changes
**None.** No wire change, no ABI change, no driver-protocol change, no plugin-contract change, and
the one API change is additive. Every 0.31.x host, client, driver and plugin keeps interoperating in
both directions with no re-pairing and no rebuild.
Five **behaviour** changes that break no build but change what a machine does:
- **`GameRunning` is reported up to `SHIM_WINDOW` (5 s) later than before** for a lease matched by
process scan. A scan match must now be seen *continuously* for that window before it latches out
of the start phase. A provider plugin's runstate report still latches immediately — that is the
launcher's own statement, not an inference — and exit detection is untouched.
- **The GameStream stream loop now re-opens the encoder at a source-driven mode**, and does not tell
the client. GameStream has no mid-stream mode-change message, so Moonlight decodes a bitstream
that disagrees with the resolution it configured its decoder from. Tolerant decoders re-init off
the SPS; a strict one (Media Foundation on Xbox) may stall. This is the same bargain the first
open in that function already takes for the monitor-mirror case (§7.3), and the alternative it
replaces is ending the session outright.
- **`GET /api/v1/clients` grows `label`**, and `PairedClient.subject` is now documented as *not* a
device name. A console or integration that displayed `subject` should prefer `label` and fall back
to `subject` only when it is unset.
- **The startup capacity probe no longer bursts at a flat 2 Gbps.** Its target is derived from
`stream_cap_kbps × 2`, still capped at 2 Gbps. `PUNKTFUNK_ABR_PROBE_KBPS` and its `> 0` filter are
unchanged, so an embedder that pins the probe explicitly sees no difference.
- **Android no longer pins the panel to its highest refresh mode on a TV.** `highRefreshModeId`
stays 0 there, which `setConsoleHighRefreshRate` already treats as a no-op. Phones and tablets are
unaffected — the pin exists for their refresh governors.
### `PATCH /clients/{fingerprint}`: an operator label for a paired client
Every moonlight-common-c client self-signs with the same fixed subject (`CN=NVIDIA GameStream
Client`), so the certificate carries no device identity at all: five paired devices are five
identical rows, distinguishable only by fingerprint prefix — most sharply when choosing which to
unpair. Reported from the field as a rename request.
The label is operator-supplied and stored host-side, keyed by fingerprint:
- **`client-labels.json`, a SIDECAR to `paired.json`, not a field inside it.** `paired.json` is a
bare `Vec<Vec<u8>>` of 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 — a corrupt or missing label file
must never be able to lock anybody out. Every read failure degrades to "no names". Writes take the
same atomic temp-file + rename as `save_paired`, serialized by `LABELS_LOCK` so two concurrent
renames cannot lose one of the two names in a whole-file rewrite. Fingerprints are normalized to
lowercase hex.
- **Route semantics.** A whitespace-only body **clears** rather than storing a blank name; 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 re-pairing 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.
- **Lanes.** The new route takes the plugin/cert lanes of the `DELETE` beside it — neither may reach
it — not the roster `GET`'s read permission.
`every_route_is_classified_for_the_plugin_and_cert_lanes` 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: `client_label_round_trips_scrubs_and_is_forgotten_on_unpair` — name it, see it in the list,
watch a bidi override and collapsed whitespace get scrubbed, clear it two ways, reject a malformed
and an unpaired fingerprint, and assert the unpair forgot it on disk.
### The encoder follows an autonomous source mode or format change
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, at the SAME
configured size, which cannot converge on 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.
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
```
Both stream loops now track what the encoder was opened against `(format, width, height)` and, when
the source delivers something else, re-open through the same `open_video` path a client-initiated
resize uses:
- **Native (`native/stream.rs`)** publishes 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, so carrying the old one across hands the encoder the
wrong operating point; H.26x rates stay with ABR, and an explicit client rate is never
second-guessed.
- **GameStream (`gamestream/stream.rs`)** does the same bookkeeping the capture-loss rebuild in that
loop already does (ring depth, RFI caps, forced IDR, in-flight numbering restart), and derives
`gs_bit_depth(frame.format)` per open so an HDR flip that recreates the ring at P010 re-opens at
the right depth. It cannot notify the client; see the behaviour note above.
A failed re-open does **not** end the session on the first try: the mode-set is exactly the kind of
event that leaves the driver settling, which is the transient the submit path's backoff exists for
("NVENC session open failing after a codec switch", 2026-07). It spends the shared `encoder_resets`
budget at the existing exponential pace (100 ms → 1.6 s), re-entering the follow-the-source guard
each round — the same ~3 s ceiling as before, but every round is now a real attempt at the new mode
rather than an in-place re-init that cannot converge. The exhausted path is tagged accurately as an
encoder **reopen** failure, not a submit failure.
This also covers a mid-session frame-format change (an HDR flip re-creating the ring at a new
format), which failed identically.
### The forced-keyframe coalesce window gets an absolute floor
`keyframe_coalesce` was `frame_interval * 2`. The window bounds IDR emission in **time** — it has to
outlast the round trip in which the client receives and decodes the IDR it already asked for — so a
frame count is the wrong unit, and it collapses exactly where it matters: 16.7 ms at 120 fps, while
a Moonlight client that has lost decode sync re-asks roughly every 30 ms. The gate never closed
between requests, so effectively every request became a full keyframe, whose bulk saturates the send
path, which causes the loss that prompts the next request. The storm sustains itself and reads as
stutter at a flat latency, because frames are being lost rather than queued.
Field log (AMD RX 7800 XT, Bazzite 44, 1080p120 HEVC over the GameStream plane): **1118 IDR requests
in one 91 s session, 1115 honoured, 3 coalesced** — about one full IDR every tenth frame at a
100 Mbps target. The same session's H.264 leg (libav VAAPI, same bitrate) took 2 requests and was
clean, which is what made it read as an HEVC fault.
`keyframe_coalesce_window(frame_interval)` is now `(frame_interval * 2).max(100 ms)`. 100 ms matches
the encoder-reset backoff in the same loop and is about one IDR's service time on a saturated link.
Note this is **not a 120-only fix**: 60 fps sat at 33.3 ms, also under the floor. A slow stream keeps
the frame-scaled window — the floor only ever raises it. NVENC ref-invalidation (cheap, no IDR
spike) is still never rate-limited. `keyframe_coalesce_window_outlasts_a_clients_request_cadence`
pins all three cases.
### The game lease stops latching on Steam's pre-launch trees
`reaper SteamLaunch AppId=<appid>` is the **appid's** wrapper, not the game's, and 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 `START_GRACE` (300 s, ending nothing) into `EXIT_CONFIRM` (3 s, ending the session). When
that tree exited with the game still starting, the watch called it the game exiting and closed the
connection with `APP_EXITED`. Reported as having to launch Rocket League twice: the first launch
streamed the "Processing Vulkan shaders" dialog and dropped ten seconds in.
Linux has nothing else to catch it — `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 **rejects** a `SteamLaunch AppId=` reaper whose payload is `fossilize_replay`
Steam's shader replayer, never a game. `program_name` handles the full-path form.
- 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 the `GameRunning` latency in the behaviour note above.
Exit detection is untouched, and a provider report still latches immediately. Diagnostics: the log
said `procs=1` and never *which* process, which is what made this unclosable from a log alone —
`procscan::names` puts the short names on the line.
### The startup capacity probe is sized from the session, not from a flat ceiling
The 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 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.
The target is `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 `cap` 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-requested one and the client sat on black until an unrelated recovery path happened to fire. A
keyframe is now requested 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.
### Android: the console's high-refresh pin is not applied on a TV
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 pin installed, 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.
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.
### Android: `acquireLatestImageAsync` hands back a fence it already gave away
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.
Both present intents now drain with `acquireNextImageAsync`, whose fence is always a fresh dup we
exclusively own, and latency picks 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.
### Android: a DualSense's buttons, touchpad click and Mute
Three defects reported against a Bluetooth DualSense on a Fire TV Stick 4K Max, **re-implemented
from #371's diagnosis**. #371 itself is not merged: all three problems are real and correctly
identified, but each fix as sent lands somewhere that breaks more hardware than it repairs.
1. **Some buttons never reach the stream.** Fire OS tags 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 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)`, would cost every Xbox pad, Switch Pro, 8BitDo, Steam Deck and
hid-playstation DualSense both stick clicks. `correct()`'s `genericKeyCode` guard is unchanged.
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`.
### The console's per-frame cost, and a resolution switch for 4K boxes
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, 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, line breaking and font fallback, for every string on screen, sixty
times a second. 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 past a 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.
- `LayerEnv::paint` raised an unbounded `save_layer` **unconditionally** — including on the settled
path, where alpha is 1, scale is 1 and 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 64 MB budget** on a 2 GB box — 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.
- The toast's layer was unbounded too, for a 34 dp pill; it takes the pill's rect now.
- `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.
On the Android host the render thread takes the same priority lift the decode thread has always had
(`-8`, a band below the stream's `-10`, so the two do not compete when the console is up
mid-session). And the console logged its GLES version and cache budget but never its render
resolution or frame cost, so "it feels sluggish" could not be triaged from a log bundle at all — it
now names the surface size and reports mean and peak draw time once a minute, 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).
**`Reduce interface resolution`** (#384) is the lever that commit deliberately left out: an
off-by-default Android switch, under Reduce motion, capping the buffer's long edge at 1920 via
`SurfaceHolder.setFixedSize` and letting the compositor scale up. Two things it had to get right:
`setFixedSize` shrinks the BUFFER and not the VIEW, so everything speaking in surface pixels is
scaled to match — safe-area insets, the design-unit scale, and pointer coordinates, which a mouse
still reports in view pixels and which would otherwise land the cursor at twice its true offset (one
factor on both axes, so aspect survives exactly). And the buffer is sized from the `SurfaceView`'s
laid-out size via `onSizeChanged`, **not** `displayMetrics`, which has a long history of disagreeing
with a view's real size by a system bar. The factor reaches the pointer listeners through
`rememberUpdatedState``AndroidView`'s `factory` runs once, so a captured value would freeze at 1.
⚠ This is the INTERFACE only and shares nothing with the stream: picture size is `effectiveMode` off
`Display.mode.physicalWidth`, and picture scaling is the separate `renderScale`.
`platform_row_split_hides_only_the_other_platforms_concepts` pins the exact ordered set of rows the
desktop does not show; the new switch is Android-only by design, so #385 grows that expected list by
one between the Controllers action row and the console-UI switch. The row-COUNT assertion beside it
was already updated and passed, which is why only the ordered-set one went red.
### gamescope: the hand-back verifies the panel came back
Field reports on 0.31.x, Bazzite and Nobara: after disconnecting, the box's own physical screen stays
black. **It could not be reproduced**#375 carries the full negative write-up, five scenarios
across both distro families on real VMs, all recovering cleanly, and the mechanism 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.
`do_restore_tv_session` issued a lifecycle verb and logged what systemd said about the **job**. "The
job succeeded" and "the box shows a picture" are different questions, and nothing in that file had
ever asked the second — the restore walked away the moment the verb returned, so every way of ending
dark looked identical to success in the log.
It now measures. 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. 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.
### Windows audio: an abandoned devnode is adopted, and capture endpoints stamp the right hive
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)`. Reproduced on .173 against the shipping 0.31.2 binary by
clearing the marker: `ROOT\MEDIA\0005` was minted and `0004` abandoned, still active and still
serving two live 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.
Separately, `write_stamps` falls back to a raw-registry write when the property store denies it, and
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` failed, `write_stamps` returned the error, and `stamp_identity`
degraded to "keeps the driver's default name". Invisible to the pad program (render-only endpoints)
and on any box where the property store route succeeds — 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.
### Steam Deck: 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` → `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.
`libdisplay-info` is also pinned to the vendored subproject, like wlroots, so a system copy cannot
change what the build links.
### Measured and dropped: the UMDF pad input-silence theory
`devtest` grows `--idle-after N` / `--resume-after M`, which stop and restart the state frames while
still pumping, to test what a Moonlight client actually does. The hypothesis:
`UhidManager::heartbeat` documents that 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
— and the two planes differ in exactly the way that would expose it, since punktfunk's own client
re-sends every live pad's snapshot every 100 ms (`input_task.rs` refresh tick) while
moonlight-common-c sends a controller packet only on **change**.
**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 present throughout. The one-line "add a
heartbeat to the GameStream arm" fix this was going to justify is therefore **not** warranted, and
was not made.
Two things the same run did establish, and they are 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` (v0.31.1) 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 though it is not what the reporter hit. A clean release-then-retake does not
collide (0 s, 1 s and 3 s gaps all created their pad), so an ordinary client reconnect is not the
trigger.
- **`PUNKTFUNK_HOST_CMD=serve` on .173 means GameStream is switched off there**, so that box has
never exercised the plane `dfcffcdd` changed — which is how a compile-only fix reached users
unexercised.
### Dependencies
- **`h2` 0.4.15 → 0.4.18, for RUSTSEC-2026-0258** (unbounded empty DATA frames, disclosed
2026-08-17; fixed in 0.4.16). Lockfile-only and transitive — no manifest declares `h2`, and
`cargo metadata --locked` accepts the two-line change with no other package moving, so the
resolver needed nothing else. This was the only finding across all five Rust lockfiles; the two
remaining `cargo audit` lines (`audiopus_sys`, `paste`) are the pre-existing *unmaintained*
warnings already allowed in `.cargo/audit.toml`.
`THIRD-PARTY-NOTICES.txt` still records `h2 0.4.15` and is **not** regenerated here: the
generator walks the dependency closure of the machine it runs on, and on macOS that drops the
`rusqlite` / `libsqlite3-sys` / `fallible-iterator` cluster (575 → 566 crates) — removing
attributions a Linux or Windows build genuinely links. Regenerate it on Linux. Nothing gates the
checked-in copy, and every packaging script regenerates it on its own platform, so this is
cosmetic drift rather than a shipped inaccuracy.
### CI
- **The flatpak build stopped updating runtimes it already has.** Every attempt died on
`dl.flathub.org` serving a 404 for one object of the then-current `rust-stable//25.08` commit;
`retry.sh` burned all 10 attempts (~9 min) on it and `flatpak-builder` segfaulted on its own error
path (rc=139), so the wrapper could not tell a dead end from a load blip. Root cause is ours:
`--install-deps-only` does not install what is missing, it **updates** what is present, and
`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.
`scripts/ci/flatpak-deps-present.sh` now asks first and reaches for Flathub only on a real miss;
it fails **open** (anything it cannot parse takes the full install path) and has a `--self-test`
that stubs `flatpak` over baked / cold / each dep missing / wrong version / unreadable manifest.
`--install-deps-from=flathub` is dropped from the build step: `builder_manifest_install_deps()`
runs whenever that flag is set, so the step billed as offline was re-running the same update.
`packaging/flatpak/build-flatpak.sh` keeps it — a dev box has no baked image. `flatpak.yml` now
also triggers on the deps-check script itself, so a change to that decision cannot ship untested.
- **A dropped Skia download read as a lint failure.** `scripts/ci/retry.sh` wraps every single-shot
network call in CI, but one of the largest fetches 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. Measured on main 2026-08-22, android job: `curl: (18) end of response with 17054400
bytes missing` — 2 MB of 19,057,024 arrived before the connection closed. `skia-bindings` then
swallowed it, falling through to starting a full from-source Skia build the CI containers carry no
deps for, so the job surfaced as something else entirely.
- **`mgmt/tests.rs` has one `ConfigDirOverride`, not one copy per test.** The unsafe-hygiene gate
failed at 6 process-global-API mentions against a baseline of 3: the new rename test had
copy-pasted the existing `EnvGuard` + lock + tempdir dance, which is exactly the duplication gate
C exists to catch. The single guard also makes the pairing harder to get wrong — the lock is a
**field** rather than a separate `_serial` binding a test could forget, and `Drop::drop` runs
before any field drops, so the environment is restored while the guard still holds the lock. Back
to 3.
### `sdk/src/gen/punktfunk.ts` had drifted from its own generator
The generated client in #374 is bigger than the feature. 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 lands the clean regeneration rather than
hand-patching generated code.
**`@punktfunk/host` 0.1.6 is cut for it** (`sdk-v0.1.6`, published by `sdk-publish.yml`), because a
plugin resolves the SDK from the registry: the types for `PATCH /clients/{fingerprint}` could not
reach one while they sat in `sdk/` unpublished. That single regenerated file is the whole diff since
`sdk-v0.1.5`.
`SDK_VERSION` in `sdk/src/version.ts` moves with `package.json`. It is a hand-maintained constant —
`tsconfig.build.json` sets `rootDir: "src"` so it cannot import `package.json`, 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` exists for
exactly that and gates it.
---
## v0.31.2
10 commits since v0.31.1 (6 non-merge), counted at the tip this was cut from.
Generated
+38 -38
View File
@@ -1090,7 +1090,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"anyhow",
"pf-capture",
@@ -1222,7 +1222,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.31.2"
version = "0.31.3"
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.2"
version = "0.31.3"
[[package]]
name = "lazy_static"
@@ -2446,7 +2446,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"bindgen",
"cmake",
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"punktfunk-core",
]
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-bitstream"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"cros-codecs",
"tracing",
@@ -2975,7 +2975,7 @@ dependencies = [
[[package]]
name = "pf-capture"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"anyhow",
"ashpd",
@@ -2996,7 +2996,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"anyhow",
"ash",
@@ -3032,7 +3032,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"anyhow",
"ashpd",
@@ -3050,7 +3050,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"anyhow",
"ash",
@@ -3073,7 +3073,7 @@ dependencies = [
[[package]]
name = "pf-dxvadec"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3083,7 +3083,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"anyhow",
"ash",
@@ -3109,7 +3109,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"anyhow",
"libc",
@@ -3122,7 +3122,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"anyhow",
"pf-host-config",
@@ -3136,11 +3136,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.31.2"
version = "0.31.3"
[[package]]
name = "pf-inject"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"anyhow",
"ashpd",
@@ -3169,14 +3169,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"anyhow",
"ash",
@@ -3191,7 +3191,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"serde",
"serde_json",
@@ -3199,7 +3199,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"anyhow",
"aws-lc-rs",
@@ -3211,7 +3211,7 @@ dependencies = [
[[package]]
name = "pf-vaadec"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3220,7 +3220,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"anyhow",
"ashpd",
@@ -3253,7 +3253,7 @@ dependencies = [
[[package]]
name = "pf-vkdecode"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"ash",
"cros-codecs",
@@ -3264,7 +3264,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"pf-paths",
"punktfunk-core",
@@ -3275,7 +3275,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"anyhow",
"ash",
@@ -3487,7 +3487,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3497,7 +3497,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"android_logger",
"anyhow",
@@ -3521,7 +3521,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"anyhow",
"async-channel",
@@ -3538,7 +3538,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"log",
"pf-client-core",
@@ -3554,7 +3554,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"async-channel",
"mdns-sd",
@@ -3572,7 +3572,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"aes-gcm",
"cbindgen",
@@ -3605,7 +3605,7 @@ dependencies = [
[[package]]
name = "punktfunk-encode-worker"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"pf-encode",
"tracing",
@@ -3614,7 +3614,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"aes",
"aes-gcm",
@@ -3684,7 +3684,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3698,7 +3698,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"anyhow",
"ksni",
@@ -3722,7 +3722,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.31.2"
version = "0.31.3"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -65,7 +65,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.31.2"
version = "0.31.3"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.31.2"
"version": "0.31.3"
},
"paths": {
"/api/v1/client-logs": {
@@ -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,
@@ -628,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
@@ -710,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
@@ -718,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
@@ -741,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"
@@ -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
@@ -404,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,
@@ -453,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) {
@@ -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`() {
@@ -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
@@ -537,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()
}
@@ -583,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.
@@ -601,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 {
@@ -631,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()
@@ -639,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
@@ -685,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(
@@ -3728,6 +3835,17 @@ fn handback_watch(units: &[String]) {
/// [`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(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.
@@ -5192,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
@@ -5564,8 +5696,9 @@ 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,
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,
@@ -5899,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());
@@ -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
+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.
+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"
);
}
}
+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. |
+18 -1
View File
@@ -136,7 +136,24 @@ 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. On a KDE desktop that goes through KWin's DPMS; on a box already in Game
Mode there is no KWin to ask, so the host turns the CRTCs off over DRM itself. Neither 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 — a box already in Game Mode has no
KDE desktop to ask for DPMS — 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
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.31.2"
"version": "0.31.3"
},
"paths": {
"/api/v1/client-logs": {
+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.
+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.
+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
@@ -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,
+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
+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
+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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@punktfunk/host",
"version": "0.1.5",
"version": "0.1.6",
"description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.",
"type": "module",
"license": "MIT OR Apache-2.0",
+1 -1
View File
@@ -8,4 +8,4 @@
*
* `version.test.ts` fails if this and `package.json` disagree, so the duplication cannot rot.
*/
export const SDK_VERSION = "0.1.5";
export const SDK_VERSION = "0.1.6";