Compare commits

...
Author SHA1 Message Date
enricobuehler 5bf64e07bf The control loop stops believing its own bookkeeping (ABR overhaul Phase 2)
apple / swift (pull_request) Successful in 2m16s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m13s
ci / bun-nix (pull_request) Successful in 23s
ci / docs-drift (pull_request) Successful in 26s
android / android (pull_request) Successful in 5m44s
ci / docs-site (pull_request) Successful in 3m59s
ci / web (pull_request) Successful in 4m35s
ci / rust (pull_request) Successful in 9m53s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m21s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m56s
The four correctness seams from the 08-22 auto-bitrate review §2, chosen
options per the RFC (planning design/abr-stack-overhaul.md §3):

- §2.3: a failed bitrate-change encoder rebuild now snaps the client back
  (retarget_tx) — the control task acks BEFORE the apply, so the client's
  climb base, utilization and proven math tracked a rate the encoder
  never ran until some later event happened to correct them.
- §2.2: the ABR rebuild announces PipelineGap on success, like the
  mode-switch and topology rebuilds already do — a ~0.6 s host-local
  stall read as congestion killed slow start for the session (the 401 ms
  field case: minutes at ~15 Mbps on a clean link).
- §2.1: an accepted mode switch re-teaches the stream-shape cap —
  computed once from the Welcome mode, 1080p→4K kept a 1080p-sized climb
  ceiling and 4K→720p left an oversized one standing. A re-set
  set_stream_cap also rebinds the already-learned ceiling downward
  (set_ceiling deliberately never lowers); the FIRST set keeps the
  founding semantics, pinned by the existing stream-bound test.
- §2.4: the bitrate_ack slot becomes a queue drained in arrival order —
  latest-wins collapsed a full resolve ack + corrective short retarget
  landing in the same 750 ms window, and host-cap learning needs two
  CONSECUTIVE short acks.

punktfunk-core --features quic: 490 tests green natively, including the
new a_mode_switch_reteaches_the_stream_cap_both_ways.
2026-08-26 19:10:28 +02:00
enricobuehler 1198522931 Merge pull request 'A slice block's lying base can no longer ship as a complete frame' (#398) from worktree-slice-tiling-guard into main
android / android (push) Canceled after 56s
arch / build-publish (push) Canceled after 58s
ci / rust (push) Canceled after 57s
ci / rust-arm64 (push) Canceled after 57s
apple / swift (push) Successful in 2m4s
ci / web (push) Canceled after 46s
ci / docs-site (push) Canceled after 3s
ci / bun-nix (push) Canceled after 0s
ci / docs-drift (push) Canceled after 0s
deb / build-publish (push) Canceled after 2s
deb / build-publish-host (push) Canceled after 2s
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 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 4s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
apple / distribute (push) Successful in 13m33s
apple / screenshots (push) Successful in 10m10s
Reviewed-on: #398
2026-08-26 14:48:11 +00:00
enricobuehler 79982060c3 Merge pull request 'The Fedora install line must name punktfunk-web, not hope for it' (#395) from worktree-fedora-web-weakdep into main
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
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
installer-smoke / smoke (arch) (push) Successful in 45s
installer-smoke / smoke (fedora-44) (push) Successful in 1m43s
installer-smoke / smoke (debian-13) (push) Successful in 2m39s
Reviewed-on: #395
2026-08-26 14:45:13 +00:00
enricobuehler fc6060f274 Merge pull request 'KWin's stored setup darkened the desk behind our back, and a TV remote could not leave the first settings tab' (#397) from worktree-kwin-stored-config-strand into main
ci / web (push) Successful in 1m22s
ci / docs-site (push) Successful in 1m22s
ci / bun-nix (push) Successful in 1m24s
ci / docs-drift (push) Successful in 30s
ci / rust-arm64 (push) Successful in 2m12s
deb / build-publish-gamescope (push) Successful in 57s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 17s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
deb / build-publish-client-arm64 (push) Successful in 2m1s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 14s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 20s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 24s
deb / build-publish (push) Successful in 3m59s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m4s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m25s
ci / rust (push) Successful in 7m10s
docker / builders-arm64cross (push) Successful in 11s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m37s
deb / build-publish-host (push) Successful in 5m58s
docker / deploy-docs (push) Successful in 40s
android / android (push) Successful in 9m18s
arch / build-publish (push) Successful in 9m51s
flatpak / build-publish (push) Successful in 4m37s
deb / smoke-install (push) Successful in 3m20s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m22s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m48s
windows-host / package (push) Successful in 12m13s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 21s
2026-08-26 14:44:44 +00:00
enricobuehler 4399664217 feat(console-ui): the legend speaks the device in hand — remote marks on TV, Nintendo's own letters, keys vs pad tracked at the source
ci / docs-drift (pull_request) Successful in 37s
ci / bun-nix (pull_request) Successful in 1m6s
ci / web (pull_request) Successful in 1m24s
ci / docs-site (pull_request) Successful in 1m26s
ci / rust-arm64 (pull_request) Successful in 1m54s
android / android (pull_request) Successful in 6m7s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m54s
ci / rust (pull_request) Successful in 6m40s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m14s
Follow-up from the same Chromecast field report: the hint legend only ever
spoke gamepad (or desktop-keyboard keycaps), whatever was actually driving.
A TV remote got Enter/Esc/Tab — keys it does not have — and a Switch pad's
badges read the Xbox letters, so 'A Select' sat over the button engraved B.

The shell now notes WHAT drove last at every input seam — the Android host
loop labels its two menu paths (discrete events are the remote, the pad
sampler is the pad), the desktop overlay's menu channel is pad by
construction, and the keyboard path notes itself. Each frame the legend
resolves from that:

* a pad speaks its own family — PlayStation shapes, Nintendo letters (both
  pairs swapped, the way the pad is engraved), ABXY for the rest;
* keys on Android are a TV remote: an OK badge for select, the ↩ return
  arrow for back, the section hint pointing at the D-pad path (▲) that
  reaches the tab strip, and the Y/X hints hidden outright — a remote has
  neither, and advertising a button the device cannot press is worse than
  silence;
* keys on the desktop stay keyboard keycaps;
* nothing has driven yet: the connected pad's family, else the platform's
  key device — so an untouched console reads right on every device.

The controller chip grows a remote mark and says 'TV remote' when that is
what is driving; a pointer press changes nothing, since a tap says nothing
about the buttons in the user's other hand. The screen dump gains the two
remote frames (Home + Settings) for the eyeball pass.
2026-08-26 16:42:06 +02:00
enricobuehler e684b3e4bd fix(core): a slice block's lying base can no longer ship as a complete frame
ci / docs-drift (pull_request) Successful in 27s
ci / bun-nix (pull_request) Successful in 52s
ci / docs-site (pull_request) Successful in 1m10s
ci / web (pull_request) Successful in 1m39s
apple / swift (pull_request) Successful in 2m10s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m50s
ci / rust (pull_request) Successful in 5m29s
android / android (pull_request) Successful in 6m27s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m5s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 7m21s
A slice-streamed sentinel's wire base was bounds-checked (in range, below
the final block) but never validated to TILE the AU: a base that lied
within bounds left a zero gap and an overlap, and the reassembler still
stamped the frame `complete`. The decoder then painted the wrong-offset
bytes as garbage rectangles while no loss counter moved — so the recovery
machinery never fired and the corruption marched on indefinitely (the
field report: black bars 'moving like an equalizer', identical across
Vulkan/DXVA/CPU decoders, Android immune because it never negotiates the
slice wire).

At completion, verify the blocks tile [0..total_data] contiguously; a
frame that doesn't is killed exactly like a lying final header — index
remembered against stragglers, counted into frames_dropped, which is the
signal that arms the freeze gate and requests recovery. The
slice-progressive prefix walk gets the same contiguity guard so a lying
base can't extend a 'contiguous' prefix either.
2026-08-26 16:28:11 +02:00
enricobuehler 8c4b1b8c62 style: the two folds rustfmt wanted (ci / rust fmt gate)
ci / web (pull_request) Successful in 1m16s
ci / docs-site (pull_request) Successful in 1m21s
ci / bun-nix (pull_request) Successful in 21s
ci / docs-drift (pull_request) Successful in 20s
ci / rust-arm64 (pull_request) Successful in 4m25s
android / android (pull_request) Successful in 8m37s
ci / rust (pull_request) Successful in 9m9s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m57s
2026-08-26 09:29:30 +02:00
enricobuehler b64ac3cb32 fix(console-ui): the settings tabs answered only to shoulders — Up from the top row puts a TV remote's D-pad on the strip
ci / web (pull_request) Successful in 1m4s
ci / rust-arm64 (pull_request) Successful in 1m25s
ci / docs-drift (pull_request) Successful in 24s
ci / docs-site (pull_request) Successful in 1m6s
ci / bun-nix (pull_request) Successful in 1m11s
ci / rust (pull_request) Failing after 4m4s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m27s
android / android (pull_request) Canceled after 3m42s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 1m17s
A Chromecast field report: with only the TV remote — the pad had dropped off,
as Chromecast pads do — every settings tab but the first was unreachable. The
split of the one 30-row scroll into tabs put section switching on L1/R1, Tab
and PgUp/PgDn, and a remote has none of those; the D-pad's left/right already
mean 'adjust the focused row'.

Up from the list's top row now steps onto the tab strip instead of recoiling:
left/right travel the ring (wrapping, like the shoulders), Down or A drop
back into the rows of the tab that's showing, B still leaves the screen, and
the shoulders keep working from either focus. The strip shows the handoff —
brighter pill, ‹ › chevrons — the rows rest their focus ring while it holds
focus, and the hint bar names the D-pad's meaning up there. A pointer press
on the rows takes the focus back.

The gamepad path is untouched: nothing changes until an Up is pressed on the
top row, which used to be a boundary recoil.
2026-08-26 09:22:30 +02:00
enricobuehler f92b093f92 fix(kwin): KWin's stored setup darkened the desk and the restore never saw it — snapshot before create, re-assert after teardown
A Bazzite triple-monitor field report: monitors cycling off/on around every
session, sessions ending with some or all monitors dark or at the wrong
resolution, and an exclusive apply logging also_disabled=[] on a desk with
three enabled physicals.

KWin persists output state per MONITOR SET (kwinoutputconfig.json). Creating
our virtual output changes the set, and a set that ever ran exclusive has
'physicals disabled' stored — so KWin disables them ITSELF, in the window
between our output's creation and the topology apply's enumeration. The apply
then finds nothing enabled, captures nothing for restore, and teardown
re-enables nothing: the desk stays dark, and KWin's own recovery improvises
modes. Under Extend/Primary the same stored setup switched screens off that
those topologies promise never to touch.

Three moves, all in the KWin backend:

* create() snapshots the enabled physicals BEFORE the virtual output exists —
  the only read KWin's reaction cannot have polluted.
* Exclusive: the snapshot joins the restore list (union_restore) — an output
  KWin disabled before we could see it is still re-enabled at teardown.
  Extend/Primary: reenable_stranded() puts the stored-config casualties back
  on, immediately and once more after a short delay (KWin can apply the
  stored setup after our first read). One shot each, never a loop.
* The group-teardown restore gains a delayed verify that re-asserts AFTER our
  output is reclaimed — under the without-us set, so KWin persists the heal
  into the stored setup instead of re-fighting us next session. It stands
  down if a new session's managed output already owns the topology.

A box stranded BEFORE this fix stays stranded once: the snapshot cannot see
outputs that were already dark. Re-enabling them once in System Settings
while no stream is live writes the healthy without-us setup back, and the
loop cannot re-form.
2026-08-26 09:22:11 +02:00
enricobuehler b9adcc4897 fix(docs): the Fedora install line must name punktfunk-web, not hope for it
ci / docs-drift (pull_request) Successful in 29s
ci / bun-nix (pull_request) Successful in 29s
ci / web (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 1m19s
ci / rust-arm64 (pull_request) Successful in 1m27s
installer-smoke / smoke (arch) (pull_request) Successful in 1m27s
installer-smoke / smoke (fedora-44) (pull_request) Successful in 3m7s
installer-smoke / smoke (debian-13) (pull_request) Successful in 3m46s
ci / rust (pull_request) Successful in 6m26s
`punktfunk` only *Recommends* punktfunk-web (and punktfunk-scripting), so
`sudo dnf install punktfunk` installs the host with no console on any box where
weak deps are off — install_weak_deps=False, --setopt=install_weak_deps=0, or an
rpm-ostree layering that drops them. The user then follows step 4 and gets
"Unit punktfunk-web.service could not be found", with nothing saying why.

Name both subpackages in the install line at its single source
(data/platforms.json + the docs-site snapshot), which the guided installer and
the Fedora page quote verbatim, and drop the "the console comes with it" claim
from the README. Adds the symptom to Troubleshooting, including the other cause:
COPR has no punktfunk-web at all (no bun in its mock chroot).
2026-08-25 21:00:10 +02:00
26 changed files with 876 additions and 68 deletions
+1 -1
View File
@@ -102,7 +102,7 @@ installer (all-vendor: NVIDIA, AMD, Intel).
|--------|---------|-------|
| **Ubuntu 26.04+ / Debian 13+** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu](https://docs.punktfunk.unom.io/docs/ubuntu) · [Debian](https://docs.punktfunk.unom.io/docs/debian) · [packaging/debian](packaging/debian/README.md) |
| **Bazzite / Fedora Atomic** (systemd-sysext) | `curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh && sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
| **Fedora** (dnf) | `sudo dnf install punktfunk` *(after adding the repo; the console comes with it)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
| **Fedora** (dnf) | `sudo dnf install punktfunk punktfunk-web punktfunk-scripting` *(after adding the repo)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
| **Arch / CachyOS** (pacman) | `sudo pacman -Syu punktfunk-host` *(binary repo — always a full `-Syu`)* | [Arch Linux](https://docs.punktfunk.unom.io/docs/arch) · [packaging/arch](packaging/arch/README.md) |
| **SteamOS / Steam Deck** (on-device build) | `bash ~/punktfunk/scripts/steamdeck/install.sh` *(after cloning this repo to `~/punktfunk`)* | [SteamOS (Host)](https://docs.punktfunk.unom.io/docs/steamos-host) |
| **Windows** (11 22H2+, x64) | `winget install unom.PunktfunkHost` *(after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest`)* · or the signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) · [packaging/winget](packaging/winget/README.md) |
+8 -3
View File
@@ -16,7 +16,8 @@ use ndk::native_window::NativeWindow;
use pf_client_core::console::{OverlayAction, PointerInput, SessionPhase};
use pf_client_core::menu_nav::{MenuEvent, MenuNav, MenuPulse, MenuSample, PadInfo};
use pf_console_ui::{
Console, ConsoleEntry, ConsoleHandles, ConsoleOptions, Insets, Key, SnapshotStore, Viewport,
Console, ConsoleEntry, ConsoleHandles, ConsoleOptions, InputSource, Insets, Key, SnapshotStore,
Viewport,
};
use punktfunk_core::config::GamepadPref;
use std::collections::VecDeque;
@@ -346,7 +347,11 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
}
Cmd::Menu(ev) => {
last_input = Instant::now();
if let Some(p) = console.menu(ev) {
// Discrete events are the remote/keyboard path (Kotlin routes pad
// buttons through PadSample) — with one wrinkle: a pad's SELECT also
// arrives here (SkiaConsoleShell's ▲-on-Home shortcut), briefly
// reading as keys. The next real pad press corrects the legend.
if let Some(p) = console.menu(ev, InputSource::Keys) {
shared.emit(HostEvent::Pulse(p));
}
}
@@ -454,7 +459,7 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
menu_out.clear();
nav.poll(&sample, Instant::now(), &mut menu_out);
for ev in menu_out.drain(..) {
if let Some(p) = console.menu(ev) {
if let Some(p) = console.menu(ev, InputSource::Pad) {
shared.emit(HostEvent::Pulse(p));
}
}
+17 -2
View File
@@ -17,6 +17,17 @@ use skia_safe::Canvas;
pub use crate::input::Key;
/// What produced a menu event — the device family the hint legend should speak in.
/// Pointer input carries no source on purpose: a tap says nothing about which buttons
/// the user's OTHER hand holds, so it leaves the legend as it was.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum InputSource {
/// A gamepad (the glyphs follow the active pad's family).
Pad,
/// Keys: a TV remote's D-pad on Android, a keyboard on the desktop.
Keys,
}
/// Where the console starts.
pub enum ConsoleEntry {
/// The host list (the session binary's bare `--browse`; the Android console's Home).
@@ -122,8 +133,12 @@ impl Console {
.render_in(canvas, viewport, &self.fonts, pad, pad_pref, pads);
}
/// A controller menu event. The pulse, if any, is what the pad should feel.
pub fn menu(&mut self, event: MenuEvent) -> Option<MenuPulse> {
/// A menu event, with WHERE it came from — a controller, or keys (a TV remote's
/// D-pad, a keyboard). The source is what keeps the hint legend speaking the language
/// of the device actually in the user's hand; the pulse, if any, is what a pad should
/// feel.
pub fn menu(&mut self, event: MenuEvent, source: InputSource) -> Option<MenuPulse> {
self.shell.note_input_source(source);
self.shell.handle_menu(event)
}
+217 -25
View File
@@ -1,9 +1,11 @@
//! Controller button glyphs and the hint bar — the "controls legend" pill every console
//! screen pins bottom-leading (the Apple client resolves real SF glyphs per pad via
//! `sfSymbolsName`; here the shapes are drawn). The style follows the ACTIVE pad:
//! PlayStation controllers read ✕/○/□/△, everything else reads ABXY letters, and with
//! no pad at all the legend swaps to keyboard keycaps — the console stays fully
//! drivable either way.
//! `sfSymbolsName`; here the shapes are drawn). The style follows WHAT IS DRIVING the
//! console (see `Shell::glyph_style`): PlayStation controllers read ✕/○/□/△, Nintendo
//! pads read their own letter positions, everything else reads ABXY letters — and when
//! the last input came from keys, the legend swaps to keyboard keycaps on the desktop or
//! to TV-remote marks (OK, the back arrow, the D-pad) on Android, where key-driven input
//! IS a remote. The console stays fully drivable in every one of them.
use crate::theme::{fg, fill, stroke, Fonts, W};
use punktfunk_core::config::GamepadPref;
@@ -15,16 +17,30 @@ pub(crate) enum GlyphStyle {
Letters,
/// PlayStation face shapes (DualSense / DualShock 4).
Shapes,
/// No controller — keyboard keycaps.
/// Nintendo letter badges: the same positional buttons, labelled the way the pad in
/// the user's hands is — south reads B, east A, west Y, north X. Without this a
/// Switch pad's legend says "A Select" over the button engraved B.
Nintendo,
/// Keys drive, on a desktop — keyboard keycaps.
Keyboard,
/// Keys drive, on Android — a TV remote: OK, the back arrow, and the D-pad. A remote
/// has no Y/X and no shoulders, so hints that need them resolve to nothing and the
/// section hint points at the D-pad path instead.
Remote,
}
impl GlyphStyle {
/// The style a PAD speaks in, from the family its `Auto` virtual pad resolves to
/// ([`PadInfo::pref`](pf_client_core::menu_nav::PadInfo) — DualSense stays DualSense,
/// Switch Pro stays Switch Pro, everything else lands on an Xbox class). The keys-drive
/// styles are picked by the shell, which knows the platform; `None` (no pad) falls to
/// keycaps as the neutral default.
pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle {
match pref {
Some(GamepadPref::DualSense | GamepadPref::DualSenseEdge | GamepadPref::DualShock4) => {
GlyphStyle::Shapes
}
Some(GamepadPref::SwitchPro) => GlyphStyle::Nintendo,
Some(_) => GlyphStyle::Letters,
None => GlyphStyle::Keyboard,
}
@@ -56,6 +72,30 @@ pub(crate) fn pad_mark(
);
return;
}
if style == GlyphStyle::Remote {
// A remote: a slim upright wand with its select ring near the top. Outlined like
// the keycap — the filled marks are for things with a body to fill.
let rw = w * 0.42;
let rh = w * 0.98;
let body = Rect::from_xywh(
(x + (w - rw) / 2.0) as f32,
(cy - rh / 2.0) as f32,
rw as f32,
rh as f32,
);
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width((1.3 * k) as f32);
canvas.draw_rrect(
RRect::new_rect_xy(body, (rw / 2.2) as f32, (rw / 2.2) as f32),
&p,
);
canvas.draw_circle(
((x + w / 2.0) as f32, (cy - rh * 0.22) as f32),
(rw * 0.30) as f32,
&p,
);
return;
}
// A gamepad: a wide rounded body with a grip under each end. Detail beyond the
// silhouette is invisible at 15 dp, so there is none — the outline IS the glyph.
let h = w * 0.52;
@@ -201,17 +241,29 @@ pub(crate) fn hint_bar(
let pad = 13.0 * k;
let gap_hint = 18.0 * k;
let gap_glyph = 7.0 * k;
let widths: Vec<(f64, f64)> = hints
// Hints with no honest glyph in this style (a remote's missing Y/X) are dropped
// here, before layout — they take no width, draw nothing and get no hit box.
let shown: Vec<&Hint> = hints
.iter()
.filter(|h| resolved(h.key, style).is_some())
.collect();
if shown.is_empty() {
return HintBar {
size: (0.0, 0.0),
rects: Vec::new(),
};
}
let widths: Vec<(f64, f64)> = shown
.iter()
.map(|h| {
(
glyph_width(fonts, h.key, style, k),
glyph_width(fonts, h.key, style, k).expect("filtered to resolvable"),
fonts.measure(&h.label, W::SemiBold, LABEL_SIZE * k) as f64,
)
})
.collect();
let content_w: f64 = widths.iter().map(|(g, l)| g + gap_glyph + l).sum::<f64>()
+ gap_hint * (hints.len() - 1) as f64;
+ gap_hint * (shown.len() - 1) as f64;
let h = BADGE_D * k + 2.0 * pad;
let w = content_w + 2.0 * pad;
let rect = Rect::from_xywh((x) as f32, (bottom - h) as f32, w as f32, h as f32);
@@ -239,8 +291,8 @@ pub(crate) fn hint_bar(
let cy = bottom - h / 2.0;
let mut pen = x + pad;
let mut rects = Vec::with_capacity(hints.len());
for (hint, (gw, lw)) in hints.iter().zip(&widths) {
let mut rects = Vec::with_capacity(shown.len());
for (hint, (gw, lw)) in shown.iter().zip(&widths) {
// Glyph + label + half the gap to the next hint, full pill height: a comfortable
// target without stealing the neighbour's.
rects.push((
@@ -272,13 +324,14 @@ pub(crate) fn hint_bar(
}
}
fn glyph_width(fonts: &Fonts, key: HintKey, style: GlyphStyle, k: f64) -> f64 {
match resolved(key, style) {
/// `None` = the hint resolves to nothing in this style and takes no space (see [`resolved`]).
fn glyph_width(fonts: &Fonts, key: HintKey, style: GlyphStyle, k: f64) -> Option<f64> {
Some(match resolved(key, style)? {
Resolved::Badge(_) | Resolved::Adjust => BADGE_D * k,
Resolved::Shoulders => 2.0 * shoulder_w(fonts, k) + 3.0 * k,
Resolved::Up | Resolved::Down => BADGE_D * k,
Resolved::Up | Resolved::Down | Resolved::Ok | Resolved::BackArrow => BADGE_D * k,
Resolved::Key(text) => keycap_w(fonts, text, k),
}
})
}
fn shoulder_w(fonts: &Fonts, k: f64) -> f64 {
@@ -291,7 +344,7 @@ fn keycap_w(fonts: &Fonts, text: &str, k: f64) -> f64 {
/// A hint key resolved against the glyph style.
enum Resolved {
/// A face-button badge: the letter (Letters) or shape index (Shapes).
/// A face-button badge: the letter (Letters/Nintendo) or shape (Shapes).
Badge(Face),
Shoulders,
Adjust,
@@ -301,6 +354,10 @@ enum Resolved {
/// The d-pad's down — the same triangle stood on its head, and style-free for the
/// same reason [`Resolved::Up`] is.
Down,
/// A TV remote's select — a round badge that simply says OK.
Ok,
/// A TV remote's back — the ↩ return arrow in a badge.
BackArrow,
Key(&'static str),
}
@@ -312,9 +369,14 @@ enum Face {
Y,
}
fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
/// `None` = this hint has no honest glyph in this style and is not drawn at all: a TV
/// remote has no Y/X, and advertising a button the device cannot press is worse than
/// silence. (The touch path loses those two bar buttons in Remote style with it —
/// acceptable: Remote only rules while KEYS drove last, and every such action still has
/// an on-screen path.)
fn resolved(key: HintKey, style: GlyphStyle) -> Option<Resolved> {
if style == GlyphStyle::Keyboard {
return match key {
return Some(match key {
HintKey::Confirm => Resolved::Key("Enter"),
HintKey::Back => Resolved::Key("Esc"),
HintKey::Secondary => Resolved::Key("Y"),
@@ -326,9 +388,26 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
HintKey::Up => Resolved::Up,
HintKey::Down => Resolved::Down,
HintKey::Key(t) => Resolved::Key(t),
});
}
if style == GlyphStyle::Remote {
return match key {
HintKey::Confirm => Some(Resolved::Ok),
HintKey::Back => Some(Resolved::BackArrow),
// A remote has no Y and no X. The screens' Y/X features stay reachable the
// ways their screens already provide; the legend just stops naming buttons
// that are not in the user's hand.
HintKey::Secondary | HintKey::Tertiary => None,
// No shoulders either — the D-pad path to the strip (Up from the top row) is
// the section switcher a remote actually has, so the hint points up.
HintKey::Shoulders => Some(Resolved::Up),
HintKey::Adjust => Some(Resolved::Adjust),
HintKey::Up => Some(Resolved::Up),
HintKey::Down => Some(Resolved::Down),
HintKey::Key(t) => Some(Resolved::Key(t)),
};
}
match key {
Some(match key {
HintKey::Confirm => Resolved::Badge(Face::A),
HintKey::Back => Resolved::Badge(Face::B),
HintKey::Tertiary => Resolved::Badge(Face::X),
@@ -338,6 +417,21 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
HintKey::Down => Resolved::Down,
HintKey::Up => Resolved::Up,
HintKey::Key(t) => Resolved::Key(t),
})
}
/// The letter a face badge shows: positional buttons, labelled the way the ACTIVE pad
/// is engraved. Nintendo swaps both pairs — its south is B and its east is A.
fn face_letter(face: Face, style: GlyphStyle) -> &'static str {
match (style, face) {
(GlyphStyle::Nintendo, Face::A) => "B",
(GlyphStyle::Nintendo, Face::B) => "A",
(GlyphStyle::Nintendo, Face::X) => "Y",
(GlyphStyle::Nintendo, Face::Y) => "X",
(_, Face::A) => "A",
(_, Face::B) => "B",
(_, Face::X) => "X",
(_, Face::Y) => "Y",
}
}
@@ -351,7 +445,10 @@ fn draw_glyph(
cy: f64,
k: f64,
) {
match resolved(key, style) {
let Some(resolved) = resolved(key, style) else {
return;
};
match resolved {
Resolved::Badge(face) => {
let r = BADGE_D * k / 2.0;
let center = Point::new((x + r) as f32, cy as f32);
@@ -360,12 +457,7 @@ fn draw_glyph(
if style == GlyphStyle::Shapes {
draw_ps_shape(canvas, face, center, (4.6 * k) as f32, (1.7 * k) as f32);
} else {
let letter = match face {
Face::A => "A",
Face::B => "B",
Face::X => "X",
Face::Y => "Y",
};
let letter = face_letter(face, style);
let size = 12.0 * k;
let w = fonts.measure(letter, W::SemiBold, size) as f64;
fonts.draw(
@@ -379,6 +471,50 @@ fn draw_glyph(
);
}
}
Resolved::Ok => {
// The remote's select: the same badge as a face button, saying OK — the word
// printed on the remote itself.
let r = BADGE_D * k / 2.0;
let center = Point::new((x + r) as f32, cy as f32);
canvas.draw_circle(center, r as f32, &fill(fg(0.10)));
canvas.draw_circle(center, r as f32, &stroke(fg(0.32), (1.2 * k) as f32));
let size = 9.5 * k;
let w = fonts.measure("OK", W::SemiBold, size) as f64;
fonts.draw(
canvas,
"OK",
x + r - w / 2.0,
cy + size * 0.36,
W::SemiBold,
size,
fg(0.92),
);
}
Resolved::BackArrow => {
// The remote's back: the ↩ return arrow in the same badge — a shaft curving
// home with an arrowhead at its left end.
let r = BADGE_D * k / 2.0;
let center = Point::new((x + r) as f32, cy as f32);
canvas.draw_circle(center, r as f32, &fill(fg(0.10)));
canvas.draw_circle(center, r as f32, &stroke(fg(0.32), (1.2 * k) as f32));
let (cx, cyf) = (center.x, center.y);
let (half_w, rise) = ((4.6 * k) as f32, (3.2 * k) as f32);
let mut p = stroke(fg(0.92), (1.7 * k) as f32);
p.set_stroke_cap(skia_safe::PaintCap::Round);
p.set_stroke_join(skia_safe::PaintJoin::Round);
let mut path = PathBuilder::new();
path.move_to((cx + half_w, cyf - rise)); // the hook, up on the right…
path.line_to((cx + half_w, cyf + rise * 0.2)); // …dropping to the shaft…
path.line_to((cx - half_w, cyf + rise * 0.2)); // …running left toward the head.
canvas.draw_path(&path.detach(), &p);
let head = (2.6 * k) as f32;
let tip = cx - half_w;
let mut arrow = PathBuilder::new();
arrow.move_to((tip + head, cyf + rise * 0.2 - head));
arrow.line_to((tip, cyf + rise * 0.2));
arrow.line_to((tip + head, cyf + rise * 0.2 + head));
canvas.draw_path(&arrow.detach(), &p);
}
Resolved::Shoulders => {
let mut pen = x;
for label in ["L1", "R1"] {
@@ -514,6 +650,62 @@ mod tests {
GlyphStyle::from_pref(Some(GamepadPref::SteamDeck)),
GlyphStyle::Letters
);
assert_eq!(
GlyphStyle::from_pref(Some(GamepadPref::SwitchPro)),
GlyphStyle::Nintendo
);
assert_eq!(GlyphStyle::from_pref(None), GlyphStyle::Keyboard);
}
/// Nintendo's badges carry the pad's OWN engravings: the positional confirm (south) is
/// the button a Switch pad labels B. Everything non-Nintendo keeps the Xbox letters.
#[test]
fn nintendo_badges_read_the_pads_own_letters() {
assert_eq!(face_letter(Face::A, GlyphStyle::Nintendo), "B");
assert_eq!(face_letter(Face::B, GlyphStyle::Nintendo), "A");
assert_eq!(face_letter(Face::X, GlyphStyle::Nintendo), "Y");
assert_eq!(face_letter(Face::Y, GlyphStyle::Nintendo), "X");
assert_eq!(face_letter(Face::A, GlyphStyle::Letters), "A");
}
/// A remote has no Y/X, so those hints resolve to nothing — the legend must not
/// advertise a button the device in the user's hand cannot press. Confirm and Back
/// resolve to the remote's own marks, and the section hint points at the D-pad path.
#[test]
fn remote_hides_the_buttons_a_remote_does_not_have() {
assert!(resolved(HintKey::Secondary, GlyphStyle::Remote).is_none());
assert!(resolved(HintKey::Tertiary, GlyphStyle::Remote).is_none());
assert!(matches!(
resolved(HintKey::Confirm, GlyphStyle::Remote),
Some(Resolved::Ok)
));
assert!(matches!(
resolved(HintKey::Back, GlyphStyle::Remote),
Some(Resolved::BackArrow)
));
assert!(matches!(
resolved(HintKey::Shoulders, GlyphStyle::Remote),
Some(Resolved::Up)
));
// Every other style resolves every hint — nothing else went silent.
for style in [
GlyphStyle::Letters,
GlyphStyle::Shapes,
GlyphStyle::Nintendo,
GlyphStyle::Keyboard,
] {
for key in [
HintKey::Confirm,
HintKey::Back,
HintKey::Secondary,
HintKey::Tertiary,
HintKey::Shoulders,
HintKey::Adjust,
HintKey::Up,
HintKey::Down,
] {
assert!(resolved(key, style).is_some(), "{style:?} lost a hint");
}
}
}
}
+1 -1
View File
@@ -57,7 +57,7 @@ mod theme;
mod widgets;
#[cfg(any(target_os = "linux", windows, target_os = "android"))]
pub use console::{Console, ConsoleEntry, ConsoleHandles, Insets, Viewport};
pub use console::{Console, ConsoleEntry, ConsoleHandles, InputSource, Insets, Viewport};
#[cfg(any(target_os = "linux", windows, target_os = "android"))]
pub use input::Key;
#[cfg(any(target_os = "linux", windows, target_os = "android"))]
@@ -388,6 +388,7 @@ impl CollectionsScreen {
Rect::from_ltrb(pills as f32, strip.top, strip.right, strip.bottom),
&labels,
selected,
false,
fonts,
k,
dt,
@@ -1641,6 +1641,7 @@ impl LibraryScreen {
),
&sorts,
sort_at,
false,
fonts,
k,
dt,
@@ -1660,6 +1661,7 @@ impl LibraryScreen {
),
&views,
view_at,
false,
fonts,
k,
dt,
+94 -2
View File
@@ -20,7 +20,7 @@ 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::menu_nav::{MenuDir, MenuEvent, MenuPulse};
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
use skia_safe::{Canvas, Rect};
@@ -384,6 +384,11 @@ 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 tab strip holds the D-pad focus (Up from the list's top row steps onto it;
/// Down/A step back off). This is how a device with ONLY a D-pad — a Chromecast/Google
/// TV remote — switches tabs at all: the shoulder ring (L1/R1) and the Tab/PgUp/PgDn
/// keys don't exist there, and a field report lost every tab but the first to that.
strip_focus: bool,
/// 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
@@ -409,6 +414,7 @@ impl SettingsScreen {
tab: 0,
tab_cursors: [0; TABS.len()],
profiles,
strip_focus: false,
custom_bitrate: None,
keyboard: Keyboard::new(),
}
@@ -609,6 +615,11 @@ impl SettingsScreen {
self.show_tab(tab, ctx);
return true;
}
// A pointer press on the rows takes the focus back from the strip — direct
// manipulation names its own target.
if p.press() {
self.strip_focus = false;
}
let ids = self.row_ids(ctx);
self.clamp_cursor(ids.len());
let (msg, pulse) = self.list.pointer(p, ids.len());
@@ -628,6 +639,26 @@ impl SettingsScreen {
if self.custom_bitrate.is_some() {
return self.custom_menu(ev, ctx);
}
if self.strip_focus {
// The strip holds the D-pad focus: left/right travel the ring, down/A drop back
// to the rows, B still leaves the screen. This is the only tab path a device
// with no shoulders and no Tab key has (a TV remote).
return match ev {
MenuEvent::Back => {
fx.pop();
None
}
MenuEvent::Move(MenuDir::Left) | MenuEvent::JumpBack => self.switch_tab(-1, ctx),
MenuEvent::Move(MenuDir::Right) | MenuEvent::JumpForward => self.switch_tab(1, ctx),
MenuEvent::Move(MenuDir::Down) | MenuEvent::Confirm => {
self.strip_focus = false;
Some(MenuPulse::Move)
}
// The top of the screen — the same recoil the list's ends answer with.
MenuEvent::Move(MenuDir::Up) => Some(MenuPulse::Boundary),
_ => None,
};
}
match ev {
MenuEvent::Back => {
fx.pop();
@@ -635,6 +666,12 @@ impl SettingsScreen {
}
MenuEvent::JumpBack => return self.switch_tab(-1, ctx),
MenuEvent::JumpForward => return self.switch_tab(1, ctx),
// Up from the top row steps onto the tab strip instead of recoiling — the
// D-pad-only path to the other tabs.
MenuEvent::Move(MenuDir::Up) if self.list.cursor == 0 => {
self.strip_focus = true;
return Some(MenuPulse::Move);
}
_ => {}
}
let ids = self.row_ids(ctx);
@@ -765,6 +802,15 @@ impl SettingsScreen {
Hint::new(HintKey::Back, "Done"),
];
}
// The strip has the focus (a D-pad-only remote's tab path): say what the D-pad does
// up here, not what the rows would do.
if self.strip_focus {
return vec![
Hint::new(HintKey::Adjust, "Section"),
Hint::new(HintKey::Confirm, "Rows"),
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")];
@@ -812,6 +858,7 @@ impl SettingsScreen {
Rect::from_ltrb(rect.left, rect.top, rect.right, rect.top + strip_h as f32),
&labels,
self.tab,
self.strip_focus,
fonts,
k,
dt,
@@ -857,7 +904,8 @@ impl SettingsScreen {
fonts,
k,
dt,
self.custom_bitrate.is_none(),
// The rows rest their focus ring while the keyboard tray or the strip holds it.
self.custom_bitrate.is_none() && !self.strip_focus,
);
let detail = ids
.get(self.list.cursor)
@@ -2718,6 +2766,50 @@ pub(super) mod tests {
assert!(fx.nav.is_none() && fx.cmds.is_empty());
}
/// A D-pad alone reaches every tab: Up from the top row steps onto the strip,
/// left/right travel it, Down drops back into the rows. This is the only tab path a
/// Chromecast/Google TV remote has — no shoulders, no Tab key — and it regressed to
/// "first tab only" when the rows were split across tabs.
#[test]
fn dpad_alone_reaches_every_tab() {
let (mut settings, pads) = ctx_parts();
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
store: crate::store::file_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();
// Up from the top row focuses the strip instead of recoiling…
assert_eq!(s.list.cursor, 0);
s.menu(MenuEvent::Move(MenuDir::Up), &mut ctx, &mut fx);
assert!(s.strip_focus, "Up from the top row lands on the strip");
// …right travels the ring…
s.menu(MenuEvent::Move(MenuDir::Right), &mut ctx, &mut fx);
assert_eq!(s.tab, 1);
assert!(s.strip_focus, "switching keeps the strip focused");
s.menu(MenuEvent::Move(MenuDir::Left), &mut ctx, &mut fx);
s.menu(MenuEvent::Move(MenuDir::Left), &mut ctx, &mut fx);
assert_eq!(s.tab, PROFILES_TAB, "the strip wraps like the shoulders do");
// …and Down returns to the rows of the tab that's showing.
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
assert!(!s.strip_focus, "Down drops back into the list");
// While the list has focus, Left/Right still adjust rows — only the top row's Up
// reaches the strip, so a value row's chevrons keep meaning what they say.
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
assert!(!s.strip_focus);
// Focusing the strip is navigation, never a settings write.
assert!(fx.nav.is_none() && fx.cmds.is_empty());
}
/// The lossless opt-in: it ships OFF, steps the cross-client table verbatim, sits directly
/// under the channel count, and follows it — dim and inert under 5.1/7.1, because this
/// client's session refuses to ASK for lossless surround (see the `enabled` note in
+15
View File
@@ -289,6 +289,12 @@ pub(crate) struct Shell {
/// (or out of) calm alongside the screen transition.
bg_mix: f64,
glyphs: GlyphStyle,
/// What drove the console LAST — a pad or keys — noted at the input seams
/// ([`Shell::note_input_source`], [`Shell::key`]) and read by the per-frame glyph
/// resolution, so the legend speaks the language of the device actually in use.
/// `None` until anything drives: the style then follows the connected pad, or the
/// platform's key device where there is none.
input_source: Option<crate::console::InputSource>,
chip: Option<String>,
pads: Vec<PadInfo>,
/// The settled top screen's hint-bar hit boxes, republished every frame by
@@ -373,6 +379,7 @@ impl Shell {
ink,
bg_mix,
glyphs: GlyphStyle::Keyboard,
input_source: None,
chip: None,
pads: Vec::new(),
hint_rects: Vec::new(),
@@ -997,6 +1004,13 @@ impl Shell {
consumed
}
/// Note what produced the menu events now arriving — the hint legend follows it.
/// Called by [`crate::console::Console::menu`] (which is told by its host) and the
/// overlay's pad path; the keyboard path notes itself in [`Shell::key`].
pub(crate) fn note_input_source(&mut self, source: crate::console::InputSource) {
self.input_source = Some(source);
}
/// The keyboard fallback — the console is fully drivable with no pad. Arrows and
/// Enter/Esc map onto menu events; Y/X mirror the pad's Secondary/Tertiary
/// (suppressed while editing, where letters are text).
@@ -1004,6 +1018,7 @@ impl Shell {
/// `shift` only matters for Tab, whose two directions are one key.
pub(crate) fn key(&mut self, key: crate::input::Key, shift: bool, repeat: bool) -> bool {
use crate::input::Key as S;
self.input_source = Some(crate::console::InputSource::Keys);
if self.editing() {
let mut ctx = Ctx {
hosts: &self.hosts,
+89 -2
View File
@@ -81,12 +81,16 @@ impl Shell {
let reduce = self.settings.reduce_motion;
crate::theme::set_reduce_motion(reduce);
self.pads = pads.to_vec();
self.glyphs = GlyphStyle::from_pref(pad_pref);
self.glyphs = glyph_style(self.input_source, pad_pref, self.platform);
// 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");
let chip = pad.unwrap_or(if self.glyphs == GlyphStyle::Remote {
"TV remote — a controller works too"
} else {
"No controller — keyboard works too"
});
if self.chip.as_deref() != Some(chip) {
self.chip = Some(chip.to_owned());
}
@@ -427,3 +431,86 @@ impl LayerEnv<'_> {
rects
}
}
/// The glyph style for this frame: the last input source rules — keys speak the
/// platform's key device (a TV remote on Android, a keyboard on the desktop), a pad
/// speaks its own family ([`GlyphStyle::from_pref`]). Before anything has driven, the
/// connected pad's family shows if there is one (a pad in hand is what a fresh console
/// will most likely be driven by), else the platform's key device.
fn glyph_style(
source: Option<crate::console::InputSource>,
pad_pref: Option<punktfunk_core::config::GamepadPref>,
platform: crate::platform::Platform,
) -> GlyphStyle {
let keys = || match platform {
crate::platform::Platform::Android => GlyphStyle::Remote,
crate::platform::Platform::Desktop => GlyphStyle::Keyboard,
};
match (source, pad_pref) {
(Some(crate::console::InputSource::Keys), _) => keys(),
(_, Some(p)) => GlyphStyle::from_pref(Some(p)),
(_, None) => keys(),
}
}
#[cfg(test)]
mod glyph_style_tests {
use super::*;
use crate::console::InputSource;
use crate::platform::Platform;
use punktfunk_core::config::GamepadPref;
/// The matrix the field report walked: a Chromecast (Android, no pad) used to show
/// keyboard keycaps — Enter/Esc/Tab, none of which its remote has. Keys on Android
/// now read as the remote, keys on the desktop as the keyboard, a driving pad as its
/// own family — and a pad that vanishes mid-session falls back to the platform's key
/// device rather than freezing on the departed pad's letters.
#[test]
fn the_legend_follows_what_drives() {
let xbox = Some(GamepadPref::Xbox360);
// Untouched console: the connected pad's family, else the platform's key device.
assert_eq!(
glyph_style(None, xbox, Platform::Android),
GlyphStyle::Letters
);
assert_eq!(
glyph_style(None, None, Platform::Android),
GlyphStyle::Remote
);
assert_eq!(
glyph_style(None, None, Platform::Desktop),
GlyphStyle::Keyboard
);
// Keys drove last: the key device, even with a pad still connected.
assert_eq!(
glyph_style(Some(InputSource::Keys), xbox, Platform::Android),
GlyphStyle::Remote
);
assert_eq!(
glyph_style(Some(InputSource::Keys), xbox, Platform::Desktop),
GlyphStyle::Keyboard
);
// A pad drove last: its family — and Nintendo reads Nintendo.
assert_eq!(
glyph_style(
Some(InputSource::Pad),
Some(GamepadPref::SwitchPro),
Platform::Desktop
),
GlyphStyle::Nintendo
);
assert_eq!(
glyph_style(
Some(InputSource::Pad),
Some(GamepadPref::DualSense),
Platform::Android
),
GlyphStyle::Shapes
);
// The pad drove, then unplugged: back to the platform's key device.
assert_eq!(
glyph_style(Some(InputSource::Pad), None, Platform::Android),
GlyphStyle::Remote
);
}
}
+11
View File
@@ -1348,6 +1348,17 @@ fn dump_console_screens() {
s.set_connecting(None);
s.session_failed("Connection timed out");
dump(&mut s, 10, 8, "10-toast", true);
// The TV-remote legend (Android platform, keys driving): the OK and ↩ badges, the
// ▲ section pointer, the hidden Y/X hints, and the remote chip mark — Home and the
// hint-dense Settings. The platform flip is legends-only for these two frames; the
// stack was built desktop, so only the glyphs and the Android row set differ.
dump(&mut s, 30, 8, "_remote-settle", true);
s.platform = crate::platform::Platform::Android;
s.note_input_source(crate::console::InputSource::Keys);
dump(&mut s, 40, 8, "11-home-remote", false);
s.handle_menu(MenuEvent::Tertiary);
dump(&mut s, 40, 8, "11b-settings-remote", false);
}
/// A 2:3 poster, PNG-encoded, in a colour derived from `seed`.
+6 -1
View File
@@ -301,7 +301,12 @@ impl Overlay for SkiaOverlay {
fn handle_menu(&mut self, event: MenuEvent) -> Option<MenuPulse> {
if self.console_visible() {
self.shell.as_mut().and_then(|s| s.handle_menu(event))
self.shell.as_mut().and_then(|s| {
// The presenter's menu_rx carries pad events only (its keyboard goes
// through `key`), so this seam IS the pad source note.
s.note_input_source(crate::console::InputSource::Pad);
s.handle_menu(event)
})
} else {
None
}
+17 -6
View File
@@ -708,6 +708,10 @@ impl TabStrip {
/// Draw the pills along the leading edge of `rect`'s top band, at the same
/// [`EDGE_INSET`] the heading above them uses. Returns nothing — the caller already
/// knows the band is [`TAB_STRIP_H`] tall.
///
/// `focused` = the strip itself holds the D-pad focus (a remote with no shoulder
/// buttons steps onto it from the list's top row): the highlight brightens and grows
/// chevrons, the same affordance a focused value row shows for left/right.
#[allow(clippy::too_many_arguments)] // the crate's render signature, same as MenuList's
pub(crate) fn render(
&mut self,
@@ -715,6 +719,7 @@ impl TabStrip {
rect: Rect,
labels: &[&str],
selected: usize,
focused: bool,
fonts: &Fonts,
k: f64,
dt: f64,
@@ -770,10 +775,16 @@ impl TabStrip {
canvas,
Rect::from_xywh(ix as f32, top as f32, iw as f32, pill_h as f32),
(pill_h / 2.0 / k) as f32,
Some(accent(0.85)),
PanelStroke::Plain(0.22),
Some(accent(if focused { 1.0 } else { 0.85 })),
PanelStroke::Plain(if focused { 0.5 } else { 0.22 }),
k as f32,
);
if focused {
// The focused value row's affordance, on the strip: left/right travel here.
let cy = top + pill_h / 2.0;
chevron(canvas, ix - 9.0 * k, cy, 4.0 * k, true, 0.9);
chevron(canvas, ix + iw + 9.0 * k, cy, 4.0 * k, false, 0.9);
}
let baseline = top + pill_h / 2.0 + size * 0.36;
self.pills.clear();
@@ -1277,18 +1288,18 @@ mod tests {
let dt = 1.0 / 60.0;
// Seat on the first tab, then a 5-step burst at one press per frame — far faster
// than the spring can settle, which is the whole point.
strip.render(surface.canvas(), rect, &TABS, 0, &fonts, 1.0, dt);
strip.render(surface.canvas(), rect, &TABS, 0, false, &fonts, 1.0, dt);
let mut worst_left = f64::MAX;
let mut worst_right = f64::MIN;
for sel in 1..=5 {
strip.render(surface.canvas(), rect, &TABS, sel, &fonts, 1.0, dt);
strip.render(surface.canvas(), rect, &TABS, sel, false, &fonts, 1.0, dt);
let (ix, iw) = strip.indicator.map(|(x, w)| (x.pos, w.pos)).unwrap();
worst_left = worst_left.min(ix);
worst_right = worst_right.max(ix + iw);
}
// Then let it land.
for _ in 0..240 {
strip.render(surface.canvas(), rect, &TABS, 5, &fonts, 1.0, dt);
strip.render(surface.canvas(), rect, &TABS, 5, false, &fonts, 1.0, dt);
let (ix, iw) = strip.indicator.map(|(x, w)| (x.pos, w.pos)).unwrap();
worst_left = worst_left.min(ix);
worst_right = worst_right.max(ix + iw);
@@ -1324,7 +1335,7 @@ mod tests {
let mut run = |w: f32, k: f64| {
let rect = Rect::from_xywh(0.0, 0.0, w, (TAB_STRIP_H * k) as f32);
let mut strip = TabStrip::new();
strip.render(surface.canvas(), rect, &TABS, 0, &fonts, k, dt);
strip.render(surface.canvas(), rect, &TABS, 0, false, &fonts, k, dt);
let first = strip.pill(0).expect("the first section was drawn");
let last = strip
.pill(TABS.len() - 1)
+159 -5
View File
@@ -137,11 +137,22 @@ impl KwinDisplay {
/// absent. Records the output's UUID (in-process) or kscreen address (fallback) for
/// [`apply_position`](VirtualDisplay::apply_position), and returns the disabled outputs (each
/// `(name, "WxH@Hz")`) for the group teardown restore. `Extend`/`Auto` disable nothing.
///
/// `pre_enabled` is the non-managed outputs that were enabled BEFORE the virtual output was
/// created ([`enabled_physicals`], captured in `create`). It exists because KWin reacts to our
/// output appearing by applying its stored setup for the NEW monitor set
/// (`kwinoutputconfig.json`), and a set that ever ran `exclusive` has "physicals disabled"
/// stored — so the physicals can be OFF by the time any post-create enumeration runs. A field
/// report (Bazzite, triple-monitor) showed exactly that: `also_disabled=[]` on an exclusive
/// apply, so teardown restored nothing and the desk stayed dark. The snapshot is the only read
/// KWin's reaction cannot have polluted: under `Exclusive` it joins the restore list, under
/// every other topology [`reenable_stranded`] puts the stored-config casualties back on.
fn apply_topology(
&mut self,
name: &str,
our_prefix: &str,
dims: (u32, u32),
pre_enabled: &[(String, String)],
) -> Vec<(String, String)> {
use crate::kwin_output_mgmt::TopologyKind;
use crate::policy::Topology;
@@ -155,6 +166,10 @@ impl KwinDisplay {
// (stable) output name for whatever monitor set it was saved under. Applies only if
// it really is mirroring; nothing else about the user's arrangement is touched.
crate::kwin_output_mgmt::clear_replication_source(our_prefix, dims.0, dims.1);
// ...and the same goes for the stored ENABLED state: these topologies promise the
// user's screens stay untouched, so KWin switching them off in reaction to our
// output appearing is undone, not honored.
reenable_stranded(pre_enabled.to_vec());
return Vec::new();
}
};
@@ -162,7 +177,13 @@ impl KwinDisplay {
let outcome = crate::kwin_output_mgmt::apply_topology(our_prefix, dims.0, dims.1, kind);
if outcome.handled {
self.our_uuid = outcome.our_uuid;
return outcome.disabled;
if kind == TopologyKind::Primary {
// `Primary` keeps the physicals enabled by contract — undo KWin's stored-config
// disable exactly as the Extend arm does. Nothing to restore at teardown.
reenable_stranded(pre_enabled.to_vec());
return outcome.disabled;
}
return union_restore(outcome.disabled, pre_enabled);
}
// Fallback: kscreen-doctor — resolve our address the old way, then shell out the topology.
tracing::info!(
@@ -171,9 +192,10 @@ impl KwinDisplay {
let addr = resolve_kscreen_addr(name, dims.0, dims.1);
self.last_name = Some(addr.clone());
match topology {
Topology::Exclusive => apply_virtual_primary(&addr),
Topology::Exclusive => union_restore(apply_virtual_primary(&addr), pre_enabled),
Topology::Primary => {
apply_virtual_primary_only(&addr);
reenable_stranded(pre_enabled.to_vec());
Vec::new()
}
Topology::Extend | Topology::Auto => Vec::new(),
@@ -324,6 +346,10 @@ impl VirtualDisplay for KwinDisplay {
// install — the output is born at the real size and 60 Hz is the offer anyway.
let want_high = mode.refresh_hz > 60;
let birth_h = if want_high { height + 16 } else { height };
// Snapshot the enabled physicals BEFORE the virtual output exists: creating it changes the
// monitor set, and KWin may apply a stored setup for the new set that disables them (see
// `apply_topology`). Everything read after this point can already be polluted by that.
let pre_enabled = enabled_physicals();
let (mut node_id, mut stop) = spawn_vout(width, birth_h)?;
// `requested_*`, NOT `width`/`height`: `spawn_vout` hands back a node id, never a size, so
// every number on this line is what we ASKED for. Logged as `width=… height=…` it read like
@@ -534,7 +560,7 @@ impl VirtualDisplay for KwinDisplay {
// bootstrap output. Applied over kde_output_management_v2 in-process (immune to a wedged
// kscreen-doctor backend; see `apply_topology`), with a kscreen-doctor fallback. `disabled`
// is the physical/bootstrap outputs, each `(name, "WxH@Hz")`, to restore on teardown.
let disabled = self.apply_topology(&name, &our_prefix, final_dims);
let disabled = self.apply_topology(&name, &our_prefix, final_dims, &pre_enabled);
// `last_name` is already the best address we have: `Virtual-<name>` from the top of this
// function, upgraded in place to the RESOLVED numeric kscreen id by whichever of the
// `want_high` fallback or `apply_topology`'s fallback actually ran a resolve. Nothing to
@@ -561,6 +587,21 @@ impl VirtualDisplay for KwinDisplay {
if !crate::kwin_output_mgmt::reenable_outputs(&disabled) {
reenable_outputs_kscreen(&disabled);
}
// This ran BEFORE our output is reclaimed (§6.1 ordering, so KWin never sees zero
// outputs) — which means it applied under the WITH-us monitor set. Reclaiming the
// output then flips KWin to the without-us set, whose stored setup can put the
// physicals straight back to disabled (how the field report's desk ended up dark
// after every stream). One delayed re-assert lands after the reclaim, under THAT
// set — and being a user-applied config, KWin persists it there, healing the
// stored setup instead of re-fighting it next session. One shot, never a loop.
let verify = disabled.clone();
std::thread::Builder::new()
.name("punktfunk-kwin-restore-verify".into())
.spawn(move || {
std::thread::sleep(STRAND_RECHECK_DELAY);
reenable_pass(&verify, "post-teardown", true);
})
.ok();
}) as Box<dyn FnOnce() + Send>
});
// Layout position (§6.2) is applied by the registry via `apply_position` right after create
@@ -575,6 +616,95 @@ impl VirtualDisplay for KwinDisplay {
}
}
/// How long after a create/teardown to re-check for outputs KWin's stored setup switched off.
/// KWin applies the stored setup for a changed monitor set promptly, but not synchronously with
/// our reads — the immediate pass catches the common case, this delayed one the late apply.
const STRAND_RECHECK_DELAY: Duration = Duration::from_millis(2000);
/// The non-managed outputs currently enabled, each `(name, "WxH@Hz")` — the same spec shape the
/// restore path stores, so the two lists interchange. Empty when output management is unavailable
/// (the callers then simply keep today's behavior).
fn enabled_physicals() -> Vec<(String, String)> {
crate::kwin_output_mgmt::list_monitors()
.map(|ms| {
ms.into_iter()
.filter(|m| m.enabled && !m.managed && m.width > 0 && m.height > 0)
.map(|m| {
let hz = ((m.refresh_mhz as f64) / 1000.0).round() as u32;
(m.connector, format!("{}x{}@{hz}", m.width, m.height))
})
.collect()
})
.unwrap_or_default()
}
/// Undo KWin's stored-config reaction to our output appearing: any output in `pre_enabled` (lit
/// BEFORE the virtual output was created) that is disabled now was switched off by KWin's persisted
/// setup for the new monitor set — the enabled-state sibling of the stored `replicationSource`
/// that [`crate::kwin_output_mgmt::clear_replication_source`] clears. Two passes: one now, one
/// after [`STRAND_RECHECK_DELAY`] (KWin can apply the stored setup after our first read). Each is
/// one shot, so a user's own later disable stays honored.
fn reenable_stranded(pre_enabled: Vec<(String, String)>) {
if pre_enabled.is_empty() {
return;
}
reenable_pass(&pre_enabled, "immediate", false);
std::thread::Builder::new()
.name("punktfunk-kwin-reenable".into())
.spawn(move || {
std::thread::sleep(STRAND_RECHECK_DELAY);
reenable_pass(&pre_enabled, "delayed", false);
})
.ok();
}
/// One re-enable pass: re-read the outputs, re-enable `expected ∩ now-disabled`. `abort_if_managed`
/// is the post-teardown guard: a managed output present by then means a NEW session already owns
/// the topology (a quick reconnect), and lighting the physicals under its `exclusive` would undo
/// it — that session's own restore covers them instead.
fn reenable_pass(expected: &[(String, String)], wave: &'static str, abort_if_managed: bool) {
let Ok(now) = crate::kwin_output_mgmt::list_monitors() else {
return;
};
if abort_if_managed && now.iter().any(|m| m.managed) {
return;
}
let dark: Vec<(String, String)> = expected
.iter()
.filter(|(name, _)| now.iter().any(|m| &m.connector == name && !m.enabled))
.cloned()
.collect();
if dark.is_empty() {
return;
}
tracing::warn!(
outputs = ?dark,
wave,
"KWin's stored output setup (kwinoutputconfig.json) left physical output(s) disabled that \
the current topology says stay enabled re-enabling them"
);
if !crate::kwin_output_mgmt::reenable_outputs(&dark) {
reenable_outputs_kscreen(&dark);
}
}
/// The `exclusive` restore list: what this apply disabled, plus every `pre_enabled` output not
/// already in it — an output KWin's stored setup disabled in the create window was invisible to
/// the apply's own enumeration, and dropping it from the list is how a teardown restored nothing
/// on a triple-monitor box (`also_disabled=[]`). Re-enabling an output that was never actually
/// disabled is a documented no-op, so the union errs on the side of the desk lighting up.
fn union_restore(
mut disabled: Vec<(String, String)>,
pre_enabled: &[(String, String)],
) -> Vec<(String, String)> {
for (name, spec) in pre_enabled {
if !disabled.iter().any(|(n, _)| n == name) {
disabled.push((name.clone(), spec.clone()));
}
}
disabled
}
/// Re-enable the outputs an `exclusive` topology disabled (bootstrap / physical) via `kscreen-doctor`
/// — the fallback for the in-process [`crate::kwin_output_mgmt::reenable_outputs`], run by the restore
/// closure only when the in-process path reports the compositor didn't answer. Called by the registry
@@ -2044,10 +2174,34 @@ fn await_created(
#[cfg(test)]
mod tests {
use super::{
mode_satisfies, modes_from_json, monitors_from_kscreen_json, pick_custom_mode, KModeRow,
MANAGED_PREFIX,
mode_satisfies, modes_from_json, monitors_from_kscreen_json, pick_custom_mode,
union_restore, KModeRow, MANAGED_PREFIX,
};
/// The field failure the union guards: KWin's stored setup for the with-us monitor set
/// disabled the physicals in the window between our output's creation and the exclusive
/// apply's enumeration, so the apply saw nothing enabled (`also_disabled=[]`) and teardown
/// restored nothing — a triple-monitor desk stranded dark. The pre-create snapshot must
/// reach the restore list; what the apply itself disabled keeps its (fresher) entry.
#[test]
fn the_restore_list_covers_outputs_kwin_disabled_before_the_apply_saw_them() {
let pre = vec![
("DP-1".to_string(), "2560x1440@144".to_string()),
("DP-2".to_string(), "2560x1440@60".to_string()),
("DP-3".to_string(), "1920x1080@60".to_string()),
];
// The log's case: the apply enumerated nothing enabled.
assert_eq!(union_restore(Vec::new(), &pre), pre);
// The healthy case: the apply's own capture wins for the outputs it saw (its spec is
// the fresher read), and the snapshot only fills the gaps.
let seen = vec![("DP-1".to_string(), "2560x1440@120".to_string())];
let merged = union_restore(seen, &pre);
assert_eq!(merged.len(), 3);
assert_eq!(merged[0], ("DP-1".to_string(), "2560x1440@120".to_string()));
assert!(merged.contains(&("DP-2".to_string(), "2560x1440@60".to_string())));
assert!(merged.contains(&("DP-3".to_string(), "1920x1080@60".to_string())));
}
/// The field failure this predicate now guards, in the shape the log reported it: a client
/// negotiated 3840x2160, KWin restored a stored 1920x1080 for the output name, and nothing
/// compared the two — so the session captured 1080p, encoded 1080p, and shipped it to a client
+60 -1
View File
@@ -552,10 +552,27 @@ impl BitrateController {
}
/// Teach the controller what this session's mode and codec could plausibly use (see
/// [`stream_ceiling_kbps`]). Applied to LEARNED ceilings only, at the same funnel as the
/// [`stream_ceiling_kbps`]). Bounds future LEARNED ceilings at the same funnel as the
/// operator's env cap.
///
/// The FIRST set is the session's negotiated shape and keeps the founding semantics — a
/// negotiated start rate above it stands, the host resolved that number (pinned by
/// `the_stream_bound_clamps_a_learned_ceiling_only`). A RE-set is a mode switch, and
/// there a DROP in pixel rate also rebinds the already-standing ceiling: `set_ceiling`
/// clamps only at learn time and deliberately never lowers, so a 4K-learned ceiling
/// would otherwise stand over a 720p stream with only the reactive loss/decode signals
/// to bound the climb (review §2.1).
pub(crate) fn set_stream_cap(&mut self, kbps: u32) {
let mode_switch = self.stream_cap_kbps.is_some();
self.stream_cap_kbps = Some(kbps);
if mode_switch && self.enabled && self.ceiling_kbps > kbps {
tracing::info!(
ceiling_kbps = self.ceiling_kbps,
stream_cap_kbps = kbps,
"adaptive bitrate: ceiling rebound to the switched mode's stream shape"
);
self.ceiling_kbps = kbps;
}
}
/// Teach the controller this session's refresh rate, so the encode thresholds can be sized in
@@ -1551,6 +1568,48 @@ mod tests {
);
}
/// Review §2.1: the stream-shape cap was computed once from the Welcome mode and never
/// again — 1080p→4K kept a 1080p-sized climb ceiling, 4K→720p left an oversized one
/// standing. A mode switch now re-teaches the cap: an upswitch opens room for the probe's
/// measurement to authorize more, a downswitch rebinds the already-learned ceiling.
#[test]
fn a_mode_switch_reteaches_the_stream_cap_both_ways() {
// 1080p session, probe measured a fat link: ceiling bound at the 1080p shape.
let mut c = BitrateController::new(20_000);
c.set_stream_cap(100_000);
c.set_ceiling(657_000);
assert_eq!(c.ceiling_kbps, 100_000);
// Switch UP to 4K: the new shape allows more, and the probe's measurement (already
// taken this session) may re-authorize up to it.
c.on_mode_switch();
c.set_stream_cap(400_000);
assert_eq!(
c.ceiling_kbps, 100_000,
"an upswitch alone raises nothing — authority still needs a measurement"
);
c.set_ceiling(657_000);
assert_eq!(
c.ceiling_kbps, 400_000,
"the 4K shape no longer pins the session to the 1080p bound"
);
// Switch DOWN to 720p: the learned 4K ceiling must not stand over the small stream —
// `set_ceiling` never lowers, so the re-taught cap is what rebinds it.
c.on_mode_switch();
c.set_stream_cap(42_000);
assert_eq!(
c.ceiling_kbps, 42_000,
"a downswitch rebinds the already-learned ceiling"
);
// A disabled controller (explicit bitrate) is untouched by all of it.
let mut d = BitrateController::new(0);
d.set_stream_cap(100_000);
d.set_stream_cap(42_000);
assert_eq!(d.ceiling_kbps, 0);
}
#[test]
fn owd_rise_alone_is_a_congestion_signal() {
let mut c = BitrateController::new(20_000);
+14 -3
View File
@@ -86,6 +86,10 @@ pub(super) async fn run_pump(args: WorkerArgs) {
let clock_rtt_ns = negotiated.clock_rtt_ns;
let resolved_bitrate_kbps = negotiated.bitrate_kbps;
let negotiated_codec = negotiated.codec;
// Session constants a mode switch does not change — the pump recomputes the stream-shape
// cap from them for the switched geometry (review §2.1).
let bit_depth = negotiated.bit_depth;
let chroma_format = negotiated.chroma_format;
// What this session's mode + codec could plausibly use — the bound the ABR holds its
// probe-measured link ceiling to. Computed here because this is where the Welcome-resolved
// geometry lives; the data pump stays codec-agnostic.
@@ -164,9 +168,14 @@ pub(super) async fn run_pump(args: WorkerArgs) {
}
});
// Adaptive bitrate ack slot: the control task parks the latest BitrateChanged here; the
// pump's controller drains it on its report tick (`take()` — an ack is consumed once).
let bitrate_ack: Arc<Mutex<Option<u32>>> = Arc::new(Mutex::new(None));
// Adaptive bitrate ack queue: the control task pushes every BitrateChanged; the pump's
// controller drains them in arrival order on its report tick. A QUEUE, not a latest-wins
// slot (review §2.4): a full resolve ack plus a corrective short retarget in the same
// 750 ms window used to collapse to whichever arrived last, and host-cap learning needs
// two CONSECUTIVE short acks — losing one delayed or prevented the cap and could
// reintroduce the encoder-overdrive sawtooth.
let bitrate_ack: Arc<Mutex<std::collections::VecDeque<u32>>> =
Arc::new(Mutex::new(std::collections::VecDeque::new()));
// Decode-recovery keyframe asks (the ABR recovery signal): the control task counts every
// outbound `CtrlRequest::Keyframe` — the one choke point all emitters funnel through — and
// the pump drains the count per report window.
@@ -279,6 +288,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
bitrate_kbps,
resolved_bitrate_kbps,
negotiated_codec,
bit_depth,
chroma_format,
stream_cap_kbps,
refresh_hz,
mode_slot: mode_slot_pump,
@@ -15,7 +15,7 @@ pub(super) struct ControlTask {
pub(super) mode_slot: Arc<Mutex<Mode>>,
pub(super) probe: Arc<Mutex<ProbeState>>,
/// The latest host `BitrateChanged` ack, drained by the pump's ABR on its report tick.
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
pub(super) bitrate_ack: Arc<Mutex<std::collections::VecDeque<u32>>>,
/// The live encoder-target mirror ([`NativeClient::current_bitrate_kbps`]): unlike the
/// drain-once ack slot above, this one always holds the latest acked rate for stats HUDs.
pub(super) live_bitrate: Arc<AtomicU32>,
@@ -200,7 +200,7 @@ impl ControlTask {
if ack.bitrate_kbps > 0 {
live_bitrate.store(ack.bitrate_kbps, Ordering::Relaxed);
}
*bitrate_ack.lock().unwrap() = Some(ack.bitrate_kbps);
bitrate_ack.lock().unwrap().push_back(ack.bitrate_kbps);
} else if let Ok(gap) = crate::quic::PipelineGap::decode(&msg) {
// The host rebuilt its capture ring + encoder in place and nothing flowed
// while it did. Park it for the pump, which discards the report window in
+33 -6
View File
@@ -27,7 +27,10 @@ pub(super) struct DataPump {
pub(super) mode_gen: Arc<AtomicU32>,
pub(super) frames_dropped: Arc<std::sync::atomic::AtomicU64>,
pub(super) fec_recovered: Arc<std::sync::atomic::AtomicU64>,
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
/// Host `BitrateChanged` acks since the last report tick, drained in arrival order — a
/// queue so a corrective short retarget can't be clobbered by a full resolve ack in the
/// same window (review §2.4; host-cap learning needs two CONSECUTIVE short acks).
pub(super) bitrate_ack: Arc<Mutex<std::collections::VecDeque<u32>>>,
/// Outbound decode-recovery keyframe asks, counted by the control task at its send choke
/// point; drained per report window as the ABR's recovery signal.
pub(super) recovery_kf: Arc<AtomicU32>,
@@ -40,9 +43,15 @@ pub(super) struct DataPump {
/// The rate the host actually configured (echoed in Welcome).
pub(super) resolved_bitrate_kbps: u32,
pub(super) negotiated_codec: u8,
/// The negotiated encode bit depth and chroma wire byte — session constants a mode switch
/// does NOT change, carried so the stream-shape cap can be recomputed for a new geometry
/// (review §2.1).
pub(super) bit_depth: u8,
pub(super) chroma_format: u8,
/// What this session's mode + codec could plausibly use (see
/// [`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.
/// to. Computed where the negotiated geometry lives; recomputed here on an accepted mode
/// switch (review §2.1).
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`]).
@@ -74,6 +83,8 @@ impl DataPump {
bitrate_kbps,
resolved_bitrate_kbps,
negotiated_codec,
bit_depth,
chroma_format,
stream_cap_kbps,
refresh_hz,
mode_slot: pump_mode_slot,
@@ -550,12 +561,26 @@ impl DataPump {
if mg != seen_mode_gen {
seen_mode_gen = mg;
abr.on_mode_switch();
let m = *pump_mode_slot.lock().unwrap();
// 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);
abr.set_frame_budget(m.refresh_hz);
// So is the stream-shape cap (review §2.1): computed once from the
// Welcome mode, 1080p→4K kept a 1080p-sized climb ceiling (under-running
// quality on a fat link) and 4K→720p left an oversized cap standing with
// only the reactive loss/decode signals to bound the climb.
// `set_stream_cap` also rebinds an already-learned ceiling downward.
abr.set_stream_cap(crate::abr::stream_ceiling_kbps(
m.width,
m.height,
m.refresh_hz,
negotiated_codec,
bit_depth,
chroma_format,
));
}
if let Some(acked) = bitrate_ack.lock().unwrap().take() {
for acked in bitrate_ack.lock().unwrap().drain(..) {
abr.on_ack(acked);
}
let owd_mean_us =
@@ -1028,7 +1053,7 @@ mod tests {
refresh_hz: 60,
})),
probe: Arc::new(Mutex::new(ProbeState::default())),
bitrate_ack: Arc::new(Mutex::new(None)),
bitrate_ack: Arc::new(Mutex::new(std::collections::VecDeque::new())),
live_bitrate: Arc::new(AtomicU32::new(0)),
recovery_kf: Arc::new(AtomicU32::new(0)),
pipeline_gap: pipeline_gap.clone(),
@@ -1064,12 +1089,14 @@ mod tests {
mode_gen: Arc::new(AtomicU32::new(0)),
frames_dropped: Arc::new(std::sync::atomic::AtomicU64::new(0)),
fec_recovered: Arc::new(std::sync::atomic::AtomicU64::new(0)),
bitrate_ack: Arc::new(Mutex::new(None)),
bitrate_ack: Arc::new(Mutex::new(std::collections::VecDeque::new())),
recovery_kf: Arc::new(AtomicU32::new(0)),
pipeline_gap: pipeline_gap.clone(),
bitrate_kbps: 20_000,
resolved_bitrate_kbps: 20_000,
negotiated_codec: crate::quic::CODEC_HEVC,
bit_depth: 8,
chroma_format: 0,
stream_cap_kbps: 100_000,
refresh_hz: 60,
mode_slot: Arc::new(Mutex::new(crate::config::Mode {
+36 -3
View File
@@ -643,9 +643,11 @@ impl Reassembler {
// Slice frames have no uniform shape to demand — the invariant is positional:
// every sentinel block must sit strictly below the final block's base
// (`total_data data_shards`; the firewall already proved the subtraction
// safe) and be a non-final index. Sentinel-vs-sentinel overlap is not policed
// the sender is AEAD-authenticated, so a lying base can only corrupt this
// frame's own pixels, never memory (placement stays in-bounds by these checks).
// safe) and be a non-final index. Sentinel-vs-sentinel overlap is not policed
// HERE — placement stays in-bounds by these checks, so a lying base can only
// corrupt this frame's own bytes, never memory — but the completion tiling
// check below refuses to deliver such a frame (black-band corruption from a
// buggy, AEAD-authenticated sender would otherwise ship as `complete`).
let final_base = total_data - data_shards;
frame.blocks.iter().any(|(&bi, b)| {
let bi = bi as usize;
@@ -881,6 +883,31 @@ impl Reassembler {
reconstructed_shards(&done.blocks, lim.max_data_shards),
);
*in_flight_bytes -= frame_cost(&done); // buffer + block state, before the truncate below
// Slice-streamed frames: every base was bounds-checked on arrival (in range,
// below the final block) but nothing yet proved the blocks TILE the AU. A base
// that lies WITHIN bounds leaves a zero gap and an overlap — wrong bytes in a
// frame stamped `complete`, which the decoder paints as garbage rectangles and
// no loss counter ever moves. Refuse to deliver: the index is already in
// `completed` (stragglers can't resurrect it), so just count the loss — the
// `frames_dropped` climb is what fires the client's recovery request.
if done.user_flags & crate::packet::USER_FLAG_SLICE_STREAM != 0 {
let total_data = done.frame_bytes.div_ceil(done.shard_bytes).max(1);
let mut next = 0usize;
let tiled = (0..block_count).all(|bi| match done.blocks.get(&(bi as u16)) {
Some(b) if b.base_shard == next => {
next += b.data_shards;
true
}
_ => false,
}) && next == total_data;
if !tiled {
if !is_probe {
StatsCounters::add(&stats.frames_dropped, 1);
}
drop(stats);
return Ok(None);
}
}
done.buf.truncate(done.frame_bytes); // trim trailing-shard zero padding
// Slice-progressive consumers already hold the delivered prefix — the completing
// packet hands up only the SUFFIX (with `last`), or the degenerate whole-AU part
@@ -925,6 +952,12 @@ impl Reassembler {
if block_count != 0 && (*next_part_block as usize) + 1 >= block_count {
break;
}
// A prefix is only a prefix if this block starts where the last one ended —
// a slice block whose wire base lies within bounds must not extend it (the
// frame then dies at the completion tiling check above).
if b.base_shard != *delivered_shards {
break;
}
*delivered_shards = b.base_shard + b.data_shards;
*next_part_block += 1;
}
+50
View File
@@ -1186,6 +1186,56 @@ fn slice_streamed_lying_final_kills_frame() {
assert_eq!(stats.snapshot().frames_dropped, 1);
}
/// Completion tiling check: a sentinel base that lies WITHIN every bounds check (in range,
/// below the final block) but breaks the tiling — a gap at the honest base, an overlap at
/// the claimed one — must NOT be delivered as a `complete` frame (the black-band corruption
/// shape: wrong-offset bytes with zeros in the gap and no loss counter moving). The frame
/// is counted lost instead, which is what fires the client's recovery request.
#[test]
fn slice_streamed_lying_base_within_bounds_kills_frame() {
let (pkts, _) = slice_streamed_packets();
let hdr_of = |p: &Vec<u8>| PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
// Shift block 1's base from shard 19 (304 B) to shard 20 (320 B) on EVERY packet of the
// block (the base is pinned by the block's first packet, so all must agree). Still
// shard-aligned, still 20 + 26 = 46 ≤ 63 (the final block's base) — every pre-fix
// check passes, and the frame would have completed with a one-shard zero gap at 19
// and block 1's last shard overwriting block 2's first.
let delivery: Vec<Vec<u8>> = pkts
.iter()
.map(|p| {
let mut h = hdr_of(p);
if h.block_count == 0 && h.block_index == 1 {
let mut p = p.clone();
h.frame_bytes = 320;
p[..HEADER_LEN].copy_from_slice(h.as_bytes());
p
} else {
p.clone()
}
})
.collect();
let cfg = slice_config();
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let coder = coder_for(FecScheme::Gf16);
let stats = StatsCounters::default();
assert!(
push_all(&mut r, coder.as_ref(), &stats, &delivery).is_none(),
"a mis-tiled frame must never be delivered"
);
assert_eq!(
stats.snapshot().frames_dropped,
1,
"the mis-tiled frame must be counted lost"
);
assert_eq!(r.in_flight(), 0, "the killed frame must release its budget");
// Its packets are stragglers for a terminated index now — no resurrection, no recount.
assert!(push_all(&mut r, coder.as_ref(), &stats, &delivery).is_none());
assert_eq!(stats.snapshot().frames_dropped, 1);
}
/// One slice bigger than a whole FEC block must cut MULTIPLE blocks from a single push (the
/// flush loop) — the final block can never be left oversized.
#[test]
@@ -2798,6 +2798,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// `interval` was built as 1/effective_hz, so the round-trip recovers the integer
// rate.
let hz = interval_hz(interval);
// Timed for the `PipelineGap` below: the rebuild stalls capture for ~0.6 s,
// and a client that isn't told discards its starved windows as congestion
// (the 401 ms field case: slow start killed, minutes at ~15 Mbps on a clean
// link — review §2.2). The mode-switch and topology rebuilds already announce.
let rebuild_t0 = std::time::Instant::now();
match crate::encode::open_video(
plan.codec,
frame.format,
@@ -2853,10 +2858,24 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
behind_score = 0;
depth_frames = 0;
ahead_run = 0;
// …and it must not feed the CLIENT's controller either: announce the
// host-local gap so the starved window is discarded, exactly as a
// mode-switch rebuild does (review §2.2 — this arm was the one rebuild
// that never told the client).
announce_pipeline_gap(
&gap_tx,
rebuild_t0.elapsed().as_millis().min(u32::MAX as u128) as u32,
);
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), to_kbps = new_kbps,
"bitrate-change encoder rebuild failed — keeping the current rate");
// The control task acked the resolved rate BEFORE this apply — with
// the rebuild failed, the client's controller now tracks a rate the
// encoder never ran: its climb base, utilization and proven math all
// drift from a phantom number (review §2.3). Snap it back, same
// channel as the short-apply correction above.
let _ = retarget_tx.send(bitrate_kbps);
}
}
}
+1 -1
View File
@@ -117,7 +117,7 @@
"gpgkey=https://git.unom.io/api/packages/unom/rpm/repository.key",
" https://git.unom.io/api/packages/unom/generic/punktfunk-keys/1/RPM-GPG-KEY-punktfunk",
"REPO",
"sudo dnf install punktfunk"
"sudo dnf install punktfunk punktfunk-web punktfunk-scripting"
]
},
{
+3 -2
View File
@@ -29,8 +29,9 @@ For **Fedora 43 or newer** (Workstation or KDE). Bazzite and other Fedora Atomic
The RPM repo has one group per Fedora release: **`fedora-44`** on Fedora 44, **`bazzite`** on
Fedora 43 (it's a plain Fedora 43 build of the same package). `rpm -E %fedora` prints your number —
set `baseurl` to match, then install. The browser console, `punktfunk-web`, comes along
automatically:
set `baseurl` to match, then install. The install line names the browser console
(`punktfunk-web`) and the plugin runner (`punktfunk-scripting`) explicitly: they're *recommended*
deps of `punktfunk`, and a box with `install_weak_deps=False` would silently skip them.
<Install platform="fedora" />
+18
View File
@@ -187,6 +187,24 @@ ffmpeg -hide_banner -encoders | grep nvenc # expect hevc_nvenc / av1_nvenc / h
The same applies on a layered Bazzite / Fedora Atomic install; the sysext image carries its own.
## `systemctl --user status punktfunk-web`: unit not found
The web console is its own package, and the `punktfunk` RPM only *recommends* it
(`Recommends: punktfunk-web`) — a box with `install_weak_deps=False` in `/etc/dnf/dnf.conf`, a
`--setopt=install_weak_deps=0` install, or an `rpm-ostree` layering that drops weak deps gets the
host with no console and no unit to enable. Install it by name:
```sh
rpm -q punktfunk-web || sudo dnf install punktfunk-web punktfunk-scripting
systemctl --user enable --now punktfunk-web
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
```
`No match for argument` instead means the repo you're on has no console: **COPR** builds host and
client only (its mock chroot has no `bun`). Use the RPM registry —
[Fedora](/docs/fedora#2-install-the-host), step 2. The same weak-dep miss happens on Debian/Ubuntu
after an `apt install --no-install-recommends`; the fix is `sudo apt install punktfunk-web`.
## pacman: error: could not register 'punktfunk' database (database already registered)
The repo block got appended to `/etc/pacman.conf` twice — the add line is an append, so running it
+1 -1
View File
@@ -117,7 +117,7 @@
"gpgkey=https://git.unom.io/api/packages/unom/rpm/repository.key",
" https://git.unom.io/api/packages/unom/generic/punktfunk-keys/1/RPM-GPG-KEY-punktfunk",
"REPO",
"sudo dnf install punktfunk"
"sudo dnf install punktfunk punktfunk-web punktfunk-scripting"
]
},
{
+1 -1
View File
@@ -283,7 +283,7 @@ REPO
CMD
)"
[ "$group" = fedora-44 ] || run "sudo sed -i 's|/rpm/fedora-44|/rpm/$group|' /etc/yum.repos.d/punktfunk.repo"
run 'sudo dnf install punktfunk'
run 'sudo dnf install punktfunk punktfunk-web punktfunk-scripting'
;;
sysext)
install_line='sudo bash punktfunk-sysext.sh install'