d3aaa16a7daaccf58604f14a77953134e4e0be0f
1570
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d3aaa16a7d |
Merge branch 'worktree-wave2-pw3-dmabuf-latch' into worktree-wave2-pyrowave
# Conflicts: # packaging/arch/punktfunk-host.install # scripts/steamdeck/install.sh |
||
|
|
2dd65bdd41 |
Merge pull request 'Opening the Steam menu on the Deck moved the game too — the pad is now held neutral while an overlay owns it' (#131) from worktree-deck-overlay-input-mask into main
apple / swift (push) Successful in 1m38s
audit / cargo-audit (push) Successful in 1m45s
audit / bun-audit (plugin-kit) (push) Successful in 37s
audit / bun-audit (sdk) (push) Successful in 30s
audit / bun-audit (web) (push) Failing after 26s
audit / docs-site-audit (push) Successful in 26s
audit / pnpm-audit (push) Successful in 11s
arch / build-publish (push) Successful in 9m2s
ci / rust-arm64 (push) Successful in 3m6s
android / android (push) Successful in 10m27s
audit / license-gate (push) Successful in 5m59s
ci / web (push) Successful in 1m30s
ci / bun-nix (push) Successful in 58s
ci / docs-site (push) Successful in 1m56s
release / apple (push) Successful in 9m51s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m52s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 31s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
windows-host / package (push) Successful in 12m31s
windows-host / winget-source (push) Skipped
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
apple / screenshots (push) Successful in 5m48s
deb / build-publish-client-arm64 (push) Successful in 6m51s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m35s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m45s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m13s
deb / build-publish-host (push) Successful in 11m34s
ci / rust (push) Canceled after 18m48s
deb / build-publish (push) Canceled after 12m54s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 8m0s
nix / flake (push) Canceled after 8m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 8m2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 5m37s
windows-host / canary-manifest (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 1m30s
Reviewed-on: #131 |
||
|
|
5cbaca7789 |
feat(client/pads): stop forwarding the pad while the Steam overlay owns it
ci / bun-nix (pull_request) Successful in 32s
ci / docs-site (pull_request) Successful in 1m21s
ci / web (pull_request) Successful in 1m37s
apple / swift (pull_request) Successful in 1m41s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m19s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 5m50s
android / android (pull_request) Successful in 7m56s
ci / rust (pull_request) Successful in 15m55s
nix / flake (pull_request) Failing after 16m33s
On a Deck in Gaming Mode the Steam menu and the QAM are driven by the SAME physical controller the client forwards, so opening either one moved the game on the host as well as Steam's UI — a second, invisible player. Steam Input masks a normal game here; it cannot mask us, because masking happens on Steam Input's virtual pad and we deliberately forward the REAL one (28DE:1205 — the virtual pad has no gyro, trackpads or paddles). SDL ships the exact behaviour we want and it is on by default: presses are dropped while the process has windows but no keyboard focus, releases still get through. It CANNOT fire on a Deck. gamescope resolves focus per Xwayland ctx and the client sits alone in its own, so the Steam overlay — which lives in the root ctx — never takes our X focus away and no FocusOut is ever generated. Measured on glass: with the QAM open, X input focus inside the client's ctx stayed on its window for the whole 4 s, while GAMESCOPE_FOCUSED_APP flipped to 769 (Steam) and GAMESCOPE_FOCUSED_APP_GFX stayed on the app. So the signal is explicit. `overlay_focus` watches those two atoms on the gamescope root ctx — which is NOT our own $DISPLAY under `--xwayland-count 2`, hence the socket-directory walk and the flatpak filesystem line — and the presenter ORs it with window focus into one `set_masked`. Masking is deliberately not `set_forwarding`: that closes the slot and sends GamepadRemove, so the game would see a controller UNPLUG every time somebody opened the QAM. This keeps every slot open and only stops the transitions, after flushing what the host believes is held so a stick deflected at overlay-open stops steering instead of freezing at its last value. On the way back, held buttons are adopted rather than replayed — the A that picked a QAM row must not fire in the game as it closes — while axes are re-sent, since a stick has no press to ghost and SDL only speaks on change. Fails open throughout: no gamescope, no X, or an unreadable signal all leave forwarding exactly as it was. `PUNKTFUNK_OVERLAY_MASK=0` opts out. |
||
|
|
78ba2342b5 |
Merge pull request 'rom-manager has been putting 0 games in the library since 08-05 — a plugin launch kind, so a scanner can publish tiles the host cannot name' (#129) from worktree-rom-manager-plugin-launch into main
apple / swift (push) Successful in 1m39s
ci / web (push) Successful in 1m45s
ci / docs-site (push) Successful in 1m35s
ci / bun-nix (push) Successful in 42s
ci / rust-arm64 (push) Successful in 2m45s
deb / build-publish-client-arm64 (push) Successful in 1m4s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
deb / build-publish (push) Successful in 3m55s
android / android (push) Successful in 6m31s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Successful in 30s
apple / screenshots (push) Successful in 6m8s
ci / rust (push) Canceled after 8m47s
deb / build-publish-host (push) Successful in 8m5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 5m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 4m57s
windows-host / package (push) Successful in 11m52s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 17s
arch / build-publish (push) Successful in 13m1s
Reviewed-on: #129 |
||
|
|
9e598f8595 |
fix(client): the 4:4:4 switch could cost a Deck its whole codec, and --probe-decode denied the queue it was decoding on
ci / bun-nix (pull_request) Successful in 41s
ci / web (pull_request) Successful in 1m17s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m21s
apple / swift (pull_request) Successful in 1m35s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 3m39s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m22s
android / android (pull_request) Successful in 3m56s
ci / rust (pull_request) Successful in 13m6s
Two Steam Deck findings from a field report of "the decoder was not found, it fell back to H.264 — but sometimes HEVC worked". **The 4:4:4 advertisement was a promise nothing checked.** `VIDEO_CAP_444` rode the "Full chroma" setting alone. That was safe while a software HEVC decoder existed underneath it; M8 removed one (there is no permissively licensed HEVC CPU decoder, so `software_decodable_codecs()` is H.264|AV1). The host grants 4:4:4 on HEVC ONLY, and answers the resolved chroma in the Welcome before the client builds a decoder — so on a device with no 4:4:4 decode the toggle did not cost crispness, it cost the entire codec: the Vulkan rung refuses the shape at construction, VAAPI refuses it too, there is no CPU rung, and the session reconnects on H.264. AMD has no HEVC 4:4:4 decode on any silicon, so every Deck with that switch on lost HEVC. It is per-profile and default-off, which is exactly why it looked intermittent — a "Work" profile lost HEVC where "Game" kept it, same box, same host. Gated on `hevc_444_hardware_decodable`, which asks the driver through the SAME code the rung uses at construction (`VkH265Decoder::probe_stream_support`), so the advertisement and the rung that must honour it cannot disagree. Both depths are required, not either: with HDR on the host may resolve 4:4:4 10-bit, and a device offering YUV444_8 but not YUV444_10 would land in the same hole. Answering from the Vulkan rung alone is exact rather than approximate — it is the only rung in this build that implements 4:4:4 at all (`pf_vaadec::profile_for` errors on chroma_format_idc 3, pf-dxvadec refuses anything but 4:2:0, the CPU rung is 8-bit 4:2:0). Deliberately NOT extended to VIDEO_CAP_10BIT/HDR: all three rungs implement 10-bit 4:2:0, so a Vulkan-only probe there would withdraw HDR from boxes whose VAAPI/DXVA rung decodes it perfectly — a real regression against a case never observed. The bit arithmetic moves into `video::video_caps_for` so the part that was wrong is testable without a GPU, a host or a Hello; the test is verified non-vacuous against the planted original defect. **`--probe-decode` described a different device from the one that streams.** The RADV video-decode opt-in sat AFTER the --list-adapters/--probe-decode/--list-audio /--pair early exits, so the triage tool never had it. Measured on a Deck (canary e22af40f), same binary back to back: bare `--probe-decode` printed "vulkan video decode: no", "driver decode ops: none (0x0)", "no queue family advertises VIDEO_DECODE"; with RADV_PERFTEST=video_decode in the environment, "YES" and "H.264, H.265, AV1, VP9". Any Deck triage that consulted it reached the opposite of the truth. Hoisted to the top of `run`, ahead of every early exit — nothing touches Vulkan before it (`main` calls `run` directly). Gates, in the Linux container: fmt, plain `cargo build` (not only --all-targets), `clippy --all-targets -D warnings`, and 185 tests. |
||
|
|
4f5ca5f9bc |
docs(pf-capture): KWin/RADV was the last untested producer — it has no fence either
Closes PW4's one remaining gap. The Steam Deck switched to Desktop Mode gives KWin on RADV, the combination none of the earlier legs covered, and it reports no implicit fence like every other: gamescope + NVIDIA (RTX 5070 Ti) NoFence Mutter + NVIDIA (RTX 5070 Ti) NoFence gamescope + RADV (Deck VANGOGH) 300/300 NoFence, mean 23us, p99 <=100us KWin + RADV (Deck desktop) no fence (older build's wording: waited=false) That is every compositor x vendor this fleet has. PW4 retires with no outstanding doubt rather than "probably fine except one box we never tried". Measured with the Deck's OWN already-authorized binary rather than a scratch build, because KWin grants zkde_screencast_unstable_v1 per EXECUTABLE PATH: it resolves /proc/<pid>/exe against a .desktop's Exec= and caches the grant on first connect, so an unregistered path is refused outright and registering one needs a re-login. The fence probe is pre-existing capture-path code, so a build from July answers the outcome question perfectly well — and nothing of the user's was modified to get it. Comment-only; no behaviour change. fmt + pf-capture clippy -D warnings green. |
||
|
|
5872dfc649 |
feat(library): a plugin launch kind, so a scanner can publish tiles the host cannot name
apple / swift (pull_request) Successful in 1m40s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m34s
ci / web (pull_request) Successful in 3m46s
ci / bun-nix (pull_request) Successful in 54s
ci / rust-arm64 (pull_request) Successful in 5m54s
android / android (pull_request) Successful in 7m29s
ci / rust (pull_request) Successful in 21m2s
The 2026-08-05 review made `launch.kind = "command"` operator-only, and a reconcile refuses
on the FIRST offending entry — so rom-manager, whose every ROM is `<emulator> <args> <rom>`,
stopped putting anything in the library at all. Playnite hit the same wall and was rescued
with a typed kind the host resolves itself; there is no fixed scheme for "whichever emulator
the operator configured, with the core and flags they chose", so that trick does not
generalise.
So the entry now carries an opaque key and nothing executable, and the host asks the plugin
that owns it what to run — at launch time, over the loopback UI port and per-boot secret it
already registered. A stolen plugin token stops being command execution: planting an entry is
not enough, because the live plugin answers 404 for a key it never published. Nothing
executable is persisted or served to a client, and an emulator that moved is picked up on the
next launch instead of leaving a dead tile (the same reasoning as `xbox` resolving its AUMID
at launch time).
The host still SPAWNS it, because only the host can put the process where the stream can see
it: on Linux the line is either gamescope's own argv or a spawn carrying the session's
compositor env, and the returned child is what session-game-lifetime tracks to know the game
exited. A plugin spawning the emulator itself would land it outside both.
- library/plugin_launch.rs — the ask: blocking ureq, bounded body, absolute cwd, no control
characters, and a log line for every way it can come back empty
- library/launch.rs — `plugin_recipe` tried before both per-OS resolvers, plus
`launch_is_resolvable` so the async handshake probe never makes the blocking call
- native.rs — the session's `resolve_launch` moves onto `spawn_blocking`
- plugin-kit — `serveUi({launch})` serves `POST /__launch`; and `SyncError` finally renders
its cause, which is why a host refusal with a fully explanatory 403 could reach a plugin's
own UI as nothing but "Decode error"
|
||
|
|
9232631299 |
feat(pf-encode): PyroWave had no encode split — so the one cost this program protects was unmeasurable
Wave-2 PW1's exit criterion, and the instrument it needed. VAAPI and direct NVENC both log a PUNKTFUNK_PERF submit split. PyroWave did not — which meant the single encoder the GPU-priority work exists to defend was the one you could not put a number on. Adds per-frame timing of the synchronous encode (whole `submit`: CSC + encode + fence wait + packetize, which for this backend IS the encode), summarised every 2 s as mean/p50/p99/max. p99 rather than mean-only on purpose. The failure patch 0005 describes is a TAIL event — frames going ~2 ms to 15-18 ms at 95 % game load while the mean barely moves — so a mean-only readout would report "fine" straight through the thing being measured. WHAT IT MEASURED — .21, RTX 5070 Ti (610.57.04), GRID 2 benchmark loop saturating the GPU at 54-87 %, PyroWave 1080p, same binary both arms (only CAP_SYS_NICE differs), 30-frame windows with the warm-up window dropped: arm p50 p99 worst frame default priority (refused) ~2.6 ms ~6.4 ms 9.5 ms REALTIME granted ~3.2 ms ~4.4 ms 5.4 ms REALTIME granted (repeat) ~3.35 ms ~4.8 ms 5.1 ms p99 down ~30 %, worst frame roughly halved, for ~0.6 ms on the median. For a streaming encoder that is the right side of the trade — the tail is what becomes a visible hitch. This CONTRADICTS the patch's only prior datum (RTX 4090 / Windows / WDDM: "did not reduce the spikes"), so patch 0005's header now records the Linux/NVIDIA result beside it, with an explicit "do NOT delete this patch on the strength of the WDDM result — the two stacks disagree". Header prose only; the diff hunks stay byte-identical and `git diff crates/pyrowave-sys/vendor/` is untouched by this commit. Caveats recorded rather than buried: the arms were not interleaved and the game load drifted between them, capture was frame-starved (~2.5 fps) so this is encode latency under contention and not a full-rate stream, and it is two granted runs against one refused run. The direction held across all 25 windows. Also worth knowing for anyone repeating this: `encode_fps` is a VACUOUS metric on this rig. A headless gamescope with no real content emits ~12 fps, so both arms simply report the capture rate. Measure latency, not throughput. Gates green at CI parity. |
||
|
|
f23e0df64c |
fix(host): a compositor pin silently vetoed dedicated game sessions
ci / bun-nix (pull_request) Successful in 26s
ci / web (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m46s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m59s
ci / rust-arm64 (pull_request) Successful in 2m31s
android / android (pull_request) Successful in 7m23s
ci / rust (pull_request) Successful in 8m5s
`PUNKTFUNK_COMPOSITOR` is documented as "which backend to drive", but it also quietly discarded `game_session=dedicated`: `resolve_compositor` gated the dedicated route on `!overridden` and logged nothing either way. A host whose pin was a forgotten validation leftover therefore went on displaying "dedicated" in the console while every launch landed in the desktop instead — for 30 days on the box that surfaced this, the only evidence being the ABSENCE of a log line. The pin still wins, since it is the operator's explicit hand-set knob, but it now says so and names itself. Two further holes the same triage turned up: - The pin put its backend into `available()` unconditionally AND skipped `apply_session_env`'s `XDG_CURRENT_DESKTOP` scrub, so `pick_compositor` could never return `None` — the one place `try_recover_session()` is called from. A pinned host whose gnome-shell had segfaulted therefore spent every connect on 8 doomed `RemoteDesktop.CreateSession: ServiceUnknown` retries while the operator's configured `PUNKTFUNK_RECOVER_SESSION_CMD` sat unreachable behind that arm. Liveness is now read on both paths, and a pin aimed at a dead session takes the recovery exit with an error naming the pin. `needs_live_session()` exempts gamescope, which stands its own session up — pinning it on a headless box stays supported. - A mode switch accepted before the pipeline existed was served the long way round: build at the now-stale mode, then immediately rebuild at the new one in the stream loop. That burns a display create, capture attach and encoder open on every such connect, and because the rebuild is deliberately create-before-drop it stands up two Mutter `RecordVirtual` monitors ~400 ms apart — which segfaults mutter 50.4 inside `meta_monitor_manager_rebuild` and takes down the whole desktop session, along with the game just launched into it (so the GAME looks like what crashed). Bring-up now adopts the newest queued mode and builds once, carrying over the H2/H3 correction ack that the replaced rebuild would have sent. Verified on a real Linux host (192.168.1.21, x86_64): `cargo clippy --workspace --all-targets --locked -- -D warnings`, `cargo fmt --all --check` and the punktfunk-host + pf-vdisplay test suites all clean. The gate was proved non-vacuous against a planted `compile_error!`. |
||
|
|
8387e48ac6 |
docs(pf-capture): the fence wait is already free — PW4 retires into this comment
Wave-2 PW4's outcome. The package proposed moving the producer-fence wait off the PipeWire loop thread, and was pre-registered to be ABANDONED if the wait turned out to already be free. It is, on every producer and vendor measured — including the one where implicit sync actually exists. Steam Deck, RADV VANGOGH, gamescope producer (built in distrobox pf2, run on the host): samples=300 mean_us=23 max_us=48 p50=<=100us p99=<=100us signaled=0 no_fence=300 timed_out=0 failed=0 p99 in the first bucket is the plan's own abandonment condition, and the outcome split explains why: 300 of 300 buffers reported NoFence. Same on both NVIDIA producers (gamescope and Mutter's virtual output — the exact no-explicit-sync case the comment cites as the reason the wait exists). So `wait_read_ready` here is one ioctl and a return, not a block. Moving it to the consumer side would buy nothing measurable and would take on the hazard the package itself names — a slot holding a not-yet-ready dmabuf, and `repeat_last` re-waiting a fence it already consumed. Not a trade worth making for 23 microseconds. The 100 ms budget stays: it guards a producer that DOES fence, which is a real thing even if nothing in this fleet does it. KWin/AMD is the one combination still unmeasured, and the histogram from the previous commit is deliberately kept as the way to re-check — run with PUNKTFUNK_PERF=1 and read the p99 bucket. Comment-only; no behaviour change. Gates green at CI parity. |
||
|
|
fb60bf653e |
feat(pf-capture): instrument the fence wait PW4 wants to move, before moving it
Wave-2 PW4, step one of one-so-far. The package's own first line is "investigation step first
(measure, then decide)", and it is pre-registered to be ABANDONED if the wait's p99 is ~0 — so the
instrument ships before the change, not after.
A per-session histogram of `wait_read_ready`, taken on the PipeWire loop thread, which is exactly
where the wait is expensive: that thread is the compositor's consumer, so time blocked there delays
buffer recycling for the NEXT frame. Logged under PUNKTFUNK_PERF at the same cadence and gate the
encode backends use for their submit splits, so a perf run reads as one instrument: samples, mean,
max, p50/p99 bucket, and the Signaled/NoFence/TimedOut/failed split.
Buckets are coarse on purpose (100us -> 10ms, plus overflow). The decision this feeds is binary —
a p99 in the first bucket means the wait is already free and PW4 becomes a comment correction; a
p99 past 1ms is a real stall against a 16.6ms frame budget. Edges are placed so those two worlds
cannot be confused, and anything past the last edge reports as overflow rather than clamping into
the top bucket ("worse than 10ms" is a distinct finding).
The outcome split sits next to the timings because "the wait is short" and "there is nothing to
wait for" are different results with different consequences, and one data point already shows the
second: on gamescope/NVIDIA the probe reports NoFence, i.e. that producer attaches no implicit
fence at all.
5 tests pin the arithmetic, including that an empty histogram reports "no answer" rather than a
decisive-looking zero — the failure mode that would retire the package on no evidence.
No behaviour change: the wait still happens where it always did. Gates green at CI parity.
|
||
|
|
2a1c968a0e |
Merge pull request 'A gamescope session told every game its display was 60 Hz — and Fedora had no way to install the build that knows better' (#120) from worktree-gamescope-virtual-display into main
apple / swift (push) Successful in 1m37s
ci / web (push) Successful in 1m42s
ci / bun-nix (push) Successful in 29s
ci / docs-site (push) Successful in 1m37s
ci / rust-arm64 (push) Successful in 5m17s
deb / build-publish-client-arm64 (push) Successful in 1m48s
apple / screenshots (push) Successful in 6m27s
android / android (push) Successful in 9m56s
windows-host / package (push) Successful in 10m52s
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 11m20s
arch / build-publish (push) Successful in 16m0s
windows-host / canary-manifest (push) Failing after 32s
deb / build-publish-host (push) Successful in 12m7s
ci / rust (push) Successful in 17m46s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / builders-arm64cross (push) Successful in 23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m15s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m8s
docker / deploy-docs (push) Successful in 33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 13m51s
nix / flake (push) Failing after 14m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m35s
Reviewed-on: #120 |
||
|
|
b815e00a87 |
fix(pf-zerocopy): one dmabuf timeout condemned every later capture on the host, forever
Wave-2 PW3.
The raw-dmabuf passthrough has two very different reasons to switch itself off, and they shared one
`AtomicBool`:
* the encoder repeatedly failed to import what this compositor allocates — unrecoverable, a
driver fact, and the reason this latch was written (it stops the encode-stall recovery
rebuilding the same doomed encoder five times and then ending the session, on every connection,
forever);
* the dmabuf-only capture offer never negotiated — which can simply mean the compositor was
mid-restart.
Sharing the flag made the second as permanent as the first. One timeout, and EVERY later session on
the host captured CPU frames until the process was restarted — including sessions against a
completely different compositor and a different node, which had never failed at anything. Nothing
said so; the arm line PW2 added would have shown `cpu` with no explanation.
Now the two causes have the lifetimes they should have, in a `RawDmabufLatch` that owns both:
* Import failures stay sticky. Unchanged threshold (3 consecutive), unchanged hazard coverage.
* Negotiation timeouts get a retry budget of 2 — one retry, deliberately small: each failure
costs a ~10 s stall, so a larger budget is paid by the user in dead air. One retry survives the
mid-restart transient; a compositor that genuinely never accepts keeps the same identity, so it
latches on the second try, one extra stall per host lifetime versus the old behaviour.
* A capture that negotiates credits the budget back, so an evening of reconnects against a
compositor that failed once cannot accumulate its way into a latch.
* BOTH are keyed to a capture identity (node id + portal bit). A new node — fresh virtual output,
compositor restart, the Bazzite Gaming↔Desktop switch — is a genuinely different question and
earns a fresh dmabuf attempt instead of inheriting a verdict about something else. The SAME
capture keeps its verdict, which is what preserves the 10 s-stall protection the latch exists
for.
The session-open line now carries the latch state, so `cpu` is no longer ambiguous between "this
host was never going to do dmabuf" and "something failed earlier and we are still living with the
verdict" — only the second is a bug worth chasing, and only the second is now visible as one.
Atomics rather than a lock because `note_import_ok` is on the per-frame import path; everything
else runs at pipeline build or on failure. The state machine is tested against a local instance
rather than the process-wide static — seven tests covering both lifetimes, the identity clear, the
same-identity hold, the budget credit, and the cause naming.
One honest note on the identity: it is the PipeWire node id, not the "(compositor-id, modifier
list)" pair the design sketched. Node id is what capture actually has at that point, and it changes
on exactly the events that matter here (new virtual output, compositor restart, session switch).
Keying on the modifier list too would need the list before the importer is built, which is the
wrong order.
|
||
|
|
102f550bba |
feat(host): use the new gamescope capabilities, and say so when the mode is lost
Pass --custom-refresh-rates (patch level 3+) and --pipewire-composite-external-overlay (level 4+) on both spawn paths, with the same probe-then-pass shape the HDR and cursor flags already use. A stock gamescope has neither flag and gets neither, which is exactly today's behaviour. New knob PUNKTFUNK_GAMESCOPE_REFRESH_RATES=60,90,120 widens the set a session offers in Steam's in-session display settings. The rate the session actually runs at is always included, so it can only add options; junk entries are skipped rather than failing the host, because the worst a typo can cost is the extra option the operator wanted. And the part that would have turned a week of field triage into one log line: warn_if_mode_lost(). --nested-refresh is the ONLY refresh a headless gamescope has, and it reaches a gamescope-session-plus solely through the GAMESCOPE_BIN wrapper, which the session script is free to lose — a sessions.d file sourced with `set -a` can reassign GAMESCOPE_BIN, and one that sets GAMESCOPECMD outright skips the whole builder. When that happens the stream still runs, still looks right, and the client's own fps counter still reads the negotiated rate (the encode loop repeats the held frame), while the game underneath is capped to 60. Nothing anywhere said so. It warns rather than refusing, deliberately: verify_managed_spawn_flags refuses because its retry resolves a different plan, but a relaunch here would hand the session the same environment and lose the mode the same way, so refusing would only loop. Fails open on the same rule as the flag check — nothing to compare against says nothing. Also corrects the comment above the launch env, which claimed CUSTOM_REFRESH_RATES "generates the mode the session ADVERTISES … what makes games see the real refresh". It never did: no upstream gamescope has --custom-refresh-rates, so gamescope_has_option gated it off and the variable was inert. That belief is why the real lever went unexamined. configuration.md gains the new knob and a warning on PUNKTFUNK_MAX_FPS, which also lowers the refresh the session REPORTS on gamescope — the docs said it does not cap the stream, which is true of the wire and not of what games are told. Linux-verified on Ubuntu: cargo check --all-targets, clippy -D warnings, 133 tests (2 new), cargo fmt --check. |
||
|
|
608baf63be |
Merge pull request 'Post-sleep sessions still failed on 0.25.0 — the host was holding open the very device its recovery asks PnP to cycle' (#119) from worktree-vdisplay-reap-pnputil into main
apple / swift (push) Successful in 1m40s
ci / web (push) Successful in 1m21s
ci / rust-arm64 (push) Successful in 2m46s
ci / docs-site (push) Successful in 1m20s
ci / bun-nix (push) Successful in 26s
android / android (push) Successful in 6m37s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
apple / screenshots (push) Successful in 6m8s
deb / build-publish-host (push) Successful in 4m27s
deb / build-publish-client-arm64 (push) Successful in 2m0s
deb / build-publish (push) Successful in 5m36s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m1s
docker / builders-arm64cross (push) Successful in 8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m23s
ci / rust (push) Successful in 9m44s
docker / deploy-docs (push) Successful in 36s
arch / build-publish (push) Successful in 12m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 4m14s
windows-host / package (push) Canceled after 11m59s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #119 |
||
|
|
2bf571a5ad |
feat(pf-encode): PyroWave's Linux encode device never asked for the priority its own patch requests
Wave-2 PW1, first half = Wave-1 WP14 step 4, executed as specced. PyroWave encodes on the same GPU shader cores a game saturates, and that is measured to hurt: patch 0005's header records `encode_gpu_synchronous` going from ~2 ms to 15-18 ms at 95 % game load, with the stream frame rate collapsing. NVENC is immune because it has its own ASIC. The lever for a compute workload is an elevated global-priority QUEUE — a process-priority raise only reorders submission, not hardware preemption. The vendored patch requests exactly that. It is gated `if (!inherit_info)`, and only Windows leaves `inherit_info` null (`pyrowave_create_device_by_compat`, where Granite builds the device itself). Linux passes its own create-infos into `pyrowave_device_create_info`, Granite's `get_existing_create_info()` hands them back, `create_device` takes the inherit branch — and the whole block is skipped. On Linux the knob has never done anything at all. Meanwhile pf-zerocopy's VkBridge has shipped the identical ladder on Linux for some time and calls it "the actual NVIDIA compute-preemption lever"; the encoder that needs it most did not have it. This wires it natively in `open_inner`'s `DeviceHold`: - The extension probe reuses the `dev_ext_props` already fetched for queue_family_foreign, and takes KHR or the EXT alias — the same spelling pf-zerocopy probes, so the two cannot disagree. - `queue_priority_candidates` is a pure fn with the grammar copied from the C patch: unset → realtime, ASCII-lowercased, `off` alone disables, `high` asks for HIGH only, junk falls back to the ladder rather than to off. One env var must not mean two things on two platforms — that is the documentation trap this package exists to close — so the grammar is unit-tested against the patch's, including where they are both deliberately un-clever (neither trims). - The create ladder is REALTIME → HIGH → no-priority, stepping only on a refusal. A refused class can never fail the open, which matters more here than on Windows: this path is reached only by a NEGOTIATED PyroWave session, so a hard error is a dead stream, not a fallback to another encoder. The subtle part is the write-back. `pyrowave_create_device` RETAINS `device_create_info` for the device's lifetime and Granite reads the chain back. If the ladder ends on the no-priority attempt while `_queue_ci[0].p_next` still points at the global-priority struct, Granite is handed a chain the device was not created with. The `None` arm therefore nulls `p_next` before the final create, and the field's doc says why. The enabled extension deliberately STAYS in the list: it really is enabled on the device, it just carries no request. One deviation from the plan, stated because it is a deviation: the ladder also steps down on `ERROR_INITIALIZATION_FAILED`, not only `ERROR_NOT_PERMITTED_KHR`. The plan and the C patch handle only the latter; pf-zerocopy's shipped ladder accepts both. Given a hard error here kills a negotiated session, treating one extra driver-specific refusal as a downgrade is the cheap side of that asymmetry. Also corrects the two vendored notes, which claimed a Linux behaviour the gate made impossible, and records that patch 0005's negative RTX-4090 result is Windows/WDDM and does not transfer to a different driver stack. Patch hunks are byte-identical (header prose only) and `git diff crates/pyrowave-sys/vendor/` is PUNKTFUNK-VENDOR.txt alone. `PYROWAVE_QUEUE_PRIORITY` is now reachable on Linux, so it is documented in the same PR. MEASURED ON GLASS, and it changes what this package is worth on its own — .21, RTX 5070 Ti, NVIDIA 610.43.02, same binary in both arms: as packaged (no capability) every class refused, REALTIME *and* HIGH -> default priority same binary, cap_sys_nice+ep granted REALTIME on the FIRST attempt, no downgrade So the lever is INERT on an unprivileged host, and that is not the RADV-specific downgrade the plan predicted — on NVIDIA it is a downgrade to nothing at all. The ladder itself is proven good across all three legs (unset / high / off): a refused class never fails the open, and `off` enables no extension and logs nothing. It simply has nothing to grant yet. The privilege needed is CAP_SYS_NICE on the host binary, which is NOT what Wave-1 WP3 ships (RLIMIT_NICE, PAM limits, CPUWeight — all different things). That grant is a security-posture change on a network-facing daemon, so it is deliberately NOT in this commit; the warn line now names the capability so an operator is not left guessing, and the docs row says the setting has no effect on most hosts today rather than implying it works. The loaded-GPU encode_us p99 A/B is therefore not run: it needs a GPU-saturating game (hence a desktop session the box does not currently have) and it is pointless before the capability lands, since the unprivileged arm has no priority to measure. NO unit test is possible for the device-create ladder itself — it needs a real Vulkan device. Its coverage is the clippy pass, the grammar tests, and the on-glass log line. Stated here rather than left for a reviewer to wonder about. |
||
|
|
9c24569db6 |
fix(spike): --codec pyrowave encoded PyroWave off a capture negotiated for somebody else
Found while taking PW2's on-glass measurement, and it is what made the measurement possible.
`spike` built its capture request from `OutputFormat::resolve`, the constructor shared with the
GameStream path, which hard-codes `pyrowave: false` ("GameStream never negotiates PyroWave").
On Linux that flag is not cosmetic: `capture_virtual_output` feeds it to `zero_copy_policy` as
`ZeroCopyPolicy::pyrowave_session`, which is what puts the capture on the raw-dmabuf passthrough.
So `--codec pyrowave` opened a PyroWave encoder over a capture negotiated for a different
consumer, and the only way to exercise the real path was the host-global
`PUNKTFUNK_ENCODER=pyrowave` lever.
That lever cannot stand in for the per-session flag, which is the part that matters here: it
resolves the backend to `Pyrowave`, and `linux_zero_copy_is_vaapi_for` returns true for that —
so it ALSO flips `backend_is_vaapi` on. A per-session PyroWave negotiation on an auto/NVENC host,
where `backend_is_vaapi` is false, was therefore unreachable from the CLI — and that is exactly
the configuration whose CPU downgrade logged nothing at all.
The spike now sets the flag from its own codec, the same comparison `session_plan::output_format`
makes for a real session. With it, the before/after on .21 is unambiguous: origin/main logs zero
capture-path lines on that configuration, this branch logs two (the resolved arm, and the named
downgrade with its cause and fix).
|
||
|
|
fba22c6c64 |
fix(host/vdisplay): the host no longer vetoes its own wake-from-sleep recovery — control handles close on retire
ci / bun-nix (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 1m23s
ci / web (pull_request) Successful in 1m29s
apple / swift (pull_request) Successful in 1m39s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m19s
android / android (pull_request) Successful in 8m16s
ci / rust (pull_request) Successful in 9m59s
The control-device sharing contract was 'bare HANDLE copies, never closed for the process lifetime': retired handles were deliberately kept alive because the pinger/linger threads and the capture delivery closures held raw copies whose soundness depended on no-close. The cost surfaced in the 2026-08-08 field log: after a wake left the driver hostless, every adapter reload came back REFUSED (Generic failure) — and an open control handle is exactly what vetoes the PnP disable (and can wedge the pnputil restart) the recovery leans on. reset-pf-vdisplay.ps1 stops the whole host service precisely to get those handles closed; the in-process recovery could not, because the process could never close them. Ownership is now Arc all the way out: ensure_device/device_handle/ control_device_handle hand out Arc<OwnedHandle> clones, every consumer holds its clone across its IOCTLs (the capture closures each own one — Arc<OwnedHandle> is Send+Sync, ending the isize smuggling), and retiring drops only the manager's reference, so the handle CLOSES when the last in-flight user drains. DeviceSlot::retired is gone. The recovery path now releases the manager's reference at the first absent sighting — the 3 s ABSENT_SETTLE doubles as the drain window — and again before a not-ready-deadline reload, so the PnP cycle finally runs against a device the host is no longer holding open. The driver attaches no meaning to the control file closing (host-gone is the IOCTL-liveness watchdog, EvtFileClose deliberately unhooked), so the close has no driver-side side effects. Lock order note: RECOVERY → device is now taken (the release hooks); the forbidden inverse still never occurs — VdisplayDriver::open never reloads. |
||
|
|
2aa763ce70 |
feat(pf-capture): a PyroWave session could drop to CPU capture and log nothing at all
Wave-2 PW2 (design/linux-host-performance-wave2-pyrowave.md). Observability only — no behaviour change to any capture decision — and it lands first because every later package in the program is measured by an A/B whose "before" is currently unreadable. The defect: the capture path's CPU-fallback warning was gated on `backend_is_vaapi`, which reads the HOST-GLOBAL encoder pref. A PyroWave session is negotiated PER SESSION, so on an NVIDIA/auto host that gate is false — and the session then fell out of every arm of the negotiation log chain, emitting nothing whatsoever while paying a full-resolution CPU pixel touch on every frame. A degraded host and a healthy one produced identical logs. Four sites, matching PW2.1-2.4: 1. The CPU-path warning now asks the per-session question (`consumer_kind`) instead of the pref, and names the consumer. Its gate widened to every GPU consumer and excludes only the software encoder, whose native input IS CPU frames — an NVENC session silently on the CPU path is the same defect, not a different one. `pyrowave_session` deliberately outranks `backend_is_vaapi`, because a PyroWave pref flips `backend_is_vaapi` on too (`linux_zero_copy_is_vaapi_for`'s `Pyrowave` arm), so testing vaapi first would swallow every PyroWave session. 2. The raw-passthrough block in `consume_frame` had four silent exits — no format, an SHM/MemFd buffer, no DRM fourcc, a failed `F_DUPFD_CLOEXEC` — each falling out of three nested `if`s into the CPU de-pad path. It is now a labeled block that breaks with a named `PassthroughFallback`, logged once per distinct reason per session with a running count, so a persistent downgrade is distinguishable from a hiccup at renegotiation. `.process` runs per frame, so the rate limit is the shippable part and is what the tests pin. Note `NoFormat` does NOT fall back — the CPU path needs `ud.format` too and returns — so the line says DROPPED for that one. Three of four downgrade; one loses the frame. 3. `force_cpu_for_nvenc_444` told a 4:4:4 PyroWave session it was "on the NVENC path", which is false in every particular: the wavelet encoder never touches NVENC, never swscales to YUV444P, and what it actually loses is the raw-dmabuf passthrough its design assumes. 4. One INFO line at pipeline build states the resolved arm and consumer (`capture pipeline resolved: dmabuf-passthrough → pyrowave`). Nothing stated it before; the 2026-08-08 triage reconstructed it from four files, and for the arm that matters most there was no detail line to reconstruct it from. Also: `spike --codec pyrowave`, so a PyroWave capture→encode pass can be driven without a client. That is the harness the rest of this program measures on, and it did not exist. Gates on .21 at CI parity: fmt, workspace clippy -D warnings, pf-encode clippy with nvenc,vulkan-encode,pyrowave and without, workspace tests. |
||
|
|
4b514cc07c |
Merge pull request 'An OLED palette, and split WHETHER the gamepad UI is offered from WHEN it appears' (#116) from worktree-oled-theme-gamepad-ui-split into main
apple / swift (push) Successful in 1m33s
ci / rust-arm64 (push) Successful in 2m51s
ci / web (push) Successful in 3m13s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m45s
ci / bun-nix (push) Successful in 45s
ci / docs-site (push) Successful in 1m30s
ci / rust (push) Successful in 4m55s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 23s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 40s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 1m3s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m51s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 1m2s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 40s
deb / build-publish-client-arm64 (push) Successful in 2m42s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 47s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m18s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m17s
deb / build-publish-host (push) Successful in 6m16s
docker / builders-arm64cross (push) Successful in 11s
release / apple (push) Successful in 9m52s
docker / deploy-docs (push) Successful in 36s
android / android (push) Successful in 13m40s
deb / build-publish (push) Successful in 9m17s
arch / build-publish (push) Successful in 14m36s
flatpak / build-publish (push) Successful in 7m20s
apple / screenshots (push) Successful in 6m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m21s
Reviewed-on: #116 |
||
|
|
f242b2d2fc |
fix(host/vdisplay): a refused adapter reload now says WHY, and never targets a phantom devnode
Field log 2026-08-08 (0.25.0, wake from sleep): every session died on 'the adapter devnode could not be reloaded (Generic failure)' — the WMI catch-all — because the REFUSED branch reported only the Disable exception and threw away everything that would identify the failure mode: why the pnputil /restart-device fallback ALSO failed (its exit code — 3010 'needs a reboot' is its own diagnosis), what state the devnode was in, and whether the right devnode was even targeted. That last one is a real trap, not just missing telemetry: Get-PnpDevice lists not-present PHANTOM devnodes (upgrade/reinstall leftovers), and Select-Object -First 1 could hand every recovery attempt a phantom — whose disable and restart both fail exactly like the field log — while a live node sat unexamined. The selector now prefers present nodes (OK before problem-state), and a phantom-only state gets a truthful refusal: no reload can revive a devnode record whose device is gone; only reinstalling re-creates it. The REFUSED line now carries devnode counts, the chosen node's PnP Status + ConfigManager problem code, and the restart exit code, so the next field log decides between handle-veto, phantom, and problem-state instead of reading 'Generic failure'. Decode pinned by test. |
||
|
|
30bd10e301 |
feat(clients): an OLED palette, and split WHETHER the gamepad UI is offered from WHEN it appears
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m39s
ci / rust-arm64 (pull_request) Successful in 4m7s
android / android (pull_request) Successful in 5m1s
ci / docs-site (pull_request) Successful in 1m47s
ci / bun-nix (pull_request) Successful in 42s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m23s
ci / rust (pull_request) Successful in 14m32s
Four changes to the client interface, kept together because two of them touch the same rows
and the last is a bug the first would have made far more visible.
A thirteenth `ui_palette` entry, `oled`. The palette table is hand-mirrored in three languages
(`pf-console-ui`'s `library.rs`, `GamepadPalette.swift`, `GamepadPalette.kt`), so it goes into
all three at index 1, directly after the brand default — which keeps `PALETTES[0]` the unknown-id
fallback and keeps the dark-to-pale cycling order intact. What earns the name is arithmetic, not
a darker shade of violet: the ramp's first two stops are literally (0,0,0) and the ground is pure
black, so the shaded half of the field is pixels switched off rather than "very dark grey", and
the calm mix the form screens sit under lifts toward nothing at all. Mean cell luminance is 0.019
against Violet's 0.254. The bright corner keeps a faint indigo-to-violet ember so the backdrop is
still a field with somewhere to go, and that ember carries enough chroma at that luminance
(60 degrees of hue travel across 13 of the 16 cells) to satisfy the existing multi-tone assertion
without adding `oled` to the near-neutral exemption Graphite and Opal take. Each port gains an
`oled_is_actually_black` test that measures the claim — pure-black corner cells, a mean under half
the darkest other field's — rather than restating the table.
A new device key, `gamepad_ui_mode`. The gamepad-UI switch had been deciding two things at once:
whether to offer the controller-optimized interface at all, and that it appears only while a pad
is attached. A user asked for the second half to stop applying. `"connected"` (the default, and
exactly what the lone Bool meant) and `"always"` separate them, surfaced as a "Show it" row
directly under the switch on all five settings surfaces and built only while that switch is on —
a picker whose every option decides nothing is worse than no picker. `GamepadUIEnvironment.isActive`
takes the mode with NO default argument on purpose: a call site that forgot it would silently
strand everyone who chose Always back on "only with a controller", which is the one bug this
parameter exists to make impossible. An unrecognized value waits for a controller, so a mode a
newer client wrote can never trap an older one in a layout it has no way back out of. It stays a
device preference on both platforms, never part of a profile: which interface this device wears
has nothing to do with how a host streams to it.
The smoothness buffer is hidden under Lowest latency, not dimmed. Everywhere else already hid it
— the GTK and WinUI shells, the Apple touch and tvOS screens, the Android touch screen — because
under that intent it names a quantity that does not exist. Two surfaces disagreed: Apple's gamepad
settings screen left the row live and steppable, and the desktop console dimmed it, having no way
to drop a row from a fixed list. That list is now rebuilt each frame through a `row_applies`
filter. The concern about a vanishing row moving everything under the cursor does not apply here
and the new test says why: the row it drops sits directly BELOW the row that drops it, so the only
cursor that can be present when the list shrinks is the one on the intent row, which does not
move. Two latent hazards went with it — `apply_row` had been indexing the row list on the
assumption the cursor is always in range, and nothing re-clamped that cursor when another writer
changed the intent behind the screen's back.
Pale palettes were unreadable on tvOS, reported from the field. `GamepadInk` was never the
problem: it flips correctly for a pale field, it is not platform-gated, and every tvOS gamepad
entry point already published it. The cause is that this app sets `preferredColorScheme` nowhere
and declares no `UIUserInterfaceStyle`, so every SYSTEM-derived colour landing on those screens —
a `.secondary` placeholder, a `.bordered` button's chrome, a NavigationStack title, a material's
frost — resolved against the DEVICE appearance, which the palette cannot reach. On iPhone, iPad
and Mac a great many users sit in Light mode, so under a pale palette those colours came out dark
and the theme looked correct by accident; an Apple TV is Dark essentially always, so every one of
them rendered white on a light field. The mirror image was broken too and had simply never been
reported: a dark palette on a Light-mode iPhone was already drawing dark on dark. The scheme is
now published beside the ink, once, in `GamepadInkModifier`, because the two are halves of one
decision and publishing only the ink silently loses every colour the frameworks draw on the app's
behalf. Two structural amplifiers went with it: `ConsoleGlass` had been scoping the scheme to the
fill inside its `.background {}` on the tvOS and pre-26 branches while the 26 branch put it on the
content, so no console row's own content ever saw it on tvOS; and `LibraryView`'s navigation
chrome and its loading, error and empty states sit above `LibraryCoverflowView` and so were never
inked at all on tvOS and macOS, where that view is presented directly rather than through the
iOS-only `GamepadLibraryScreen` wrapper.
That last one exposed a second tvOS gap worth closing in the same breath: `ui_palette` had no row
in tvOS's ordinary Settings, and the gamepad settings screen that owns it everywhere else needs an
extended-profile controller to open on tvOS. An Apple TV driven by the Siri Remote alone could not
reach the palettes at all, which would now include the OLED one. `SettingsView.tvBody` carries a
Background row.
Verified: pf-console-ui builds, passes `clippy --all-targets -D warnings` and runs 74 tests clean
under linux/amd64 (a Mac `cargo check` of that crate is vacuous — every module is cfg'd to
linux/windows); `cargo fmt --check` clean for it and pf-client-core. Android `:app` runs 80 tests
with 0 failures, including four new `gamepadUiActive` cases and the palette parity table. The
Apple package builds for macOS AND tvOS and its 9 palette/gamepad-UI tests pass — the tvOS
typecheck is possible because the checked-in xcframework already carries a `tvos-arm64` slice. The
tvOS RENDERING fix is compile-verified only; an on-glass Apple TV check under a pale palette is
still owed, and is the one thing here that a build cannot answer.
|
||
|
|
e4f8c64b9f |
Merge pull request 'Library scanners sat in the nav and could not sync local art — and you can now hide one game' (#113) from worktree-plugin-nav-category-and-art into main
audit / bun-audit (plugin-kit) (push) Successful in 19s
apple / swift (push) Successful in 1m38s
audit / bun-audit (sdk) (push) Successful in 48s
audit / pnpm-audit (push) Successful in 11s
audit / docs-site-audit (push) Successful in 1m8s
audit / bun-audit (web) (push) Failing after 1m14s
apple / screenshots (push) Successful in 5m46s
ci / rust-arm64 (push) Successful in 4m32s
audit / license-gate (push) Successful in 5m12s
ci / bun-nix (push) Successful in 38s
arch / build-publish (push) Successful in 8m1s
ci / docs-site (push) Successful in 1m12s
ci / web (push) Successful in 1m28s
android / android (push) Successful in 9m3s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 33s
deb / build-publish-client-arm64 (push) Successful in 1m25s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 27s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 28s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
audit / cargo-audit (push) Failing after 10m5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 27s
ci / rust (push) Successful in 7m55s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m31s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m22s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
sdk-publish / publish (push) Failing after 31s
docker / builders-arm64cross (push) Successful in 11s
deb / build-publish-host (push) Successful in 4m20s
docker / deploy-docs (push) Successful in 35s
windows-host / package (push) Successful in 16m9s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 31s
deb / build-publish (push) Successful in 12m39s
nix / flake (push) Canceled after 14m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 14m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 13m18s
Reviewed-on: #113 |
||
|
|
6cffe29b13 |
feat(host,console): hide individual library titles
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m23s
apple / swift (pull_request) Successful in 1m40s
ci / bun-nix (pull_request) Successful in 21s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m28s
android / android (pull_request) Successful in 4m25s
ci / rust (pull_request) Successful in 6m26s
nix / flake (pull_request) Successful in 15m40s
The library had one visibility control and it was all-or-nothing: turn a SOURCE off
and every one of its games goes. There was no way to drop a single title — a Proton
tool the filter missed, a demo, a game someone doesn't want on the TV — short of
hiding the whole launcher it came from.
**Where the setting lives.** Not on the entry. Only manual custom entries are stored;
a scanner's and a plugin's titles are rebuilt from scratch on every scan and every
reconcile, so a flag written onto one would be erased by the next sync — silently, and
minutes later, which is the worst possible shape for a setting. So `library-hidden.json`
holds the ids, mirroring how `library-scanners.json` holds disabled sources. The id is
stable by construction (D2: a claimed store's entries keep `<store>:<external_id>`
across reconciles), so a hide survives a re-scan, a plugin restart, and a store's
built-in→plugin migration.
**Where it takes effect.** In `all_games`, which is the one place every play surface
already funnels through — the grid on a client, native clients, the GameStream app
list, and launch resolution. Putting it there rather than at each call site is
deliberate: a per-surface filter is a rule someone has to remember, and forgetting one
is precisely the class of bug the `file://` art asymmetry in the previous commit was.
Hiding is curation, not access control — nothing is deleted, and un-hiding is instant.
**The console is the one surface that still sees them**, or a hidden title could never
be brought back. That exception is a TYPE, not a flag: `GET /library` answers
`Vec<GameEntry>` on every lane but the operator's and `Vec<OperatorGameEntry>` on
theirs, so a hidden entry cannot reach a paired streaming client by someone forgetting
a filter — there is no field there to leak. `hidden` is skipped when false, so the
response is byte-identical to today's for a library with nothing hidden.
`PUT /library/hidden/{id}` is operator-only — neither the plugin lane nor a paired cert,
unlike the scanner toggle. A plugin has no business deciding what its operator sees, and
a client must not be able to hide a game on the host it is streaming from. The id is not
validated against the current library on purpose: a title can be legitimately absent at
that moment (launcher closed, plugin mid-sync, drive unmounted), and refusing the
operator's choice in that window is worse than storing an id that matches nothing today.
On the card, the poster dims and a Hidden badge says why — a faded tile with no label
reads as a broken cover. Its controls stay at full contrast and, unlike an ordinary
card's, are not hover-revealed: the un-hide button is the only way out of the state, and
hiding it behind a hover would strand anyone on a touch screen.
Verified on .21 (Linux): 469 host tests pass (5 new), clippy clean under `-D warnings`,
`cargo fmt --all --check` clean. The routing test is the one that earns its keep — every
library id contains a colon and Heroic's contain two, so a router that split on it would
404 the console against ids the host itself produced. Console: tsc clean, production
build clean, i18n 633 messages across en+de, biome clean on the touched files.
|
||
|
|
975fef2048 |
fix(host/vdisplay): the ghost-monitor reap can no longer fail in silence
The reap that keeps departed virtual monitors from exhausting the IddCx
monitor-slot budget launched pnputil by BARE NAME — under the LocalSystem
service's PATH that can miss System32, SilentlyContinue swallowed the
miss, and the Rust side logged only when the count was positive: a reap
that removed nothing and a box with no ghosts were byte-identical
(silence). Ghosts then ratcheted up with every sleep cycle until
IOCTL_ADD wedged at 0x80070490 and every session black-screened — and
the wedge self-heal shipped in 0.25.0 retried an ADD behind a reap that
could never remove anything, which is exactly a persistent post-sleep
"no connection" surviving the
|
||
|
|
9089651406 |
Merge pull request 'The jitter ring only ever learned from clicks — it now grows on near-misses, un-does refused shrinks, and cashes growth on the click it already paid' (#111) from worktree-audio-jitter-lowwater into main
apple / swift (push) Successful in 1m36s
ci / web (push) Successful in 1m14s
ci / docs-site (push) Successful in 1m20s
ci / bun-nix (push) Successful in 1m59s
ci / rust-arm64 (push) Successful in 2m31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
ci / rust (push) Failing after 3m6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 21s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 27s
deb / build-publish-client-arm64 (push) Successful in 1m55s
deb / build-publish-host (push) Successful in 4m38s
docker / builders-arm64cross (push) Successful in 9s
deb / build-publish (push) Successful in 5m5s
docker / deploy-docs (push) Successful in 32s
android / android (push) Successful in 10m35s
flatpak / build-publish (push) Successful in 7m16s
release / apple (push) Successful in 10m45s
windows-host / package (push) Successful in 12m21s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 25s
arch / build-publish (push) Successful in 13m42s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m35s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m50s
apple / screenshots (push) Successful in 6m9s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m19s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 23m55s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 24m41s
Reviewed-on: #111 |
||
|
|
d237646c66 |
fix(host,sdk,kit): library scanners sat in the nav, could not sync local art, and so never got their settings
Three symptoms on .21, two defects. Lutris and Heroic appeared in the console sidebar
they explicitly opt out of; Lutris's settings were unreachable from the Library
screen; and Lutris and Steam logged `sync (startup) failed: HostRequestError`.
**The sidebar is a publish gap.** The console is correct — it keeps
`category: "library"` plugins out of the nav (`uiPlugins`, app-shell.tsx) — but the
host reports no category for them at all. `defineLibraryPlugin` sets it and
`sdk/src/ui.ts` forwards it; what SHIPS does not. `@punktfunk/host` was bumped to
0.1.2 on 2026-07-20 and `category` landed 2026-08-05 without a bump, so the registry's
0.1.2 is the pre-category build and every installed scanner registers without one.
Bumps the SDK to 0.1.3 — **inert until it is published**.
Because the field rides the untyped `pf.request` seam so an older host ignores it
rather than rejecting the registration, dropping it is silent by design. `serveUi` now
reads its own directory entry back and warns once when a requested category did not
land, the same way `defineLibraryPlugin` already warns when a store claim did not take.
That is what turns the next occurrence into a log line instead of a bug report.
**The missing settings and the failed sync are ONE defect: a write/read disagreement
about `file://`.** `local_art_bytes` decodes a `file://` value before testing
containment; `validate_art_paths` handed the raw value to `Path::new`, where
`file:///home/u/c.jpg` is a RELATIVE path whose first component is `file:`. It
canonicalized against the cwd, failed, and read as "outside every art root". So the
host refused every cover the kit's own `fileUrl` helper emits — the documented way for
a plugin to publish local art — while the read path would have served those same files.
That the two symptoms share a cause is not obvious and is why this is one commit: the
Library screen's settings control renders only for `origin: "plugin"`, and a source
becomes `plugin` only once it holds a store CLAIM, which is taken during a successful
reconcile. Lutris failed at entry 0 and Steam at entry 3, so neither ever claimed its
store, both stayed `origin: "builtin"`, and neither got a settings button. Heroic
reconciled (its art is http(s)) and has had its settings all along; rom-manager was
never affected because zero entries meant it never applied.
`art_path_is_servable` now decodes first, so both halves of the confinement judge the
same string. Confinement itself is unchanged: an out-of-root path is still refused in
`file://` clothing, which the test asserts alongside the accept case.
Diagnosing this took the HOST's journal, because both surfaces that should have
explained it lied. `HostRequestError` stringified to its bare tag, so the sync engine's
`${e.cause}` logged `HostRequestError` and discarded the method, the path and the
host's own message; it now renders all three, including an object-shaped cause that
used to print `[object Object]`. And the host logged "payload carries a field this lane
may not set" for BOTH refusals in `check_entry_fields`, so a 400 about an art path read
as an auth problem — it now logs the real reason and the entry title.
Verified on .21 (Linux): 463 host tests pass, clippy clean under `-D warnings`,
`cargo fmt --all --check` clean. The new art test fails without the fix and passes with
it. plugin-kit 71 and SDK 72 tests pass, both typecheck clean, biome clean.
|
||
|
|
69728b6f4e |
fix(pf-presenter): "Native resolution" streamed the compositor's POINTS, not the panel's pixels
ci / bun-nix (pull_request) Successful in 23s
ci / web (pull_request) Successful in 1m11s
ci / docs-site (pull_request) Successful in 1m19s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m33s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m25s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m22s
android / android (pull_request) Successful in 4m41s
ci / rust (pull_request) Successful in 6m52s
A CachyOS / KDE Plasma 6.7.4 Wayland client with its 2560x1600@165 laptop panel at 150 % scaling negotiated 1706x1066 for "Native resolution" and streamed a visibly blurry image. Two independent defects, and they stack — which is why forcing the mode to 2560x1600 by hand did not fully fix it either. 1. `SDL_GetDesktopDisplayMode` reports a mode in SCREEN COORDINATES and hands the pixels-per-point ratio back separately as `pixel_density`. We read `m.w`/`m.h` raw. KDE advertises that panel as 1707x1067 points with a density of ~1.4997, `render_scale::apply` even-floors both odd axes, and 1706x1066 goes on the wire — exactly the mode in the reporter's handshake log. Multiplying by the density recovers 2560x1600 to the pixel, because SDL derives it as the output's exact pixels/points ratio. On X11 and Windows SDL never sets a density and `SDL_video.c` normalizes the unset 0.0 to 1.0, so this is inert there: the bug needed a compositor doing FRACTIONAL scaling. 2. The SDL window was created without `HIGH_PIXEL_DENSITY`, so the Wayland surface stayed at buffer scale 1 — the Vulkan swapchain was built at 1707x1067 and KWin upscaled it to the glass. Even a correct 2560x1600 stream was resampled down and then back up. The same flaw silently shrank "Match window", which asks the host for `size_in_pixels()`. The reporter's `SDL_VIDEO_WAYLAND_SCALE_TO_DISPLAY=1` workaround is this same fix applied from outside SDL, which is why it helped. The surrounding code was already written for pixels != points — the swapchain, match-window and pointer mapping all read `size_in_pixels()` while window-size persistence reads logical `size()` — so the flag only makes those two stop being the same number. `display_scale()` starts reporting 1.5 into a swapchain that is 1.5x larger, leaving the OSD the size it already was. Also closes a smaller hole on the way past: only an `Err` from SDL reached the 1920x1080 fallback, so a display that reported a 0x0 mode sent a 0x0 request. Verified on home-worker-5 (CachyOS — the reporter's distro, real SDL 3.4.14): `cargo clippy --all-targets -p pf-presenter -- -D warnings` clean and 18/18 pf-presenter tests pass, three of them new and pinned to the field-reported numbers. |
||
|
|
3bb87d260e |
fix(audio): detect jitter before it is audible, and stop re-probing a depth the link just refused
apple / swift (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 2m1s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m44s
ci / web (pull_request) Successful in 1m38s
android / android (pull_request) Successful in 4m52s
ci / docs-site (pull_request) Successful in 1m33s
ci / rust-arm64 (pull_request) Successful in 4m10s
ci / bun-nix (pull_request) Successful in 28s
ci / rust (pull_request) Successful in 9m26s
The 0.25.0 MacBook field report — audio jitter 'at certain points' — is the jitter policy learning exclusively from audible failures, on both of its sides. Growth needed THREE audible underruns before deepening the ring; the A/V sync loop re-tested a shallower ring every five quiet seconds and paid an audible starvation event every time it was wrong, forever; and a grown target was never re-banked — growth raises a threshold, only a re-prime deepens the ring — so a bunching link rode the knife edge, clicking once per bunching period with the 'grown' target sitting inert. A ten-minute simulation of the Wi-Fi power-save pattern (25 ms gaps / 300 ms, −50 ppm skew) measured ~2000 audible events under the shipped policy. Three mechanisms, in JitterPolicy (Linux/Windows/Android) and mirrored in the Swift AudioRing: - NEAR-MISS: a read served with less than one protocol frame left over is the same evidence as an underrun, heard by no one. It grows the target one step per window, BEFORE the click — waiting for the third audible underrun means the user heard two. - SHRINK PROBES: every shrink is armed for five seconds; answered by an underrun or near-miss it is undone on the spot, and a failed sync-driven shrink is not retried for a doubling backoff (60 s → 8 min). A probe that survives resets the backoff. Continuity outranks sync, now with a memory. - HOLLOW RE-PRIME: an underrun while the depth AVERAGE runs more than a step below the target re-primes immediately, spending the click it already cost on the whole refill instead of limping. The average, not the instant, is what separates a hollow ring from one late packet, and it is seeded on prime so a fresh ring is never spuriously hollow. Same simulation after: 9 audible events, tail clean but for the clock-skew re-anchor (a genuinely slow host must re-bank every few minutes; only rate adaptation would remove that, and no client has it). Neutralising the three constants reproduces the ~2000 — the convergence tests fail against the old behaviour. Verified: 203 punktfunk-core tests, 254 Swift tests (5 skipped), clippy -D warnings on punktfunk-core --all-features, cargo fmt --all --check. |
||
|
|
86bb09e2cf |
Merge pull request 'Arch could upgrade FFmpeg out from under the host and brick it — and the host now builds against FFmpeg 9' (#108) from worktree-ffmpeg9-support into main
apple / swift (push) Successful in 1m28s
android / android (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
audit / cargo-audit (push) Canceled after 0s
audit / bun-audit (plugin-kit) (push) Canceled after 0s
audit / bun-audit (sdk) (push) Canceled after 0s
audit / bun-audit (web) (push) Canceled after 0s
audit / docs-site-audit (push) Canceled after 0s
audit / pnpm-audit (push) Canceled after 0s
audit / license-gate (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
nix / flake (push) Canceled after 0s
release / apple (push) Canceled after 2m25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 4s
windows-msix / package (x64, , 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
decky / build-publish (push) Successful in 1m6s
Reviewed-on: #108 |
||
|
|
deeb8b6700 |
feat(pf-encode): build against FFmpeg 9
apple / swift (pull_request) Successful in 1m53s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 2m34s
ci / web (pull_request) Successful in 2m32s
ci / docs-site (pull_request) Successful in 1m25s
ci / bun-nix (pull_request) Successful in 26s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m23s
android / android (pull_request) Successful in 6m47s
ci / rust-arm64 (pull_request) Successful in 8m49s
nix / flake (pull_request) Failing after 16m7s
ci / rust (pull_request) Successful in 23m39s
ffmpeg-next 8.1.0 could not accept FFmpeg 9 at all: ffmpeg-sys-next's version probe
covered avcodec majors 56..62 (the range is exclusive of its end), so libavcodec 63 fell
outside what it knew how to bind. 9.0.0 widens that to 56..63, which is what actually
unblocks Arch. Bump both pins — the unconditional Linux dep and the optional Windows
amf-qsv one — and the lock with them.
No API drift to fix. The crate major is a CEILING, not a target: one source tree still
spans FFmpeg 7.x/libavcodec 61, 8.x/62 and 9.x/63 via per-version cfgs, and every wrapper
symbol the NVENC-libav, VAAPI and amf-qsv backends name survives 8.1.0 -> 9.0.0
unchanged. The three hand-written #[repr(C)] hwcontext mirrors are the parts no compiler
checks, so they were re-read against the real headers rather than trusted:
AVCUDADeviceContext and AVD3D11VAFramesContext are byte-identical across 7.1/8/9, and
AVD3D11VADeviceContext gained two trailing UINTs in 8 that 7.1 lacks — which is why that
mirror deliberately stops at the common prefix, and why its assertions now say what they
do and do not buy you. They pin our layout, not libav's; a green build is not evidence.
The CI image is the step that makes this reach users. arch.yml deliberately runs no -Syu
("the image's snapshot IS the build environment"), so the builder stayed frozen on ffmpeg
8 no matter what Arch shipped, and a canary built from that snapshot could not satisfy the
soname dep the PKGBUILD now derives. Re-keying ci/ rebuilds it against ffmpeg 9.
Ubuntu and Windows deliberately stay put: the noble .deb bundles its own FFmpeg 8 behind
an rpath and strips the libav sonames from its Depends, and Windows bundles BtbN DLLs into
the signed installer — neither is exposed to the break, BtbN publishes no FFmpeg 9 build,
and moving either would re-qualify an encode stack to buy nothing.
Verified end to end on 192.168.1.21 (CachyOS, system ffmpeg 2:9.0-5, RTX 5070 Ti): host
builds clean and links libavcodec.so.63/libavutil.so.61/libavfilter.so.12/libswscale.so.10
with no unresolved sonames; the ffmpeg-8 compat shim is gone and the service runs with
NRestarts=0 and answers 401 on :47990; pf-encode's 67 tests pass; and a live synthetic
encode drives real NVENC hardware through FFmpeg 9's libavcodec to a decodable 1080p HEVC
stream (180/180 frames, FEC loopback 0 mismatches) with libavcodec.so.63 and
libnvidia-encode both mapped into the encoding process.
|
||
|
|
cabd011f1d |
Merge pull request 'The 272 ms audio buffer was legal: the PipeWire callback filled the buffer ceiling, not the graph's request' (#106) from fix/pw-playback-requested into main
apple / swift (push) Successful in 1m34s
ci / web (push) Successful in 1m3s
ci / bun-nix (push) Successful in 17s
ci / docs-site (push) Successful in 1m15s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m37s
ci / rust-arm64 (push) Successful in 3m1s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 27s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
android / android (push) Successful in 5m6s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m43s
apple / screenshots (push) Successful in 6m5s
deb / build-publish (push) Successful in 4m8s
deb / build-publish-host (push) Successful in 3m54s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m9s
deb / build-publish-client-arm64 (push) Successful in 4m36s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m11s
ci / rust (push) Canceled after 2m31s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
arch / build-publish (push) Successful in 8m34s
flatpak / build-publish (push) Successful in 18m15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 22m4s
Reviewed-on: #106 |
||
|
|
be86cfcdc0 |
fix(client/audio): the PipeWire callback stops filling the buffer ceiling every cycle
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m12s
apple / swift (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 23s
ci / web (pull_request) Successful in 1m0s
ci / docs-site (pull_request) Successful in 1m8s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m18s
ci / rust-arm64 (pull_request) Successful in 1m40s
android / android (pull_request) Successful in 5m24s
ci / rust (pull_request) Successful in 12m51s
The playback process callback sized its writes from the mapped buffer's capacity — PipeWire's quantum-limit, 8192 frames ≈ 170 ms — instead of the graph's per-cycle ask (pw_buffer.requested). Every cycle therefore queued up to 170 ms of PCM downstream of the ring, and, worse, taught JitterPolicy that the device drains 170 ms per callback: the underrun floor (want + one frame) rose above any depth the A/V sync loop may request, so sync measured audio ~280 ms late and was forbidden — by its own continuity rule — from draining it. The first on-glass run of the latency overhaul showed exactly that: audio buffer 272 ms, a/v +284 ms, stable. Honor requested (capacity remains both the ceiling and the fallback for requested == 0), and log requested-vs-capacity once per stream in the shape of the host's per-capture-open quantum line, so the next on-glass report can say which one is sizing the writes. Needs libpipewire >= 0.3.49 (2022-03) for the requested field; every ship target clears that. Verified on .21: cargo clippy -p pf-client-core --all-targets -D warnings clean, 167 tests pass, fmt clean. |
||
|
|
e9e1ec7dc5 |
fix(pf-inject): the DualShock 4 Windows backend never imported OFF_INPUT
ci / web (pull_request) Successful in 1m22s
apple / swift (pull_request) Successful in 1m32s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m58s
ci / docs-site (pull_request) Successful in 1m25s
ci / bun-nix (pull_request) Successful in 30s
android / android (pull_request) Successful in 5m2s
ci / rust (pull_request) Successful in 8m2s
The Windows host does not build:
error[E0425]: cannot find value `OFF_INPUT` in this scope
--> crates\pf-inject\src\inject\windows\dualshock4_windows.rs:65:48
error: could not compile `pf-inject` (lib) due to 1 previous error
`dualshock4_windows.rs` writes the neutral report straight to `OFF_INPUT` in its
bootstrap path — correctly, and exactly as the DualSense and Steam Deck backends
do: the devnode does not exist yet at that point, so there is no reader to race
and no seqlock to take. Its steady-state path already goes through
`publish_input`, which is the v2.3 seqlock.
But the import list only names `publish_input`. `steam_deck_windows.rs` imports
`OFF_INPUT` explicitly for the same bootstrap write; this one was missed when the
list was edited to add `publish_input`.
One word in a `use`. No behaviour.
WHY CI DID NOT CATCH IT: `pf-inject`'s Windows backends compile only for
`*-pc-windows-msvc`, and the crate is host-side, so the client Windows workflow
never touches it. A cargo check from a Mac cannot stand in either — pf-inject
pulls punktfunk-core and therefore ring, whose C build wants MSVC headers, so the
cross-check dies in cc-rs long before it reaches this file.
FOUND BY: running windows-host.yml's own build line on the CI runner (.133)
against the v0.25.0 release tree before tagging —
`cargo build --release -p punktfunk-host --features nvenc,amf-qsv,qsv`. It fails
at `pf-inject`, which is step 1 of the host job, so a v0.25.0 tag would have
produced no Windows host binary, no installer, and no host asset on the release.
|
||
|
|
7f82bca9c0 |
fix(pf-dxvadec): a wrapped sentence turned "6." into an ordered list
ci / bun-nix (pull_request) Successful in 23s
ci / web (pull_request) Successful in 1m10s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m17s
ci / docs-site (pull_request) Successful in 2m3s
ci / rust-arm64 (pull_request) Successful in 2m16s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m10s
apple / swift (pull_request) Successful in 1m34s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m59s
ci / rust (pull_request) Successful in 6m3s
A doc paragraph in `pic_av1.rs` wrapped so that "first at frame / 6. Releasing…" put `6.` at the start of a line. rustdoc reads that as an ordered-list item starting at 6, which makes the following unindented `///` line a lazy continuation — `clippy::doc_lazy_continuation`, denied by `-D warnings`. Reflowed so the number cannot begin a line. Prose is byte-identical in content; only the wrap points move. No code, no behaviour. WHY THIS MATTERS FOR THE TAG. `pf-dxvadec` is Windows-only, and no Windows leg runs on a push to main — so main being green proves nothing about this. The failure surfaces for the first time in a release tag's fan-out, which is exactly what happened to the FIRST v0.23.0 tag: it went red on Windows clippy for this same lint, and the cure was a tag re-point. Caught pre-tag by re-running the lazy-continuation scanner over the tree while preparing v0.25.0 (0 hits before this commit's parent merged the new decode crates, 1 after). Cannot be verified by compiling here — the crate does not build on macOS — so the evidence is the scanner plus the lint's own rule, not a clippy run. |
||
|
|
3d20f2c0e5 |
Merge pull request 'Three decode rungs were decoding into a surface they were predicting from' (#102) from integration/decode-aliasing-program into main
ci / bun-nix (push) Successful in 30s
ci / web (push) Successful in 1m22s
ci / docs-site (push) Successful in 1m24s
apple / swift (push) Successful in 1m35s
ci / rust-arm64 (push) Successful in 1m56s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 16s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 16s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 3m6s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m11s
android / android (push) Successful in 5m57s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m21s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m37s
apple / screenshots (push) Successful in 5m52s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m24s
deb / build-publish-host (push) Successful in 6m20s
ci / rust (push) Canceled after 6m56s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows-host / package (push) Failing after 1m37s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 5m39s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m47s
arch / build-publish (push) Successful in 11m19s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m19s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 6m37s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 10m20s
flatpak / build-publish (push) Successful in 8m54s
Reviewed-on: #102 |
||
|
|
2b167595aa |
docs(client): the VAAPI rung has parity now — say what is actually left
ci / bun-nix (pull_request) Successful in 28s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 30s
apple / swift (pull_request) Successful in 1m36s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m19s
ci / web (pull_request) Successful in 1m30s
ci / rust-arm64 (pull_request) Successful in 2m39s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m26s
android / android (pull_request) Successful in 4m31s
ci / rust (pull_request) Successful in 10m52s
Its rows still read "never frame-hash parity-checked: the rung exports a tiled dmabuf with no CPU-readable image, so parity needs a readback path that does not exist yet". That readback now exists, and all SEVEN legs came back bit-identical to libavcodec on RDNA3: vendored H.264 250/250, our host's low-delay H.264 120/120, vendored H.265 250/250, host low-delay H.265 120/120, HEVC Main 10 50/50 as P010, vendored AV1 250/250 of 274 decoded, and host low-delay 4K two-tile AV1 60/60. The two arms collapse into one, because the thing that split them — AV1 having evidence the other legs lacked — is gone. Every leg now has the same evidence. It stays `verified = false`, and the note says why in the words the unproven-rung test requires: it has NEVER run on a second vendor and has never been soaked. That is a real limit rather than a formality — every other verified pair in this table earned it on more than one part, and the D3D11VA AV1 row two entries up is a rung that passed on one vendor's driver while failing on another's. The second reason is not about evidence at all, and it belongs in the record rather than in a commit nobody reads later: flipping this flag is a ROUTING change. `native_rung_admitted` is `verified || !below.verified`, so a verified VAAPI outranks Vulkan Video on every Linux AMD and Intel client — the Steam Deck included. The parity result justifies that change; it should still be made on purpose, by someone who wants it, rather than arriving as a side effect of writing down a test result. |
||
|
|
a9e7c033c3 |
test(client/vaapi): the last rung of the ladder, finally checked in pixels — 7 legs, all bit-identical
Every other decode rung earns `verified` with frame-hash parity against
libavcodec. VAAPI could not: it hands out a DRM-PRIME dmabuf whose memory the
driver tiles, so nothing could read its decoded pixels back, and all four of its
legs sat at "never frame-hash parity-checked".
That was never bookkeeping. The D3D11VA AV1 rung decoded 250 frames, streamed
4K60 through a clean five-minute soak, and produced WRONG PIXELS for 186 of 250
frames on NVIDIA and 245 of 250 on Intel. It looked perfect on glass; only the
goldens caught it, and the same defect turned out to be in H.264 on two other
rungs. VAAPI was the one rung where that class of bug could still be sitting
with nothing able to see it.
It is not. Measured on .25 (Radeon 780M, RDNA3, radeonsi, Mesa 26.0.3, VA-API
1.23) on 2026-08-08, against the SAME golden files the Vulkan and D3D11VA rungs
are held to, read across the crate boundary rather than copied:
H.264 vendored vector 250/250 bit-identical (7 from the flush)
H.264 our host, low-delay 640x480 120/120 bit-identical (3 from the flush)
H.265 vendored vector 250/250 bit-identical (2 from the flush)
H.265 our host, low-delay 640x480 120/120 bit-identical (0 from the flush)
HEVC Main 10, P010 50/50 bit-identical (2 from the flush)
AV1 vendored vector 250/250 delivered of 274 decoded, and
display frame 0 byte-identical to
libavcodec's own PIXELS
AV1 our host, 4K two-tile 60/60 bit-identical
⚠ ONE vendor. AMD/radeonsi only; no Intel iHD box has run these legs.
The readback that made it possible:
* `pf-vaadec`'s `va` module gains `VAImage` and `VAImageFormat`, hand-declared
with every size and offset measured off libva 2.23.0's real headers by
`layout-probe.c` and pinned as compile-time assertions — the same discipline
the decode buffers already keep. The trap: `VAImage::width`/`height` are
16-bit, so `data_size` sits at 60 and not at the 64 counting 32-bit fields
gives, and every field after them is two bytes earlier than it looks.
* `pack_two_plane` is the pure geometry — the crop to the picture, the padding
columns dropped per row, and the chroma plane taken from the driver's OWN
`offsets[1]` rather than from `pitch * display_height`, which is the 1088-row
smear this program has already paid for once. It needs no device, so ten CPU
tests cover it on macOS and in the container.
* `video_vaapi_native::parity` drives the seven streams above through the
production entry point and hashes what the rung DELIVERS, in delivery order,
tail included — so the delivery path is under test as well as the decode, and
a frame's surface comes from its own release token rather than from an
inference about which pool entry holds which picture.
THE READBACK CANNOT REACH THE PRODUCTION PATH, and that is structural rather
than a promise. `vaDeriveImage`, `vaCreateImage`, `vaGetImage`, `vaMapBuffer`
and the rest are resolved by a `#[cfg(test)]` type that dlopens libva itself;
the production `Libva` gains no field; `sha2` is a dev dependency. A CPU test
scans this file's own source and fails if any of those symbols is dlsym'd
outside the harness, so a refactor cannot quietly undo it.
Derive is not guaranteed, so both routes are implemented and neither is
optional: `vaDeriveImage` first, `vaCreateImage` + `vaGetImage` as the fallback
(which also detiles), and if neither yields the pool's own fourcc the leg FAILS
naming what the driver gave it. There is no skip path — a parity test that
passes because it could not read anything is the failure mode this program has
been bitten by three times. Both answer on radeonsi, the first frame of every
leg is read through BOTH and they must agree, and `PF_VAAPI_READBACK=getimage`
reproduces the H.264 leg's 250/250 through the copying route alone, so the
fallback is exercised rather than merely written.
And it can fail — proven, not asserted. Planting the real geometry defect this
driver's layout makes visible (rows read contiguously, ignoring the 512-byte
pitch behind a 320-wide picture) fails at display frame 0 with the full
localisation: 68312 luma and 14998 chroma samples differing, max |delta| 255,
luma bounding box (0,1)..(319,239) — and with the goldens forced through one
route, 250/250 diverging with "suspect the readback geometry". `compare` and
`localise` also have CPU counterfactuals, and a hardware leg proves the readback
reads real and DISTINCT pixels and localises a one-byte flip to the exact pixel.
⚠ One thing the hardware legs do NOT cover, found by planting the other defect
and watching it do nothing: radeonsi's decode surfaces for every fixture here
have no VERTICAL padding — `offsets[1]` is exactly `pitch * height` — so the
chroma-plane trap is untested on this driver, and `pf-vaadec`'s
`reading_chroma_at_the_display_height_would_have_been_caught` is the only place
it is checked at all. `probe_this_machines_readback_routes` now prints the
derived layout and says which of the two it is, so the next driver answers for
itself instead of being assumed.
|
||
|
|
bfed711921 |
Merge remote-tracking branch 'origin/main' into audio/latency-overhaul
ci / bun-nix (pull_request) Successful in 33s
ci / web (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m21s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m13s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m36s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m9s
android / android (pull_request) Successful in 7m53s
ci / rust (pull_request) Successful in 12m23s
|
||
|
|
f926bab9f7 |
fix(client): the native VAAPI rung stopped dropping decoded frames on the floor
`finish` showed `outputs.last()` and retired every other picture an access unit bumped out of the DPB without ever displaying it, and nothing flushed the DPB at end of stream. Measured on .25 against the vendored vectors: 225 of 250 frames for H.264, 204 of 250 for H.265, 45 of 50 for HEVC Main 10. D3D11VA and Vulkan deliver every frame, so this was the rung's alone. All four legs now deliver 250 / 250 / 50 / 250. The same function carried a second defect. `DmabufFrame::keyframe` was stamped with the CURRENT access unit's `is_idr`, not the flag of the picture it was about to display, and on a reordering stream those are different pictures: the IDR is bumped out several units after it decodes and arrived flagged `false` on all three legs' first frame, while a later AU draining the DPB flagged some old trailing picture as a keyframe. That field is `DecodedImage::is_keyframe`, the pump's post-loss re-anchor signal, so a mislabel re-anchors on the wrong frame. Three changes, all inside this rung: * **A deliverable queue**, the same shape as `video_vk_native`'s — extend, ship the front, trim the oldest past the bound, count and rate-limit the drops into `DecodeHealth::dropped`. Its DEPTH is derived differently and the divergence is documented: the Vulkan rung's bound is `HOLD_HEADROOM - PIPELINE_HOLD` = 1 because a queued frame there counts against the pool ON TOP of the DPB's own residency. Here the three claims are disjoint and a bumped picture MOVES from `pending`/slot to `held`, so the queue inherits the claim rather than adding one. The bound is the DPB's depth — the deepest carry-over a bump can leave — and the measured cost is at most one surface (zero on H.264, whose three seven-picture IDR drains are the deepest bursts these vectors have). A bound of 1 would have left 235 of 250 on H.264, most of the defect still in place. * **An end-of-stream flush.** This rung has no EOS signal and cannot have one: the pump feeds access units until the session ends and then drops the decoder. So `flush` has the two honest callers — `Drop`, where nothing can be presented and the job is to release the queue's surfaces and the DPB's before the pool goes, and a caller that KNOWS the stream ended, which today is the conformance harness. One walk, not a production path and an untested teardown path. AV1 needs none: it shows at most one frame per temporal unit and buffers nothing, which its 250/250 says out loud. * **`PictureFacts` recorded when a picture decodes**, and read back when it is displayed. `keyframe` was the defect; `color` and `display` are the same mistake one field along — an in-band HDR switch changes the VUI mid-stream and AV1's render region is per-frame, so a queued frame shown two units later would have been drawn with the newest picture's signalling. Concealment answers `Ok(None)` and deliberately does NOT drain the queue, which is the Vulkan rung's order and is load-bearing: `clears_demotion_streak` is `delivered || !concealed`, so shipping a queued frame on a concealed AU would zero the streak and take away the escape hatch that stops a rung concealing forever from holding a frozen picture. The three delivered-count assertions moved with the fix, and so did the CPU derivation that reproduces them without a GPU — it now simulates the whole delivery model (ledger, queue, one-per-AU hand-off, flush) in the order `decode` does it, and carries the old behaviour beside the new one as a counterfactual: a queue bound of 0 with no flush still reproduces 225/204/45 exactly, and the test fails if it ever stops being SHORT. `settle` was split out as the pure half of `finish` so the claim walk, the display ordering and the picture facts are all assertable with no device; `the_queue_never_needs_a_surface_the_pool_does_not_have` runs the surface-lifetime arithmetic over the real vectors and pins the peak claims (9 of a 16-surface pool on H.264, 8 of 14 on both HEVC vectors), with an unbounded queue as the counterfactual that shows the bound doing its job. Gates run: `cargo fmt --all -- --check`, `cargo clippy -p pf-client-core -p pf-vaadec --all-targets --features sdl3/build-from-source -- -D warnings`, `cargo test -p pf-client-core --lib --features sdl3/build-from-source` (176 pass), the same filtered to `video_vaapi_native -- --include-ignored` (23 pass, 0 ignored) and `cargo test -p pf-vaadec` (48 pass) — all on .25 (Radeon 780M, RDNA3, radeonsi, Mesa 26.0.3, VA-API 1.23); plus `cargo fmt --all -- --check` and `cargo clippy --workspace --all-targets -- -D warnings` in pf-lxcheck2. |
||
|
|
12a5318397 |
fix(audio): place audio with the picture instead of wherever the ring settles
The host stamps `pts_ns` on every audio datagram and the client decoded it
into `AudioPacket` — and then never read it. Video's `pts_ns` is used end to
end (the presenter computes a true glass-to-glass `displayed + clock_offset −
pts`), so audio free-ran at whatever depth its jitter ring happened to reach,
video was presented on an independent path, and nothing ever compared them.
The A/V offset was an accident of buffer depths: it moved whenever the ring
ratcheted under underrun pressure, and it got WORSE every time video got
faster, because a quicker decoder lowers the video leg and leaves audio's
exactly where it was. That is what a field report on the Steam Deck heard as
"the audio delay is way too high", and it is why shaving milliseconds off the
audio budget had not helped.
Video is the master. In a game streamer the video leg is the input-feel budget
and must never be inflated to satisfy the audio clock, while audio tolerates
small crossfaded corrections that are inaudible — and `crossfade_drop` already
applies them. So audio moves:
audio_e2e = (now + buffered_ahead + clock_offset) − pts_ns
av_offset = audio_e2e − video_e2e (> 0 ⇒ audio behind the picture)
`AvSync` smooths that with an EWMA, ignores what sits inside a deadband no
listener can detect, refuses the implausible outright rather than clamping it
(a wall-clock step must not steer the ring), and proposes a depth.
Continuity outranks sync, always. `JitterPolicy::set_sync_target` only ever
takes a REQUEST, clamped between the existing underrun-driven floor and the
hard cap. A link whose jitter genuinely needs more buffer than the picture is
away keeps its buffer and the residual is reported — sync can never starve the
ring into dropouts. `None` is the default and reproduces the previous behaviour
exactly, so the four client rings can adopt this one at a time without
diverging.
Two upstream defects found on the way, both prerequisites:
* The host stamped `pts_ns` at ENCODE time, inside the loop draining an
already-accumulated chunk, so every frame of a chunk carried near-identical
timestamps describing when we got round to encoding. Harmless while nothing
consumed it; a sync loop regulating against it would regulate against a
fiction. It now comes off the capture clock.
* The host did not pace. One capture callback hands over a whole quantum — 5 ms
when the graph honours our ask, 21.3 ms on a VM, where stock PipeWire raises
`min-quantum` to 1024 — and the loop drained all of it into back-to-back
`send_datagram` calls. The wire carried a 4-5 frame burst then ~21 ms of
nothing, and a ring can only absorb that by standing a burst period deep.
Frames now leave on the audio clock, which costs no average latency.
And the reason none of this was visible: `buffer_ms`/`target_ms` existed only
as a `tracing::debug!` line, absent from `Stats`. On a Deck the client runs
under Steam's `reaper` with stdout on a pipe nobody can read, so the one number
identifying a deep ring was unobtainable on the device reporting the latency.
The HUD now carries `audio buffer N ms · a/v ±N ms` — both, because a deep ring
on a jittery link is correct and only the offset separates that from audio held
late. The host also reports its negotiated quantum against the one it asked
for, per capture open rather than once per process.
Verified: 364 core + 40 presenter tests on Linux, clippy -D warnings clean on
punktfunk-{core,host} + pf-{client-core,presenter}, fmt clean. New tests pin
the safety invariant (sync cannot pull the target below the continuity floor on
any preset), that `None` leaves the policy bit-identical, and that a device
quantum exceeding the hard cap does not panic `Ord::clamp` inside a realtime
callback.
Android and Apple keep today's behaviour (the `None` default) until their
presenters publish a video figure to align against; design/audio-latency-
overhaul.md carries the plan.
|
||
|
|
1482e6b373 |
docs(client): all four VAAPI legs have decoded — the evidence table said two never had
The H.264 and H.265 rows still read "NEVER decoded a frame on any hardware". That stopped being true on 2026-08-07, in the same session that proved AV1: every access unit of the vendored H.264 (250), H.265 (250) and HEVC Main 10 (50) vectors was accepted on .25 (Radeon 780M, RDNA3, Mesa 26.0.3) with no decode error — NV12 for the 8-bit legs, P010 for Main 10, all on the same tiled AMD modifier — and probe_this_machines_libva reports VLD decode for all three profiles. The row records the delivered counts honestly rather than rounding them up: 225/204/45 against 250/250/50 access units, because `finish` shows `outputs.last()` and drops the other pictures an AU bumps, and nothing flushes the DPB at end of stream. That is this rung's own behaviour — D3D11VA delivers all 250 — and it is invisible on punktfunk's zero-reorder host output. It is recorded and asserted rather than fixed: changing the one-frame-per-AU contract touches the pump's deliverable queue, an end-of-stream flush, and the `keyframe`-labels-the-access-unit defect in the same function, so it belongs in a commit that moves all three. Still `verified = false` for all four, and the note says why in the words the unproven-rung test requires: never frame-hash parity-checked. That is not pedantry — the D3D11VA AV1 row two lines above is a rung that decoded 250 frames and produced wrong pixels for every one of them. Parity is what distinguishes them, and this rung exports a tiled dmabuf with no CPU-readable image, so it needs a readback path nothing has written yet. |
||
|
|
79afa9ce79 | Merge branch 'fix/hevc-lowdelay-parity-gate' into integration/decode-aliasing-program | ||
|
|
bb9f482b2c | Merge branch 'fix/vaapi-decode-target-aliasing' into integration/decode-aliasing-program | ||
|
|
dc116d28ca | Merge branch 'fix/vaapi-h264-h265-hardware-proof' into integration/decode-aliasing-program | ||
|
|
d25a20a233 |
feat(vkdecode): the AV1 rungs meet a second tile for the first time
Every AV1 frame either decode rung has ever been measured against is `tile_cols = tile_rows = 1`. The vendored vector is single-tile on all 274 of its frames, so every tile array the conversions fill — `tiles.widths`, `tiles.heights`, the per-tile records — had only ever been written at index 0, and a conversion that wrote tile 0 and left the rest zero would pass the whole suite. Our encoder splits 4K into TWO TILE ROWS. **The fixture.** `lowdelay-3840x2160.ivf.av1`, 261 KB, 60 frames — `punktfunk-host spike --source synthetic --codec av1 --width 3840 --height 2160 --fps 60 --seconds 1 --bitrate 1` on .21 (NVENC, RTX 5070 Ti), wrapped to IVF with `ffmpeg -f obu … -c copy` so `common::split_av1_aus` (the vendored parser's own `IvfIterator`) frames it exactly as it frames the vector, with no second splitter that could disagree. **4K is not a size choice, it is the only shape with the property.** Measured on the same box with the same command: 1280x720, 1920x1080 and 2560x1440 all give `tile_cols = tile_rows = 1`; 3840x2160 gives `tile_cols = 1, tile_rows = 2` with `width_in_sbs_minus_1 = [59]`, `height_in_sbs_minus_1 = [16, 16]`, and both tiles in ONE Tile Group OBU. 60 frames instead of 120 pays for the resolution: 261 KB, under both the 282 KB H.264 and 270 KB H.265 low-delay fixtures. Goldens are libavcodec's software decode, cross-checked between ffmpeg n8.1.2 (Arch x86_64, libdav1d) and 8.1.1 (Homebrew, macOS arm64, libdav1d) whose 746,496,000-byte raw outputs are BYTE-IDENTICAL, not merely equal per frame. 60 of 60 digests distinct. **AV1's frame accounting is asserted, never derived.** The vendored vector is 250 temporal units carrying 274 coded frames of which 24 are hidden; this stream is 60 units, 60 coded, 60 shown, 0 hidden, 0 `show_existing_frame`, 1 key frame. Neither is the general case, so both parity harnesses now take units / decoded / shown as three independent parameters instead of computing one from another, and the CPU guard states all six numbers. **A CPU gate that needed no hardware at all.** `pic_av1`'s new `a_two_tile_frame_fills_both_row_entries_and_leaves_the_rest_zero` pins the second row entry against its OWN `height_in_sbs_minus_1`, requires the two rows to tile the frame exactly, and requires TWO tile RECORDS out of ONE tile group with rows (0,0) and (1,0) — the transposition a square grid could never reveal — each spanning real bytes. The existing one-tile test asserts index 0 is right and `1..` are zero, which a broken multi-tile conversion also satisfies. ⚠⚠ **This is a file, and on AV1 that distinction has already cost a release.** "250/250 delivered frames bit-identical to libavcodec" was true for the entire period the host was shipping only the FIRST TILE of every 4K frame: the verification ran against a vendored file while the truncation lived in packetisation, and the suite stayed green throughout. This fixture closes the multi-tile gap on the DECODE rungs and closes nothing about fragmentation, reassembly, loss or AU boundaries — the golden header, both module docs and the leg docs all say so, at length, so the next reader does not inherit the same false confidence. Legs: `low_delay_host_av1_every_frame_hashes_bit_identical_to_libavcodec` on the Vulkan rung (11 ignored legs now) and on the D3D11VA rung, plus two non-ignored CPU tests. Verified: 11/11 Vulkan parity legs on .21 (RTX 5070 Ti, 610.57.04), the new one 60/60 bit-identical; workspace clippy `-D warnings` and `cargo fmt --all --check` clean on .21. |
||
|
|
e8a7a1e6af |
fix(client/vaapi): the third rung does NOT alias — and now it cannot start to
The D3D11VA and Vulkan rungs both decoded into a surface they were predicting from, on 117 of 120 access units of our own host's low-delay H.264 (`1c54d099` for AV1, `834b2443` for H.264). `pf-vaadec` feeds `reference_frames` from the same `plan.dpb_refs` snapshot, releases its whole `removed` list inline exactly as the two broken conversions did, and neither fix commit touched it. It is still exempt — this is the evidence, and the thing that keeps it true. **Measured on the CPU, no GPU needed.** `walk_for_aliasing` drives the planner and `plan_to_va` over both streams and counts four shapes. On `lowdelay-640x480.h264` the aliasing PRECONDITION is fully present: 117 of 120 access units remove a picture their own `dpb_refs` still names, and on the same 117 the setup picture is handed the slot of a picture that access unit READS — the D3D11VA/Vulkan defect verbatim, in this conversion, today. On the vendored conformance vector both counts are 0, which is why that vector proved nothing on two other backends for two milestones. Aliased submissions: **0 on both**. **Why.** A slot is not a surface here. `plan_to_va` never invents one — every reference it can name is read out of the `surfaces` table it is handed — and the decode target is a separate parameter the caller takes from OUTSIDE that table. `setup_surface` reaches the submission at exactly one field per codec (H.264/H.265 `curr_pic.picture_id`, AV1 `current_frame` and `current_display_picture`); HEVC is doubly safe, because its per-slice `RefPicList` stores an INDEX into `reference_frames` rather than a surface. AV1's documented substitution fallback is the one place the target can be named as a reference, and only where the store resolved nothing at all to prefer. **The exemption was incidental; it is structural now.** It needs the reference table and the decode target to come from ONE snapshot of the bindings, and the rung had that only by writing `free_surface()` and `surface_table()` adjacently at three call sites. Split them and this rung acquires the defect exactly: the table must be the PRE-removal one (that is where the references are), while a free list consulted after the removals offers precisely the displaced picture's surface. `Session::acquire_target` now returns the index, the surface and the table together from `&self`, so a later edit cannot move one call and not the other. No behaviour change: same order, same values, same refusal message. Tests. `no_submission_names_its_decode_target_as_one_of_its_own_references` (both streams, 0) with `taking_the_decode_target_from_the_slot_table_aliases_on_the_low_delay_stream` as the counterfactual that reproduces the defect on 117 of 120 — so the walk demonstrably CAN see it when it is there. `the_low_delay_stream_reassigns_slots_whose_pictures_it_still_reads` pins 0/250 and 117/120 so neither can drift silently. `the_decode_target_can_never_be_a_surface_the_reference_table_names` sweeps every binding state a 4-surface/3-slot pool can hold, and `taking_the_free_surface_after_the_removals_would_hand_out_a_referenced_surface` is the ordering counterfactual. ⚠ One existing test lost a VACUOUS half. `the_setup_picture_routinely_inherits_a_just_freed_slot` asserted the decode target was never also a reference while handing every picture its own never-reused surface id — distinct integers cannot collide, so that assertion could not fail whatever the conversion did. Its real measurement (225 of 250 access units reuse a just-freed slot, which is why the target is a parameter) is kept; the collision half is gone, and the doc says where the question is actually answered and why a recycling pool is what it takes to answer it. Gates, run on `.25` (Radeon 780M, radeonsi, Mesa 26.0.3, VA-API 1.23), this rung being Linux-only: `cargo fmt --all -- --check`; `cargo clippy -p pf-client-core -p pf-vaadec --all-targets --features sdl3/build-from-source -- -D warnings`; `cargo test -p pf-client-core --lib --features sdl3/build-from-source` (171 passed); the same filtered to `video_vaapi_native` with `--include-ignored` (18 passed); `cargo test -p pf-vaadec` (48 passed). Plus the pf-lxcheck2 container for the cross-platform half — fmt, clippy and `cargo test -p pf-vaadec`, all clean. All four VAAPI legs still decode with the refactor in place, not one access unit refused: H.264 225 of 250 access units delivering a frame, H.265 204 of 250, HEVC Main 10 45 of 50 (P010), AV1 250 of 250 — the same counts and the same tiled modifier 0x200000010401b04 those legs recorded before it. ⚠ The H.26x legs live on `fix/vaapi-h264-h265-hardware-proof`, not on this branch, so they were run by overlaying that commit's test module onto the scratch tree; only the AV1 leg and the libva probe are reachable from here. This is a decode measurement, not frame-hash parity — the rung exports a driver-tiled DRM-PRIME dmabuf, so there is no CPU-readable image to hash. The alias assertions above are the real evidence and they need no device. ⚠ NOT taken: `finish`'s `outputs.last()`, which ships one frame per access unit and drops the rest of what a bump displaces (225/204/45 against 250/250/50), with no end-of-stream flush. It cannot bite punktfunk — hosts emit zero-reorder output, so `outputs` never holds more than one picture — and fixing it changes `decode()`'s one-frame-per-access-unit contract with the pump (it wants a deliverable queue, which `video_vk_native` already keeps) plus an end-of-stream flush and the `keyframe`-labels-the-access-unit defect in the same function. It is recorded and asserted on that other branch, whose three delivered-count assertions any fix has to move in the same commit; doing that from here, blind to them, would be worse than leaving it. |
||
|
|
f0702f3e06 |
feat(vkdecode): HEVC's exemption stops being an argument and becomes a vendored stream
`fd6241a2` made HEVC's freedom from the release-ordering defect falsifiable on CPU and
recorded what was still missing: no low-delay HEVC stream was vendored, so the exemption
rested on a structural argument plus one throwaway measurement. This vendors the stream,
and the exemption HELD.
**The fixture.** `lowdelay-640x480.h265`, 270 KB, 120 pictures — `punktfunk-host spike
--source synthetic --codec h265 --width 640 --height 480 --fps 60 --seconds 2 --bitrate 1`
on .21 (NVENC, RTX 5070 Ti, driver 610.57.04). Deliberately the H.264 sibling's resolution
and frame count: the two are then directly comparable, 640 and 480 are both multiples of
MinCbSizeY so there is no conformance window and a hash mismatch can only be decode rather
than readback geometry, and 270 KB sits alongside the 282 KB already accepted for H.264.
Goldens are libavcodec's software decode, cross-checked BIT-IDENTICAL across ffmpeg n8.1.2
(Arch, x86_64) and 8.1.1 (Homebrew, macOS arm64), 120 of 120 digests distinct.
**The exemption held, measured rather than argued.** `sps_max_dec_pic_buffering_minus1 = 4`
against the four pictures 8.3.2 keeps marked in steady state, `sps_max_num_reorder_pics = 0`,
`numRefL0 = 1` — a five-picture DPB filled exactly by four references plus the current
picture. 115 of the 120 access units retire a picture, and `removed ∩ dpb_refs` is **0 of
120**. A 300-picture 1080p stream from the same host reports the same shape: 295
retirements, 0 intersections. It is the encoder and not the resolution, exactly as for
H.264.
**A zero proves nothing on its own, so the fixture is pinned by its counterfactual.**
`test-25fps.h264` reported zero for two milestones while every stream we ship aliased on
99% of its frames. So the guarantee here is not "we looked and it was fine": hand
`plan_to_dxva_h265` the marked DPB as it stood BEFORE `decode_rps` — the mutation a
snapshot move would cause, reconstructed exactly as `dpb_refs(N-1) ∪ {stored(N-1)}` — and
the alias appears on **115 of 120** access units, driven through the real conversion rather
than through planner arithmetic. If a regeneration ever produced a stream that reordered,
or a DPB deeper than its reference count, that 115 collapses to 0 and the tests say so
instead of continuing to pass.
**The two rungs are exempt for different reasons, and the asymmetry is now a gate.** DXVA
binds the whole marked DPB — `RefPicList` is spec-defined that way, and an RFI long-term
anchor has to survive in it — so its exemption really is `H265Planner`'s snapshot ordering,
one call away from being untrue. `plan_to_vk_h265` never reads `dpb_refs` at all:
`pReferenceSlots` is the slots the operation uses, so it binds the current RPS sets, which
`decode_rps` itself derives and which therefore cannot name a picture that same RPS just
dropped. A new test feeds that conversion the identical widened snapshot and asserts
nothing changes, so a future change making the Vulkan rung bind the marked DPB — a
legitimate thing to want, since a *Foll* anchor invisible to the hardware is the RFI
failure shape — fails loudly instead of silently acquiring the defect.
What the Vulkan pixel leg adds is therefore NOT aliasing coverage, and its docs say so:
it is the first HEVC frame either rung has decoded from our own encoder, under a DPB that
retires and reissues a slot on 115 of 120 access units back to back, where the vendored
vector's reordering keeps that eviction slack.
Legs: `low_delay_host_h265_every_frame_hashes_bit_identical_to_libavcodec` on the Vulkan
rung (10 ignored legs now, up from 9) and on the D3D11VA rung, plus three non-ignored CPU
guards that run in ordinary CI.
Verified: 10/10 Vulkan parity legs on .21 (RTX 5070 Ti, 610.57.04), the new one 120/120
bit-identical; workspace clippy `-D warnings` and `cargo fmt --all --check` clean on .21.
|
||
|
|
fd6241a24f |
fix(dxvadec): the review round — a doc that had become false, a warn-storm on renegotiation, and HEVC's exemption made falsifiable
Four findings, all real. **`SlotMap`'s own docs had become false.** "feed it every `DpbUpdate` in decode order (via `Self::apply` or `plan_to_vk`, which applies internally)" — `plan_to_vk` no longer applies internally, which is the entire point of the change, and `release`'s docs named it as one of the two things that may free a slot. A reader following those docs would build the next caller wrong in exactly the way this commit's parent fixed. Both now say which conversions defer, which one does not, and why H.265 is the one that does not. **The deferred release warned on a legitimate event.** `release_deferred` warned per id when a deferred release found no slot — but a renegotiation replaces the whole `Session`, and with it the slot map, INSIDE `plan`, while the planner's own drain reports every drained picture in that same access unit's `removed`. Every one of those ids then misses, and nothing is wrong. `debug!`, with the legitimate cause named so the illegitimate one stays diagnosable. **HEVC's exemption was asserted only in its consequence.** `the_current_picture_is_ named_by_curr_pic_and_never_aliases_a_reference` checked that no reference shares the decode target's slot — which on the vendored vector holds whether or not the reasoning behind it does. That is precisely how the H.264 leg passed for two milestones. The test now also asserts the PLANNER property the exemption rests on (`removed ∩ dpb_refs = ∅`, falsified by moving `dpb_snapshot()` above `decode_rps`), and records that the low-delay measurement was 0 of 300 against H.264's 297 of 300 from the same host and the same run. It also records what is still missing: no low-delay HEVC stream is vendored, so HEVC's freedom is a re-derivable argument plus one measurement, not a standing hardware leg. **Two stale cross-references.** Both AV1 conversions told the reader the H.264/H.265 zero was "measured on reordering vectors and not a proof" — the open question this commit's parent closed. They now say what the answer was. |
||
|
|
834b244301 |
fix(client): the H.264 twin was real — every low-delay picture decoded into a surface it predicted from
The AV1 review round flagged the H.264 leg as "plausibly the same defect, traced in source, not reproduced" and deliberately did not touch it. It is reproduced now, and it is worse than the AV1 one: it fires on 297 of 300 access units of every stream a punktfunk host emits, at 720p, 1080p and 2160p alike, on BOTH the DXVA rung and the Vulkan one. **Decided on the CPU, no GPU needed.** `H264Planner` snapshots `dpb_refs` in `begin_picture`, BEFORE `finish_picture` runs 8.2.5's marking and C.4.5.3's bump, so a picture the sliding window unmarks and the bump then evicts lands in both `dpb_refs` (which `RefFrameList` is built from) and `dpb.removed`. The conversion released the whole `removed` list and then assigned the decode target a slot; `SlotMap::assign` takes the lowest free slot, which is the one just vacated. `CurrPic = N` and `RefFrameList[k] = N`, in one submission. The two conditions have to coincide in ONE access unit, and low-delay H.264 is exactly what makes them: `max_num_reorder_frames = 0` means the evicted picture has already been output, which is what makes it evictable at all. NVENC seals it by writing `max_num_ref_frames = 3` ALONGSIDE `max_dec_frame_buffering = 3` — a DPB exactly as deep as its reference count — so the window unmarks the oldest reference in the very unit whose bump drops it. The aliased picture is `ref_idx 2` of a three-entry `num_ref_idx_l0_active` list: addressable by any macroblock, not a spare. **Why two hardware-proven codecs and four GPUs never saw it.** `test-25fps.h264` is level 1.3 with no VUI `bitstream_restriction`, so `dpb_limit` falls back to A.3.1's level ceiling and gives a 7-frame DPB against 2 reference frames — the window unmarks two units before the bump can evict — and it REORDERS, which keeps an unmarked picture alive past the unit that unmarked it. Two independent reasons, both properties of that vector rather than of H.264. It measured zero and passed 250/250 throughout. `data/lowdelay-640x480.h264` is vendored to close exactly that: our own host's output, 120 pictures, goldens from libavcodec cross-checked bit-identical across two ffmpeg builds on two architectures. **The fix is the AV1 fix.** `DecodePlanDxva` and `DecodePlanVk` grow `release_after_decode`, the conversions hand the removals back instead of applying them, and the callers release them once the decode op is issued. It costs no slot the map does not have: `SlotMap::new` allocates `max_dpb_frames + 1` and the DPB never exceeds `max_dpb_frames`, so a free slot always exists with the whole `removed` list still held — measured, peak 4 of 4 on the stream that defers on 117 of 120 units. The Vulkan rung breaks on it in both DPB modes and neither loudly: DISTINCT hands the aliased reference the same array layer the setup writes; COINCIDE clears `slot_image[setup]` in the binding sync and the reference then resolves to no bound image, dropping out of `pReferenceSlots` with a `trace!`. Its deferred release runs on the FAILURE paths too — the fallible region's Result is held rather than `?`-ed, because seven exits sat between the conversion and the release and each would have leaked a slot. `a_full_dpb_bump_reuses_the_slot_but_the_pool_model_binds_a_fresh_image` asserted the aliasing as "the planner's normal behaviour": an authored depth-1 stream whose AU1 references the picture it evicts. It now asserts the opposite, which is the defect in two lines. New evidence, all of it runnable: the CPU proof pins BOTH numbers (0 on the vector, 117 of 120 on the low-delay stream) so neither can drift silently; the ledger-pressure test measures the peak; and a low-delay parity leg is added to `pf-vkdecode`'s `gpu_parity` and `pf-client-core`'s `video_d3d11_native::parity` so both rungs are held to what they stream rather than only to what they conform to. |