Commit Graph
1290 Commits
Author SHA1 Message Date
enricobuehler 0de161e29b Merge pull request 'feat(clients/input): controllers can stop being forwarded, for couches that hand the pad over another way' (#22) from worktree-gamepad-passthrough-toggle into main
ci / web (push) Successful in 1m10s
apple / swift (push) Successful in 1m22s
ci / docs-site (push) Successful in 2m0s
ci / rust-arm64 (push) Successful in 2m37s
decky / build-publish (push) Successful in 48s
deb / build-publish-client-arm64 (push) Successful in 1m27s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m33s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
deb / build-publish (push) Successful in 4m0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m3s
deb / build-publish-host (push) Successful in 3m51s
docker / builders-arm64cross (push) Successful in 14s
arch / build-publish (push) Failing after 5m22s
docker / deploy-docs (push) Successful in 33s
android / android (push) Successful in 7m22s
ci / rust (push) Failing after 9m12s
flatpak / build-publish (push) Failing after 5m29s
release / apple (push) Successful in 9m14s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m12s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m34s
apple / screenshots (push) Canceled after 4m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 10m21s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 11m8s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 1m31s
Reviewed-on: #22
2026-08-02 21:38:01 +00:00
enricobuehlerandClaude Opus 5 b297542c4d feat(clients/input): controllers can stop being forwarded, for couches that hand the pad over another way
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m39s
ci / docs-site (pull_request) Successful in 2m6s
ci / rust-arm64 (pull_request) Successful in 2m50s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
android / android (pull_request) Successful in 3m27s
ci / rust (pull_request) Successful in 7m59s
A controller that reaches the host by USB passthrough — VirtualHere and friends, or simply a
pad plugged into the host — arrived there twice: once as the real device, once as the virtual
pad this client built from the same hands. Games read both, so a stick drifts against the
centred second pad and menus take every input twice.

New per-client setting, "Forward controllers", default on (today's behaviour). It is tier-P,
so a profile can decline what another profile forwards.

On Linux and Windows it is deliberately stronger than "send nothing". Opening a controller is
what CLAIMS it — SDL's HIDAPI drivers take the device node — and a claimed device is one a
passthrough tool cannot bind, so with this off the session opens no slot at all and never
enables the Valve HIDAPI drivers. Menu navigation is untouched: the launcher still opens the
active pad, and a session supersedes menu mode whether it forwards or not, so the pad is free
for the whole time a stream is up. The consequence, documented at both the setting and the
chord: the controller escape chord is read off forwarded pads, so it is unavailable there.

The Apple and Android input stacks claim nothing, so those clients keep their slots and their
chords and only gate the wire sends — losing tvOS's only controller way out of a stream would
have been the worse bug. Android does stop its DualSense and Steam Controller 2 USB captures,
which do claim the device.

Surfaces: GTK, WinUI, the console settings screen, Apple's touch and gamepad settings, the
Android touch and gamepad settings, and Decky (which also hides the rows that now have nothing
to act on). Everywhere the "which pad" and "pad type" rows grey out while it is off.

Verified: cargo clippy --all-targets -D warnings + 79 tests on pf-client-core, pf-console-ui,
punktfunk-client-session and punktfunk-client-linux (linux/amd64 container, gate proven
non-vacuous with a planted error); swift build for the Apple clients; gradle compile + 49 unit
tests for Android (likewise proven); tsc for Decky. clients/windows is UNCOMPILED — both
Windows boxes were offline; its edits were reviewed against the helper signatures by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:21:26 +02:00
enricobuehlerandClaude Fable 5 98e040fd01 fix(host/stream): the wire holds the session rate when the display outruns it
apple / swift (push) Successful in 1m20s
ci / rust (push) Successful in 5m0s
android / android (push) Successful in 6m30s
ci / rust-arm64 (push) Successful in 1m50s
apple / screenshots (push) Successful in 5m37s
arch / build-publish (push) Successful in 8m0s
ci / web (push) Successful in 1m28s
ci / docs-site (push) Successful in 1m36s
deb / build-publish-client-arm64 (push) Successful in 2m19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 18s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
windows-host / package (push) Successful in 13m17s
windows-host / winget-source (push) Skipped
deb / build-publish-host (push) Successful in 5m33s
deb / build-publish (push) Successful in 5m39s
docker / builders-arm64cross (push) Successful in 7s
docker / deploy-docs (push) Successful in 24s
windows-host / canary-manifest (push) Successful in 15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m56s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m18s
PUNKTFUNK_VDISPLAY_HZ_MULT promises extra display refreshes without one
extra frame on the wire, but the frame-driven trigger enforced its pace only
as a per-gap floor: sleep to 0.9×interval, then wake on arrival. A source
that always has a frame pending — the overdriven display under uncapped
content — settled at 0.9-interval spacing, 1.11× the negotiated rate. That
is the field report's 132 fps on a 120 fps session: ten percent more
bitrate, encode and decode for frames a 120 Hz panel can only drop.

A credit bucket (PaceBudget) now pins the long-run average at the pacing
rate: credit accrues at one frame per interval of real elapsed time, capped
at 1.25 frames of post-stall burst, and every submitted frame spends one. A
grab may run early only against banked credit, so the 0.9 floor keeps its
per-gap jitter headroom while the average cannot exceed the rate — and a
source at or below it banks faster than it spends and is never delayed.
Anchoring to real elapsed time also keeps the synchronous-encode overlap the
arrival-anchored floor bought (the owed fraction absorbs a constant encode
tail instead of stacking on top of it), and it cannot fight the phase lock's
submit grid: both agree the period is the interval.

The charge lives under the same guard as the gate — the legacy fixed tick
paces by its own grid, and charging it without ever accruing would bank
unbounded debt that stalls the loop if a rebuild later flips the capturer to
arrival-wait.

Verified on .25: native::stream tests 15/15 (three new PaceBudget tests),
punktfunk-host 369/369, clippy -D warnings clean, fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 21:54:09 +02:00
enricobuehlerandClaude Fable 5 5174a59832 fix(capture/kwin): a hidden cursor leaves the stream — KWin's id-0 meta is the hide
Since the 0.22.0 cursor work (the seat-pointer park + the metadata
composite), a KWin capture-model stream always has a cursor — and it never
went away again: not in game, not in Big Picture, not with a controller in
hand (field report, 2026-08-01). The host blended the arrow forever because
pf-capture deliberately ignores SPA_META_Cursor id 0, and once `visible`
latched true nothing on Linux ever cleared it.

Two producer contracts meet on id 0, and one flag now carries which one a
stream follows. KWin rewrites the cursor meta on EVERY enqueued buffer and
writes id 0 whenever Cursor::isOnOutput says the pointer is not in this
stream — which covers both a globally hidden cursor and a client null-cursor
surface (empty geometry intersects nothing). There id 0 IS the hide, and
honoring it is what lets a game hide the pointer mid-stream. Mutter only
rewrites a buffer's meta when the cursor changed, so recycled buffers carry
stale id-0 regions between damage frames — honoring those flickered the
cursor off between hovers (on-glass round 5), and that path keeps its
last-known-state behavior.

The flag rides from the backend that created the output (correct for
registry-pooled reuse too — a kept display only ever matches its own
backend) through capture_virtual_output into the parser's CursorState. The
portal-monitor path stays on the stale-meta contract: the only thing routed
through it today is Mutter's HDR mirror.

Verified on .25: pf-capture 45/45, punktfunk-host 369/369, clippy
-D warnings clean (pf-capture, punktfunk-host, cursor-probe), fmt clean.
On-glass KDE validation still owed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 21:54:09 +02:00
enricobuehlerandClaude Opus 5 20de58a78a fix(android/present): the panel grid can be wrong in both directions, and the margin listens to the latch
Three defects in the 0.23.0 timeline presenter, all found while root-causing the
field report that turned out to be the slice wire. None of them is that bug; all
three are real, and the first is the one that would still bite once it is fixed.

The panel-period learner could only ever narrow. It is seeded from the display
mode Kotlin asked for — and `preferredDisplayModeId` is a REQUEST the system may
refuse (Smooth Display off, battery saver, thermal, an OEM governor). Ask for
120 Hz on a panel that stays at 60 and the presenter pins an 8.33 ms grid on a
16.67 ms display with no way back, for the rest of the session: it then aims at
instants that never arrive and releases faster than the panel scans. The learner
moves both ways now, and lives in `punktfunk_core::phase::PanelGrid` where it is
host-testable and where the iOS and desktop presenters can share it. The
asymmetry is kept and made explicit — narrowing is immediate (a finer real grid
is always safe to subdivide onto, and it is the per-uid down-rate case the seed
most often gets wrong), widening needs eight consecutive agreeing observations
and then takes the narrowest of them, because one wide sample is a missed
callback and eight in a row is a display that really did slow down.

The glass budget was a prediction with nothing underneath it. `OnFrameRendered`
already reports what actually reached glass, but the budget never consulted it,
so a wrong grid could hand SurfaceFlinger frames indefinitely: BufferQueue fills,
MediaCodec runs out of output buffers, the decoder stalls, and the no-output
backstop starts begging for keyframes. Releases are now counted against their
confirms and the presenter holds back past six outstanding — loose on purpose,
since the callbacks are allowed to arrive batched and a held frame in the
newest-wins slot is a dropped one. It self-clears when the confirms catch up, and
writes the ledger off after the same 100 ms the stale reopen uses, so a platform
that stops confirming can never wedge the stream. `qWait` and `unconfirmed` join
the 1 Hz pf.present line, which is what would have made this visible from a log.

The adaptive latch margin widened on `paced_drops` — the newest-wins store's own
policy evictions, which happen whenever the stream out-runs the panel and say
nothing about SurfaceFlinger's latch lead. On a healthy device that walked the
margin to its 2.5 ms ceiling and re-imposed the display latency the P2e sweep had
just measured away. It now widens on the measured latch exceeding one panel
period plus the live margin, which is what a missed vsync actually looks like.

Also corrects two doc comments that named `display.refreshRate` as the panel_hz
source; it has been the mode table since the A024 down-rate fix.

Gates: 278 punktfunk-core lib tests (7 new PanelGrid cases incl. the refused-mode
regression), clippy -D warnings and fmt clean, cargo ndk check green on arm64 and
armv7. Android clippy reports the same 4 warnings as the base commit and no new
ones. NOT yet confirmed on glass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:15:45 +02:00
enricobuehlerandClaude Opus 5 97b2c01ac1 fix(core/packet): a slice-streamed frame costs its own size, not the whole frame ceiling
The 0.23.0 slice wire flushes a block every MIN_STREAM_BLOCK_SHARDS, so every
ordinary access unit is now opened by a SENTINEL — a header with no totals. The
reassembler sized those frames at `max_frame_bytes`, which the QUIC handshake
clamps to 8-64 MiB. That was survivable while sentinels were rare (the streamed
path emitted one only for an AU exceeding a whole FEC block, ~281 KB); it is not
survivable now that every frame is one.

Two consequences, both measured: each access unit allocated and ZEROED a
multi-megabyte buffer, and the in-flight budget (IN_FLIGHT_BUF_FACTOR x
max_frame_bytes) was spent after ~3 concurrent frames — with production geometry,
12 ordinary AUs in flight lost 9 of them outright, every packet dropped before it
could be placed. On a link with normal reorder that is a permanent loss storm:
frames never complete, the re-anchor gate freezes the picture, and the client begs
for keyframes. Only clients advertising VIDEO_CAP_MULTI_SLICE reach this path —
Android and the Linux/Windows session client; Apple and the Windows in-process
client never did, which is why it read as a platform-specific "video pipeline"
fault in the field.

A sentinel carries no total but does pin its own block's extent: a slice sentinel
by its wire base, a legacy one by its full-K position. Size the buffer to that and
grow as later blocks (or the final block's totals) reveal more. The budget is
re-checked on growth for the same reason it is checked at open.

The same flush also drained `pending` to empty whenever the AU's length was an
exact multiple of the shard payload, leaving `finish_streamed` to seal a final
block of one zero-padded FILLER shard. Its derived base overlapped the block
flushed a moment earlier, retro-validation correctly read that as a lying header,
and the whole AU died — one frame in every 1408 on a 1500-MTU link, ~12 s apart at
120 fps, each costing a freeze and a recovery keyframe. A flush now keeps one
whole shard back, restoring the invariant `StreamedAu::pending` already documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:15:45 +02:00
enricobuehlerandClaude Opus 5 b6acbd096e fix(host/vdisplay): waking the PC stops failing the first session
android / android (push) Failing after 2s
apple / swift (push) Successful in 1m21s
ci / rust-arm64 (push) Successful in 1m49s
ci / web (push) Failing after 1s
ci / docs-site (push) Successful in 1m32s
ci / rust (push) Successful in 6m38s
apple / screenshots (push) Successful in 5m55s
arch / build-publish (push) Successful in 8m58s
deb / build-publish (push) Successful in 6m38s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3m19s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m9s
deb / build-publish-host (push) Successful in 4m57s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 3m43s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3m59s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
windows-host / package (push) Successful in 12m4s
windows-host / winget-source (push) Skipped
docker / deploy-docs (push) Successful in 55s
windows-host / canary-manifest (push) Successful in 1m30s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 10m36s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 10m55s
deb / build-publish-client-arm64 (push) Failing after 11m12s
docker / builders-arm64cross (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 11m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m27s
A woken Windows host refused every connection with "pf-vdisplay driver
interface not found", on a box where the driver was installed and running.

Resuming re-enters D0 and re-registers the IddCx control interface while
the rest of the resume storm is still going. A client reconnecting a
second after wake lands inside that gap. `ensure_available` probed
exactly ONCE, so it read the gap as a dead driver and answered a device
that was seconds from ready by disabling and re-enabling it — then gave
the interface 4 s to come back, which a contended post-resume PnP does
not meet. The session failed, and the log blamed a missing install.

The recovery also could not tell whether it had recovered anything. It
ran the whole cycle under `SilentlyContinue` and reported
`(Get-PnpDevice).Status` — the DEVICE's status, not the cycle's outcome —
so a disable that was REFUSED left the adapter untouched, started, and
reading `OK`. That is the reporter's `cycled the adapter device …
status=OK` line: a recovery that never happened, announcing success. And
a refusal is the expected case here, not the exotic one:
reset-pf-vdisplay.ps1 stops the host service first precisely because the
host holds the driver's control device open, a step an in-process cycle
structurally cannot take.

- Distinguish a devnode MID-TRANSITION (interface registered, not started
  yet, or the open refused) from one genuinely ABSENT. Wait the first
  out; only the second earns a reload. `Probe` carries the counts.
- Report what the reload DID, not what the device looks like afterwards:
  every failable step is `-ErrorAction Stop` in a `try`, and
  `pnputil /restart-device` is the fallback for the in-use device that
  `Disable-PnpDevice` refuses. Failure paths re-enable, so a half-cycle
  can never strand the adapter DISABLED.
- Give the interface 15 s to arrive after a reload, not 4 — under a 30 s
  hard ceiling so a permanently wedged devnode still fails predictably.
- Serialize recovery: N sessions racing in after a wake perform ONE
  reload, not N interleaved ones. The lock is taken only where no manager
  lock is held, so the order stays one-way.
- Retire the manager's cached control handle when a reload runs, instead
  of letting the next session discover it via a failed IOCTL.
- Surface the real reason. `ensure_available` returns `Result`, so the
  log names how long it waited, whether a reload ran, and how many
  interface instances were seen in what state — the detail that would
  have identified this from the field report's log alone.

`VdisplayDriver::open` now shares the wait (brief, no reload) instead of
carrying a second, drifted copy of it — that path is also reached by
`hw_cursor_capable` mid-handshake, where a reload would be the wrong
trade for one capability bool.

Windows-gated, so verified with scripts/xcheck.sh (check + clippy -D
warnings, --all-targets) and cargo fmt; on-glass wake test still owed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 13:12:54 +02:00
enricobuehlerandClaude Opus 5 362595b20f fix(vdisplay/kwin): the streamed output declares it mirrors nothing, so a stored replicationSource can't clone a panel into the stream
apple / swift (pull_request) Successful in 1m25s
ci / rust-arm64 (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m13s
ci / rust (pull_request) Successful in 4m22s
ci / docs-site (pull_request) Failing after 18m2s
ci / web (pull_request) Failing after 18m8s
ci / rust (push) Failing after 3s
ci / rust-arm64 (push) Failing after 5s
deb / build-publish-host (push) Failing after 3s
deb / build-publish-client-arm64 (push) Failing after 3s
apple / swift (push) Successful in 1m22s
ci / docs-site (push) Successful in 1m24s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 31s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 23s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 4m14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 4m13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 4m15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 2s
docker / builders-arm64cross (push) Successful in 21s
android / android (push) Successful in 6m2s
docker / deploy-docs (push) Successful in 1m14s
apple / screenshots (push) Successful in 6m3s
arch / build-publish (push) Successful in 8m11s
deb / build-publish (push) Successful in 5m4s
ci / web (push) Failing after 10m16s
windows-host / package (push) Successful in 11m15s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 17s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 10m25s
KWin stores output configuration per *setup* — the exact set of connected
outputs, matched by EDID/connector — in `kwinoutputconfig.json`, and
`replicationSource` is one of the fields it saves and restores
(`OutputConfigurationStore::storeConfig` / `setupToConfig`). Our virtual output
carries a STABLE name on purpose, so once any setup has an entry making
`Virtual-punktfunk` a mirror of a physical head, KWin re-applies it to OUR
output on every session that reproduces that same monitor set — and only that
set, which is why the failure looks environment-dependent: a field report has
the stream cloning the panel whenever exactly one monitor is live, and behaving
normally the moment the others come back (a different setup key, a different
stored entry).

A mirroring output is not a desktop. KWin's `applyMirroring` overrides its scale
and render offset to the source's, so the stream carries the physical screen's
viewport at the physical screen's size instead of the mode the client
negotiated. The protocol says the rest out loud on `priority`: "an output may
not be in the output order if it's disabled or mirroring another screen" — so
the primary assertion this module works so hard to verify silently stops meaning
anything too.

Nothing we sent ever contradicted the stored value. The topology config enabled
our output, took priority 1 and disabled the others, but never stated the one
property that decides whether the thing is its own screen. Now it does:
`set_replication_source(ours, "")` rides along in the config we already build
(free, idempotent — an empty source is exactly what KWin resolves to "mirrors
nothing"), gated on management v13 where the request appeared, since wayland-rs
does not range-check requests and an out-of-range opcode would kill the
connection.

`extend`/`auto` issue no topology calls by design — the streamed output is meant
to join the desk without rearranging it — but a mirror is not an arrangement, it
is a broken source under every topology. So they get `clear_replication_source`,
which enumerates and applies ONLY when our output really is mirroring.

The device's `replication_source` event is now read, so the state is visible: a
mirrored streamed output names its source in a warn instead of leaving "the
stream just shows my monitor" as something only the reporter can see.

Verified on 192.168.1.25 (Ubuntu, cargo 1.96): `cargo test -p pf-vdisplay` 128
pass (7 in `kwin_output_mgmt`), `cargo clippy -p pf-vdisplay --all-targets
--locked -D warnings` clean, `scripts/xcheck.sh linux` clean, fmt clean. NOT yet
on-glass — no KDE box here reproduces a stored mirror; the reporter's setup is
the real test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 11:55:34 +02:00
enricobuehlerandClaude Fable 5 caa47e28e6 fix(client/decode): AV1 hardware decode stops silently opening libdav1d
apple / swift (push) Successful in 1m27s
ci / rust (push) Successful in 5m28s
android / android (push) Successful in 5m59s
ci / rust-arm64 (push) Successful in 2m24s
ci / web (push) Successful in 1m55s
arch / build-publish (push) Successful in 9m23s
ci / docs-site (push) Successful in 1m45s
deb / build-publish-client-arm64 (push) Successful in 2m23s
windows-host / package (push) Successful in 12m12s
windows-host / winget-source (push) Skipped
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 37s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
release / apple (push) Successful in 12m6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
deb / build-publish (push) Successful in 5m54s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
deb / build-publish-host (push) Successful in 5m19s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m43s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m44s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
windows-host / canary-manifest (push) Successful in 24s
docker / deploy-docs (push) Successful in 45s
docker / builders-arm64cross (push) Successful in 8s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m7s
apple / screenshots (push) Successful in 5m51s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m17s
flatpak / build-publish (push) Successful in 5m38s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m7s
avcodec_find_decoder(id) returns the registry's FIRST decoder for the id, and
upstream orders the native av1 decoder LAST on purpose ("hwaccel hooks only,
so prefer external decoders" — allcodecs.c). All three hardware backends
selected by id, so every AV1 session opened libdav1d: a software decoder that
silently ignores hw_device_ctx and never calls get_format. Each frame then
failed the backend's hw-format guard and the session burned the demotion
ladder MID-STREAM — field-logged as 68 Vulkan fails → D3D11VA → 102 fails →
software, ~3 s of black — with "hardware decode active" already printed and
the D3D11 profile/pool probes all green. H.264/HEVC never hit this only
because their native decoders happen to be registered first.

Selection is now by capability: find_hw_decoder walks av_codec_iterate and
takes the first decoder whose avcodec_get_hw_config advertises the backend's
surface via HW_DEVICE_CTX, so a build without a usable hw decoder fails at
OPEN in milliseconds and the ladder runs there — the idiom the D3D11 probes
already follow. Registry order still wins among capable decoders, so
H.264/HEVC select exactly what they always did. The software path keeps the
id lookup on purpose: libdav1d is the fastest CPU AV1, and the native av1
decoder has no software path at all.

Every decode log now carries the selected decoder's name — decoder="av1" vs
decoder="libdav1d" is the whole diagnosis, and no log line said it. The
session log names the WIRE codec and drops the FFmpeg id for PyroWave
(ffmpeg_codec_id's fallthrough claimed codec_id=HEVC for wavelet sessions
that never touch FFmpeg).

The CPU lane also stops passing raw PQ off as a tone-map: software-decoded
frames deliberately never take the HDR10 swapchain, but a PQ stream there was
then shown UNtonemapped (washed out) with no warning — the pq-downgrade warn
keys off the swapchain answer — while the Detailed OSD badge claimed the
"HDR→SDR" tone-map that only the hardware lane's CSC runs. The presenter now
warns once when a PQ CpuFrame arrives, and the badge distinguishes
"HDR→SDR (raw)" (no tone-map pass) from the hardware lane's real "HDR→SDR".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 09:54:43 +02:00
enricobuehlerandClaude Fable 5 652abeb397 fix(host/audio): an unsatisfiable wiring plan waits for an endpoint change instead of hammering
Field case 2026-08: the display isolate invalidated the only real render
endpoint; the mic held the Steam Streaming Microphone, the Speakers were
blacklisted, and the capture loop re-ran the full wiring pass — three
IPolicyConfig SetDefaultEndpoint writes included — every 2 s for 8+
minutes, retrying a verdict that could never change.

- wiring_plan: a plan with no loopback is a typed structural verdict
  (Wiring::loopback_unsatisfiable + an endpoint-set fingerprint); the
  dead leftover() tier (byte-identical to real_hw()) becomes a real last
  resort that accepts ONLY the Steam Streaming Speakers, flagged
  loopback_last_resort — a known-silent-loopback QUALITY risk, never the
  cable/VoiceMeeter echo CORRECTNESS risks. excluded_from_loopback stays
  untouched (judge_default's mid-stream snap-back semantics).
- wasapi_cap: an unsatisfiable plan errors ONCE per topology with the
  render inventory, per-endpoint rejection reasons and only the remedies
  not already taken, then parks on a cheap enumerate-and-hash poll and
  re-plans the instant the set changes; transient failures get a real
  capped exponential backoff (2 s → 60 s, reset on success or set
  change); a last-resort capture re-plans on any set change and promotes
  the 30 s zero-packet breadcrumb to warn.
- audio_control: the recording default is asserted only when the plan
  changed or the default drifted — set_default_endpoint fires all three
  SetDefaultEndpoint roles unconditionally, so the 2 s loop silently
  stomped any operator recording-device change; the "attach one, or let
  the host install the Steam Streaming pair" warn (already satisfied in
  the field case) is replaced by the same diagnosis.

Verified: 19/19 wiring_plan tests (native rustc --test and the Linux CI
image via docker); both Windows files type-check and clippy clean
against wasapi 0.23.0 / windows 0.62.2 for x86_64-pc-windows-msvc via an
xcheck-style stub harness (the in-tree target check dies in
openh264-sys2's build script on macOS, as scripts/xcheck.sh documents).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 09:53:36 +02:00
enricobuehlerandClaude Fable 5 48511d1267 fix(abr): probe throughput is measured over the client's receive interval
The capacity probe divided client-side bytes by the HOST's burst
duration — a window wrong on both edges (base snapshotted before the
burst reached the host, frozen only when the ProbeResult landed, while
the host's clock stops the moment ITS send window closes, before the
switch/kernel queue finishes draining toward the client). On a 1 GbE
link a 2 Gbps burst target "measured" 1266 Mbps and set an 886 Mbps
climb ceiling the link could never carry — permanent for the session,
because set_ceiling never lowers.

The reassembler now stamps probe-scoped counters (bytes, packets,
first/last arrival, monotonic ns) at its FLAG_PROBE routing, so video
around the burst contaminates neither numerator nor denominator; the
throughput divisor is the client's first→last arrival interval, with
the host duration kept as the fallback when fewer than two probe
packets arrived. The user-facing speed test shares the corrected
computation (ProbeOutcome/PunktfunkProbeResult layouts unchanged;
elapsed_ms docs updated to the new semantics).

Two guards ride along:
- PUNKTFUNK_ABR_MAX_MBPS clamps inside set_ceiling — the one funnel
  every learned ceiling passes through — so a user cap binds no matter
  what any probe concludes.
- The controller latches decode_cap_kbps when two CONSECUTIVE backoffs
  carry decode-severe evidence (deep decode excursion or jump-to-live
  flush) at a similar pre-backoff rate, mirroring host_cap_kbps for the
  client decoder: a knee below the link ceiling was a permanent 30-60 s
  sawtooth costing a flush + dropped-frame burst per cycle (1440p120
  HEVC field case, knee ~490 Mbps). One spurious flush never latches;
  the cap re-probes on the CAP_REPROBE_WINDOWS clock, so it lifts when
  the decoder recovers.

Also rights the three stale "3 Gbps" probe-clamp comments (the host
constant has been 10 Gbps since MAX_PROBE_KBPS moved).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 09:53:36 +02:00
enricobuehlerandClaude Fable 5 3e649d372e fix(capture/stall): the ETW witness testifies in QPC, so compose-silence stops convicting content
The stall classifier's present witness never worked: the consumer was opened
without PROCESS_TRACE_MODE_RAW_TIMESTAMP, so ProcessTrace converted every
event's TimeStamp to FILETIME regardless of the session's ClientContext=1 —
FILETIME ticks (100 ns since 1601) are ~4 orders of magnitude above QPC, so
every ts <= to_q comparison was false. summary() always printed etw=none,
window_counts() always returned presents=0/queue_adds=0 while present_history
was still true (satisfied by the unfiltered ring), and classify() therefore
convicted EVERY compose-silence hole as CONTENT-SILENCE; FRAME-GENERATION —
the class the program exists to catch — was unreachable. Two comments
asserted the wrong contract ("TimeStamp IS a QPC value"); both now state the
real one: ClientContext selects the session clock, RAW_TIMESTAMP is what
stops the FILETIME conversion on delivery.

Three adjacent defects fixed with it:

- summary() and window_counts() each took their own ring lock and their own
  (Instant::now(), qpc_now()) anchor, with OpenProcess syscalls between the
  two calls — the prose and the verdict could disagree about the same hole.
  Merged into window_report(): one snapshot, one anchor, both halves; the
  summary keeps its 300 ms lead-in, the counts keep the gap-only window, and
  the etw=/etw_presents=/etw_queue_adds= log fields are unchanged.

- present_history/queue_history meant "an event EVER sat in the ring" —
  satisfied by events arriving after the hole, or by a previous session's
  leftovers in the never-cleared static RING. Both flags now mean witness
  LIVENESS: at least one event inside a 5 s LOOKBACK ending at the hole's
  start, i.e. the witness demonstrably worked before the hole opened. The
  ring is cleared when a new session starts, so a dead session's events can
  never pose as the next one's history.

- window_counts() accepted only BltQueueAddEntry (1071) as queue history
  while summary() also took BltQueueCompleteIndirectPresent (1068); either
  proves the queue witness works, so the merged reader takes both.

The windowing math is factored into a pure count_window() (plain i64 tick
arithmetic) with unit tests, and the classify() matrix gains the live-witness
zero-presents case. Conviction thresholds are untouched.

Verified: scripts/xcheck.sh windows clippy (-D warnings, --all-targets) green
for pf-frame/pf-win-display/pf-capture/pf-vdisplay; native cargo check green.
The new Windows-gated tests type-check but need a Windows box to run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 09:53:36 +02:00
enricobuehlerandClaude Fable 5 0d004c4680 fix(host/handshake): the 4:4:4 gate names the encoder backend, not the capturer
capture_supports_444 was an encoder-backend fact (direct NVENC or PyroWave)
logged under a capture-ish name — a field report burned real time hunting a
capture problem because of it. The 'encode chroma' line now says
ingest_chain_supports_444, a requested-but-declined session logs WHICH gate
lost, and the console UI's Full chroma explainer names the real requirement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 09:53:29 +02:00
enricobuehlerandClaude Fable 5 8d7e273a96 feat(client/present): PUNKTFUNK_PRESENT_MODE gains explicit mailbox and fifo_relaxed arms, and the docs stop guessing
The env knob silently folded 'mailbox' and every typo into the default arm,
FIFO_RELAXED was not reachable at all, and clients/session/README.md claimed
the default is FIFO (it is MAILBOX with a FIFO fallback). An AMD-on-Windows
client always lands on FIFO because that driver offers no MAILBOX — now
documented at the picker and in the docs-site client table, next to the ABR
probe/ceiling knobs a field report went looking for and couldn't find.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 09:53:28 +02:00
enricobuehlerandClaude Fable 5 02a5bdb965 fix(gamepad): the virtual DualSense stops demanding a firmware update it cannot take
windows-drivers / probe-and-proto (push) Successful in 53s
apple / swift (push) Successful in 1m25s
ci / rust (push) Failing after 2m11s
windows-drivers / driver-build (push) Successful in 1m44s
ci / rust-arm64 (push) Successful in 1m38s
ci / web (push) Successful in 1m4s
android / android (push) Successful in 5m58s
ci / docs-site (push) Successful in 1m11s
apple / screenshots (push) Successful in 6m6s
arch / build-publish (push) Successful in 9m7s
deb / build-publish-client-arm64 (push) Successful in 1m3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 4s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
deb / build-publish (push) Successful in 4m57s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
deb / build-publish-host (push) Successful in 4m52s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / builders-arm64cross (push) Successful in 8s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 18s
docker / deploy-docs (push) Successful in 26s
windows-host / package (push) Successful in 11m30s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 14s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m35s
The emulated pad's firmware-info feature report (0x20) advertised update
version 0x0154 — a 2021-era number. PlayStation Accessories compares it
against Sony's latest (0x0630 as of 2026-08) and offers an Update that can
only end in "can't complete the update", since the virtual pad speaks no DFU;
libScePad titles (Stellar Blade) surface the same nag in-game. A real pad
plugged in directly reads up to date, which made the prompt look like
punktfunk corrupting the controller.

The old value was chosen to keep the kernel and SDL on the flag0
COMPATIBLE_VIBRATION convention, but parse_ds_output has since learned the
firmware-≥2.24 COMPATIBLE_VIBRATION2 flag as well, so nothing depends on
looking old anymore. Advertise 0x0999 — above anything Sony has shipped and
comfortably ahead of their ~yearly cadence — instead of chasing their exact
latest, which would resurrect the prompt on every Sony release. Writers that
read the version now use the v2 flag; both conventions land in the same
rumble plane. Bumped in both copies of the blob (host uhid + Windows driver);
the DualSense Edge shares them, and its own versioning (0x0217 latest) sits
below the new value too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:24:20 +02:00
enricobuehler ea469162f9 fix(docs): three doc comments start a markdown list they never meant to
apple / swift (push) Successful in 1m14s
ci / rust-arm64 (push) Successful in 3m31s
ci / web (push) Successful in 1m27s
ci / docs-site (push) Successful in 1m53s
apple / screenshots (push) Successful in 5m57s
ci / rust (push) Successful in 8m9s
android-screenshots / screenshots (push) Successful in 1m54s
android / android (push) Successful in 6m53s
deb / build-publish-host (push) Successful in 5m17s
decky / build-publish (push) Successful in 42s
deb / build-publish-client-arm64 (push) Successful in 1m33s
release / apple (push) Successful in 9m50s
arch / build-publish (push) Successful in 11m34s
deb / build-publish (push) Successful in 11m20s
sbom / sbom (push) Successful in 24s
linux-client-screenshots / screenshots (push) Successful in 5m18s
docker / builders-arm64cross (push) Successful in 7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m42s
flatpak / build-publish (push) Successful in 6m44s
docker / deploy-docs (push) Successful in 1m25s
web-screenshots / screenshots (push) Successful in 3m57s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m6s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m43s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m51s
windows-host / package (push) Successful in 11m36s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Successful in 21s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m38s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m1s
Windows clippy on the v0.23.0 tag: `doc_lazy_continuation` in
crates/pf-client-core/src/audio_wasapi.rs:37. The cause is one line break —
`+ wire cost.` begins a line, so the markdown parser reads `+` as a bullet
marker and the following line becomes a lazy continuation of that list item.

Fixed by reflowing so the `+` is mid-line rather than by taking clippy's
suggested indent: indenting would keep the accidental bullet in the rendered
docs, which is the actual defect. Same treatment for the two siblings a sweep
of every `///` line found, both invisible to the Linux gate for their own
reasons:

- gamestream/audio.rs:237 — `+ libopus;` at line start, on the
  cfg(not(linux/windows)) stub, so only a macOS clippy would ever see it.
- mgmt/tests.rs:1653 — `404.` at line start IS an ordered-list marker
  (CommonMark: 1-9 digits + `.`), and it is behind cfg(test), so only an
  --all-targets run sees it.

This is the [[Windows clippy sees what the Linux gate structurally cannot]]
shape again: audio_wasapi.rs is cfg(windows), so no amount of Linux CI would
have caught it.

Verified: a scanner over every .rs doc comment in the tree now reports zero
line-initial list markers with an unindented continuation; rustfmt clean (it
does not reflow doc comments, so these edits are stable).
2026-08-01 01:30:45 +02:00
enricobuehler 3c509d48c9 feat(core/abi): report_phase earns its version — C ABI 13 -> 14
`punktfunk_connection_report_phase` (fa822744, coherence tail 1d31e4c5) and the
`PUNKTFUNK_CLIENT_CAP_PHASE_LOCK` mirror const (7cf71dd2) grew the embeddable C
surface without moving ABI_VERSION. Every prior additive entry in that doc list
bumped it — v3's wake_on_lan, v5's next_rumble2, v8's clipboard block, v13's
send_pen — precisely so an embedder can ask punktfunk_abi_version() whether the
function it wants to link is there. Left at 13, the one number that answers that
question said "no report_phase" about a core that has one.

The header was already regenerated with both symbols, so it carried the new
surface under the old number; the regen here changes exactly the #define and its
doc block and nothing else, which also confirms the committed header was
otherwise current.

Additive and capability-gated: the host arms on report receipt, the wire grows
only PhaseReport (0x32) — a control message an old host never reads — and a
strict-prefix append on the 0xCF host-timing tail, so WIRE_VERSION stays 2. No
in-tree caller compares ABI_VERSION against a literal; mgmt/tests.rs asserts
against the symbol.

Verified: cargo build -p punktfunk-core regenerates include/punktfunk_core.h to
exactly this diff; punktfunk-core lib suite 134/134; rustfmt clean. The C ABI
harness cannot run on this Mac (`ld: library 'opus' not found`, the documented
pre-existing local linker gap) — CI's Linux leg is the gate for it.
2026-08-01 00:58:11 +02:00
enricobuehler 951bcec650 Merge remote-tracking branch 'origin/main' into audio/mic-latency-echo 2026-08-01 00:47:50 +02:00
enricobuehlerandClaude Fable 5 f3a39df7b3 test(host/audio): prove a reopen recovered with a live uplink, not one frame
`reopens_after_push_death` failed about one run in nine, and widening its
timeout did not help — the earlier commit blamed the backoff and was wrong.

The pump drops whatever queued while it was down: audio from before the
device came back is stale, so a fresh instance drains the channel right
after opening. The harness counts `opens` from the START of the open, so
the moment the test sees the counter move, the pump has not reached that
drain yet. The single frame it then sent landed inside the drain window and
was discarded exactly as designed, leaving the test waiting for audio that
was never going to arrive.

So the test now keeps feeding, which is what a real uplink does and what
the drain assumes. The sequence advances each time or the de-jitter reads
the repeats as duplicates and drops them for a second, correct reason.

Production behaviour is unchanged: this was the test asserting something
the pump never promised.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:41:07 +02:00
enricobuehlerandClaude Fable 5 4d45a96ff9 feat(encode/windows): sub-frame readback defaults on where the GPU supports it
Linux parity, validated by the .173 on-glass A/B (no regression; the win goes
to clients that actually consume slice-progressive parts): the caps probe now
reads NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK and seeds resolve_subframe with it
instead of a hard false, so PUNKTFUNK_NVENC_SUBFRAME becomes the tri-state
escape it already is on Linux, and the split×sub-frame arbitration hears the
real forced flag for its log severity.

The A/B also caught the default path opening every session with a WARN: the
submit-time idr_hint missed that NVENC emits the session-opening frame as an
IDR regardless of pic flags, so frame 1's early chunks went out unflagged and
the divergence check fired at every start. The hint now carries the Linux
twin's `opening` term.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:40:58 +02:00
enricobuehlerandClaude Opus 5 0985726415 fix(host,web): an empty update channel stops looking like a broken host
android / android (push) Canceled after 36s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 37s
ci / rust (push) Canceled after 37s
ci / rust-arm64 (push) Canceled after 36s
ci / web (push) Canceled after 34s
ci / docs-site (push) Canceled after 34s
deb / build-publish (push) Canceled after 19s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (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/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 33s
windows-host / winget-source (push) Canceled after 0s
flatpak / build-publish (push) Successful in 5m43s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 6m59s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m9s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 2m19s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
A channel nobody has published to answers `manifest.json` with a 404, and the
check reported that the same way it reports a dead registry or a bad signature:
"Last check failed: feed returned HTTP 404". Every host on the stable channel
shows it today, because the stable manifest only publishes when someone
dispatches `announce` for a release tag — so the first thing an operator sees
from the new Updates card is a red failure caused by nothing being wrong.

The shared checker now distinguishes the two. `feed::fetch_manifest_blocking`
returns a typed `FeedError` instead of a string, and only a 404 on the manifest
ITSELF becomes `NotPublished` — a 404 on the detached signature still fails
loudly, because that is the half-published pair the manifest-then-signature
upload order can produce, and it must stay fail-closed.

The host carries that through as `UpdateStatus.not_published`, mutually
exclusive with `last_error`. It is benign only while no manifest has ever been
seen for the channel: once a check has succeeded, the same 404 means the feed
LOST a document it used to serve, which stays an error. The console then shows a
plain sentence naming the channel instead of the failure banner, and "None
published yet" rather than "Not checked yet".

The Linux client makes the same distinction but deliberately NOT the same
choice: `--check-update` keeps exiting 1 and keeps `error` set, because its
consumer is a shell script and an empty channel is the absence of evidence that
this build is current — not a confirmation that it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:37:56 +02:00
enricobuehler 23f1debe69 Merge remote-tracking branch 'origin/main' into audio/mic-latency-echo 2026-08-01 00:21:24 +02:00
enricobuehlerandClaude Fable 5 10a1863cc7 test(host/audio): give the reopen tests longer than the backoff they wait for
`wait_until` allowed 200 × 10 ms — exactly the 2 s `backoff_start` the reopen
path spends before it can succeed. On a warm, idle machine it wins the race;
on a cold binary or a loaded box it does not, and `reopens_after_push_death`
failed 3 of ~5 cold runs while gating this branch. CI is always cold.

Six seconds costs nothing when the test passes and only delays a genuine
failure, so the budget now clears the backoff with room to spare.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:15:54 +02:00
enricobuehlerandClaude Opus 5 6a4ffcb15c fix(client): HDR stops leaking out of the stream and blowing out the console UI
ci / web (push) Successful in 1m3s
ci / rust-arm64 (push) Successful in 1m23s
ci / docs-site (push) Successful in 1m12s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m29s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 49s
deb / build-publish-client-arm64 (push) Successful in 1m57s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
deb / build-publish-host (push) Successful in 3m40s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m55s
deb / build-publish (push) Successful in 5m59s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m7s
docker / builders-arm64cross (push) Successful in 4s
docker / deploy-docs (push) Successful in 27s
android / android (push) Successful in 8m10s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m12s
arch / build-publish (push) Successful in 9m35s
flatpak / build-publish (push) Successful in 6m57s
ci / rust (push) Successful in 10m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 7m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m17s
apple / swift (push) Successful in 5m10s
apple / screenshots (push) Canceled after 19m1s
The gamepad/console UI looked right on launch, and wrong forever after the first HDR
session: connect to an HDR host, disconnect, and the UI came back overblown with wrong
colours.

The UI is not its own renderer. It is a `pf_presenter::overlay::Overlay` composited into
the SAME swapchain the stream used, and it draws plain sRGB with no HDR awareness at all —
so the swapchain's colorspace decides how its pixels are read. `present` switches SDR↔HDR10
from the FRAME's colour signalling, and a UI-only present is `FrameInput::Redraw`, which
carries none: the mode block is skipped entirely and nothing ever hands HDR10 back. The
UI's sRGB mid-tones were then emitted as PQ code points, i.e. near-peak nits.

`leave_hdr` drops back to SDR, called where the UI-only present already happens and gated
on the existing `browse_idle` — Browse mode with no live connector, i.e. the UI owns the
screen. That covers every route back to the UI rather than just the Ended/Failed arms, and
it is guarded internally so idle iterations stay free.

It also bails when minimized, which is load-bearing rather than an optimization:
`recreate_swapchain` keeps the old swapchain at a zero extent, but `set_hdr_mode` would by
then have rebuilt the CSC and overlay pipes against the SDR format — mismatched against
live HDR10 images. `present` early-returns on a zero extent above its HDR block, so this
was unreachable until a caller outside `present` existed.

Deliberately not applied to the `resize_scrim` arm of the same present: that scrim is a
mid-stream gap in a session that is still HDR, and flipping there would rebuild the
swapchain twice per resize.

Does not address the adjacent case: an overlay drawn DURING a live HDR session (the stats
HUD, the resize scrim) is blown out the same way. That needs the overlay to PQ-encode when
`hdr_active`; dropping to SDR is only correct here because the console UI shows exactly
when no stream is live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:08:38 +02:00
enricobuehlerandClaude Fable 5 a7143a6510 Merge origin/main into audio/mic-latency-echo
Main moved through the same surfaces while the audio work was in flight, so
three files needed hand-resolution:

- clients/windows/src/app/settings.rs — main gave Windows its speaker and
  microphone endpoint pickers, the gap this branch could only report. Both
  keep their rows: the pickers, then Echo cancellation, and the microphone
  description keeps the sentence naming the mute chord.
- crates/pf-console-ui/src/screens/settings.rs — both sides grew the couch
  row list. Main's seven new rows and Echo cancellation are all reachable
  in Gaming Mode; the count is 22 and the rationale comment names echo
  cancellation among the fields that would otherwise be unreachable there.
- crates/pf-presenter/src/run.rs — both sides added a stats test at the same
  line. Both are kept.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:04:34 +02:00
enricobuehlerandClaude Opus 5 43a631ea9c fix(clients): a host that re-keys stops locking the client out for good
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m21s
ci / rust-arm64 (push) Successful in 2m48s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
deb / build-publish-client-arm64 (push) Successful in 2m25s
docker / deploy-docs (push) Canceled after 25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m31s
deb / build-publish (push) Successful in 3m52s
apple / swift (push) Successful in 5m20s
arch / build-publish (push) Successful in 6m52s
docker / builders-arm64cross (push) Successful in 8s
android / android (push) Canceled after 7m17s
deb / build-publish-host (push) Successful in 5m45s
apple / screenshots (push) Canceled after 1m5s
ci / rust (push) Canceled after 7m24s
flatpak / build-publish (push) Canceled after 3m32s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m58s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 2m47s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 11s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Reinstall a host, wipe its ProgramData, or otherwise regenerate its identity,
and the desktop clients refused it forever: "Host identity rejected — wrong
fingerprint, or the host requires pairing", including immediately after a
successful re-pair. There was no way out of it from the UI — the host list
showed two cards for one address and forgetting the wrong one was a guess.

`KnownHosts::upsert` matches on the FINGERPRINT, which is what lets a host that
moved address keep its record and everything the user set on it. A host that
changed identity matched nothing, so pairing appended a SECOND record for an
address that already had one, and `find_by_addr` returned whichever came first
in the file — the dead one, every time.

Trust decisions (PIN ceremony, delegated approval, TOFU accept, headless pair —
all funnelled through `persist_host`, plus the Windows shell's two direct
upserts) now go through `upsert_trusted`, which retires any OTHER record for
that address. Retired means DELETED, not demoted: a record whose certificate
the host no longer holds cannot connect, so keeping it only reproduces the two-
cards-one-address confusion this fixes. What described the box rather than the
identity — its MAC, its OS chain, the bound profile, the pinned cards, when it
was last used — moves onto the record that survives, so a reinstall doesn't
quietly cost the user their setup. What described the dead identity does not:
`paired` and `clipboard_sync` are decisions about one specific certificate and
have to be made again for a new one, and the retired record's stable id stays
retired (a deep link written from it falls through to the `host=` recovery the
link grammar already specifies).

Only trust decisions may retire a record. The wake path's address re-key and
every learn-from-advert path stay on plain `upsert`: those are driven by
unauthenticated mDNS, and letting an advert delete a saved host by claiming its
address would trade this bug for a much worse one. A plain reconnect still
fails closed on a pin mismatch — nothing here changes what the pin is checked
against.

Stores that already hold the duplicate recover on the next connect, not at
load: which of two records is live isn't knowable at load time and guessing
wrong would throw away the good one. Instead `find_by_addr` stops being
positional — a real fingerprint beats a placeholder, and among real ones the
newest trust decision wins, since records are only ever appended by one. The
next successful pair then cleans the store up for good. Every lookup that picks
a pin or a per-host decision for a connect now goes through it (the session's
pin and clipboard read, the deep-link resolver, orchestrate's plan, both speed
tests, the CLI's --wake and --library, which had also been ignoring the port),
and an advert's learned MAC/OS lands on the record it identified rather than on
a stale namesake that merely came first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:00:49 +02:00
enricobuehlerandClaude Opus 5 6af067da2d fix(client): the bottom rows stop smearing — the decode pool is taller than the picture
ci / web (push) Successful in 1m49s
ci / rust-arm64 (push) Successful in 2m19s
ci / docs-site (push) Successful in 2m29s
ci / rust (push) Successful in 7m3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
deb / build-publish-client-arm64 (push) Successful in 2m28s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 21s
deb / build-publish (push) Successful in 4m17s
deb / build-publish-host (push) Successful in 4m51s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Successful in 36s
android / android (push) Successful in 8m24s
arch / build-publish (push) Successful in 8m28s
flatpak / build-publish (push) Successful in 6m8s
apple / swift (push) Successful in 5m54s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 13m5s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m10s
apple / screenshots (push) Canceled after 7m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 16m25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 15m57s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 5m3s
A user's 1080p stream repeated its last row of pixels over the final few rows, so the
image looked stretched at the bottom.

The Vulkan-Video CSC pass sampled the decoded planes with the fullscreen triangle's
normalized 0..1 UVs, but its render target is built at the CROPPED frame size. Those are
not the same rectangle: FFmpeg sizes the decode pool from `avctx->coded_*`, and H.264
codes `16 * mb_height` — so a 1080-row picture decodes into a 1088-row pool. Destination
row 1079 sampled source row ~1087.5, dragging the 8 alignment rows into view and squashing
the picture 0.7%. Encoders fill that padding by replicating the last picture line, which
is why it reads as a smeared bottom row rather than garbage.

Confirmed on glass (.173, RTX, H.264 1080p, vulkan-video):
  Vulkan Video first frame width=1920 height=1080 pool_w=1920 pool_h=1088

`VkVideoFrame` now carries the pool extent and `record_csc` takes a `uv_scale`, written to
the shader's `params.zw` — which the CSC shader already reserved for a use like this. The
chroma cositing offset is unchanged and stays correct: `textureSize` reports the pool
width, which is the space the scaled UV is already in.

Only the Vulkan-Video path passes a scale below 1.0. D3D11VA already clamps this in its
VideoProcessor blit (the same bug, seen as a green bar there because DXVA padding is
uninitialized rather than replicated); dmabuf imports its planes at the crop over the real
stride; PyroWave allocates its ring at exact stream dims. Apple and Android crop at the OS
layer. Every other call site passes [1.0, 1.0], so the change is inert there.

Also adds a one-time first-frame layout log mirroring the D3D11VA one, so the frame-vs-pool
gap is visible in the field instead of having to be re-derived from FFmpeg internals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:40:33 +02:00
enricobuehlerandClaude Fable 5 7ec3107b4a feat(client): the mic has an off switch, and echo cancellation has a switch too
Ctrl+Alt+Shift+V mutes and unmutes the microphone mid-stream — V for
voice, since M and S were taken. The uplink keeps running while muted:
`MicStreamer::spawn` takes a shared AtomicBool the capture callback reads
every quantum, and a muted callback drains whole frames and sends
nothing. Stopping the stream instead would have re-primed the device
buffers and, on Linux, re-run source selection on every unmute — a
second of glitch for a key people press mid-sentence. The sequence
counter deliberately does NOT advance while muted, so the host sees one
continuous sequence with a pause rather than a gap the size of the mute,
which its de-jitter would try to conceal frame by frame (its 600 ms
stale-flush covers the rest).

The mute lives on SessionHandle as a MicControl with two flags, not one:
`live` is raised by the pump only once the uplink is actually running, so
a session with the mic off in Settings — or whose capture device wouldn't
open — reports "nothing to mute", the chord says so in the log, and no
indicator appears. Per session, never persisted.

Muted state draws as a persistent "Microphone muted" badge in the stream's
top-right corner, off `FrameCtx::mic_muted` rather than the stats text: it
has to be there with the stats overlay Off, which is where most people
leave it. The Detailed mic line still reads throughput, so it simply falls
to zero — the badge is what answers "am I muted".

Echo cancellation stops being an env-only lever. `Settings::echo_cancel`
(default on, `#[serde(default)]` so every stored file loads with it on)
now gates the same hooks PUNKTFUNK_NO_AEC gated: the echo-cancelled
PipeWire source preference and WASAPI's Communications stream category.
The env var still wins, one-way — it can only turn AEC off, never back on
— and both `aec_enabled` helpers say so. The row ships in the GTK, WinUI
and console settings, under the microphone toggle and greyed out while it
is off, matching what Apple and Android shipped in wave 1.

SettingsOverlay grows `echo_cancel` as a first-class field — apply,
absorb, clear, is_empty — instead of riding the `extra` passthrough, where
`clear_override("echo_cancel")` answered false. The JSON key is the one
Apple and Android already write, so one catalog round-trips through all
three.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 23:07:30 +02:00
enricobuehlerandClaude Fable 5 9f72a3b6ad feat(host): HDR and 4:4:4 stop being mutually exclusive on Windows
windows-host / package (push) Failing after 22s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
ci / web (push) Successful in 1m7s
ci / rust-arm64 (push) Successful in 1m30s
ci / docs-site (push) Successful in 1m31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 19s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
deb / build-publish-client-arm64 (push) Successful in 2m21s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m6s
deb / build-publish (push) Successful in 4m43s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 33s
deb / build-publish-host (push) Successful in 5m4s
android / android (push) Successful in 6m40s
ci / rust (push) Successful in 7m10s
arch / build-publish (push) Successful in 8m11s
apple / swift (push) Successful in 5m8s
apple / screenshots (push) Canceled after 10m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 16m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 15m50s
An HDR display cost you full chroma: the IDD-push capturer's only 10-bit
output was P010, so a session that negotiated 4:4:4 on an HDR desktop was
converted to 4:2:0 at capture time — *after* the Welcome had already told
the client 4:4:4. The client believed it (nothing on the wire contradicts
a Welcome), and the new chroma tag in the stats overlay is what finally
made the discrepancy visible.

Everything except the source was already in place, which is why this is
small: the NVENC config layer has stamped `FREXT` + `chromaFormatIDC=3` +
`pixelBitDepthMinus8=2` — HEVC Main 4:4:4 10 — with a unit test since the
4:4:4 work landed, `PixelFormat::Rgb10a2` already maps to `ABGR10` and
already counts as a full-chroma input, and the desktop client learned the
10-bit 4:4:4 Vulkan pool format in 74863c96. The one missing piece was a
capture format that keeps 10 bits AND full chroma.

`HdrRgb10Converter` is that piece: one full-res pass from the FP16 scRGB
desktop to packed `R10G10B10A2` in BT.2020 PQ, reusing the P010 shader's
`scrgb_to_pq2020` verbatim so both HDR outputs share bit-identical colour
math — it simply stops before the RGB→YUV matrix, the studio-range
squeeze and the chroma decimation. NVENC then does the CSC to YUV 4:4:4
itself under FREXT, exactly as the SDR BGRA passthrough has always done
at 8 bits.

No swizzle is involved and that is worth stating, because it looks like
it should be: NVENC names packed formats from the MSB down, so its
`ABGR10` (A2B10G10R10) puts R in the low 10 bits — bit-identical to DXGI
`R10G10B10A2_UNORM`. It is the same relationship the proven SDR pair
relies on between DXGI `B8G8R8A8` and NVENC's `ARGB`.

The honesty gap closes as a consequence: `capturer_supports_444` no
longer has a depth it cannot serve, so the chroma resolved before the
Welcome is the chroma the wire carries. AV1 is deliberately untouched —
Range Extensions are HEVC-only and no consumer encoder does AV1 4:4:4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 23:06:44 +02:00
enricobuehler c846b165ae Merge branch 'audio/core-desktop-uplink' into audio/mic-latency-echo 2026-07-31 22:12:51 +02:00
enricobuehlerandClaude Fable 5 79856d2c50 fix(host/audio): a VoiceMeeter box can no longer loop the mic into the stream
wiring_plan could hand the mic Voicemeeter Input and the loopback
Voicemeeter Aux Input — two strips of the same internal mixer, i.e. a
digital feedback loop with no acoustic path to break it, because
leftover() never asked virtualish() and the exclusion list only knew
"cable" and the Steam Speakers. Now every VoiceMeeter or generically
"virtual" render is excluded from loopback, and the last-resort tier
refuses anything virtual: a box with only mixer endpoints gets
loopback=None, honest like the cable-only case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 22:11:17 +02:00
enricobuehlerandClaude Fable 5 efac33fd33 feat(host/audio): the virtual mic buffers for the client it has, not the worst one ever
The mic pump grows a real de-jitter (audio/mic_jitter.rs): a two-frame
reorder window in front of the decoder, libopus concealment on sequence
gaps (up to 5 frames — a lost datagram no longer drains the ring into a
silence + re-prime crackle), and an adaptive target depth measured from
inter-arrival jitter, clamped to 10–60 ms. Both backend rings now prime
at one consumer quantum + that target: the old bursty Mac client still
measures ~42 ms and lands where the fixed 48 ms prime protected it, a
modern 10 ms-cadence client settles at ~25–35 ms, and a 2048-frame
recorder on Linux stops buying 128 ms of latency from the 3-quanta
clamp. Depth stuck above target sheds near-silent frames a few ms per
100 ms — never speech, never a hard clear.

PUNKTFUNK_MIC_LEGACY_BUFFER=1 (documented) is the one-release escape
hatch back to the fixed constants, and a "mic uplink health" line every
30 s (depth/target, cadence, gaps, conceals, reorders, drops, re-primes)
finally says which side of the link a bad mic lives on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 22:11:08 +02:00
enricobuehlerandClaude Fable 5 2ce0bea830 feat(client): desktop phase-locked capture, a real display volume on Windows, and the console's missing rows
The cross-platform half of the gap sweep:

* Phase-locked capture reaches the DESKTOP clients (it shipped on
  Apple/Android only, though the desktop presenter has the best latch
  signal of all — true on-glass stamps via VK_KHR_present_wait). The
  presenter's 1 Hz fold publishes a latch grid (anchor = last on-glass
  instant; period = min positive present spacing, capped by the display
  mode's refresh so an arrival-paced sub-panel-rate stream can't claim a
  slower grid); the session pump folds every AU's arrival stamp against
  it with the SHARED `phase::circular_latch` statistic and sends the
  ~1 Hz PhaseReport (1 ms uncertainty — reference-client parity). The
  cap is advertised only when present timing is real
  (`VulkanDecodeDevice::present_timing` gates `SessionParams::phase_lock`),
  and the host's applied grid offset from the 0xCF tail is logged so an
  on-glass run can watch the controller engage.
* `Hello::display_hdr` stops being hardcoded `None`: Windows reads the
  panel's colour volume from DXGI (`IDXGIOutput6::GetDesc1`, the
  `--window-pos` output else the primary, advanced-color outputs only,
  gated on the HDR setting) so the host's virtual-display EDID matches
  the real glass. Linux keeps the EDID defaults — no portable
  Wayland/X11 query exists — and the comment now says exactly that.
* The console settings screen (the ONLY editor in Gaming Mode) learns
  the rows it was missing: render scale, full chroma 4:4:4, invert
  scroll, capture system shortcuts, fullscreen-on-stream, auto-wake and
  the game-library toggle.
* A spec-run session's device picks (GPU adapter, speaker, microphone)
  now come from the `--resolved-spec` instead of a raw Settings load —
  the last store read the spec path still owed (§5), and what would
  make those fields profileable.
* `session_args()` documents why the GTK/CLI spawners pass no
  `--window-pos` (Wayland exposes no global coordinates to read and SDL
  can't apply them).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 22:05:24 +02:00
enricobuehlerandClaude Fable 5 6286f91f89 feat(host/audio): the mic uplink keeps its sequence, and a backlog heals itself
The 0xCB datagrams always carried a seq + pts, and the ingest threw both
away one line after decoding them — the pump saw an anonymous byte pile.
Frames now travel as MicFrame {seq, pts_ns, opus} so the de-jitter that
follows can reorder, conceal and measure.

The shared queue also stops being a latency reservoir: cap 64 → 12, and
a pump that wakes to a backlog deeper than 6 frames jumps to the newest
4 instead of replaying the pile — before this, a scheduling stall could
park up to 1.28 s of standing mic delay that only a >600 ms silence gap
ever flushed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:50:29 +02:00
enricobuehlerandClaude Fable 5 55e01b4dcb feat(client): the stats overlay learns whether your mic frames arrive
NativeClient::mic_stats reaches the per-second stream window: sent and
dropped (queue-full + stale-shed) frame deltas join the tracing line and
the Stats event, and the Detailed OSD tier renders a mic line while the
uplink is live — a healthy 10 ms-frame mic reads ~100 f/s, and a drop
term means the client is shedding backlog, not the network eating audio.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:49:12 +02:00
enricobuehlerandClaude Fable 5 d17e942db0 feat(client/audio): the mic speaks 10 ms mono and asks the OS to cancel the echo
Uplink format on both desktops: 10 ms mono frames (was 20 ms stereo),
Opus Voip at 48 kbps with in-band FEC against 10 % assumed loss — half
the frame-fill latency, half the samples, and a lost datagram's audio
now rides in its successor. One datagram per frame, unchanged wire.

Linux: the capture stream finally asks for its own quantum
(NODE_LATENCY 480/48000) instead of inheriting the graph's 1024-2048
sample bursts, and when the user picked no mic it prefers an existing
echo-cancel source over the default (PUNKTFUNK_NO_AEC=1 opts out;
loading module-echo-cancel ourselves needs a load_module the pipewire
crate doesn't expose yet). Windows: the capture client declares
AudioCategory_Communications before Initialize so an endpoint's
communications APO (the system AEC) can engage; capture stays stereo
via autoconvert — the proven path — and downmixes to mono in code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:49:05 +02:00
enricobuehlerandClaude Fable 5 15392bd707 fix(core/client): the mic queue stops hoarding a second of your voice
MIC_QUEUE claimed 64 was ~320 ms of 5 ms frames; the frames were 20 ms, so
it really allowed 1.28 s — and since a full tokio mpsc can only refuse the
FRESH frame, one worker stall turned the whole backlog into permanent
standing mic latency. The queue shrinks to 12 and the pump's mic task now
sheds oldest-first past a ~60 ms backlog, so a stall costs a short dropout
and heals itself. New per-stage counters (sent / dropped-full /
dropped-stale) surface through NativeClient::mic_stats for the stats HUDs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:46:02 +02:00
enricobuehlerandClaude Fable 5 698925a036 fix(clients): settings saves stop reverting each other, Windows gets audio pickers, and a game link launches its game
The rest of the client-gaps handoff, plus what a parity sweep surfaced:

* Every whole-file settings save rebases on the file first — the GTK
  dialog close and its two speed-test apply arms, the Windows
  per-control commit, the console screen. The file has five writers and
  no merge (profiles.rs documents the debt); saving a shell-lifetime
  snapshot silently reverted whatever another writer stored meanwhile,
  most visibly the spawner-persisted match-window size (item 4).
* Windows honors the Speaker/Microphone picks: `audio_wasapi` grows
  endpoint enumeration (on its own MTA thread — UI threads are STA) and
  resolves PUNKTFUNK_AUDIO_SINK/SOURCE as endpoint ids, falling back to
  the default when the picked device is gone; the settings page gets
  the two rows (defaults scope, "(not detected)" like the GPU row).
  `speaker_device`/`mic_device` stop being Linux-only fields (item 5).
* A punktfunk:// link with `launch=` opened a plain desktop session on
  Windows — the id was parsed, validated, planned, and dropped at the
  last hop. It now rides `initiate_launch` (plus a waking variant for
  the dial-first path). Linux always forwarded it.
* The console UI offered "VAAPI" on Windows — a dead option there that
  also hid d3d11va, the actual Windows hardware path. The decoder list
  is per-OS now.
* The Windows gamepad picker learns "Steam Deck" (the GTK picker had
  it; the host-side pad has always existed).
* Doc fixes: gpu.rs named a nonexistent env var (PUNKTFUNK_ADAPTER →
  PUNKTFUNK_VK_ADAPTER), and session/Cargo.toml claimed video decode is
  Linux-only while explaining what the ARM64 leg drops.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:21:26 +02:00
enricobuehlerandClaude Fable 5 74863c96b3 feat(client): 4:4:4 can stay on hardware, and the overlay says what you actually got
Two halves of the same honesty problem (client-gaps handoff items 1+2,
done together as the handoff asked).

The presenter learns full chroma: `vkframe_plane_views` accepts the
2-plane 4:4:4 pool formats (8-bit, and the 10-bit 3PACK16 sibling) —
what NVIDIA's Vulkan Video reports for HEVC RExt decode — with the
accepted set extracted into `vkframe_plane_formats` and pinned by a
decision-table test. The CSC shader needed nothing: its 4:2:0 siting
correction already self-disables when the plane widths match. The VAAPI
leg gets the same treatment (NV24 in `drm_fourcc_for` and the dmabuf
import, full-size chroma plane), and the Vulkan decoder's sw-format
gate admits NV24/P410. 3-plane 4:4:4 stays rejected — it needs a third
CSC binding — and demotes cleanly like every other unsupported format.

Design call (the handoff's fork, argued here as requested): (B)+(C),
not (A). No capability probe gates VIDEO_CAP_444 — software decode is
the guaranteed display floor on both OSes (swscale → RGBA), the decoder
ladder demotes on its own, and a probe-gated bit would turn the switch
inert on exactly the boxes that rely on the fallback. What (A) wanted
from a prediction, the overlay now delivers as ground truth:

The Detailed tier prints the encoder's target next to the measured
rate — `19.4 Mb/s · target 20 Mb/s (auto)` — and the resolved chroma,
`4:4:4→4:2:0` when the host declined the ask (mirroring `HDR→SDR`).
The target is live: `NativeClient::current_bitrate_kbps()` mirrors
every BitrateChanged ack, so an Automatic session's ABR re-targets are
visible as they move. This is the figure whose absence let the
settings-drop bug (9c5af8d7) ship four releases — measured goodput
alone cannot distinguish "the encoder is capped at 20" from "my
200 Mb/s grant met a cheap scene".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:21:10 +02:00
enricobuehlerandClaude Fable 5 5926306a4c feat(windows): the web console becomes a supervised child of the host service
windows-host / package (push) Failing after 22s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
ci / docs-site (push) Successful in 1m47s
ci / web (push) Successful in 1m53s
ci / rust-arm64 (push) Successful in 1m58s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 58s
apple / swift (push) Successful in 4m45s
deb / build-publish-client-arm64 (push) Successful in 3m28s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m27s
deb / build-publish (push) Successful in 5m26s
docker / builders-arm64cross (push) Successful in 6s
android / android (push) Successful in 6m22s
docker / deploy-docs (push) Successful in 38s
ci / rust (push) Successful in 7m10s
deb / build-publish-host (push) Successful in 5m27s
arch / build-publish (push) Successful in 8m42s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m58s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m16s
apple / screenshots (push) Successful in 20m19s
Three silent console outages in one week (0x1 / 0xFFFFFFFF / 0x41306),
each a different proximate cause of the same structural defect: the
console's lifecycle was owned by Task Scheduler — one best-effort start
per boot/logon/install, no retry on a plain non-zero exit, no watchdog —
while the product already shipped a real supervisor.

The service now supervises the console as a second child slot: plain
session-0 spawn (suspended → own no-breakaway kill-on-close job →
resume), started only once the host has written mgmt-token + cert.pem +
key.pem (the cert race dies by construction), secrets read from their
files at every respawn, bun's stdout finally captured in logs\web.log,
doubling backoff 0.5s→60s that never gives up. Session switches never
touch it; a service stop takes it down via the job.

The PunktfunkWeb task is retired: web setup slims to password + legacy
task delete + firewall, the 127-line web-run.cmd batch supervisor is
deleted, an [InstallDelete] entry reaps the stale copy, and service
install now sets SCM crash-recovery actions (restart 1s/5s/60s) since
the console rides on the service process. StopBunRuntimes stays for the
scripting runner + the one migrating upgrade.

Design: punktfunk-planning design/windows-web-console-lifecycle.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 20:16:19 +02:00
enricobuehlerandClaude Opus 5 9c5af8d7e1 fix(clients): the settings you chose reach the stream again, and four more that never did
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 2m50s
ci / rust-arm64 (push) Successful in 2m55s
deb / build-publish-host (push) Successful in 4m16s
deb / build-publish-client-arm64 (push) Successful in 4m56s
apple / swift (push) Successful in 4m41s
flatpak / build-publish (push) Successful in 6m32s
deb / build-publish (push) Successful in 3m44s
android / android (push) Successful in 8m2s
ci / rust (push) Successful in 11m18s
arch / build-publish (push) Successful in 10m24s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m42s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m10s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m24s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m1s
apple / screenshots (push) Successful in 20m36s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m40s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m9s
Since spec mode (`f32c3aaa`, shipped in 0.22.0) the GTK shell has handed the session
a `--resolved-spec` built from `Settings::default()`. A session running from a spec
performs ZERO store reads by design, so every stream since has run at the defaults:
`bitrate_kbps: 0` reaches the host, which reads it as its 20 Mbps fallback — the
field report this starts from, "my bitrate seems to be stuck at 20 MBPS" on 0.22.3 —
and with it the resolution, refresh, render scale, codec, decoder, HDR, 4:4:4, audio
channels, mic, touch/mouse mode, invert scroll, stats tier, match-window and gamepad
type. Profiles never applied at all: the spec's `profile` was None, and spec mode
ignores `--profile`. The tell in the wild is that the GPU and audio-device pickers
kept working — the session reads those three off disk into env vars before it reads
the spec.

The plan carried a comment explaining that `settings` "carries only what the argv
needs (the fullscreen flag)". That was true when it was written and stopped being
true one commit later, in another file. `ConnectPlan::for_target` answers that shape
of bug: a front-end holding its own request type resolves through the same helper the
session's compat path uses, so there is no hand-built `Settings` left to go stale.
The GTK shell's `fullscreen_on_stream` parameter goes with it — that is a tier-P
field, and a shell-read global was beating a profile that set it. The Windows shell
was never affected (it spawns with no spec) but had the same fullscreen-vs-profile
bug, so it moves onto the resolver too.

The audit that followed found four more settings stored, rendered in two UIs, and
read by nothing:

- `enable_444` never became `VIDEO_CAP_444`. "Full chroma (4:4:4)" is announced in
  the 0.22.0 notes for Linux and Windows and did nothing on either; only Apple
  advertised the bit. The host already gates it on its own policy, its capturer,
  HEVC and a real GPU probe, and answers the resolved chroma in the Welcome before
  we build a decoder — the client only has to ask. There is no desktop decode probe
  (Apple has `Stage444Probe`), but the presenter bails cleanly on a plane format it
  doesn't know and demotes to software, so the downside is a slow decode, not a
  broken one.
- `session_params` picked the HEIGHT fallback off `settings.width == 0`.
- `cli::exec_session` dropped `--profile` from its forward list, so
  `punktfunk --connect … --profile Work` from a script or a Decky wrapper streamed
  with the host's binding instead.
- `ResolvedSpec::write_temp` named the spec by the SPAWNER's pid alone, so a
  cancelled connect and the retry behind it shared one path — and the first child's
  exit deleted the file the second was still starting up to read.

The regression test asserts the plan carries the profile-overlaid bitrate and that
the spec equals it. On the old code both are 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:32:51 +02:00
enricobuehlerandClaude Opus 5 3594bc029e fix(host): a host with no host.env streams a screen, not a test pattern
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 37s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 40s
ci / rust-arm64 (push) Canceled after 37s
ci / web (push) Canceled after 38s
ci / docs-site (push) Canceled after 36s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 46s
deb / build-publish-client-arm64 (push) Canceled after 41s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 25s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m19s
docker / builders-arm64cross (push) Successful in 16s
docker / deploy-docs (push) Successful in 38s
windows-host / package (push) Successful in 17m58s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 34s
`PUNKTFUNK_VIDEO_SOURCE` had no default, and unset fell through to the synthetic
test pattern. That was invisible while the systemd unit made host.env mandatory —
you could not start the host without the file, and every copy of the file sets
`virtual`. Now that the unit treats host.env as optional (020306b5), unset is
reachable, and a packaged Linux install with no host.env would serve a test
pattern to Moonlight clients.

Default it to `virtual`, the flagship per-client virtual output — which is what
every setter in the tree already says: packaging/bazzite/host.env,
packaging/kde/host.env, both host.env.example files, the host.env the Windows
installer generates, and scripts/bench/gpu-stream.sh. Nothing sets `synthetic`
and nothing relied on unset. `synthetic` stays reachable by naming it, since any
unrecognised value lands in that arm.

Only one of the three readers changes behaviour: stream.rs's
`== Some("virtual")` now matches when the variable is absent. gamestream/mod.rs
already folded None and Some("virtual") into the same `_` arm for the HDR
capability probe, and rtsp.rs only tests for `portal`.

The native punktfunk/1 plane never read this — it always builds a virtual
display — so this only ever affected GameStream/Moonlight sessions.

host.env.example's header no longer claims this key is the one non-optional
setting; the file is now overrides only, and the host runs without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:31:36 +02:00
enricobuehlerandClaude Opus 5 fd3c54bd43 feat(client): "Capture system shortcuts" finally decides where Alt+Tab goes
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 2m22s
ci / rust-arm64 (push) Successful in 3m34s
android / android (push) Canceled after 4m8s
apple / swift (push) Canceled after 4m13s
apple / screenshots (push) Canceled after 0s
decky / build-publish (push) Successful in 42s
arch / build-publish (push) Canceled after 4m25s
ci / rust (push) Canceled after 4m30s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
deb / build-publish (push) Canceled after 4m35s
deb / build-publish-host (push) Canceled after 3m29s
deb / build-publish-client-arm64 (push) Canceled after 2m15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 6s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 4s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 10s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 30s
flatpak / build-publish (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 6m14s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
The toggle has been stored, profileable and rendered in two settings UIs since
profiles landed, and nothing read it. Windows grabbed the keyboard whenever input
was captured, setting or no setting; Linux never grabbed at all, because the grab
sat behind `#[cfg(windows)]` with a comment deferring the compositor story to "the
shells" — which never picked it up.

`Settings.inhibit_shortcuts` now reaches the presenter and gates the grab, on both
platforms. SDL3 already maps `SDL_SetWindowKeyboardGrab` onto
`zwp_keyboard_shortcuts_inhibit_manager_v1` on Wayland and `XGrabKeyboard` (plus
`_XWAYLAND_MAY_GRAB_KEYBOARD`) on X11, so dropping the cfg is the Linux fix.

Capture state still gates it, so releasing input hands the chords straight back,
and the desktop mouse model never grabs. A compositor with no shortcuts-inhibit
global says so once instead of failing silently — at debug under gamescope, which
has no shortcuts to inhibit in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:27:33 +02:00
enricobuehlerandClaude Opus 5 020306b5ac fix(packaging,host): a fresh Linux install can start, and the comments stop lying
ci / web (push) Successful in 1m3s
ci / docs-site (push) Successful in 2m11s
ci / rust-arm64 (push) Successful in 2m41s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 24s
deb / build-publish-client-arm64 (push) Successful in 2m28s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
apple / swift (push) Successful in 4m48s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m36s
arch / build-publish (push) Successful in 7m20s
ci / rust (push) Successful in 7m21s
deb / build-publish-host (push) Successful in 7m4s
docker / builders-arm64cross (push) Successful in 8s
deb / build-publish (push) Successful in 5m31s
docker / deploy-docs (push) Successful in 34s
android / android (push) Successful in 8m17s
windows-host / package (push) Successful in 10m43s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m40s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 17m31s
apple / screenshots (push) Successful in 20m57s
Fallout from the documentation sweep: verifying doc claims against the code
turned up defects in the code and the shipped templates. Mostly comments that
describe behaviour we no longer have — which is how the docs went wrong in the
first place, since someone reads the comment and writes the page.

The one that mattered: a fresh deb/RPM/Arch install could not start the host at
all. The unit's `EnvironmentFile=` had no `-`, making host.env mandatory, and no
package creates it — all three ship only the templates under /usr/share and the
postinst merely prints the copy command. So `systemctl --user enable --now
punktfunk-host` died on "Failed to load environment files". Every field in
HostConfig::from_env resolves through unwrap_or/filter/None, so absent means all
defaults, exactly like a hand-run `serve`; the Nix module already wrote it as
`-${environmentFile}`. The Deck installer's own generated unit gets the same
prefix for the case where an operator later removes the file.

Shipped templates: PUNKTFUNK_SECURE_DDA is read by nothing (DDA/WGC are gone;
IDD-push is the sole Windows capture path and the secure desktop is
unconditional), so it stops being written into a fresh host.env;
PUNKTFUNK_INPUT_BACKEND offered a `uinput` value that does not exist and omitted
`kwin`, which is what a KDE session actually resolves to; PUNKTFUNK_RENDER_ADAPTER
no longer claims to pick a "Desktop-Duplication" GPU.

Comments corrected rather than deleted, since each explains a real why:
PUNKTFUNK_10BIT is default-on with explicit-off grammar, not an operator opt-in;
GNOME reaches EIS through Mutter's direct RemoteDesktop API, so it needs no portal
approval and is headless-capable; and the host does not run in session 0 — the
service is the session-0 supervisor and the host runs as SYSTEM in the interactive
console session, which is why game_term has to bind the input desktop at all.

packaging/bazzite/update-punktfunk.sh is now installed to /usr/share/punktfunk/,
so the command the docs promised exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 18:46:38 +02:00
enricobuehlerandClaude Fable 5 2a6bf3c421 fix(vdisplay/gamescope): the takeover stops a live session's DM on every flavor — the mask never stopped SDDM's relogin
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 2m12s
ci / rust-arm64 (push) Successful in 2m26s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 49s
apple / swift (push) Successful in 4m49s
ci / rust (push) Failing after 4m7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
android / android (push) Successful in 5m58s
deb / build-publish-client-arm64 (push) Successful in 3m31s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 32s
deb / build-publish-host (push) Successful in 4m14s
deb / build-publish (push) Successful in 5m44s
windows-host / package (push) Successful in 11m1s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 21s
arch / build-publish (push) Successful in 14m59s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m9s
apple / screenshots (push) Successful in 21m19s
The managed takeover masked gamescope-session-plus@*.service and trusted
the mask to hold SDDM's Relogin=true loop at bay. On images whose sddm
session helper execs the session script directly (/etc/sddm/wayland-session
gamescope-session-plus steam — f43 bazzite-deck), the relogin never touches
the unit: SDDM relogged in ~3x/s for the whole stream, each attempt a full
bash --login session start failing against the managed instance. Live on
the .41 VM (2026-07-31): 328 forks/s, load 6+, 1481 logind sessions in
8 minutes, the journal flooded past its own rotation — and a 5070 Ti that
"could not hold 240fps".

A DM that drove a LIVE gaming session is now stopped for the stream's
duration on every flavor (the fragile-flavor machinery — lingering guard,
pkexec dm-helper, restore via reset-failed+restart, crash-persisted state —
already existed and is reused as-is; a pure dm_plan() derives the decision
and is unit-tested). The flavors now differ only in the degraded mode when
the stop is impossible: SDDM falls back to mask-only (the storm tax, but
Steam stays protected — never attach), fragile DMs still fail the takeover
into the attach degrade. The mask stays as belt-and-braces for the window
before the stop and for images that do route the relogin through the unit.

Verified: scripts/xcheck.sh linux clippy (-D warnings, --all-targets)
clean; pf-vdisplay suite 125/125 on Ubuntu 26.04.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:59:00 +02:00
enricobuehlerandClaude Fable 5 7cf71dd218 feat(clients): the Apple deadline presenter reports its latch phase
Phase-locked capture had no client half on Apple — the host's v3 controller
(grid-locked submits, coherence-gated engage) shipped with only the Android
reporter feeding it. Now the stage-4 link thread flushes the same v2
circular arrival-phase statistic at ~1 Hz:

- PhaseReporter (Stage2Pipeline): the decode callback deposits per-AU
  reassembly-completion stamps, the CAMetalDisplayLink update deposits the
  latch grid (period = window-min of update spacing), and the flush ports
  punktfunk_core::phase::circular_latch verbatim — a period-smeared Wi-Fi
  link reads coherence ≈ 0 and the host correctly never engages; a wired
  link opens the gate. Binds/unbinds per session like DecodeReport.
- PunktfunkConnection.reportPhase wraps the existing ABI entry point.
- Hello honesty: iOS/tvOS advertise CLIENT_CAP_PHASE_LOCK (macOS stays
  without — the stage-2 arrival presenter has no latch grid), Android now
  sets the bit its reporter already earned, and the ABI grows the
  PUNKTFUNK_CLIENT_CAP_PHASE_LOCK mirror const (header regenerated).
  Advisory in v1: the host arms on report receipt.
- Stage-3 doc comment no longer calls itself the tvOS default (stage-4 took
  that over in the 2026-07 rebuild).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:41:46 +02:00
enricobuehlerandClaude Fable 5 86d4b36bfa chore(core): the Apple recv path documents its unsafe blocks
clippy -D warnings only lints this cfg(target_vendor = "apple") file on a
Mac, which CI never does — the three recv/recvmsg_x blocks predate the
undocumented_unsafe_blocks sweep and kept every local Apple-side clippy run
red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:41:15 +02:00
enricobuehlerandClaude Fable 5 49bc93cd20 feat(encode/windows): the sync NVENC session reads slices out mid-encode
ci / web (push) Successful in 1m2s
apple / swift (push) Successful in 1m19s
ci / rust-arm64 (push) Successful in 1m45s
ci / docs-site (push) Successful in 2m6s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 16s
ci / rust (push) Canceled after 2m36s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 10s
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/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
deb / build-publish-client-arm64 (push) Successful in 2m8s
android / android (push) Successful in 5m37s
deb / build-publish-host (push) Successful in 4m40s
deb / build-publish (push) Successful in 5m56s
apple / screenshots (push) Canceled after 6m40s
arch / build-publish (push) Canceled after 8m14s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 6m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m11s
windows-host / package (push) Canceled after 8m58s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
The Windows direct-NVENC session gets the Linux backend's sub-frame
chunked poll: a doNotWait sampling lock cuts each completed slice out of
the bitstream while the frame is still encoding, so the send loop's
streamed slice wire starts shipping an AU's front before its tail
exists. The default retrieve mode on Windows is synchronous, which is
exactly the mode the sampler requires - the async opt-in keeps its
whole-AU path and the chunked latch drops away when it is active.

The session's slice count now honors the negotiation instead of pinning
one: the direct-NVENC default of 4 clamped by the client's ceiling, so a
single-slice client (Amlogic-class decoders that never advertised
multi-slice) keeps today's wire shape and a multi-slice client gets real
boundaries to cut at. The pending queue carries a submit-time IDR hint
because an AU's early chunks ship before the driver reports the picture
type; sub-frame write itself stays env-gated (PUNKTFUNK_NVENC_SUBFRAME=1)
until the Windows on-glass A/B validates it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:34:31 +02:00
enricobuehlerandClaude Opus 5 5520167958 feat(clients): the OS marks tell gaming distros apart, and Windows looks current
Three things were wrong with the host-card OS icons.

The Windows mark was Font Awesome 5's, which is still the Windows 8/10 flag
with the perspective skew — dated next to the flat four-pane mark Microsoft
has shipped since Windows 11. No icon set has the current one (Simple Icons
carries no windows/microsoft slug at all), so it is drawn here: four equal
squares at the authentic 11.377 + 1.246 proportion. The Decky plugin was
pulling FaWindows straight from react-icons, so it now inlines the masters
like the web console does, or it would have kept the old flag regardless.

Bazzite, CachyOS and Nobara collapsed onto their family's mark. The host
already advertises the full chain, so this is purely missing art: all three
now ship a leaf mark, because "a Bazzite box" and "a Fedora box" are
different machines to the person reading the card. CachyOS and Nobara come
from Simple Icons; Bazzite has no icon anywhere, so its "b" is lifted out of
the project's own badge (Apache-2.0, attributed).

On Android every non-square mark was stretched. A VectorPainter maps the
viewport onto the ImageVector's default size with independent x and y
scales, so declaring a 448x512 Tux as 24x24 dp squashed it — silently, no
crash, no warning. The longest viewport edge now sets the 24 dp box and the
other follows the ratio, which is what Icon()'s ContentScale.Fit expects.
A unit test pins the invariant; every other client was already correct.

Also: scripts/gen-os-icons.sh replaces the undocumented hand-run pipeline
that turns a master into the GTK symbolic SVG, the Windows PNG and the Apple
template PDF. It reproduces the committed artifacts byte-identically.

Verified: web and Decky typecheck, Decky bundles, Android compiles and its
tests pass, PunktfunkKit builds, osinfo's tests pass. Not verified on glass,
and the GTK/Windows client crates do not build on macOS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:17:02 +02:00
enricobuehlerandClaude Fable 5 f4f83202cb feat(core/client): an AU's prefix reaches the decoder while its tail is on the wire
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
ci / web (push) Successful in 1m13s
apple / swift (push) Successful in 1m21s
ci / rust (push) Failing after 2m14s
android / android (push) Canceled after 2m26s
apple / screenshots (push) Canceled after 58s
arch / build-publish (push) Canceled after 2m42s
ci / docs-site (push) Successful in 2m36s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
ci / rust-arm64 (push) Canceled after 2m47s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
deb / build-publish (push) Canceled after 2m50s
deb / build-publish-host (push) Canceled after 1m38s
deb / build-publish-client-arm64 (push) Canceled after 41s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 10s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 25s
release / apple (push) Canceled after 0s
windows-host / package (push) Canceled after 3m36s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 7s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Delivery used to be all-or-nothing: the decoder saw byte 0 only after the
last packet of the AU landed, so the whole transmit time sat in front of
decode. With the slice-streamed wire (previous commit) blocks now arrive
addressable, and a client can opt in (connect's new frame_parts) to
receive each AU's newly-contiguous prefix as Frame::part pieces - offset
tiling, first/last marked, the completing push carrying only the suffix.
A PARTIAL_FRAME-capable decoder then chews slices concurrently with the
remaining network transfer.

The reassembler walks a per-frame cursor over successfully-completed
blocks (failed FEC reconstructs don't advance it), coalesces blocks that
finished out of order into one part, keeps probe filler whole, and stops
short of the final block so the zero-padded tail still trims at
completion. Whole-frame consumers see byte-identical behavior - parts
never flow without the opt-in, and never on PyroWave (its newest-wins
draining assumes whole AUs).

Per-AU accounting keeps its units: OWD/ABR feeds, the inter-arrival
series and the clock-based jump-to-live detector only count completing
deliveries, and FrameChannel::depth() counts AUs so a part-rich queue
can't trip jump-to-live at a fraction of the real backlog. The consumer
contract (gap or orphan part = AU lost: abandon, flush, resync on the
next first) is documented on FramePart; the C ABI keeps parts off until
PunktfunkFrame can express them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:14:46 +02:00