198b1d5e800a3c62e0e4f676cacdd8446c8b6124
1121
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0c4e582b29 |
fix(gamescope): a managed session now proves our flags reached it
The bare spawn builds gamescope's argv itself. The two managed modes hand the flags over indirectly — `gamescope-session-plus` through `GAMESCOPE_BIN` + `PF_HDR_ARGS`, SteamOS through a PATH shim — and a session free to ignore either would exec the distro's gamescope carrying none of them. Half of that failure was already loud: HDR dies on the bit depth the Welcome fixed before the display existed. The other half was silent. The host had been told the compositor would paint the pointer, so it stopped painting one, and nobody did — a stream that is correct in every respect except that it has no cursor, which nothing in the logs would have said. So both managed paths now read the running compositor's `/proc/<pid>/cmdline` once its node appears, and refuse the session when a flag we passed is missing. The plan is fixed by then (`cursor_blend` feeds the encoder open, which precedes the display), so this session cannot be corrected in place; instead the capability is latched off for the process and the spawn fails, and the retry resolves a correct SDR host-composited session. One rejected attempt per boot, then it converges. Fails OPEN at every ambiguity: no flags expected, or no readable gamescope in `/proc`, says nothing. And it accepts ANY running gamescope carrying the flags, because a box commonly has a second one — the Nobara test box runs its own game-mode `/usr/bin/gamescope --steam` beside ours. That leaves only false PASSES possible, and the flag that matters cannot produce one: `--pipewire-composite-cursor` exists nowhere but our patch set. Two things fell out of writing it. `gamescope_argvs` matches argv[0]'s basename with `ends_with`, not `==` — the old equality test would have missed our own `punktfunk-gamescope`, which is what `current_gamescope_output_size` was already scanning for. And `hdr-probe` gained a line for who paints the cursor, the one capability with no symptom of its own until you compare two streams. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ee716d0137 |
fix(encode): pf-encode did not build on Linux without vulkan-encode
`vulkan_encode_available_at` and `vulkan_encode_caps` were gated on bare `target_os = "linux"`, but the second returns `vulkan_video::VulkanEncodeCaps` and both reference that module — which only exists under the feature. Every CALL SITE was already correctly gated, so nothing pointed at them; the two definitions alone were enough to fail the build. That is CI's default-feature line (`cargo clippy --workspace --all-targets`), so this was going to be caught — it was caught on a Fedora 44 box first. `cursor_blend_capable`'s `ten_bit` goes unused in the featureless arm for the same reason, which `-D warnings` also rejects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3d3ecf1e82 |
test(encode/nvenc): the NVIDIA HDR leg, verified on an RTX 5070 Ti
The NVIDIA half of "zero-copy, no host CSC, 10-bit HDR" had never met a GPU. Two `#[ignore]`d smokes now drive it, run on `.41` (Bazzite f43, RTX 5070 Ti, driver 595.58.03): * `nvenc_cuda_hdr10_packed_rgb` — a packed 2:10:10:10 CUDA payload straight into NVENC as `ARGB10`, HEVC **and** AV1. Asserts what would catch a mislabelled stream: the encoder DERIVED 10-bit and HDR from the input format rather than being told, and picked `ARGB10` for `X2Rgb10`. That derivation is what selects Main10 / AV1-at-10 and the BT.2020 PQ signalling. * `nvenc_cuda_hdr10_cursor_blend` — `cursor_blend.comp` MODE 3/4, the 10-bit channel-unpacking twin. Asserts the blend targets `SlotFormat::X2Rgb10` and not the 8-bit layout, which would tint the pointer and shift its channels. Both green, and the dumped bitstreams decode 0-error reporting `Main 10` / AV1 `Main`, `yuv420p10le`, `bt2020nc` / `smpte2084` / `bt2020`. There is no host colour conversion anywhere on this path: the frame arrives as a LINEAR dmabuf, crosses to CUDA through the Vulkan bridge, and NVENC's ASIC does the BT.2020 conversion following the VUI the session configured. The "no CSC" property AMD gets from the EFC, NVIDIA gets from the encoder itself. The whole pre-existing `nvenc_` suite was re-run alongside them — 16/16, no regression from the depth-derivation and buffer-format changes underneath it. Capture half checked too: the patched gamescope binary built on the AMD box runs unmodified on the NVIDIA one (same Fedora 43 base) and its node offers the same four formats with the colorimetry props — so headless gamescope + 10-bit PQ is not AMD-specific. |
||
|
|
576bf7e294 |
test(encode/vulkan): 10-bit smokes — and they pass on a real AMD GPU
The Vulkan Video 10-bit path was the least-exercised code on this branch:
nothing had ever created a Main10 video session, so the profile query, the
`G10X6…3PACK16` picture allocation, the scratch→plane copy, the hand-packed
AV1 sequence header and the colour signalling were all first-run-on-glass.
Three `#[ignore]`d smokes now drive them, and they were run.
`.116` (Bazzite f43, AMD 780M, RADV / Mesa 26.0.4), all three green:
* `vulkan_smoke_10bit` — HEVC Main10 through the compute CSC;
* `vulkan_smoke_10bit_av1` — AV1 at 10 bits;
* `vulkan_smoke_rgb_10bit` — HDR with NO host CSC, the EFC converting BT.2020
off the packed 10-bit source. It did **not** soft-skip, which answers the one
capability in this whole feature I had only ever read in a registry: RADV's
VCN EFC really does advertise `MODEL_YCBCR_2020` and accept a 10-bit
packed-RGB encode source.
Every stream decodes 0-error and reports `yuv420p10le` + `bt2020nc` /
`smpte2084` / `bt2020`. The AV1 one parsing at all is the load-bearing result
there: `high_bitdepth` precedes the CICP bytes in `color_config()`, so a wrong
bit would have thrown every later field out of phase rather than merely
mislabelling the depth.
Round-trip on solid frames, fed (160,160,800) as 10-bit codes:
compute CSC -> (159, 158, 796)
EFC -> (159, 158, 796)
AV1 -> (158, 159, 794)
Under 0.5%, all of it limited-range quantisation and lossy encode. The compute
and EFC results being IDENTICAL is the strongest check available: two
independent BT.2020 NCL implementations — my shader and AMD's fixed-function
block — agreeing to within rounding.
The smokes deliberately assert structure (submits encode, AU count, the depth
the encoder settled on), not colour: a shader writing the 10 bits into the
wrong end of the word still produces a decodable stream. Colour is the dump +
ffmpeg round-trip above, which is what actually caught nothing this time.
Also corrects a comment: I claimed a wrong store factor was a "~1.6% luminance
error". It is not. The `<< 6` PLACEMENT is the load-bearing part (dropping it
is 64x too dark); `1/1023` vs `64/65535` is ~0.1% and harmless.
|
||
|
|
fd648aa776 |
feat(gamescope): put the cursor in the node, so a session can be zero-copy
gamescope keeps the pointer out of its PipeWire node — it lives on a hardware plane for scanout, and `paint_pipewire()` composites a separate, reduced frame that never includes it. So punktfunk has always reconstructed it from XFixes and blended it in host-side. That blend is what has been blocking the zero-CSC encode path, and the cost is larger than it sounds: `VulkanVideoEncoder::open` refuses the RGB-direct (EFC) source for any session with `cursor_blend`, because that front end is fixed-function and has no blend stage. `cursor_blend_for` sets it unconditionally for gamescope. Net effect: a gamescope session paid a full-frame colour-conversion pass per frame, forever, for a pointer. So put the cursor where it belongs. A second carried gamescope patch adds `--pipewire-composite-cursor` (off by default — the node has never carried it, and a consumer that draws its own would get two), painting it with the same `MouseCursor::paint` call the scanout composite uses. It scales for free: paint_pipewire has already set `currentOutputWidth/Height` to the capture size, which is what that function scales against. The repaint test grows the cursor's state beside the commit ids — a pointer-only move produces no commit, so without it the composited cursor would freeze on a static screen while the real one moved, and a cursor that became hidden would never be erased. Host side, the `+pfhdr` marker becomes a monotonic PATCH LEVEL, so one probe answers every capability the session must know before it is planned (level 1 = HDR formats, level 2 = the cursor flag). `cursor_blend_for` and the `gamescope_cursor` resolver both consult it through one helper, because they have to agree: the reader without the blend is a wasted X11 connection, the blend without the reader is a stream with no pointer, and both together with a gamescope that paints its own would draw two. ⚠ The two indirect spawn modes carry the flag through `PF_HDR_ARGS`, so this shares a dependency with the HDR flags: a session that ignores `GAMESCOPE_BIN`/`PATH` and execs the distro's gamescope gets neither. HDR fails loudly there (negotiation timeout + SDR latch); a missing cursor would be silent. Noted in packaging/gamescope/README.md as worth a post-spawn `/proc/<pid>/cmdline` check if it ever bites. |
||
|
|
2a13bc5252 |
fix(gamestream): advertise AV1 Main10, and honour HDR per negotiated codec
`ServerCodecModeSupport` layered `SCM_HEVC_MAIN10` and never `SCM_AV1_MAIN10`, on the stated theory that "the GameStream AV1 path is left off until live-confirmed". But the SDR baseline has always offered AV1 **Main8** to every client, so that path is either live or it is not — the DEPTH was never the uncertain part, and the omission only cost AV1-preferring clients their HDR. Now that the encoders probe 10-bit per codec, each bit is gated on that codec's own `can_encode_10bit` AND the SDR baseline already advertising it. A box that does HEVC Main10 but not 10-bit AV1 — or the reverse — advertises the truth instead of one bit standing in for both. Two gates follow from that: * `host_hdr_capable` becomes codec-agnostic (ANY 10-bit-capable codec makes the host HDR-capable). It was asking about HEVC alone, which would have hidden HDR entirely on a hypothetical AV1-only-10-bit box; * the RTSP honor gains the per-session half: a client that negotiated the codec this host CANNOT do 10-bit with degrades to 8-bit SDR there, rather than being handed a PQ label over an 8-bit stream. H.264 always lands there — there is no 10-bit H.264 encode anywhere. The unit test now pins each bit independently, including the two one-without- the-other cases a single shared flag got wrong in both directions. |
||
|
|
cb4690f216 |
feat(encode/vulkan): probe the device instead of guessing — AV1 10-bit + zero-CSC HDR
Three fixes to the same mistake: deciding what the Vulkan Video backend can do
from a table in our heads rather than from the driver, and routing everything
that didn't fit to libav VAAPI — where a session loses real RFI recovery and
the cursor blend for no reason the hardware asked for.
**Capability probe, per codec AND depth.** `probe_encode_support`'s "is there
an encode queue" boolean becomes `VulkanEncodeCaps { supported, eight_bit,
ten_bit }`, answered by `vkGetPhysicalDeviceVideoCapabilitiesKHR` against the
very profile chain the session open builds. So the dispatcher's prediction
cannot disagree with reality: a capable device keeps the Vulkan path, an
incapable one routes to VAAPI BEFORE burning a failed open, and the
cursor-blend mirror stays honest for free. This is the shape the direct-SDK
NVENC path already uses for its codec GUIDs.
**AV1 10-bit.** It was excluded on a guess about driver coverage; now the
device answers. `color_config()` carries `high_bitdepth` + the BT.2020/PQ CICP
triplet in both the `StdVideoAV1ColorConfig` and the sequence-header OBU we
bit-pack ourselves — they must stay identical or the driver's frame OBUs parse
against a header we didn't write. `high_bitdepth` sits BEFORE the CICP bytes,
so getting it wrong doesn't just mislabel the depth, it puts every following
field one bit out of phase; the new test reads the packed bits back.
**Zero-CSC RGB-direct in HDR.** The EFC probe assumed BT.709 and BGRA. It now
asks for the model this session's colourimetry needs (`MODEL_YCBCR_2020` for
10-bit — the extension has always had it) and for the CAPTURED format as an
encode-source format, and the session create-info selects the matching model.
An HDR session with no pointer to composite therefore hands the captured
buffer straight to the fixed-function front end and runs no host CSC at all.
Sessions that DO composite a pointer keep the compute CSC, unchanged: the EFC
cannot blend, and that rule outranks everything.
Also: `can_encode_10bit` on AMD/Intel now reports the union of VAAPI's and
Vulkan Video's answers instead of VAAPI's alone. `open_amd_intel` tries Vulkan
first and falls back, so either one being able to encode Main10 makes the
session 10-bit-capable — answering `false` because only one of them said yes
stranded encodable HDR sessions at 8 bits.
|
||
|
|
479f0965ee |
feat(encode/vulkan): Vulkan Video encodes 10-bit, so AMD/Intel HDR keeps the good path
The Vulkan Video backend was 8-bit for no structural reason — the API has `VK_VIDEO_COMPONENT_BIT_DEPTH_10_BIT` and `PROFILE_IDC_MAIN_10` in the very fields this pinned to 8 and MAIN, and AMD VCN and Intel both encode Main10. It was six hardcoded sites, and the cost of leaving them was paid twice over: an HDR session had to take libav VAAPI, losing real RFI loss recovery AND the compute CSC's cursor blend — which on gamescope is the only way the pointer reaches the stream at all, since gamescope has no embedded-cursor mode. An HDR session now opens a Main10 profile with 10-bit component depths, a `G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16` picture + DPB, and an SPS carrying `bit_depth_*_minus8 = 2` with the BT.2020/PQ CICP triplet instead of BT.709. `rgb2yuv10.comp` is the CSC's twin, and the two interesting parts of it are: * it is a PURE 3x3 matrix. The samples arrive already PQ-encoded (gamescope composites into the PQ container), so BT.2020 NCL applies to the code values as they are — there is no transfer function to apply here and applying one would be wrong; * the scratch planes are `R16`/`RG16`, not the picture's plane formats. The 10-bit ycbcr plane formats are not storage-image formats, so the shader writes the value into the HIGH bits by hand (`code10 << 6`, hence the `64/65535` factor and not `1/1023`) into planes that are merely SIZE-compatible with the picture's — which is all `vkCmdCopyImage` requires. Scope and safety: * HEVC only. AV1 10-bit encode has far thinner driver coverage, and a session open is not the place to gamble on it — those stay on VAAPI, as does a device that fails the Main10 profile query inside the open (the pre-existing "failed Vulkan open falls back to VAAPI" net, no new probe needed). * HDR pins the compute-CSC arm over the EFC RGB-direct one, which the EFC could not serve anyway: its fixed-function conversion is 8-bit BT.709 narrow with no knob for BT.2020. * `open_inner` binds `hdr` to the parameter-set HEADER bytes, so the depth flag is `ten_bit` there — the one name collision this change had to route around. |
||
|
|
17f824c3e9 |
feat(encode/nvenc): an HDR capture stays zero-copy on NVIDIA
AMD/Intel needed no new encoder code for HDR — the VAAPI path already ingests an XR30 dmabuf into `format=p010:out_color_matrix=bt2020`. NVIDIA did: the 10-bit formats were excluded from the GPU import outright, so an HDR session fell back to a CPU readback plus swscale, which is the one thing the capture path is not allowed to ship. It turns out no CSC kernel is needed. NVENC ingests packed 10-bit RGB natively as `ARGB10`/`ABGR10` and does the conversion itself following the configured VUI matrix — which `apply_low_latency_config` already sets to BT.2020 NCL for an HDR session. So the frame travels LINEAR dmabuf → Vulkan bridge → CUDA → NVENC unconverted: no host CSC pass, no depth loss, no extra work on a contended SM. * invariant 1 is restated rather than dropped: HDR must never take the TILED EGL de-tile blit (it renders into an 8-bit `GL_RGBA8` texture). The HDR pods are LINEAR-only by construction, so the plan may build the importer; the per-frame gate — which sees the negotiated modifier the plan cannot — is what enforces the tiled half, and falls back to the CPU path if a producer ever ignores our offer. * …but only where the encoder can actually take the payload (`linux_hdr_cuda_ok`). libav's HDR route builds a P010 hardware frames context and swscales into it, so on a host without the direct-SDK backend a packed-2:10:10:10 CUDA buffer would land in a P010 surface as garbage. Those keep the CPU path. * `nvenc_cuda` stops pinning 8-bit/SDR. Depth and HDR now follow the INPUT format, like the Windows backend: a 10-bit session whose capture came back 8-bit encodes AND labels 8-bit rather than mislabelling. * the cursor-blend compute shader gains two 10-bit modes, so the pointer gamescope leaves out of its node survives the HDR path. Same display-referred blend the CPU path's `composite_cursor_rgb10` already does — the samples are PQ, and a real sRGB→PQ cursor LUT is polish, not correctness for a pointer. |
||
|
|
689297c86e |
feat(hdr): a gamescope session streams true HDR10, decided before the Welcome
The Linux native plane has been 8-bit for a reason that stopped being true: `capturer_supports_hdr()` returned a flat `false` because Mutter's virtual monitors are SDR-only upstream. That is still right for Mutter, KWin and wlroots — and wrong for gamescope, whose node can now offer 10-bit BT.2020 PQ (packaging/gamescope). Open the three gates that held it off, together: * the capture-side gate becomes SOURCE-AWARE (`capturer_supports_hdr_for`): Windows keeps its platform answer, gamescope asks whether the resolved binary offers the formats, everything else stays false. It must be truthful rather than optimistic — the Welcome is irrevocable, and PQ frames on an 8-bit encoder are a deliberate hard error — so every term is a static fact resolved before anything is spawned, including "do we spawn gamescope at all" (an attach inherits someone else's flags and can promise nothing); * `want_hdr` reaches the capturer: `open_virtual_output` grew the parameter it never had, and the host arm stopped dropping `want.hdr` on the floor; * the spawn gains `--hdr-enabled --hdr-debug-force-support` on all three sub-modes (bare args, the `GAMESCOPE_BIN` wrapper, the SteamOS PATH shim), from one shared `hdr_args` — the force flag is what makes it work headless, and forgetting it looks exactly like a negotiation failure. Two things that would have gone wrong quietly: * the keep-alive reuse key gains `hdr`. gamescope cannot turn HDR on live, so a kept SDR display handed to an HDR session would give the game no HDR surfaces while the stream negotiated PQ over an SDR composite — wrong, and not obviously broken. Same for the managed session-plus/SteamOS reuse check. * the HDR-negotiation-failure latch becomes PER SOURCE. It was one process-wide flag, so a monitor that left HDR mode would have disabled gamescope HDR until the host restarted, and vice versa — two facts with nothing in common but the word HDR. GameStream follows the same shape: `host_hdr_capable` gains the gamescope arm, and the RTSP gate's live BT.2100 monitor probe is scoped to the portal source — a headless box has no monitor to be in HDR mode, so running it there would hard-refuse every gamescope HDR session. Off by default for one release (`PUNKTFUNK_GAMESCOPE_HDR=1`), and `punktfunk-host hdr-probe` now answers for both Linux HDR sources. |
||
|
|
28c50d1c5b |
fix(encode): every backend signals its colour, so no decoder has to guess
audit / cargo-audit (push) Successful in 2m38s
audit / bun-audit (push) Successful in 13s
ci / rust (push) Failing after 12s
ci / web (push) Successful in 1m4s
ci / docs-site (push) Successful in 1m8s
ci / bench (push) Successful in 6m59s
ci / rust-arm64 (push) Successful in 10m2s
android / android (push) Successful in 13m6s
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 10s
arch / build-publish (push) Failing after 14m19s
deb / build-publish (push) Successful in 11m13s
deb / build-publish-host (push) Failing after 4m36s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 44s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m6s
docker / build-push-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 35s
windows-host / package (push) Failing after 7m59s
windows-host / winget-source (push) Skipped
deb / build-publish-client-arm64 (push) Successful in 7m11s
apple / swift (push) Successful in 5m17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m21s
flatpak / build-publish (push) Successful in 6m43s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m30s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m57s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m23s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m6s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m14s
apple / screenshots (push) Successful in 22m57s
Three encode paths shipped a bitstream with no colour description at all, leaving primaries/transfer/matrix/range "unspecified": - Vulkan Video HEVC (`vk_build.rs`) built an SPS with no VUI whatsoever. This is the DEFAULT backend for AMD/Intel Linux hosts on HEVC/AV1. - Vulkan Video AV1 packed `color_description_present_flag = 0`. - The openh264 software path wrote nothing (it converts BT.709 limited and relied on decoders defaulting to that). - The libav-NVENC Linux path excluded packed-RGB 4:2:0, on the belief that "NVENC's internal CSC writes its own VUI". It doesn't: libavcodec derives `colourDescriptionPresentFlag` from the AVCodecContext colour fields, so leaving them unspecified emits none. Reachable on a CPU/dmabuf capture, a build without `--features nvenc`, or PUNKTFUNK_NVENC_DIRECT=0. Unsignalled looks fine on every punktfunk client — `csc_rows` falls back to BT.709 on "unspecified" — which is why this survived. Vendor TV decoders do not: they guess colorimetry from RESOLUTION, and an LG webOS panel reads a 4K SDR stream as BT.2020 and renders it visibly washed out. All four now signal BT.709 limited, which is what every host CSC actually produces (`rgb2yuv.comp`, `convert_bt709`, the swscale paths) and what the Welcome's `ColorInfo::SDR_BT709` already advertises out-of-band. NVENC, VAAPI, QSV, AMF and the Windows libav path were already correct. Two tests, both parsing the REAL emitted bitstream rather than re-asserting the constants: an independent bit-walk of the AV1 sequence header (the packed OBU must stay identical to the `StdVideoAV1ColorConfig` handed to the driver), and an H.264 SPS/VUI parse proving openh264 honours the request instead of dropping it. Not yet verified on hardware: the HEVC VUI depends on the driver's SPS writer emitting `vui_parameters()`. PUNKTFUNK_VULKAN_ENCODE=0 falls back to VAAPI if a driver mishandles it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 3c56ff5717b2c9a0871953127da3dadd6a84220d) |
||
|
|
0868b6a364 |
fix(vdisplay): compositor availability follows the live session, not the env
GNOME/Mutter reported "Unavailable" on a host sitting in a live Mutter session — and, on the same request, "Default", because the two columns had different sources. available() asked each backend, and those probes read the process env (XDG_CURRENT_DESKTOP for Mutter, WAYLAND_DISPLAY for KWin's registry handshake, SWAYSOCK for sway) — env a host started outside the session (systemd --user, a TTY, ssh) never inherited. It is only retargeted at the live session on the connect path, so the answer also flipped depending on whether anyone had connected yet. Both columns now come from the same /proc scan detect() already used: the live session's compositor is usable by definition, as is an explicit operator pin, and the per-backend probe stays as the fallback for backends that are not the live session (gamescope, which spawns its own). A live KWin without the zkde_screencast grant now surfaces as available and fails at create with that probe's precise message, which beats "no usable compositor" on a box visibly running KDE. Mutter's env sniff stays deliberately narrow — one var, not three. XDG_CURRENT_DESKTOP is the one apply_session_env owns end to end (written per connect, scrubbed when nothing is live); sniffing DESKTOP_SESSION alongside would resurrect the bug that scrub exists to prevent, where a stale value after a gnome-shell crash routes the next client into a dead session. Non-Linux hosts now report no compositors at all rather than five Linux backends flagged unavailable with no default — on Windows the pf-vdisplay driver is the only backend and vdisplay::open ignores the argument, so the old list read as broken detection instead of "not applicable here". The console says so explicitly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit eb7ba3d6177552f5c3ed6a15439404084869c636) |
||
|
|
5ea087ca47 |
feat(session): the stats overlay scales with the display's DPI
The stream chrome — stats OSD, capture hint, start banner, resize label — was hardcoded at 14 px with 12/10/8 px insets. The overlay composites into the swapchain 1:1 in PHYSICAL pixels, so on a 4K panel at 200 % all of it rendered at half its intended physical size: fine on a 1080p monitor, a squint on a HiDPI laptop. FrameCtx now carries a scale — SDL's window display scale (DPI × the display's content scale) times a PUNKTFUNK_OSD_SCALE preference — and every metric moved into a `base` module that is multiplied by it. Re-read per frame and quantized into the damage key, so dragging the window to a differently-scaled monitor re-renders at the new size rather than keeping the stale one. Sanitized because SDL returns 0.0 when it cannot resolve the window's display, which would collapse the panel to nothing. Two details worth keeping: the face is re-derived at the scaled size rather than the canvas transformed, because Skia rasterizes glyphs at the requested size where a magnified 14 px bitmap would be mush; and the long capture hint is fit-clamped to the window — at 2× it is wider than a 1080p screen, so scaling it naively would have traded a small OSD for a truncated one. Linux and Windows share this path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 744467d13a28b91ba88a23d6038da70263e9b502) |
||
|
|
b444308592 |
feat(host): PUNKTFUNK_HOST_NAME names the host in Moonlight and the clients
A box called `bazzite-htpc` had no way to present itself as "Living Room" short of renaming the machine. The new knob overrides the name everywhere a human sees it: the GameStream serverinfo <hostname> element and the mDNS service instance name both adverts carry. Unset (the default) is the machine's own hostname, exactly as before. Free text is the point, so the DNS-level name is now a separate concern from the display name. The instance label may contain spaces and accents; an A-record target may not, and mdns-sd rejects the whole ServiceInfo if the target is not a legal name — which would take discovery down rather than merely look wrong. dns_label() sanitizes the target and passes an already-legal name through byte-for-byte, so hosts without the override advertise precisely what they always did. The display name loses `.` (it would split the label, and clients derive the name as the first label of the fullname, so "Ben's PC v1.2" would arrive as "Ben's PC v1") and is capped at the 63-byte DNS-SD ceiling on a char boundary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit bbf72261a12e0e67601f549d40db65ae61979268) |
||
|
|
188f55d3b1 |
fix(encode/nvenc): the host advertises what the driver lists, not a superset
Every NVIDIA host advertised a static H.264|HEVC|AV1 superset, so a 1st-gen Maxwell (GTX 960M, no HEVC/AV1 encode) offered HEVC — a client that believed it got ~15 s of blank video and a disconnect instead of a stream. Both OSes now ask the driver itself (nvEncGetEncodeGUIDs) on one throwaway direct-SDK session: Linux on the shared CUDA context, Windows on the selected render adapter, wired into host_wire_caps AND the GameStream serverinfo mask (which had been left on the superset for NVIDIA on both OSes). Fails open — an unanswerable probe keeps the historical superset, so it can only ever narrow the advertisement to codecs the GPU really encodes. The HEVC 4:4:4 answer rides the same session on Linux instead of opening a libav hevc_nvenc FREXT probe: that open is the prime suspect for the field bug where one probe wedges NVENC process-wide (NV_ENC_ERR_INVALID_VERSION on every later session until a host restart), and the direct backend re-checks the same caps bit at session open anyway. The ffmpeg probe remains only for hosts that really stream over libav (PUNKTFUNK_NVENC_DIRECT=0 or a build without the nvenc feature), where ffmpeg's NVENC client runs regardless. The 10-bit probe deliberately stays libav — Linux HDR rides the libav P010 path. On-hardware: .136 (RTX 5070 Ti) 14/14 nvenc tests in one process incl. the probe followed by real sessions and dirty teardown; .173 (Windows RTX) probe + 47 release lib tests. The Windows probe test documents the pre-existing MSVC debug-link failure (LNK2019 via the sdk crate's unused lazy loader) — run it with --release, the same reason windows-host.yml gates with clippy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 0346ec8090568eb499e8cb7d735305b28471185e) |
||
|
|
560e663aef |
fix(drivers): the pad channel asks the devnode who to trust, not the mailbox
ci / rust (push) Failing after 12s
windows-drivers / probe-and-proto (push) Successful in 48s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m6s
deb / build-publish-client-arm64 (push) Failing after 10s
decky / build-publish (push) Successful in 47s
windows-drivers / driver-build (push) Successful in 1m40s
apple / swift (push) Successful in 3m6s
ci / bench (push) Successful in 7m39s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
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 (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8m2s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m0s
android / android (push) Successful in 12m28s
deb / build-publish (push) Successful in 12m13s
ci / rust-arm64 (push) Successful in 12m31s
arch / build-publish (push) Successful in 12m40s
deb / build-publish-host (push) Successful in 12m17s
windows-host / package (push) Successful in 18m26s
windows-host / winget-source (push) Skipped
apple / screenshots (push) Successful in 23m25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m34s
docker / build-push-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m24s
A LocalService principal could take over a virtual pad's shared input section and
forge HID input into the interactive desktop.
The host duplicates each pad's unnamed DATA section into the driver's WUDFHost, and
through gamepad proto v2 it learned that process from `driver_pid` in the named
bootstrap mailbox. That mailbox has to be LocalService-writable — that is what the
driver's own WUDFHost runs as — and the delivery gate, verify_is_wudfhost, only checks
that the target's IMAGE is %SystemRoot%\System32\WUDFHost.exe. That image is
world-executable. So anything running as LocalService — notably the deliberately
de-privileged plugin runner — could spawn its own WUDFHost (CREATE_SUSPENDED parks it
indefinitely with the right image path), publish that pid, and be handed
SECTION_MAP_READ|WRITE on a live section. For pf-mouse that section drives a real
absolute pointer, so it was desktop control; for the pads it was forged gamepad input
plus a read of the remote user's controller state.
The module docs claimed mailbox tampering "yields at worst a gamepad DoS, never a read
or an injection". That was wrong, and the reasoning behind it — that a LocalService
token is DACL-denied OpenProcess on a UMDF WUDFHost — only covers the REAL host, not
one the attacker spawned itself.
The pid now comes from the device stack (ChannelProof, proto 2 -> 3). The host asks the
devnode it SwDeviceCreate'd who is serving it, looked up by the instance id PnP handed
back, so a planted look-alike devnode is not a candidate and the kernel — not anything
the attacker supplies — does the routing. Only the driver PnP actually bound to that
device can answer. `driver_pid` survives as a liveness hint; a tamperer can still deny a
pad, which squatting the name always allowed, but can no longer choose the recipient.
Two rules keep the state machine honest around it: a delivery stands until its target
process EXITS (judged on a retained SYNCHRONIZE handle, so a recycled pid cannot fake
it, and UMDF's restart-after-driver-crash still re-attaches), and a pad with no
SwDeviceCreate devnode refuses to deliver rather than fall back — unless an operator
sets PUNKTFUNK_PAD_CHANNEL_TRUST_MAILBOX, which says so loudly.
Three transports, because Windows carries different things to different driver shapes,
and the obvious two did not survive contact with hidclass. Measured on .173 (Win11
26200): HidD_GetIndexedString is NOT forwarded to a UMDF HID minidriver at all — it
failed for every index including ones the driver demonstrably serves through the named
wrappers; and a private device interface registers and enumerates but cannot be OPENED
(ERROR_GEN_FAILURE), because hidclass owns IRP_MJ_CREATE on a devnode it is the FDO for.
That is exactly why pf-xusb was never affected: it is not a HID minidriver, so nothing
sits above it. What works:
* pf-xusb — a private IOCTL on its own GUID_DEVINTERFACE_XUSB.
* pf-mouse — the HID serial string. Verified: PFCP:3:0:7296, and 7296 was a genuine
service-spawned WUDFHost.exe. Safe here alone: nothing reads the virtual
mouse's serial, whereas a pad's is SDL/Steam dedup material.
* pf-gamepad — a HID feature report, and it cost NO report-descriptor change. The
captured descriptors already declare far more Feature ids than the driver
ever served: 0x85 is declared on DualSense, DualShock 4 and Edge alike and
used to fail with STATUS_INVALID_PARAMETER, so hidclass lets it through and
nothing can have depended on the old failure. The Deck's one feature report
is unnumbered and Steam drives it command->response, so its proof rides that
existing contract via a private two-byte command. Verified: feature 0x85
returned magic "PFCP", proto 3, pad_index 0, wudf_pid 18456 — and 18456 was
a WUDFHost — with the product string still 'DualSense Wireless Controller'.
Also renamed pf-dualsense -> pf-gamepad. One driver has always served four identities, so
the old name read as if the other three lived elsewhere. ONLY the package identity moved
(crate, INF/CAT/DLL, UMDF service, build script, CI lines, log file, env var). The four
HARDWARE IDS are deliberately unchanged — they bind every devnode the host creates and
every installed system — as are the Global\pfds-boot-<i> mailbox and PAD_MAGIC, which are
wire contract. `driver install --gamepad` now retires the pre-rename store package first,
matched on pf_dualsense.dll because that string appears only in the OLD inf; matching on
the hardware ids would delete what we are about to install. On .173 that separated 14
stale packages from the 1 new one with 0 ambiguous, and the renamed package binds the old
hwid (devgen root\pf_dualsense -> oem143.inf = pf_gamepad.inf).
The repo's own pre-commit/pre-push rustfmt hooks named the old crate, so they caught the
rename before the commit did — they now check pf-gamepad, and pf-mouse alongside it, which
they had been missing relative to the CI line.
Host and drivers MUST ship together: v2<->v3 fails closed in both directions by design,
with the existing "update host + drivers together" diagnostic.
The rename moved files that also carry the security change, so splitting this into two
commits would mean reconstructing an intermediate state that was never gated. It is one
commit on purpose.
Gated on the windows-amd64 runner with cargo clean first (the box's clock lags, so stale
artifacts would read as a vacuous green): clippy -D warnings clean for pf-inject,
pf-capture and pf-driver-proto, drivers workspace build + the CI clippy line clean,
cargo check --release -p punktfunk-host clean, 19 + 58 tests green. Also fixes pf-mouse
still writing its debug log to world-writable C:\Users\Public, which the 2026-07-17
review moved for the other three drivers and missed here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
9b3ec9204c |
fix(capture): a refused EGL→CUDA dmabuf offer stops being asked
ci / rust (push) Failing after 41s
android / android (push) Canceled after 1m19s
apple / screenshots (push) Canceled after 0s
apple / swift (push) Canceled after 1m20s
arch / build-publish (push) Canceled after 1m23s
ci / rust-arm64 (push) Canceled after 1m24s
ci / web (push) Canceled after 1m24s
ci / docs-site (push) Canceled after 1m23s
ci / bench (push) Canceled after 1m22s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / build-publish (push) Canceled after 46s
deb / build-publish-host (push) Canceled after 2s
decky / build-publish (push) Canceled after 1s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
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
windows-host / winget-source (push) Canceled after 0s
windows-host / package (push) Canceled after 1m35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 5s
The raw-dmabuf passthrough has had a negotiation-timeout latch since the hybrid-Intel case: one conclusive timeout and later captures negotiate the CPU path instead of re-paying it. The EGL→CUDA dmabuf-only offer had no equivalent — a compositor that accepts none of the importer's modifiers timed out the same 10 s negotiation on every session, forever, under the generic 'format negotiation never completed' diagnosis. Now the symmetric latch (note_gpu_dmabuf_negotiation_failed) gates build_importer in the one negotiation resolver, scoped to this offer alone (raw passthrough, worker-death latch, encoder untouched), with the same operator escape as the raw arm: an explicit PUNKTFUNK_ZEROCOPY=1 keeps erroring loudly instead of downgrading. One correctness detail beyond the handoff's sketch: whether the GPU offer was ACTUALLY advertised is a runtime fact of the PipeWire thread (the importer may fail to construct — no CUDA — in which case no dmabuf was offered and a timeout must not latch it off). plan.build_importer alone cannot answer that, so the thread records the made-offer on CaptureSignals (gpu_dmabuf_offer) and the timeout diagnosis branches on the offer that really happened, not the plan's intent. Also renames vaapi_dmabuf_forced → zerocopy_forced (it now guards both timeout arms; single caller). New plan invariant pinned in tests: the latch gates only the importer, never the raw passthrough. (V3 from design/pf-zerocopy-sweep-handoff.md — the deferred design call, now decided in favour of the symmetric latch.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e92a0aaa00 |
fix(zerocopy/vkslot): a timed-out blend can't corrupt its slot, and NV12 cursor chroma sits on the grid
Two [GPU]-class defects from design/pf-zerocopy-sweep-handoff.md; the code is compile- and unit-verified, the visual halves still owe on-glass time. - blend_ref reused a fence and command buffer after a wait timeout: on TIMEOUT it reset a fence that still had a pending signal and re-recorded a command buffer the GPU might still be executing — reached exactly in the contended case the CPU-synced path exists for (no timeline export + visible cursor + a >1 s GPU stall). A failed wait now drains the device before the reset (the VkBridge::import_linear precedent), and free_slots quiesces unconditionally instead of only when a timeline exists, so teardown after a timed-out sync blend no longer frees objects an outstanding submission references. (C1) - cursor_blend.comp anchored NV12 chroma blocks to the cursor's oy, not the surface chroma grid: for odd oy every UV sample averaged luma rows one below the rows it covers (a one-row colour fringe on the cursor's edges, ~half of all cursor positions) and the cursor's last row's chroma was never written. Block rows now anchor to the chroma grid the same way spans anchor to the word grid — y0 = floor(oy/2)*2, per-row cy guards for the straddle block — and blend_geometry counts blocks from the same anchor (one extra block when oy is odd; covered by new tests, including negative oy). cursor_blend.spv rebuilt (glslangValidator, spirv-val clean, drift gate passes). (C6) Owed on-glass: a stalled-GPU cursor session for C1; an odd-oy cursor colour check for C6 (subtle fringe on the cursor's top/bottom edge). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fa083f50d3 |
fix(zerocopy): unsafe fn bodies join the unsafe-proof program
#![deny(clippy::undocumented_unsafe_blocks)] never inspected the body of an unsafe fn — operations there are not "unsafe blocks" — so roughly 20 functions' worth of raw GL/CUDA/Vulkan driver calls sat outside the invariant the crate advertises. Concretely, that blind spot is why the constructor-leak and teardown-ordering shapes fixed earlier in this series could ship without ever prompting a reviewer. #![deny(unsafe_op_in_unsafe_fn)] now closes the gap: every unsafe fn body is an explicit unsafe block carrying a SAFETY comment that names the caller contract it relies on (the dlopen'd CUDA wrapper table gets a uniform forward-to-live-table proof). Mechanical; no behavior change. (V2 from design/pf-zerocopy-sweep-handoff.md.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c677732c60 |
fix(zerocopy/client): a wedged worker can't park the reaper, and no worker outlives the host
Three related reaper problems (R5 from design/pf-zerocopy-sweep-handoff.md): - sweep_reaper called kill() + blocking wait() while holding the global REAPER mutex. A worker wedged in a driver ioctl is in D state and ignores SIGKILL, so wait() never returned — parking every later spawn() and every importer drop() behind the lock. Expired entries are now drained under the lock and killed outside it, with a bounded (~100 ms) try_wait poll; a worker that still won't die is parked again for a later sweep (re-killing is harmless) instead of blocking anyone. - No PR_SET_PDEATHSIG: if the host died, the worker survived holding its CUcontext + BufferPool — by the code's own comment, hundreds of MB of VRAM. The pre_exec closure (async-signal-safe: prctl/getppid/dup2/fcntl only, no allocation) now arms SIGKILL-on-parent-death with the standard getppid race guard. Not taken: arming the 20 s kill deadline from a timer instead of the next spawn/drop (the handoff's optional third leg). PDEATHSIG closes the worst-case orphan; a wedged worker after the LAST capture of a session still waits for the next spawn to be swept. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3c62da3b8e |
fix(zerocopy/client): renegotiation retires the old generation's IPC mappings
Shared::mappings only ever grew: clear_cache reset sent_keys and told the worker to drop its fd cache, but nothing closed the host-side CUDA IPC mappings for the previous pool generation. A session that renegotiates repeatedly (mode changes, HDR toggles, client reconnects) accumulated a pool's worth of stale mappings each time, each pinning a host VA reservation to peer memory the worker had already freed. Mappings now carry a refcount and a retired flag. clear_cache closes every unreferenced mapping immediately and marks the rest retired; a retired mapping closes when its last in-flight DeviceBuffer releases. The worker's half of the contract: ClearCache also forgets its VA→id map, so anything delivered after the boundary gets a fresh id WITH its descriptor — without that, a same-shape renegotiation (worker keeps its pool) would re-deliver an old id whose host mapping was just closed, and the host would misread it as a desync. Ids never repeat (next_id only counts up), so fresh ids cannot collide with the graveyard. (R6 from design/pf-zerocopy-sweep-handoff.md.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a2033d6c82 |
fix(zerocopy/vulkan): VkBridge bring-up and the CSC build unwind instead of leaking
VkBridge::new leaked its instance (and past device creation, the device and command pool too) on every error path — reached repeatedly, because a box whose Vulkan device refuses the external-memory extensions retries the bridge on every LINEAR frame. Pre-device failures now destroy the instance explicitly (the VkSlotBlend::new shape); after device creation the remaining objects build into an incrementally-filled struct whose existing Drop tolerates the nulls a partial init leaves (Vulkan destroy calls are defined no-ops on VK_NULL_HANDLE). ensure_csc had the same hole across its six-object pipeline build; the fallible half now fills the Csc front to back and a mid-build failure destroys exactly what was created, leaving self.csc None for a clean retry. (R3 from design/pf-zerocopy-sweep-handoff.md.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8d02255703 |
fix(zerocopy/egl): construction can fail late, so teardown must be structural
Three unwind holes in the EGL side, all of the same shape — fallible construction over raw handles with no Drop to unwind through — and all retried per frame, so a sustained failure (VRAM pressure is the realistic one) leaked unboundedly: - The GlBlit/Nv12Blit/Yuv444Blit constructors interleave GL-object creation with fallible steps (FBO completeness, CUDA registration, pool allocation). A GlNameGuard now owns every bare GL name until the final struct exists; on unwind it deletes them AFTER the RegisteredTexture locals unregister (declaration order), preserving the unregister-before-delete invariant. gl.rs gets the same treatment for its compile paths: the vertex shader on a fragment-compile failure, the program on a link failure. (R1) - EglImporter::new leaked the DRM render-node fd and the whole gbm_device on every '?' after their creation (most realistically cuda::context() failing on a host where EGL comes up but CUDA does not). Both now live in a GbmDevice with its own Drop: destroy the device, then close the fd. (R2) - EglImporter's manual Drop destroyed the gbm device and closed the fd BEFORE field drops ran the blit destructors, which then called cuGraphicsUnregisterResource/glDeleteTextures against a dead native display — the stale-driver-state class this path once crashed on. The Drop impl is gone; teardown is now purely field order (blits and bridge first, GbmDevice last), and the blit SAFETY comments cite that order instead of the previously-false claim. (R4) (R1/R2/R4 from design/pf-zerocopy-sweep-handoff.md.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8de5ba4092 |
test(zerocopy): the fd must actually cross the socket, not just claim to
SCM_RIGHTS descriptor passing is the mechanism the whole worker isolation design rests on, and no test verified it end to end: both suites asserted only the has_fd boolean parsed from the JSON body. Setting the send-site's descriptor to None — zero-copy broken in production — stayed green; so did dropping the received fd in the worker's dispatch loop. Now the client-side scripted peer records the st_ino of every descriptor that arrives and the tests assert the sequence against dmabuf_key() of the sent plane (SCM_RIGHTS re-numbers the fd but preserves the open file description, so the inode is the identity the worker keys its cache on); the worker dispatch test sends one import with a live fd and asserts the backend received a descriptor with the sender's identity. (T1 from design/pf-zerocopy-sweep-handoff.md) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
143a707f76 |
fix(zerocopy): the mechanical sweep batch — truthful fence waits, forgiving env flags, no leaked planes
From the pf-zerocopy review sweep (design/pf-zerocopy-sweep-handoff.md), the compile-verifiable batch: - dmabuf_fence: the blocking poll's result was discarded — EINTR silently skipped the wait (reopening the stale-frame race a SIGCHLD away) and a timeout reported as waited. Now EINTR retries with the remaining budget and the caller gets Signaled/TimedOut/NoFence, so the one diagnostic operators have about implicit fencing stops lying. (C2) - env flags: PUNKTFUNK_ZEROCOPY=TRUE meant *off* — values are case-folded now, and an unrecognised spelling falls back to the flag's default with a one-shot warning instead of silently inverting the operator's intent. (C3) - cuda: alloc_pitched_nv12 leaked the Y plane when the UV allocation failed (per-frame under VRAM pressure — the worst possible time to leak); a failed async-copy enqueue now drains the stream before returning, so a recycled pool buffer can't race an orphaned in-flight copy. (C4, C5) - worker: --fd is validated (>= 3, fstat + S_ISSOCK) before OwnedFd adoption — 'zerocopy-worker --fd -1' was constructing OwnedFd's niche value. (V1) - docs: all 15 rustdoc warnings fixed, including the link to the renamed note_raw_dmabuf_negotiation_failed. (D1) - CI: a SPIR-V drift gate — the committed .spv blobs are include_bytes!'d and rebuilt by hand; the gate diffs disassembly (filtering only the shaderc/glslang generator difference) so a forgotten rebuild fails CI instead of shipping the old kernel. Both blobs verified in sync. (S1) - tests: bt709_limited pinned to external BT.709 anchors (it is the sole oracle for the GPU colour self-test); blend_geometry gets its first tests — empty rect, CURSOR_MAX clamp, per-format group counts, and negative-ox floor alignment. (T2, T3) Verified on .25: clippy -D warnings clean, 27/27 tests, cargo doc 0 warnings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a98174ebfa |
feat(host): anchor-test, and libei says which output absolute input landed on
The absolute-region ladder existed for one case a unit test can only simulate — two heads of the SAME size, where matching a libei region by the streamed mode is a coin flip — and that case had never been run against a real compositor. It was also unobservable: the log said which regions the EIS server offered, never which one a coordinate went into, so "the pointer is on the wrong monitor" could only be discovered by looking at a monitor. libei now reports the chosen region once per distinct answer, beside the existing miss warning: one covers an anchor that named nothing, this one covers an anchor that matched something, by saying which. `anchor-test` is the gate that reads it, in the shape mirror-test established: it enumerates the heads, SAYS whether the rig actually has two of the same size (a green run on a single-head box proves nothing), anchors at a named one (or `--none` for the A/B that makes the anchored run mean anything), and walks the corners. It refuses to run off libei rather than emitting a green result about a ladder that isn't there. On-glass on KWin 6.7.3 with `kwin_wayland --virtual --output-count 2` (regions 1920x1080+0+0 and +1920+0): unanchored picks +0+0, anchored at Virtual-1 picks +1920+0, anchored at Virtual-0 picks +0+0. That is open item 3 of design/per-monitor-portal-capture.md §8b, closed. Clearing the pin now logs too — setting one always did, and the streamed screen going back to virtual is the same size of change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
45f04f50fe |
test(mgmt): gamescope reports no monitors, and that is not a missing explanation
/display/monitors must never answer empty AND silent — the console reads that as "the host is broken". But gamescope is nested: it owns no physical heads by construction, so empty-and-silent is the correct answer there, not an unexplained one. Any dev box that has ever been in game mode keeps gamescope-0 sockets in its runtime dir, so detect() resolves gamescope and the test failed on the machine rather than on the code (found running the suite on .136). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6afc05155b |
feat(vdisplay/session): derive SWAYSOCK — the last session var a --user host lacked
Session detection already hands every backend the live session's env: WAYLAND_DISPLAY from a socket scan, XDG_RUNTIME_DIR from the uid, the session bus, XDG_CURRENT_DESKTOP, and Hyprland's instance signature. sway was the exception — SWAYSOCK had to be INHERITED from the login shell, so a systemd --user host that never saw one had no sway IPC at all: no output enumeration, no capture chooser, no wlroots backend (its is_available() keys off that very variable). It is derivable, and by identity rather than guess: sway names its socket sway-ipc.<uid>.<pid>.sock, and detection already knows the compositor PID. Ladder: a valid inherited value, then the exact PID's socket, then the newest one we own (a sway re-exec can leave the name pointing at a PID we didn't see). None on river — the other wlroots desktop ships no sway IPC, and saying so is what stops apply_session_env exporting a SWAYSOCK that points at nothing. Closes the session-env half of design/per-monitor-portal-capture.md §6 for a desktop-login host: nothing has to be exported for the host to find its session. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
829bcb7962 |
fix(gamestream): the streamed-screen pin applies to the portal source too
PUNKTFUNK_CAPTURE_MONITOR is documented as "instead of creating a virtual display OR taking whichever head the portal hands back", and the console presents it as a host-wide setting. The compat plane's portal source did neither: it went straight to the chooser, so a Moonlight client on a pinned host silently got whatever screen the portal picked. That is the one outcome the whole feature exists to prevent — showing the wrong monitor is worse than showing none. The portal arm now mirrors the pinned head instead, through the same MirrorDisplay the virtual source reaches via vdisplay::open. It launches nothing and creates no virtual output, so it needs neither the game-lifetime machinery nor the registry (External ownership passes through it anyway). Which screen a pooled capturer is showing is now the third reuse key beside HDR-ness and cursor mode. The pin is a LIVE setting — the console can re-aim the host between two connects — so without it in the key the second connect would keep streaming the previous screen, which is the same silent-wrong-monitor failure by another route (§7.3, open item 5). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
de3ef50434 |
fix(native): a mirrored monitor has a fixed mode — refuse to resize it
A physical head runs at the mode its owner set, and MirrorDisplay::create ignores the requested one on purpose. So a mid-stream Reconfigure against a mirror could only ever tear the cast down and rebuild the identical one at the identical size — a hitch that buys nothing, and an invitation to the worse reflex of reconfiguring the display someone is sitting at. reconfig_allowed grows a third gate beside gamescope and per-client-mode identity, and the session captures it at bring-up like the others: this session opened its display under whatever the pin said then, so a console change mid-session must not retroactively change the answer it gives. Linux-only, because vdisplay::open only routes to the mirror there — a pin left in a Windows host's settings streams nothing different and must not disable resize as a side effect. design/per-monitor-portal-capture.md §7.3, open item 4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b08226dbf1 |
feat(vdisplay): mirror a pinned monitor on sway and Hyprland
P5 of design/per-monitor-portal-capture.md, and the end of a hole worth naming: the pin was accepted, persisted and offered in the console while `create` bailed on these two backends, so a sway or Hyprland host with a monitor selected failed EVERY session. Both already ship the mechanism — a managed portal-chooser config plus a per-session selection file (xdpw's `Monitor: NAME`, xdph's `[SELECTION]screen:NAME`) — so mirroring is that same chooser pointed at a physical connector instead of a headless output we created. The keepalive stops the cast and nothing else; the monitor is the compositor's. The selection write is now serialized against the handshake that reads it (SELECTION_LOCK, applied to the virtual-output paths too). That file is one per-user path: whoever writes last before the portal reads wins, and the loser does not fail — it silently captures the other session's output. `managed` is no longer grounds for refusal everywhere. It is conclusive only where the name is ours by construction (KWin's `Virtual-punktfunk`, Hyprland's `PF-N`); sway names EVERY headless output `HEADLESS-N`, its own included, so the old rule would have refused to mirror any output on a headless sway box — exactly the remote setup this feature serves. Found on-glass, and now a test. Verified on a two-output headless sway: asking for HEADLESS-1 yields 1280x720 frames and HEADLESS-2 yields 1920x1080, so the chooser really does steer per request. Hyprland is compile-only — no Hyprland box exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d461d889c3 |
feat: the streamed screen is a persisted setting, pickable from the console
P4 of design/per-monitor-portal-capture.md. The pin was an env var read once at startup, which a console picker can never write — so it becomes a field of the display policy (orthogonal to presets, like game_session), and `vdisplay::capture_monitor()` resolves env-over-policy: an appliance that pinned in its unit's host.env stays pinned there, and a console click cannot re-aim a machine whose operator already declared the answer. Read per `open` rather than cached, so a picker change takes effect on the next session instead of the next host restart. The host re-resolves the input anchor on every policy write — including clearing it when the pin is cleared, or a later virtual-display session inherits an anchor aimed at a monitor it is not showing. `with_manual_layout` carries the pin through like the other orthogonal axes: without that, saving a display ARRANGEMENT would silently swap the streamed screen back to virtual. Console: a "Streamed screen" card listing the host's real monitors beside "Virtual screen (default)", saving on selection. A disabled head is listed but not selectable (so "why isn't it here?" has an answer), and an env-pinned host renders read-only with the reason rather than offering controls that silently lose to the environment. Verified on GNOME/Mutter: pinning via the policy file alone (no env) routes sessions to the mirror and anchors input; setting the env to a different connector overrides it, exactly as documented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
358cfa4be4 |
feat(host): mirror-test — the on-glass gate for per-monitor capture
Opens the display backend exactly as a session would, attaches a capturer and reports the frames a named head actually produces, with no client involved. `--monitor` names the head explicitly (it cannot go through PUNKTFUNK_CAPTURE_MONITOR: pf_host_config parses the environment once and startup already read it, so a tool setting it for itself would still see the old snapshot — hence vdisplay::open_mirror); with no argument it exercises the production routing through the pin. A frame timeout is NOT treated as failure. Compositor capture is damage-driven — the host's own capture diag logs new_fps=0 for virtual outputs on an idle desktop for exactly this reason — so the first version of this tool ended its measurement on the first idle gap and made a working mirror look like a 2-frame stall. Verified on KWin 6.7.3 (home-nobara-1), mirroring HDMI-A-1: 346 frames in 20s at 1920x1080, arriving while input was injected and pausing when the desktop went quiet, on both the CPU (BGRx/MemFd) and zero-copy (NV12 dmabuf) paths, ownership=External. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
045deaf77a |
fix(vdisplay/kwin): bind the output-device REGISTRY — KWin 6.7 stopped
advertising per-output globals Found on-glass while testing monitor enumeration: KWin 6.7.3 advertises kde_output_device_registry_v2 and NOT one kde_output_device_v2 global per output. This module only ever bound the globals, so on a current KWin it saw zero devices. That is not just an enumeration gap — it silently disabled the whole in-process output-management path, which exists precisely because kscreen-doctor wedges. The live 0.19.2 host on the test box logs "kde_output_management unavailable — kscreen-doctor fallback" on EVERY session, so every topology apply there has been going through the tool this module was written to stop using. Both models are supported now: the per-output globals older KWin sends, and the registry's `output` events. Devices from the registry arrive a round later than globals do, so the handshake takes one more barrier — skipped when nothing is still awaiting its `done`, so the classic path costs nothing. Verified on KWin 6.7.3: enumeration now reports HDMI-A-1 1920x1080@60 at +0,+0 scale 1.35 primary, matching kscreen-doctor exactly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
bf2f54a5fe |
feat(vdisplay): mirror a pinned monitor on Mutter, and scope the HDR probe
P3 of design/per-monitor-portal-capture.md. Mutter's mirror is the same private ScreenCast session the virtual path already drives, one call different — RecordMonitor instead of RecordVirtual — so it inherits what makes that path work headlessly: a direct D-Bus API that needs no interactive approval, unlike the xdg portal a background service could never answer. Deliberately not under TOPOLOGY_LOCK: that serializes operations which add or remove a monitor or apply a monitor configuration, and mirroring does neither. Nothing is created, no layout changes, and teardown is just Stop — the SIGSEGV-adjacent ordering the virtual path must observe cannot arise when no monitor is being removed. The GNOME HDR probe now honors the pin. Asking "is ANY monitor in BT.2100 mode" was fair while capture took whatever head it was handed; once the operator names one, an HDR TV on the next connector must not talk the host into offering PQ formats for the SDR panel it is actually streaming. A pin that names no live head reports SDR rather than falling back to "any" — the session is about to fail on that same missing monitor, and an over-claimed HDR offer would be a second, quieter wrong answer. ⚠️ RecordMonitor's exact signature is read from the interface, not yet confirmed against a live gnome-shell; no GNOME box was reachable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
53c772702f |
feat(host): list-monitors CLI, and two bugs it caught on-glass
An operator configuring an unattended host has to learn the connector
names from somewhere, and "curl the management API before the host is
configured" is not it. `punktfunk-host list-monitors` prints them with
geometry and flags the pinned one.
Running it under a real sway immediately caught two defects the unit tests
could not:
- the query used the `swaymsg` command helper, which inserts `--`, so
`-t get_outputs` came back as "Unknown/invalid command '-t'". Queries
now go through a `swaymsg_query` helper that cannot make that mistake.
- compositors write the literal "Unknown" rather than leaving make/model
empty, so the picker label read "Unknown Unknown". One `describe`
helper now treats that as absent, for all four backends.
Verified on a headless two-output sway: names, modes, positions, scale and
the case-insensitive pin all resolve.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
93c2765db7 |
feat(vdisplay): mirror a pinned physical monitor on KWin
P2 of design/per-monitor-portal-capture.md. zkde_screencast's stream_output records an output KWin already has — the connector name IS the selection, so there is no dialog, no portal and no chooser for a background service to answer. It arrives as a VirtualDisplay reporting DisplayOwnership::External, which already means "someone else's display, merely mirrored: no keep-alive, no topology, no reuse". So the rule that we must never disable, move or "restore" a monitor the user is sitting in front of holds by construction rather than by everyone remembering it — and create() ignores the client's requested mode for the same reason: a panel runs at the mode its owner set. Routing lives in vdisplay::open, the one place every session opens a display, so the pin cannot be honored on one plane and ignored on another. The host sets the libei anchor from the same pin (pf-vdisplay must not depend on pf-inject), which is what makes absolute input land on the mirrored head instead of a same-sized neighbour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2eeee650b9 |
fix(inject/libei): absolute coordinates resolve by identity, not by size
libei hands the injector one region per logical monitor with no output name attached, so picking one meant matching the streamed mode's SIZE — a coin flip the moment two heads share a mode, and it already resolved wrong on-glass once (GNOME, a dummy HDMI beside the virtual primary: the seat cursor never entered the streamed monitor, so neither cursor path could see it). The ladder is now mapping_id -> origin -> size -> first. Two outputs can share a size but never a top-left, and a mirrored head's region is not the client's size at all, so the size rung cannot find it — this is what makes per-monitor capture land its input on the monitor it is showing. An anchor that matches nothing falls back down the ladder rather than stranding input: the region set is the truth and the anchor is our belief about it. It warns once per distinct anchor, because the failure this exists to prevent is a pointer that silently lands elsewhere. P1 of design/per-monitor-portal-capture.md — mechanism and tests only. Nothing sets an anchor yet; the pin wires it in P2, and the doc comment records why it is host-level and not per-session. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
95e3314d9a |
feat(vdisplay): enumerate the host's physical monitors
P0 of per-monitor capture (design/per-monitor-portal-capture.md): nothing in the tree could answer "what heads does this host have?", which both a monitor pin and a console picker need first. One read per compositor, each beside the code that already speaks that dialect: KWin's kde_output_device_v2 (the topology session now also records geometry + scale), Mutter's DisplayConfig.GetCurrentState, swaymsg get_outputs, hyprctl monitors. Logical geometry throughout, because x/y is what identifies a head — two monitors can share a size but never an origin. PUNKTFUNK_CAPTURE_MONITOR parses here and is reported at startup, loudly including that it is not yet enforced: a knob that is read but inert has to say so, or "it didn't work" reads as a bug. GET /display/monitors always answers 200 with an explained empty list, so a compositor-less host renders as "nothing to pick", not as a broken console. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6be865f33f |
fix(host): a host that is stopped hands the box's session back first
Until now the host had no signal handling at all: SIGTERM killed it outright. That is fine for a host that owns nothing — but a managed gamescope takeover owns the box's session, and on a mask-fragile display manager (Nobara's plasmalogin) it has STOPPED that display manager for the length of the stream. Killed there, the host leaves a box with no graphical session and nothing left to restart it: the crash-restore state lives in $XDG_RUNTIME_DIR, which logind removes along with the user manager, so not even the next host start can heal it. `systemctl --user restart punktfunk-host` mid-stream — or a package update doing it for you — was enough to strand a box until someone reached a VT. So SIGTERM and SIGINT now restore first and exit after. The restore runs on a blocking thread (it shells out) under a 20s grace, well inside systemd's TimeoutStopSec, and a host that took nothing over exits immediately. `restore_takeover_now` is the synchronous sibling of the debounced disconnect restore, and deliberately ignores the keep-alive policy the latter honors: `keep_alive=forever` pins a session for the NEXT client, which means nothing once the host that would serve them is exiting. Both paths now share one `takeover_live()` predicate instead of repeating the four-way "is anything ours" test. Checked on Linux: clippy -D warnings on punktfunk-host + pf-vdisplay, tests, rustfmt. |
||
|
|
f3615f83a5 |
fix(gamescope): the takeover no longer kills the host it is running in
Field report, Nobara + 0.20.0: switching into Game Mode mid-stream disconnects the client and the box never lights up again. His journal has the whole mechanism, ten seconds apart: 12:34:18.9 freed Steam: stopped the display manager for this stream 12:34:29.0 systemd[1685]: Stopping punktfunk-host.service Stopping the display manager ends the user's last login session. The packaged host is a `systemd --user` unit, so logind then stops user@1000.service once UserStopDelaySec elapses — 10s by default on Nobara — and takes the host with it. The stream dies, the scheduled restore never runs, and the display manager stays down with nothing left to restart it: a black box that needs a VT to recover. Exactly the two symptoms reported, and the intermittency is just whether an active Gaming Mode session was there to trigger the takeover at all. It never showed on the repro VM because lingering was enabled there for the earlier sessionless tests (`Linger=yes` — confirmed on the VM today), which is precisely the thing that breaks the dependency: logind keeps the user manager alive with no session. Our KDE/GNOME/Arch setup guides already ask for it; his box, following the KDE route, did not have it. So the takeover now ensures lingering BEFORE it touches the display manager: `loginctl enable-linger` directly (allow_active in logind's own policy — verified working unprivileged on Nobara), else the packaged helper gains a `linger` verb that enables it for PKEXEC_UID alone, never a caller-named user. If neither works the takeover is refused with an actionable error and managed degrades to attach — mirroring the box's own session is strictly better than a screen that needs a VT. Hosts that cannot be reached this way (root, a system unit) skip the check: the cgroup test is a pure `cgroup_under_user_manager` with a unit test, matched against the real shapes on a Nobara box (`user@1000.service/app.slice/...` vs a `session-N.scope`). Also documents the requirement in the gamescope guide's Nobara section. Checked on Linux: clippy -D warnings, 78 tests, rustfmt; helper verified with `sh -n`, and the linger enable/disable round-trip run live on the Nobara VM. |
||
|
|
eae1837a3a |
fix(gamescope): a managed takeover never waits on a dialog nobody can see, nor acts on a months-old sentinel
Two ways the Nobara DM-stop takeover could end a session and leave the box
dark, both found while triaging a 0.20.0 field report (managed round-trip:
"my client disconnects and my monitor doesn't turn back on", intermittent).
0.20.0 is the first release where that box runs the takeover for real —
before it, no package shipped the polkit privilege, so managed always
degraded to attach and never touched the display manager.
Privileged verbs are non-interactive now. `try_stop_display_manager` and
`restore_display_manager` try a plain system-bus `systemctl` first, and
without `--no-ask-password` that asks polkit for INTERACTIVE authorization.
On a box where the host runs inside the user's graphical session, polkit
hands that to the session's agent: a password dialog on the box's own
screen, which during a takeover is off or mid-switch. Nobody sees it,
nobody answers it, and the call blocks on the stream's own thread — the
capture-loss rebuild, or the restore worker — while the rebuild budget
burns. The takeover then lands after the session it was for already ended.
With the flag it fails immediately ("interactive authentication required"),
which is what both callers are already written for: the packaged pkexec
helper (allow_any, no agent needed) takes it from there.
A sentinel with no baseline is not a request. `session_select_requested`
read "no baseline recorded" as "the sentinel appeared during the session" —
but `~/.config/steamos-session-select` is a permanent file, so every box
whose user has ever switched sessions has one. The baseline was recorded
only after a SUCCESSFUL launch, leaving a real window: takeover succeeds,
launch fails, the client retries inside the 5s restore debounce (which the
retry cancels), and the honor gate fires on a months-old write — pushing
the box to the desktop the user never asked for, plus a 120s grace refusing
to relaunch game mode. Now the baseline is taken at takeover too (setting
STOPPED_DM is what arms the gate), and "never baselined" reads as no
request. `SESSION_SELECT_BASELINE` carries both states explicitly, and the
decision is a pure `sentinel_advanced` with a unit test for all six cases.
Checked on Linux: cargo check --all-targets, clippy -D warnings, 77 tests,
rustfmt.
|
||
|
|
622817954a |
fix(capture/win-display): a deactivated laptop panel is a named stall suspect, not a blind spot
audit / bun-audit (push) Successful in 14s
ci / web (push) Successful in 52s
windows-drivers / probe-and-proto (push) Successful in 44s
ci / docs-site (push) Successful in 1m7s
audit / cargo-audit (push) Successful in 2m18s
windows-drivers / driver-build (push) Successful in 2m21s
apple / swift (push) Successful in 4m54s
ci / bench (push) Successful in 6m41s
ci / rust-arm64 (push) Successful in 10m4s
decky / build-publish (push) Successful in 42s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 26s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 56s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
deb / build-publish-host (push) Successful in 10m31s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
ci / rust (push) Failing after 13m9s
deb / build-publish (push) Successful in 12m14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m9s
android / android (push) Successful in 16m5s
arch / build-publish (push) Successful in 17m22s
deb / build-publish-client-arm64 (push) Successful in 9m46s
windows-host / package (push) Successful in 16m38s
windows-host / winget-source (push) Skipped
flatpak / build-publish (push) Successful in 7m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m24s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m30s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 17m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m42s
release / apple (push) Successful in 27m50s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m49s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 26m34s
docker / deploy-docs (push) Successful in 54s
docker / build-push-arm64cross (push) Successful in 9m53s
apple / screenshots (push) Successful in 23m52s
Field A/B (reporter, Legion 5 Pro hybrid, 2026-07-27): the ~2 s capture-stall metronome IS the exclusive isolate's own doing — deactivating the laptop panel leaves a dark-but-connected eDP head whose driver-level servicing disturbs the capture path (~1.7-2.8 s period); `topology: primary` (panel stays active) stopped it outright (16.3 stalls/min → 0). Our own .221 logged the identical signature the day before. The detector then steered AWAY from the cause: connected_inactive filtered to EXTERNAL physicals only, so the one display that mattered — on a hybrid laptop, the default config — reported as `none`. - TargetInventory: new `internal_panel` class bit (eDP/LVDS/embedded). - connected_inactive_externals → connected_inactive_physicals: internal panels join the suspect list (virtual/indirect stay excluded); a nameless panel renders as "laptop panel". - The below-OS METRONOMIC warn names the dark-panel case and its actual cures (`topology: primary`, `pnp_disable_monitors`) — the old text's "unplug it" prescriptions are impossible for an internal panel. - ddc_power_off: a zero-ack sweep now says at INFO that the axis did nothing (internal panels have no DDC/CI) instead of silently no-op'ing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1945052e66 |
fix(capture/vdisplay): descriptor-following defers while a topology reassert is in flight
The exclusive-topology reassert's forced re-commit transiently bounces the virtual display's mode; the descriptor poller can sample that EVICTION state and recreate the ring at a mode the recovery chain is about to undo (field log 2026-07-27 10:30:44Z: hdr=true → false → recreate → recovery restores true → second recreate). New pf-win-display::topology_churn latch (deadline semantics — self-expiring, so a lost release can never wedge descriptor- following off): the watchdog holds it for interval+3s each fighting round and releases on "stable again"; poll_display_hdr consumes samples but acts on nothing while held — which also keeps the negotiated-depth pin-back from issuing a CCD write mid-eviction, where it would fight the reassert itself. The deliberate recreate_ring_in_place recovery path is unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
2c69cbdab9 |
docs(host/stats): say what the stats actually measure — three field-triage traps
Three lines that each sent the 2026-07 PyroWave field triage down a wrong path, made honest: - The 'client accepts streamed AUs … will stream per-slice' log fires before the encoder exists and regardless of backend support (only Linux direct-NVENC implements chunked polling) — it now states the client capability only. - StatsSample::mbps was documented as 'transmit goodput'; it is attempted sealed wire bytes at seal time — AU bytes + shard framing + FEC parity (+ PyroWave window padding), not reduced by socket send drops. - The per-stage split's 'encode' stage is the poll() duration, which is ~0 by construction for synchronous backends (PyroWave encodes inside submit → its time shows as 'submit') — documented at the split's definition so 'encode 0.00' stops reading as an instrumentation hole. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d8d8c6c43d |
fix(core/abr): the capacity probe's own overload no longer reads as session congestion
The startup speed-test probe deliberately overloads the link (2 Gb/s for 800 ms) to measure capacity. The report tick is suppressed while it runs and the window anchors are rebased when it ends — but two residues leak past the rebase: probe frames still pending in the reassembler age out as frames_dropped for another ~120 ms (the loss-window fuse), and the burst can latch flush_in_window (a jump-to-live it caused itself). Either one reads as a SEVERE window: the controller backs off ×0.7 and — worse — slow start ends at the first congestion signal, permanently. That is precisely the 2026-07 field report's Automatic session: 20→14 Mb/s exactly one report tick after the probe, then a purely additive +6 %/4.5 s crawl that took the whole 4.5-minute match to reach 308 Mb/s on a link the probe had just measured at ~1.2 Gb/s. Slow start (doubling per clean window to the probed ceiling, 'seconds instead of minutes') never got to run. The first post-probe report window is now discarded outright: no LossReport (probe residue would also spike the host's adaptive FEC), the standing-latency detector closes it as not-loss-free (its clean-run just resets), and the ABR is fed nothing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b8b8ac336c |
feat(core/client): all-intra streams drain the frame channel to the newest AU
The pre-decode FrameChannel is strictly FIFO because H.26x reference chains forbid mid-stream drops — falling behind is only recoverable via the coarse jump-to-live thresholds (depth ≥6 sustained, or 400 ms behind the capture clock) at the cost of a keyframe round-trip. PyroWave has no reference chains: every AU decodes independently, so a slow consumer can skip straight to the newest queued AU with zero recovery cost. The pump now flags the channel all-intra for PyroWave sessions and pop() drains to the newest, which caps any standing pre-decode queue at ~1 frame structurally — the 2026-07 field report's 780M client could otherwise ratchet a real multi-frame backlog (and with it the OSD latency) between those coarse thresholds. Skips are counted separately from losses (they were delivered, just superseded) and surfaced at debug on the report tick — the OSD 'lost' line keeps meaning wire loss. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6d1baa0add |
fix(pf-encode/pyrowave): the bitrate pin holds on the WIRE, not the raw bitstream
A datagram-aligned PyroWave session inflates the codec bitstream ×1.2–1.3 on its way to the wire — greedy packing of few-hundred-byte atomic block packets into 1408 B windows zero-pads most window tails, plus the 4-byte prefixes and FRAG chains. The 2026-07 field report's 1440p60 10-bit "Automatic" pin of 407 Mb/s put a measured 550 Mb/s on a 1 GbE link; nothing enforced the pin past the rate controller. New shared WireBudget (pyrowave_wire.rs, both backends): tracks the real per-frame AU/bitstream ratio as a ×1024 fixed-point EMA (prior ×1.25, weight 1/8, clamped ×1.0–×2.0) and deflates the budget handed to pyrowave's rate control by it, so the windowed AU lands on the configured rate. Sealed-datagram framing (+4.5%) and FEC parity stay uncompensated — H.26x sessions carry those on top of the configured bitrate too, and the pin must mean the same thing for every codec. Dense (non-chunked) sessions are untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
53640b8754 |
fix(core/clock): re-sync survives loaded links — floor baseline, spaced rounds, bounded staleness
The mid-stream clock re-sync starved on high-bitrate LAN sessions (2026-07 PyroWave-sawtooth field report, RX 9070 XT -> 780M @ 550 Mb/s): every batch was judged against the CONNECT-TIME RTT, measured before the video data plane existed, with a 2 ms floor — mid-stream control RTTs on a loaded GbE link sit above that almost permanently, so batches were rejected for minutes while the wall clocks drifted apart and the OSD e2e figure ramped 19->150 ms before snapping back on a lucky batch. Three changes: - Rounds are spaced 7 ms apart (stamped at send time, so the spacing never lands in the RTT). An 8-round batch used to complete inside ONE ~6 ms video burst — all rounds sampled the same congestion state; spacing walks them across the frame cycle so the min-RTT round finds a quiet gap. - ResyncGuard replaces the static baseline: the guard band follows the best RTT the session has evidenced (connect RTT, then min over every completed batch — rejected ones included). - Rejection streaks are bounded: after 3 consecutive rejections the best (min-RTT) batch of the streak is applied anyway. Its queueing bias is at most ~half its RTT; unbounded wall-clock drift costs more per minute. Rejections now log at warn with the streak and floor — the starvation signature is grep-able in exactly the logs users send. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0323158a3e |
fix(vdisplay/gamescope): a fresh dedicated launch streams from the first second
audit / bun-audit (push) Failing after 20s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 31s
ci / bench (push) Successful in 6m35s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 7m50s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
arch / build-publish (push) Canceled after 7m36s
deb / build-publish (push) Canceled after 4m26s
ci / web (push) Successful in 1m8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
deb / build-publish-host (push) Canceled after 6m54s
deb / build-publish-client-arm64 (push) Canceled after 6m48s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 40s
flatpak / build-publish (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
apple / swift (push) Successful in 5m32s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 13s
audit / cargo-audit (push) Successful in 2m54s
ci / docs-site (push) Successful in 1m25s
release / apple (push) Canceled after 1m57s
decky / build-publish (push) Successful in 45s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 25s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 39s
windows-host / winget-source (push) Canceled after 0s
windows-host / package (push) Canceled after 6m34s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
A freshly spawned headless gamescope never delivered a single PipeWire buffer when a game was launched: gamescope only composites (and only on a composite pushes a capture buffer) when a client paints, and a nested Steam bootstrap paints nothing until its UI's first frame — far longer than the 10 s first-frame budget. The native plane's retry ladder then killed the half-booted Steam on every attempt and started from zero; the GameStream plane died on its single wait. Reused live displays worked, which pointed away from the real cause (root-caused on .41 with a raw pw_stream probe: `sleep infinity` nested → 0 buffers ever, `vkcube` nested → 60/s immediately). Every bare spawn now backgrounds the host's own splash client (`gamescope-splash`, hidden subcommand) beside the nested app: a dark window with a subtle breathing bar painted at ~2.5 Hz — damage from the first second, so capture gets its first frame within the budget on both planes. In `--steam` mode gamescope composites only windows whose appid is in the root `GAMESCOPECTRL_BASELAYER_APPID` list (live-proven: even a painting vkcube gets zero composites without it), so the splash declares itself as the Steam UI (STEAM_GAME=769) and seeds the baselayer iff unset; Steam's own rewrite at game launch hands composite focus to the game with no action on our side. PUNKTFUNK_GAMESCOPE_SPLASH=0 is the escape hatch. The dedicated Steam launch is also shaped with `-gamepadui` instead of `-silent`: the nested Steam is Big Picture — the identity gamescope's `--steam` integration is built around — so the boot shows the gamepad UI instead of the desktop client window flashing through the stream, and gamescope's focus rules (game outranks the Steam UI appid) cover what `-silent` was working around. On-glass on .41 (Bazzite, RTX 5070 Ti, gamescope c31743d): cold connect + Balatro launch delivers frames on the first attempt on both a cold and a torn-down-then-fresh spawn; Big Picture boot → game handover confirmed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |