diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a2b12b3..4b411b2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,437 @@ with the version table of the release you are moving to, then read **Breaking ch --- +## v0.31.0 + +90 commits since v0.30.0 (65 non-merge). + +Nothing versioned moves. `WIRE_VERSION` stays **2**, the C ABI stays **24** — `include/punktfunk_core.h` +is byte-identical to the v0.30.0 tag — the driver protocol, gamepad channel and plugin index schema +are all unchanged, and no `trust::Settings` field, capability bit or control-message type byte was +added. Every 0.30.x host, client, driver and plugin keeps interoperating in both directions, with no +re-pairing. + +What did move is beneath the versioned surfaces, and three parts of it are worth a packager's or +embedder's attention: the Linux host package installs **three new system files** (a udev rule, a +WirePlumber policy and an ALSA UCM drop-in) that the DualSense audio path depends on; the Linux +desktop-audio capture **flipped topology by default** (`PUNKTFUNK_STREAM_SINK` unset now means a +host-owned `null-audio-sink`, with `=stream` a one-release escape hatch to the 0.30 shape); and the +Android app's Compose console is **deleted** — `pf-console-ui` over Skia/GL is now the console on all +three ABIs, which removes the Compose screenshot scenes. + +### Versions + +| | v0.30.0 | v0.31.0 | Notes | +|---|---|---|---| +| Wire protocol | 2 | **2** | unchanged | +| C ABI | 24 | **24** | unchanged — `include/punktfunk_core.h` is byte-identical to the v0.30.0 tag; the only new `pub` items in `punktfunk-core` are three RT-safe DSP helpers (`crossfade_insert`, `pcm::raised_cosine_tail`, `pcm::raised_cosine_head`), Rust-only, no `pub const` for cbindgen to pick up | +| 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.30.0 tag | +| Windows virtual-gamepad channel | 3 | **3** | unchanged | +| Plugin index schema | 1 | **1** | unchanged | +| Host event schema | 1 | **1** | unchanged (`punktfunk-host/src/events.rs`) | +| `api/openapi.json` | 0.29.0 | **0.29.0** | unchanged — no management-API surface moved this cycle; both copies (`api/` and `docs-site/public/`) are byte-identical to each other and to the tag | +| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files. ⚠ `packaging/gamescope/PKGBUILD` still says `pfhdr7` — pre-existing at v0.30.0, not a regression this cycle, but the Arch package builds a binary the host's `>= 8` probe rejects for the keymap path | +| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged in `package.json` — but `sdk/src/config.ts` and `runner-cli.ts` changed (the `mgmt-endpoint` fix below), so a `sdk-v0.1.5` cut is **owed**; plugins resolve the SDK from the registry and cannot pick the fix up until it ships | +| `@punktfunk/plugin-kit` | 0.4.2 | **0.4.2** | unchanged in `package.json` — but `sync-engine.ts` gained `minInterval` (below), so a `plugin-kit-v0.4.3` cut is **owed** for the same reason | + +⚠ The SDK and plugin-kit version independently of the app (`sdk-v*` / `plugin-kit-v*` tags, +`sdk-publish.yml` / `plugin-kit-publish.yml`); this release commit does not bump them. Both have +unpublished code changes, called out in the table so they are cut deliberately rather than +discovered. + +### ⚠ Breaking changes + +**None on any versioned surface.** No wire change, no C ABI change, no driver-protocol change, no +plugin-contract change. Four things are worth attention anyway; none breaks a build: + +- **`refactor(android)!` — the Compose console is deleted.** `pf-console-ui` (the Skia shell the + desktop session binary draws) is now Android's console on arm64-v8a, x86_64 **and** armeabi-v7a; + the gate is simply "does the native host exist", and where it does not a controller drives the + touch UI through focus. ~6.5 kLOC of `GamepadHome`, `GamepadSettingsScreen`, + `GamepadAddHostScreen`, `GamepadDialogs`, `HomeTiles`, the console halves of `LibraryScreen`, + `ConnectOverlay`/`ConnectTakeover`, the `gamepadUi` branches of `ConnectScreen`/`ConnectPrompts`/ + `AdaptiveDialogs`, `App.kt`'s `GamepadShell`/`GamepadScreen` and their tests are gone. The `!` is + for the **store-screenshot surface**: the Compose console's marketing scenes cannot be rendered by + Roborazzi any more (the shell draws over native GL); its shots come from the desktop screenshot dump + or a device capture. Sysprop `debug.punktfunk.console_backend=compose` is meaningless; `=none` + still forces the touch UI on glass. +- **Linux desktop-audio capture topology flipped by default** — see the audio section. `=stream` + restores 0.30 for **one release only**. +- **Hyprland / sway: `topology: exclusive` now does what it says.** Both backends accepted it, + echoed it as the session's effective topology, and dropped it with a warning; because `auto` + resolves to Exclusive on any unpinned host, the *default* policy on every auto-detected Hyprland + or sway box was an Exclusive that behaved as Extend. Operators who relied on that get their + monitors disabled for the session now (closes #284). +- **Three new system files in the Linux host package** — the DualSense audio path does not work + without them. Downstream repackagers: see the packaging section. + +### DualSense audio and haptics on Linux: five faults, and the files they needed + +The whole in-game path — GE-Proton's haptic router → the pad's ALSA card → the voice coils — had +never once worked against our virtual pad. In wire order: + +- **`usbip`: the calibration feature report was 42 bytes; `hid-playstation` asks for 41.** On a USB + backend an over-long reply is not truncated: the kernel treats it as hostile and tears down the + connection, not the transfer — the pad vanished ~400 ms after enumerating, and the dmesg order made + the teardown look like the cause. Three changes so the trap is not left set: the constant is 41 and + all three feature-report sizes are pinned by test; `clamp_reply` clamps every reply to the requested + length in the transport and drops any payload a handler returns on an OUT (the kernel never reads + one; those bytes would misframe every following PDU); `DualSenseUsbip::open` waits for the kernel + to actually bind a HID driver before reporting success (vhci attach succeeds immediately and + enumerates asynchronously), so bring-up faults return `Err` and the uhid fallback catches them. + New `PUNKTFUNK_USBIP_TRACE` (both socket directions to disk) and `scripts/usbip-trace-analyse.py`. +- **`usbip`: every non-ISO OUT was answered with an empty buffer, i.e. `actual_length = 0`.** vhci + copies that field verbatim into the URB's actual length; the driver returned 0 as the write's byte + count; Wine's bus driver reads 0 as failure and prints the thread's *stale* errno — so the ENOENT / + EINVAL / EAGAIN in the GE logs were never kernel verdicts. New + `UsbIpResponse::usbip_ret_submit_out_success(header, accepted)`; the debug assertion now pins + "OUT carries no buffer", not "OUT claims 0"; two wire-byte tests pin both directions. **The Steam + Controller 2 shares this handler.** `usbip-trace-analyse.py` had flagged *any* nonzero OUT + actual_length as a desync — the rule that would have hidden this bug — and now flags an OUT reply + claiming more than it was sent, or 0 against a non-empty write. +- **`usbip`: ISO completions were paced by relative sleeps**, so timer slop, socket I/O and lock waits + accumulated per transfer: the pad's clock ran ~26 % slow (~35,700 frames/s against 48 kHz), its PCM + backed up into dropouts, and because completion *is* the pad's audio clock, on the test box the pad + sink became the graph driver and pulled desktop capture to 50 % delivery. Now a per-endpoint + absolute deadline ledger (a stall > 20 ms re-anchors instead of fast-forwarding a burst); measured + after: 48,005 frames/s. Two paused-clock tests pin the rate and the re-anchor. +- **`usbip`: the capture forwarded the pad's hardware quad as the wire's speaker pair.** Hardware + is HP-L, HP-R+mono-speaker, coil-L, coil-R; the wire puts the speaker pair first. Now: the speaker + channel duplicated across the wire's speaker pair, coils passed through, HP-L dropped. The + stream-sink (uhid) capture path already emitted the logical layout and is unchanged. +- **`usbip`: `iSerialNumber` was the literal `"Serial"`.** A real DualSense reports none, ALSA bakes it + into the card id (`…Wireless_Controller_Serial-00` vs `…Wireless_Controller-00`) and PipeWire + carried it into every node name and `device.serial`. Cleared. Explicitly *not* a fix for anything + observed broken — GE's winepulse leg matched the placeholder — and *not* a UCM-selection fix + (alsa-ucm-conf keys on `${CardComponents}`, `USB054c:0ce6`). +- **The pad's ALSA card was root-only.** It is created mid-session-bringup with no seat session + active, so logind's ACL never materialises; WirePlumber's probe got EACCES and the card never + appeared in PipeWire at all. `scripts/60-punktfunk.rules` gains two `SUBSYSTEM=="sound"` rules for + `054c:0ce6` / `054c:0df2` (`GROUP="input" MODE="0660" TAG+="uaccess"`), matching physical pads too. + Verified live on Bazzite f44. +- **The DualSense's only playback route was a 1-channel `Default__Speaker__sink`**, from which + GE-Proton mints its synthetic endpoint, and *Marvel's Spider-Man Remastered* overruns it ~74 s in + (`EXCEPTION_ACCESS_VIOLATION`, write; the copy loop past the frame count, 5206/5207 vs 5034 — a + game/GE bug on a code path that only exists when the mono sink does). Fix: delete the sink. New + ALSA UCM drop-in `scripts/alsa-ucm2/USB-Audio/conf.d/{054c-0ce6,054c-0df2}.conf` + + `scripts/alsa-ucm2/USB-Audio/Punktfunk/DualSense-PS5-Haptic{,-HiFi}.conf` raises a `SpeakerHaptic` + device at playback priority 200 against `Speaker`'s 100, so the card takes the 4-channel HiFi + profile and the mono sink never exists. Shipped **without** replacing a file `alsa-ucm-conf` owns: + `USB-Audio.conf` ends with an unconditional optional include of `conf.d/{vid}-{pid}.conf` + (verified against alsa-lib source; hook and DualSense profile both since 1.2.15). New CI guard + `scripts/ci/check-dualsense-ucm.sh` runs the chain on a real distro tree via UCM's card-less + `conf.virt.d`, negative-tested both ways. **NixOS is not covered** (no `/usr/share/alsa/ucm2` to + drop into). +- **WirePlumber met every new pad card at `default-sink-volume` 0.4 — cubed, i.e. −23.88 dB — and + both ends minted one**, so haptics reached the coils at 0.064² = −47.8 dB (field-measured −48). + Client: `pin_sink_volume` from `correlate_pad_sink` at every pick (skipped for the `split_parent` + pick). Host: new `audio/linux/pad_card_volume.rs`, started when `PadUsbCapturer::open` succeeds + (the host half matters because `pad_usb` captures at the ISO OUT endpoint, downstream of this + sink), retrying 15 s because the USB device is live before its ALSA card is; only sinks of a + DualSense **card** are touched (`device.id` keeps it off the host's own minted pad sink). Neither + end restores on exit, deliberately. New `PUNKTFUNK_PAD_SINK_VOLUME=0` disables both ends for + bisecting. Both pins unit-tested for one unity float per channel — PipeWire silently ignores a + `channelVolumes` whose length mismatches the port count. +- **`scripts/60-punktfunk-dualsense.conf`** — a new WirePlumber policy installed to + `/usr/share/wireplumber/wireplumber.conf.d/` by rpm/deb/arch/nix: `node.always-process` + no + suspend on the pad's `alsa_output` (GE opens the backing device raw when it is free, then hits + "busy" against its own handle and spins a 100 Hz refresh loop — SteamOS never shows this because + PipeWire always holds the device there), and `priority.driver = 0`. **Zero, not one**: the field is + unsigned and a driver is skipped only when `<= 0`; at 1 the pad was merely *last*, and last is still + elected whenever nothing above it qualifies — the ordinary in-session state on a host that has + claimed its own sink as default and idled the real card. A second rule sets `priority.driver = 0` + on the same cards' `alsa_input` (in the Pro Audio profile that node carries 2600 and clocked a + reporter's whole desktop session with nothing linked to it). The rule's first landing duplicated + its `%files` line into `%install`, which killed every RPM build on main for a few hours (fixed same + day, no release affected). +- **`0xD1` lane split:** speaker = Opus `Application::Audio` @ 96 kbps (~120 B / 10 ms frame), + haptics = `Application::LowDelay` @ 64 kbps CBR, unchanged. +- **`punktfunk-session --pad-audio-test`** now prints the effective `pad_speaker` / `pad_haptics` + before the tone (the capability is never advertised when the toggle is off, so no later log line can + catch it); the Android settings row states its default. Android is the one client defaulting pad + speaker **off**; `pf_client_core`'s `default_pad_speaker` is `"pad"` and always was. + +### The Linux desktop-audio capture drives its own graph group + +The stream sink was a `pw_stream` wearing `media.class = Audio/Sink`. A stream is structurally a +follower, so its group had no clock and PipeWire assigned it to the highest-priority *running* +driver on the box. On a reporter's host that was a DualSense forwarded over VirtualHere in the Pro +Audio profile — never suspended, nothing linked, its frame counter a kernel stub logging "not yet +implemented" and returning 0 ~1900×/s. Not xruns: 11 errors in 15 min, wait never past 111 µs; the +loss was *between* cycles — 3.9 delivery holes/s, worst 142 ms, **15.4 % synthesized silence** over +a 15-minute session. + +Now a `support.null-audio-sink` adapter created on our own connection, captured through its monitor +(the same object `pactl load-module module-null-sink` creates). Three load-bearing properties: +`node.passive` on the monitor tap (idle between sessions, so the null sink's timer parks — the +objection that kept `node.always-process` off the old stream sink); `node.force-quantum`, not +`node.latency` (a driver's quantum is the smallest follower latency rounded **down** to a power of +two under the default `default.clock.power-of-two-quantum`, which is why the 240-frame ask has been +served as **128** — 2.67 ms callbacks, not the 5 ms it is designed around — on every stock Linux host +since the capture was written; force-quantum skips the rounding and forces nothing on anyone else, +since this sink drives only its own group); and `node.dont-fallback` **with** `node.linger`, never +one alone (WirePlumber 0.5 reads dont-fallback alone as licence to destroy the stream when its target +is not visible). Routing claim, capture callback, stats line and everything downstream untouched. +`PUNKTFUNK_STREAM_SINK`: unset = new topology, `stream` = 0.30's (one release), `0` = the legacy +default-sink-monitor follower. Documented at last in `configuration.md`, with a new troubleshooting +section on the `punktfunk-audio-…` recording stream and on another device clocking your capture. + +Around it, from the same 2026-08-14/17 field logs: + +- The host binds its own node and reads `node.driver-id` from its `info` event (a node-id→name map + from the registry): on change, `audio capture graph driver` names the clocking node — WARN in the + null-sink mode (exactly one right answer), INFO in the legacy topologies (they borrow a clock by + design). +- `CaptureStats::observe_gap` is now the one accounting behind both feeds (Linux callback cadence and + the Windows discontinuity flag) and buckets holes at <20 / <50 / <100 / ≥100 ms — the client + concealment edges. Both capture lines print `gap_hist=a/b/c/d missing_ms=`; the sum closes the + arithmetic against `delivered_pct`. The Windows loopback **reader** thread now takes + `boost_thread_priority(true)` like the paced sender it feeds. +- **The pacer's schedule was wall clock; the source was not.** A missed 2.7 ms cycle is below the gap + counter's floor and the infill threshold, so the schedule kept the debt and repaid the next ≥ 10 ms + hole as a burst of (lag + 10)/5 silence frames (field: 33–72 % departures late, worst 99 ms, + re-anchors 0). The infill decision now sees schedule lag; `after()` follows the real quantum + (`InfillPolicy::note_quantum`) — one chunk plus one frame, never under two frames; a slot whose + backlog exceeds one chunk plus one frame sends a second frame in the same slot (at most two), since + a fast source clock could otherwise only grow the backlog — 5 ms of host latency per 50 s at + 100 ppm. Holes fade out over 1 ms (`pcm::raised_cosine_tail`) and the first real frame after fades + in (`raised_cosine_head`). + +### The client jitter ring can now grow without de-priming + +`JitterStep::insert_front` mirrors `drop_front`: when the sync loop wants more than the adaptive +target and the depth EWMA has sat > `INSERT_MARGIN_MS` below the request for `INSERT_SUSTAIN_MS` of +consumed audio, duplicate one frame at the front, crossfaded (`crossfade_insert`, the RT-safe twin +of `crossfade_drop`). Sync-only, primed-only, below-target-only. `hollow` is judged against the +**adaptive** target, never the sync request — the bug was that a ≥ 10 ms sync request read as hollow +on the next callback and the next late packet cost 15–60 ms of silence, since ~0.24/0.25. Margin is +half the sync loop's ±10 ms deadband (a margin at or above it would leave every request it is allowed +to make unanswered). Also fixes `crossfade_drop`'s seam: the fade-out source is now the continuation +of the sample the device just played, not the tail of the discarded region — a hard-cap trim stepped +2,688 samples where it now stays under 17. Wired into the PipeWire, WASAPI and AAudio rings +(`PlaybackVitals.inserts`, `drift_inserts=` on the 10 s lines) and ported line for line to the Swift +ring (`insertOneFrame()`, `AudioRingDriftTests` carrying the same vectors). No new `pub const`; the +C header is unchanged. + +Beside it: the Linux desktop client's playback stream now connects with `RT_PROCESS` (it ran on the +main-loop thread at nice 0, and when late PipeWire rendered silence for our node and moved on — an +underrun no counter saw); the ring is pre-reserved so `extend` never reallocates on the RT loop; new +`audio_vitals::PlaybackVitals` printed from the decode thread on wall clock. New `audio_rt` module +raises the decode, pad-audio, PipeWire-loop and Linux mic threads: `setpriority` where `RLIMIT_NICE` +allows → inside a Flatpak the `org.freedesktop.portal.Realtime` portal → else rtkit +`MakeThreadHighPriorityWithPID`. The split is `module-rt`'s and not optional: rtkit-daemon has no +PID-namespace translation (verified on the Deck, rtkit 0.14), so a direct call from a sandbox is +ENOENT; the portal maps pid/tid. Never setcap / `SCHED_RR`. Windows: MMCSS "Pro Audio" + +`THREAD_PRIORITY_HIGHEST` on the render and mic loops. Acceptance on the Deck: `ps -eLo +cls,rtprio,ni,comm` shows the decode thread at nice −10 after connect. + +The client log ring drops DEBUG/TRACE from `cros_codecs` (its WARN+ still lands) and normalizes +`log`-bridge events to their real target: a dozen DPB lines per frame at 120 fps last three seconds +in a 4,096-line ring — a 2026-08-17 Deck bundle read "2,037,456 older lines evicted". `Cargo.lock` +gains two direct deps already in the graph. + +### Android: `pf-console-ui` is the console, presented through `ASurfaceControl` + +- **`pf-client-core` un-gated for Android** (trust::Settings, known-hosts store, profiles model, + deep links, the library *model*; the ureq fetches stay desktop), with `audio_format`, + `decoder_pref`, `menu_nav` (`MenuEvent`/`MenuNav`/`PadInfo`) and `console` (`OverlayAction`, + `PointerInput`, `SessionPhase`) split out and re-exported. `pf-console-ui`: Vulkan overlay + SDL + event path behind the default `vulkan-overlay` feature (clients/session unchanged); a `Key` enum + replaces SDL scancodes; a `SettingsStore` seam (desktop = the file, `SnapshotStore` across a + language boundary); `Viewport{width,height,insets,scale}`; `Platform` filters the settings rows; + `ConsoleOptions`; a portable `Console` driver. skia-safe features are target-specific: desktop + `jpegd-jpege-pdf-textlayout-vulkan` (the flatpak pin), Android `gl-jpegd-jpege-pdf-textlayout`. + Model types derive serde — the wire IS the model. `MenuNav` gains the stick hysteresis + (`MENU_RELEASE = 0.3`) both the Apple and Android shells had grown on glass. +- **`clients/android/native/src/console/`**: hand-declared EGL binding, Skia GL `DirectContext` over + FBO 0, one render thread paced by `eglSwapBuffers`, ~28 `nativeConsole*` JNI seams; a run of GL + setup failures ends the render thread through the normal release path, which raises the + `SkiaConsole.healthy` handover to the touch UI. `SkiaConsoleShell` (SurfaceView + lifecycle, + insets = systemBars ∪ displayCutout in surface px, system bars hidden transiently while the console + is up, phone density floor **0.6 → 0.75**, pad probes into the shared `MenuNav`, remote D-pad, + hardware keys, Back as B, touch as pointer). Pad-listener slot is a **stack** with removal by + identity (a leaving Controllers/Licences page used to null the console's claim). Android-only + settings rows ride `Settings::extra` `android.*` keys; `row_on()` keeps them off the desktop list. + New `ConsoleCmd::PadAction { action, pad_key }` (`sc2_bluetooth`, `sc2_usb`, `ds_usb`, rumble, + pad-audio self test); `PlatformScreen::Controllers` removed (the mechanism stays for Licences); + `PadInfo` gains detail line / forwarded / rumble. Detail band 84 → 64 units; the grid's two-column + minimum shrinks covers instead of clipping. +- **Skia prebuilts** for all three ABIs come from `unom/skia-binaries` release **0.99.0** on + git.unom.io (R2-backed), mirroring rust-skia's `{tag}/{key}` layout; the armv7 archive + (`a25a0fdb7d90429aa2d1-armv7-linux-androideabi-gl-jpegd-jpege-pdf-textlayout`, sha256 + `4867856b…`) is built by us since rust-skia publishes none. GitHub is out of the Android build path; + `-PskiaBinariesUrl` / `SKIA_BINARIES_URL` remain as overrides. +- **Present path:** the codec renders into an `AImageReader`; frames are composited onto an + `ASurfaceControl` layer via a transaction carrying a desired present time, and completion reports + the real latch time and the previous buffer's release fence — so the panel period is learned from + real latches (Android down-rates a game process's vsync callbacks; the old presenter could learn 60 + on a 120 Hz panel) and the frame budget is bounded by real completions. `ASurfaceControl` / + `ASurfaceTransaction` are not in ndk-sys 0.6, so `surface_control.rs` hand-declares them and + resolves via `dlsym` from `libandroid.so` (all API 29, above minSdk 28), same pattern as `adpf.rs` / + `vsync.rs`. Memory safety does not rest on the fences (an `AImage` keeps its buffer alive through + SurfaceFlinger's own reference; a mishandled fence is at worst a tear). **Default**; auto-fallback + to the SurfaceView presenter, byte-for-byte unchanged, on API < 29 or any init failure; escape hatch + `debug.punktfunk.present_backend=surfaceview`. The layer is sized to the view's on-screen pixels, + not the window buffer (which is reported in a rotated/scaled space — 1260×567 for a 2800×1260 + stream, drawing into the top-left 45 %). The present-time grid uses the mode table's seed period + for spacing and the last real latch only for phase (learning the period from latches was + self-fulfilling and locked the panel at 60). On glass at 2800×1260@120: e2e p50 30 → ~18 ms, + skipped 40–50/s → 0. Whether the panel *holds* 120 is the OEM's LTPO governor — measured: no + app-side API (`preferredDisplayModeId`, `preferredRefreshRate`, the layer rate vote, + `frameRatePowerSavingsBalanced`) raises the render-range floor — so the ineffective pins were + removed again and `pf.present` gained the cadence loop's late-permille / jitter / cushion / + re-anchors / qDepth. + +### Hyprland / sway: `topology: exclusive` (closes #284) + +`exclusive` disables the operator's outputs for the session and restores them when the display +group's last member is torn down, through the same registry hand-off KWin uses (the compositor never +sees zero enabled outputs; a sibling session's desk is never re-enabled under it). The disable filter +is group-aware — enabled, not ours (`PF--` on Hyprland, the `HEADLESS-` prefix on sway), not +managed. **The Hyprland restore is `hyprctl reload`, and that is measured, not chosen**: re-applying +the head's own mode/position/scale does not undo a disable (probed 2026-08-18 against 0.56.2 +hyprlang and 0.55.4 Lua — every targeted form was accepted at exit 0 and changed nothing, including +`,enable`, `preferred,auto,1`, `monitorv2 disabled=false`, `keyword unset monitor`, the Lua +`disabled = false`, `dispatch dpms on`, `forcerendererreload`); a runtime rule is additive and the +disable keeps winning. Disable is spelled per config era (`keyword monitor ,disable` under +hyprlang; `hl.monitor{ output = "", disabled = true }` under Lua) and confirmed by **read-back**, +not exit status. `hyprctl_dispatch` now also matches "can't" (the Lua manager's "keyword can't work +with non-legacy parsers"). `primary` stays extend and warns distinctly. ⚠ **The sway half is not +exercised on a live sway** — no box in the fleet runs one; both argv shapes are pinned by tests and +the read-back turns a wrong guess into a warning naming the outputs. Six new unit tests. + +### Gaming Mode takeover: the mask was the relogin storm + +On an SDDM-autologin box the runtime mask the takeover laid sat in SDDM's relogin path, so every +autologin failed in milliseconds and `Relogin=true` has no backoff: 962 logind sessions in 3.7 min, +system buttons re-scanned 5,688×, udev `change` at ~20/s, iio-sensor-proxy crash-looping ~16 +starts/s, load 26 on 12 cores — and Wine's bus driver, re-enumerating udev per event, read the pad at +~1.4 Hz. `dm_plan` loses its `mask` input and `dm_survives_masked_unit`; the mask is laid **only after +the stop has landed** and every restore path unmasks before restarting; a planned DM stop that does +not land now **fails the takeover** and the caller degrades to ATTACH. `skip` is `!any_live` on every +flavor; `any_live` now counts `deactivating` and `reloading`. New `DmHelperError::shape()`; +`watch_for_relogin_storm()` (two `read_dir`s of `/run/systemd/sessions` 5 s apart, ERROR above 1/s, +detect-only); `systemctl_system` captures stderr at DEBUG (the "requires interactive authentication" +line was going to the journal on the *successful* path). `cargo test -p pf-vdisplay --lib gamescope` +52 passed, 1 ignored. + +### Windows host: two session-killers + +- **`untune_process` logged from a TLS destructor.** By then `tracing`'s own thread-local state can be + gone; the log call panicked, and a panic escaping a TLS destructor aborts. The panic hook then hid + the evidence — it logged through the same framework and panicked the same way, and a panic inside + the hook is a case where std deliberately does not format the message (the field log: a location, a + blank line, "thread panicked while processing panic. aborting."). The service manager restarted the + host ~6 s later, so it read as a reconnect. `untune_process` no longer logs (still atomic under the + refcount lock); the panic hook writes straight to the `LogRing` (`OnceLock` + `Mutex`, TLS-free; + `thread::current()` and `Backtrace::force_capture()` verified safe during TLS destruction). + Reproduced standalone on 1.96.0, byte-identical to the field log. +- **A Windows launch is a hand-off, and 0.30 read its exit as the game's.** `explorer.exe + "playnite://…"`, `Steam.exe "steam://…"` and shell app-folder links spawn a forwarder that quits a + second later (launcher already running) or *becomes* the launcher (it was not); the shim window that + guards this was skipped for hint-less titles — the one shape that needs it — so the lease reported + running, then the forwarder's exit closed the connection. The forwarder was also a termination + target. `WinRecipe::owns_game` records which recipe lines start the game (only `gog`, `command` and + a plugin's own recipe) and which forward; a forwarder's pid is dropped; the shim window applies to a + bare child or pid whatever the spec holds; giving up on tracking lands on `GameState::Untracked` + instead of `launching` forever. Fixture in `a_pid_only_launch_reports_its_exit` widened 4 → 8 s + (it passed only because of the bug); new ignored test drives the field report. + +### Everything else an integrator might notice + +- **`mgmt-endpoint` is followed everywhere.** `PUNKTFUNK_MGMT_BIND` moved off 47990 left every plugin, + the runner's log shipper and the tray dialing a dead port (task Running, plugins never registering, + empty library, "no logs at all"). `sdk/src/config.ts::publishedMgmtUrl` reads + `/mgmt-endpoint`; `resolveConfig` uses it after `PUNKTFUNK_MGMT_URL` and before the + default; `runner-cli.ts` exports it into `PUNKTFUNK_MGMT_URL` before any plugin loads (older + vendored SDK copies follow too). New `pf_paths::published_mgmt_port`; `punktfunk-tray` depends on + `pf-paths` and its `mgmt_port` is `Option` — `None` re-reads the file every poll. SDK 83 tests + (4 new). **Unpublished — `sdk-v0.1.5` owed.** +- **`scripts/windows/scripting-run.cmd`** redirects the runner's stdout+stderr to + `%ProgramData%\punktfunk\plugin-state\runner.log` (previous run rotated to `.1`; writability probed + with `copy /y nul`; no `goto`, the file is LF). Verified by reading only. +- **`@punktfunk/plugin-kit`: `SyncSettings.minInterval`** (optional; `LibraryPluginDef.minInterval` + overrides), default `DEFAULT_FS_CHANGE_MIN_INTERVAL` = 30 s — a floor on top of the 3 s debounce, + which cannot bound the *rate* under sustained churn (`plugin:steam sync (fs-change)` 102× in + 27 min). Changes inside the hold coalesce into one trailing sync. **Unpublished — `plugin-kit-v0.4.3` + owed.** Narrowing the Steam plugin's watch set lives in the steam plugin repo. +- **Nix binary cache at `https://nix.unom.io`** (`nix.yml` third tier: build Rust packages + + gamescope, sign, publish on every main push; a release needs no new trigger since `Cargo.toml` is + in the path filter). Only punktfunk's own store paths (~300 MB per publish); the step asserts every + output matches the name filter; NARs before narinfos, rsync without `--delete`. New + `packaging/nix/server/{Caddyfile,compose.production.yml,prune.sh}` (a `caddy:2-alpine` static tree + on unom-1 beside the flatpak repo) and `scripts/setup-nix-cache.sh` (five stages; the secret key is + shown once and never written to disk; four stages after #318, which also made it detect an + installed key and refuse to casually regenerate one). The signing key is generated and installed as + the `NIX_CACHE_SIGNING_KEY` Actions secret; its public half, + `punktfunk-cache-1:yhOJmHxzg6tzXpxSFzlYn6Pc6r0jHprsWqt8MZC654o=`, is pinned in `install.md` and + `packaging/nix/README.md` and served by the cache at `/punktfunk-cache.pub` (the wizard compares the + two and warns on mismatch). DNS for `nix.unom.io` is provisioned through `unom/infra`'s OpenTofu + (`terraform/cloudflare/records.tf`, applied by `dns-cutover.yml`) — not a dashboard click. + `inputs.punktfunk.inputs.nixpkgs.follows` defeats the cache entirely. Rejected: Gitea's package + registry (no Nix type), storage.unom.io (home uplink, and S3 answers 403 not 404 for a missing key, + which nix treats as fatal). +- **Apple console-UI parity** (Swift, PunktfunkKit/PunktfunkShared): `LibraryCollation` ports + `pf-console-ui`'s `collate.rs` (the desktop's eight tests by name; both read + `clients/shared/library-collate-vectors.json`, new — desktop is the source of truth and regenerates + it); `GameEntry.platform` (sent in `GameMeta` all along, dropped by `Codable`); `LibraryPlaceStack`, + `CollectionsHandover.decide`, `LibraryGridCursor` (port of `GridShape`/`grid_step`/`grid_col_hint`, + nine grid tests by name), `GridGeometry` (the grid owns its scroll offset — no trackpad wheel on the + grid, a named trade); `ConsoleContract.swift` pins `ConsoleMotion` to the shared vectors' + `motion_spring` (response 0.42, damping 0.88, slide 36, scales 0.985/0.96, reveal 0.4, + interruptible; the v1 `$deprecated` note now names Android as the last v1 reader — and Android + moved to the shared shell in this same release). Device keys `librarySort` / `libraryView` / + `libraryCollections` / `libraryGroupBy` — presentation only, never in a profile. `PosterImage` + decodes at the drawn size (`CGImageSourceCreateThumbnailAtIndex`). `HostCardView`'s primary action + reverted to connect (`22fdea66` reverted; `swift test` 375/0). New dev hooks + `PUNKTFUNK_FAKE_LIBRARY=`, `PUNKTFUNK_SHOT_EDITING=`, `PUNKTFUNK_SHOT_INTERACTIVE=1` + (screenshot harness only). +- **New environment variables:** `PUNKTFUNK_PAD_SINK_VOLUME` (`=0` skips both pad-sink pins), + `PUNKTFUNK_DUALSENSE_USBIP_GRACE_MS` (pad-arrival grace), `PUNKTFUNK_USBIP_TRACE` (byte-level + USB/IP trace prefix, off by default), and the three Apple screenshot-harness hooks above. + `PUNKTFUNK_STREAM_SINK` gained the `stream` value and is documented for the first time. +- **New packaging payload (Linux host, rpm/deb/arch; nix where noted):** `scripts/60-punktfunk.rules` + (+2 sound rules), `scripts/60-punktfunk-dualsense.conf` (WirePlumber, also nix), + `scripts/alsa-ucm2/…` (UCM drop-in, **not** nix). Bazzite sysext inherits all three from the RPMs. +- **Docs:** `AGENTS.md` + `docs/agents/` (issue tracker is Gitea via the `gitea` MCP server; the + five triage labels; single-context domain docs). A host audio-source comment corrected + (`pw_impl_node_set_driver` marks props changed but leaves the flush to the next info emission). +- **CI:** Nix publish job records `df` after the build as well as before. + +### Verification status + +Gates run on the release tree (this MacBook, rustc/rustfmt 1.96.0 per `rust-toolchain.toml`): +`cargo fmt --all --check` clean — **after** a whitespace-only commit on the release branch: two files +(`pf-console-ui/src/screens/controllers.rs`, `punktfunk-host/src/audio/linux/pad_card_volume.rs`) +had landed on main formatted differently from rustfmt 1.96.0, so `ci.yml`'s Format step was red on +the tip this is cut from; `cargo metadata --offline` ok with the `Cargo.lock` diff versions-only +(36/36 lines); `cargo test -p punktfunk-core` **272 passed** in the unit suite; the android.yml Play +notes gate run verbatim — 498/500 characters and not byte-identical to any prior release's; both +openapi copies `cmp` identical and unchanged since the tag; `include/punktfunk_core.h` regenerated +by the build and `git diff` clean against the tag. + +⚠ **The C ABI harness (`tests/c_abi.rs`) did not run on this cut**: it links the staticlib with +`-lopus` and this machine has no libopus (`ld: library 'opus' not found`), which is an environment +gap, not a code fault. The header it exercises is byte-identical to v0.30.0's, where the harness +passed (261 + 1 + 8), and nothing in `punktfunk-core`'s C surface changed. The CI runner is its +first execution for this tag. + +⚠ **Verified by reading only** — compiled nowhere available to the cutting host: the Windows runner +log redirect (`scripting-run.cmd`), the tray's `Option` port on Windows, and the sway half of +`topology: exclusive` (no live sway in the fleet, as with #283). + +⚠ **Not verified on hardware by this cut**, named rather than left to be discovered: the null-sink +capture topology's on-glass validation (pw-top showing our sink at the top of its own group, 5 min +of loud audio at `delivered_pct=100 gaps=0` on a box where a hardware sink also runs) was still owed +when it landed; the 96 kbps speaker lane was judged on glass by ear only; and the Android +`ASurfaceControl` path was verified on one device (Nothing Phone 3) — the fallback presenter is +byte-for-byte the 0.30 one. + +--- + ## v0.30.0 175 commits since v0.29.0 (131 non-merge). diff --git a/Cargo.lock b/Cargo.lock index 922cc204..4ab26e59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1090,7 +1090,7 @@ dependencies = [ [[package]] name = "cursor-probe" -version = "0.30.0" +version = "0.31.0" dependencies = [ "anyhow", "pf-capture", @@ -1222,7 +1222,7 @@ dependencies = [ [[package]] name = "display-disturb" -version = "0.30.0" +version = "0.31.0" dependencies = [ "pf-win-display", "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", @@ -2343,7 +2343,7 @@ dependencies = [ [[package]] name = "latency-probe" -version = "0.30.0" +version = "0.31.0" [[package]] name = "lazy_static" @@ -2446,7 +2446,7 @@ dependencies = [ [[package]] name = "libvpl-sys" -version = "0.30.0" +version = "0.31.0" dependencies = [ "bindgen", "cmake", @@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "loss-harness" -version = "0.30.0" +version = "0.31.0" dependencies = [ "punktfunk-core", ] @@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pf-bitstream" -version = "0.30.0" +version = "0.31.0" dependencies = [ "cros-codecs", "tracing", @@ -2975,7 +2975,7 @@ dependencies = [ [[package]] name = "pf-capture" -version = "0.30.0" +version = "0.31.0" dependencies = [ "anyhow", "ashpd", @@ -2996,7 +2996,7 @@ dependencies = [ [[package]] name = "pf-client-core" -version = "0.30.0" +version = "0.31.0" dependencies = [ "anyhow", "ash", @@ -3032,7 +3032,7 @@ dependencies = [ [[package]] name = "pf-clipboard" -version = "0.30.0" +version = "0.31.0" dependencies = [ "anyhow", "ashpd", @@ -3050,7 +3050,7 @@ dependencies = [ [[package]] name = "pf-console-ui" -version = "0.30.0" +version = "0.31.0" dependencies = [ "anyhow", "ash", @@ -3073,7 +3073,7 @@ dependencies = [ [[package]] name = "pf-dxvadec" -version = "0.30.0" +version = "0.31.0" dependencies = [ "cros-codecs", "pf-bitstream", @@ -3083,7 +3083,7 @@ dependencies = [ [[package]] name = "pf-encode" -version = "0.30.0" +version = "0.31.0" dependencies = [ "anyhow", "ash", @@ -3109,7 +3109,7 @@ dependencies = [ [[package]] name = "pf-frame" -version = "0.30.0" +version = "0.31.0" dependencies = [ "anyhow", "libc", @@ -3122,7 +3122,7 @@ dependencies = [ [[package]] name = "pf-gpu" -version = "0.30.0" +version = "0.31.0" dependencies = [ "anyhow", "pf-host-config", @@ -3136,11 +3136,11 @@ dependencies = [ [[package]] name = "pf-host-config" -version = "0.30.0" +version = "0.31.0" [[package]] name = "pf-inject" -version = "0.30.0" +version = "0.31.0" dependencies = [ "anyhow", "ashpd", @@ -3169,14 +3169,14 @@ dependencies = [ [[package]] name = "pf-paths" -version = "0.30.0" +version = "0.31.0" dependencies = [ "tracing", ] [[package]] name = "pf-presenter" -version = "0.30.0" +version = "0.31.0" dependencies = [ "anyhow", "ash", @@ -3191,7 +3191,7 @@ dependencies = [ [[package]] name = "pf-update" -version = "0.30.0" +version = "0.31.0" dependencies = [ "serde", "serde_json", @@ -3199,7 +3199,7 @@ dependencies = [ [[package]] name = "pf-update-check" -version = "0.30.0" +version = "0.31.0" dependencies = [ "anyhow", "aws-lc-rs", @@ -3211,7 +3211,7 @@ dependencies = [ [[package]] name = "pf-vaadec" -version = "0.30.0" +version = "0.31.0" dependencies = [ "cros-codecs", "pf-bitstream", @@ -3220,7 +3220,7 @@ dependencies = [ [[package]] name = "pf-vdisplay" -version = "0.30.0" +version = "0.31.0" dependencies = [ "anyhow", "ashpd", @@ -3253,7 +3253,7 @@ dependencies = [ [[package]] name = "pf-vkdecode" -version = "0.30.0" +version = "0.31.0" dependencies = [ "ash", "cros-codecs", @@ -3264,7 +3264,7 @@ dependencies = [ [[package]] name = "pf-win-display" -version = "0.30.0" +version = "0.31.0" dependencies = [ "pf-paths", "punktfunk-core", @@ -3275,7 +3275,7 @@ dependencies = [ [[package]] name = "pf-zerocopy" -version = "0.30.0" +version = "0.31.0" dependencies = [ "anyhow", "ash", @@ -3487,7 +3487,7 @@ dependencies = [ [[package]] name = "punktfunk-cli" -version = "0.30.0" +version = "0.31.0" dependencies = [ "pf-client-core", "punktfunk-core", @@ -3497,7 +3497,7 @@ dependencies = [ [[package]] name = "punktfunk-client-android" -version = "0.30.0" +version = "0.31.0" dependencies = [ "android_logger", "anyhow", @@ -3521,7 +3521,7 @@ dependencies = [ [[package]] name = "punktfunk-client-linux" -version = "0.30.0" +version = "0.31.0" dependencies = [ "anyhow", "async-channel", @@ -3538,7 +3538,7 @@ dependencies = [ [[package]] name = "punktfunk-client-session" -version = "0.30.0" +version = "0.31.0" dependencies = [ "log", "pf-client-core", @@ -3554,7 +3554,7 @@ dependencies = [ [[package]] name = "punktfunk-client-windows" -version = "0.30.0" +version = "0.31.0" dependencies = [ "async-channel", "mdns-sd", @@ -3572,7 +3572,7 @@ dependencies = [ [[package]] name = "punktfunk-core" -version = "0.30.0" +version = "0.31.0" dependencies = [ "aes-gcm", "cbindgen", @@ -3604,7 +3604,7 @@ dependencies = [ [[package]] name = "punktfunk-encode-worker" -version = "0.30.0" +version = "0.31.0" dependencies = [ "pf-encode", "tracing", @@ -3613,7 +3613,7 @@ dependencies = [ [[package]] name = "punktfunk-host" -version = "0.30.0" +version = "0.31.0" dependencies = [ "aes", "aes-gcm", @@ -3683,7 +3683,7 @@ dependencies = [ [[package]] name = "punktfunk-probe" -version = "0.30.0" +version = "0.31.0" dependencies = [ "anyhow", "mdns-sd", @@ -3697,7 +3697,7 @@ dependencies = [ [[package]] name = "punktfunk-tray" -version = "0.30.0" +version = "0.31.0" dependencies = [ "anyhow", "ksni", @@ -3721,7 +3721,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "pyrowave-sys" -version = "0.30.0" +version = "0.31.0" dependencies = [ "bindgen", "cmake", diff --git a/Cargo.toml b/Cargo.toml index 65da60ba..e32a932c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,7 +65,7 @@ exclude = [ ndk = { path = "clients/android/native/vendor/ndk" } [workspace.package] -version = "0.30.0" +version = "0.31.0" edition = "2024" rust-version = "1.85" license = "MIT OR Apache-2.0" diff --git a/crates/pf-console-ui/src/screens/controllers.rs b/crates/pf-console-ui/src/screens/controllers.rs index 074d53ef..59a46759 100644 --- a/crates/pf-console-ui/src/screens/controllers.rs +++ b/crates/pf-console-ui/src/screens/controllers.rs @@ -75,11 +75,7 @@ const PASSTHROUGH: [(PadAction, &str, &str); 4] = [ "Grant", ), (PadAction::Sc2Usb, "Steam Controller 2 over USB", "Grant"), - ( - PadAction::DsUsb, - "DualSense / DualShock over USB", - "Grant", - ), + (PadAction::DsUsb, "DualSense / DualShock over USB", "Grant"), (PadAction::DsHaptics, "DualSense haptics self-test", "Test"), ]; @@ -258,7 +254,14 @@ fn spec(row: Row, ctx: &Ctx) -> RowSpec { RowSpec { header: (i == 0).then_some("Gamepads"), label: pad.name.clone(), - value: Some(if pad.rumble { "Test rumble" } else { "No rumble" }.into()), + value: Some( + if pad.rumble { + "Test rumble" + } else { + "No rumble" + } + .into(), + ), value_dim: !pad.rumble, caret: false, adjustable: false, @@ -289,22 +292,26 @@ fn detail(row: Row, ctx: &Ctx) -> String { .into(), Row::Pad(i) => pad_detail(&ctx.pads[i]), Row::Passthrough(i) => match PASSTHROUGH[i].0 { - PadAction::Sc2Bluetooth => + PadAction::Sc2Bluetooth => { "A Steam Controller 2 paired over Bluetooth cannot be detected at all without \ Bluetooth access. Wired and Puck-dongle controllers need no permission." - .into(), - PadAction::Sc2Usb => + .into() + } + PadAction::Sc2Usb => { "A wired or Puck-dongle Steam Controller 2 needs USB access to be captured; \ until then it stays in its built-in keyboard/mouse mode." - .into(), - PadAction::DsUsb => + .into() + } + PadAction::DsUsb => { "A wired DualSense or DualShock 4 needs USB access to be captured — with it, \ streams drive rumble, adaptive triggers, lightbar and gyro directly." - .into(), - PadAction::DsHaptics => + .into() + } + PadAction::DsHaptics => { "Play a short tone through a wired DualSense's audio endpoint, to tell a pad \ that cannot do haptics from a stream that is not sending them." - .into(), + .into() + } // Not offered as a passthrough row — the pads carry it. PadAction::Rumble => String::new(), }, diff --git a/crates/punktfunk-host/src/audio/linux/pad_card_volume.rs b/crates/punktfunk-host/src/audio/linux/pad_card_volume.rs index 5de7bdd9..840b8756 100644 --- a/crates/punktfunk-host/src/audio/linux/pad_card_volume.rs +++ b/crates/punktfunk-host/src/audio/linux/pad_card_volume.rs @@ -130,18 +130,16 @@ fn pin_pad_sinks() -> Result { move |g| { let Some(props) = g.props else { return }; let usb_id = |k: &str| { - props - .get(k) - .and_then(|v| { - let v = v.trim(); - // The specimen publishes `0x054c`; a bare `054c` read with base 0 - // is octal and yields nonsense, so the radix is chosen explicitly. - v.strip_prefix("0x") - .or_else(|| v.strip_prefix("0X")) - .map(|h| u32::from_str_radix(h, 16)) - .unwrap_or_else(|| u32::from_str_radix(v, 16)) - .ok() - }) + props.get(k).and_then(|v| { + let v = v.trim(); + // The specimen publishes `0x054c`; a bare `054c` read with base 0 + // is octal and yields nonsense, so the radix is chosen explicitly. + v.strip_prefix("0x") + .or_else(|| v.strip_prefix("0X")) + .map(|h| u32::from_str_radix(h, 16)) + .unwrap_or_else(|| u32::from_str_radix(v, 16)) + .ok() + }) }; match g.type_ { // Cards announce their identity keys, so no second round is needed for them. diff --git a/docs/releases/v0.31.0.md b/docs/releases/v0.31.0.md new file mode 100644 index 00000000..5099c9ec --- /dev/null +++ b/docs/releases/v0.31.0.md @@ -0,0 +1,72 @@ +Wire-compatible with 0.30.x — everything you have already paired keeps working, and you can update one side at a time. Nothing in this release changes how a host and a client agree on what to send each other, so an old client on a new host, or the other way round, streams exactly as it does today. + +Most of this release is things that were wrong in ways nothing announced. A DualSense's rumble and speaker never actually worked in a game streamed from a Linux host, for five separate reasons stacked on top of each other, and every one of them is fixed. On Windows, ending a session was silently killing the whole host, and launching a game from the library on 0.30 could drop your stream a second later. Android phones and TVs now show the same controller console the desktop does — one interface on three platforms — and the picture on a phone arrives markedly earlier with no dropped frames. On Linux hosts, another device on the box can no longer make your desktop audio stutter, and a Gaming Mode takeover on some machines was starving your controller with a login storm of our own making. + +## TL;DR + +- **DualSense rumble and speaker work in-game from a Linux host.** The controller was dying 400 ms after it appeared, every message the game sent it was reported as failed, its sound card was invisible to the sound system, and what got through arrived 48 dB too quiet. All fixed; the details are below. +- **Windows: ending a session no longer kills the host, and launching a game no longer drops your stream.** The first looked like a mystery reconnect; the second arrived with 0.30. +- **Android has the desktop's console, and a much faster picture.** One controller interface across Windows, Linux and Android — including 32-bit TV boxes — and on the reference phone end-to-end latency went from 30 ms to about 18 ms with dropped frames going from 40–50 a second to none. +- **Desktop audio on a Linux host no longer stutters because of somebody else's device.** In one 15-minute session, 15 % of what the listener heard was silence the host had papered over gaps with — because a controller's sound card, attached over the network and connected to nothing, was setting the pace for the whole box. +- **A Gaming Mode takeover on a machine that logs itself in was choking its own controller input** — the pad enumerated fine and then reported at about 1.4 times a second instead of 250. The cause was ours, and it is gone. +- **Hyprland and Sway users: the default display mode now really turns your desk monitors off during a session.** It said it did before and did nothing. Read *Before you update*. + +## Before you update + +- **Hyprland and Sway hosts:** the "exclusive" display setting — which is what the default resolves to on these desktops — now genuinely disables your own monitors for the length of a session and brings them back afterwards, exactly as it always has on KDE. Until now it was accepted, reported back as active, and quietly behaved as "extend", so your monitors stayed on. If you would rather keep them on, set the display topology to *extend*. Two things to know: on Hyprland, bringing the monitors back reloads your Hyprland configuration, which also drops any other runtime tweaks you have applied since login and re-runs its startup commands; and the Sway half is written to the same contract but has not been exercised on a live Sway machine, so if it misbehaves, please say so. +- **NixOS hosts:** there is now a binary cache, so a host no longer takes an hour to build. Add the cache and its public key to your configuration — the install guide has the snippet, and the cache serves its own public key. If your flake overrides Punktfunk's `nixpkgs` input, the cache cannot help you: every package changes and rebuilds. Separately, one of the controller-audio fixes below is a sound-configuration file the other Linux packages install into a system location NixOS does not have; it needs a package override there. +- **Other Linux hosts:** several controller-audio fixes arrive as system files inside the host package rather than as code — a device rule, a sound-system policy and a sound-card configuration. They take effect after the package is installed and the controller is reattached (or the machine rebooted). +- **Linux hosts, if you look in your sound settings mid-session:** you will now see a virtual output *and* a recording stream both named after Punktfunk. That is the host's own audio output, and it is not a leak — the troubleshooting guide has a new section on it and on what to do when it tells you another device is clocking your audio. +- **If you moved the host's management port and hand-edited the tray's start-up command to match, you can undo that.** The tray, every plugin and the plugin runner now follow the port the host actually bound, so nothing needs telling. + +## New + +- **NixOS users get prebuilt binaries.** Every other install channel shipped binaries; Nix compiled the whole workspace and our patched compositor from source — roughly an hour, on the critical path of enabling the host at all. A signed binary cache is now published on every change to the main branch, so a build that once took an hour takes the time it takes to download. +- **Hyprland and Sway hosts can run a session on the virtual display alone.** With the display topology on *exclusive*, your own monitors are switched off while the session runs and switched back on when its display goes away — never all at once, so the desktop is never left with nothing to show, and never a monitor belonging to a second session or a second host on the same machine. *Primary* is still treated as *extend* on these desktops, and now says so on its own rather than sharing a warning with *exclusive*: Wayland has no notion of a primary output, only a focused one, and the streamed display already holds that. +- **The Android app's controller interface is the desktop's.** Plug in or pair a controller and the phone or TV shows the same console Windows and Linux do — same screens, same navigation, same motion — instead of an Android-only recreation of it that had to be fixed three times over. It runs on every Android device, including the 32-bit TV boxes that were previously left out. On a phone the console now takes the whole panel, tucking the system bars away for the duration and bringing them back on a swipe, and the library gives the sort bar's height back to the covers unless you have actually pulled it down. Connected controllers is a page of the console itself now, with the permission prompts, the rumble test and the controller-audio self-test where they were before. +- **The Apple gamepad library grows up.** On Mac, iPhone, iPad and Apple TV the controller-driven library gets the desktop console's poster grid alongside the shelf, a live sort and view bar (Default · A–Z · Platform · Store; Shelf · Grid) that writes the same setting the settings rows do so the two can never disagree, and **Collections** — group by platform or store, walk the groups, open one, and back out the same way — presented as tiles carrying a fanned deck of up to three covers. The plain touch library learned to sort and group from a toolbar menu too. Every title's platform, which the host had been sending all along and the app was throwing away, now shows in the detail band and drives the grouping. +- **The audio buffer can grow without going silent.** Every client keeps a small buffer of sound and adjusts its depth to what the link and the picture are doing. It could shrink gently, one crossfaded frame at a time — but the only way it could *grow* was to throw everything away and refill from silence. So whenever the picture's timing wandered and the sync loop asked for a little more audio depth, the very next late packet cost a 15–60 ms gap. This shape has been with us since roughly 0.24. The buffer now moves toward its target in both directions with the same gentle instrument, on Windows, Linux, Android, Mac, iPhone, iPad and Apple TV alike. +- **The audio threads that feed the speakers run at real priority on the client.** The device callback itself already did; the threads decoding audio and rendering controller audio did not, and on a Steam Deck decoding a 1440p120 stream on the same four cores that was a source of clicks. The previous attempt at this was a no-op on the Deck (its user account is not allowed to ask), so the client now goes through whichever door the machine actually offers — including from inside a Flatpak. Windows clients get the same through the system's pro-audio scheduling. +- **A host that is losing audio now says why.** The Linux host logs, by name, whichever device is setting the pace for its audio whenever that changes, and warns when it is not the host's own output — because those stalls become your holes. The capture summary also breaks its lost audio down by how long each gap was, so a periodic scheduler on the box and one long outage stop reading identically. +- **Plugins stop re-scanning your library while a game runs.** Steam writes to its folders the whole time a game is open, and every write was re-walking the library — 102 times in one 27-minute log. There is now a floor of one re-scan per half-minute per plugin, with nothing lost: whatever changed during the hold is picked up in exactly one trailing scan. + +## Improved + +- **Windows: the plugin runner writes a log file you can read.** A field report on a 0.30 host had plugins installed, the runner running, an empty library and "no logs at all" — and that was by design, since the runner's only way to speak was through the host it could not reach. It now writes a plain log file next to its plugin state, and the console's empty-library hint tells you where it is. +- **Apple gamepad screens move like the desktop's.** Screen transitions in the Mac, iPhone, iPad and Apple TV controller shell use the same spring the desktop console uses, and they can be interrupted — press B mid-flight and the same spring carries you back. Reduce Motion crossfades instead of snapping. +- **The Apple library fits a phone.** The grid fills the width instead of leaving a fifth of it empty on a phone; in a landscape phone's height it holds two rows instead of one; the shoulder-button hint hides on any phone and the sort bar has become a tray you pull down with ▲ and dismiss with ▼, A or B, so the field keeps every point of height it has. Navigating the grid no longer scrolls twice for one move, and a diagonal flick of the stick is one move, not two. +- **The on-screen keyboard on Mac and iPad behaves.** The row you are typing into flies from its place in the list to a seat directly above the keys and back, instead of the empty row doing the flying while the real one appeared from nowhere; a hardware keyboard types straight into the field while the tray is up, including characters the on-screen grid does not offer; and Esc means Done rather than closing the whole screen. +- **The controller speaker sounds like a speaker.** Both controller-audio lanes were compressed with the low-delay voice profile that suits rumble — on the speaker it sounded, in the words of the person who heard it, insanely compressed. The speaker lane now uses the full-quality music coder at a higher bitrate; the rumble lane is unchanged. +- **A silent controller speaker is no longer indistinguishable from broken hardware.** On Android the controller speaker is off by default — deliberately, it is a small loudspeaker in your hands — but nothing said so, and one field session spent an evening measuring the host for a speaker that was simply switched off on the phone. The setting now states its default, and the host's controller-audio test tells you up front whether your client would even be asked to play what the test is about to prove works. +- **The virtual controller looks exactly like a real one to the system.** It carried a placeholder serial number no real pad has, which leaked into every device name derived from it. Cleared, so anything that matches on those names sees the same text a physical pad produces. + +## Fixed + +- **Ending a session killed the entire host on Windows.** The service restarted it about six seconds later, so it read as a mystery reconnect rather than a crash — four times on one machine, every one of them a session teardown. The cause was a single log line written at a moment when the logging machinery could already be gone, and the crash handler then hid the evidence by trying to log the same way and failing the same way. Neither happens now, and if anything of that shape ever recurs, the message naming it will actually be written down. +- **Launching a game from a Windows host on 0.30 could drop your stream a second later**, with the console reporting nothing running. Almost every Windows launch is a hand-off to Steam, Playnite or the shell — a process that quits a second after the launcher takes over. 0.30 learned to track the process it started, and for a title without any way to recognise its window it took that hand-off's exit as the game's and closed the connection. It would also, in one shape, have asked your whole Steam client to close when the game ended. Both fixed; a title the host cannot track shows as untracked instead of "launching" forever. +- **DualSense rumble and speaker in a game streamed from a Linux host — five faults, in order:** + - **The controller vanished 400 ms after it appeared** when the newer USB-style transport was on. One of its setup reports was one byte longer than the driver asks for, and USB treats an over-long reply as hostile and tears the whole device down. The report is the right length now, every reply is trimmed to what was asked for, and the host waits for the system to actually adopt the pad before it calls the pad ready. + - **Every message the game sent to the pad was reported as failed**, so the one command that switches on rumble, adaptive triggers and the speaker never took, and nothing downstream could show a result. The pad was acknowledging every write with "0 bytes accepted". It acknowledges the bytes it took now — and the Steam Controller 2 shares that path, so its writes were being reported wrong too. + - **The pad's sound card was only readable by the administrator**, because it appears at a moment when no desktop session is active to grant access, so the sound system never saw it and games had nowhere to route controller audio. A device rule fixes it, for virtual and physically plugged pads alike. + - **What reached the voice coils was 48 dB too quiet.** The sound system starts every new device at what a mixer shows as 40 %, which in real amplitude is about −24 dB — and both the host and the client minted one, so the two stacked. Both ends now pin the controller's volume to unity. + - **The controller's only playback route was a mono output that at least one game overran and crashed on**, about 74 seconds in. The pad now presents its proper multi-channel profile from the moment it appears, so that route — and the crash path — never exists, and the coils get their own channels into the bargain. + - Also on this path: the speaker channel was being forwarded to the wrong side, so you felt rumble but heard nothing; the pad's audio clock ran about 26 % slow under load, backing its own audio up into dropouts and dragging desktop capture down to half delivery with it; and the sound-system policy that keeps a controller from setting the pace for the whole box was set to a value that made it merely last in line rather than excluded, which on a Punktfunk host is still elected. All corrected. +- **Desktop audio on a Linux host stuttered because another device was setting its pace.** The host's audio output was a stream pretending to be an output, and a stream never keeps its own time — so the sound system handed the job to the highest-priority running device on the box. On one reporter's host that was a DualSense forwarded over the network: connected to nothing, never sleeping, and unable to keep steady time for something arriving over a link. Every cycle that ran was healthy; the loss was in the gaps between them — 3.9 holes a second, the worst 142 ms, and 15.4 % of a 15-minute session filled with silence the host generated to cover them. The host now creates a real virtual output that keeps its own time inside the sound system, so no hardware or network device can ever be chosen for it. It also turns out the host's audio callback has been running at 2.67 ms rather than the 5 ms it was designed around on every stock Linux host since it was written, because of a rounding rule nobody had accounted for; that is corrected on the same path. +- **A Gaming Mode takeover on a machine that logs itself in flooded the box with logins.** On a host set to log its user in automatically, the way the takeover held the desktop's login manager back sat squarely in that manager's retry path — so every automatic login failed in milliseconds and it tried again, with no back-off: 962 logins in under four minutes, the system's buttons re-scanned 5,688 times, a load of 26 on 12 cores, and every program that listens for new devices drowning in the noise. To you it looked like "my DualSense is not detected, or only with an insane delay": the pad enumerated perfectly and then delivered input at about 1.4 Hz. That method of holding back is gone; the takeover now stops the login manager first and only then steps in. If it cannot stop it, it no longer tries anyway — it falls back to streaming the desktop's own session, which is a working stream, rather than fighting a login loop that costs you your controller. +- **Audio hiccups on the client that nothing counted.** On the Linux desktop client and the Steam Deck the audio callback was running on the wrong thread at ordinary priority, and when that thread was late the sound system played silence for us and moved on — an underrun no counter ever saw. On the host, the audio pacer measured its schedule against the wall clock while the audio it carried did not, so every small hole left it a little further behind until a bigger hole repaid the debt as a burst of silence frames — one field log showed 33–72 % of departures late and the worst nearly 100 ms behind. Both fixed, and holes no longer open and close with a click. +- **A Steam Deck's support bundle covered three seconds instead of the whole session.** The video decoder writes a dozen bookkeeping lines per frame, and at 120 fps that flushed the entire log ring — 2,037,456 lines evicted from a 27-minute session, including the one audio line three rounds of investigation had been waiting for. The chatter is filtered before it reaches the ring. +- **Android: the picture arrived late and dropped frames on a phone that could easily keep up.** The reference phone decodes a frame in 4–5 ms and was still showing 30 ms end to end with 40–50 skipped frames a second, because it was pacing against a display clock that Android quietly slows down for game processes. Frames are now composited straight onto the display on the panel's real timing, and each one reports back exactly when it landed. On glass: end-to-end 30 ms → about 18 ms, skipped frames 40–50 a second → 0. Whether the panel *holds* 120 Hz turns out to be the phone maker's power policy, not the app's — nothing an app can ask lifts it — so if you want 120 on such a phone, set your phone's minimum refresh rate. +- **Android: the controller went dead after opening the Controllers or Licences page from the console** — every press was dropped until you force-stopped the app. Also, plugging in a controller could leave you on a grey screen for the rest of the session if the console could not draw; it now hands you back to the touch interface instead. +- **Mac, iPad and Apple TV: clicking a host connects to it again.** A change earlier in this cycle had made the host card open the game library instead, with "connect" pushed into the menu — the opposite of every other client. Reverted: tap to connect, "Browse Library…" back in the menu, everything else the library work landed kept. +- **The Apple grid's first day on real hardware.** A single stick flick was read as up-then-right on the way out of the dead zone; the grid drew over the pinned title; rows vanished while still in view; one step down scrolled the row above half away; and "Copy link" was a face button on a gamepad interface. All addressed — X now opens a small options menu for the title. +- **RPM builds were broken by one stray line**, so for a short window the main branch produced no Fedora packages at all. Fixed the same day; no release was affected. + +## Thanks + +Several of these were found because someone sent a log detailed enough to disprove the obvious. The Steam Deck bundle that turned out to hold three seconds of decoder chatter is what made the log-ring bug visible; the audio-clock investigation went through four field logs and a purpose-built probe before a single column in a diagnostic tool named a controller's sound card; and the report of a Windows host with plugins installed, a runner running and nothing to show for it described exactly the failure the runner could not report on its own. Thank you. + +## For developers + +Protocol, ABI, driver and embedder detail — including the version table and the notes on what moved — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.31.0/CHANGELOG.md). + +The short version: nothing versioned moves — no wire, ABI, driver-protocol or plugin-contract change — and the C header is byte-identical to 0.30.0's. Three things are worth reading before you package or embed this release: the Linux host package now installs three new system files (a device rule, a sound-system policy and a sound-card configuration) that the controller-audio fixes depend on; the Linux desktop-audio capture changed topology by default, with a one-release escape hatch back to the 0.30 shape; and the Android app's Compose console is deleted outright, which removes its screenshot scenes. diff --git a/docs/releases/whatsnew/v0.31.0.txt b/docs/releases/whatsnew/v0.31.0.txt new file mode 100644 index 00000000..f6e71b58 --- /dev/null +++ b/docs/releases/whatsnew/v0.31.0.txt @@ -0,0 +1,3 @@ +• The controller interface is now the same console the desktop app shows — on every phone and TV, 32-bit boxes included — with a Controllers page of its own. +• A much faster picture: frames land on the panel's real timing, so on the reference phone latency fell from 30 to about 18 ms and dropped frames from 40–50 a second to none. +• Fixed: the gamepad going dead after opening Controllers or Licences, a grey screen when the console could not draw, and audio gaps when the buffer needed to grow.