6edd7009107a4b441ee1fdc18072d5bd73acfe41
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6edd700910 |
fix(client/gamepad): honor an explicit controller type — the per-pad arrival was re-declaring the physical pad
The "Controller type" setting reached the Hello and stopped there. Every pad then opened with a GamepadArrival carrying its DETECTED kind, and the host builds each virtual device from that arrival — the session default is only the fallback for a pad that never declares one. So "emulate my DualSense as a DualShock 4" put a DualSense on the host the instant the controller connected, and no amount of reconnecting helped. Apple and the native clients both; Android already applied the setting per pad. The setting now rides the declaration too: explicit wins for every slot, Automatic keeps per-pad detection so a mixed session stays honest. The physical kind still drives the LOCAL feedback paths — a DualSense emulated as a DualShock 4 keeps its lightbar and adaptive triggers, and a Deck keeps its rumble keep-alive. Regressed in |
||
|
|
8362d57001 |
fix(vdisplay/windows): exclusive topology gets re-asserted — a verified isolate is not durable
ci / docs-site (push) Successful in 1m8s
ci / web (push) Successful in 1m40s
apple / swift (push) Successful in 5m9s
ci / bench (push) Successful in 6m36s
ci / rust-arm64 (push) Successful in 10m12s
deb / build-publish (push) Successful in 9m32s
decky / build-publish (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 30s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 59s
android / android (push) Canceled after 13m40s
apple / screenshots (push) Canceled after 8m25s
arch / build-publish (push) Canceled after 13m44s
ci / rust (push) Canceled after 13m45s
deb / build-publish-host (push) Canceled after 12m5s
deb / build-publish-client-arm64 (push) Canceled after 7m5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 2s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 2m28s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 1m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 1s
windows-host / package (push) Canceled after 13m45s
The CCD isolate verifies "sole active desktop" at commit time and is never checked again. On a hybrid Intel+NVIDIA box the internal panel is re-activated moments later (the isolated topology is deliberately not saved to the CCD database so teardown can restore the user's layout, and the post-isolate resize/HDR churn — or the panel's own driver, or display-poller software — re-resolves back to the stored layout). Proven on-glass: seconds after the verify, CCD showed the panel ACTIVE beside the virtual display while the host still believed it was exclusive — cursor and windows can land off-stream, and the lock screen can land on the physical panel. A group-scoped watchdog now re-queries every PUNKTFUNK_EXCLUSIVE_REASSERT_MS (default 2 s, 0 disables) while an EXCLUSIVE isolate is live and re-issues the isolate when a non-managed display crept back, logging who-is-fighting escalation (3×WARN → 1×ERROR → DEBUG). Gated on a new GroupState.ccd_exclusive flag so a Primary group's deliberately-lit panels are never "fixed". Cycles take the state lock via try_lock with a sliced sleep, so teardown's stop+join under the state lock cannot deadlock and is bounded by ~250 ms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6c2c8b6eab |
feat(mgmt/web): surface the host.env encoder pin so a conflicting GPU choice isn't invisible
listGpus gains encoder_pin (PUNKTFUNK_ENCODER when it actually pins something — unset/auto stay null), and the console's GPU card shows it: a muted note when the pin is compatible, an amber warning naming the pinned vendor and the next session's GPU when it contradicts the selection (the host overrides such a pin at session open — without this field the selection just looked broken). Docs updated to say the adapter wins. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
751d1de506 |
fix(encode/windows): a stale PUNKTFUNK_ENCODER pin no longer wedges a conflicting GPU selection
On a hybrid box, picking a GPU in the web console whose vendor contradicts a host.env PUNKTFUNK_ENCODER pin produced an unrecoverable session: the pin won backend selection while the adapter followed the console, the wrong-vendor encoder failed deterministically at submit, the reset ladder burned its 5 in-place rebuilds on it, and the client reconnected into the identical wall forever (~10 s per cycle, no visible reason). Three legs: - windows_resolved_backend() now reconciles: a hardware pin whose vendor contradicts the selected GPU is overridden by the adapter-derived backend (capture + encode share one adapter, so honoring the pin can only fail); open_video warns loudly when a pin loses. The reconciliation is a pure, unit-tested table (resolve_windows_backend). - the QSV wrong-adapter bind is typed TerminalEncoderError, and the stream loop's reset ladder ends the session immediately on it instead of feeding a deterministic config error 5 futile rebuilds. - the un-pinned path was already correct (verified on-glass: NVIDIA preference + no pin = stable AV1 10-bit HDR on NVENC), so the override simply takes the conflict case onto that path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
36e566052f |
fix(apple/cursor): a pointer whose bitmap has not landed must not vanish
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m5s
ci / bench (push) Successful in 7m30s
apple / swift (push) Successful in 6m42s
ci / rust-arm64 (push) Successful in 10m2s
decky / build-publish (push) Successful in 27s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
deb / build-publish-host (push) Successful in 10m7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
android / android (push) Successful in 12m14s
deb / build-publish (push) Successful in 12m47s
windows-host / package (push) Successful in 16m3s
docker / build-push-arm64cross (push) Successful in 24s
docker / deploy-docs (push) Successful in 50s
arch / build-publish (push) Successful in 18m4s
deb / build-publish-client-arm64 (push) Successful in 10m45s
flatpak / build-publish (push) Successful in 7m12s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m25s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m7s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m17s
ci / rust (push) Successful in 28m53s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m45s
release / apple (push) Successful in 33m5s
apple / screenshots (push) Successful in 24m18s
`resetCursorRects` wore the host shape only when `hostCursors[st.serial]` hit,
and fell through to `invisibleCursor` on a miss. But a miss is the ROUTINE case,
not a degenerate one, and it is not a reason to hide the pointer:
* State (0xD0) is a per-frame datagram and announces the new serial the moment
the host QUEUES the bitmap on the reliable control stream, so on every single
shape change the client knows a serial before it holds the pixels.
* The shape ring drops the NEWEST under burst (CURSOR_SHAPE_QUEUE = 8) and the
host never re-sends it — it only sends on a serial CHANGE — so a dropped
serial stays un-backed until the pointer next changes shape. Crossing a
toolbar flips arrow/I-beam/hand/resize several times a second, and each flip
mints a fresh serial and a fresh bitmap, so bursts are ordinary.
Either way the pointer BLINKED OUT rather than lagging, and in the dropped case
it stayed gone for as long as the pointer held that shape — reported on glass as
"the I-beam never appears over text fields, every other cursor is fine".
Hold the last worn shape across the gap: only `st.visible == false` (the host
says the pointer is hidden) may hide it now, never a missing bitmap. Worst case
is a briefly stale pointer. This also makes two comments that already claimed
this behaviour true — the shape-rejected warning's "keeping the previous cursor"
and CURSOR_SHAPE_QUEUE's healing claim, both of which were fiction.
Host side is exonerated: on .221 the poller sees the I-beam handle (0x10005,
CURSOR_SHOWING set), and the monochrome AND-over-XOR conversion renders a correct
glyph — black beam, white outline — so the bitmap that goes on the wire is good.
swift build PunktfunkKit + cargo check punktfunk-core green; on-glass owed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
6a9b3b0f28 |
fix(windows/cursor): a re-rendered pointer keeps its handle — re-probe the extent
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m9s
ci / bench (push) Successful in 7m23s
apple / swift (push) Successful in 6m15s
deb / build-publish (push) Successful in 9m17s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
deb / build-publish-host (push) Successful in 9m54s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
android / android (push) Successful in 12m39s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m8s
ci / rust-arm64 (push) Successful in 14m7s
deb / build-publish-client-arm64 (push) Successful in 10m39s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6m38s
arch / build-publish (push) Successful in 19m9s
windows-host / package (push) Successful in 19m10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9m11s
docker / deploy-docs (push) Successful in 31s
docker / build-push-arm64cross (push) Successful in 8m32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m15s
ci / rust (push) Successful in 31m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m38s
apple / screenshots (push) Successful in 25m36s
The GDI shape poller rasterises only when the HCURSOR VALUE changes. But Windows rebuilds the system cursors at a new size whenever the display scale under the pointer changes, and it does that behind a handle that never moves — the shared arrow is 0x10003 for the whole session. So the cache latched whatever size the pointer happened to have when the poller started and never let go. That window is not rare, it is the norm: a fresh virtual display is created at Windows' RECOMMENDED scale and only picks up the client's saved PerMonitorSettings override a beat later, while the poller starts within a second of the monitor appearing. Sample inside it and the session forwarded — and composited — a 96 px pointer over a 100 % desktop for its entire life, which reads on the client as a pointer 3x too large while every other thing on the streamed desktop is correctly sized. Scaling on the client cannot undo it: the bitmap is proportional to the video, it is just proportional to the WRONG scale. Re-read the bitmap's extent on a slow cadence whenever the handle is unchanged — dimensions only, no pixel copy, 4 Hz — and drop the cache when it moved, so the next tick re-rasterises and publishes a new serial. Observing the extent itself rather than a DPI proxy also covers the accessibility pointer-size slider and any other cause of a same-handle re-render. Windows-side clippy -D warnings green via scripts/wincheck.sh; on-glass owed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1421e235f2 |
fix(host): hdr-p010-selftest silently ignored its size argument
apple / swift (push) Successful in 5m41s
ci / web (push) Successful in 1m24s
ci / docs-site (push) Successful in 2m15s
windows-host / package (push) Successful in 11m2s
arch / build-publish (push) Successful in 12m41s
ci / rust-arm64 (push) Successful in 9m56s
ci / bench (push) Successful in 7m52s
android / android (push) Successful in 17m58s
decky / build-publish (push) Successful in 28s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
deb / build-publish (push) Successful in 9m2s
docker / build-push-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 11s
ci / rust (push) Successful in 21m47s
deb / build-publish-host (push) Successful in 9m27s
deb / build-publish-client-arm64 (push) Successful in 8m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m31s
apple / screenshots (push) Successful in 29m0s
`args` starts AT the subcommand (main builds it with `env::args().skip(1)`), so a subcommand's own optional arguments begin at index 1 — cf. `args.get(1)` in the `service` arm. This arm read `skip(2)`, swallowing the first optional argument. The documented invocation `hdr-p010-selftest 1920x1080 nvidia` therefore picked up the vendor (it is in the second slot) and ran at the 64x64 DEFAULT. A size-only `hdr-p010-selftest 1920x1080` parsed nothing whatsoever and still printed PASS. Nothing warned, because an unrecognised token is the only thing that errors and no token was ever inspected. The size is the entire point of the flag: the arm's own comment says heights like 1080 are not 16-aligned and exercise a different driver path. So the self-test has only ever validated the one geometry that exercises the least, and the sweep's W7 gate — 1920x1080 and 5120x2880 on the RTX box — could not actually run as written. Found by running that gate: both sizes printed "HDR P010 self-test (64x64 ...)" with byte-identical plane layouts (row_pitch=128, expected_total=12288 — those are 64x64's). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
28bbaa5250 |
tools: check and lint Windows-only Rust from a Linux dev box (scripts/wincheck.sh)
ci / web (push) Successful in 1m30s
ci / docs-site (push) Successful in 1m31s
ci / rust-arm64 (push) Successful in 9m50s
ci / bench (push) Successful in 7m15s
apple / swift (push) Successful in 5m41s
android / android (push) Successful in 12m23s
arch / build-publish (push) Successful in 17m26s
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
windows-host / package (push) Successful in 18m55s
deb / build-publish (push) Successful in 9m9s
deb / build-publish-client-arm64 (push) Successful in 7m29s
ci / rust (push) Successful in 22m14s
deb / build-publish-host (push) Successful in 11m27s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 22m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m15s
docker / build-push-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 23s
apple / screenshots (push) Successful in 26m13s
Linux CI never compiles `#[cfg(target_os = "windows")]` code, so a Windows-side edit was
unverifiable until the Windows CI job ran or someone tested on a real box. The pf-capture sweep's
Phases 3–6 used an ad-hoc version of this harness in a scratch directory; committing it means the
next Windows change does not have to rediscover the recipe.
The obvious in-tree command fails, and not because of the Windows code:
cargo check --target x86_64-pc-windows-msvc -p pf-capture
dies in build scripts that compile C for the target — audiopus_sys (cmake wants a VS generator),
then ring (needs an MSVC C compiler plus the Windows SDK headers). Both enter through
punktfunk-core's `quic` feature.
So the script generates a workspace whose members are Cargo.toml copies with `src` SYMLINKED at the
real crate directories — nothing to keep in sync, no copies to go stale — plus a ~30-line stub
punktfunk-core carrying only `Mode` and `quic::HdrMeta`, which is provably all the Windows capture
stack uses. rustls, ring and opus never enter the graph. Every path dep that is not a generated
member points at the real crate.
Covers pf-frame, pf-win-display and pf-capture with --all-targets, so the Windows `#[cfg(test)]`
modules are type-checked too. ~10 s cold, ~1 s warm, into target/wincheck (gitignored).
Verified non-vacuous: a deliberate type error planted in a windows-only file
(idd_push/stall.rs) is caught and fails the run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
0d69ebf60b |
ci(windows): run pf-capture's tests instead of only type-checking them
Phase 0 of the sweep added `clippy -p pf-capture --all-targets` to this workflow, which type-checks all 19 Windows `#[test]`s and executes none of them. They were decorative. The workflow's own comment explains why pf-encode's tests cannot run here — nvenc link-imports NvEncodeAPICreateInstance, which resolves only against the driver's import lib. That reasoning does not extend to pf-capture: it has no encoder dependency at all (`cargo tree -p pf-capture` lists no nvidia/ffmpeg/libvpl/pyrowave), so its test binary links against nothing the runner lacks. Confirmed empirically rather than reasoned: `cargo test --release -p pf-capture` was run on a Windows dev box against a workspace checkout — it linked, executed and passed, building in ~51 s off an existing release target dir. --release keeps it in the one C:\t\release tree, so it does not trip the C1069 disk exhaustion a second debug dep tree causes. 18 of the 19 run here (StallWatch + ring-generation masking, the cursor shape→wire truth table, and `f32_to_f16`'s rounding-carry/saturation edges); all are pure — no Win32, no device, no desktop. The 19th, `hdr_p010_selftest_intel_1080_live`, is `#[ignore]`d because it needs a real Intel adapter. This is Windows-only code no other job can execute, so it was the cheapest coverage still on the table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1423b63333 |
fix(gamestream): the HDR SDR-downgrade latch gated the capture offer but not the negotiation (X6)
`pf_capture::hdr_capture_failed()` had exactly one consumer: `open_portal_monitor`, which uses it to drop the HDR offer (`want_hdr && !hdr_capture_failed()`). The RTSP negotiation consulted only `gnome_hdr_monitor_active()`. So once the latch was set — an HDR negotiation timed out, the monitor left HDR mode between probe and negotiation, NVIDIA EGL not listing LINEAR for XR30, a pre-50 Mutter — the host kept telling the client HDR while capturing and encoding SDR: `cfg.hdr` flowed to `open_portal_monitor`, which then silently opened the SDR offer. The client renders an SDR stream as PQ. Washed out, wrong gamut, and no error anywhere. The latch is sticky until host restart, so every reconnect repeated it. Consulted at RTSP honor time rather than folded into `host_hdr_capable()` for the same reason as the colour-mode probe next to it: that fn is the STATIC serverinfo capability (it decides whether to advertise SCM_HEVC_MAIN10 at all), and this is a live per-session fact. The native plane needs no equivalent — `capturer_supports_hdr()` is hard-false on Linux, and the latch is a fact about the portal capturer, which that plane never uses. Noted in handshake.rs so it isn't re-derived. Listed in the sweep's confirmed-defect register as cross-cutting/medium, then absent from every phase of its implementation plan. Verification is on-glass (force the latch, reconnect a client that requests HDR, confirm the Welcome/SDP reports SDR). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
59e33ad11d |
Merge origin/main into the pf-capture sweep
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m10s
ci / bench (push) Successful in 5m43s
windows-host / package (push) Successful in 10m48s
ci / rust-arm64 (push) Successful in 13m38s
decky / build-publish (push) Successful in 34s
deb / build-publish (push) Successful in 12m32s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 40s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 21s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
android / android (push) Successful in 17m7s
deb / build-publish-host (push) Successful in 9m49s
arch / build-publish (push) Successful in 17m59s
ci / rust (push) Successful in 21m32s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6m51s
docker / build-push-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 26s
deb / build-publish-client-arm64 (push) Successful in 9m55s
apple / swift (push) Successful in 6m10s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 23m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 23m24s
apple / screenshots (push) Successful in 24m29s
Upstream landed `fc335b39` (negotiate the cursor around what the encoder can blend) on the same files this sweep restructured. Merged rather than rebased: the sweep MOVED ~2,000 lines out of `linux/mod.rs` into `pipewire.rs`/`portal.rs`/`pw_*.rs`, so a rebase would have re-fought the same conflict in up to nine commits; merging resolves it once with both sides in view. The repo already carries merge commits (`land/sweep-all`). Two conflicts, both resolved by keeping BOTH changes: * `linux/mod.rs` — `PortalCapturer::open` gains upstream's `want_metadata_cursor` AND keeps the sweep's `PortalSession` teardown, so the portal threads now take `(setup_tx, quit_rx, want_metadata_cursor)`. The second conflict was upstream editing inside the region Phase 5.1/5.3 moved out; the split wins and upstream's change is ported to where the code now lives: `choose_cursor_mode`'s new `want_metadata` ladder (4 arms — prefer Embedded when the encoder can't blend, and warn-then-take Metadata when Embedded isn't advertised) is now in `portal.rs`, taken verbatim. * `gamestream/stream.rs` — the pooled-capturer slot keeps upstream's third reuse key (`metadata_cursor`, beside HDR-ness) AND the sweep's `result.is_ok() && capturer.is_alive()` re-pool gate. Both guard the same slot against different failures: theirs against reusing a session negotiated for the wrong cursor mode, the sweep's (L2) against reusing a dead one. Everything else auto-merged, including the `want_metadata_cursor` thread through `host/capture.rs`'s facade and `native/stream.rs`'s `session_plan::cursor_blend_for`. Verified after the merge: workspace `cargo test` green, `clippy --workspace --all-targets` clean on Linux AND on x86_64-pc-windows-msvc, `cargo fmt --check --all` clean, pf-capture 38/38. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
14914bc72f |
test(pf-capture): the suite the splits enable — 38 Linux + 19 Windows tests (Phase 6)
The plan's priority order was "every producer- or OS-controlled parser, blend, geometry guard or negotiation decision", none of which had a test before this sweep (X3). Phase 5's splits are what made most of them reachable without a compositor or a driver. **6.1 `bitmap_extent`** — extracted from `update_cursor_meta`, which is the guard whose own SAFETY proof says a missing bound "SIGSEGVs inside the PipeWire `.process` callback (a segfault `catch_unwind` cannot catch)". Every input except the region size is producer-written. 5 tests: a bitmap that fits (with and without header/pixel offsets); the last-row rule (`stride·(bh−1) + row`, so a bitmap ending flush against the region is accepted rather than rejected by padding that is never read — and one byte short is rejected); anything past the region; degenerate geometry; and four distinct overflow vectors including the near-`i32::MAX` stride the SAFETY comment calls out. One assertion I first wrote was wrong, not the code — with a single row `stride·0 == 0`, so even a `usize::MAX`-wide row is arithmetically in range; the test now exercises the ≥2-row case that really overflows and says why the other is fine (the caller has already capped `bw` at 1024). **6.2 the composite blits** — 8 tests over `composite_cursor` / `composite_cursor_rgb10`: every packed `PixelFormat` arm lands the colour in its OWN channels (so a byte-order slip cannot pass); clipping off all four edges plus six fully-outside positions; zero-alpha, `visible: false` and no-bitmap-yet all drawing nothing; the integer alpha blend; NV12/Yuv444 declined rather than mis-blitted; and for the 10-bit path a bit-exact round trip of an untouched pixel (including the two alpha bits the repack must preserve), the R-at-bit-20 vs R-at-bit-0 distinction between `X2Rgb10` and `X2Bgr10`, and the same clipping. Plus `decode_bitmap_pixel`'s four byte orders. **6.4 the pod builders** — 4 tests. The `dataType` bitmask is pinned per Buffers pod, because each bit is load-bearing in a different direction: the mappable pod MUST include DmaBuf (or gamescope's modifier-bearing format pod wins and the buffer intersection is empty — a link silently stuck in "negotiating"), the SHM-only pod MUST exclude it (or Mutter hands dmabufs and the race-free download path is not), and the dmabuf pod MUST exclude the mappable types (or an HDR session can be handed a MemFd buffer, which Mutter paints 8-bit ARGB32 regardless of the negotiated 10-bit format). Also: every pod parses back through `Pod::from_bytes`; the HDR pods carry MANDATORY PQ + BT.2020 + LINEAR-modifier; and only the NV12 offer pins the colour matrix/range. The `dataType` reader parses the SPA property layout literally (`key, flags, size, type, value`) — a "find the first plausible-looking int" heuristic read the `size` word, which is also 4, and reported the wrong mask. **6.5 `FrameToken` generation masking (W14)** — 2 tests, with `IDD_GENERATION` parked two below the 24-bit boundary so the WRAP is what gets exercised: every minted generation is non-zero, fits the token's field, and survives the pack/unpack round trip `try_consume` performs; and the cleared- `latest` 0 sentinel never matches a live generation. **6.6 the cursor conversion (Windows)** — `convert`'s pixel logic extracted from its GDI plumbing into `mono_planes_to_rgba` / `apply_and_mask_alpha` / `alpha_is_empty`, which is what makes it testable at all (the caller needs a live `HCURSOR` and a screen DC). 6 tests: the four-state AND/XOR truth table in one row; transparency surviving without an invert neighbour; the invert outline covering all eight neighbours, overwriting only TRANSPARENT ones, and clipping at the edges; the alpha-less colour cursor taking alpha from the AND mask; and a short mask not panicking. **6.3 / 6.7** landed with the fixes they guard (`negotiation_plan` in 2.3, `f32_to_f16` in 3.5). 38 Linux tests, up from 6 at the start of the sweep — ci.yml already runs them. 19 `#[test]`s on the Windows side; Phase 0.1's `--all-targets` lint type-checks them all, but note it does not RUN them (the workflow lints only), so the Windows ones execute on a Windows box. clippy --all-targets clean on both targets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
306f4a514d |
refactor(pf-capture): structural splits + collapse the restated signal set (Phase 5)
Refactors LAST, after every defect fix, so no behaviour change hides in a move diff — and so the newly-exposed review surface was already clean when it landed (the WP7 discipline). 28f8fc71's recorded trap (an inline `use super::X` inside a moved body silently changing meaning) was audited first: every `use super::` in the moved code is either at module level — where `super` still means `linux` — or inside a `mod tests` that moved with its parent. 5.1 `mod pipewire` → `linux/pipewire.rs`. It was 1,900 of that file's lines. `mod.rs` is a directory module, so a plain `mod pipewire;` resolves with no `#[path]`. 5.2 out of that, `linux/pw_pods.rs` (the 7 param-pod builders + `serialize_pod` + the PQ constant and its test) and `linux/pw_cursor.rs` (`CursorState`, `decode_bitmap_pixel`, `update_cursor_meta`, `dst_offsets`, both `composite_cursor*`). The pods are the crate's WIRE surface — a missing property is not a compile error but a link that stalls in `negotiating`; the cursor half is producer-driven and bounds-critical. Both are now pure enough to unit-test without a compositor, which is what Phase 6 needs. 5.3 `linux/portal.rs`: the ScreenCast/RemoteDesktop handshake, the cursor-mode choice and GNOME's BT.2100 probe — the async/tokio/zbus control plane, separated from the realtime half. Zero re-exports changed: `mod.rs` re-exports `gnome_hdr_monitor_active` at its old path. 5.4 `idd_push/open.rs` (the whole one-time construction path + `SharedObjectSa` + `AttachTexFail`) and `idd_push/compose_kick.rs`. Read `open.rs` when a session will not START and the parent when one stops flowing; the steady state stays with the parent by decision. Also moved the ~65-line stall REPORTING block out of `try_consume` (the hot loop) into `StallWatch::report`, taking its two correlation counters with it — they were capturer fields nothing else touched. 5.5 `windows/dxgi/selftest.rs`: `p010_reference`, both self-tests, `hdr_p010_convert_bars_on_luid`, `f32_to_f16` and the two test modules — the validation path, none of which runs in a session. The two `pub` entry points are re-exported, so `main.rs`'s subcommand and pf-encode's live e2e keep their paths. (An explicit `#[path]`, like `idd_push`'s children: this file is itself reached through a `#[path]`, so a bare `mod` would resolve to `windows/selftest.rs`.) 5.6 `CaptureSignals`. The seven shared flags/slots were created in `spawn_pipewire`, `_cb`-cloned one by one, passed as seven of `pipewire_thread`'s parameters, and then re-declared as fields on `PwHandles`, `PortalCapturer` AND `UserData` — the same list written four times, each with its own drifting copy of the doc comment. One `#[derive(Clone)]` struct instead (a refcount bump per field), which also makes it structurally obvious that producer and consumer share ONE set. And `CaptureOpts` for the four trailing `bool`s: four adjacent same-typed positional arguments are a silent-transposition footgun — swap `want_444` and `want_hdr` and it compiles, negotiates the wrong pod family, and surfaces as a black screen ten seconds later. 5.7 trait shape, targeted: `set_active(&self)` → `&mut self` (it took `&self` only because the flag happened to be an `Arc<AtomicBool>` — an implementation detail leaking into the contract); `capture_target_id` ⇒ `resize_output`'s pairing rule stated on both methods and in a cluster note; one-line cluster headers over the 13 methods. NOT done from 5.7: taking the gamescope targets as an `open_*` option instead of the trait method. It would put a Linux-only parameter on the cross-platform `open_virtual_output` and on the host's `capture_virtual_output` facade — a `#[cfg]`-shaped wart in two shared signatures to delete one already-`cfg`'d defaulted method whose lifecycle rule 2.5c had already made harmless. Wrong trade under 5.7's own "no churn for no defect fixed" principle. File sizes: `linux/mod.rs` 2,778 → 770 (target ≤900 ✓), `windows/dxgi.rs` 1,374 → 954, and `windows/idd_push.rs` 2,694 → 1,922. The last two miss the plan's ≤850/≤1,300 targets, which were computed against the ORIGINAL line counts — Phases 1–4 added several hundred lines of SAFETY proofs and defect rationale to exactly these files before the split. The moves themselves are the ones the plan specifies; closing the rest would mean inventing new splits it does not call for. Zero behaviour delta. pf-capture 20/20; workspace clippy --all-targets clean on Linux and on windows-msvc. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
530909a154 |
perf(pf-capture): take three per-frame costs off the Windows HDR hot path (Phase 4)
**4.1 (W10) — the HDR convert allocated two views and mapped a constant buffer per frame, inside
the ring slot's keyed-mutex hold.** So the driver's publisher sat blocked on that slot while the
host created `CreateRenderTargetView`s and Mapped/Unmapped a 16-byte buffer whose contents never
change. Both are lifetime-of-mode facts, not per-frame ones:
- the two P010 plane RTVs now belong to the out-ring SLOT, built once in `ensure_out_ring`
(`out_ring` becomes `Vec<OutSlot>`). A driver that rejects planar RTVs — the one hard
requirement of this path — therefore fails at ring build, where such a failure belongs, instead
of on the first frame.
- `inv_src` becomes an IMMUTABLE constant buffer filled in `HdrP010Converter::new(device, w, h)`.
The converter is already dropped by `recreate_ring` on every mode change, so it cannot go
stale. This also deletes the `Map`'s silently-ignored failure: it could leave the chroma pass
sampling at a garbage texel size with no error anywhere.
**4.2 (W15) — `sdr_white_level_scale` ran inside the keyed-mutex hold too.** It is a CCD query that
contends the display-config lock — precisely the contention `DescriptorPoller`'s doc says must stay
off the frame path — and `prepare_blend_scratch` called it while holding the slot. Moved to
`refresh_sdr_white_scale`, called at open and after each ring recreate (where the scratch is
invalidated anyway). "Only on scratch rebuilds" is not the same as harmless when the thing being
blocked is the producer.
**4.3 (W11) — `VideoProcessorSetStreamAutoProcessingMode(vp, 0, false)`.** The comment claimed "no
interpolation/auto-processing" while only setting the frame format; auto-processing's documented
default is ENABLED, so vendor denoise/edge-enhancement was free to run inside every SDR
`VideoProcessorBlt` — altering the pixels we encode and spending video-engine time on the
desktop-capture hot path. Now actually disabled, with each call's purpose stated separately.
**4.4 (the Linux CPU-path map cache) is deliberately NOT done.** The plan gates it on measurement
("measure first, and only if the CPU path still matters") and this box cannot measure a live capture
session; it also needs a frame-buffer return path through `FramePayload::Cpu`, which is a larger
change than the rest of this phase. Left for Phase 7's measured pass.
Compile-verified for windows-msvc locally; pf-capture 20/20 on Linux; workspace clippy
--all-targets clean on both targets. The GPU-capture check and the measured `cap_us` delta are owed
to Phase 7.3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
bc45287dc6 |
fix(pf-capture): Windows IDD-push defects — HDR pin, cursor, recreate, handles, f16 (Phase 3)
Compile-verified for x86_64-pc-windows-msvc locally (a scratch workspace symlinking the real
sources against a stub punktfunk-core — the `quic` feature's ring/opus C builds are what blocks an
in-tree cross-check). Behaviour is owed the on-glass validation in sweep Phase 7.3.
**3.1 (W1/F3) — the HDR pin asserted a flip it never verified.** `poll_display_hdr` discarded
`set_advanced_color`'s `bool` and then wrote `now.hdr = self.client_10bit` — the DESIRED state in
place of the observed one. On a display that cannot be flipped (the state this file already logs as
"Downgrade point D" at open) that broke in both directions: wanting HDR, the fabricated `true`
differed from `current`, so two poller samples drove `recreate_ring(true, …)` and rebuilt the ring
FP16 while the driver composed 8-bit BGRA — every publish dropped by the driver's format guard,
`recovering_since` expiring, `try_consume` bailing: a permanent 3-second reconnect loop. Wanting
SDR, the fabricated `false` MATCHED `current`, so no recreate ever fired and the ring stayed BGRA
against an FP16 composition — the same dropped-publish outcome, silently. Now it re-reads
`advanced_color_enabled` and follows what the display actually composes, with a one-shot error
naming want/observed/returned. A not-yet-settled read costs one debounce cycle, never a wrong ring,
which is why this does not block the frame path on a settle poll the way `open_on` does. Downgrade
point D's error now carries the same pair, so `Some(false)` (display says no) and `None` (the CCD
read failed) are distinguishable.
**3.2 (W2, W3, W6) — cursor correctness.**
- W2: the poller's desktop rect was captured once at open and used forever, for BOTH the
desktop→frame offset and the `in_rect` visibility test — while both mid-session mode-change
paths (`resize_output`, `poll_display_hdr` → `recreate_ring`) keep the same poller. After an
in-place resize the pointer was clipped to the old rect and offset by a stale origin. It is now
a SEED: the poll thread re-queries on its existing 250 ms reattach cadence, keeping the last
good value on `None` (a transient CCD failure must not park the rect at zero and report every
position invisible), which keeps the CCD call off the encode thread as `DescriptorPoller`
demands.
- W3: `composite_forced` tested `cursor_sender.is_none()`, but §8.6's rationale is "no cursor
CHANNEL" — and the delivery just above it is explicitly allowed to fail non-fatally, which is
precisely the state needing the rescue. It was the one state that skipped it: a negotiated
channel that failed to create or deliver left a cursor-excluded target with NO pointer at all.
Now `cursor_shared.is_none()`, evaluated after that binding.
- W6: `cursor()` degraded poller→shm correctly, but the BLEND path — the only consumer that
matters in the composite model, since the Windows encode loop never attaches `frame.cursor` —
read the poller directly with no `alive()` check and no fallback, so the documented fallback and
the spawn-failure warning were both untrue for exactly those sessions (a dead poller meant
pointer-less frames, not a degraded pointer). One `live_cursor()` now serves all three
consumers and LATCHES the source, because the two keep independent serial namespaces and
interleaving them poisons the client's shape cache.
**3.3 (W4, W5, W14) — recreate hardening.**
- W4: `recreate_ring` committed `display_hdr`/`width`/`height` BEFORE the fallible
`create_ring_slots` (VRAM pressure at a large new mode — exactly when resizes happen), leaving
a failed recreate emitting frames stamped with the new geometry against the old ring, the old
generation and an unchanged header. Slots are built first; nothing after the commit point fails.
- W5: a recreate never cleared the driver's status words, and `wait_for_attach` — the only
classifier of TEX_FAIL/BIND_FAIL and the only source of the LUID rebind — runs at open ONLY. A
stale `OPENED` therefore made a failed re-attach look healthy while the recover-or-drop bail
reported nothing. Now cleared before the Release generation store (plus `status_logged`), and
the 3 s bail prints the live `(driver_status, detail, render_luid)` the way `next_frame`'s 20 s
bail already did. The four-field read is one `driver_diag()` helper instead of four copies of
the same unsafe block.
- W14: `IDD_GENERATION` is a full `u32` but the publish token carries 24 bits and `unpack` masks
what it reads, so past 2²⁴ recreates `tok.generation != self.generation` would be permanently
true — every frame rejected. Masked at the single mint point, and 0 skipped (it is also the
cleared-`latest` sentinel).
**3.4 (W8, W9, W12) — handle hygiene.** `shared_object_sa`'s security descriptor is a `LocalAlloc`
nobody freed: leaked twice per open and once per ring recreate. It is now an RAII `SharedObjectSa`
whose `Drop` `LocalFree`s it and whose `as_ptr()` only lends a borrow — which also makes the
"descriptor must outlive the attributes" rule structural instead of a comment. The PyroWave fence's
shared NT handle, created per capturer and never closed, becomes an `OwnedHandle` (the encoder holds
its own duplicate, so closing ours is safe). `cursor_blend`'s `cbuf_scale` is cached only on a
successful `Map` — caching unconditionally wedged the HDR/SDR cursor scale for the session after one
transient failure.
**3.5 (W7) — `f32_to_f16` swallowed the rounding carry.** `sign | half_exp | (half_mant + round)`
ORs a mantissa carry into bit 10, so for every ODD biased exponent (bit 10 already set) the carry
vanished and the result came back ~2× low: `1.9998779 → 1.0`, `0.49996948 → 0.25`. Only values one
ULP below a power of two are affected — precisely what a gradient test pattern is full of — so this
made `hdr-p010-selftest` FAIL a correct shader. Composed additively, with 4 tests (18 asserted bit
patterns, a round-trip property over the self-test's scRGB values, saturation) — all verified
numerically against a standalone reference on this box, including a scan confirming old-vs-new
diverges ONLY on the carry cases. Phase 0.1's `--all-targets` lint is what lets these compile in CI
at all.
pf-capture 20/20 on Linux; workspace clippy --all-targets clean on Linux and windows-msvc.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
9a7b3a46d0 |
fix(pf-capture): gamescope cursor — auth without setenv, frame-space coords, live targets (2.5)
**2.5a (L4) — stop `set_var`ing `XAUTHORITY` from a live multithreaded host.** The old connect swapped the process-global var around each `RustConnection::connect` under a mutex. That lock serialises this source against itself and nothing else: `getenv` takes no lock, so every OTHER thread's read raced it — and the concurrency is by construction, since `attach_gamescope_cursor` runs while the PipeWire thread is starting up (libspa plugin load, EGL/CUDA init). The primary path now parses the MIT-MAGIC-COOKIE-1 entry out of the given file itself and hands it to `DefaultStream::connect` + `RustConnection::connect_to_stream_with_auth_info` — the same two steps `RustConnection::connect` performs internally, minus its env-derived auth lookup — so nothing in this process touches the environment. The env swap survives only as a fallback for a file we cannot parse, and a wrong cookie pick cannot do damage: the server rejects it and the fallback (which does libxcb's full family/address match) takes over. Sharing `pf_vdisplay`'s process-wide env lock was not the fix — wrong layer, and it would still not fix `getenv`. **2.5b (L6) — publish the pointer in FRAME coordinates.** `QueryPointer` answers in the nested root's space, but `CursorOverlay::x/y`'s contract is frame pixels, and gamescope's `-w/-h` (nested root) and `-W/-H` (output + PipeWire node) are independent knobs — at `-W 1280 -H 720 -w 640 -h 360` they differ by 2×, so the pointer drew at a fraction of its real position. The root size comes free off the setup reply we already parse; the negotiated frame size arrives from the PipeWire thread's `param_changed` through a new `Arc<AtomicU64>` (`0` = not negotiated ⇒ pass through, as before). Scaling is computed in `i64` — a 5K coordinate times a 5K width overflows `i32`. Position only: the bitmap stays at root scale, warned once so a mismatched session is visible. **2.5c (L7) — the targets are a PROVIDER, not a snapshot.** The list was discovered once, before the game launched. gamescope creates the game's Xwayland at launch and advertises only the FIRST in any child's environ (verified on this box: `--xwayland-count 2` makes `:2` and `:3`, only `:2` is advertised), so the game's display was invisible — and when the connected Big Picture display then reported "gamescope is not drawing the pointer here", the source blanked the cursor for the whole game session, which is the exact regression the module doc says it fixed. The worker now re-runs the provider every 2 s: it adopts new Xwaylands, reconnects dead ones, and `spawn` no longer returns `None` on an empty list — a stream that starts before the game converges instead of staying cursorless. The provider is a host-facade closure, same one-way-edge shape as `FrameChannelSender`. **2.5d (L8) — bound the teardown join.** `Drop` joined the worker unbounded while the worker blocks in `RustConnection` replies with NO read timeout, so a peer that stops answering but keeps its socket open hung capturer teardown — on the session path. Now: a completion channel, `recv_timeout(250 ms)`, then detach with a warning (the thread only touches its own X connections and an `Arc`'d slot). 7 new tests, all host-runnable: the cookie reader against a hostile `.Xauthority` (wrong protocol, wrong display, wildcard entry, truncation mid-length-prefix, an over-long length prefix, a missing file), display-string parsing, and the root→frame mapping incl. the 5K overflow. pf-capture 20/20; workspace clippy --all-targets clean on Linux and windows-msvc. Phase 7.2 owes the on-glass nested-gamescope validation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3f4ad08869 |
test(encode/ffmpeg_win): extract the decision logic and pin it with unit tests
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 20s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
deb / build-publish (push) Successful in 9m9s
docker / build-push-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 24s
deb / build-publish-host (push) Successful in 10m47s
android / android (push) Successful in 14m53s
deb / build-publish-client-arm64 (push) Successful in 10m38s
windows-host / package (push) Successful in 17m40s
ci / web (push) Successful in 1m46s
ci / docs-site (push) Successful in 1m50s
arch / build-publish (push) Successful in 11m37s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m11s
apple / swift (push) Successful in 6m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m15s
ci / bench (push) Successful in 5m47s
ci / rust-arm64 (push) Successful in 9m11s
ci / rust (push) Successful in 21m50s
apple / screenshots (push) Successful in 25m14s
The QSV open-failure fallback (1,400 lines, 23 unsafe, 0 tests) follows the vaapi.rs treatment: the device-free decisions now live in named functions with their contracts pinned — the per-vendor zero-copy default matrix (AMF on-glass-validated on, QSV opt-in), the PUNKTFUNK_FFWIN_POLL_MS clamp-before-µs-conversion (the 27.7-hour-spin class), the readback routing table with its mid-stream depth-change guard, the swscale source map, the QSV display-remoting latency contract (async_depth=1/low_power=1/ look_ahead=0/forced_idr=1/scenario) and the AMF no-B-frames contract, and the per-vendor zero-copy pool bind flags. A probe smoke rides along #[ignore]d for the runner. No FFI plumbing chased; no behavior changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fc335b39e9 |
fix(host/encode): negotiate the cursor around what the encoder can blend
EncoderCaps::blends_cursor's contract said the HOST must fall back to capturer-side compositing when a cursor-as-metadata session lands on an encoder that can't composite — but that host half was never built: open_video warned and the session streamed WITHOUT a pointer (confirmed on the VAAPI dmabuf and libav-NVENC CUDA paths; latent on vulkan RGB-direct/native-NV12). The negotiation is now caps-aware, ahead of capture, on both planes: * pf-encode grows cursor_blend_capable() — the pre-open dispatch mirror (sibling of linux_native_nv12_ok) answering whether the resolved backend composites frame.cursor; its pure core is test-pinned arm by arm. * Native plane: handshake::cursor_forward grants the cursor channel only where the resolved backend can blend (the capture-mouse flip makes the host draw the pointer on demand); denied sessions keep the pre-channel path — the compositor EMBEDS the pointer, never cursorless, never doubled. The Welcome's HOST_CAP_CURSOR bit is computed once and read back at both session-wiring sites instead of recomputed. SessionPlan::output_format additionally keeps every cursor-blend session off producer-native NV12 (the arm with no CSC to fold a cursor into), and vulkan RGB-direct now yields to a cursor-blend session even when pinned (EFC cannot composite; the open logs the override). Windows plans cursor_blend=false via the new shared cursor_blend_for() rule — the IDD capturer composites the pointer itself, and asking the encoder anyway fired the blends-cursor warn spuriously on every cursor-channel session. * GameStream plane: the hardcoded cursor_blend=true is gone. The portal source asks for cursor-as-metadata only when the resolved backend blends, otherwise negotiates an Embedded pointer (choose_cursor_mode's new ladder); the capturer pool now also keys on that mode. The virtual-output source passes false — its capture embeds the pointer where it can. The per-arm warns in vulkan_video (RGB-direct, native-NV12) are now structurally unreachable and removed. open_video's post-open check stays as the single backstop for what planning cannot see: a Vulkan-open falling back to VAAPI mid-session, and the gamescope residual (no embedded mode exists there, so a never-blending backend — H.264-on-AMD VAAPI, software — still streams cursorless; fixing that needs a compositing stage, deliberately not built in this pass). Zero-copy is preserved throughout — every fallback is a capture-negotiation change, never a readback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
759fdf767c |
fix(pf-capture): buffer-geometry guards + a genuine drop-oldest hand-off (sweep 2.4, 2.6)
**2.4 — buffer geometry correctness on the Linux CPU path.**
L5: the self-mmap de-pad mapped the fd from offset 0 and then indexed by `chunk.offset()` ALONE,
ignoring `spa_data.mapoffset` — where that spa_data's region actually begins inside the fd. For a
pooled-memfd producer (every buffer a slice of one fd) it therefore read the WRONG buffer, and the
`needed > avail` guard could not catch it: `avail` came from the whole-fd mapping, so it was ample
for any single buffer's span. PipeWire's own MAP_BUFFERS slice (the fallback) was always correct
because it already starts at `mapoffset` — only the hand-rolled "fix" path was wrong, so the two
paths now compute their base separately (checked add; both halves are producer-controlled).
L15: when `fstat` failed, the mmap length was invented as `offset + needed` — producer-controlled
geometry. That defeats the entire point of the fstat (the "buffer smaller than the frame span"
guard then compares against the number the producer just supplied) and can map, and read, past the
end of the object — a SIGBUS, not an `Err`. Without a real length we now decline to self-map and
let PipeWire's own slice serve, which is bounded by construction.
L12: `bytes_per_pixel()`'s catch-all answers 4 for NV12, so a producer-native NV12 buffer computed
`row = 4w` against a stride of ~w and ALWAYS tripped the stride guard — reporting "chunk stride <
row", i.e. blaming the producer for a host limitation. NV12 cannot be de-padded on this path at all
(plane 1 is not even in `datas[0]`'s span), so reject it with a message that says which side is at
fault and under what condition the NV12 offer is valid.
L14: `spa_meta_bitmap` was read field-by-field through an aligned `*const` at a
PRODUCER-controlled offset, with a SAFETY comment asserting an alignment the code never
established. One `read_unaligned` of the `Copy` POD instead.
**2.6 — hand-off semantics (L9, L10).** The frame channel was `sync_channel(8)` + `try_send`, i.e.
drop-NEWEST, while the trait documented drop-oldest. Neither remedy the plan offered works: a
`SyncSender` cannot pop, so "drain one and retry" is not expressible, and shrinking the bound makes
staleness WORSE, not better (with bound N the queue holds the first N frames of a stall and
everything after is discarded, so a larger N actually yields a fresher frame — the defect is the
discarding, not the depth). Replaced with a one-deep OVERWRITING mailbox plus a depth-1 wakeup
channel: publishing overwrites, so a stalled consumer loses the intermediate frames and is always
handed the freshest one. `next_frame` used to return the OLDEST of eight stale frames.
Falls out of it:
- `wait_arrival` now honours its documented "must NOT consume" contract by peeking the slot. The
`pending` field existed only to stash a frame the un-peekable channel forced it to consume;
it is gone.
- a queued `CapturedFrame` can own a dup'd dmabuf fd or a CUDA buffer, so the old 8-deep backlog
could pin eight compositor buffers. One-deep by construction now.
- L10: `set_active(false)` flushes the mailbox, so a reused capturer no longer opens the next
stream with the previous session's last frame (wrong content, and a `pts_ns` from the old
clock). Two lines, where flushing a queue + a `pending` stash was fiddlier.
- the producer-liveness signal moves to the wakeup channel's `Disconnected`, and a final frame
left in the slot as the thread exits is still served before the error.
pf-capture 13/13; workspace clippy --all-targets clean on Linux and windows-msvc.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
f495b201e1 |
fix(encode): delete the write-only EncoderCaps::supports_hdr_metadata
A caps field nothing reads is a contract nobody honors — and this one shipped write-only: its single reader anywhere in the workspace was a hardware-gated assertion inside pf-encode's own AMF smoke test. Both planes send the static HDR grade out-of-band unconditionally (the native 0xCE datagram per keyframe, the GameStream 0x010e control message), every first-party client reads exclusively that path, and none parse in-band SEI — so the host decision the field was reserved for (suppress out-of-band when the encoder embeds) can never validly exist. The field's doc contract had also rotted in two directions: it claimed set_hdr_meta no-ops when false (native AMF and QSV consume it regardless) and that only Windows direct-NVENC attaches in-band metadata (AMF and QSV do too). The in-band SEI/OBU emission itself is untouched — it stays a bonus for stock decoders, documented at the emit sites; the trait docs now describe the real routing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
17eb8100a3 |
fix(pf-capture): portal teardown, observable death, one negotiation resolver (sweep 2.1–2.3)
**2.1 (L1) — the portal thread leaked a live screencast session.** It parked on
`future::pending()` and `Drop` stopped only the PipeWire thread, so every dropped portal capturer
permanently leaked a thread + a 2-worker tokio runtime + a zbus connection — and because dropping
that connection is what ends the compositor's cast (ashpd's `Session` has no `Drop`), the
COMPOSITOR-side session leaked too. GameStream drops the pooled capturer on every HDR↔SDR
reconnect flip and opens a fresh one, so the sessions accumulated: exactly the "second conflicting
screencast" the pooling exists to prevent. Both portal threads now park on a `oneshot::Receiver`
and `PortalSession::drop` fires it, then waits — BOUNDED (750 ms, then detach with a warning) —
for a completion signal the thread sends after its runtime has been dropped. Bounded because
teardown runs on the session path and the thread may be inside a D-Bus round-trip: an unbounded
`join()` there would hang the host rather than leak. Every early-return path in `open` drops the
session too, so a failed PipeWire spawn no longer strands a fresh cast.
**2.2 (L2) — a dead capturer was pooled forever.** All three of the portal backend's terminal
states are sticky and observable only by consuming a frame, and `Capturer` exposed no health
predicate, so GameStream re-pooled unconditionally — after `result` was already an `Err`. One
zerocopy-worker death or one compositor restart therefore wedged GameStream portal video at 10 s
per reconnect attempt, permanently (that path has no rebuild closure). Adds
`Capturer::is_alive()` (default `true`), implemented for `PortalCapturer` as
`!broken && streaming && !thread.is_finished()` — the third term catches a dead PipeWire thread,
which is otherwise indistinguishable from an idle desktop. A static desktop stays `Streaming`, so
an idle-but-healthy capture is never reported dead. The host re-pools only on
`result.is_ok() && capturer.is_alive()` and logs which of the two retired it. Both halves ship
together: either alone leaves the wedge reachable.
**2.3 (L3/F1) — a "VAAPI" downgrade latch was really a global zero-copy kill switch.**
`spawn_pipewire` hand-mirrored the thread's `vaapi_passthrough` decision and the copy omitted
`raw_dmabuf_import_disabled`, so once that latch fired the capturer still believed it had made the
passthrough offer while the thread had already fallen back — and its timeout branch then latched a
downgrade for an offer nobody made. That downgrade was `note_vaapi_dmabuf_failed`, which fed
`pf_zerocopy::enabled()`, so ONE failed negotiation dropped every later session on the host — the
NVENC EGL→CUDA path included — to CPU capture until restart. And since the raw-passthrough offer
is also the PyroWave one on ANY vendor, a single PyroWave negotiation timeout was enough to do it
on an NVIDIA box.
Two fixes, both structural:
- `negotiation_plan(NegotiationInputs) -> NegotiationPlan` is now the ONE resolver, consumed by
the thread AND by `spawn_pipewire` — the mirror is deleted, not re-synced (WP7.6's shape), so
the drift class is unrepresentable. It is pure (every env/latch read is an input), which is
what makes it testable; the runtime-dependent half stays a method (`want_dmabuf` needs the
modifier list the importer's construction actually yielded). The redundant `importer.is_none()`
term goes: `build_importer` already excludes the passthrough, and that term is what made the
decision look impure enough to "need" a mirror. `PUNKTFUNK_FORCE_SHM` was ALSO read in both
places; now once.
- the capture-side downgrade becomes `pf_zerocopy::note_raw_dmabuf_negotiation_failed`, which
latches `RAW_DMABUF_DISABLED` — gating only the raw-passthrough decision. `enabled()` is now
the env var alone, and `VAAPI_DMABUF_FAILED` is deleted.
Also (L13, needed by 2.3's plan inputs): `PUNKTFUNK_PIPEWIRE_NV12` honoured only the exact string
`"0"`, so `"0 "` or `"false"` read as force-ON — the bug class of
|
||
|
|
232b6d6be2 |
test(encode/vaapi): extract the open-time decision logic and pin it with unit tests
The fallback backend under Vulkan Video — and the only AMD/Intel H.264 and 10-bit/HDR encoder — had 1,300 lines, 26 unsafe blocks, and zero tests. The device-free decisions now live in named functions with their contracts pinned: the entrypoint ladder + LP_MODE latch round-trip (the cross-GPU session-killer and the 8-bit-pins-10-bit under-advertisement are both key'd tests now), the PUNKTFUNK_VAAPI_LOW_POWER / _ASYNC_DEPTH grammars, the VUI ↔ scale_vaapi colour agreement (the Mesa-BT.601 hue-shift pin), the honest-downgrade depth table, the HEVC-Main10-only explicit profile, and the 10-bit probe gate. Probe + CPU-path encode round-trip ride along as #[ignore]d hardware smokes in the house style. No FFI plumbing was chased; no behavior changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
54a37aeb46 |
docs(pf-capture): truth pass over comments, docs and log strings (sweep Phase 1)
Every item here is prose that actively misdirects a maintainer or an operator. Landing it before
the defect fixes means those diffs get reviewed against accurate comments.
1.1 (X5) — split two merged doc blocks that documented the item ABOVE them: `hdr_meta`'s whole
contract was glued onto `fn cursor()` in lib.rs (leaving `hdr_meta` undocumented), and
`hdr_p010_selftest_at`'s was glued onto `hdr_p010_convert_bars_on_luid` in dxgi.rs. rustdoc for
the trait's central HDR contract was simply wrong.
1.2 (L9 doc half) — the `Capturer` trait doc advertised a "bounded drop-oldest channel". The
Linux portal's `sync_channel(8)` + `try_send` is drop-NEWEST: under a consumer stall the arriving
frame is discarded and the encoder gets the stalest queued one. Say so. (Phase 2.6 changes the
behaviour; this commit only stops the doc from lying about today's code.)
1.3 — delete the DONT_FIXATE claim from the dmabuf offer comment. `build_dmabuf_format` emits a
plain MANDATORY `ChoiceEnum::Enum` and `param_changed` re-emits nothing, so there is no two-step
DMA-BUF handshake here; libspa 0.9's `ChoiceFlags` cannot even express DONT_FIXATE. Recorded as a
real follow-up instead of an implemented thing.
1.4 — `mainloop.run()` no longer "blocks until process exit": the quit channel attached above it
stops the loop from `PortalCapturer::drop`.
1.5 (L11) — the 6-arm negotiation log ladder told a fully zero-copy PyroWave session "VAAPI
encode with the CPU capture path — zero-copy was disabled", one line after logging that it had
advertised the PyroWave device's dmabuf modifiers: the passthrough arm was gated on
`pyrowave_modifiers.is_empty()`, so the pyrowave case fell through to the CPU warning. Ungate it;
the CPU-path arm is now reachable only when no dmabuf is advertised at all, which is what it
claims. Its parenthetical also gains the third real cause (the session asked for CPU frames).
Control-flow-neutral for every other combination.
1.6 (W13) — `kick_dwm_compose` claimed a "sub-millisecond" round trip while its
cursor-on-a-sibling-display branch sleeps 35 ms on the CALLER's thread. State the cost on the
function and at both call sites, including which one is on the live frame path.
1.7 (W16) — DDA-era residue, DDA having been removed:
- retarget the win32u hook's rationale + all four log strings: its job is no longer "keep DDA on
one adapter" but "keep the virtual display on the adapter SET_RENDER_ADAPTER pinned" (a DXGI
reparent surfaces as the driver's TEX_FAIL render-adapter mismatch). The hook stays installed.
- the per-monitor-v2 DPI awareness rationale likewise: not DuplicateOutput1's E_ACCESSDENIED any
more, but keeping cursor/window coordinates in the PHYSICAL pixels the host's CCD geometry
(`source_desktop_rect`, `desktop_bounds`) is already in.
- `HYBRID_HOOK_HITS` was write-only. Surface it as `hybrid_hook_hits()` on the IDD-push open
line, which is the first point where DXGI has actually been exercised — the patch-readback
check proves the bytes landed, only this proves DXGI reaches the export.
- delete `hdr_p010_selftest()`: an unreachable 64×64 wrapper (main.rs calls
`hdr_p010_selftest_at`); its description moves onto the function that survives.
- `VideoConverter`'s docs promised a P010/BT.2020 output and a live scRGB input path. It pins
`YCBCR_STUDIO_G22_LEFT_P709` unconditionally and its only caller always passes
`scrgb_input: false`.
1.8 (X7) — the native handshake's 4:4:4 gate comment stated the OPPOSITE of what
`pf_capture::capturer_supports_444` returns on Windows ("delivers subsampled NV12/P010 today, so
it returns false there" — it forwards `resolved_backend_ingests_rgb_444()` and the IDD ring passes
BGRA through for an SDR 4:4:4 session).
No `.rs` behaviour delta: comments, doc comments and log strings, plus 1.5's control-flow-neutral
ladder and 1.7's counter accessor. Linux tests 6/6; clippy --all-targets clean on both targets.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
cc848479c4 |
test(pf-encode): re-point the cpu_img size-change smoke at the CSC guard's contract
arch / build-publish (push) Failing after 58s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 53s
android / android (push) Failing after 7m59s
ci / bench (push) Failing after 4m9s
ci / rust-arm64 (push) Failing after 6m44s
ci / rust (push) Failing after 6m45s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
decky / build-publish (push) Successful in 24s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 47s
apple / swift (push) Successful in 5m28s
deb / build-publish (push) Successful in 9m25s
windows-host / package (push) Successful in 10m44s
docker / deploy-docs (push) Successful in 31s
docker / build-push-arm64cross (push) Successful in 5m33s
deb / build-publish-client-arm64 (push) Successful in 8m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 7m41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m4s
deb / build-publish-host (push) Successful in 12m7s
apple / screenshots (push) Successful in 25m11s
vulkan_cpu_img_survives_a_source_size_change drove MISMATCHED source sizes through the then-lenient CSC arm as its vehicle for the staging cache hazard (format-only-keyed cpu_img → OOB copy while submit said Ok). e3354b6d's guard — correctly the equality check every sibling arm always had, against the MODE, not the coded extent, so the padded render-vs-coded tolerance in the direct arms is untouched — makes that scenario unrepresentable through submit and broke the test on main (.25 layers baseline read 13/13 instead of 14/12). Replaced by vulkan_csc_refuses_a_mismatched_source: refusal pinned in BOTH directions, plus the property that actually needs proving — a refused submit does not WEDGE the session (the bail lands after step 1's frame-type bookkeeping; the next well-sized frame must still encode, and an AU must come out). Verified on the 780M under validation layers: 8/8 vulkan tests, full-suite baseline restored to 14/12. The WP4.2 size-keyed staging stays as belt-and-braces; the hazard it fixed is now structurally unreachable through submit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6a2a153c0b |
chore(pf-capture): make the crate verifiable — Windows CI lint + two denies (sweep Phase 0)
Gate for the rest of design/pf-capture-sweep.md: today half this crate is compiled by no CI job
at all, so the Windows-side findings can't even be type-checked.
0.1 (X1) — windows-host.yml lints `-p pf-capture --all-targets`. The host lint above it builds
pf-capture only as a DEPENDENCY, so its `#[cfg(test)]` modules were never compiled anywhere: the
5 StallWatch tests, the DXGI HDR self-tests and the cursor-conversion tables were dead code in
CI. The workflow's own comment already diagnosed this blind spot and fixed it for pf-encode;
pf-capture never got the same treatment. No features on this crate, so no feature juggling and
no extra dep tree.
0.2 (X2) — `#![deny(unsafe_op_in_unsafe_fn)]` beside the existing
`deny(clippy::undocumented_unsafe_blocks)`. The crate declares "every unsafe block carries a
SAFETY proof; enforce it" in ten file headers, but in edition 2021 that lint has nothing to fire
on inside an `unsafe fn` — so the hardest FFI in the crate was exempt from its own program: the
ring/slot construction, the fence signal, the blend scratch, and every D3D converter ctor/convert.
119 operations across 16 functions now carry a proof. `shared_object_sa` loses its `unsafe`
marker instead (it states no precondition — only its RESULT is a raw pointer, which is the
caller's business).
0.3 (X4) — drop the crate-wide `#![allow(dead_code)]`. Verified: zero warnings on either target
without it, so it was hiding nothing the lint can see (W16's dead items are `pub`/written-once,
so they need Phase 1.7's deletion instead).
No behaviour change: only lint attributes, `unsafe { }` scoping and comments.
Linux `cargo test -p pf-capture` 6/6, clippy --all-targets clean on both targets (Windows
verified by cross-checking x86_64-pc-windows-msvc locally).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
bf9386ecb2 |
test(pf-encode): pin the typed-EINVAL classifier's chain-survival contract (Phase 8)
ci / docs-site (push) Failing after 52s
android / android (push) Failing after 6m28s
ci / web (push) Successful in 1m5s
ci / bench (push) Successful in 6m40s
decky / build-publish (push) Successful in 26s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
arch / build-publish (push) Successful in 12m51s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
ci / rust-arm64 (push) Successful in 9m51s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 42s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 17s
deb / build-publish-client-arm64 (push) Successful in 8m54s
deb / build-publish-host (push) Successful in 10m31s
deb / build-publish (push) Successful in 11m17s
windows-host / package (push) Successful in 10m41s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m25s
docker / build-push-arm64cross (push) Skipped
docker / deploy-docs (push) Skipped
apple / swift (push) Successful in 5m25s
ci / rust (push) Successful in 22m21s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m7s
apple / screenshots (push) Successful in 23m50s
Rides on
|
||
|
|
bf9fb3fb22 |
fix(pf-encode): enable VK_EXT_queue_family_foreign for the dmabuf acquires (Phase 8)
Both Linux Vulkan encode backends named QUEUE_FAMILY_FOREIGN_EXT as the acquire barriers' src family without ever enabling the extension — spec-invalid on every device, tolerated by RADV. The audit filed vulkan_video's three sites; pyrowave's fresh-import acquire had the identical defect on its own device (critic catch). Enable when advertised (a fresh open-time enumerate — the rgb probe's is a probe-local and skipped entirely on native-NV12, so there was nothing to reuse; pf-presenter/dmabuf.rs is the in-repo precedent that already enables this extension). Not advertised → the core-1.1 QUEUE_FAMILY_EXTERNAL conservative substitute, chosen once at open and warn-logged (no fleet hardware takes that arm; such devices were never valid targets before). All four sites are acquire-only (src=FOREIGN, EXCLUSIVE images, oldLayout=UNDEFINED) — the swap is index-only. On-glass: 780M under validation layers — vulkan smokes + pyrowave smokes green, FOREIGN advertised and enabled, no fallback engaged. Shared ext_advertised helper in vk_util (cfg = the union of both consumers) with a unit test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1cefd37603 |
fix(pf-encode): arbitrate NVENC split-encode vs sub-frame readback (Phase 8)
Verified against nvEncodeAPI.h's own splitEncodeMode doc (user-prompted — the audit's 'exclusive for HEVC' one-liner deserved checking): - H.264: split 'is not applicable' — hard-DISABLE the mode so the written config, CeilingKey, the split diagnostic log and the rejection-retry stay truthful (the retry used to re-open a byte-identical session after an H.264 'split rejection'). The libav path's operator arm gains the codec gate its auto arm always had. - HEVC: split 'not supported if … subframe mode' — when WE force split (TWO/THREE/AUTO_FORCED, the 4K120 throughput lever), sub-frame yields with a logged escape (PUNKTFUNK_SPLIT_ENCODE=0 chooses sub-frame). ⚠ Keyed on FORCED modes only, never != DISABLE: AUTO(0) is the resolver's fallthrough for every sub-950Mpix session, and the wider key would have disarmed the Phase-3 chunked-poll feature fleet-wide (critic catch). Under AUTO the driver arbitrates — the shipped state. - AV1: untouched — per-tile sub-frame + split are legal together. The arbitration is a pure nvenc_core fn called by each backend BEFORE the ladder, the ceiling key and the chunked-poll latch — all three see the post-arbitration truth. A drop inside build_init_params would have left poll_chunk busy-polling its whole budget every AU (numSlices stays 0 without reportSliceOffsets; both loop exits dead — critic catch). Linux latches subframe_forced beside subframe_on at query_caps (no env re-reads after open); Windows records the arbitrated state so reconfigure presents exactly the params the open had (also closes the pre-existing mid-session env-flip hazard there). Truth-table tests in nvenc_core; PUNKTFUNK_NVENC_SUBFRAME documented (it never was); PUNKTFUNK_SPLIT_ENCODE row updated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
25765c53ec |
fix(encode): classify libav-NVENC open failures by errno, not English strerror text
ci / docs-site (push) Successful in 58s
ci / web (push) Successful in 59s
apple / swift (push) Successful in 5m17s
ci / bench (push) Successful in 7m27s
deb / build-publish (push) Failing after 8m0s
ci / rust-arm64 (push) Failing after 9m1s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
decky / build-publish (push) Successful in 22s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
ci / rust (push) Failing after 9m31s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
deb / build-publish-host (push) Successful in 10m23s
arch / build-publish (push) Successful in 12m54s
android / android (push) Successful in 15m8s
deb / build-publish-client-arm64 (push) Successful in 7m54s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 5m43s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 6m35s
windows-host / package (push) Successful in 19m10s
docker / build-push-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 24s
apple / screenshots (push) Successful in 23m39s
The bitrate-probe ladder stepped down on format!("{e:#}").contains(
"Invalid argument") — an English substring over the WHOLE context chain,
which also fired on any other wrapped EINVAL (e.g. a CUDA-context errno)
and gated a ~10-step ladder on strerror wording. The root ffmpeg::Error
survives the anyhow chain; downcast and match Error::Other{errno:EINVAL}
instead. Same fix for the intra-refresh ENOSYS probe in the open path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
36589c39da |
fix(gamestream): stall-recovery ladder — a wedged encoder no longer ends the stream
The backends deliberately turn a wedged GPU into a bounded error so the caller can reset in place, but only the native plane ever did: on the GameStream path every submit/poll error propagated straight out of the video loop, costing Moonlight clients a full disconnect/reconnect. Port the native ladder (bounded resets + backoff + silent-wedge watchdog, same field-lesson backoff curve). Also keep the drop-recovery keyframe armed until it actually EMITS: it was consumed on read, so the coalesce gate could swallow it for good — leaving duplicate wire indices in the encoder's reference table for a later RFI to anchor on, exactly the stale-anchor case rfi.rs exists to prevent, and it opened only under congestion + loss, which is when RFI fires. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e3354b6d5d |
fix(encode/vulkan): guard the CSC source dimensions, and bound reset()'s wait
The CSC path was the only backend arm that took frame.width/height on trust: the shader samples with clamped 1:1 texelFetch, so a mismatched frame silently streamed a cropped/edge-padded picture where every sibling errors into the encoder-rebuild path. The import cache now also carries the extent it imported at (a (st_dev, st_ino) hit alone doesn't prove the allocation still matches) and is dropped on reset(). reset() opened with an untimed device_wait_idle on the one thread whose every other wait is capped at ENCODE_FENCE_TIMEOUT_NS for exactly this reason — reset() runs BECAUSE the GPU looks wedged. Both vulkan-video and pyrowave now bound the wait and report "no in-place rebuild" on timeout instead of parking recovery on the suspect device; Drop keeps the unbounded wait (teardown must stay memory-safe against a wedged device). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
28f8fc71c4 |
refactor(pf-encode): split vulkan_video's construction tail into vk_build.rs (WP7.5)
ci / web (push) Successful in 56s
ci / rust-arm64 (push) Failing after 2m18s
ci / docs-site (push) Successful in 1m2s
android / android (push) Failing after 4m7s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
apple / swift (push) Successful in 5m21s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
ci / bench (push) Successful in 6m10s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 5m40s
deb / build-publish-client-arm64 (push) Successful in 7m57s
docker / build-push-arm64cross (push) Successful in 10s
deb / build-publish (push) Successful in 10m6s
docker / deploy-docs (push) Successful in 24s
deb / build-publish-host (push) Successful in 10m30s
arch / build-publish (push) Successful in 13m57s
windows-host / package (push) Successful in 14m6s
ci / rust (push) Successful in 22m9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m30s
apple / screenshots (push) Successful in 24m30s
The ~820-line tail of free builders — make_frame/make_frame_csc/ make_frame_common, make_video_image, probe_rgb_direct, the H.265/AV1 parameter-set writers and the AV1 bit-writer — moves to a #[path] child module, the amf_sys.rs shape: the child sees the parent's private items (Frame and friends), so the split costs zero visibility churn. Six parent-called items went pub(super); five stay child-private (dead_code is per-item and each is used within the child). vulkan_video.rs drops 5,292 → 4,489 lines and the construction unsafe gets its own review surface; steady-state encode logic stays in the parent. ⚠ Trap recorded for future child-module splits: inline `use super::X` statements INSIDE moved fn bodies silently change meaning (super shifts one level) — vk_av1_encode/vk_valve_rgb imports needed crate:: paths. Proven on-glass, not just compiled: all 8 vulkan GPU smokes green under the validation layers on the 780M post-split (H.265 + AV1, RGB-direct, CSC, CPU paths — every moved constructor exercised). nvenc_cuda.rs and qsv.rs are DECLINED the same treatment, with evidence in the handoff doc: no equivalent self-contained seam — their candidate regions are ~150-line loader/accessor clusters interleaved with the encoders' own state types, and a thin-forwarder impl split is exactly the churn a no-defect phase penalizes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
bc5ec5105c |
refactor(pf-encode): one Linux backend resolver, consumed by dispatch AND mirrors (WP7.6)
The Linux backend decision existed as open_video_backend's string match plus five partial hand-copies (the zero-copy-plane gate, the pyro advertisement gate, the software advertisement pin, the vulkan pref ceiling, resolved_backend_is_gpu). Windows solved this long ago — windows_resolved_backend() is consumed by its dispatch AND its mirrors, with labels still stamped at the open sites. Linux now has the twin: resolve_linux_backend (pure, lazy auto probe) + linux_resolved_backend (config wrapper, unknown→auto exactly as every mirror's old `_` arm). ⚠ This deliberately DEVIATES from the audit's shadow-assertion prescription, on both critics' findings: the shadow had no execution venue (open_video had ZERO test call sites; shipped hosts are --release) — unfalsifiable ceremony — and the file's own Windows half proves dispatch-consumes-resolver is safe: the mgmt record is protected by the label-from-the-open-site convention, not by resolver avoidance. One consumed table beats two tables plus an inert cross-check. Preservation riders from the critique, all applied: - pref_ceiling KEEPS its cfg!(vulkan-encode) branch (the resolver is feature-blind; dropping it re-creates advertise-then-die-at-open). - linux_zero_copy_is_vaapi's Vulkan|Software arm preserves the old `_` fallthrough EXACTLY — the vulkan-on-NVIDIA capture-plane mismatch and the software twin are FILED in the design doc, not fixed here. - resolved_backend_is_gpu splits as linux + not(any(windows, linux)) — never a target list, so no exotic target loses the fn. - The auto probe is lazy (impl FnOnce) — explicit prefs stay zero-probe (/serverinfo polls through these mirrors), pinned by a panicking closure in the resolver test. New coverage: the full alias table pinned, and a GPU-free dispatch test through the real open_video_backend (software arm, vendored openh264) — its first test call site anywhere. Config-latch seam guarded loudly. Cross-crate mirrors recorded out of scope in the design doc: session_plan::resolve_encoder, gamestream/serverinfo.rs base_codec_mode_support, capture.rs's "pyrowave" string. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cb69cd3b8c |
refactor(pf-encode): move the AMF C-ABI mirror to its own file (WP7.4)
The ~410-line hand-mirrored AMF vtable ABI was already isolated in an inline `mod sys` — the remaining value of the audit's 'best split in the crate' is the FILE boundary: amf.rs drops 3379 → 2965 lines and the pure unsafe-FFI surface (25 unsafe fns, zero policy) is reviewable in isolation, which is the crate's stated review goal. Pure move: `#[path = "amf_sys.rs"] mod sys;` keeps the module name and every `sys::` call site byte-identical; the module was self-contained (only `use std::ffi::c_void`, no super:: references). The banner became the file's module docs; contents de-indented one level — rustfmt-clean on the first check, which is what proves the move byte-exact. Gated on .173: all five clippy combos + the amf-qsv,qsv test leg (38 passed — the live AMF matrix on real VCN silicon among them). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9491cc8759 |
refactor(pf-encode): extract the range-family RFI recovery policy (WP7.2)
The two direct-NVENC backends carried hand-copied twins of the same loss-recovery decision: range validity, covering-range dedup, DPB window, clamp — ~30 duplicated lines each. The decision now lives once as nvenc_core::plan_range_recovery (the range half of WP7.2; the slot half is enc/rfi.rs), pure and unit-tested; each backend keeps its session gate, its unsafe per-timestamp driver loop, and its state stores. The step order is load-bearing and now pinned by tests: the covering dedup runs with the UNCLAMPED last and BEFORE the DPB window (a covered re-ask never touches the driver even when the range has since aged out of the DPB), the boundary at next_ts - RFI_DPB is inclusive, and the Invalidate carries the CLAMPED last — which is also what the caller records in last_rfi_range, exactly as the inline code stored it. A driver failure mid-loop still returns false with NO range recorded and no anchor armed. Decline deliberately clears nothing (neither twin touched pending_anchor on decline — same shape as Vulkan's non-clear, opposite of AMF/QSV; do not harmonize). The exact-cover → Covered test records EXISTING behavior including that a covered range survives a forced IDR with zero driver calls — a recorded fact, not an endorsement. RFI_DPB's import leaves both twins: its only per-backend use was the arithmetic that moved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9f1e648e4e |
refactor(pf-encode): extract the slot-family RFI recovery policy (WP7.2)
AMF (user-LTR bitfield), QSV (mfxExtRefListCtrl) and Vulkan Video (the
app-owned DPB slot table) each hand-implemented the same loss-recovery
decision: distrust every reference encoded at-or-after the loss start,
anchor on the newest one strictly older. Three copies had already
diverged once — the
|
||
|
|
d28fb1282b |
test(pf-encode): guard TrackedEncoder's forwarding completeness (WP7.7, cheap half)
A defaulted Encoder method that TrackedEncoder doesn't forward silently no-ops through the wrapper — the host loop only ever holds the wrapped box, so the feature dies for every session with nothing in the logs. The trap has bitten three times (set_wire_chunking's §4.4 chunking probe, set_pipelined's LN3 escalation, applied_bitrate_bps's ABR truth), and every Phase 7 consolidation that adds a trait method re-arms it. Source-text parse of the trait and the forwarding impl (both top-level rustfmt items: block ends at the first column-0 brace, method names sit on 'fn '-prefixed lines), then set equality — the reverse direction is already a compile error, so equality == completeness. Mutation-verified: removing set_wire_chunking's forward fails naming exactly that method. 16/16 today. Limit stated in the test doc: name-set equality only — a forward whose body delegates to the WRONG inner method still passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
94b818da28 |
fix(client/pyrowave): declare the decode planes' real format — 10-bit sessions decoded through an R8 view of R16 planes
apple / swift (push) Successful in 5m14s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 6m32s
arch / build-publish (push) Failing after 7m6s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
decky / build-publish (push) Successful in 18s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m53s
android / android (push) Successful in 12m56s
docker / deploy-docs (push) Successful in 24s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m41s
docker / build-push-arm64cross (push) Successful in 4m15s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m40s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m44s
deb / build-publish-host (push) Successful in 10m43s
deb / build-publish-client-arm64 (push) Successful in 9m8s
apple / screenshots (push) Successful in 22m58s
flatpak / build-publish (push) Successful in 6m56s
deb / build-publish (push) Successful in 8m58s
ci / rust (push) Successful in 22m10s
ci / rust-arm64 (push) Successful in 9m22s
ci / web (push) Successful in 48s
ci / docs-site (push) Successful in 1m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 12m48s
ci / bench (push) Successful in 6m3s
The HDR/4:4:4 leg (
|
||
|
|
3e7828522d |
fix(tray): drop the conflicting-host warning — the tray is the wrong place for it
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 1m6s
ci / bench (push) Successful in 7m0s
deb / build-publish (push) Failing after 7m35s
decky / build-publish (push) Successful in 22s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
deb / build-publish-host (push) Failing after 8m6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
android / android (push) Successful in 13m38s
ci / rust-arm64 (push) Successful in 12m28s
docker / deploy-docs (push) Successful in 24s
arch / build-publish (push) Successful in 14m10s
windows-host / package (push) Successful in 10m56s
deb / build-publish-client-arm64 (push) Successful in 8m36s
docker / build-push-arm64cross (push) Successful in 4m37s
apple / swift (push) Successful in 5m28s
ci / rust (push) Successful in 22m58s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 13m9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m0s
apple / screenshots (push) Successful in 23m51s
The tray led its tooltip with "⚠ conflicting host: Sunshine" and (on Linux) went NeedsAttention for a Sunshine that was merely *installed* — never started, never listening, harmless. An always-on warning over a dormant package is a false alarm on every poll, and the tray is the wrong surface for a one-shot install-time observation anyway. Removes the `conflicts` field from the tray's `Summary`, the tooltip prefix, `has_conflicts()`, and the ksni attention branch that fed on it. The host still detects and reports conflicts where that belongs — the startup `punktfunk::detect` warning, the `detect-conflicts` subcommand, and the `/api/v1/local/summary` field, which the tray must (and does, per the new test) keep tolerating on the wire. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ffc7aec91a |
feat(encode/pyrowave): log which GPU pyrowave picked — the selection stays put, by decision
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m12s
apple / swift (push) Successful in 5m20s
ci / rust (push) Failing after 5m55s
android / android (push) Failing after 5m59s
deb / build-publish (push) Failing after 5m1s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 17s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
ci / bench (push) Successful in 7m17s
ci / rust-arm64 (push) Successful in 9m40s
docker / deploy-docs (push) Successful in 26s
docker / build-push-arm64cross (push) Successful in 4m9s
deb / build-publish-host (push) Successful in 10m32s
arch / build-publish (push) Failing after 12m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 5m58s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 5m50s
deb / build-publish-client-arm64 (push) Failing after 7m10s
windows-host / package (push) Successful in 17m27s
apple / screenshots (push) Successful in 23m26s
WP4.5's device-selection half, closed as the observability intermediate after TWO selection designs died in adversarial review: - Attempt #1 (d26bcf05, withdrawn): match pf_gpu::selected_gpu(). Its Linux auto arm answers "the NVIDIA GPU" whenever /dev/nvidiactl exists, moving the encoder off the iGPU that can import the compositor's dmabufs on an Intel-compositor + NVIDIA-present laptop — import failures feed the process-wide raw-dmabuf latch, which never un-latches. - Attempt #2 (this session, withdrawn before commit): anchor on the PUNKTFUNK_RENDER_NODE-else-renderD128 node via VK_EXT_physical_device_drm. Render minors are driver-BIND-ORDER artifacts, not display topology: on the common AMD-iGPU + NVIDIA-display desktop, in-tree amdgpu binds before out-of-tree nvidia, so the anchor deterministically picks the idle iGPU while the compositor allocates on NVIDIA — the same latch, opposite polarity, behind a success-looking log. The correct oracle is evidence of which device ALLOCATED the capture buffers — producer identity from the capture negotiation, threaded per session into this open. Until that plumbing exists, selection stays first-usable, both call sites still share one selector (pure over the device list, so capture_modifiers and open_inner cannot diverge — including across an in-place resize's re-open, which does not renegotiate capture), and the open logs ONE greppable line: picked vendor/device, the anchor node and its owner (DRM render major/minor, VK_EXT_pci_bus_info fallback), and the console's selected GPU. A wrong-device session on a multi-GPU host used to be completely invisible; a field report can now show it. No WARN arm on purpose: the wrong-pick direction inverts between the laptop and desktop topologies, so a mismatch is not evidence of a wrong pick, and a warning that fires forever on healthy hosts teaches people to ignore warnings. Decision recorded against the audit's framing: manual console GPU selection stays unhonored by pyrowave on Linux (the Windows twin honors it) — honoring console-mutable state without per-session threading is what made attempt #1 unsafe. Verified on the 780M: the line resolves all three identities (1002:15bf x3, DRM-props match live); full pyrowave on-glass suite green; selection behaviour byte-for-byte unchanged. WP4.5 (device half). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
46935bf0a5 |
fix(encode/vulkan): VBR instead of CBR — the driver was stuffing ~98% filler into every calm stream
Second attempt at WP6.3; the first (ce543668) was withdrawn after its tight CBR window measured as a 36x bandwidth regression (97% filler NALs). The correction that unlocked this one: that measurement's BASELINE row was an 8-frame artifact. Under the shipped 1000ms/500ms CBR window a calm stream overflows the CPB once the initial fill drains (~30 frames at 10Mbps/60fps) and RADV then pads every frame to the exact rate share, forever. Measured on the 780M (1280x720@60, 10Mbps, calm content): 64 frames = 97.5% filler; 300 frames = 5.63MB at 98.5% filler where this commit ships 83KB at 0%. AV1: 99.6% -> 0%. The status quo was ~the full target bitrate of zeros on every idle AMD/Intel Vulkan-encode desktop and Steam Deck — and stuffing to exactly the target permanently satisfied the ABR calm brake (actual >= 3/4 * current), the ratchet WP6.3's withdrawal feared from the tight window, live in the shipped code all along. The fix reads VkVideoEncodeCapabilitiesKHR::rateControlModes (previously ignored — rateControlMode was hardcoded CBR with no capability check) and installs VBR with average == max plus the house ~1-frame window (vbv_window_ms, PUNKTFUNK_VBV_FRAMES-scaled) when the driver advertises VBR. VBR permits underspend, the exact missing degree of freedom: Vulkan exposes no filler-suppression control (AMF's filler_data=false / NVENC's default-off have no VK equivalent), so the MODE is the only lever. CBR-only drivers keep the loose window untouched — tightening it under CBR just starts the stuffing 30 frames earlier. Drivers advertising neither mode (ANV per current Mesa) keep the pre-existing CBR install, now WARN-logged. No pacing claim, deliberately: burst A/B on the 780M is byte-identical between 1000ms CBR and 17ms VBR (max AU 1.19MB in both) — this firmware ignores the window for QP decisions entirely. The payload is filler elimination. PUNKTFUNK_VULKAN_RC=cbr|vbr is the field escape hatch and the on-box A/B control (two withdrawn attempts bought that insurance). Also on the same caps struct: maxBitrate is now read and clamps open + retarget (RADV reports 1 Gbps — within 5% of the 4K120 ABR targets), and applied_bitrate_bps() reports the encoder-side truth (pending-first, so the session loop's read right after reconfigure_bitrate sees the clamp) — without it a binding clamp would feed the ABR a phantom base, the trap the trait doc names. And the one-frame VUID-vkCmdBeginVideoCodingKHR-pBeginInfo-08254 violation found in the withdrawal review: record_submit promoted a pending retarget into self.bitrate BEFORE recording whenever first_frame was set, so after a mid-stream reset() (which preserves the pending rate and rc_installed) the begin-coding declaration named a rate the session had not installed — and the two triggers, ABR retarget and the stall watchdog, correlate. Now the declaration always names the session's current rate and the RESET install carries the pending one via its own struct; promotion stays in post_submit_bookkeeping. The extended validation-layer test reproduces the retarget-then-reset coincidence: exactly one 08254 on the pre-fix build, zero on this one (RADV PHOENIX, on glass). Gates: docker amd64 legs green; Windows .173 seven legs (34 passed); .25 full-suite parity vs origin/main (identical CUDA-only failures) + all 9 vulkan on-glass tests + validation layers clean. WP6.3, plus WP7.1's ms-form half (vbv_window_ms). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fdded5b8c3 |
fix(encode/pyrowave): refuse a frame that isn't the session's mode
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 55s
ci / bench (push) Failing after 6m37s
ci / rust (push) Failing after 6m43s
deb / build-publish-host (push) Failing after 5m44s
arch / build-publish (push) Failing after 6m45s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
decky / build-publish (push) Successful in 18s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
docker / build-push-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 23s
ci / rust-arm64 (push) Successful in 9m47s
deb / build-publish (push) Successful in 9m9s
android / android (push) Successful in 11m59s
deb / build-publish-client-arm64 (push) Successful in 8m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 8m27s
windows-host / package (push) Successful in 17m35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m58s
apple / swift (push) Successful in 6m51s
apple / screenshots (push) Successful in 26m43s
PyroWave never checked frame dimensions against the session, and it applies no alignment — `width`/`height` are the negotiated mode verbatim — so a mismatched frame was encoded edge-smeared or cropped, silently, forever. Every other Linux backend already refuses exactly this, with this shape, in `submit`: libav-NVENC (`linux/mod.rs`), VAAPI (`vaapi.rs`) and openh264 (`sw.rs`) all carry the same `ensure!`. PyroWave was the only one that didn't. That is the justification; an earlier draft cited `vulkan_video.rs` instead, which is the weaker precedent — it bails in its Dmabuf arms only, and its CSC path and CPU arm have no dimension guard at all. Mostly this is a wrong-picture bug and not a memory-safety one: `rgb2yuv.comp` clamps every fetch with `min(p, textureSize - 1)` and the CPU arm uploads `min(len, need)` into a session-sized image. But it also closes a narrow real hazard that was not in the filing: `import_cached` keys on `(st_dev, st_ino)` and returns the cached `VkImage` on a hit WITHOUT rechecking the extent, and unlike the capture side it is never cleared on a renegotiation — so a dmabuf inode recycled across a shrinking renegotiation would hand the encoder an image sized for the old, larger allocation. This check closes that route. ⚠ Recorded at the code because it changes the failure mode, not just the detection: a mismatch is NOT always transient. A compositor-initiated PipeWire renegotiation updates the capturer's size in place and signals nothing the encode loop reads, so it can be a permanent new steady state — and `reset()` reopens at the same dimensions by construction, so the host's five-reset budget cannot recover (~3.1 s of frozen stream, then the session ends) where before it would have streamed on with a wrong picture. For a real mode change that trade is clearly right; for a 16-row KWin mismatch it is not, and the proper fix is for the host to classify this error as a PIPELINE rebuild rather than an encoder reset. Filed, not done here. The device-selection half of WP4.5 was written, reviewed and WITHDRAWN — see the handoff doc. Matching the selected render GPU regresses the hybrid Intel-compositor + NVIDIA-present topology this project has a live field report for, because `selected_gpu()` answers NVIDIA whenever `/dev/nvidiactl` exists regardless of where capture actually runs. WP4.5 (dimension half). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9fe9cbbf07 |
perf(encode/vulkan): build the padded RGB frame in the staging memory, not beside it
The RGB-direct CPU-upload path allocated and zero-filled a whole padded frame on every submit, filled it row by row, then memcpy'd the whole thing into the mapped staging buffer. The zero-fill was entirely dead: the row loop writes every byte of every row, and rows past the source re-copy the last source row. So the frame paid for an allocation, a page-fault storm over fresh pages, a full zero-fill, and two full-frame copies where one would do. Map first, write the padded rows straight into the mapping. Nothing reads back from the destination — the row source is always the caller's buffer, never the staging memory — so writing into (write-combined) host memory costs nothing extra, and `make_host_buffer` allocates HOST_COHERENT, so the writes need no flush before the transfer reads them. Measured on real RDNA3 780M silicon, release build, `submit` alone, 400 frames x 3 rounds: - **1920x1080** (rows padded to 1088): p50 2315 -> **1725 us** (-25%), p99 2819 -> **1972 us** (-30%), and the spread (p99-min) tightens 739 -> 483 us. - **1366x768 -> 1408x768** (BOTH axes padded, so the column tail loop runs): p50 1013 -> **926 us** (-8%), p99 1303 -> **1110 us** (-15%). This is the one case where the new code could have been slower — 4-byte stores straight into write-combined memory — and it is not. - **CONTROL, 1280x720** (64x16-aligned, so the branch is never entered): p50 692 vs 692 us. No delta, which is what makes the two above attributable to this change rather than to anything else on the branch. The branch is reached by any RGB-direct session at a mode that is not 64x16 aligned, whenever capture delivers CPU frames (the default Linux capture path is dmabuf and never enters it). 1080p qualifies, since 1080 aligns to 1088. ⚠ An earlier draft of this message claimed "33 MB at 4K" — that is wrong: 3840 and 2160 are both already aligned, so 4K UHD never enters this branch at all. The large case is an ultrawide like 3440x1440 -> 3456x1440, ~20 MB. Three things this deliberately does NOT do: - The extent guards stay scoped to the `pad` branch. The CSC path deliberately supports a source SMALLER than the encode extent — its shader clamps at sample time — so a guard hoisted above the branch would break it. - Every fallible step stays above the map. An error raised between `map_memory` and `unmap_memory` would strand the mapping for the life of the slot's staging buffer, and the next frame's `map_memory` on it then violates VUID-vkMapMemory-memory-00678. (The filed "two exits above the map leak Vulkan objects" hazard is separately already gone: `47a23bec` moved that unwind into `make_host_buffer`.) - It adds guards rather than removing them: a zero source axis made `sh - 1` underflow, and "cannot fail after the map" has to be true by construction. Three corrections to an earlier draft, all found by review of that draft: - The `dw*dh*4 == need` precondition was a `debug_assert!` placed BELOW the map. That is wrong twice: it made the only check on the slice length vanish from the builds that ship, and a fired assert would have unwound past `unmap_memory` — the exact failure the bullet above says was designed out. It is now a real checked `?` above the map. - `need` was `(iw * ih * 4) as u64`: a u32 multiply widened after the fact, which agreed with the usize slice length only up to ~32768x32768. The old code's `min(need)` was a hard backstop against exactly that and the rewrite dropped it. Now widened before the multiply, matching `read_slot`'s existing discipline. - The extent guard now runs BEFORE the payload-length guard, so the usize `sw * sh * 4` cannot overflow on a garbage frame header. WP6.2(a). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e680096c6a |
perf(encode): stop re-reading the environment on every submit and poll
`std::env::var` was on three per-frame paths. Measured on `.173` (Windows, 57 environment variables, 2M iterations): **121.9 ns** per call for the NVENC in-flight cap and **114.9 ns** per call for the ffmpeg poll spin, against **0.9 ns** once memoized. On Linux, 32 ns → 1.5 ns. ⚠ Those numbers deflate the filing, and that is worth recording: the audit ranked this as a hot-path defect, but ~120 ns/frame is ~0.003% of a frame budget. The fix is still right — it is free, and it takes a global environment lock off the encode thread — but nobody should schedule it ahead of anything on the strength of the "hot path" framing. The severity RANKING was also inverted, and the measurement confirms why. The site the audit called worst — `nvenc_cuda`'s backpressure loop condition — costs a default session nothing, because the condition short-circuits on `async_rt.is_some()` and the default session never engages the two-thread retrieve. The one that actually pays every frame is Windows `submit`, which consults `async_inflight_cap()` in BOTH arms of the ring-depth match, sync mode included, where the result is then thrown away. Windows `poll` is the second unconditional one, and the audit ranked it last. Memoized inside each helper rather than latched into a session field. Nothing in the workspace mutates these variables at runtime (enumerated: no `set_var` for either key anywhere in first-party code, and the Windows service's arbitrary-key `host.env` loader runs in the SCM supervisor, which re-execs the host as a child and never opens an encoder itself). A field would instead change WHEN the value is read — and the Windows `cap` composes the env with `input_ring_depth`, which `set_input_ring_depth` may change after open, so freezing that half would reintroduce the in-place-overwrite bug the ring term exists to prevent. Two deliberate behaviour changes ride along on `PUNKTFUNK_FFWIN_POLL_MS`, so "behaviour-preserving" describes the memoization only, not this whole commit: - **A 1000 ms ceiling.** The reachable hazard was never the overflow — that needed `ms >= 1.8e16` — it was a slipped digit: `=100000000` was a 27.7-hour spin of the encode thread. - **`.trim()`, now on all three parsers.** An earlier draft applied the house rule (WP7.8) to one of the three, which left `PUNKTFUNK_NVENC_ASYNC=" 1 "` working while `PUNKTFUNK_NVENC_ASYNC_DEPTH=" 6 "` silently fell back to 4 — and memoization would have frozen that silent fallback for the process lifetime. Reachable: the Windows `host.env` loader trims around `=` before stripping quotes, so `=" 2 "` yields a value with inner spaces. ⚠ Correction to an earlier draft of this message, which asserted that the audit's `saturating_mul` proposal would relocate an overflow panic into release builds. That is FALSE and the code comment now says so: `Duration::from_micros(u64::MAX)` is ~1.8e13 seconds, six orders of magnitude below `Duration`'s ceiling, so `Instant + Duration` neither overflows nor panics (measured, with and without debug assertions). `saturating_mul` is still wrong, for a different reason — it sets a deadline ~584,000 years out on a spin that provably never produces the owed AU, i.e. it wedges the encode thread permanently. A hang, not a panic. WP6.1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0044649b19 |
fix(encode/nvenc): codec-gate the HEVC 4:4:4 union write, and stop it eating the 10-bit arm
android / android (push) Successful in 12m39s
windows-host / package (push) Successful in 10m31s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
decky / build-publish (push) Successful in 19s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
arch / build-publish (push) Successful in 18m50s
docker / deploy-docs (push) Successful in 12s
docker / build-push-arm64cross (push) Successful in 5m55s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m21s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m29s
ci / docs-site (push) Successful in 1m11s
ci / web (push) Successful in 1m11s
apple / swift (push) Successful in 5m39s
ci / bench (push) Successful in 6m55s
ci / rust-arm64 (push) Failing after 8m55s
ci / rust (push) Successful in 21m27s
deb / build-publish (push) Successful in 8m45s
deb / build-publish-client-arm64 (push) Successful in 9m9s
deb / build-publish-host (push) Successful in 11m26s
apple / screenshots (push) Successful in 26m31s
`encodeCodecConfig` is a C union, so the `hevcConfig` writes in the 4:4:4 branch are only meaningful on an HEVC session — on H.264 or AV1 they reinterpret that codec's own config bytes. The branch was gated purely on `chroma_444 && full_chroma_input` with no codec test, and stayed non-UB only because `lib.rs` degrades 4:4:4 for non-HEVC codecs: a two-file invariant with nothing asserting it, on the path BOTH direct-NVENC backends take. The audit filed that much. What it did not note is that the same shape hides a second bug: this is an `if`/`else if`, so a non-HEVC session that arrived with `chroma_444` set took the HEVC branch and skipped the per-codec bit-depth arm entirely — ending up with neither HEVC 4:4:4 (wrong for it) nor its own 10-bit configuration (simply absent). AV1 would have lost `pixelBitDepthMinus8`/`inputPixelBitDepthMinus8` silently. Non-HEVC now falls through to the arm that knows what to do with it, and an unexpected request is logged rather than swallowed. Adds two tests that need no GPU — `apply_low_latency_config` is pure config authoring, so an AV1 session can assert it never receives the HEVC FREXT profile GUID (an INVALID_PARAM at open) and that its own depth still lands, with an HEVC case guarding the good path. ⚠ `NV_ENC_CONFIG` must NOT be `mem::zeroed` in those tests: `frameFieldMode`/`mvPrecision` are C enums whose discriminants start at 1, so all-zero is not a valid value and Rust's zero-init check ABORTS the process (SIGABRT, caught by the Linux gate). They seed it from `Default` the same way `build_config` does before overwriting from the driver preset. Worth knowing before writing any further test against these SDK types. Verified: Linux gate L1-L4 green (39 tests, the two new ones among them) and the full Windows gate on .173 — 7 legs. Note the Windows test leg runs `--features qsv`, so these tests only execute on the Linux leg; the Windows legs prove the change compiles in all five feature combinations. |
||
|
|
b3f8803ea3 |
fix(encode/qsv): stop advertising intra-refresh the driver silently dropped
`ir_active` was `cfg.intra_refresh && set.co2.is_some()` — i.e. "we asked for it", not "we got it". Both `Query` and `Init` can return MFX_WRN_INCOMPATIBLE_VIDEO_PARAM, a WARNING the open path deliberately accepts, while dropping the intra-refresh wave on the floor. That lie is not cosmetic. `ir_active` feeds `EncoderCaps::intra_refresh`, so the session advertises gradual refresh to the client; the client then stops asking for the IDRs it would otherwise request on packet loss, and a lost frame gets concealed instead of repaired. The stream degrades exactly when recovery matters. Now confirmed against the driver: a best-effort `GetVideoParam` with a CodingOption2 buffer chained on, read back for `IntRefType`. Deliberately a SEPARATE query rather than a buffer attached to the existing `BufferSizeInKB` call (which is what the audit finding suggested) — that value backs every bitstream allocation, and a runtime that disliked the chained buffer would take it down with the readback. A failure here costs only the verdict and resolves conservatively (trust the request), so the worst case is the previous behaviour. Verified on Intel UHD 750 (.42), which is where the Intel on-glass run originally caught this: with PUNKTFUNK_INTRA_REFRESH=1, H.264 now logs "silently dropped intra-refresh (GetVideoParam reports IntRefType=0)" and advertises it OFF, while H.265 on the same GPU stays ON — it is genuinely active there. Per-codec, so it could never have been decided statically. 8 live QSV tests pass. Gated on .173: clippy -D warnings at nvenc,amf-qsv,qsv (host + pf-encode --all-targets), amf-qsv without qsv, qsv alone, no-features, cargo test --features qsv, rustfmt — all green. qsv.rs is Windows-only so the Linux legs do not compile it. Closes WP3.2(c), the last Phase 3 item that was hardware-blocked. |
||
|
|
47a23bec12 |
fix(encode/vulkan): unwind every open/import leak, and serve 24-bpp CPU instead of dying on it
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
arch / build-publish (push) Successful in 12m42s
windows-host / package (push) Successful in 18m11s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m6s
docker / deploy-docs (push) Successful in 32s
docker / build-push-arm64cross (push) Successful in 6m26s
apple / swift (push) Successful in 6m42s
deb / build-publish-host (push) Failing after 5m39s
android / android (push) Failing after 6m36s
deb / build-publish-client-arm64 (push) Failing after 5m35s
deb / build-publish (push) Successful in 9m40s
ci / rust (push) Successful in 22m26s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 55s
ci / bench (push) Successful in 5m54s
ci / rust-arm64 (push) Successful in 11m18s
apple / screenshots (push) Successful in 27m18s
Phase 5's Linux half (audit WP5.1 + WP5.4), each item shaped by the
review that rejected the obvious fix:
Dmabuf import unwind (vk_util): every failure after create_image leaked
the VkImage, and the dup'd dmabuf fd leaked as a raw i32. The sharp edge
is that a SUCCESSFUL vkAllocateMemory transfers fd ownership to Vulkan
(vkFreeMemory closes it), so the naive close-on-error is a double close
that clobbers whatever unrelated descriptor recycled the number. The dup
now lives in an OwnedFd released exactly in the allocate-success arm;
every other path drops it once, and bind/view failures free image+memory.
PyroWave open unwind: open_inner had ~20 fallible steps that each leaked
everything before them (instance, device, pyrowave objects, the whole
CSC pipeline). Rather than a parallel teardown guard — whose reviewed
hazards were a null-unsafe pyrowave_encoder_destroy and a drifting
duplicate of Drop — Self is now constructed right after create_device
with every later resource null, and the existing Drop (wait-idle first,
pw_enc null-guarded, delete-nullptr and VK_NULL_HANDLE destroys are
no-ops) is the single unwind path for error and normal teardown alike.
The ensure_cpu_rgb staging twins (create/allocate/bind, both backends)
and the RGB-direct make_view pair get the same discipline via a shared
make_host_buffer. Observed on hardware: 32 forced import failures, zero
fd drift (the new import_failure_leaks_no_fds smoke on RADV).
24-bpp CPU service (WP5.4): pixel_to_vk had no mapping for the packed
Rgb/Bgr the PipeWire portal negotiates, so a session committed to a path
the backend could not serve and died at its first frame. The filed
open-gate was rejected as a half-mirror — the dmabuf axis is keyed by
fourcc at submit, unknowable at open — so instead the CPU axis is
SERVED: a 3-to-4 expand at the staging upload (normalize_cpu_rgb, the
CPU twin of WP1.4's swscale expand), order-preserving for the CSC
samplers and BGRA-forced for the RGB-direct encode source, whose session
pictureFormat is B8G8R8A8 — the on-glass run caught R-first sources
violating VUID-vkCmdEncodeVideoKHR-pEncodeInfo-08207, a mismatch that
predates this change for plain Rgbx CPU sources. The dmabuf axis feeds
pf-zerocopy's raw-dmabuf degrade latch (
|
||
|
|
e0e845b7df |
fix(encode/pyrowave): pin the NT-handle import contract to consume-on-success-only
import_plane closed the shared NT handle on every pyrowave_image_create
failure, believing pyrowave only consumes it on success. The vendored
truth was messier: Granite's allocator closed by-reference handles
UNCONDITIONALLY after the first vkAllocateMemory — success AND failure —
while failures before the allocator (validation, vkCreateImage, no
memory type) left the handle open, and both classes surface as the same
error code. So the call site could not know whether to close, and an
allocate-stage failure double-closed a possibly-recycled handle value
(audit WP5.3). The filed fix (DuplicateHandle + close the original
unconditionally) traded the double close for a guaranteed leak on every
pre-allocate failure and left the ambiguity in place.
Patch 0006 fixes the callee instead: the allocator never closes a
caller's handle, and pyrowave_image_create consumes it exactly at its
success return — which is what pyrowave.h ("take ownership and close the
HANDLE on import") documents, and what Granite's semaphore import
already did, so import_fence was correct as written and the two paths
now share one contract. The close-on-failure in import_plane is thereby
correct on EVERY path, including a vkBindImageMemory failure after a
successful allocate. Bonus fix recorded in the patch: the allocator's
block-recycling retry loop used to re-run vkAllocateMemory with
import_info still pointing at the just-closed handle.
Both-platform gates green; pyrowave_win_smoke (.173, 34/34) re-validates
the live import path against the rebuilt vendored C++.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
d6d4408c8e |
fix(encode/nvenc): teardown that tells wedged from routine, and a session budget that can heal
Three Windows NVENC teardown/accounting defects (audit WP5.2, marked BLOCKING — the obvious fixes were reviewed and rejected before this): Event-handle leak: init_session pushed each completion event to self.events only AFTER nvEncRegisterAsyncEvent, so a failed registration leaked the Win32 handle. Push first — the init-failure path already runs teardown, which closes everything in the list (unregistering the one never-registered event is a harmless error return). Wedged drain: teardown drains the retrieve thread's backlog, and each queued job could burn its full 5 s completion wait — cap x 5 s on the encode thread, paid again on every rebuild of the stall-recovery ladder. The rejected fix (a shutdown flag) abandoned the backlog on EVERY teardown, turning the routine drain into destroy-while-encoding. Instead the wedge evidence stays where it lives: after one full-budget timeout in the retrieve loop, later jobs wait a 250 ms slice; a success resets the latch. Routine teardown is byte-identical to before — nothing is ever abandoned — and a first timeout is already encoder-fatal, so short slices behind an established wedge change only how long teardown blocks. Session-unit accounting: LIVE_SESSION_UNITS was refunded even when destroy_encoder FAILED, drifting the budget low and over-admitting parallel displays. The rejected fix (never refund on failure) let one transient wedge episode poison admission until a host restart. Now the refund follows PROOF: destroy succeeded, or its status says the driver holds no session (device gone/TDR). Ambiguous failures park the handle — units still charged, fail-closed, pinning the D3D11 device the driver session references — and init_session retries the destroy while zero sessions are live, under a gate that serializes every session open against the reap so a recycled handle address can never alias a live session. The charge lasts exactly as long as the driver keeps refusing the destroy, which is the definition of still-leaked. Admission stays a lock-free atomic load. Both-platform gates green (docker linux/amd64 4 legs; .173 all 7 legs). The retry-destroy-on-a-failed-handle assumption is documented at the reap: the SDK defines no retry semantics either way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3efbe4164e |
fix(capture): capture CPU frames once the encoder proves it can't import dmabufs
audit / cargo-audit (push) Successful in 2m13s
audit / bun-audit (push) Failing after 13s
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m3s
windows-host / package (push) Successful in 10m18s
ci / bench (push) Successful in 6m41s
android / android (push) Successful in 12m26s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 14s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
arch / build-publish (push) Successful in 12m30s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m10s
ci / rust-arm64 (push) Successful in 11m46s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m17s
deb / build-publish (push) Successful in 11m17s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m13s
deb / build-publish-host (push) Successful in 12m35s
deb / build-publish-client-arm64 (push) Successful in 8m23s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m5s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7m40s
apple / swift (push) Successful in 5m28s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8m6s
docker / deploy-docs (push) Successful in 14s
ci / rust (push) Successful in 21m44s
docker / build-push-arm64cross (push) Successful in 7m18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m37s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m14s
flatpak / build-publish (push) Successful in 6m38s
release / apple (push) Successful in 29m56s
apple / screenshots (push) Successful in 25m36s
A dmabuf import the GPU driver refuses is refused identically on every retry, but the only recovery above it was the encode-stall ladder: five in-place encoder rebuilds, then the video session ends. So a host whose driver will not take what its compositor allocates lost every session on its first frame, and every reconnect repeated it — while the very same host streamed fine with `PUNKTFUNK_ZEROCOPY=0`. The software knew how to run that machine and never chose to. Latch it, exactly as the sibling CUDA-import path already does after repeated worker deaths: three consecutive import failures with no frame in between disable the raw-dmabuf passthrough for the host process, and capture negotiates CPU frames from the next session on. Three sits below the encoder's rebuild budget, so the latch is set before the session it doomed ends — one bad session, then a working (if slower) host, with a log line saying which and why instead of an operator having to find an environment variable. Only the two stages that ARE the import are counted — the buffersrc push of the DRM-PRIME descriptor and the buffersink pull where `hwmap` maps it into a VA surface. `avcodec_send_frame` is deliberately left out: that one is the encoder stalling, which the in-place rebuild exists to recover, and taking zero-copy away permanently over a transient fault would be a bad trade. The latch lives in pf-zerocopy because it is the leaf both sides can see — the capture→encode edge is one-way by design, so pf-capture cannot ask pf-encode anything. |
||
|
|
f32207a6ba |
fix(encode/vaapi): tell libva the dmabuf's real size, not zero
The zero-copy DRM-PRIME descriptor declared `objects[0].size = 0`, on the belief that ffmpeg would work the real size out. It does not: both of its import paths pass the value straight through to libva — `prime_desc.objects[i].size` on the PRIME_2 path, `buffer_desc.data_size` on the legacy fallback it tries next — so every VA driver we have ever handed a dmabuf to was told the backing object was empty and left to derive the size itself. The drivers this path has run on (radeonsi, modern Intel iHD) derive it correctly, which is why nobody noticed. A Gen9 Intel host does not get that far: `vaCreateSurfaces` answers VA_STATUS_ERROR_ALLOCATION_FAILED on the first frame and every frame after it, `av_buffersink_get_frame` returns EIO, and five in-place encoder rebuilds later the video session is over. That host cannot stream at all with zero-copy on. `lseek(SEEK_END)` is the standard dma-buf size query and the same one this tree's Vulkan bridge already performs on these very fds. A kernel that refuses it leaves the old 0 rather than costing a frame that might still have encoded. Whether this alone fixes that host is unconfirmed — the descriptor was wrong either way, and it is the first thing the driver reads. |
||
|
|
7c82a72ecd |
fix(zerocopy): find the NVIDIA render node instead of assuming renderD128
The EGL importer — the head of the CUDA/NVENC zero-copy path — opened
`/dev/dri/renderD128` and hoped. On a single-GPU host that is the
NVIDIA node and it always worked. On a hybrid laptop the iGPU is bound
first, so renderD128 is Intel: we built a GBM device on Mesa, asked it
for a pbuffer-capable OpenGL config, got none (Mesa's GBM platform
advertises only window-capable ones — gbm_surface is the point of that
platform there), and reported
zerocopy worker init failed: no EGL config for OpenGL
on a machine whose NVIDIA EGL stack was demonstrably fine, naming
neither the device nor the reason. `PUNKTFUNK_RENDER_NODE` was no
escape either: this path never read it.
Pick the node by what it is — the first `/dev/dri/renderD*` whose
sysfs PCI vendor is 0x10de, the same identification the crate's Vulkan
bridge already uses for its physical device. A local scan rather than a
`pf_gpu` dependency, because this crate is a leaf whose worker is its
own process, and `pf_gpu::linux_render_node` answers a different
question anyway: it follows the operator's GPU *preference*, which may
legitimately name the iGPU. What is needed here is not which GPU to use
but where CUDA is. `PUNKTFUNK_ZEROCOPY_RENDER_NODE` overrides, and no
NVIDIA node found keeps the historical guess — sysfs may just not be
mounted, and the CUDA context is what properly fails on a host with no
NVIDIA at all.
Then stop the config query being able to fail this way: retry without
the surface-type constraint, which is honest because we never create an
EGLSurface — `eglMakeCurrent` runs surfaceless. And name the node in
every error and in the ready line, so the next hybrid host diagnoses
itself.
|
||
|
|
6571a26aa9 |
fix(scripting): park the idle runner instead of pinning a core
`punktfunk-scripting` with nothing to run pinned a full CPU core
indefinitely — in exactly the state its own systemd unit documents as
inert ("the runner does nothing until you add scripts or install
plugins"). A field report on a 7.7 GB laptop had it at 99.9% CPU for
two hours straight, `strace` showing a bare `clock_gettime` loop and no
other syscall at all, which starved the compositor badly enough to
blank the physical display.
Nothing in the runner was looping. `Effect.runFork` registers nothing
with the event loop — effect's keep-alive timer lives in
`Runtime.makeRunMain`, which runner-cli deliberately doesn't use
because its shutdown is the bespoke two-signal one — and a bun process
whose only pending work is an unresolved promise busy-spins rather than
blocking. With units running there is normally a socket or timer to
park on; with none there is nothing at all, so the no-op state was the
one state that span.
Hold one idle handle for the runner's lifetime and drop it when the
unit tree ends on its own. Measured on the packaged bun (1.3.14), three
seconds idle with empty scripts/plugins dirs: 3098 ms of CPU before,
280 ms after (bun's startup, then nothing).
The regression is a property of the process rather than of any function
in it, so the test spawns the real CLI and reads the child's CPU time,
asserting it also logged and stayed alive — a crashed runner burns no
CPU either and must not pass.
|
||
|
|
8578141d43 |
fix(encode/vulkan): AV1 at unaligned modes was violating two VUIDs on every frame
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
android / android (push) Successful in 15m38s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
arch / build-publish (push) Successful in 16m13s
windows-host / package (push) Successful in 10m20s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m20s
apple / swift (push) Successful in 5m36s
docker / deploy-docs (push) Successful in 23s
docker / build-push-arm64cross (push) Successful in 8m41s
ci / web (push) Successful in 48s
ci / docs-site (push) Successful in 1m8s
ci / bench (push) Successful in 6m49s
deb / build-publish-client-arm64 (push) Successful in 7m31s
deb / build-publish (push) Successful in 11m40s
ci / rust-arm64 (push) Successful in 12m11s
deb / build-publish-host (push) Successful in 12m15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m59s
ci / rust (push) Successful in 21m45s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m51s
apple / screenshots (push) Successful in 25m22s
Found by running the smokes at 1920x1080 under the validation layers while chasing the three
items left over from WP4.2. AV1 forbids the encode source's `codedExtent` differing from the
sequence header without `FRAME_SIZE_OVERRIDE`
(VUID-vkCmdEncodeVideoKHR-flags-10324), or from the reference slots without
`MOTION_VECTOR_SCALING` (`-10325`). RADV PHOENIX advertises NEITHER — and RGB-direct is the
default on EFC hosts with true-extent the default at unaligned modes, so plain 1080p AV1 was
tripping both on every frame: source 1920x1080 against an app-aligned 1920x1088 header and
DPB. Measured 16 violations per 8-frame run; the CSC path had none.
The fix is to make all three agree at the RENDER size rather than the aligned one — an
unpadded coded size is valid on this hardware, so the coded frame simply IS the visible
frame:
- the AV1 sequence header follows the render size when true-extent is active (joining
native NV12, which already authors true-size headers for the same reason);
- the DPB setup and reference slots carry `src_extent` instead of the aligned `ext2d`.
`src_extent` already collapses to `ext2d` whenever true-extent is off, so every other
configuration is untouched;
- `render_and_frame_size_different` now compares render against the DECLARED source extent
instead of the aligned size, or true-extent would have claimed a mismatch that no longer
exists.
Fixing only the header is not enough and is actively misleading: it clears -10324 and
immediately exposes -10325, because the mismatch has moved to the reference slots rather than
gone. Both had to move together.
This keeps the EFC fast path. Two alternatives were implemented, measured and rejected on the
way here: falling back to the compute CSC costs the zero-copy the B2 work existed to deliver,
and routing to the padded-copy staging trades these two VUIDs for
VUID-VkImageCreateInfo-pNext-06811 — `pad_img`'s extra TRANSFER_SRC usage is not in the
profile-advertised set, measured 8x per session on HEVC-padded too, so that is a pre-existing
defect of the padded path and not somewhere to route a default session.
HEVC is deliberately untouched: it has no equivalent constraint (its crop rides the
conformance window), it measures zero violations, and its aligned-SPS path is the validated
one.
On RADV PHOENIX (780M, Mesa 26.0.4) the AV1 stream now decodes as coded 1920x1080 / render
1920x1080 — genuinely unpadded, where before the alignment rows were encoded and cropped
back out. The CSC path still reports coded 1920x1088 / render 1920x1080, which is correct for
it. All four `vulkan_smoke*` pass at 256x256 and 1920x1080.
Two validation errors remain on the RGB-direct path and are NOT ours, now with evidence
rather than assumption: `VUID-VkImageViewCreateInfo-image-08336` uses the PROFILE-BLIND format
query, so it cannot see that RGB conversion legalises BGRA as an encode source — the
profile-aware query used by -06811 accepts the very same image; and
`VUID-VkQueryPoolCreateInfo-pNext-pNext` rejects
`VkVideoEncodeProfileRgbConversionInfoVALVE`, which the VALVE extension REQUIRES for profile
identity, and the layer diagnoses itself as "a struct from an extension added to a later
version of the Vulkan header".
Verified: canonical Linux gate (docker linux/amd64) L1-L4 green; on-glass on RADV PHOENIX
under `VK_LOADER_LAYERS_ENABLE='*validation*'`, with the bitstreams read back through
libdav1d/trace_headers.
|
||
|
|
87863c12a9 |
fix(encode/windows): clear the Windows Phase 4 backlog — NVENC sticky state, QSV HDR + ABR
arch / build-publish (push) Failing after 5m39s
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m3s
android / android (push) Successful in 11m50s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / bench (push) Successful in 6m25s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
ci / rust-arm64 (push) Successful in 10m5s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5m22s
deb / build-publish-client-arm64 (push) Successful in 9m34s
deb / build-publish (push) Successful in 10m58s
docker / deploy-docs (push) Successful in 12s
deb / build-publish-host (push) Successful in 11m51s
docker / build-push-arm64cross (push) Successful in 6m20s
apple / swift (push) Successful in 5m22s
ci / rust (push) Successful in 22m30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m2s
windows-host / package (push) Successful in 10m48s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m13s
apple / screenshots (push) Canceled after 16m50s
Four items, gated on the RTX box (.173, all 7 Windows legs) and the Linux docker gate, with
the QSV HDR one proven by an A/B on real Intel silicon (.42, UHD 750).
WP4.1 — Windows NVENC sticky state. Both halves were the same mistake: a single field doing
duty as both "what the session negotiated" and "what this session can actually do", so the
first downgrade destroyed the negotiation.
- `query_caps` clears `self.hdr` on a GPU without 10-bit encode, but `submit` re-derives the
requested HDR state from every frame's pixel format and compared it against that cleared
value. On such a GPU a P010 capturer therefore reported "HDR changed" on EVERY FRAME and
tore down and rebuilt the whole encode session each time. The trigger now compares against
`hdr_requested`, and the downgrade is latched in `hdr_unsupported` so it is remembered
(and warned once) instead of rediscovered per init.
- One subsampled-YUV frame cleared `chroma_444` permanently, so 4:4:4 never returned when
the capturer went back to RGB. The negotiation now lives in `chroma_444_requested` and the
effective value is recomputed at every init; the per-session downgrade still happens and
is still reported honestly through `caps()`, it just is not destructive any more.
WP4.3(a) — QSV HDR mastering luminance was divided by 10,000. The old comment justified it as
"VPL wants whole cd/m²", which is true of the VIDEO-PROCESSING unit but not the encode path:
the runtime passes the value straight into the ITU-T H.265 Annex D mastering-display SEI,
whose unit is 0.0001 cd/m². Verified end to end on Intel UHD 750 by dumping the bitstream and
reading SEI 137 with trace_headers, before and after:
before: max_display_mastering_luminance = 1000 (0.1 nits)
after: max_display_mastering_luminance = 10000000 (1000 nits)
with `min` (500 = 0.05 nits) and both content-light-level fields unchanged. That asymmetry —
a correct min beside a 10,000x-low max — was the original tell.
WP4.3(b) — `reconfigure_bitrate` never re-read `BufferSizeInKB`, so an ABR step-up could hand
the runtime an AU buffer sized for the old, lower bitrate. Fixed in both places it was broken:
re-read the driver's answer via `GetVideoParam` after the `Reset` (mirroring `init_encode`),
AND stop `take_bs` recycling pooled buffers without checking their capacity — the pool would
otherwise keep serving short buffers even once `bs_bytes` was correct.
WP7.8 — the `PUNKTFUNK_QSV_FFMPEG` half, finally landable now that a Windows gate exists (it
sits behind `#[cfg(feature = "qsv")]`, so nothing in a Linux-only matrix compiles it — it was
deliberately held back for exactly that reason). Trimmed like its siblings, and kept
case-insensitive: the knob already accepted `TRUE`, and the bare house `matches!` would have
silently dropped that spelling.
Phase 4 of the audit is now complete, and WP7.8 with it.
Verified: Windows gate on .173 — clippy -D warnings at nvenc,amf-qsv,qsv (host + pf-encode
--all-targets), amf-qsv without qsv, qsv alone, no-features, `cargo test --features qsv`
(34 passed), rustfmt. Linux docker gate L1-L4 green. On-glass: the QSV A/B above on .42.
Owed: NVENC sticky-state on-glass on .173 (needs a live 10-bit-capable capture toggle).
|
||
|
|
bab309017d |
fix(encode/vulkan): five mode-specific correctness bugs, three caught on real silicon
WP4.2 of the pf-encode audit, validated on RADV PHOENIX (780M, Mesa 26.0.4) under the Vulkan
validation layers rather than by inspection alone. Three of the five reproduce with the
driver naming the violation; one turned out milder than filed and is recorded as such.
1. `ensure_cpu_rgb` cached the staging image on FORMAT ONLY while sizing it to the SOURCE
frame, and the copy uses the CURRENT frame's extent — so a same-format source-size
increase copied past the allocation. Now keyed on (format, width, height). This is the
memory-safety one: on hardware it was 8 x
VUID-vkCmdCopyBufferToImage-imageSubresource-07971 ("extent.width (512) exceeds
imageSubresource width extent (128)") and `submit` returned Ok every single time, so
nothing upstream could notice. Zero after the fix. Note the trigger for anyone re-testing:
the image is cached PER RING SLOT, so the ring must wrap before a slot sees a larger frame
— two differently-sized submits in a row land on different slots and look fine.
2. The RGB-direct CPU padding loop only ever grows a source into the aligned extent; a source
LARGER than the encode extent panicked on the row `copy_from_slice`. Now refused by name,
matching what the DMA-BUF arms already do, so a bad frame takes the encoder-rebuild path
instead of unwinding out of the encode thread with an index message.
3. AV1 wrote `render_width/height_minus_1` but never set
`render_and_frame_size_different`, and AV1 ignores the render size unless that flag says it
differs — so every mode needing 64x16 alignment presented the padding. libdav1d on the
encoded stream now reports `size 1920x1088 ... render 1920x1080`; before, the flag being
unset meant render defaulted to the coded 1088, i.e. 8 rows of duplicated edge pixels
shipped to the client.
4. `read_slot` read the PERF timestamp pool whenever the pool merely EXISTED, but the
padded-RGB path's CPU-upload arm records its own command buffer and writes no timestamps —
reading an unreset query with WAIT is undefined. Now gated on a per-slot `ts_written`,
because "a pool exists" was being used as proof of "the pool was written". SEVERITY
CORRECTION vs the audit: this is not a hang in practice. It needs PUNKTFUNK_PERF *and*
RGB-direct *and* the non-default PUNKTFUNK_VULKAN_RGB_TRUE_EXTENT=0 *and* an unaligned mode
*and* CPU capture, and RADV then fails the read rather than blocking. Another driver may
block, hence the fix.
5. `reset()` re-arms `first_frame`, which was also what decided whether begin-video-coding
declares the rate-control state. But a reset does not rebuild the session, so the CBR
installed earlier is still current when the next frame opens its coding scope: the
declaration was omitted exactly when it was required. Split into `rc_installed`, which
survives reset. On hardware this was
VUID-vkCmdBeginVideoCodingKHR-pBeginInfo-08253 ("no VkVideoEncodeRateControlInfoKHR ... but
the currently set mode is CBR"); zero after.
Also fixes two pre-existing AV1 spec violations that were firing on EVERY frame of a shipped
path and are not in the audit at all — found only because the validation layers were on, and
confirmed untouched by this branch before being attributed as pre-existing:
`constantQIndex` must be 0 unless rate control is DISABLED (VUID-...-10320; this session is
always CBR, so the driver owns Q), and `pStdPictureInfo->pSegmentation` must be NULL
(VUID-...-10350; it pointed at a zeroed struct, and leaving it unset means the same
"segmentation disabled" while being spec-correct).
Items 1 and 5 keep their reproductions as `#[ignore]`d hardware tests, documented as
meaningful only under `VK_LOADER_LAYERS_ENABLE='*validation*'` since the violations are
invisible to the API's return values.
STILL OPEN, filed not fixed (three more pre-existing validation errors the cleanup exposed):
VUID-VkQueryPoolCreateInfo-pNext-pNext on the AV1 and RGB paths, and 2 x
VUID-VkImageViewCreateInfo-image-08336 (the RGB-direct encode-src view's format features lack
VIDEO_ENCODE_INPUT) — the latter may be a validation-layer gap around the VALVE extension
rather than our bug, and wants real analysis rather than a quick patch.
Verified: canonical Linux gate (docker linux/amd64) fmt + clippy --all-targets at default and
at nvenc,vulkan-encode,pyrowave + both test legs; and on RADV PHOENIX all four `vulkan_smoke*`
tests pass at 256x256 and at 1920x1080 with zero validation errors on the paths touched here.
|
||
|
|
e5e68f1d24 |
feat(presenter): DRM card selection + a usable kmsdrm swapchain error
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m15s
apple / swift (push) Successful in 5m25s
ci / bench (push) Successful in 7m44s
deb / build-publish-host (push) Successful in 10m30s
decky / build-publish (push) Successful in 30s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
android / android (push) Successful in 12m52s
deb / build-publish (push) Successful in 11m46s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
arch / build-publish (push) Successful in 13m6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 45s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
ci / rust-arm64 (push) Successful in 13m32s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m1s
deb / build-publish-client-arm64 (push) Successful in 9m12s
windows-host / package (push) Successful in 18m4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7m1s
flatpak / build-publish (push) Successful in 7m25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m46s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 9m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 9m24s
ci / rust (push) Successful in 23m48s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m28s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m46s
docker / deploy-docs (push) Successful in 22s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m42s
release / apple (push) Successful in 31m30s
docker / build-push-arm64cross (push) Successful in 7m6s
apple / screenshots (push) Successful in 23m4s
Both come straight out of the first compositor-less bring-up (P3), on a two-GPU box: PUNKTFUNK_DRM_CARD=<n> pins SDL's KMSDRM device index. SDL enumerates /dev/dri/card* and takes the first it can open, which is regularly the wrong one: it chose the card a live compositor already held DRM master on and died at swapchain creation, while the idle card with the connected display sat unused. Kept an explicit operator choice rather than auto-detection — deciding "is this card already mastered" needs the very ioctl that taking master IS, so any in-process guess would be fragile. The swapchain error now carries what to actually check. "vkCreateSwapchainKHR: Initialization of an object has failed" is useless to someone bringing up a kiosk; on the kmsdrm backend it now names the pinned card and lists the three real causes in order (no connected connector / another DRM master / NVIDIA). Empty on every other backend, where it would be noise. The NVIDIA note is measured, not guessed: on NVIDIA proprietary + kmsdrm, Vulkan enumerates the GPU AND the display (VK_KHR_display reports the connected HDMI connector) and still fails — as root, with nvidia_drm.modeset=Y, on a card no compositor was using. Not permissions, not DRM master; their direct-display path wants the display leased via vkAcquireDrmDisplayEXT and SDL's kmsdrm surface path does not do that. Plan: punktfunk-planning design/embedded-arm64-client.md §P3 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
aff169e131 |
build(flatpak): make the manifest architecture-generic
Two things were x86-specific, both now expressed properly instead of
hardcoded:
* PKG_CONFIG_PATH carries the runtime's multiarch directory.
flatpak-builder does NOT shell-expand `env` values, so ${FLATPAK_ARCH}
would be taken literally — a build-options.arch override supplies the
aarch64 string and inherits the rest.
* The prebuilt Skia archive is per-target and pinned by sha256. Two
`type: file` sources discriminated by only-arches now share one
dest-filename, so SKIA_BINARIES_URL stays a single literal path.
Upstream publishes the aarch64 archive under the same skia commit hash
and the same resolved-feature key, so the two stay in lockstep on a
skia-safe bump.
build-flatpak.sh gains ARCH (default: this machine's), passes --arch to
both flatpak-builder and build-bundle, and arch-suffixes the bundle name so
an x86_64 and an aarch64 build can coexist in dist/ instead of the second
silently overwriting the first. CI composes its own published filename, so
nothing downstream changes.
The aarch64 Skia sha256 was verified by downloading the published archive,
not taken from the API. No aarch64 flatpak has been built — flatpak-builder
builds in a sandbox for the target arch, which needs an arm64 machine.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
4dc078692d |
build(arch): build the client for aarch64 (Arch Linux ARM)
arch=('x86_64' 'aarch64'), and on aarch64 `pkgname` drops punktfunk-host
so makepkg never enters the host's build or package path; build() takes a
client-only cargo invocation without the NVENC/Vulkan-encode features.
Dropping the host from pkgname rather than giving package_punktfunk-host a
per-package arch is what makes the skip unambiguous, and it mirrors how
PF_WITH_WEB already extends that array.
The host stays x86-only because its encode stack (NVENC/QSV/AMF) is.
Verified by sourcing the PKGBUILD under both CARCH values: x86_64 yields
`punktfunk-host punktfunk-client`, aarch64 yields `punktfunk-client`. NOT
verified by a real build — there is no official arm64 Arch container, and
the README says so rather than implying it is tested.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
1c76b8c7b4 |
build(rpm): let the spec build a client-only aarch64 RPM
The blocker was never ExclusiveArch — it was that %build builds host + tray + client together and %install lays down the host unconditionally, with no way to express "client only". `%bcond_without host` (default ON, so an x86_64 build is unchanged) now gates the host binary, the tray, the headless-session data, the firewalld services, the main %post and the bare %files section. Omitting %files for the MAIN package is the load-bearing part: it is what stops rpm emitting an empty `punktfunk` alongside punktfunk-client. build-rpm.sh exposes it as PF_WITHOUT_HOST=1 and skips the libcuda stub regeneration and the libcuda leak check, neither of which means anything without the host. Verified two ways. `rpmspec -P` shows the default expansion still carrying 3 host install lines, the tray build and one bare %files, while --without host carries none of those and one %files client. And a real rpmbuild in a native aarch64 Fedora 43 container produced punktfunk-client-*.aarch64.rpm with correct aarch64 sonames (libc.so.6(GLIBC_2.17), libSDL3.so.0, libavcodec.so.61) and no main package. Not a cross-compile: %build runs cargo for the build machine's arch, so this wants an arm64 builder. No CI leg yet — deliberate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cac82d13df |
ci: check the client for aarch64 on every push
Adds a `rust-arm64` job: clippy -D warnings for the client crates at --target aarch64-unknown-linux-gnu, plus a --no-default-features session build so the minimal embedded configuration keeps compiling in its own right rather than only as a subset of the default one. Runs in the cross image on the ordinary amd64 runner; client crates are listed explicitly because --workspace would drag in the x86-only host encode stack. This is a regression guard, not an artifact leg (deb.yml ships those). c_char signedness cuts both ways and neither direction is visible from an x86-only CI: hardcoding i8 fails to COMPILE on ARM, while casting to u8 fails the LINT on ARM. Two such defects were already found this way. Also documents the arm64 client .deb recipe in packaging/debian/README.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ab3884c763 |
fix(core/abi): cast identity PEM pointers with .cast(), not as *mut u8
`c_char` is i8 on x86_64 and u8 on aarch64, so `cert_pem_out as *mut u8` is a REQUIRED conversion on one target and a no-op on the other — where clippy::unnecessary_cast then denies it. `.cast::<u8>()` is correct and lint-clean on both. Caught by the new aarch64 clippy leg on its first run. A sweep of the remaining `as *mut u8` / `as *const u8` sites in the client crates found no other c_char-derived casts; the rest convert from c_void or u16. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
000f8b85b0 |
build(ci): cross-compile and publish an aarch64 Linux client
Adds punktfunk-rust-ci-arm64cross (the rust-ci toolchain + an Ubuntu ports
arm64 multiarch sysroot) and a deb.yml job that cross-builds the client
package on the ordinary amd64 runner — no arm64 runner in the fleet and
none needed. Client only: the Linux host encodes with NVENC/QSV/AMF, all
x86. The apt registry keys pool entries by the .deb's own arch, so an arm64
box picks the package up with no client-side configuration.
Three things this had to get right, each of which cost a failed build:
* rustup target add must run against the toolchain rust-toolchain.toml
PINS, not the image's default `stable` — otherwise the pinned toolchain
has no aarch64 std and the build dies ~230 crates in on "can't find
crate for core". Copying the pin file in also pre-downloads the
toolchain every CI job currently re-fetches on first use.
* ffmpeg-sys-next compiles a probe it intends to RUN, so it forces
.target(HOST) while still passing the TARGET's include paths; the arm64
dir then shadows the host's own libc headers and the x86 compiler dies
in bits/math-vector.h on NEON/SVE types. Prepending the host dir does
NOT help — GCC drops a -I that duplicates a system directory, keeping
its original later position — so ci/pf-host-cc strips target include
dirs from host compiles instead.
* the job hard-fails on a non-AArch64 session binary, rather than
shipping an amd64 payload under an arm64 package name.
skia needs no special handling: the aarch64-linux-gnu textlayout+vulkan
prebuilt resolves, so arm64 ships the FULL client, OSD included — unlike
Windows ARM64, which still builds --no-default-features.
The cross image builds in its own docker.yml job (needs: build-push) since
it is FROM punktfunk-rust-ci:latest and must not race the entry that
publishes that base. rpm/Arch/Flatpak stay x86_64 for now — their builders
have no arm64 sysroot, which is its own piece of work.
Plan: punktfunk-planning design/embedded-arm64-client.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
915fc3ef9e |
feat(session): headless --pair, so a box with only SSH can enrol
`punktfunk-session --pair <PIN> --connect host[:port]` runs the SPAKE2 ceremony with no window and no toolkit, prints the same `paired <addr>:<port> fp=<hex>` line as `punktfunk-client --pair`, and exits. Until now the PIN ceremony lived only in the GTK shell or the Skia console, so enrolling an embedded/kiosk client meant installing a desktop on it — or copying the identity store by hand. Dispatches above every graphics call (the machine may have no display) and is in the `--no-default-features` build: enrolling must never be the reason a minimal image pulls in Skia. `--name` defaults to the hostname instead of the desktop path's hardcoded "Steam Deck". `forget_placeholder` and `device_name` move into pf_client_core::trust so the two binaries share one implementation rather than the session re-deriving them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b7cea71bbd |
fix(presenter): type the Vulkan extension pointers as c_char, not i8
`char` is signed on x86_64 but UNSIGNED on aarch64, so the hardcoded `Vec<*const i8>` compiles on every desktop target and then fails to match ash's `&[*const c_char]` when cross-compiling the client for ARM. Found by the aarch64 cross-build spike (punktfunk-planning: embedded-arm64-client.md) — the only portability defect in the client stack; nothing else in the client crates assumes an architecture. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
07f8e37c85 |
fix(encode/sw): refuse a mode openh264 cannot encode at open, not at every submit
ci / docs-site (push) Successful in 1m2s
ci / web (push) Successful in 1m10s
decky / build-publish (push) Successful in 18s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
apple / swift (push) Successful in 4m12s
ci / bench (push) Successful in 7m0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7m3s
android / android (push) Successful in 12m29s
docker / deploy-docs (push) Successful in 28s
deb / build-publish-host (push) Successful in 13m15s
deb / build-publish (push) Successful in 11m37s
arch / build-publish (push) Successful in 15m59s
windows-host / package (push) Successful in 18m2s
ci / rust (push) Successful in 22m20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m53s
apple / screenshots (push) Successful in 24m23s
openh264 tops out at level 5.2 — 3840x2160 landscape or 2160x3840 portrait — and enforces that ceiling inside `reinit`, which the crate calls on the FIRST ENCODE rather than at encoder construction. So an oversized mode built a perfectly healthy-looking encoder and then failed every single submit: the session connects, negotiates, and never delivers a frame, with the real reason buried in a per-frame error rather than at the open. `validate_dimensions` does not cover this. It is keyed on the codec, and H.264 legitimately reaches 4096 on every hardware backend — this ceiling belongs to the software backend alone, which is exactly the path a GPU-less host falls back to. Rejects at open instead, mirroring the rule from the openh264 version we actually ship (0.9.3) rather than from its docs — including the orientation-aware shape, since a naive per-axis `w <= 3840 && h <= 2160` would wrongly refuse a legal 2160x3840 portrait session. Three tests: the accepted modes in both orientations, the modes `validate_dimensions` lets through but openh264 cannot serve, and that `open` itself refuses rather than deferring to submit. They cost nothing to run — the guard fires before openh264 is initialised at all. Verified with the canonical Linux gate (docker linux/amd64): fmt, clippy --all-targets at default and at nvenc,vulkan-encode,pyrowave, and the pf-encode test leg (37 passed). |
||
|
|
ed525c4c73 |
fix(encode/vulkan): PUNKTFUNK_VULKAN_RGB_DIRECT read "0 " as force-ON
The knob tested `v == "0"` for the disable case and treated everything else that was set as a force-enable. So a trailing space — the kind a `.env` line or a shell script leaves behind — made `PUNKTFUNK_VULKAN_RGB_DIRECT=0 ` mean the exact opposite of what the operator wrote, forcing the RGB-direct encode source on. On a cursor-blend session that is not a subtle difference: the EFC front-end cannot blend, so the pointer disappears from the stream. An empty value and any typo did the same thing. Now parsed like every sibling knob in the crate — `matches!(v.trim(), "1"|"true"|"yes"|"on")` and the matching false spellings — so unrecognised input falls back to the default instead of being read as a force-on. `=0` and `=1` keep working exactly as documented. Splits the parse into a pure function so the accepted spellings are covered by tests. That matters more than it looks: the env-var read itself is untestable in a parallel test binary (the process environment is shared), which is precisely why this knob's behaviour had no coverage and the inverted case survived. Verified with the canonical Linux gate (docker linux/amd64): fmt, clippy --all-targets at default and at nvenc,vulkan-encode,pyrowave, and the pf-encode test leg (37 passed). |
||
|
|
3fac1a6da1 |
fix(encode/linux): stop the capability probes racing each other's libav log level
Every capability probe drops libav's log level to AV_LOG_FATAL around an encoder open it expects to fail, then restores what was there before. That level is one process-global int and the probes genuinely overlap — they are reached both from `/serverinfo` and from session bring-up. Two overlapping save/restore pairs interleave as get(INFO) → get(FATAL) → set(INFO) → set(FATAL), and the process is then pinned at AV_LOG_FATAL permanently: every later libav diagnostic silently dropped, including the ones you want when a stream fails to open later on. Replaces the four hand-rolled save/restore pairs with one RAII guard over a single shared mutex. The audit filed two sites; there are four — the NVENC 4:4:4 and 10-bit probes in `linux/mod.rs` and both VAAPI probes, which is why the lock has to be shared across the two modules rather than kept local to either. Being RAII also closes a smaller hole the old shape had: the restore was a statement after the open, so any early return added to those functions later would have leaked the quiet level. Now it cannot. The guard is poison-tolerant — a probe that panicked mid-window has already restored the level through `Drop`, so refusing the lock forever afterwards would be strictly worse than proceeding. It is not re-entrant, which is safe here because no probe body reaches another probe (they only call encoder-open helpers); that invariant is written down at the type. Serialising the probes costs nothing measurable: they are process-once behind their caches and each already pays for a real encoder open. Verified with the canonical Linux gate (docker linux/amd64): fmt, clippy --all-targets at default and at nvenc,vulkan-encode,pyrowave, and the pf-encode test leg (37 passed). |
||
|
|
a17413327b |
fix(encode/vulkan): give the vendored Vulkan ABI the layout guard it never had
`vk_av1_encode.rs` and `vk_valve_rgb.rs` are hand-copied `#[repr(C)]` structs handed to the driver through raw `p_next` chains. Nothing in the type system relates them to the C definitions any more, so an edit that inserts, drops, widens or re-pads a field is not a compile error — it is the driver reading our bytes at the wrong offsets, silently. The sibling vendored ABI in `amf.rs` has carried assertions for exactly this reason; these two had none at all. Adds size, alignment and per-field offset assertions for all 19 vendored structs. They are `const` rather than `#[cfg(test)]` (the shape `amf.rs` uses) so they hold in every build including the shipped one, and on any target the modules compile for. Assertions cannot catch a swap of two same-typed fields — the offsets are unchanged — so the field order was diffed field-by-field against the authoritative headers while writing them: `vulkan_core.h` and `vk_video/vulkan_video_codec_av1std_encode.h` from Vulkan-Headers `main` as of 2026-07-25. That diff covered every struct, every `ST_*` structure-type value, every flag bit and both enum groups, and found no drift — the vendored copies are faithful. The one remaining hand-copied table the compiler still cannot see is the bitfield member order inside the three `*Flags` words, where a wrong index means the driver reads `use_superres` where we meant `render_and_frame_size_different`. Three tests pin those to the header by listing the members in C declaration order and asserting the Nth setter writes bit N, with the trailing `reserved` field checked too — a dropped member shifts `reserved` down and fails. Verified with the canonical Linux gate (docker linux/amd64): fmt, clippy `--all-targets` default and `nvenc,vulkan-encode,pyrowave`, and both test legs. |
||
|
|
6c2183ceec |
fix(nvenc): stop telling operators to reboot when the driver version is fine
`NV_ENC_ERR_INVALID_VERSION` is how the driver reports two opposite failures, and we only ever explained one of them. The message told the operator to update the NVIDIA driver or reboot — correct for a genuine header/kernel-module skew, and actively misleading for the field case behind it: a host that streams once per boot and then fails every later session at the caps probe, until the *process* restarts. A version skew is static; it cannot come and go inside one process, so that advice cost the reporter a reboot per stream. Split the two on the only fact that tells them apart: whether a session has already opened in this process. `nvenc_status` gains a `SESSION_OPENED` latch set right after every successful `open_encode_session_ex` — both backends' caps probe and real open, plus the Windows availability probe. No session yet, the version word really is in question and the existing skew advice stands. A session already opened, and the kernel module demonstrably accepted this build's version word, so the message now names per-process driver state and points at the cheap fix (restart the host service, no reboot) plus a request for the log. The load-time gate cannot serve as this discriminator: `NvEncodeAPIGetMaxSupportedVersion` is a pure userspace query, so the classic "updated the driver, didn't reboot" skew sails through it and only fails later at the open. Only a session that actually opened proves the kernel module agreed. The split lives in a pure `invalid_version(bool)` so both halves are unit-tested without touching the process-wide latch. This is a diagnosis change only — it does not fix the underlying field bug, whose root cause is still open. Verified on Linux (192.168.1.25): clippy `-p pf-encode --features nvenc` clean, `cargo test -p pf-encode --features nvenc --lib` 15 passed, rustfmt clean. |
||
|
|
ead37d066f |
perf(encode/nvenc-linux): drop the bring-up probe left on the per-frame blend path
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 58s
decky / build-publish (push) Successful in 17s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
apple / swift (push) Successful in 5m27s
ci / bench (push) Successful in 6m10s
deb / build-publish (push) Successful in 9m22s
docker / deploy-docs (push) Successful in 24s
windows-host / package (push) Successful in 10m0s
deb / build-publish-host (push) Successful in 13m54s
android / android (push) Successful in 16m12s
arch / build-publish (push) Successful in 16m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m6s
ci / rust (push) Successful in 21m41s
apple / screenshots (push) Successful in 23m17s
`33121ece` added a block explicitly labelled "TEMP KWin composite probe (DROP BEFORE MERGE)" to prove the cursor blend was dispatching. It merged, and has been running on every blended frame since: an atomic fetch_add per frame plus a `tracing::info!` with seven computed fields every 512 frames, on the submit hot path of the default-on Linux direct-NVENC backend. The signal it existed for is already covered — the failure arm above it warns once with the dispatch error, and the `else` arm warns when an overlay arrives with no blend at all. What is deleted is only the success-path telemetry. Verified `-D warnings --all-targets` + tests on Linux (default; shipped nvenc,vulkan-encode,pyrowave), and `clippy --features vulkan-encode,pyrowave` on real AMD RDNA3 hardware. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
310b85f155 |
fix(encode): a forced-Vulkan pref must not advertise codecs that arm will refuse
ci / docs-site (push) Successful in 54s
ci / web (push) Successful in 56s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 59s
apple / swift (push) Successful in 5m10s
ci / bench (push) Successful in 5m58s
windows-host / package (push) Successful in 10m3s
deb / build-publish (push) Successful in 12m10s
docker / deploy-docs (push) Successful in 27s
deb / build-publish-host (push) Successful in 12m52s
android / android (push) Successful in 15m8s
arch / build-publish (push) Successful in 15m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m27s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m51s
ci / rust (push) Successful in 22m34s
apple / screenshots (push) Successful in 24m6s
With `PUNKTFUNK_ENCODER=vulkan`, `open_video_backend`'s vulkan arm bails outright for
anything that is not HEVC/AV1 ("the Vulkan Video encoder supports HEVC + AV1; the
session negotiated {codec:?}"). But `host_wire_caps` for that same pref fell through
to the VAAPI probe / static superset, which includes H.264 — so the host advertised
H.264, a client could negotiate it, and the session died at encoder open.
The pref now contributes a CEILING that is intersected with the device probe, never a
replacement for it. That distinction is the whole fix: pinning a static HEVC|AV1
would have ADDED AV1 on the AMD/Intel hosts whose probe currently withholds it
(pre-RDNA3, pre-Arc), re-creating this very bug for a different codec. Intersecting
can only ever narrow.
Without the `vulkan-encode` feature the pref cannot open anything at all — that arm
bails with "requires a build with --features vulkan-encode" — so the ceiling is
empty there rather than optimistic.
Costs nothing on the handshake path: a `&str` match plus a const-folded `cfg!`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
087a3e358a |
fix(encode/vaapi): key the entrypoint cache on the device, not just the codec
`LP_MODE` cached the resolved VAAPI entrypoint (full-feature `EncSlice` vs low-power
`EncSliceLP`/VDEnc) in a process-global array indexed by codec alone. That is not a
staleness bug, it is a session-killer, because the cache is load-bearing: once a mode
is latched the open tries exactly ONE mode and the `[false, true]` fallback is gone.
Latch low-power on an Intel Arc (Gen12+ removed the full-feature entrypoints, so it
is the only one that works there), then switch the web-console GPU preference to an
AMD dGPU. `render_node()` follows that preference, so every VAAPI open now goes to
radeonsi passing `low_power=1`, which it rejects — with no full-feature retry, for
the process lifetime. `probe_can_encode` reports all-false (so the advertisement
silently falls back to the static superset) AND the session's own encoder open fails.
Now keyed on (render node, codec, bit depth):
- The render node because the entrypoint is a property of the DEVICE libva opens,
and it is literally what `render_node()` hands libva — so key and device cannot
describe different GPUs. Deliberately not `pf_gpu::selection_key()`, which can
name a different adapter than the node actually opened.
- The bit depth because Main10 and 8-bit can resolve to different entrypoints on
the same device; one shared slot let an 8-bit answer pin the 10-bit open, i.e.
HDR under-advertisement.
Verified `-D warnings --all-targets` + tests on Linux (default; shipped
nvenc,vulkan-encode,pyrowave). The multi-GPU switch itself needs a box with two
VAAPI-capable adapters — owed on-glass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
7536f7319a |
fix(gamestream): a software-encode host must advertise H.264 only
ci / web (push) Successful in 1m0s
ci / docs-site (push) Successful in 1m7s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
ci / bench (push) Successful in 7m9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5m31s
deb / build-publish-host (push) Successful in 9m36s
docker / deploy-docs (push) Successful in 26s
deb / build-publish (push) Successful in 11m22s
arch / build-publish (push) Successful in 11m56s
android / android (push) Successful in 15m17s
windows-host / package (push) Successful in 17m7s
apple / swift (push) Failing after 17m50s
apple / screenshots (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m41s
ci / rust (push) Successful in 27m13s
`base_codec_mode_support` fell through to the static superset (H.264|HEVC|AV1) on a GPU-less host, so Moonlight negotiated HEVC or AV1 and the session then died at encoder open with "the software encoder emits H.264 only" — openh264 encodes nothing else. The native plane's twin of this function, `pf_encode::Codec::host_wire_caps`, has gated on exactly this since it was written; this one never did. Deliberately a local gate rather than delegating wholesale to `host_wire_caps()`. Delegation is the drift-proof shape and was the first design, but on Windows it re-runs the DXGI adapter enumeration several times per `/serverinfo` GET — the probe helpers each sample it uncached — and this endpoint is polled by every client. The software case is a plain config read, so it costs nothing here. Recorded in a comment rather than fixed: the static `MaxLumaPixelsHEVC` in the serverinfo XML still advertises an HEVC limit even when the mask now drops HEVC. Harmless (Moonlight gates capability on the mask, not the limit) but it is a second, now-inconsistent advertisement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4d9f94e0a4 |
docs(encode): record why the production loops must not call Encoder::flush
`flush` looked dead — the only caller in the host is the `spike` dev subcommand — so an audit sweep filed it as "wire it in or delete it". Both are wrong, and without this note the next sweep will re-file it. Wiring it in is refuted by control flow: both encode loops reach their exit only AFTER the transport is gone (client disconnected, or the session stopped), so the AUs a flush would recover have nowhere to go. And flush is the one call on this trait that can BLOCK on a wedged encoder, on exactly the teardown path a stopped session needs to finish promptly — the Linux direct-SDK NVENC retrieve-thread join is untimed, so flushing there could hang a session that is already ending. Deleting it is refuted by real consumers: `spike` encodes a FINITE clip and wants the tail, and the `#[ignore]`d hardware smoke tests across the backends assert the drain contract on real GPUs. Those are finite-stream users; a live session is not one. Doc only, no behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ef239691df |
feat(encode): make cursor blending a queryable capability, not an assumption
`open_video`'s `cursor_blend` argument was a request with no answer: lib.rs did
`let _ = cursor_blend;` and only three backends ever read `CapturedFrame::cursor`.
So a session could ask for a composited pointer, get a backend that silently
discards it, and stream with no mouse cursor and nothing in the logs. Two
separately-confirmed audit findings — the VAAPI dmabuf path and the libav-NVENC CUDA
path — are symptoms of that one hole.
`EncoderCaps::blends_cursor` makes it a fact each backend states. The four exhaustive
`EncoderCaps { .. }` constructors mean adding the field is a compile error until every
backend answers, which is the enforcement mechanism for future backends rather than a
side effect. Vulkan Video answers from its ACTUAL configured source rather than
statically: only the CSC path composites (`prep_cursor` feeds the compute shader),
while the RGB-direct/EFC front-end and the native-NV12 source have no compositing
stage at all and merely warn once that the pointer is being dropped.
`open_video` warns when a session asked for blending and the opened backend cannot
deliver it. A warning is deliberately all it does: `open_video` cannot re-plan
capture, so refusing would trade a missing pointer for a dead session. The host owns
`plan.cursor_blend` and is the only layer that can fall back to capturer-side
compositing — this gives it something to base that on.
Enforcement is NOT included. The reviewed design proposed refusing the client's
host-composite flip to keep the client drawing its own pointer, but `CursorRenderMode`
is client->host only: there is no host->client counterpart, so refusing yields no
pointer at all — the same failure it claimed to prevent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
ab63e0dad3 |
fix(encode/nvenc-windows): fail safe when nobody sets the input ring depth
`set_input_ring_depth` is a DEFAULTED trait method, so a caller that forgets it fails silently — and one did. The GameStream loop opens an encoder (gamestream/stream.rs:671 and the rebuild at :831) and never called it, while the Windows IDD-push capturer declares a ring of 2 and `async_inflight_cap()` defaults to 4. With PUNKTFUNK_NVENC_ASYNC=1 a Moonlight session therefore pipelined four encodes against a two-texture ring, letting the capturer rotate a texture out from under a live encode: torn or mixed frames, never an error — precisely the corruption the cap exists to prevent, and precisely what the trait doc warns "fails silently and intermittently". Guarded at the single point of CONSUMPTION rather than by plumbing the setter into every loop: `input_ring_depth` has exactly one reader in the workspace, so one fail-safe covers every caller including ones not yet written, whereas fixing N call sites only fixes the N someone remembered. An unconfigured ring is now treated as the shallowest any capturer here declares, so the unconfigured path degrades to less pipelining — a latency cost, not corruption. The two GameStream sites also pass the REAL depth, because the fail-safe is a floor, not a substitute: `idd_depth` is configurable and a deeper ring is free pipelining the fallback would forfeit. Default (sync) sessions are unaffected — the cap is only read while the async retrieve thread exists, and that is opt-in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
78fe77b049 |
feat(host/encode): de-escalate the latency escalation once cadence holds clean
ci / web (push) Successful in 46s
ci / docs-site (push) Successful in 1m7s
decky / build-publish (push) Successful in 32s
ci / bench (push) Successful in 6m12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6m44s
deb / build-publish (push) Successful in 12m39s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
deb / build-publish-host (push) Successful in 13m29s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6m35s
android / android (push) Successful in 15m57s
arch / build-publish (push) Successful in 16m18s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9m21s
docker / deploy-docs (push) Successful in 23s
windows-host / package (push) Successful in 17m51s
apple / swift (push) Successful in 5m59s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m32s
ci / rust (push) Successful in 21m53s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m20s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m45s
flatpak / build-publish (push) Successful in 6m24s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m2s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m35s
release / apple (push) Successful in 30m44s
apple / screenshots (push) Successful in 24m33s
Escalate-and-hold's missing half. The contention escalation (capture depth, then the NVENC pipelined retrieve) was permanent: one sustained overrun — even one CAUSED by the ABR overdrive's rebuild storms — cost the session its depth-1 latency and its sub-frame streaming forever, and `encode_us` reported queue depth instead of ASIC time for the rest of the session. - The leaky bucket now keeps scoring after escalation. A sustained every-frame-on-cadence run (~5 s at 120 fps) winds back one stage in reverse order: pipelined retrieve first (its rebuild restores the IO-stream binding and sub-frame chunked streaming), then capture depth back to 1. Attempts are paced by an exponential backoff (1 → 5 → 25 min, capped) — a workload that truly needs the escalation converges to keeping it, but never a permanent latch. - NVENC (Linux) implements `set_pipelined(false)`: a `want_sync` latch handled at the same drained safe point as the engage side (`maybe_disengage_async` mirrors `maybe_engage_async`); the lazy sync re-init re-arms everything and opens on an IDR. The stream loop polls until the switch lands, then re-runs the escalation warmup so the wind-back's own stall can't re-escalate it. `PUNKTFUNK_NVENC_ASYNC=1` (operator-pinned async) refuses the wind-back; the trait doc now specifies the two-way contract. - While escalated, `cadence_degraded` stays latched (bitrate climbs refused) even with the bucket drained: the headroom is spent, and climbs resuming mid-escalation would saw against it and starve the clean run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1b27706a9b |
feat(host/native): truthful bitrate state — applied-rate adoption, ceiling pre-clamp, climb refusal
Host half of the §ABR-overdrive fix. The stream loop now reads `Encoder::applied_bitrate_bps()` after every bitrate apply and stores THAT into `bitrate_kbps`/`live_bitrate` — the send pacer, web console, mgmt registry and control-task acks all track what the ASIC really targets instead of the requested rate (the pre-fix ack promised 1.01 Gbps while the encoder ran 794 Mbps, and the client controller climbed from the phantom base forever). - A short apply teaches `encoder_ceiling_kbps` (shared atomic): the stream loop pre-clamps incoming requests to it and SKIPS the apply when nothing would change — ending the reconfigure-reject → full-rebuild(~0.6 s + IDR) storm — and the control task resolves future SetBitrate acks against it, so the client learns the ceiling through the existing ack path (no wire change; old clients converge too). - `cadence_degraded` (shared flag, leaky-bucket level ≥ 10 or an escalated session): while set, the control task resolves climbs to the current applied rate — on a fat LAN no network signal ever stops a climb the encoder can't serve, and past the compute knee more bits only deepen the cadence miss. Descents always pass; they're the cure. - Escalation-warmup hygiene: an ABR rebuild stall (~70 missed deadlines at 120 fps, 3.5× the escalate threshold) and the backlog scored against a heavier rate no longer feed the latency escalation — rebuilds reset the leaky bucket and re-run the warmup; in-place down-steps clear the bucket. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f3c3a9427b |
feat(client/abr): learn the host's rate cap from short acks, and read host encode time as a signal
Two client-side halves of the §ABR-overdrive fix (the 4K120 sessions that climbed to 1 Gbps against a 794 Mbps encoder and a 8.33 ms budget): - Short-ack cap learning: the host now resolves a climb it can't serve to what the encoder actually runs at (its codec-level ceiling, or the current rate while encode is behind cadence). Two consecutive identical short acks latch that value as a host cap the climb logic folds into its ceiling — one short ack stays a transient (a failed rebuild also acks short once). The cap is mode-scoped (cleared on an accepted mode switch, tracked via a mode generation counter the control task bumps) and re-probes one step after ~60 s parked clean, so a heavy-scene refusal can't quietly cap the whole session. - Host-encode-latency down-driver: the per-AU 0xCF `encode_us` the host already ships (and the overlay already draws) now feeds the controller through its own window accumulator — the overlay channel is lossy and embedder-drained, so the ABR gets a dedicated mirror of the decode-latency path. Baseline-relative like the decode signal (an escalated host reports encode_us inflated by ~a frame of queue depth; an absolute budget threshold would read permanently red), with the baseline rebased after our own decreases so one backoff doesn't train-fire into the floor. This is the only signal that can push an already-too-high rate back under the encoder's compute knee — host climb refusal stops the climb, but nothing else descends on a clean LAN. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c1d54b835b |
fix(encode/nvenc): cache the level ceiling, expose the applied bitrate, force 2-way split at 4K120
Three encode-time fixes from the 4K120 field analysis (sessions pinned ~107 of 120 fps, 14-15 ms reported encode): - Ceiling truth: the codec-level bitrate clamp (binary search at session open) now records its result in a process-lifetime advisory cache keyed by GPU/config, so an ABR overshoot opens — or in-place reconfigures — straight AT the ceiling instead of re-running the ~6-open search (and a full rebuild + IDR) on every overshoot. New `Encoder::applied_bitrate_bps()` exposes the post-clamp rate so the session loop can stop pacing/acking a phantom requested rate (consumed host-side in a follow-up). - Clamp-search hygiene: only NVENC parameter/caps rejections steer the search now (`NvCallError` keeps the raw status downcastable); a transient failure (busy engine, session limit, OOM, driver skew) propagates instead of shrinking into — and now caching — a bogus ceiling. The floor fallback also records the split mode it actually opened with, so a later reconfigure re-presents the real session params. - Split-frame selection: one shared `resolve_split_mode` replaces the two byte-identical direct-SDK copies; the force-2-way threshold moves to `SPLIT_FORCE_PIXEL_RATE` (950 Mpix/s, shared with the libav path) because 4K120 = 995,328,000 px/s missed the old `> 1e9` gate by 0.47% and stayed on AUTO — which never engages at 2160 px height, leaving the second NVENC engine idle in exactly the mode the threshold existed for. The Linux session-ready info! line now carries the final split mode (journals are INFO+; this was undiagnosable from user logs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
42e5f5ad1e |
fix(encode/windows): two dead items the new lint leg exposed in the shipped combo
windows-host / package (push) Successful in 11m35s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 1m0s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 6m3s
arch / build-publish (push) Successful in 12m26s
android / android (push) Successful in 15m11s
docker / deploy-docs (push) Successful in 11s
deb / build-publish (push) Successful in 11m16s
deb / build-publish-host (push) Successful in 12m13s
apple / swift (push) Successful in 5m48s
ci / rust (push) Successful in 21m49s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m43s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m52s
apple / screenshots (push) Successful in 24m41s
|
||
|
|
d49f1bba49 |
fix(tray): count native sessions as streaming, and stop hiding "Open web console"
apple / swift (push) Successful in 1m30s
ci / web (push) Successful in 1m1s
windows-host / package (push) Failing after 4m40s
ci / docs-site (push) Successful in 1m8s
ci / bench (push) Successful in 5m31s
decky / build-publish (push) Successful in 29s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
arch / build-publish (push) Successful in 11m30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 41s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
android / android (push) Successful in 15m55s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5m36s
deb / build-publish (push) Successful in 9m54s
deb / build-publish-host (push) Successful in 9m42s
ci / rust (push) Failing after 21m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m45s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 9m57s
apple / screenshots (push) Successful in 22m35s
docker / deploy-docs (push) Successful in 25s
The tray sat at "Idle" through an entire stream, and its Windows context menu could come up without its main action. Two separate causes. `GET /api/v1/local/summary` reported `video_streaming`/`audio_streaming` straight from `AppState.streaming` — flags only the GameStream media pipeline raises. The native punktfunk/1 plane is the DEFAULT (GameStream is opt-in) and never touches them, so every native session read as idle: idle icon, and a tooltip that printed that session's own resolution next to the word "idle" (the `session` field was already native-aware). This is the blind spot `/status` was fixed for — see the `session_status` module doc — which the summary kept. Both flags now OR in a live native session, and the tray additionally treats a reported session as streaming, so a new tray reads an older host correctly too. The menu built "Open web console" (and the pairing entry) only while a live loopback probe of the console had just succeeded. A console still starting, or an SSR render slower than the 2 s probe timeout, therefore deleted the tray's most-wanted action outright — indistinguishable from a tray that never had one, with left-clicking the icon as the only, undiscoverable, way in. The entry is now always present and the default item; a failed probe changes its label to "(not responding)" instead of hiding it, and takes two consecutive misses to say so, since one timeout is not "down". While here: a "Release kept display…" entry when displays are held (the summary field's own doc promised a one-click release that did not exist), and entries deep-link to /pairing and /displays instead of all landing on the dashboard. The Linux SNI menu mirrors all of it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
09aa2db37c |
fix(encode): probe Vulkan encode before committing capture to producer-native NV12
ci / web (push) Successful in 1m4s
ci / docs-site (push) Successful in 1m8s
apple / swift (push) Successful in 1m22s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 45s
ci / bench (push) Successful in 6m59s
windows-host / package (push) Failing after 10m45s
deb / build-publish (push) Successful in 11m9s
android / android (push) Successful in 12m59s
deb / build-publish-host (push) Successful in 11m59s
arch / build-publish (push) Successful in 13m21s
apple / screenshots (push) Successful in 16m48s
ci / rust (push) Successful in 22m37s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m39s
docker / deploy-docs (push) Successful in 28s
`linux_native_nv12_ok` is the verdict the host threads into capture negotiation
(session_plan -> OutputFormat::nv12_native -> ZeroCopyPolicy::native_nv12_session
-> pf-capture's prefer_native_nv12). Its doc claimed "the backend must be eligible
to open", but it asked only three static questions — codec is H265/AV1, an env
default, and a pref denylist — and never whether this GPU has an encode queue at
all. It could not: vulkan_video.rs exposed no probe.
That verdict is uniquely load-bearing. Once the producer has been asked for
two-plane NV12 there is NO fallback: open_video deliberately makes a failed Vulkan
open FATAL for an NV12 capture rather than degrading to libav VAAPI, because VAAPI
would import that buffer as packed RGB and stream silent garbage. So on a gamescope
host whose Mesa lacks Vulkan HEVC encode the session died at its first frame, while
the same host with PUNKTFUNK_PIPEWIRE_NV12=0 streamed fine — and the comment
promising such a device "degrades gracefully to the old backend rather than
breaking the stream" was stale on every gamescope host.
Two changes:
1. The gate. The third conjunct was a denylist of the EXPLICIT prefs that skip
Vulkan Video ("nvenc"|"nvidia"|"cuda"|"pyrowave"), which silently missed the one
that matters: the DEFAULT encoder_pref is "", and "" resolves to auto, which on
an NVIDIA box opens NVENC. A stock NVIDIA host passed this gate. It now consults
`linux_zero_copy_is_vaapi`, which layers the pref on top of the same auto
decision open_video makes — what the note on `linux_auto_is_vaapi` says a
capability probe must use, and what the downstream consumer of this very verdict
already uses. This alone was worth fixing; a probe added behind the old gate
would have opened a Vulkan instance on every NVIDIA handshake.
2. The probe. `vulkan_video::probe_encode_support` runs the FIRST check open_inner
performs and hard-fails on — the physical-device + encode-queue-family scan for
this codec's op — and nothing more, so it is provably no stricter than the open
and can never talk a working host out of the fast path. The scan is now a shared
`find_encode_device` used by BOTH, because a probe that mirrors a dispatch goes
stale the first time the dispatch grows a case (the failure open_video's
backend-label note already records). Cost: one instance plus physical-device
queries — no logical device, no video session, no VRAM — cached per (selected
GPU, codec) in the can_encode_10bit idiom, with the probe run outside the lock.
Per codec, not once: codec_op_for selects a different queue-family bit for AV1,
and HEVC-encode-without-AV1-encode is the common VCN/ANV configuration.
Later stages (create_device, create_video_session, the capability query) can still
fail for reasons the probe does not model. Those are harmless: the capture format
is only committed once the probe says yes, and everything after keeps the ordinary
packed-RGB negotiation.
Verified `-D warnings --all-targets` + tests on Linux (default; shipped
nvenc+vulkan-encode+pyrowave). The behaviour needs an on-glass check on the bazzite
RADV box — including the negative case, which `RADV_DEBUG=novideo` reproduces.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
4063ddc93d |
perf(encode/nvenc-linux): swscale the packed 3-bpp expand instead of a per-pixel loop
`submit_cpu`'s RGB24/BGR24 → rgb0/bgr0 expand ran `w*h` iterations per frame, each building two bounds-checked sub-slices for a 3-byte copy plus a pad-byte store — a shape LLVM will not vectorise into the byte shuffle it actually is. At 3840x2160 that is 8.3M iterations on the encode thread, per frame. It is not an edge case. The module header says the portal commonly negotiates packed 24-bit RGB, and pf-capture offers `VideoFormat::RGB` first because wlroots commonly fixates it — so this was the mainstream CPU path, running roughly an order of magnitude slower than the 4-bpp sibling branch (a plain row memcpy) purely because of how it was written. swscale's packed-RGB expanders are SIMD, the sibling VAAPI backend already routes RGB24/BGR24 through them, and this file already owned the `sws_getContext` / `sws_freeContext` lifecycle — so this is net subtractive rather than new machinery: the existing CSC condition widens to include `expand`, and the hand-written loop goes away. No new field, no second context, nothing added to `Drop`: `expand` is false whenever `want_444` (see the `nvenc_pixel`/`expand` binding), so the three users are mutually exclusive and one context serves whichever applies. The `expand` field itself is gone with its only reader. `sws_setColorspaceDetails` is now applied ONLY for the 4:4:4/HDR users. The expand is a pure byte shuffle — NVENC does the RGB→YUV itself downstream — and handing it a matrix and range would have silently range-converted every packed-RGB session, which is exactly what the module header promises does not happen. Verified `-D warnings --all-targets` + tests on Linux (default; shipped nvenc+vulkan-encode+pyrowave). The pixel path itself is unverifiable without an NVIDIA box: `nvenc_hdr10_smoke` and friends are #[ignore]d, so the channel order and the pad byte want an on-glass check on the CachyOS 5070 Ti. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c7081b2a82 |
fix(encode/nvenc-linux): stop leaking the SwsContext on open's early returns
`sws_getContext` hands back a raw pointer whose only `sws_freeContext` lives in
`Drop for NvencEncoder` — and `Drop` needs a CONSTRUCTED `Self`, which does not
exist on either of `open`'s early returns: the intra-refresh-unsupported path
(which recurses into `Self::open`) and the plain error return. Both sit between
the context's creation and the `Ok(NvencEncoder { … })` that would give it an
owner, so every failed open leaked one.
That is not a once-per-process wart. `open_nvenc_probed` walks an EINVAL bitrate
ladder — requested rate, then the codec-level cap, then ×3/4 down to 50 Mb/s —
re-entering `open` at each step, so a GPU that refuses the requested bitrate leaks
a context per step (up to ~10). The intra-refresh retry adds one more.
Fixed structurally rather than with a guard: the block depends only on values known
before the encoder open (`want_444`, `want_hdr10`, `cuda`, `format`, `nvenc_pixel`,
the dimensions, `full_range_444`), so moving it BELOW `video.open_with(opts)` — to
just above the struct construction, with nothing fallible in between — makes the
leak unrepresentable instead of merely handled. No behaviour change: same context,
same colourspace details, same field.
Found while designing the WP1.4 swscale expand, which would have promoted this from
a 4:4:4/HDR-only leak (the only sessions that build a context today) to one on every
packed-RGB session. Fixing it first is the prerequisite for that change.
Verified `-D warnings --all-targets` + tests on Linux (default; shipped
nvenc+vulkan-encode+pyrowave).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
6be174dc9c |
fix(encode/windows): the encode bit depth must follow the pixels, not the negotiation
All three Windows backends derived it the same wrong way —
`bit_depth >= 10 || matches!(format, P010 | Rgb10a2)` at ffmpeg_win.rs:154,
amf.rs:1275 and qsv.rs:777 — so the NEGOTIATED depth could force a 10-bit encoder
over an 8-bit capture.
That combination is not hypothetical. A client advertises 10-bit, the handshake
negotiates bit_depth=10, and then enabling advanced colour on the IDD virtual
display fails — at which point the capturer says exactly that and delivers 8-bit
NV12 anyway ("10-bit HDR was negotiated but enabling advanced color on the virtual
display FAILED — encoding 8-bit SDR"). Every vendor then lost the session, each in
its own way:
- native AMF and native QSV derived `expected = P010`, saw Nv12, and bail!'d at
open;
- the libavcodec path accepted the open, built a P010 encoder, and then failed
EVERY submit forever, because its per-frame check recomputes the depth from the
frame and never matches. reset() could not help: the rebuild re-derived the
same wrong depth from the same stored bit_depth. The host burned
MAX_ENCODER_RESETS and ended the session.
The last one is the worst of the three because it IS the fallback: native QSV
correctly refuses this input at open, and lib.rs then falls back to the ffmpeg path
"for robustness" — which accepted it and died per frame instead.
Since the duplication was the bug, the fix is one shared `ten_bit_input()` in
codec.rs that all three call, and it follows the delivered pixels. That also keeps
the stream HONEST rather than merely alive: the depth selects the colour signalling
(BT.2020 PQ vs BT.709) and the staging surface format, so an 8-bit capture now
produces an 8-bit stream that says it is SDR — which is what the capturer already
reported it was sending. It warns when the negotiated depth is discarded.
The negotiated depth stays an upper bound. The session LABEL may still claim HDR;
that mismatch lives in the negotiation, not in the encoder, and is not addressed
here.
`is_10bit_format` keeps its (now format-only) definition and is used by
submit_d3d11's per-frame check, so the predicate the encoder was built from and the
one it re-checks per frame cannot drift. That check can now only fire on a genuine
mid-stream depth change.
Verified `--all-targets -D warnings` on Windows (no features / pyrowave / qsv /
nvenc,qsv; 31 tests) and Linux (default; shipped nvenc+vulkan-encode+pyrowave).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
6c97c00add |
fix(encode/pyrowave): the RDO block-index cap must cover 4:2:0, not just 4:4:4
The vendored rate controller packs the 32x32 block index into the low 16 bits of `RDOperation::block_offset_saving` (pyrowave-sys patches/0002). Past u16::MAX the index collides with the `saving` field, the resolve over-credits, and the emitted payload can overshoot the buffer `pyrowave_encoder_packetize` writes into — whose only bounds check is an `assert` that the Release (NDEBUG) vendored build compiles out. There is no Rust-side guard behind it. All three callers of `pyrowave_mode_fits_rdo` hardcoded 4:4:4, leaving 4:2:0 unchecked — but 4:2:0 overflows too, just later: 8192x6144 is 73728 blocks and 8192x8192 is 98304, while `Codec::PyroWave.max_dimension()` permits 8192 per axis, so both are reachable from a client-requested `mode=WxHxFPS`. Worse, the host's only use of the helper (native/handshake.rs) is a 4:4:4 -> 4:2:0 downgrade, so an oversized mode was actively routed INTO the unguarded branch. The cap now lives in `validate_dimensions`, checked against 4:2:0 — the most permissive chroma, so a mode that cannot fit there fits no PyroWave session at any chroma. That is the single chokepoint both the negotiator (handshake.rs:180) and `open_video_backend` already run. Both encoder opens additionally check the chroma actually being opened: the 4:4:4 half, plus defence in depth for the `PUNKTFUNK_ENCODER=pyrowave` lab override. Not dormant: punktfunk-host has `default = ["pyrowave"]` and no build passes `--no-default-features`, so these backends ship in every host binary. Tests pin the arithmetic (8192x6144 = 73728, 8192x8192 = 98304, 7680x4320 still fits) and assert the cap does not leak to H.265/AV1, which have no such controller. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
16fa43da40 |
ci(encode): lint and test the feature-gated encode backends
pf-encode's GPU backends are off by default, so almost none of them were under
`-D warnings`:
- ci.yml lints/tests with default features, which do cover VAAPI, libav-NVENC
and — via punktfunk-host's `default = ["pyrowave"]` — the PyroWave backends,
but not `nvenc` or `vulkan-encode`.
- deb.yml builds those two, but with `cargo build`, where warnings are not errors.
That left enc/linux/nvenc_cuda.rs, enc/linux/vulkan_video.rs and the vendored
vk_av1_encode / vk_valve_rgb bindings — ~8,150 lines carrying ~70 `unsafe` blocks —
never linted anywhere, so the crate's own
`#![deny(clippy::undocumented_unsafe_blocks)]`, its stated unsafe-proof gate, was
never actually enforced on them.
Linux gains a clippy+test leg at the SHIPPED feature set: deb.yml builds
`punktfunk-host/nvenc,punktfunk-host/vulkan-encode` WITHOUT
`--no-default-features`, so the .deb carries pyrowave too and that combination is
what deserves the lint. GPU-free — the hardware tests are `#[ignore]`d and
NVENC/CUDA dlopen their entry points, so the test binary links with no driver.
Windows gains a separate `-p pf-encode --all-targets` lint. The existing lint is
`-p punktfunk-host`, which never builds pf-encode's test targets — the blind spot
that let the Linux twin's tests rot. It must be clippy rather than `cargo test`:
on MSVC nvidia-video-codec-sdk link-imports NvEncodeAPICreateInstance /
NvEncodeAPIGetMaxSupportedVersion, so a test binary cannot link without the
driver's import lib. clippy type-checks without linking; ci.yml runs the tests.
`--all-targets` is load-bearing, not decoration: without it the feature-gated
`#[cfg(test)]` modules are never compiled at all.
Also widens windows-host.yml's paths filter, which listed `crates/pf-encode/**`
but none of the crates it compiles against (pf-frame, pf-gpu, pf-zerocopy,
pf-host-config, pf-capture, ...), so a change reaching the Windows host through
one of those triggered no Windows build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
07348c0175 |
refactor(encode): drop the crate-wide allow(dead_code)
Inherited from the pre-extraction host crate root as scaffolding for backend
paths defined ahead of the build that used them. A census across every feature
combination on both platforms (flip it to `warn`, rebuild) found it was hiding
exactly two items — so it bought nothing while blinding the crate to future rot:
- `vaapi::fourcc` — superseded by `pf_frame::drm_fourcc`; no call sites left.
- `vulkan_video::open_opts` — test-only (the smoke tests use it to pass the
RGB-direct request explicitly rather than through the env), now `#[cfg(test)]`.
Removing it surfaced a real latent defect the Linux census could not see:
`amf.rs`'s `percentile` / `drive_and_measure` helpers are used only by the
`#[cfg(feature = "amf-qsv")]` latency A/B benchmark, so a `--features nvenc,qsv`
build compiled the helpers with their caller gated out. They now carry the same
gate as their caller.
Verified `--all-targets -D warnings` on Linux (no features; shipped
nvenc+vulkan-encode+pyrowave) and Windows (no features; pyrowave; qsv; nvenc,qsv).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
e30551b1e2 |
fix(encode/nvenc-linux): unrot the direct-NVENC test module
All ten `NvencCudaEncoder::open` call sites in the `#[cfg(test)]` module passed 9 arguments to a 10-parameter function: `cursor_blend` was added to `open` and the tests were never updated. The module has been uncompilable ever since — `cargo clippy -p pf-encode --features nvenc --all-targets` fails with 10x E0061. Nothing in CI noticed because no job compiles this crate's feature-gated test targets: ci.yml lints/tests with default features (which do not include `nvenc`), and windows-host.yml lints `-p punktfunk-host`, which builds pf-encode as a dependency and so never builds its test targets. The CI commit below closes that gap; this has to land first or that leg starts red. `false` is what these tests exercised before `cursor_blend` existed: every frame they build sets `cursor: None`, and there is no cursor-blend test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8d5a9f66c9 |
fix(gamescope): ship a packaged privilege path for the DM-stop takeover
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 55s
android / android (push) Successful in 12m5s
decky / build-publish (push) Successful in 25s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 46s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
ci / bench (push) Successful in 6m29s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m5s
docker / deploy-docs (push) Successful in 27s
deb / build-publish (push) Successful in 8m40s
arch / build-publish (push) Successful in 14m51s
deb / build-publish-host (push) Successful in 9m15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 15m32s
apple / swift (push) Successful in 23m28s
ci / rust (push) Successful in 26m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m37s
apple / screenshots (push) Successful in 2h32m51s
Field report (Nobara, 0.19.2): entering Game Mode mid-stream left the monitor on and the stream mirroring at the desktop's resolution. Root cause: the DM-flavor takeover needs privilege to stop plasmalogin, the polkit rule is a docs-only manual step nobody installs, so every managed entry silently degraded to ATTACH — and with a physical display connected the attach path mirrors the box's own session at its own mode. Ship the privilege with the packages instead: a root helper (libexec/punktfunk/pf-dm-helper, verbs stop|restore) behind its own polkit action (io.unom.punktfunk.dm-helper, allow_any — the same mechanism Nobara's os-session-select uses, and the helper derives the DM unit from the display-manager.service symlink itself so callers never name a unit across the privilege boundary). The host tries the plain system-bus verbs first (root / operator rule still take precedence), then pkexec's the helper; the restore paths (idle restore + in-stream desktop-switch honor) use the same ladder so a takeover that needed the helper can always be undone by it. Packaged in rpm/deb/arch (Arch under /usr/lib/punktfunk with the policy's exec.path annotation rewritten; the host probes both layouts). Nix is left out deliberately: store paths can't match the probe, NixOS keeps the manual rule. Docs updated — the rule snippet stays as the scoped alternative. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5c7a9407ff |
fix(windows/web): start the console once its inputs exist, and verify it started
arch / build-publish (push) Successful in 17m1s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 58s
apple / swift (push) Successful in 1m19s
decky / build-publish (push) Successful in 24s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 35s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 47s
ci / bench (push) Successful in 6m19s
deb / build-publish (push) Successful in 9m35s
docker / deploy-docs (push) Successful in 29s
apple / screenshots (push) Successful in 10m56s
deb / build-publish-host (push) Successful in 13m40s
android / android (push) Successful in 16m19s
windows-host / package (push) Successful in 17m13s
ci / rust (push) Successful in 19m39s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m18s
A fresh install left PunktfunkWeb registered but not running: `web setup` waited only for the mgmt token before firing `schtasks /run`, while `web-run.cmd` also requires the host identity cert — and the host writes the token during argument parsing but `cert.pem` only after the pure-Rust RSA-2048 keygen inside `serve`. The launcher lost that race, exited 1, and since the task carried no trigger but boot (Task Scheduler does not reliably restart on a non-zero exit code) the console stayed down until the next reboot, with the installer still reporting "web console set up + started". - `web setup` gates on cert.pem (written last) as well as the token, 90 s budget. - After `schtasks /run`, poll for the :47992 listener and retry before giving up; warn honestly instead of claiming a start that did not happen. - `web-run.cmd` (installed + dev) waits in-process for the token + cert (~5 min) rather than exiting 1 and hoping restart-on-failure retries. - Register the task with a logon trigger alongside boot, falling back to the boot-only XML if a Task Scheduler build rejects it. - Linux had the same defect: punktfunk-web.service's Restart=on-failure gave up permanently after systemd's default 5-starts-in-10 s limit. StartLimitIntervalSec=0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7781d09e26 |
feat(linux): log if unsupported / too low gamescope version discovered
apple / swift (push) Successful in 2m29s
android / android (push) Successful in 12m52s
arch / build-publish (push) Successful in 12m59s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m6s
ci / bench (push) Successful in 5m30s
ci / rust (push) Successful in 19m51s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 21s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
deb / build-publish (push) Successful in 8m43s
apple / screenshots (push) Successful in 20m59s
deb / build-publish-host (push) Successful in 9m25s
docker / deploy-docs (push) Successful in 24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m22s
|
||
|
|
f4fe4d0792 |
chore(release): bump workspace version to 0.19.2
android-screenshots / screenshots (push) Successful in 2m59s
windows-host / package (push) Successful in 9m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m48s
android / android (push) Successful in 12m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m20s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m5s
arch / build-publish (push) Successful in 12m2s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m45s
audit / cargo-audit (push) Successful in 2m10s
audit / bun-audit (push) Successful in 14s
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m6s
apple / swift (push) Successful in 1m57s
ci / bench (push) Successful in 5m41s
ci / rust (push) Successful in 18m52s
decky / build-publish (push) Successful in 20s
deb / build-publish (push) Successful in 8m50s
deb / build-publish-host (push) Successful in 9m24s
web-screenshots / screenshots (push) Successful in 3m7s
flatpak / build-publish (push) Successful in 6m27s
docker / deploy-docs (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
release / apple (push) Successful in 1h4m50s
apple / screenshots (push) Successful in 23m21s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m40s
linux-client-screenshots / screenshots (push) Successful in 7m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m0s
An all-fixes patch on top of 0.19.1, all host-side: - KDE Plasma display takeover moved off kscreen-doctor to in-process kde_output_management_v2 ( |
||
|
|
087e7d0405 |
fix(inject/windows): map absolute input over the streamed output's rect, not the whole desktop
audit / cargo-audit (push) Successful in 2m13s
audit / bun-audit (push) Successful in 15s
arch / build-publish (push) Successful in 12m46s
windows-host / package (push) Successful in 12m53s
android / android (push) Successful in 13m48s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m9s
apple / swift (push) Successful in 15m13s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m9s
ci / bench (push) Successful in 5m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m10s
ci / rust (push) Successful in 19m38s
decky / build-publish (push) Successful in 27s
deb / build-publish (push) Successful in 8m37s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m27s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 44s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 1m36s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 1m39s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 1m15s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m5s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4m54s
docker / deploy-docs (push) Skipped
deb / build-publish-host (push) Successful in 10m7s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m26s
flatpak / build-publish (push) Successful in 6m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m42s
release / apple (push) Successful in 56m19s
apple / screenshots (push) Successful in 23m11s
Pen, touch, and absolute mouse arrive normalized to the STREAMED display's frame, but pointer_windows::to_screen and sendinput's MouseMoveAbs mapped them over the whole virtual desktop — correct only when the virtual display is the sole active display (Exclusive topology). In Extend — a physical monitor kept on beside the virtual output, or an Exclusive isolate degraded to the 0x57 keep-physicals fallback — the streamed output sits at a non-zero origin, so every sample landed shifted and mis-scaled. The pen exposed it first (field report 2026-07-24): a stylus is strictly absolute, with no closed-loop correction onto the target like a cursor. New pf-inject::stream_target (Windows): the host publishes the streamed output's CCD target id at capture bring-up (one central site — capture_virtual_output covers the native and GameStream planes); mapping sites resolve its current desktop rect through pf-win-display's source_desktop_rect — the same resolver the cursor-readback poller uses, so inject and readback always agree — TTL-cached (250 ms) because a group-layout re-arrange moves a live output's origin mid-session. No target set / never resolved falls back to the whole virtual desktop (the historical mapping, still right for Exclusive topology and devtest). One change in to_screen covers pen + touch; MouseMoveAbs converts the same desktop pixel into the 0..65535 MOUSEEVENTF_VIRTUALDESK coordinate, closing the identical latent absolute-mouse bug (apollo-comparison open item #14/#30). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
64511af4d3 |
fix(kwin): install >60Hz custom modes in-process too — KWin path is now kscreen-free
audit / bun-audit (push) Successful in 35s
audit / cargo-audit (push) Successful in 2m34s
decky / build-publish (push) Successful in 17s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 47s
deb / build-publish-host (push) Successful in 9m56s
deb / build-publish (push) Successful in 11m17s
android / android (push) Successful in 12m31s
docker / deploy-docs (push) Successful in 29s
arch / build-publish (push) Successful in 15m14s
apple / swift (push) Successful in 1m21s
apple / screenshots (push) Successful in 4m50s
release / apple (push) Successful in 12m45s
flatpak / build-publish (push) Successful in 7m14s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m58s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m18s
windows-host / package (push) Successful in 16m32s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m19s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m14s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m28s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m25s
ci / rust (push) Successful in 17m55s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 59s
ci / bench (push) Successful in 5m45s
Follow-up to the previous commit: the only KWin topology op still shelling out to kscreen-doctor was the >60 Hz custom-mode INSTALL (`set_custom_refresh`). Port it to `kde_output_management_v2` so the whole KWin path is kscreen-free on modern KWin. `kwin_output_mgmt::set_custom_mode` builds a one-entry `kde_mode_list_v2` (set_resolution / set_refresh_rate / set_reduced_blanking=full / add_mode), applies it via `kde_output_configuration_v2.set_custom_modes` (since 18), waits for KWin to generate the mode (its CVT generator may align the width down — matched with the same height-exact / width-within-8 / refresh-within-1Hz gate as before), then selects it — which changes the output size and drives the sacrificial-birth stream renegotiation. `kwin::create`'s want_high branch tries this first and only resolves a kscreen id + calls `set_custom_refresh` if it returns None (pre-6.6 KWin without set_custom_modes, or the compositor not answering) — so a >60 Hz session on a box where kscreen-doctor wedges no longer eats a 5 s resolve timeout either. Bonus: `set_custom_modes` REPLACES the custom list, so reconnects are idempotent — no more one-custom-mode-per-connect growth of the user's display list. Verified: cargo test -p pf-vdisplay (73 pass), clippy -D warnings clean, fmt clean. The set_custom_modes round-trip is proven live on KWin 6.6.4: apply=applied, and a 1648x928@75 request generates 1648x928@74.901 (CVT fractional refresh, caught by the 1 Hz tolerance). The mode SELECT + full >60 Hz stream is still owed on-glass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
86d5d66660 |
fix(kwin): drive display topology over kde_output_management_v2, not kscreen-doctor
A KDE host on Nobara stopped disabling its physical screens and creating the virtual output the moment it updated: streaming still came up, but bring-up took ~26 s and the streamed output never became the desktop (`also_disabled=[]`). Root cause is not our topology logic — it's that every `kscreen-doctor` call on the reporter's session wedges. kscreen-doctor drives libkscreen, which (per setup) waits on the kscreen KDED module over D-Bus; when that layer is stuck it blocks in its own connect and never returns, so all five topology queries hit their 5 s budget and got killed (host log). Reproduced live on a Nobara / KWin 6.6.4 box: `kscreen-doctor -j` there times out at 8 s, every time. But the compositor's OWN Wayland is fully responsive on that same session — the host just created a virtual output over it via zkde_screencast. So drive the topology (resolve our output, take primary, disable the physical/bootstrap outputs, capture their modes, re-enable on teardown, position) directly over `kde_output_management_v2` + `kde_output_device_v2` instead of shelling out. On that same Nobara box the in-process path binds management (v19) and enumerates the outputs in 2.4 ms — ~3400x faster than the 8 s hang, and immune to whatever wedges the standalone tool. - vendor kde-output-management-v2 / kde-output-device-v2 (KWin advertises mgmt v19, device v20); generate client bindings inline (the interdependent-protocol module split from the wayland-protocols crate; needs the `bitflags` dep for the device protocol's bitfield enums). - new `kwin_output_mgmt`: bounded enumerate-then-apply over one Wayland connection; every wait is time-bounded so a genuinely wedged compositor degrades to `handled = false` and the old kscreen-doctor path still runs. - `kwin::create` topology + `apply_position` prefer the in-process path (address our output by its stable device UUID, supersede-robust) with kscreen-doctor as the fallback. The 60 Hz path now makes ZERO kscreen-doctor calls; only the >60 Hz custom-mode install still shells out (its in-process port is a follow-up). Verified on Linux: cargo test -p pf-vdisplay (73 pass), clippy --all-targets -D warnings clean, fmt clean, punktfunk-host checks clean. In-process enumeration proven live against KWin 6.6.4 (the reporter's env); the disable/apply path is not yet on-glass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
a3e21d9227 |
feat(vdisplay/gamescope): honor in-stream 'Switch to Desktop' under the managed DM takeover
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 1m7s
apple / swift (push) Successful in 1m21s
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
ci / bench (push) Successful in 6m2s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m32s
apple / screenshots (push) Successful in 6m21s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m27s
deb / build-publish-host (push) Successful in 9m39s
docker / deploy-docs (push) Successful in 29s
deb / build-publish (push) Successful in 13m6s
android / android (push) Successful in 17m28s
arch / build-publish (push) Successful in 18m8s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m9s
windows-host / package (push) Successful in 24m2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m12s
ci / rust (push) Successful in 26m49s
First on-glass test of the DM-flavor takeover confirmed the black-screen fix, but exposed that the managed model could not switch back: Steam's in-stream session-select is a silent no-op while the display manager is stopped (every config-write branch in os-session-select requires the DM to be running), so the capture loss it causes just relaunched game mode. The user pass of the switch script does leave one durable trace — the ~/.config/steamos-session-select sentinel, written before any DM check. The managed launch now baselines its mtime; a capture loss with the sentinel advanced reads as the switch request and is honored by replaying the switch with the DM up: restore the display manager, run the distro's own os-session-select (its internal pkexec is authorized allow_any by the distro policy, so it works from the host's sessionless context), then stop the autologin game-mode unit so Relogin lands in the newly selected desktop. The capture-loss re-detection then follows KWin as it comes up; a 120 s post-honor grace stops the rebuild loop from racing the booting desktop back into game mode (superseded early if the box's own game-mode unit reappears — the desktop→game leg stays fast). The baseline is re-recorded on crash-restore so a pre-existing sentinel never reads as a fresh request. Every verb in the sequence was live-validated on the Nobara repro VM. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e35dad529b |
fix(vdisplay/gamescope): DM-flavor-aware session takeover — stop masking Nobara's plasmalogin to death
Field report (Nobara 43 HTPC): switching the host to Steam Game Mode mid-stream permanently black-screened the box. Live-proven root cause on a Nobara repro VM: our takeover masks the box's gamescope-session-plus unit, plasmalogin's Relogin=true then fails its session Exec repeatedly and trips systemd's start limit within ~1 s — the display manager dies, and our restore verb (unmask + user start) cannot bring a seatless gamescope back. Only 'reset-failed + restart' of the DM recovers. The takeover is now display-manager-flavor-aware: - SDDM (Bazzite/SteamOS) keeps the proven mask+SIGKILL path unchanged. - plasmalogin/unknown DMs never mask: with privilege (root or an operator polkit rule scoped to the DM unit — documented) the host stops the DM for the stream and restores it with reset-failed + restart (PUNKTFUNK_RECOVER_SESSION_CMD as fallback), recorded in the persisted takeover state so a host crash still restores; without privilege the managed takeover degrades to ATTACH and mirrors the box's live Game Mode instead of destabilizing the seat. Both legs of the privileged cycle live-verified on the repro VM (headless managed session works with zero login sessions; render nodes are 0666). A loaded-but-inactive leftover instance never triggers the DM stop. Companion fixes from the same triage: - ensure_box_gamescope_mode gains the attach-only rebuild-probe guard both managed paths already had (stale post-capture-loss detection restarted the box's unit), and no longer re-modes a box that drives a physical display — attach mirrors on-glass; re-mode is the headless-box model. - Capture-loss rebuilds targeting gamescope get a 100 s budget: the 40 s budget expired inside the first 45 s Steam-cold-start launch attempt, a guaranteed single-shot failure. - A PUNKTFUNK_COMPOSITOR pin now WARNs once per capture loss when the live session no longer matches it (the pin disables session-following — the reporter's original stream-death trigger). - A managed session that took nothing over (client gamescope pin beside a live desktop) is stopped on disconnect instead of being orphaned forever. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |