Commit Graph
92 Commits
Author SHA1 Message Date
enricobuehler 31bb3ca7f7 Merge pull request 'The console answers a mouse and a finger, and host cards get a menu' (#90) from worktree-console-tabs-pointer into main
ci / bun-nix (push) Successful in 24s
ci / docs-site (push) Successful in 1m17s
apple / swift (push) Successful in 1m32s
ci / web (push) Successful in 1m36s
deb / build-publish-client-arm64 (push) Successful in 2m42s
ci / rust-arm64 (push) Successful in 3m18s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m34s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m38s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 17s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m15s
docker / builders-arm64cross (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m34s
deb / build-publish (push) Successful in 4m51s
docker / deploy-docs (push) Successful in 34s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m51s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m2s
apple / screenshots (push) Successful in 5m53s
deb / build-publish-host (push) Successful in 7m16s
arch / build-publish (push) Successful in 7m59s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m20s
android / android (push) Failing after 11m58s
flatpak / build-publish (push) Failing after 12m26s
ci / rust (push) Successful in 17m57s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m14s
Reviewed-on: #90
2026-08-07 11:38:41 +00:00
enricobuehler 413a3e3e76 feat(client): say which GPU can do Vulkan Video, and why not when it can't
ci / bun-nix (pull_request) Successful in 29s
ci / rust-arm64 (pull_request) Successful in 1m44s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m5s
apple / swift (pull_request) Successful in 1m35s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m21s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m1s
ci / rust (pull_request) Successful in 4m27s
ci / docs-site (pull_request) Failing after 16m24s
ci / web (pull_request) Failing after 16m25s
Field report from an Intel Arc + NVIDIA laptop: pinning the Vulkan rung on the Arc
iGPU silently produced D3D11VA, and there was no way to tell whether the build had
tried at all. That ambiguity was ours, in three places.

The "unavailable" log printed three of the FIVE conjuncts that gate Vulkan Video.
A device with 1.3, the features and a decode queue family — but no codec extension
— logged dev_is_13=true features_ok=true decode_family=true next to the word
"unavailable" and named nothing actionable. It now prints all five, plus which
base extensions are missing, which codec extensions are present, the decode
family's own advertised codec operations, and the device name and vendor. It also
no longer says "VAAPI/software" on Windows, where the rung below is D3D11VA.

The native-vulkan PIN refusal logged `video_decode` alone. On a device that
decodes something but not THIS codec, that reads as a contradiction: refused, yet
video_decode=true. It now carries the caps mask and the codec bit that was wanted,
so "your GPU can't" is distinguishable from "we asked for the wrong thing" — only
the second is our bug.

And `--probe-decode` is new: per-adapter Vulkan Video capability with no session,
no surface and no logical device. For each GPU it answers usable yes/no, the
driver's own decode ops, the extensions, and — when the answer is no — which
conjunct failed, in words. Separate from --list-adapters, which the desktop shells
parse line-by-line for their GPU picker and which therefore keeps printing bare
names.

The listing is ordered like pick_device (discrete first) and marks entry 0 as the
default presenter, because that ordering is very likely the reporter's actual
answer: pick_device ranks DISCRETE_GPU above INTEGRATED_GPU, Vulkan Video decodes
on the PRESENTER's device by design (that is what makes it zero-copy), and
PUNKTFUNK_DECODER does not move the presenter. So on a hybrid laptop, pinning the
decoder while the dGPU presents probes the wrong GPU entirely —
PUNKTFUNK_VK_DEVICE=<index> is the knob that moves it, and the index printed is
that value.

To keep the probe honest, VIDEO_BASE and VIDEO_CODECS moved to module scope and
the five-way AND became video_decode_gate(), called by both the probe and device
creation. A probe holding its own copy of the rule is one that eventually reports
a capability the session then refuses — which reads to everyone as a decoder bug
rather than a probe bug.

Gates: fmt clean; clippy -D warnings over punktfunk-client-session and
pf-presenter. The Linux container was unavailable (the host's disk filled and took
the docker daemon with it), so this ran on the macOS host target only — the
container leg is owed, and CI covers it on the PR.
2026-08-07 12:42:33 +02:00
enricobuehler f06b3d9d04 feat(console): the console answers a mouse and a finger, and host cards get a menu
Two gaps, both found on the shared Linux/Windows console UI.

**The settings tabs only moved for a gamepad.** They were bound to the shoulder
buttons and to PgUp/PgDn, and the legend spells PgUp/PgDn out only when NO pad is
attached — so with a controller plugged in a keyboard user had nothing to find,
and a mouse or a touchscreen could not change section at all.

The root cause was wider than the strip: `SkiaOverlay::handle_event` matched only
`KeyDown` and `TextInput`, so every mouse button, wheel and touch contact fell
past the console into the run loop, which routes pointer input exclusively at
`stream.capture` — `None` while you are browsing. Nothing in the console had ever
been clickable. Making just the pills answer would not have helped either: the
settings screen is opened with X from home, so a mouse could not reach it.

So the console gets a real pointer path:

- `Overlay::handle_pointer` carries mouse/touch in SWAPCHAIN PIXELS. The run loop
  converts (it owns the window, hence the display scale, and mouse coordinates are
  logical while fingers are normalised); the console then hit-tests the very rects
  it drew last frame. Only DIRECT touch devices are offered — an indirect trackpad
  already drives the mouse.
- Widgets act on the PRESS, not the release. The list and both carousels scroll the
  focused item toward the centre, so what you pressed has slid out from under your
  finger by the time it lifts; press-to-act has no such race and there is no drag
  gesture to compete with.
- The hint bar became the pointer's button bar. It is already the console's only
  on-screen statement of what the face buttons do, and a pointer has none — so its
  Confirm/Back/Secondary/Tertiary pills are clickable on every screen, which is what
  puts Settings and Library within reach of a mouse at all.
- Tab / Shift+Tab change section; PgUp/PgDn still do, and the keyboard legend now
  reads "Tab".
- Right-click is Back everywhere, EXCEPT at the root: B there quits the launcher and
  a right-click is far easier to fire by accident. Quitting stays explicit.

**Host cards had no menu.** Every other client hangs Wake / Copy link / Edit /
Forget off a host card; the console could add a host and connect to one, and that
was all — so a renamed machine or a fat-fingered address stayed wrong forever
unless you opened a desktop shell. UP on a saved tile now opens that host's menu,
the same gesture the Android console uses, on the one direction a horizontal
carousel leaves free.

- `ConsoleCmd::UpdateHost` edits the stored host IN PLACE. Removing and re-adding
  would silently drop the fingerprint, the learned MAC, the pinned cards and the
  profile binding — that is a rename, not a re-pair.
- `ConsoleCmd::ForgetHost` drops it; if it is still advertising it returns as a
  discovered, unpaired row, which is the honest state.
- Forget arms on the first press and fires on the second. The other clients forget
  outright; a console is driven by a thumbstick from across a room.
- A pinned profile card offers only Unpin. It is a shortcut, not a second host, and
  offering to forget the host from it would blur exactly the distinction a pin draws.
- "Edit…" REPLACES the menu on the stack rather than stacking over it, so Back from
  the editor doesn't land on a menu describing the host as it was before the edit.

Verified in the pf-lxcheck2 container (this crate compiles to nothing on macOS —
a bare `cargo check` there is vacuous): plain build and `clippy --all-targets`
clean under `-D warnings`, 72 tests pass. Seven are new, and cover the reported
bug directly — a press on a pill selects that tab, and each tab still keeps its
own cursor when a pointer is what switched it.
2026-08-07 12:36:22 +02:00
enricobuehler bbbcf321e5 Merge origin/main into worktree-native-decode-m0
ci / web (pull_request) Successful in 1m19s
apple / swift (pull_request) Successful in 1m32s
ci / docs-site (pull_request) Successful in 1m23s
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 25s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m8s
android / android (pull_request) Successful in 3m31s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m23s
ci / rust-arm64 (pull_request) Successful in 5m36s
nix / flake (pull_request) Failing after 11m59s
ci / rust (pull_request) Successful in 14m27s
main moved 93 commits while this branch ran. Two conflicts, both where main's new
work sat next to M10's excision:

packaging/flatpak/io.unom.Punktfunk.yml — main added the vendored gamescope WSI
layer (the only route to HDR on a Deck) and, before it, a vulkan-headers module.
Took both: this branch predates them and deletes neither. But the headers module's
stated consumer was pf-ffvk's bindgen over FFmpeg's hwcontext_vulkan.h, and M10
deleted pf-ffvk — so it now reads as dead weight to the next person. It is not:
the WSI layer IS a Vulkan layer, compiles against those headers, and builds after
it, so module order is the dependency. Rewrote the rationale to say so, including
why dropping it would be expensive to discover — flatpak.yml has no pull_request:
trigger, so a manifest break reaches main invisibly and a tag then ships no Linux
flatpak. Also recorded that the native decoder needs nothing from there: pf-vkdecode
reaches Vulkan through ash, which is pure Rust bindings, no bindgen, no C headers.

crates/pf-console-ui/src/screens/settings.rs — main restructured the gamepad
settings into TABS, which removed the per-row section headers; this branch had left
Some("Video") untouched from the merge base and added the pre-M10 decoder migration
next to it. Git could not tell those apart. Took main's structure (no header, its
deliberate change) with this branch's migration layered on: a stored `vulkan`,
`vaapi` or `d3d11va` names no preset in the tabbed list and would render as "—",
then silently rewrite the user's preference on the next save.

Gates on the merged tree, Linux container: fmt clean; cargo check --workspace
--all-targets clean; clippy --workspace --all-targets -D warnings clean; tests
green across pf-vkdecode (187), pf-client-core (163), pf-console-ui (58) and
punktfunk-host (447 of 448 — the one failure is the pre-existing
gamestream::stream::tests::sender_delivers_batches, a UDP-loopback EINTR under
qemu that fails identically on a pristine HEAD).
2026-08-07 10:50:32 +02:00
enricobuehler 5c05246098 feat: M10 — FFmpeg is gone from the client
cargo tree -p punktfunk-client-session finds no ffmpeg. The host still does,
which is the whole point: pf-encode keeps libavcodec unconditionally and no
host workflow, packaging script or licence file was touched.

Deleted: crates/pf-ffvk, video_vulkan.rs, video_vaapi.rs, video_libav.rs, the
libavcodec half of video_d3d11.rs, the av_log machinery, ffmpeg::codec::Id as
the decoder's vocabulary (the quic CODEC_* wire constants now serve, which is
why the evidence table was keyed on them), DecodedImage::VkFrame and ::Dmabuf,
the presenter's AVVkFrame lane, and the ffmpeg-fallback feature with
everything behind it. DrmFrameGuard collapses from an enum to a newtype, which
removes an unsafe impl Send. Roughly 25,000 lines.

Then the CI, packaging, licensing and docs work the plan's §6 lists: the
Windows workflows lose FFMPEG_DIR, PF_FFVK_VULKAN_INCLUDE and their PATH
prepend; the MSIX loses its DLL wildcard; the client .deb stops emitting libav
sonames on its own because depends come from dpkg-shlibdeps; arch, flatpak and
nix drop the dependency; and the README's "FFmpeg 7 or 8" contract narrows to
the host.

Three defects reached users' machines in the first cut, and none was in the
deletion itself.

All three desktop Settings UIs offer vulkan, vaapi and d3d11va as stored
decoder values, so those strings sit in shipped settings files today. Refusing
them by name — which is the correct rule for a stale pin — would have bricked
every upgraded client whose owner ever touched that dropdown. They now migrate
onto the native rung for the same hardware family, at decoder construction AND
at each dialog's lookup, because a legacy value that matches no preset
displays as "Automatic" and silently rewrites the user's preference on the
next save.

M9's evidence filter was deleted on the argument that with no libavcodec twin
below, barring an unproven rung removes hardware decode rather than moving
down one rung. That is true on Windows and false on Linux for Intel and every
unknown vendor id, where prefer_vulkan_first is false and the order is
native-vaapi → native-vk: a rung that has decoded nothing anywhere sitting
above one that is 250/250 on three drivers. Every Intel Linux desktop would
have moved from libavcodec VAAPI, shipping for years, onto pf-vaadec by
default — and a rung that constructs and then produces wrong pixels leaves
only by the error-streak demotion, which this codebase already documents as
not tripping on the B580's strobing. The filter is restored as a narrow, pure,
testable rule: an unproven rung yields to a proven one, and to nothing else.
Windows deliberately passes no rung below, because that vendor family is the
one with a measured wrong-pixel report against Vulkan decode, and trading no
evidence for evidence of corruption is the wrong direction.

And the notices still said FFmpeg was bundled. The root file is what both
desktop clients include_str! and what the MSIX ships, three lines under the
new card saying no FFmpeg is bundled; Apple's Acknowledgements said it too, on
iOS, tvOS and macOS. The generator now emits four per-client files scoped by
transitive closure — 0 FFmpeg mentions in each, verified — while the root file
keeps it for the host. That also ends the standing false attribution of
ffmpeg-next, GTK4, windows-rs and the NVENC SDK to an iPhone.

Windows has no reachable box, so it was compiled instead: a cross clippy at
-D warnings on x86_64 and aarch64-pc-windows-msvc with the C toolchain stubbed
so build scripts run without linking. That gate immediately caught an
include_str! path one directory too deep, which nothing else could have.

Gates: container clippy -D warnings, 160 tests, workspace check, both Windows
targets clean, client ffmpeg count 0 and host 2. The four decode crates are
untouched, so the hardware rungs' 250/250 stands.

⚠ Owed and unrun: no GPU has executed any of this milestone. M8's on-glass
software check, M7's D3D11 and VAAPI AV1 hardware legs, and M9's field bake
all still want hardware, and the bake window and criteria remain the user's.
2026-08-07 08:58:47 +02:00
enricobuehler d5e23146c0 feat(client): M8 — the software rung is openh264 and rav1d, and swscale is gone
The ladder's last rung no longer runs FFmpeg. H.264 decodes through openh264,
AV1 through rav1d, and HEVC is refused outright: no permissively licensed
software HEVC decoder exists, so an HEVC session that exhausts its hardware
rungs now tears down and re-dials advertising HEVC-less caps, and the host
picks H.264. The plan calls that a first-class path; it is one.

swscale is deleted, and with it the BT.601 default that its correction code
existed to undo. Colour on the H.264 lane now comes from the same
pf-bitstream planner every hardware rung submits from — openh264 reports no
VUI at all — and AV1's comes per-picture from the sequence header. One colour
source, one CSC: the old default is unrepresentable rather than merely fixed.
Frames reach the presenter as three tightly-packed planes through the planar
CSC pass, which had to be un-gated from the pyrowave feature and its device
probe, since the last rung must exist on devices that failed that probe.

rav1d rather than the dav1d crate, deliberately and against the plan's
literal wording: dav1d-sys is system-deps-only, so it would add a system
library and a .pc file to every client package — in the milestone family
whose excision checklist exists to delete exactly that. rav1d is the same
decoder, same licence, statically linked. The cost is honest: no-asm builds
on both decoders, and software throughput is still unmeasured.

The colour test is the milestone's exit criterion, so it is built to fail.
Three fixtures, and a mutation check: hardcoding the swscale default turns the
red bar to [255,24,0], and swapping Cb/Cr turns red to blue — a silent error
no metadata assertion could catch. Review then disproved the range half of it
numerically: with eight saturated bars, decoding the full-range fixture with
the wrong range gives max error ZERO, because a mismatch only pushes values
outside [0,1] where the shader clamps. A mid-tone was added; the wrong range
now costs 11, well past the tolerance. The exit criterion I set was
satisfiable by a test that proved nothing.

Two blocking defects, both emergent rather than local.

Software AV1 on a 10-bit stream never reached its typed refusal: rav1d is
built 8-bit-only and returns ENOPROTOOPT, which the send loop turned into a
generic error, so the pump's typed downcast missed and every AU failed
identically — a permanent freeze on precisely the shipping case, since AV1 is
advertised only where hardware AV1 exists and hardware AV1 plus HDR is Main
10. The shape is now read from the sequence header before any byte reaches
the decoder, exactly as the H.264 leg reads the active SPS.

And the new Reconnecting phase was the first state that is not streaming, not
connecting, and still holding a live stream — which opened all three guards
that had made a second launch impossible. Pressing A assigned over `stream`
where every other site shuts down first, and StreamState has no Drop, so the
old pump was detached: a second live session still submitting to a Vulkan
device that gets destroyed underneath it. Nothing about the reconnect was
wrong in isolation; the defect lived between a new state and three guards
nobody re-examined. Start is now defensive and the retry raises the
connecting modal, so the UI matches the state and B can cancel.

Also closed: retry_caps was computed, tested and never applied, so a shape
refusal could end a session reporting no codec available while a working
retry existed; the retry inherited force_software sticky-true, landing an
HEVC→H.264 fallback on software H.264 with working hardware H.264; it
re-dialled with a stale mode; the CPU present arm had no survivable-failure
handling where the pyrowave arm — same pass — has it; HEVC is no longer
advertised when the decoder is pinned to software; and the software rung now
feeds the recovery-point SEI it already had in hand to the re-anchor gate.

⚠ Two host-side gaps found while tracing, neither in scope here: Hello::launch
is NOT idempotent (gog:/custom: targets spawn a second copy on a retry; the
field is kept verbatim because dropping it orphans the gamescope display whose
reuse key includes the command), and a reconnected session can never adopt a
game predating its own launch stamp, so it has no game-exit detection.

⚠ OWED: the on-glass software run. ~200 lines of new Vulkan on a path that
only runs because the GPU already failed, and no driver has seen it. The
review's minimum check is sync validation enabled, a non-multiple-of-16 mode,
a mid-session resize and demotion, and both colour matrices.

Gates: container clippy -D warnings over four crates, 236 tests, workspace
check. pf-vkdecode and pf-bitstream are byte-for-byte untouched, so the
hardware rungs' 250/250 stands.
2026-08-07 06:18:23 +02:00
enricobuehler a6e51215fd feat(client): M6's rung is wired — libva, dlopen'd, no libavcodec
The native VAAPI decoder now runs end to end: pf-vaadec's plans go into
libva's buffers, the surface comes back as DRM-PRIME dmabufs, and the
presenter imports them exactly as it does the FFmpeg rung's. Pin-only —
`PUNKTFUNK_DECODER=native-vaapi` — for the reason M5's D3D11VA rung was:
`auto` admission is earned with hardware parity and a soak, and this rung
has decoded nothing yet.

libva is dlopen'd rather than linked, so the pf-lxcheck2 container compiles
and clippies the whole thing without libva-dev, and a machine without a
VAAPI runtime gets a clean refusal instead of a packaging dependency.

The surface pool is not the slot map. `SlotMap::assign` hands out the lowest
free slot, and a slot freed by an access unit's own removals is free by the
time that unit's picture takes it — measured at 225 of the vendored vector's
250 access units. A surface bound by slot index would therefore decode, on
nine frames in ten, into the surface still holding the picture on screen. So
`plan_to_va` now takes the decode target as a parameter, bound by the caller
at activation time the way pf-vkdecode binds a pool image, and a surface is
free only when no live picture is bound to it, no output is owed for it, and
no consumer holds it.

Measured rather than transcribed, as everywhere else here: layout-probe.c
grew the export descriptor (312 bytes, objects[4]/layers[4]), the buffer-type
enumerators — VASliceParameterBufferType is 4 and VASliceDataBufferType is 5,
not the 3 and 4 that counting off the header suggests — and the config,
attribute and generic-value layouts. All pinned as compile-time assertions,
which is how the 12-byte VAGenericValue in the first draft was caught: the C
union holds a pointer, so it is 8-aligned and 16 bytes.

The plane walk lives in pf-vaadec, pure and unit-tested on macOS, because it
is the one structure the DRIVER writes and we read: SEPARATE_LAYERS returns
NV12 as two layers, and taking layers[0] is the green screen this project has
already paid for. It also refuses what it cannot express rather than guessing
— a bogus object count, a plane naming an object that is not there, objects
disagreeing on tiling.

Own DecodedImage variant, same payload type. The physical hand-off is
identical to the FFmpeg rung's, so the presenter keeps ONE arm and one
demotion streak; the variant exists so the compiler asks which rung decoded
wherever that matters. Both D3D11VA rungs share a variant and `1573a987` had
to fix the consequence afterwards — a "native" soak that could silently have
been an FFmpeg soak. Here the four uncovered matches were compile errors.

Buffers are destroyed by us, not by vaEndPicture: va.h is explicit that the
user must call vaDestroyBuffer, and the libva 0.x behaviour is long gone.
Leaking two per picture at 60 fps exhausts the driver's store in minutes.

pf-vaadec's presenter headroom was 4, written against no consumer. The Vulkan
rung had already measured the client pipeline at four to seven held frames;
it is 8 now, pinned to that crate's constant so a re-measurement moves both.

Gates: macOS fmt/clippy/341 tests/cargo doc, and in the container clippy
-D warnings over six crates, 795 tests, workspace check.

Hardware legs are still owed — no AMD/Mesa or Intel box was reachable.
2026-08-06 16:56:38 +02:00
enricobuehler 2a67c02f7e fix(clients/cursor): the host must not composite a pointer under a released client's own cursor
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 1m9s
android / android (pull_request) Successful in 4m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 1m36s
ci / web (pull_request) Successful in 4m31s
ci / docs-site (pull_request) Successful in 4m43s
ci / rust-arm64 (pull_request) Successful in 6m50s
ci / rust (pull_request) Failing after 12m8s
Streaming a KDE desktop showed two cursors: the one the user was moving, and a
second one sitting underneath it that never moved. It was not KDE's — KWin 6.7.3
in cursor-as-metadata mode calls `setRenderCursor(false)` on every recorded buffer
and hands the cursor item to an exclusive `ItemTreeView`, so `shouldRenderItem()`
skips it and no pointer is ever painted into that stream. It was ours.

Both clients declared the render model as `captured && desktop`, so ANY released
pointer handed compositing back to the host. But releasing does not remove the
local cursor — it restores the ordinary window arrow over the video. The host then
blends its own pointer in underneath, and since a released client forwards no
motion, nothing drives it: it stays frozen wherever the host pointer was last left.
Caught live on the host with the render-model diag:

    cursor diag: client_draws=false blended=true live=Some((-1, 622, true))

x = -1 — parked on the streamed output's left edge, unchanged sample after sample,
while the user moved their own cursor around freely. Engaging capture flipped it to
`client_draws=true blended=false` and the duplicate vanished, which is why it only
looked "stuck when not dragging": dragging means engaged, and engaged was the one
state that behaved.

The host may composite ONLY while the client holds a grabbed, hidden pointer — the
capture model, engaged — which is the single state with no local cursor on screen.
Released now counts as "the client draws it": the host stops compositing and keeps
forwarding shape/state over the channel (the forwarder ticks on this side of the
flip), so re-engaging is seamless and the client's cached shape stays warm.
2026-08-06 15:03:11 +02:00
enricobuehler 2a57ee36f8 feat(client): M4 — the decoder's own verdict reaches the session
This program exists because a field corruption was architecturally
undetectable through FFmpeg: no decode-status read, no corrupt-frame
flag, errors only as scraped log lines, and no recovery-point signal so
intra-refresh healing was invisible. The native decoder has all of those.
M4 is where they stop being internal.

DecodeHealth counts, per session and without allocating per frame, what
the three answers actually are: damaged (the stream arrived incomplete),
refused (the rung would not decode it at all) and driver-failed (the
hardware says it could not decode what arrived), plus the current and
worst concealment run — the figures that separate one bad AU from a
stream that never came back. They ride the stats line additively, so an
FFmpeg session and a healthy native session emit byte-identical output
to today. The status-query capability is reported too: without it a
clean report cannot be told from an unmeasured one, which is the whole
nb_queries=0 lesson.

The headline is local recovery. Until now the pump could only learn that
intra-refresh healing finished from wire flags the host sends; absent
those it froze until the 500 ms backstop forced an IDR. The parsed
recovery-point SEI now feeds the re-anchor gate directly, so a session
lifts on the picture that is actually clean. Wire semantics are
untouched for every client that never calls it.

Detection now asks for recovery instead of erroring — an integrity
warning ticking the error streak would demote the native rung on exactly
the lossy links it exists to diagnose, where an FFmpeg rung conceals
silently and keeps its job.

Review round 12 found that trade had removed the escape hatch entirely.
Concealment returning Ok(None) reset the demotion streak, and worse: the
driver-verdict ledger is only populated when a frame ships, so under
continuous concealment no verdict was ever read and the erroring arm
could not fire at all. A host framing regression of the 0.23.0
slice-wire class — which does not self-heal, and which a keyframe does
not clear — would have frozen indefinitely with no demotion and a clean
integrity line, where before it demoted to FFmpeg-Vulkan and showed a
picture. Now only an answer that proves the rung works clears the
streak: a shipped frame, or a clean no-frame. Concealment neither ticks
nor clears, so a lossy link still cannot demote a healthy rung while a
driver failure interleaved with concealment reaches the threshold again.

Two more honesty defects from the same round. A rung refusing every AU
reported no integrity line at all — the founding failure mode, wearing
the shape of a clean bill of health; refusals are now counted. And
driver-failed could be non-zero on a device that cannot produce driver
verdicts, because a degraded timeline read looked the same as one; the
attribution is now withheld inside the counter rather than at call
sites, so the self-contradictory line is unrepresentable.

Local recovery also no longer trusts any recovery-point SEI: only one
whose target advances past an outstanding wave counts as a new wave, so
an encoder re-announcing the current wave with a decreasing count — legal,
and what x264 intra-refresh does — cannot lift the freeze early onto a
partially stale picture. Frames buffered across an arm are dropped by
decode order for the same reason.

Fault injection is a first-class tool now (PUNKTFUNK_AU_FAULT, inert
unless set, env read once). Its test replays the vendored vectors
through the real planners and asserts a negative the plan assumed away:
truncation and bit flips are PROVABLY invisible to the parser — Annex-B
carries no NALU length, so a cut slice is just a shorter slice and a
flipped payload byte is syntactically perfect. Only dropped AUs are
parser-detectable; the rest need the driver verdict, which is why the
status query matters. The H.265 leg found a second: three of that
vector's faulted AUs are sub-layer non-reference pictures, so dropping
them damages nothing and silence is correct — the test asserts both
verdicts and guards that neither half goes vacuous.

Per-frame decode latency was deliberately NOT built. Polling answers
only 'complete by now', and the pump polls once per AU, so every sample
would quantise up by as much as a frame interval — 8.3 ms at 120 Hz
against decodes of 0.1-2 ms. Sampling faster needs a spin or a second
thread on a decoder that is deliberately not Sync. A blocking per-frame
wait is the field scar that once capped a stream at 51 fps. The honest
sampled stat stands.

Also fixed, pre-existing: the re-anchor gate re-armed on every damaged
AU, so sustained damage permanently zeroed the mark count — meaning the
wire's two-mark rule could never complete on exactly the lossy links it
was written for.

Field note recorded while wiring this: intra_refresh_recovery is set by
exactly one encoder backend (Linux libav-NVENC under
PUNKTFUNK_INTRA_REFRESH). AMF and QSV run a wave with no wire mark, and
AMF emits no recovery-point SEI either, so AMD/Windows intra-refresh
sessions still have no clean recovery point by either route.

Gates: fmt clean; container clippy -D warnings zero across
pf-client-core + pf-presenter + pf-vkdecode + punktfunk-core; tests
69/131/129/354/41 plus 5 fault-detection green; cargo check --workspace
clean.
2026-08-06 04:30:40 +02:00
enricobuehler e4d8573475 feat(client): the native rung now decodes HEVC as well as H.264
The last piece of M3 WP-2 — VkH265Decoder was built and hardware-gated
but nothing drove it. video_vk_native.rs holds a two-arm codec enum and
forwards to it; the ledger, release tokens, status-query settling and
timeline waits are byte-for-byte what they were, since they were always
codec-agnostic over one DecodedVkFrame contract. The forwarders are
written out per arm rather than macro'd so the unchanged H.264 arm is
visible to a reviewer.

The picture's own format now reaches the presenter, which picks bit
depth and MSB packing from it instead of assuming the H.264 envelope.
That incidentally fixes a live bug on the SHIPPING FFmpeg-Vulkan path:
it derived ten-bit-ness by comparing against the 10-bit 4:2:0 format
alone, so a 10-bit two-plane 4:4:4 surface — which its own format table
accepts, and which NVIDIA reports for HEVC RExt — got 8-bit range and
transfer maths. Reachable today with Full chroma plus 10-bit: decoded
correctly, displayed wrong.

Review round 11 caught a regression this WP would otherwise have
shipped. pf-vkdecode refuses a stream whose (chroma, depth) pair has no
picture format on the device, but the session is built lazily from the
first SPS, so the refusal arrived AFTER construction — past the point
where a native init failure falls through to FFmpeg-Vulkan. It burned
the error streak instead and demoted to VAAPI/D3D11VA, which on
NVIDIA/Linux means software. Turning on Full chroma on any non-NVIDIA
GPU was enough: a 4K HEVC session that ran on FFmpeg-Vulkan before this
branch would have landed on software decode.

Both halves are fixed. The negotiated chroma and bit depth — already at
the call site, the PyroWave arm four lines up uses them — are threaded
into the backend, which probes the same caps path ensure_state would
run, so the whole class refuses at CONSTRUCTION where the fall-through
already exists. For the legs no negotiation can carry (a level above
maxLevelIdc, an SPS that disagrees with the Welcome) the decoder latches
'never delivered a frame' and routes that first streak to FFmpeg-Vulkan
rather than down the hardware ladder. H.264 is deliberately not probed:
its envelope is fixed, so a probe would only add a profile guess on the
bit-exact path; it gets the latch as its backstop.

Two more from the round. Planner warnings are typed again rather than
Debug strings — pf-vkdecode simply lacked the h265 re-export its h264
twin already had — which restores the H.264 log rendering exactly and
unblocks M4, whose job is counting concealment by kind. And concealment
is now the integrity set only: NonZeroReorder is documented spec-legal
and fully planned, but the client treated every warning as damage, so
the opening IDR and every ABR renegotiation's IDR were released unshown
and re-anchored — a visible hitch on a healthy stream.

Also: a raw-format newtype so a neighbouring i32 field cannot be passed
to the colour maths, the presenter's depth table now pinned against
pf-vkdecode's actual output vocabulary rather than the FFmpeg lane's,
a per-format warn latch, and four stale docs.

Gates: fmt clean; container clippy -D warnings zero across
pf-client-core + pf-presenter + pf-vkdecode; tests 69/125/108/40 green;
cargo check --workspace clean.
2026-08-06 03:02:09 +02:00
enricobuehler 6d8f3b45b5 feat(pf-vkdecode): the GPU half of HEVC decode — session, pools, recording
M3 WP-2 complete. caps_h265.rs builds the profile the stream actually
needs (profile idc + chroma + bit depths, all three stated on every
Vulkan object) and resolves its picture format — Main to NV12, Main 10
to P010, RExt 4:4:4 to the two-plane 4:4:4 formats — validating it
against the format list of every role the chosen arrangement creates
images in. A Main 10 stream on an 8-bit-only device is refused BEFORE a
session exists, never narrowed: decoding 10-bit into an 8-bit surface is
the silent-wrongness class this crate exists to refuse. session_h265.rs
adds the three-array parameters ledger; decoder_h265.rs adds
VkH265Decoder, mirroring VkH264Decoder method-for-method so the client
wiring is a two-arm dispatch away.

H.264 and H.265 now SHARE the machinery instead of duplicating it:
derive_arrangement (one coincide/distinct/layered decision table),
ring::rebased_offsets (the slices-only rebase — non-VCL NALUs in the
decode range hang VCN firmware), session::bind_session_memory, and a
parameterised build_frame. A DecodeProfile enum replaces the bare
profile idc that images.rs and ring.rs used to take: both codecs' idc
types are c_uint, so handing an H.265 idc to the H.264 path COMPILED
SILENTLY and built a mismatched profile chain. That is now
unrepresentable.

The VPS leg is the ledger's real work. The vendored parser attaches a
VPS to an SPS only when it saw the NALU, and clients join live streams,
so VpsSource is Parsed-or-FromSps and is stored BY VALUE: re-activating
a VPS-less SPS is Current (no churn), but the real VPS arriving under
the same id is a content change and RECREATES onto it, because Vulkan
cannot replace a stored parameter set.

Review round 10 (adversarial) confirmed the hardware-proven H.264 path
is NOT regressed — derive_arrangement's check order and error identity
are byte-for-byte the original, build_frame's call sites still pass the
granularity-aligned extent (the 1088-row scar stays shut), and
rebased_offsets reproduces the deleted inline loop for every input while
moving the sum to u64 so overflow errors instead of wrapping. Also
verified: the refs-order contract on every path, the RESULT_STATUS caps
gate (each of reset/begin/end individually gated, no pool created when
unsupported — recording one on RADV hangs its VCN), pNext lifetimes, and
that no panic is reachable on stream input.

Its 10 findings are fixed. The two that mattered:

- A failed decode stranded a DPB slot. Once plan_to_vk_h265 had mutated
  the slot map, five later failure paths returned without restoring it,
  so planner and slot map both believed a picture was resident while no
  image held it — and every later AU referencing it failed, where H.264
  soft-degrades and keeps delivering. Fail-closed is kept (substituting
  a reference silently is the corruption-hiding this program exists to
  end) but made RECOVERABLE: a latch flushes the planner to AwaitingIdr
  and resets the bindings on the next decode, which composes with the
  client already requesting a keyframe on every decode error. The fix
  deliberately covers pre-mutation failures too — those strand the
  picture the other way round and wedge identically.
- DecodedVkFrame carried no picture format, so a Main 10 frame would
  decode correctly and be rendered with 8-bit transfer/range math. It
  now carries one, stamped from the pool so it is truthful for both
  decoders by construction. The presenter comment says depth 8 is
  because only H.264 is WIRED, not a decoder limit.

Plus: bind_session_memory freed allocations before the session that may
hold them was destroyed (an ordering regression from the extraction,
with a SAFETY comment asserting the opposite) — the bind-stage exit now
hands them back so Drop destroys first; max_level_idc is codec-tagged
rather than an H.264 type carrying H.265 code points; and the decode
family's videoCodecOperations is now checked, turning 'create an H.265
session on a device without the extension' from UB into a clean ladder
demote.

Deferred by design: no HEVC gpu_smoke/gpu_parity yet (its goldens are
already in tests/data/test-25fps-h265.nv12.sha256), and no codec
dispatch in the client — both later legs.

Gates: fmt clean; mac clippy zero warnings, pf-vkdecode 106 +
pf-bitstream 69 green; container clippy -D warnings zero for
pf-client-core + pf-presenter + pf-vkdecode, tests 69/121/106 green.

HARDWARE (.173, after the refactor — review saying the proven path is
safe is not the GPU saying it): gpu_parity '250 frames bit-identical to
libavcodec software decode' on BOTH the NVIDIA 4090 (610.88, coincide
mode) and the AMD iGPU (Adrenalin 25.10.30.02, distinct mode), gpu_smoke
green on both. Two independent drivers, both DPB modes, still bit-exact.
The smoke trace also shows the new videoCodecOperations capture reading
DECODE_H264 | DECODE_H265 | DECODE_AV1 off the real decode family.
2026-08-06 01:59:39 +02:00
enricobuehler 6331ae7fd9 fix(pf-vkdecode): zero-copy pool model + the two faults the first hardware run found
WP-D leg 1 (.25 RADV, distinct mode) root causes, both real:
1. Output starvation: the fixed 4-deep ring lost to a stream that keeps
   max_dpb_frames+1 = 8 pictures pending. Zero-copy fix (user
   requirement, no copies): one picture pool of required_slots +
   HOLD_HEADROOM(8) images decoupled from DPB slots — a re-activated
   slot binds a fresh free image, so a delivered picture is never a
   decode target; the WP-B pin layer became dead and is deleted.
   Per-image timeline semaphores carry the AVVkFrame contract: decode
   signals value+1, the presenter waits and signals back, later decodes
   wait the image's latest value — layout traffic ordered against
   reference reads with no copy anywhere.
2. RESULT_STATUS queries HANG RADV's VCN firmware (ring timeout,
   DEVICE_LOST): queryResultStatusSupport=false on the decode family.
   Queries are now caps-gated; without them poll/wait degrade to
   timeline-completion verdicts (FFmpeg parity — and the likely reason
   upstream never wired nb_queries). The Ally-X-class detection runs
   where drivers advertise the query; .173 probes NVIDIA/Windows-AMD.

Also: slice-only bitstream feeding (the field-proven consumer shape),
graveyarded pool retirement keyed by release tokens + generation,
decode-current-AU-before-status attribution, take_ready drained,
H264-bit gating, teardown short-circuit on disconnected channel.

On-glass: 48 AUs green on .25 holding 4 frames like the real client.
Gates: fmt clean, container clippy -D warnings zero, 27+121+52 green
both platforms.
2026-08-05 19:12:20 +02:00
enricobuehler d0659d2b61 feat(client): wire the native Vulkan decoder in behind PUNKTFUNK_DECODER=native-vulkan
M2 WP-C. video_vk_native.rs adapts the presenter's VulkanDecodeDevice to
pf-vkdecode (queue lock shared only when the families actually collide —
the one case the 2026-07-09 DEVICE_LOST race proved matters), and the
presenter consumes DecodedImage::NativeVk on its own device: no handle
import, no AVVkFrame co-authoring — wait the timeline, barrier to
sampled, existing crop-aware CSC, barrier back, release after the fence.

Frame lifetime is a token: presented, retired, displaced or dropped
mid-demotion, the guard's drop sends it exactly once; the backend
releases the decoder slot only after the status query resolves, so a
recycled slot can never report a false Failed. Driver-reported decode
failures and plan warnings ride the existing streak/reanchor machinery —
the Ally X corruption class is now a visible error, not a silent frame.

Opt-in only until WP-D's on-glass parity verdict; H.264 sessions only;
failures demote to the existing ladder. Known WP-D items recorded in
code: coincide-mode cross-queue reference overlap, renegotiation
teardown window, VUI colour plumbing.

Gates: fmt clean; container clippy -D warnings zero for pf-client-core +
pf-presenter + pf-vkdecode; 121+53+27 tests green.
2026-08-05 17:57:12 +02:00
enricobuehler b2e716ad5f Merge pull request 'fix(client/desktop): the Deck keeps its trackpad, and a pad stops buzzing at exit' (#38) from worktree-haptics-m6-presenter into main
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
android / android (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
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
apple / swift (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
apple / screenshots (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
flatpak / build-publish (push) Canceled after 27s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 4m25s
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
2026-08-04 21:02:36 +00:00
enricobuehler 63a4f583b9 feat(console): profiles reach the gamepad UI — pinned cards, pin management, settings section
The Skia console now renders a pinned profile card after its host's primary
tile (KnownHost::pinned_profiles resolved by the service thread), connects
with that profile as a one-off via the existing effective_settings resolver,
and shows the bound default profile on the primary tile. The settings screen
gains a trailing Profiles section — one row per catalog profile with a live
pin count — whose activation opens a pin-to-hosts screen; toggles ride the
new ConsoleCmd::SetPin to the binary, which persists pinned_profiles (the
same field the CLI resolves for Decky's host list). Profiles themselves stay
desktop-authored (design client-settings-profiles.md §5.2a, §5.4).
2026-08-04 19:40:53 +02:00
enricobuehler 8abdd74a62 fix(client/desktop): the Deck keeps its trackpad, and a pad stops buzzing at exit
ci / rust-arm64 (pull_request) Successful in 1m55s
ci / web (pull_request) Successful in 1m14s
ci / docs-site (pull_request) Successful in 1m15s
android / android (pull_request) Successful in 8m52s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m2s
ci / rust (pull_request) Successful in 12m50s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m7s
apple / swift (pull_request) Successful in 1m23s
apple / screenshots (pull_request) Skipped
Three faults in the desktop session's gamepad path.

The Steam Deck lost its built-in trackpad-mouse at the start of every session.
SDL's Valve HIDAPI driver clears the pad's digital mappings during
*enumeration*, which is part of bringing the gamepad subsystem up — so holding
the drivers off from inside GamepadService::pumped could never work: receiving
a GamepadSubsystem means the enumeration has already happened. The hint set
there detached a driver that had already done the damage, and lizard mode only
came back seconds later when the firmware watchdog restored it. The presenter
now disables them with its other pre-SDL_Init hints. The threaded worker always
had this right; only the caller-pumped path was wrong, and it could not fix
itself, hence a separate entry point its callers can place correctly.

Player LEDs did nothing at all on any pad that is not a DualSense. The match
arm handled the DualSense raw-effects path and let everything else fall through
a bare `_`, though SDL exposes set_player_index and owns the per-device
pattern. The wire carries a positional bitmask rather than an index, and the
bridge is the popcount: every convention that reaches this wire spells "player
N" as N lit LEDs — the DualSense patterns 0x04/0x0A/0x15/0x1B/0x1F and the
Switch/XInput run 0x01/0x03/0x07/0x0F alike — so counting them works for both,
where reading a bit position would only ever suit one. No lit LED means no
player, not player 0. The remaining unhandled variants are now named rather
than swept up by `_`, so a new one cannot join them silently.

A forwarded pad could be left buzzing when the session ended. detach() only
posts Ctl::Detach; the close that flushes the pad, tells the host to remove it
and explicitly zeroes the motors runs when the pump next drains that message.
Single mode broke out of the loop immediately after detaching and Event::Quit
never detached at all, so both skipped it entirely. The teardown now sits where
every exit converges instead of on the individual breaks. That still leaves the
several paths that leave by `?` on a fatal overlay or present error, so the
pump also silences its slots on Drop — the explicit call stays, because a pad
should go quiet before a long teardown rather than after it. Drop closes the
slots directly rather than draining the queue that would have done it: same
physical outcome, and it touches no lock, where draining reaches an unwrap on a
Mutex that would abort the process if it panicked mid-unwind.
2026-08-04 19:10:45 +02:00
enricobuehlerandClaude Opus 5 6b3c582eb1 feat(client/present): use the driver's queue-free vblank mode where it exists
apple / swift (pull_request) Successful in 1m14s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m30s
ci / rust-arm64 (pull_request) Successful in 2m51s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m3s
android / android (pull_request) Successful in 5m32s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m53s
ci / rust (pull_request) Successful in 7m13s
`VK_PRESENT_MODE_FIFO_LATEST_READY_EXT` is FIFO's tear-free vblank pacing that
presents the LATEST READY image at each refresh and retires the older ones,
instead of draining a queue. That is precisely what the software glass gate
emulates — so where the driver offers it, the driver does the job, and it does
it exactly where the gate matters most: a surface with no MAILBOX gets
newest-wins behaviour back without the app holding frames.

Found by asking the surface what it actually offers rather than trusting a
comment: the previous commit's `surface present modes` line read back
`[MAILBOX, 1000361000, FIFO]` on NVIDIA/Wayland, and 1000361000 is this mode.

The extension postdates the Vulkan headers ash 0.38 is generated from (1.3.281),
so there is no binding — hence the bare number in the log. It is hand-declared
here: mode value, extension name, and
`VkPhysicalDevicePresentModeFifoLatestReadyFeaturesEXT` spliced into the device
pNext chain. One trap worth naming: the SURFACE advertises the mode even with
the extension disabled, and using it on that basis is undefined — so the ladder
only offers it when the device feature actually came back true and we enabled it.

The gate/probe predicate had to split in two, and the distinction is the point:

* `needs_glass_gate()` — FIFO and FIFO_RELAXED only. NOT this mode: gating on
  top of a driver that already retires stale images would hold frames back to
  emulate something the presentation engine is doing, paying the serialisation
  twice, which is the ~27 ms the last commit measured.
* `vblank_locked()` — the whole FIFO family INCLUDING this mode, because it
  still presents on the refresh boundary, so the VRR cadence probe's premise
  ("with VRR off, a present waits for vblank") still holds.

Ranking: MAILBOX first (measured good at 1.4 ms), then LATEST_READY, then plain
FIFO — so a MAILBOX-less surface reaches newest-wins in the driver rather than
in our gate.

MEASURED ON GLASS (.21, NVIDIA 610.43.03, GNOME/Wayland): the extension probe,
feature enable and swapchain creation all succeed with a mode ash has no binding
for. Default ladder selects MAILBOX with `fifo_latest_ready=true`; the VRR ladder
selects `present_mode=1000361000` and measures `display 2.6 ms (pace 0.6 + latch
2.0)` — against 13-28 ms for plain FIFO + gate on the same box. The vblank-locked
path is now MAILBOX-class.

That changes the previous commit's reversal. The VRR ladder was reverted to
opt-in because it led with plain FIFO and cost ~27 ms; led with LATEST_READY it
costs 0.6 ms over MAILBOX. So `allow_vrr` is automatic again WHERE THE DEVICE
OFFERS THE MODE, and stays behind `PUNKTFUNK_VRR_FIFO=1` where it does not — on
those drivers the ladder would fall back to plain FIFO and the regression
returns. Both branches are pinned by tests. This also retires a dead switch: the
"Follow variable refresh rate" row did nothing at all after the reversal, and now
does something real on any driver with the extension.

⚠ Still unverified off this box: whether Windows and Intel drivers expose the
mode at all. Nothing measured here carries over — Windows Vulkan WSI goes through
DXGI, so exposing the enum and mapping it usefully onto flip-model semantics are
separate questions, and Intel is a different vendor stack again. Both facts are
logged unconditionally now (`surface present modes` + `fifo_latest_ready=`), so
one run on any box settles it. The code is safe either way: the mode is only
requested where the device feature enabled, and `allow_vrr` only goes automatic
there — everywhere else the shipped MAILBOX-first behaviour is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:01:56 +02:00
enricobuehlerandClaude Opus 5 e08474d96d fix(client/present): log the surface's actual present modes, and document the VRR opt-in
"AMD's Windows driver offers no MAILBOX" is the premise the FIFO glass gate is
built on, and it has been carried in a code comment rather than measured. Present
modes are a property of the (surface, device) pair — they vary by platform
surface, driver version and fullscreen state — so the only way to settle it is to
read them back from real machines. One unconditional log line makes every field
log answer the question.

First reading, .21 (NVIDIA 610.43.03, GNOME/Wayland):
  surface present modes available=[MAILBOX, 1000361000, FIFO]

Two things fall out. No IMMEDIATE and no FIFO_RELAXED on this surface, which is
why a PUNKTFUNK_PRESENT_MODE=immediate run reported mode=fifo — the pin was not
offered and the ladder fell through; previously that looked like a puzzling
result and is now evidence. And 1000361000 is
VK_PRESENT_MODE_FIFO_LATEST_READY_EXT: FIFO's tear-free vblank pacing that
presents the LATEST READY image instead of draining a queue — the driver-native
version of what the glass gate emulates in software, and a candidate to replace
it wherever the driver exposes it (needs VK_EXT_present_mode_fifo_latest_ready
enabled at device creation, so a work package rather than a tweak).

Also documents PUNKTFUNK_VRR_FIFO, which the previous commit introduced without
a docs entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:01:56 +02:00
enricobuehlerandClaude Opus 5 f422ae3e38 fix(client/present): what the first on-glass session found, including a reversed default
WP6 ran against .21 (CachyOS, RTX 5070 Ti, NVIDIA 610.43.03, GNOME/Wayland,
1080p60 HDMI, VRR provably disabled — `org.gnome.mutter experimental-features`
is empty), host and client on the same box, `VK_KHR_present_wait` available.

Five defects that unit tests and both CI gates had passed over:

1. The latch learner and the VRR probe observed NOTHING. Both derived spacings
   with `windows(2)` inside a single batch, but the run loop drains present-wait
   samples every pass, so a batch is normally ONE stamp. `period_us` read back
   exactly the mode fallback — correct by luck on a 60 Hz panel, wrong the moment
   a mode lies, which is the entire reason PanelGrid exists. The tests fed
   40-stamp batches, a shape the live loop never produces. Spacings are now
   measured against the previous stamp across calls.

2. The VRR reference was circular. It compared spacings against the LEARNED
   period, but the grid cannot be learned from our own presents when the stream
   runs below panel rate — we only ever observe multiples ≥ our frame interval,
   so the learner adopts our own cadence and every delta is on-grid by
   construction. It learned 18-22 ms from a 40-50 fps stream and reported VRR on
   a display with VRR off. The reference is now the DISPLAY MODE's period, which
   is the vblank grid presents actually quantize to.

3. The probe is meaningless outside FIFO. MAILBOX deliberately decouples presents
   from scanout, so its stamps are never grid-quantized: same panel, same minute,
   FIFO read `no` (correct, period 16.4 ms) and MAILBOX read `yes` (wrong).
   Outside a FIFO-family mode the honest answer is Unknown, and that is now what
   it reports.

4. Round evaluation was per-CALL rather than per-sample, so the verdict depended
   on how the caller batched its stamps. Closed inside the sample loop now, with
   a test pinning bulk-vs-one-at-a-time equivalence — the same invariant (1)
   violated, in a second place.

5. `force_latency` was dead code without the `pyrowave` feature: a warning in the
   `--no-default-features` build CI actually ships (the Windows ARM64 leg). The
   gate only ever tested default features; it now tests both.

DESIGN REVERSAL — the VRR FIFO-first ladder is opt-in (`PUNKTFUNK_VRR_FIFO=1`),
no longer default. It shipped default-on for `allow_vrr` + fullscreen, which is
the default configuration. Measured A/B, same box, back to back, reproduced
across three runs: FIFO+engine `display 28.4 ms (pace 11.8 + latch 16.6)` versus
MAILBOX `1.4 ms (0.2 + 1.2)`. Under a compositor the FIFO present's on-glass
confirmation arrives a whole refresh later and the presenter serialises behind
it. The VRR upside is real in principle but UNMEASURED — no VRR panel was
available — and a default that is measurably ~27 ms worse on the hardware we
could test, bought against an unproven win on hardware we could not, is the
wrong way round. A test pins the default to MAILBOX; flip it back when a VRR
panel confirms the win.

NOT measured, and not claimed: the FIFO glass gate's own headline. The standing
queue only forms when the stream rate approaches the panel rate, and an idle
GNOME desktop is damage-driven at 40-50 fps on a 60 Hz panel, so `gated`/`forced`
read 0 in every mode and the mechanism never engaged. The 11-13 ms figure is
still the code's inherited documentation, not a fresh measurement. It needs its
actual target: AMD-on-Windows (no MAILBOX, direct scanout) under load.

Rig caveats recorded rather than smoothed over: host and client shared one GPU,
so absolute latencies are contended and run-to-run variance was large, and it
could not be visually confirmed what the physical screen showed. Mode selection,
the fallback ladder, the VRR verdict and the counter plumbing are robust to
that; absolute numbers are not.

Gates: fmt, clippy -D warnings over the five client crates AND the
`--no-default-features` build (added because defect 5 hid there), 160 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:01:56 +02:00
enricobuehlerandClaude Opus 5 e38e3c44c9 feat(client/present): V-Sync and VRR become real settings, and VRR is measured
WP3 of design/desktop-presentation-rebuild.md. The `vsync` and `allow_vrr`
settings have existed since WP1 but nothing consumed them — the swapchain picked
MAILBOX-or-FIFO once, from an env var, and froze. This makes them mean
something, which is also what unblocks their settings rows (deliberately
withheld from WP5 rather than shipped as dead switches).

Present-mode selection is now a preference ladder, not a constant:

* V-Sync off — IMMEDIATE, then FIFO_RELAXED, then the tear-free modes. Asking
  to tear and silently getting vsync is a lie, so the mode that actually took is
  named in the stats line and a refused preference is logged requested-vs-active.
* V-Sync on + VRR allowed + fullscreen — FIFO first. On a variable-refresh panel
  with direct scanout the FIFO present IS the flip, so the panel follows the
  stream's cadence instead of a fixed grid; MAILBOX would decouple presents from
  scanout and re-quantize to the compositor's clock. This is only safe because
  WP2's glass gate bounds the standing queue that historically made FIFO costly.
* Otherwise — MAILBOX then FIFO, the shipped default, unchanged.

`PUNKTFUNK_PRESENT_MODE` still pins a mode outright and now falls back to the
settings (rather than to mailbox) when the name is unknown.

VRR detection is MEASURED, never queried. No portable query exists — SDL exposes
none, Wayland does not report adaptive-sync state, Windows surfaces nothing
through Vulkan — and the platforms that do answer have been caught lying (see
the Android per-uid refresh-rate finding). The discriminator is quantization: on
a fixed-refresh panel every on-glass instant lands on the vblank grid, so the
spacing between presents is ~k×period for whole k even when the stream runs
slower than the panel (it just picks a larger k); under real VRR the panel
refreshes when we present, so the spacing follows our own cadence and sits off
the grid. `CadenceProbe` folds each delta to its distance from the nearest
multiple of the learned period and takes the median. Tri-state: it stays Unknown
below 24 deltas and after a display change, so `vrr` is reported only when it
has been measured — never inferred from what the display claims.

Also fixes the read-once refresh rate: `native.refresh_hz` was sampled at
startup and never revisited, so dragging the window to another monitor left a
60 Hz-seeded clock pacing a 144 Hz panel. `WindowEvent::DisplayChanged` now
relearns the latch grid, resets the cadence verdict, and clears the served-slot
latch.

Settings rows for both, on all three surfaces (GTK, WinUI, console). The
console's V-Sync row is reachable in Gaming Mode, which is the only editor a
Deck user has.

Gates: punktfunk-rust-ci linux/amd64 — fmt, clippy -D warnings over
pf-client-core, pf-presenter, pf-console-ui, the session binary and the GTK
client, 160 tests (the two new ones cover every ladder and both cadence
regimes, including the case that matters most: a stream slower than a FIXED
panel must still read as fixed). WinUI leg on the Windows runner .133:
clippy=0 tests=0, against a tree proven by content to contain the edit.

⚠ On-glass validation is still owed and is NOT claimed here: every box with a
real display was powered off when this landed, so the VRR ladder and the
detector have been exercised only against synthetic stamps in unit tests.

Rebase follow-up: `20de58a7` landed the same "panel grid can be wrong in both
directions" defect fix on Android and extracted the corrected learner into
`punktfunk_core::phase::PanelGrid` for the iOS and desktop presenters to share.
This clock had the identical bug — it capped the learned period at the display
mode's refresh, and the mode is only a CLAIM, so a display really running slower
than it advertises pinned a grid whose instants never arrive, for the session,
with no way back. Adopted the shared learner rather than carrying a second,
buggier copy; still fed the window's MIN spacing, which preserves the k×period
resistance the cap was actually aimed at while the streak requirement lets a
genuinely slower panel be discovered. New test: seed 120 Hz, real panel 60 Hz,
the clock must climb back out.

Took the same commit's third lesson too: the adaptive margin widened on a
latch over 1.5×period (a number picked here), and now widens on the latch
exceeding one period plus the lead already applied — the slot actually aimed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:01:56 +02:00
enricobuehlerandClaude Opus 5 b1ac4d02de feat(client/present): the display stat splits, and the intent reaches the settings UI
WP4 + WP5 of design/desktop-presentation-rebuild.md, on top of the WP1/WP2
engine. The engine shipped with no way to choose it and no way to see what it
cost; this closes both.

WP4 — the display stage splits into `pace` (decoded → present-submit, our own
pipeline) + `latch` (submit → on-glass, the presentation queue and the vblank
wait), off the `submitted_ns` stamp WP2 already carried. That split is what
makes a high `display` self-diagnosing: latch dominating is the vsync floor or
a standing queue, pace dominating is us. A `present:` line joins the Detailed
tier naming the live swapchain mode — the answer to most "why is my latch a
whole refresh" questions, since a MAILBOX request silently lands on FIFO
wherever the driver has no mailbox — plus the engine's counters, rendered only
when they are non-zero so a healthy latency session shows just the mode.

Deviation from the plan: the planned `display_adj` twin is NOT here. It was
specified as `display − latch_p50` for parity with the Apple HUD's shaved
figure, but with a real per-sample `pace` percentile that twin is the same
quantity derived worse (subtracting percentiles). `pace` IS the
Apple-comparable number — Apple subtracts its OS present floor, the latch is
ours — and the user docs now say exactly that.

WP5 — Prioritize + Smoothness buffer on all three surfaces: the GTK dialog (a
new Presentation group on the Display page), the WinUI settings page, and the
console settings screen, which is the ONLY editor reachable in Gaming Mode and
so the one that decides whether Deck users can reach this at all. The buffer
control follows the intent the way echo cancellation follows the mic: hidden on
the desktop shells, dimmed and inert on the console, where a row that vanished
mid-list would shift everything under the cursor.

The V-Sync and VRR rows are deliberately NOT here. Their settings exist and are
profile-routed, but the swapchain does not honour them until WP3, and a toggle
that does nothing is exactly how "Full chroma (4:4:4)" shipped inert on desktop
for three releases after being announced.

Buffer labels carry no millisecond hints (Apple/Android derive them from the
session refresh): under a Native mode the shells do not know the refresh at
settings time, so the captions state the cost as one refresh per frame rather
than a confident wrong number.

Docs: the stats page documents the split and the `present:` line, and stops
claiming Linux/Windows measure to the present instant (untrue since
present_wait); client-settings documents both new rows and drops the stale
claim that the desktop 4:4:4 toggle has no effect (it was wired to
VIDEO_CAP_444); configuration documents PUNKTFUNK_PRESENTER and
PUNKTFUNK_PRESENT_DEBUG.

Gates: punktfunk-rust-ci linux/amd64 — fmt, clippy -D warnings over
pf-client-core, pf-presenter, pf-console-ui, the session binary and the GTK
client, 158 tests. The WinUI leg cannot be reached by any Linux or macOS check,
so it was compiled on the Windows runner .133: clippy -D warnings and tests
both exit 0, against a tree proven by content to contain the edit. ⚠ The first
run there reported a false pass — the script printed its done-marker while the
log carried a test failure (a STATUS_DLL_NOT_FOUND launch failure, ffmpeg's
DLLs missing from PATH); the harness now echoes each phase's exit code so the
verdict is a fact in the log rather than an inference from a marker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:01:38 +02:00
enricobuehlerandClaude Opus 5 5f55fa874a feat(client/present): the desktop presenter gains the Apple/Android intent model
WP1+WP2 of design/desktop-presentation-rebuild.md. The shared Linux/Windows
session client presented arrival-paced with no pacing layer at all: two depth-2
newest-wins hops into a drain-to-newest and an immediate present. That IS the
lowest-latency intent, but it was unnamed, unselectable, and had no alternative
— and on a surface without MAILBOX (AMD's Windows driver offers none, and any
compositor holding images does the same) the swapchain's own FIFO becomes a
standing queue worth a measured 11-13 ms at 60 Hz.

WP1 — the settings cluster, under the keys the Apple client already writes into
the shared profile catalog (present_priority / smooth_buffer / vsync /
allow_vrr): mismatched names would ride SettingsOverlay::extra, carried but
never applied. PresentPriority::resolve mirrors the Android reference exactly
(anything but an explicit "smooth" is latency; a buffer outside 1..=3 becomes
2), so a profile authored on any client means the same thing on all of them.
Only the first two are consumed here; vsync/allow_vrr land in WP3.

WP2 — the engine (present_pace.rs, pure state + arithmetic, 6 tests):
- FrameStore: newest-wins slot, or the smoothing FIFO with preroll-to-capacity,
  drop-oldest overflow, and an underflow that re-arms the preroll (repeat by
  omission) — the Apple/Android semantics, with qDrop/qDry counters.
- LatchClock: the panel grid learned from VK_KHR_present_wait glass stamps,
  min positive spacing capped by the mode refresh (measured, never queried —
  VRR and Android's per-uid refresh lie both punish trusting a reported rate).
  It now also publishes the host-facing LatchGrid, so the phase-lock report and
  the local scheduler cannot disagree about the grid.
- PresentGate: one undisplayed present in flight on FIFO surfaces, with the
  100 ms stale force-open. This is the standing-queue killer, and it is inert
  on MAILBOX/IMMEDIATE and without present timing — where behaviour stays
  byte-for-byte the shipped arrival pacing.

Wiring: glass samples drain every pass (a 1 Hz batch would starve clock and
gate) and the waiter pushes an SDL wake, so a gate reopen never waits out the
event timeout; smoothness serves one frame per latch slot and tightens the
loop's wait to that deadline; the adaptive slot margin starts at 0 and widens
+500 us per missed window toward 2.5 ms (a fixed lead was measured to be pure
display tax). PUNKTFUNK_PRESENTER=arrival disables the whole engine for field
A/B without a rebuild.

PyroWave collapses smoothness to latency for the stream: its plane-ring
retirement accounting assumes the depth-2 newest-wins hand-off, and all-intra
frames make buffering moot anyway.

Gates (punktfunk-rust-ci, linux/amd64, sources touched first so a warm target
cannot print a vacuous Finished): clippy -D warnings across pf-client-core,
pf-presenter and punktfunk-client-session; 80 + 32 tests pass; rustfmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:00:50 +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 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
enricobuehler 23f1debe69 Merge remote-tracking branch 'origin/main' into audio/mic-latency-echo 2026-08-01 00:21:24 +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 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 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 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 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 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
enricobuehler 65cd388a52 fix(presenter,core): close the last of the proof-lint hole — the Vulkan contract, stated once
pf-presenter's 120 sites are `ash` calls almost without exception, so 120 independent arguments
would have been 120 restatements of the signature — the exact noise this program exists to remove.
They get the `abi.rs` treatment instead: the Vulkan contract stated once in `lib.rs`, each site
naming which of three shapes it is.

The three are not equal, and separating them is the point. CREATE and RECORD carry no real
precondition — the device is owned, the builders are locals, nothing executes until submit. DESTROY
does: the GPU must not still be using the object, and that is established by the path (a fence wait,
a `queue_wait_idle`, a retired swapchain), not by the call. Those sites say so, because getting it
wrong is a use-after-free no type catches. The contract also tells the next person that a block
outside the three shapes needs a real proof, and that writing "as above" is the signal it doesn't
belong in them.

punktfunk-core's Windows half is finished here too: `qos_windows.rs`'s `GetLastError` reads (called
before anything can reset the thread's error slot) and `udp/windows.rs`'s control-message write,
whose argument is that `ctrl` is sized by `WSA_CMSG_SPACE(4)` — computed two lines up — so header
plus payload cannot run past it, and `write_unaligned` is used because `WSA_CMSG_DATA` offers no
alignment guarantee.

⚠️ THE WINDOWS BLIND SPOT BIT A THIRD TIME. A Linux measurement put this crate pair at 113; the
real number was 129 — `d3d11.rs`, `win32.rs`, `qos_windows.rs`, `udp/windows.rs` are all
`cfg`-hidden. Every crate in this sweep had to be finished on .47 after being "done" on .21. For a
cross-platform crate the Linux number is a lower bound, never the answer.

All three crates now deny `undocumented_unsafe_blocks`, which was the goal: it applied to 8 of 11
crates carrying unsafe, and the three exempt ones held 381 items between them — including the C ABI
surface and the presenter. Verified: Linux .21 fmt + both CI clippy steps rc=0; Windows .47 all four
closed crates clippy `-D warnings` rc=0 plus the full Windows CI clippy set and pf-capture's tests.
Only small crates remain unguarded (75 items total, largest 26).
2026-07-29 08:48:42 +02:00
enricobuehlerandClaude Opus 5 a2be3f4de9 feat(client): settings profiles — the override catalog, the one resolver, and the session flag
A client setting is global today: pick 4K@120 HDR and the retro box gets it too.
The one escape hatch (per-host `clipboard_sync`) proves the shape works but covers a
single field. This is P0 of design/client-settings-profiles.md — the headless core the
shells, Apple, Android and the deep-link grammar all build against. No UI yet.

A profile is a NAMED BUNDLE OF OVERRIDES, not a snapshot: `SettingsOverlay` is sparse
`Option`s, so a field the user never touched keeps following the global value live, and
fixing a global once fixes it everywhere. `Some(x)` equal to today's global is still
meaningful — it pins x against a later global change — which is why the UI will write
`Some` on touch and `None` on an explicit reset, never by diffing.

The catalog is its own `client-profiles.json`, deliberately not part of the settings
file: that file has five whole-file load-modify-save writers with no merge, so a profile
written by one would be dropped by the next. Which host uses which profile is a field on
the host record instead of a map keyed by host — that is what dissolves the client's
long-standing "what identifies a host record" question for this feature.

Resolution has exactly one implementation, `trust::effective_settings()`:

    effective = overlay(profile).apply(global)
    profile   = one-off pick ?? host binding ?? none

Both session construction sites go through it (`main.rs` AND `console.rs` — touching one
and not the other is a Windows-only build break), so the console, and therefore Decky,
honor host bindings with no work of their own. Nothing here can fail a connect: a
deleted binding resolves as "no profile", and a one-off that can't be honored falls back
to the DEFAULTS rather than to the host's own profile — "connect with Work" must never
quietly stream "Game". `--profile <id|name>` is the one-off door for the shells' coming
"Connect with ▸" and for `punktfunk://…&profile=`; `--profile ""` forces the defaults on
a bound host. The active profile's name closes the stats overlay's first line, so
"which profile am I on?" is answerable without leaving the stream.

Also here, because this effort touches every one of these anyway:

- `KnownHost` gains `profile_id`, `pinned_profiles` and a lazily minted stable `id`. The
  id is groundwork (design §4.5) — no lookup is re-keyed, `fp_hex`/`addr:port` stay.
- `upsert` now preserves the state the USER set on a record — the profile binding, its
  pins, the clipboard decision, the id — against refreshes that carry none of it.
  `clipboard_sync` survived that only because the function happened not to mention it.
- `KnownHost::default()` exists so construction sites read `..Default::default()`; five
  hand-written literals were exactly how a new field gets silently dropped on re-pair.
- All three client stores now write temp+rename. A torn settings file loads as `Default`
  — i.e. silently resets every setting the user has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:22:33 +02:00
enricobuehler 5ea087ca47 feat(session): the stats overlay scales with the display's DPI
The stream chrome — stats OSD, capture hint, start banner, resize label —
was hardcoded at 14 px with 12/10/8 px insets. The overlay composites into
the swapchain 1:1 in PHYSICAL pixels, so on a 4K panel at 200 % all of it
rendered at half its intended physical size: fine on a 1080p monitor, a
squint on a HiDPI laptop.

FrameCtx now carries a scale — SDL's window display scale (DPI × the
display's content scale) times a PUNKTFUNK_OSD_SCALE preference — and every
metric moved into a `base` module that is multiplied by it. Re-read per
frame and quantized into the damage key, so dragging the window to a
differently-scaled monitor re-renders at the new size rather than keeping
the stale one. Sanitized because SDL returns 0.0 when it cannot resolve the
window's display, which would collapse the panel to nothing.

Two details worth keeping: the face is re-derived at the scaled size rather
than the canvas transformed, because Skia rasterizes glyphs at the
requested size where a magnified 14 px bitmap would be mush; and the long
capture hint is fit-clamped to the window — at 2× it is wider than a 1080p
screen, so scaling it naively would have traded a small OSD for a truncated
one. Linux and Windows share this path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 744467d13a28b91ba88a23d6038da70263e9b502)
2026-07-28 17:01:59 +02:00
enricobuehlerandClaude Opus 5 e5e68f1d24 feat(presenter): DRM card selection + a usable kmsdrm swapchain error
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m15s
apple / swift (push) Successful in 5m25s
ci / bench (push) Successful in 7m44s
deb / build-publish-host (push) Successful in 10m30s
decky / build-publish (push) Successful in 30s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
android / android (push) Successful in 12m52s
deb / build-publish (push) Successful in 11m46s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
arch / build-publish (push) Successful in 13m6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 45s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
ci / rust-arm64 (push) Successful in 13m32s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m1s
deb / build-publish-client-arm64 (push) Successful in 9m12s
windows-host / package (push) Successful in 18m4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7m1s
flatpak / build-publish (push) Successful in 7m25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m46s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 9m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 9m24s
ci / rust (push) Successful in 23m48s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m28s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m46s
docker / deploy-docs (push) Successful in 22s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m42s
release / apple (push) Successful in 31m30s
docker / build-push-arm64cross (push) Successful in 7m6s
apple / screenshots (push) Successful in 23m4s
Both come straight out of the first compositor-less bring-up (P3), on a
two-GPU box:

PUNKTFUNK_DRM_CARD=<n> pins SDL's KMSDRM device index. SDL enumerates
/dev/dri/card* and takes the first it can open, which is regularly the
wrong one: it chose the card a live compositor already held DRM master on
and died at swapchain creation, while the idle card with the connected
display sat unused. Kept an explicit operator choice rather than
auto-detection — deciding "is this card already mastered" needs the very
ioctl that taking master IS, so any in-process guess would be fragile.

The swapchain error now carries what to actually check. "vkCreateSwapchainKHR:
Initialization of an object has failed" is useless to someone bringing up a
kiosk; on the kmsdrm backend it now names the pinned card and lists the
three real causes in order (no connected connector / another DRM master /
NVIDIA). Empty on every other backend, where it would be noise.

The NVIDIA note is measured, not guessed: on NVIDIA proprietary + kmsdrm,
Vulkan enumerates the GPU AND the display (VK_KHR_display reports the
connected HDMI connector) and still fails — as root, with
nvidia_drm.modeset=Y, on a card no compositor was using. Not permissions,
not DRM master; their direct-display path wants the display leased via
vkAcquireDrmDisplayEXT and SDL's kmsdrm surface path does not do that.

Plan: punktfunk-planning design/embedded-arm64-client.md §P3

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 14:32:45 +02:00
enricobuehlerandClaude Opus 5 b7cea71bbd fix(presenter): type the Vulkan extension pointers as c_char, not i8
`char` is signed on x86_64 but UNSIGNED on aarch64, so the hardcoded
`Vec<*const i8>` compiles on every desktop target and then fails to match
ash's `&[*const c_char]` when cross-compiling the client for ARM.

Found by the aarch64 cross-build spike (punktfunk-planning:
embedded-arm64-client.md) — the only portability defect in the client
stack; nothing else in the client crates assumes an architecture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 14:32:45 +02:00
enricobuehlerandClaude Opus 4.8 6cbc3eec8b fix(sdl/cursor): scale the forwarded pointer to the video fit
apple / swift (push) Successful in 1m30s
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m3s
android / android (push) Canceled after 1m27s
apple / screenshots (push) Canceled after 1m39s
arch / build-publish (push) Canceled after 1m29s
ci / rust (push) Canceled after 1m29s
ci / bench (push) Canceled after 1m17s
deb / build-publish (push) Canceled after 1m15s
deb / build-publish-host (push) Canceled after 27s
decky / build-publish (push) Canceled after 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
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
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 1m18s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 8s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
flatpak / build-publish (push) Canceled after 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / deploy-docs (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 17s
Parity with the Apple client (35c2c145): the SDL/Linux presenter built the
host-forwarded cursor as an OS cursor at its native bitmap size, so the
on-screen pointer tracked the HOST's display scaling — a 4K/high-DPI virtual
display forwards a 96 px bitmap that then rendered huge on the client.

SDL cursors are fixed-size from their surface (no draw-time scaling), so
cache the shapes RAW and resample each by the live video-fit factor
(min(window_px / mode) — the same aspect-fit `finger_to_content` uses) at
install time, rebuilding when the serial OR the fit changes (a resize). The
resample area-averages on premultiplied alpha so a transparent pixel's
colour can't bleed into the fringe. The pointer now stays sized to the
streamed desktop at any host scaling.

Not yet eyeballed on a Deck/Linux box — SDL's HiDPI cursor sizing is
version-dependent; validate on glass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 16:28:34 +02:00
enricobuehlerandClaude Opus 4.8 92761813a9 feat(core+host+driver+client): mid-stream cursor-render flip — host composites in the capture model
The cursor channel excluded the host pointer from the video for the
whole session, but the client only draws it under the DESKTOP mouse
model — flipping ⌃⌥⇧M to capture mid-stream left the session
pointer-blind (user-found). The fix makes render ownership LIVE state:

- wire: CursorRenderMode 0x51 (client→host, control stream) —
  client_draws: true = desktop model (host excludes + forwards),
  false = capture model / released (host composites, full fidelity —
  DWM on Windows incl. real XOR inversion, encoder blend on Linux).
  Sessions start client_draws=true (the pre-message behavior).
- host: control task stores the flag; the encode loop edge-detects it —
  forwarding + frame.cursor strip while the client draws, quiet
  forwarder + overlay-into-blend while composited. SessionPlan now
  grants blend CAPABILITY wherever the capture has a pointer (dropping
  the !cursor_forward gate) so the composite side needs no encoder
  rebuild; per-tick frame.cursor decides what is drawn.
- Windows: Capturer::set_cursor_forward → retained
  IOCTL_SET_CURSOR_FORWARD sender (proto v6) → driver (un)declares the
  IddCx hardware cursor on the LIVE monitor. Un-declare re-issues the
  setup with EMPTY caps (candidate mechanism — the DDI has no
  documented un-setup); resetup-on-commit is skipped while off, so a
  mode commit's software-cursor default is the fallback path. Failure
  against a pre-v6 driver logs and keeps declared-at-ADD behavior.
- SDL client: one edge-detected reconciler at the cursor pump site —
  desktop-active = client draws; capture model or released = host
  composites. Covers the chord, the M3 auto-flip, and engage/release.
- C ABI v12: punktfunk_connection_set_cursor_render (additive; wire
  version unchanged — pre-§8 hosts ignore the unknown message type).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:43:23 +02:00
enricobuehlerandClaude Fable 5 c3cbffe662 feat(host+client): host-driven mouse-model flip — remote-desktop sweep M3
The full Parsec model: launching a game from a desktop session flips the
client into captured relative automatically, and back — no chords.

- Capture overlay now distinguishes 'hidden' from 'no cursor yet':
  CursorOverlay grows a visible flag, overlay() returns Some whenever a
  bitmap is known (the encode loop strips invisible overlays after
  forwarding so no blend path ever draws one; the CPU composite guarded
  on visibility already).
- Host forwarder maps it onto the reserved wire bit: visible ⇒ VISIBLE,
  hidden-but-known ⇒ RELATIVE_HINT (an app grabbed/hid the pointer),
  never any hint before the first bitmap — a cold start can't flip a
  desktop session into capture.
- Presenter: edge-triggered auto-flip on hint changes via the new
  Capture::set_desktop (chord semantics untouched); a manual ⌃⌥⇧M sets
  an override latch that holds until the HOST's intent next changes, so
  the hint never fights the user. Leaving relative warps the local
  cursor to the host's last pointer position through the new
  content_to_window inverse-letterbox mapping (unit-tested against
  finger_to_content) — the hand-back is seamless.

Verified on .21: fmt + clippy -D warnings + tests (presenter 20 incl.
the inverse-mapping roundtrip, host 245).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 23:43:23 +02:00
enricobuehlerandClaude Fable 5 4a01bc4463 feat(core+host+client): cursor channel — remote-desktop sweep M2a+M2b
The host cursor stops riding the video and becomes a real OS cursor on
the client (the Parsec/RDP model): pointer feel no longer pays the
capture→encode→network→decode→present round trip.

Wire (M2a):
- Hello grows a client_caps trailing byte (CLIENT_CAP_CURSOR) after the
  fixed display_hdr block — presence disambiguated by remaining length,
  which caps the post-HDR tail at 27 bytes (documented); Welcome answers
  HOST_CAP_CURSOR (capable-and-asked, the 444/clipboard precedent).
- CursorShape (0x50, control stream): serial + dims + hotspot + straight
  RGBA, ≤120px/side so the u16 frame always fits (128² would overshoot);
  client caches by serial — re-showing a known shape costs 14 bytes, not
  a bitmap (RDP pointer-cache for free).
- CursorState (0xD0 datagram): serial + visible/relative_hint flags +
  position, sent once per encode-loop tick — latest-wins, self-healing
  under loss, no refresh timer. relative_hint is reserved for M3.
- Client core: two new planes (control-task + datagram-task arms) →
  next_cursor_shape/next_cursor_state; connect() grows client_caps
  (C ABI passes 0 until the v11 cursor poll fns exist).

Host (M2b, Linux portal only):
- handshake::cursor_forward is THE predicate (client asked ∧ Linux ∧
  compositor ≠ gamescope) — Welcome bit and session wiring both read it.
- SessionPlan.cursor_blend goes false for a forwarding session; the
  encode loop ticks a CursorForwarder every iteration: shape-serial diff
  → control-task bridge (mirrors probe_result), state datagram → conn.
- CursorOverlay/capture CursorState carry the hotspot through
  (nearest-neighbor downscale backstop for XL cursors, unit-tested).

Presenter:
- CursorChannel drains both planes per loop iteration; shapes become
  SDL color cursors (from_surface + hotspot), applied while the desktop
  mouse model is engaged; visibility follows the host; capture/released
  hands back the system cursor. Sessions advertise the cap when they
  START in desktop mode.

Verified on .21: fmt + clippy -D warnings (7 crates) + tests green
(core 218 incl. new wire roundtrips, host 245 incl. e2e + forwarder
downscale tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 23:43:23 +02:00
enricobuehlerandClaude Fable 5 df04880273 feat(client/input): desktop (absolute) mouse mode — remote-desktop sweep M1
New physical-mouse model beside capture: Desktop leaves the pointer
uncaptured and sends absolute positions through the letterbox
(MouseMoveAbs — every host injector already consumes it). Capture
stays the default and the game model.

- pf-client-core: MouseMode (capture|desktop) persisted in Settings,
  default capture so existing stores are unchanged.
- pf-presenter: Capture grows a desktop model — latest-wins pending
  abs position coalesced per loop iteration (same 1000 Hz discipline
  as relative), flushed before clicks/keys/wheel so they land where
  the cursor is; Ctrl+Alt+Shift+M flips the model live; local cursor
  stays hidden over the window (the host's composited cursor is the
  one you see until the M2 cursor channel); Windows keyboard grab
  only engages for capture (a desktop stream is something you
  Alt-Tab away from).
- gamescope gating: its EIS is relative-only, so desktop mode is
  pinned off there (resolved_compositor), with a log note.
- Settings surfaces: GTK row (dynamic caption), WinUI combo,
  console-UI row + step test, capture-hint line.

Verified: fmt + clippy -D warnings + tests (33/40/19) on Linux .21;
clippy -D warnings for all five crates incl. punktfunk-client-windows
native on .173.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 23:37:32 +02:00
enricobuehlerandClaude Fable 5 49c6dd00c9 fix(presenter): CPU frames never take the HDR10 passthrough surface
audit / bun-audit (push) Failing after 15s
ci / docs-site (push) Successful in 56s
ci / web (push) Successful in 58s
apple / swift (push) Successful in 1m22s
audit / cargo-audit (push) Successful in 2m20s
decky / build-publish (push) Successful in 32s
ci / bench (push) Successful in 7m0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7m18s
release / apple (push) Successful in 9m28s
deb / build-publish-host (push) Successful in 10m32s
deb / build-publish (push) Successful in 11m48s
docker / deploy-docs (push) Successful in 23s
android / android (push) Successful in 14m36s
arch / build-publish (push) Successful in 15m4s
windows-host / package (push) Successful in 16m11s
flatpak / build-publish (push) Failing after 8m19s
apple / screenshots (push) Successful in 6m40s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m11s
ci / rust (push) Successful in 19m38s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m22s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m39s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m39s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m36s
Software decode uploads swscale RGBA with no CSC/tonemap pass. On the new
desktop-compositor HDR10 surfaces (GNOME 48 / Plasma 6 + Mesa >= 25.1 offer
ST2084 even on SDR desktops) that sRGB-encoded content was composed as PQ —
the field-reported psychedelic picture (Fedora clients decode HEVC in
software because stock Mesa strips it; reproduced + fix live-validated on
GNOME Wayland: mode-0 soup -> HDR→SDR washed-out-but-correct).

The CPU lane keeps the SDR swapchain and its known untonemapped-PQ gap; a
real PQ→sRGB pass for CPU frames is the follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:30:35 +02:00
enricobuehlerandClaude Fable 5 e7c6c96e5f fix(presenter): set the Wayland app_id so the session window gets its icon
SDL_APP_ID = io.unom.Punktfunk — compositors match it against the shipped
.desktop for the window icon; without it KWin/mutter show the generic Wayland
icon (field-noticed on the Deck). Linux analog of win32 AppUserModelID.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:30:35 +02:00
enricobuehlerandClaude Fable 5 da134ad045 feat(presenter): PUNKTFUNK_HDR10=0 refuses the HDR10 swapchain (SDR tonemap pin)
GNOME 48 / Plasma 6 with Mesa >= 25.1 now offer HDR10_ST2084 surfaces even on
SDR desktops, silently flipping PQ streams into the mode-0 passthrough lane —
previously unreachable on desktop compositors and never validated there. The
hatch pins the shader tonemap for diagnosis and as the field escape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:30:35 +02:00
enricobuehlerandClaude Fable 5 40c2f68231 feat(client): device-pick plumbing — GPU enumeration + audio endpoint targeting
The Settings GPU pick existed (adapter → PUNKTFUNK_VK_ADAPTER) but no Linux
shell could enumerate anything to pick: the GTK shell deliberately links no
Vulkan. pf_presenter::vk::list_adapters() reads the physical devices'
marketing names (no surface, discrete first, deduped — the name is the whole
match key in pick_device), surfaced as `punktfunk-session --list-adapters`.

Audio gets the same treatment for the new speaker_device/mic_device settings
(PipeWire node.name; empty = default): session main maps them onto
PUNKTFUNK_AUDIO_SINK/SOURCE — a hand-set env still wins, like the adapter —
and the playback/mic streams pass them as `target.object` (raw key: the
keys::TARGET_OBJECT constant is feature-gated on a newer libpipewire than we
require). pf_client_core::audio::devices() is the registry roundtrip the
pickers read, exposed for debugging as `punktfunk-session --list-audio`.
The WASAPI leg (Windows endpoint IDs) is still to come; the fields are
ignored there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 19:32:38 +02:00
enricobuehlerandClaude Fable 5 51f7ff1b3f feat(client/windows): invert scroll direction
The Apple client's toggle, wired through the shared session presenter:
Settings::invert_scroll -> SessionOpts -> Capture, applied at the single
seam where wheel deltas enter (before accumulation, so the fractional
remainders stay consistent with what was actually sent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 19:06:06 +02:00
enricobuehlerandClaude Fable 5 1ef0229bd9 feat(client/windows): PyroWave decode + surface it in the GUI codec picker
The decoder was gated to Linux because, when it landed, the Windows client
still had its own in-process WinUI/D3D11 presenter and the PyroWave present
path there was an open question. That client has since been retired: Windows
now spawns the SAME Vulkan session presenter as Linux, and the decoder is
plain Vulkan compute on the presenter's device (no fds, no dmabuf, no D3D11
interop), so the question that gated it answered itself. pyrowave-sys already
builds on Windows too -- the Windows HOST encoder ships on it.

So this is a port by un-gating: every cfg(all(target_os = "linux", feature =
"pyrowave")) becomes any(linux, windows) -- decoder module, backend variant,
Decoder::new_pyrowave, the CODEC_PYROWAVE advertisement, the session pump's
opt-in/build/label arms, and the presenter's planar CSC pass. No new code.

Then offer it in the Windows GUI, which is what prompted this. It stays
preference-only (resolve_codec never auto-picks it) and a host or device that
can't do PyroWave just falls back down the ladder to HEVC.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 19:06:06 +02:00
enricobuehlerandClaude Fable 5 fb8deb31a5 feat(client): HDR pass-through on the D3D11VA path
A PQ stream on the D3D11VA backend was always tone-mapped to sRGB by the
VideoProcessor — with D3D11VA now auto's first choice on Intel (40030e90),
Intel Windows users would have lost HDR entirely. When the presenter can
import an RGB10A2 D3D11 texture AND offers an HDR10 swapchain (the new
VulkanDecodeDevice::d3d11_hdr10 probe), the hand-off ring switches to
RGB10A2 and the VideoProcessor does a pure colorspace conversion (YCbCr
G2084 -> RGB G2084, no tone mapping); the emitted frame carries PQ/BT.2020
color, so the presenter flips its HDR10 swapchain and video image exactly
as it does for Vulkan Video PQ frames, and the blit passes the PQ values
through untouched. SDR-only paths keep the tonemap-to-sRGB BGRA8 ring;
in-band PQ flips rebuild the ring like a resize (generation-bumped).

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