Compare commits

..
21 Commits
Author SHA1 Message Date
enricobuehler 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
apple / screenshots (push) In progress
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
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)
2026-07-28 17:01:59 +02:00
enricobuehler 1db8f7631b fix(nix): move the bun packages to bun2nix — no more hand-bumped deps hash
`nix build .#punktfunk-web` has been broken since 1e9957d9 re-resolved
web/bun.lock: the console's node_modules came from a fixed-output derivation
whose single aggregate `outputHash` was last refreshed in 4094f620, so every
lockfile change silently invalidated it and the fix required a round-trip on a
Linux nix box (build, read the `got:` hash, paste it back). The runner
(sdk/bun.lock) had the same latent trap.

Replace both FODs with bun2nix (github:nix-community/bun2nix, pinned to 2.1.2).
`fetchBunDeps` turns a generated, committed `bun.nix` into bun's global install
cache — ONE `fetchurl` per package, keyed by the integrity hash already in the
lockfile — and the setup hook then runs a fully offline `bun install` in
`bunRoot`. There is no aggregate hash left to go stale. The `@unom` scope needs
no special handling: bun.lock records those tarballs' full git.unom.io URLs and
the registry is read-public.

`bun.nix` keeps itself in step: `bun2nix` is now a devDependency of both
packages and regenerates the file on every `bun install` — web via
`postinstall`, the SDK via `prepare`, because sdk/ is the published
@punktfunk/host package and a postinstall would fire on consumers' installs.
Both the flake input and the npm devDependency are pinned to the same exact
version; `bun.nix` has no schema stability guarantee across bun2nix releases, so
they move together (README documents this).

Dropped along the way: the manual `cp -R ${deps}/node_modules` + `chmod -R u+w`
+ `patchShebangs web/node_modules` dance, since bun2nix patches shebangs inside
the cache. `dontUseBunPatch` keeps the hook from running `patchShebangs .` over
the whole repo checkout (it would rewrite scripts/web-init.sh, which we ship
verbatim); `dontRunLifecycleScripts` preserves the old `--ignore-scripts`
behaviour, so playwright still never tries to download browsers.

Verified on a Linux nix box (Determinate Nix 3.21.5): `.#punktfunk-web` and
`.#punktfunk-scripting` both build green, offline; the i18n guard reports its
421 compiled messages, the `Bun.serve` bundle guard passes, and
`nix run .#punktfunk-scripting -- --list` discovers an installed plugin.
`nix flake show --all-systems` evaluates every output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 4cfe7f05ee608868857be9e7eec079044448a965)
2026-07-28 17:01:59 +02:00
enricobuehler 0d8862457b style(nix): run the repo's own nix fmt (nixfmt-rfc-style) over the flake
flake.nix, packaging/nix/packages.nix and packaging/nix/nixos-module.nix had
drifted from the formatter the flake itself declares (`formatter =
nixfmt-rfc-style`). Pure reformat — no expression changes — split out so the
bun2nix migration that follows is reviewable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 927b27c4fade6d646e3ef822678b6738f1e2f28a)
2026-07-28 17:01:59 +02:00
enricobuehler 347c106498 fix(packaging/tray): the .deb must build the tray in its own cargo invocation
punktfunk-tray panicked at every launch on Debian/Ubuntu:

  zbus-5.16.0/src/abstractions/executor.rs:190
  there is no reactor running, must be called from the context of a Tokio 1.x runtime

Not a code bug — cargo feature unification. zbus picks its executor from
its own feature flags; the host's ashpd enables zbus/tokio while the tray
runs ksni's async-io executor with no tokio runtime by design. Features
are additive across everything built in ONE invocation, and deb.yml built
`-p punktfunk-host -p punktfunk-tray` together, handing the tray a
tokio-flavoured zbus with no runtime anywhere near it.

The invariant is already established and explained inline in the RPM spec,
the Arch PKGBUILD and the Nix packages.nix — all three build the tray
alone, and their comments even claim "(Same split the .deb does.)" The
.deb did not, in two places at once: the workflow co-built it, and
build-deb.sh's own correct standalone build was skipped by an
`if [ ! -x "$TRAY_BIN" ]` guard, so it packaged the poisoned artifact.
That is also why only Debian/Ubuntu users saw it.

Drops the tray from the workflow's host build and makes build-deb.sh's
tray build unconditional — cargo no-ops when the artifact is already
async-io-resolved and rebuilds it when it is not, so a stale co-built
binary can no longer be shipped. Corrects the Nix README note that had
recorded deb/rpm/arch as sharing a "latent" crash, and its nix develop
recipe, which co-built all four.

Verified on a Linux box: co-built reproduces the panic exactly; built
alone, the tray reaches the session bus and exits with the intended
"no StatusNotifier tray available".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit f91983a84c56464bd2c18d537963cb7fb3a6e059)
2026-07-28 17:01:59 +02:00
enricobuehler 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)
2026-07-28 17:01:59 +02:00
enricobuehler 3b11288c97 fix(web): unsaved custom display settings are visible and recoverable
Everything else on the Displays page auto-applies — a preset click, the
game-session choice, the experimental toggles — but the Custom block does
not, and its Save button sat at the bottom of a block taller than most
viewports. People edited, never scrolled far enough to find it, navigated
away, and silently lost the lot.

The draft is now compared against the last-seeded server value, and that
one boolean drives the whole affordance: a badge in the card header
(visible without scrolling), an amber ring plus a title on the block, and
a sticky action bar pinned to the viewport for as long as any part of the
block is on screen. The bar states which of the two states you are in
rather than leaving it implied, Save disables when there is nothing to
save, and Discard changes puts the stored policy back.

Two ways edits could still vanish are closed: a preset click now confirms
before overwriting pending edits, and a reload/close warns. Re-picking
Custom while already on Custom is a no-op instead of re-seeding, which
would otherwise fill in defaults for unset fields and report "unsaved
changes" for a click that changed nothing.

Adds a `warning` badge variant + --warning token for the pending state —
attention, not failure, and distinct from the destructive red.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 744467d13a28b91ba88a23d6038da70263e9b502)
2026-07-28 17:01:59 +02:00
enricobuehler 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)
2026-07-28 17:01:59 +02:00
enricobuehler 40714317a8 fix(web): empty-state cards get their top padding back
"No games found yet." sat flush against the top edge of its card on any
screen ≥640px. The call sites overrode CardContent's padding with a bare
`p-8`, but tailwind-merge only resolves conflicts within a variant: `p-8`
cancels `p-4` and leaves `sm:p-6 sm:pt-0` standing, so the desktop
breakpoint kept the zero top inset that exists for cards WITH a header.
These three have none.

Uses `flush` — the escape hatch CardContent documents for exactly this —
so the padding is owned outright at every breakpoint instead of fought
from the outside. The library grid is the one that was reported; the
store catalogue and the recordings list are the same idiom with the same
bug. PairedDevices deliberately keeps its `p-6`: it is a table under a
header, where pt-0 is the intended look.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 71fc47f32af7c74327a26425035298f8e6464c96)
2026-07-28 17:01:59 +02:00
enricobuehler 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)
2026-07-28 17:01:59 +02:00
enricobuehlerandClaude Opus 5 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>
2026-07-28 16:54:40 +02:00
enricobuehlerandClaude Fable 5 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>
2026-07-28 13:15:36 +02:00
enricobuehlerandClaude Fable 5 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>
2026-07-28 13:01:46 +02:00
enricobuehlerandClaude Fable 5 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>
2026-07-28 12:59:15 +02:00
enricobuehlerandClaude Fable 5 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>
2026-07-28 12:53:43 +02:00
enricobuehlerandClaude Fable 5 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>
2026-07-28 12:52:42 +02:00
enricobuehlerandClaude Fable 5 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>
2026-07-28 12:49:42 +02:00
enricobuehlerandClaude Fable 5 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>
2026-07-28 12:47:54 +02:00
enricobuehlerandClaude Fable 5 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>
2026-07-28 12:43:39 +02:00
enricobuehlerandClaude Fable 5 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>
2026-07-28 12:40:37 +02:00
enricobuehlerandClaude Sonnet 5 c4e80fd455 docs(release): v0.21.0 notes should only cover its own delta
apple / swift (push) Successful in 1m33s
android / android (push) Successful in 12m23s
arch / build-publish (push) Successful in 13m11s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m5s
ci / bench (push) Successful in 5m47s
ci / rust-arm64 (push) Successful in 9m23s
ci / rust (push) Successful in 23m7s
apple / screenshots (push) Successful in 21m43s
deb / build-publish (push) Successful in 8m56s
deb / build-publish-client-arm64 (push) Successful in 7m24s
decky / build-publish (push) Successful in 32s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
deb / build-publish-host (push) Successful in 9m55s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7m52s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m36s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10m55s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5m32s
docker / build-push-arm64cross (push) Successful in 4m23s
docker / deploy-docs (push) Successful in 31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m39s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m8s
Duplicated the entire v0.20.1 fix bundle verbatim instead of
following house convention (each vX.Y.Z.md covers only what changed
since the immediately preceding release file — see v0.19.1 -> v0.19.2,
neither restates the other). v0.20.1 is a real, already-tagged
release with its own notes file; 0.21.0 only needed to add the
monitor-streaming feature, the KDE registry fix found while building
it, and the CI-only winget fix, with a pointer back to v0.20.1 for
the rest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 09:08:47 +02:00
97 changed files with 10676 additions and 1642 deletions
+16
View File
@@ -26,6 +26,22 @@ jobs:
apt-get update apt-get update
apt-get install -y --no-install-recommends libgtk-4-dev libadwaita-1-dev libsdl3-dev apt-get install -y --no-install-recommends libgtk-4-dev libadwaita-1-dev libsdl3-dev
# The committed pf-zerocopy SPIR-V blobs are pulled in with include_bytes! and rebuilt only
# by hand — edit a .comp, forget the rebuild, and the OLD kernel ships with no compile error
# or failing test. Recompile each shader and diff the disassembly. Filtering OpSourceExtension
# (+ --no-header) is exactly what absorbs the shaderc-vs-glslang generator difference; every
# instruction, ID and constant must match.
- name: Shader SPIR-V drift gate (pf-zerocopy)
run: |
apt-get install -y --no-install-recommends glslang-tools spirv-tools
for s in rgb2nv12_buf cursor_blend; do
d=crates/pf-zerocopy/src/imp
glslangValidator -V "$d/$s.comp" -o "/tmp/$s.spv" >/dev/null
diff <(spirv-dis --no-header "$d/$s.spv" | grep -v OpSourceExtension) \
<(spirv-dis --no-header "/tmp/$s.spv" | grep -v OpSourceExtension) \
|| { echo "::error::$d/$s.spv is stale — rebuild it from $s.comp"; exit 1; }
done
# Best-effort caches (act_runner's built-in cache server). Keyed on Cargo.lock: # Best-effort caches (act_runner's built-in cache server). Keyed on Cargo.lock:
# registry/git are download caches, target/ the incremental build. The target key # registry/git are download caches, target/ the incremental build. The target key
# carries the rustc version — resolved via `rustc --version` (below) rather than parsed # carries the rustc version — resolved via `rustc --version` (below) rather than parsed
+11 -4
View File
@@ -234,11 +234,18 @@ jobs:
git config --global --add safe.directory "$PWD" git config --global --add safe.directory "$PWD"
# Same features the old combined build used: --nvenc (direct-SDK NVENC, real RFI on NVIDIA; # Same features the old combined build used: --nvenc (direct-SDK NVENC, real RFI on NVIDIA;
# NVENC/CUDA is dlopen'd — no link dep, so this image needs no libcuda stub) + --vulkan-encode # NVENC/CUDA is dlopen'd — no link dep, so this image needs no libcuda stub) + --vulkan-encode
# (raw VK_KHR_video_encode_h265 on AMD/Intel, pure ash). punktfunk-tray also ships in the host # (raw VK_KHR_video_encode_h265 on AMD/Intel, pure ash). ffmpeg-sys-next links the image's
# .deb (build-deb.sh builds+installs it). ffmpeg-sys-next links the image's bundled FFmpeg 8 # bundled FFmpeg 8 via PKG_CONFIG_PATH (set in rust-ci-noble).
# via PKG_CONFIG_PATH (set in rust-ci-noble). #
# punktfunk-tray is deliberately NOT in this invocation — build-deb.sh builds it separately,
# and that split is load-bearing (see the identical note in the RPM spec / Arch PKGBUILD):
# cargo unifies features across one build, so co-building the tray with the host pulls the
# host's ashpd -> zbus/tokio onto the tray's shared zbus and the tray panics at every launch
# with "there is no reactor running, must be called from the context of a Tokio 1.x runtime".
# It WAS listed here, which is why only the .deb shipped a crashing tray while the RPM and
# Arch packages — which already split it — were fine.
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \ cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host -p punktfunk-tray -p punktfunk-host
- name: Build host .deb (FFmpeg bundled) - name: Build host .deb (FFmpeg bundled)
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the # BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
+2 -2
View File
@@ -153,9 +153,9 @@ jobs:
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` + # `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a # `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
# toolchain-only probe crate and is excluded.) # toolchain-only probe crate and is excluded.)
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
- name: cargo fmt --check the safe-layer + gamepad/mouse drivers - name: cargo fmt --check the safe-layer + gamepad/mouse drivers
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse --check run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse --check
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build - name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
run: | run: |
# explicit --target (.cargo/config.toml) -> output under the triple subdir. # explicit --target (.cargo/config.toml) -> output under the triple subdir.
+29 -1
View File
@@ -104,6 +104,13 @@ struct CaptureSignals {
/// Set once the stream negotiated one of the 10-bit PQ formats, i.e. frames really are /// Set once the stream negotiated one of the 10-bit PQ formats, i.e. frames really are
/// PQ/BT.2020 — drives `hdr_meta`. /// PQ/BT.2020 — drives `hdr_meta`.
hdr_negotiated: Arc<AtomicBool>, hdr_negotiated: Arc<AtomicBool>,
/// Set by the PipeWire thread once it ACTUALLY advertised the EGL→CUDA dmabuf-only offer
/// (importer constructed, importable modifiers found, not the raw passthrough, not HDR).
/// `plan.build_importer` alone cannot answer this — the importer may fail to construct (no
/// CUDA on this box), in which case no dmabuf was offered and a negotiation timeout must NOT
/// latch the GPU offer off. Read by `next_frame`'s timeout diagnosis, the EGL→CUDA twin of
/// the raw-passthrough arm.
gpu_dmabuf_offer: Arc<AtomicBool>,
/// The LIVE cursor overlay, published from every buffer's `SPA_META_Cursor` — including the /// The LIVE cursor overlay, published from every buffer's `SPA_META_Cursor` — including the
/// cursor-only "corrupted" buffers that never become frames — so the encode loop's forwarder /// cursor-only "corrupted" buffers that never become frames — so the encode loop's forwarder
/// tracks pointer-only motion on a static desktop (the frame-attached overlay alone goes stale /// tracks pointer-only motion on a static desktop (the frame-attached overlay alone goes stale
@@ -123,6 +130,7 @@ impl CaptureSignals {
streaming: Arc::new(AtomicBool::new(false)), streaming: Arc::new(AtomicBool::new(false)),
broken: Arc::new(AtomicBool::new(false)), broken: Arc::new(AtomicBool::new(false)),
hdr_negotiated: Arc::new(AtomicBool::new(false)), hdr_negotiated: Arc::new(AtomicBool::new(false)),
gpu_dmabuf_offer: Arc::new(AtomicBool::new(false)),
cursor_live: Arc::new(std::sync::Mutex::new(None)), cursor_live: Arc::new(std::sync::Mutex::new(None)),
frame_size: Arc::new(std::sync::atomic::AtomicU64::new(0)), frame_size: Arc::new(std::sync::atomic::AtomicU64::new(0)),
} }
@@ -444,6 +452,7 @@ fn spawn_pipewire(
native_nv12_session: policy.native_nv12_session, native_nv12_session: policy.native_nv12_session,
raw_dmabuf_import_disabled: pf_zerocopy::raw_dmabuf_import_disabled(), raw_dmabuf_import_disabled: pf_zerocopy::raw_dmabuf_import_disabled(),
gpu_import_disabled: pf_zerocopy::gpu_import_disabled(), gpu_import_disabled: pf_zerocopy::gpu_import_disabled(),
gpu_dmabuf_negotiation_failed: pf_zerocopy::gpu_dmabuf_negotiation_disabled(),
// Default ON; `PUNKTFUNK_PIPEWIRE_NV12=0` (or any falsy spelling — the shared parser, not a // Default ON; `PUNKTFUNK_PIPEWIRE_NV12=0` (or any falsy spelling — the shared parser, not a
// bare `!= "0"` string compare) restores the packed-RGB negotiation. // bare `!= "0"` string compare) restores the packed-RGB negotiation.
native_nv12_env_on: pf_host_config::env_on("PUNKTFUNK_PIPEWIRE_NV12").unwrap_or(true), native_nv12_env_on: pf_host_config::env_on("PUNKTFUNK_PIPEWIRE_NV12").unwrap_or(true),
@@ -721,7 +730,7 @@ impl PortalCapturer {
reconnect to stream SDR", reconnect to stream SDR",
self.node_id self.node_id
)) ))
} else if self.vaapi_dmabuf && !pf_zerocopy::vaapi_dmabuf_forced() { } else if self.vaapi_dmabuf && !pf_zerocopy::zerocopy_forced() {
// The dmabuf-only raw-passthrough offer was never accepted. Latch the // The dmabuf-only raw-passthrough offer was never accepted. Latch the
// downgrade so the encode loop's pipeline rebuild retries on the CPU offer // downgrade so the encode loop's pipeline rebuild retries on the CPU offer
// instead of failing this same negotiation forever. The latch is SCOPED to the // instead of failing this same negotiation forever. The latch is SCOPED to the
@@ -738,6 +747,25 @@ impl PortalCapturer {
rebuild will renegotiate without dmabuf", rebuild will renegotiate without dmabuf",
self.node_id self.node_id
)) ))
} else if self.signals.gpu_dmabuf_offer.load(Ordering::Relaxed)
&& !pf_zerocopy::zerocopy_forced()
{
// The EGL→CUDA dmabuf-only offer was never accepted — the twin of the raw-
// passthrough arm above (the offer the thread ACTUALLY made, per the signal
// it set — see `CaptureSignals::gpu_dmabuf_offer`). One timeout is conclusive:
// a compositor that allocates none of the importer's modifiers refuses them
// identically on every retry, so latch the offer off and let the pipeline
// rebuild renegotiate the CPU path instead of re-running this same 10 s
// timeout on every reconnect. A forced PUNKTFUNK_ZEROCOPY=1 keeps erroring
// loudly instead (same rule as the raw arm).
pf_zerocopy::note_gpu_dmabuf_negotiation_failed();
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the dmabuf-only offer (EGL→CUDA GPU import) — downgrading THIS \
offer to the CPU path for the rest of the process; the pipeline rebuild \
will renegotiate without dmabuf",
self.node_id
))
} else { } else {
Err(anyhow!( Err(anyhow!(
"no PipeWire frame within {within}s (node {}): format negotiation never \ "no PipeWire frame within {within}s (node {}): format negotiation never \
+56 -11
View File
@@ -127,6 +127,9 @@ pub(super) struct NegotiationInputs {
pub raw_dmabuf_import_disabled: bool, pub raw_dmabuf_import_disabled: bool,
/// `pf_zerocopy::gpu_import_disabled()` — repeated import-worker deaths. /// `pf_zerocopy::gpu_import_disabled()` — repeated import-worker deaths.
pub gpu_import_disabled: bool, pub gpu_import_disabled: bool,
/// `pf_zerocopy::gpu_dmabuf_negotiation_disabled()` — a previous EGL→CUDA dmabuf-only offer
/// timed out (the compositor accepts none of the importer's modifiers).
pub gpu_dmabuf_negotiation_failed: bool,
/// `PUNKTFUNK_PIPEWIRE_NV12` (default ON) — allow the producer-side NV12 preference. /// `PUNKTFUNK_PIPEWIRE_NV12` (default ON) — allow the producer-side NV12 preference.
pub native_nv12_env_on: bool, pub native_nv12_env_on: bool,
} }
@@ -154,8 +157,8 @@ pub(super) struct NegotiationPlan {
/// Diagnostic: this capture WOULD have taken the raw passthrough, but its scoped latch is /// Diagnostic: this capture WOULD have taken the raw passthrough, but its scoped latch is
/// set (the encoder repeatedly failed to import, or a previous negotiation timed out). /// set (the encoder repeatedly failed to import, or a previous negotiation timed out).
pub raw_dmabuf_latched: bool, pub raw_dmabuf_latched: bool,
/// Diagnostic: this capture WOULD have built the EGL→CUDA importer, but repeated /// Diagnostic: this capture WOULD have built the EGL→CUDA importer, but a latch fired —
/// import-worker deaths latched the GPU import off. /// repeated import-worker deaths, or a previous dmabuf-offer negotiation timeout.
pub gpu_import_latched: bool, pub gpu_import_latched: bool,
} }
@@ -176,8 +179,14 @@ pub(super) fn negotiation_plan(i: NegotiationInputs) -> NegotiationPlan {
// Building the EGL→CUDA importer would waste a CUDA probe under a raw passthrough — or // Building the EGL→CUDA importer would waste a CUDA probe under a raw passthrough — or
// worse, succeed and produce CUDA payloads only NVENC can consume. HDR is excluded by // worse, succeed and produce CUDA payloads only NVENC can consume. HDR is excluded by
// invariant 1. `gpu_import_disabled` is the repeated-worker-death latch (a wedged GPU stack // invariant 1. `gpu_import_disabled` is the repeated-worker-death latch (a wedged GPU stack
// must not crash-loop). // must not crash-loop); `gpu_dmabuf_negotiation_failed` is the offer's own timeout latch (a
let build_importer = i.zerocopy && !raw_passthrough && !i.want_hdr && !i.gpu_import_disabled; // compositor that accepts none of the importer's modifiers refuses them identically on every
// retry, so the next session negotiates the CPU path instead of re-paying the 10 s timeout).
let build_importer = i.zerocopy
&& !raw_passthrough
&& !i.want_hdr
&& !i.gpu_import_disabled
&& !i.gpu_dmabuf_negotiation_failed;
// Note there is no `importer.is_none()` term, unlike the expression this replaces: it was // Note there is no `importer.is_none()` term, unlike the expression this replaces: it was
// redundant (`build_importer` already excludes `raw_passthrough`, so the importer is // redundant (`build_importer` already excludes `raw_passthrough`, so the importer is
// necessarily absent here) and it is what made the decision look impure — the reason // necessarily absent here) and it is what made the decision look impure — the reason
@@ -200,7 +209,10 @@ pub(super) fn negotiation_plan(i: NegotiationInputs) -> NegotiationPlan {
&& !i.force_shm && !i.force_shm
&& raw_passthrough && raw_passthrough
&& i.raw_dmabuf_import_disabled, && i.raw_dmabuf_import_disabled,
gpu_import_latched: i.zerocopy && !raw_passthrough && !i.want_hdr && i.gpu_import_disabled, gpu_import_latched: i.zerocopy
&& !raw_passthrough
&& !i.want_hdr
&& (i.gpu_import_disabled || i.gpu_dmabuf_negotiation_failed),
} }
} }
@@ -321,14 +333,15 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
// attach no fence. Covers both the GPU import and the CPU mmap read below. // attach no fence. Covers both the GPU import and the CPU mmap read below.
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf { if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
match pf_zerocopy::dmabuf_fence::wait_read_ready(datas[0].fd(), 100) { match pf_zerocopy::dmabuf_fence::wait_read_ready(datas[0].fd(), 100) {
Ok(waited) => { Ok(outcome) => {
static F1: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); static F1: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
if F1.swap(false, Ordering::Relaxed) { if F1.swap(false, Ordering::Relaxed) {
tracing::info!( tracing::info!(
waited, ?outcome,
"dmabuf implicit-fence sync active (waited=true → driver fences \ "dmabuf implicit-fence sync active (Signaled → driver fences the \
the render, race closed; false → no implicit fence, zero-copy \ render, race closed; NoFence → no implicit fence, zero-copy may \
may still show stale frames)" still show stale frames; TimedOut → fence pending past 100ms, \
proceeded anyway)"
); );
} }
} }
@@ -809,7 +822,8 @@ pub fn pipewire_thread(
// the import off — see `negotiation_plan`). // the import off — see `negotiation_plan`).
if plan.gpu_import_latched { if plan.gpu_import_latched {
tracing::warn!( tracing::warn!(
"zero-copy GPU import disabled after repeated import-worker deaths — using CPU path" "zero-copy GPU import disabled for this host process (repeated import-worker deaths, \
or a previous dmabuf negotiation timeout) — using CPU path"
); );
} }
let mut importer = if plan.build_importer { let mut importer = if plan.build_importer {
@@ -861,6 +875,13 @@ pub fn pipewire_thread(
// The one runtime-dependent half of the decision (see `NegotiationPlan::want_dmabuf`): it // The one runtime-dependent half of the decision (see `NegotiationPlan::want_dmabuf`): it
// needs the modifier list the importer's construction actually yielded. // needs the modifier list the importer's construction actually yielded.
let want_dmabuf = plan.want_dmabuf(importer.is_some(), &modifiers); let want_dmabuf = plan.want_dmabuf(importer.is_some(), &modifiers);
// Record whether THIS capture really advertises the EGL→CUDA dmabuf-only offer, for the
// capturer's negotiation-timeout diagnosis (its latch must fire only for an offer that was
// actually made — `plan.build_importer` alone can't know the importer constructed).
signals.gpu_dmabuf_offer.store(
want_dmabuf && !vaapi_passthrough && !want_hdr,
Ordering::Relaxed,
);
if force_shm { if force_shm {
tracing::info!( tracing::info!(
"capture: PUNKTFUNK_FORCE_SHM — race-free SHM download path (no dmabuf, no zero-copy)" "capture: PUNKTFUNK_FORCE_SHM — race-free SHM download path (no dmabuf, no zero-copy)"
@@ -1397,6 +1418,7 @@ mod tests {
native_nv12_session: false, native_nv12_session: false,
raw_dmabuf_import_disabled: false, raw_dmabuf_import_disabled: false,
gpu_import_disabled: false, gpu_import_disabled: false,
gpu_dmabuf_negotiation_failed: false,
native_nv12_env_on: true, native_nv12_env_on: true,
} }
} }
@@ -1511,6 +1533,29 @@ mod tests {
} }
} }
/// The EGL→CUDA offer's own negotiation-timeout latch gates `build_importer` — the twin of
/// the raw passthrough's latch, so a compositor that accepts none of the importer's modifiers
/// stops being asked (previously it re-ran the same 10 s timeout on every session, forever).
/// The raw passthrough is untouched by it.
#[test]
fn gpu_dmabuf_negotiation_latch_gates_only_the_importer() {
let p = negotiation_plan(NegotiationInputs {
gpu_dmabuf_negotiation_failed: true,
..nvenc()
});
assert!(!p.build_importer, "latched offer must not be re-made");
assert!(p.gpu_import_latched, "the downgrade must be diagnosable");
let p = negotiation_plan(NegotiationInputs {
gpu_dmabuf_negotiation_failed: true,
..vaapi_native_nv12()
});
assert!(
p.vaapi_passthrough,
"the raw passthrough has its own latch — this one must not touch it"
);
assert!(!p.gpu_import_latched, "no importer was ever wanted here");
}
/// A PyroWave session on an NVIDIA box takes the raw passthrough (the wavelet encoder's own /// A PyroWave session on an NVIDIA box takes the raw passthrough (the wavelet encoder's own
/// Vulkan device imports dmabufs on any vendor) and therefore must NOT also build the /// Vulkan device imports dmabufs on any vendor) and therefore must NOT also build the
/// EGL→CUDA importer — whose payloads only NVENC can consume. /// EGL→CUDA importer — whose payloads only NVENC can consume.
+25 -4
View File
@@ -245,10 +245,31 @@ impl Drop for KeyedMutexGuard<'_> {
/// broker duplicates sensitive handles into it. The pid is driver-reported (the frame channel's /// broker duplicates sensitive handles into it. The pid is driver-reported (the frame channel's
/// [`control::AddReply::wudf_pid`], or the gamepad bootstrap's `driver_pid`); a spoofed devnode / a /// [`control::AddReply::wudf_pid`], or the gamepad bootstrap's `driver_pid`); a spoofed devnode / a
/// tampered mailbox could name an arbitrary process to receive the channel, so this is the /// tampered mailbox could name an arbitrary process to receive the channel, so this is the
/// confused-deputy gate. Best-effort image-path identity is proportionate: a fully-compromised REAL /// confused-deputy gate. `what` names the channel in the error (e.g. `"frame-channel"`); shared with
/// driver is already a channel endpoint, and any *other* process (attacker exe, a non-driver pid) /// the gamepad sealed channel (`inject/windows/gamepad_raii.rs`).
/// fails this WUDFHost image check. `what` names the channel in the error (e.g. `"frame-channel"`); ///
/// shared with the gamepad sealed channel (`inject/windows/gamepad_raii.rs`). /// # What this does and does NOT prove (security-review 2026-07-28)
///
/// It proves the target's image is the system WUDFHost binary. It does **not** prove the target is
/// *the* WUDFHost hosting our devnode, and it is not an authorization check: `WUDFHost.exe` is
/// world-executable, so anyone who can name a pid to a broker can first spawn their own copy (e.g.
/// `CREATE_SUSPENDED`, which parks it indefinitely with the right image path) and pass this check.
/// It therefore only screens out *non-WUDFHost* pids — an attacker exe, a stale/wrong devnode.
///
/// Whether that is sufficient depends entirely on who can name the pid, so it must be judged at each
/// caller, NOT here:
/// - **Frame channel** — sufficient. The pid is `AddReply::wudf_pid`, which the driver fills with its
/// own `GetCurrentProcessId()` and returns over the pf-vdisplay control device, whose DACL is
/// `D:P(A;;GA;;;SY)(A;;GA;;;BA)`. Only SYSTEM/Administrators can speak on that channel, and both are
/// out of scope by SECURITY.md.
/// - **Gamepad/mouse channel** — NOT sufficient on its own. The pid comes from a `Global\` mailbox
/// that LocalService can write, so this check does not identify the caller; see the corrected
/// analysis and the one-delivery rule in `pf-inject/src/inject/windows/gamepad_raii.rs`.
///
/// Do not add a token/session/account check here expecting it to fix the gamepad case: a genuine
/// UMDF host and a LocalService attacker's spawned WUDFHost are both session 0 and both LocalService,
/// so such checks discriminate nothing while risking a false negative that would silently kill
/// display capture and every virtual pad.
/// ///
/// # Safety /// # Safety
/// `process` must be a live process handle carrying `PROCESS_QUERY_LIMITED_INFORMATION`. /// `process` must be a live process handle carrying `PROCESS_QUERY_LIMITED_INFORMATION`.
+112 -17
View File
@@ -48,6 +48,10 @@ struct Drawn {
height: u32, height: u32,
stats: Option<String>, stats: Option<String>,
hint: Option<String>, hint: Option<String>,
/// The UI scale this was drawn at, in percent — part of the damage key so dragging the window
/// to a differently-scaled monitor re-renders the chrome at the new size instead of keeping
/// the stale one (the text is identical, so nothing else here would notice).
scale_pct: u16,
/// The start banner's alpha, quantized — a fade step is a redraw, steady is not. /// The start banner's alpha, quantized — a fade step is a redraw, steady is not.
banner_step: u8, banner_step: u8,
/// The resize scrim's spinner phase, quantized — a nonzero, ever-changing step while a /// The resize scrim's spinner phase, quantized — a nonzero, ever-changing step while a
@@ -56,6 +60,26 @@ struct Drawn {
resize_step: u16, resize_step: u16,
} }
/// The stream chrome's base metrics, in pixels at 100 % scale (96 dpi). Everything here is
/// multiplied by `FrameCtx::scale` before it is drawn: the overlay composites into the swapchain
/// 1:1 in PHYSICAL pixels, so on a 4K panel at 200 % an unscaled 14 px OSD renders at half its
/// intended physical size — legible on a 1080p monitor, a squint on a HiDPI laptop.
mod base {
/// The monospace OSD/hint/label size. Also the size the shared `Font` is built at, so the
/// scale factor below is exactly the multiplier applied to it.
pub const FONT_PX: f32 = 14.0;
/// Top-left inset of the stats panel.
pub const OSD_MARGIN: f32 = 12.0;
/// Stats-panel inner padding and corner radius.
pub const OSD_PAD_X: f32 = 10.0;
pub const OSD_PAD_Y: f32 = 8.0;
pub const OSD_RADIUS: f32 = 8.0;
/// Hint/banner pill padding and its gap from the bottom edge.
pub const PILL_PAD_X: f32 = 14.0;
pub const PILL_PAD_Y: f32 = 8.0;
pub const PILL_BOTTOM: f32 = 24.0;
}
/// Where the console starts (the session binary's `--browse` forms). /// Where the console starts (the session binary's `--browse` forms).
pub enum ConsoleEntry { pub enum ConsoleEntry {
/// The host list (bare `--browse`). /// The host list (bare `--browse`).
@@ -221,7 +245,7 @@ impl Overlay for SkiaOverlay {
skia_safe::FontStyle::normal(), skia_safe::FontStyle::normal(),
) )
.context("no monospace typeface (fontconfig alias or system family)")?; .context("no monospace typeface (fontconfig alias or system family)")?;
self.font = Some(Font::new(typeface, 14.0)); self.font = Some(Font::new(typeface, base::FONT_PX));
self.fonts = Some(crate::theme::build_fonts()?); self.fonts = Some(crate::theme::build_fonts()?);
self.gpu = Some(Gpu { self.gpu = Some(Gpu {
@@ -360,11 +384,15 @@ impl Overlay for SkiaOverlay {
self.drawn = Drawn::default(); // forget content so re-show re-renders self.drawn = Drawn::default(); // forget content so re-show re-renders
return Ok(None); return Ok(None);
} }
// 1 % granularity: fine enough that no real display scale is rounded into another, coarse
// enough that float noise on the same monitor can't churn the damage gate every frame.
let scale = ctx.scale.clamp(0.5, 4.0);
let want = Drawn { let want = Drawn {
width: ctx.width, width: ctx.width,
height: ctx.height, height: ctx.height,
stats: ctx.stats.map(str::to_owned), stats: ctx.stats.map(str::to_owned),
hint: ctx.hint.map(str::to_owned), hint: ctx.hint.map(str::to_owned),
scale_pct: (scale * 100.0).round() as u16,
banner_step, banner_step,
resize_step, resize_step,
}; };
@@ -387,16 +415,20 @@ impl Overlay for SkiaOverlay {
let canvas = slot.surface.canvas(); let canvas = slot.surface.canvas();
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0)); canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0));
// Each drawer re-derives the face at its own (fit-clamped) size rather than the canvas
// being transformed: Skia hints and rasterizes glyphs at the requested size, so this
// stays crisp where a magnified 14 px bitmap would be mush. Only on a damage redraw —
// a steady stream re-renders nothing at all.
let font = self.font.as_ref().expect("init ran"); let font = self.font.as_ref().expect("init ran");
// The resize scrim sits UNDER the OSD/hint so those stay legible over it. // The resize scrim sits UNDER the OSD/hint so those stay legible over it.
if let Some(phase) = resize_phase { if let Some(phase) = resize_phase {
draw_resize_scrim(canvas, font, ctx.width, ctx.height, phase); draw_resize_scrim(canvas, font, ctx.width, ctx.height, phase, scale);
} }
if let Some(stats) = &want.stats { if let Some(stats) = &want.stats {
draw_osd_panel(canvas, font, stats, 12.0, 12.0); draw_osd_panel(canvas, font, stats, ctx.width, scale);
} }
if let Some(hint) = &want.hint { if let Some(hint) = &want.hint {
draw_hint_pill(canvas, font, hint, ctx.width, ctx.height, 1.0); draw_hint_pill(canvas, font, hint, ctx.width, ctx.height, 1.0, scale);
} else if banner_step > 0 { } else if banner_step > 0 {
// The start banner: the leave/stats shortcuts, fading out on its own — // The start banner: the leave/stats shortcuts, fading out on its own —
// discoverable without the stats overlay, gone before it annoys. // discoverable without the stats overlay, gone before it annoys.
@@ -408,6 +440,7 @@ impl Overlay for SkiaOverlay {
ctx.width, ctx.width,
ctx.height, ctx.height,
banner_alpha as f32, banner_alpha as f32,
scale,
); );
} }
} }
@@ -543,25 +576,61 @@ impl SkiaOverlay {
} }
} }
/// The stats OSD: a translucent rounded panel, one text line per `\n` (the GTK OSD's /// The chrome face at `scale`. `with_size` only fails on a nonsensical size (the caller clamps),
/// look, minus the toolkit). /// in which case the unscaled face is still better than no text.
fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) { fn chrome_font(font: &Font, scale: f32) -> Font {
let (_, metrics) = font.metrics(); font.with_size(base::FONT_PX * scale)
let line_h = metrics.descent - metrics.ascent + metrics.leading; .unwrap_or_else(|| font.clone())
}
/// Shrink `scale` until a box of `width_at_scale` (which must be linear in the scale — every
/// chrome metric is) fits in `budget`. Scaling text up by the display's DPI is only an
/// improvement while the result still fits the window: the capture hint is a ~150-character line
/// that already spans most of a 1280 px window at 100 %, so at 200 % it would run off both edges
/// and lose its ends. Fitting keeps it whole, just smaller than the nominal scale.
fn fit_scale(scale: f32, width_at_scale: f32, budget: f32) -> f32 {
if width_at_scale > budget && width_at_scale > 0.0 {
(scale * budget / width_at_scale).max(0.1)
} else {
scale
}
}
/// The stats OSD: a translucent rounded panel in the top-left, one text line per `\n` (the GTK
/// OSD's look, minus the toolkit), sized for the display's UI `scale`.
fn draw_osd_panel(canvas: &Canvas, base_font: &Font, text: &str, width: u32, scale: f32) {
let lines: Vec<&str> = text.lines().collect(); let lines: Vec<&str> = text.lines().collect();
// Panel width is linear in the scale, so measuring once at the requested scale is enough to
// solve for the scale that keeps the Detailed tier's long lines inside the window instead of
// running them past the right edge on a HiDPI display.
let width_at = |s: f32| {
let font = chrome_font(base_font, s);
let widest = lines let widest = lines
.iter() .iter()
.map(|l| font.measure_str(l, None).0) .map(|l| font.measure_str(l, None).0)
.fold(0.0f32, f32::max); .fold(0.0f32, f32::max);
let (pad_x, pad_y) = (10.0, 8.0); widest + 2.0 * (base::OSD_PAD_X + base::OSD_MARGIN) * s
};
let scale = fit_scale(scale, width_at(scale), width as f32);
let font = chrome_font(base_font, scale);
let (_, metrics) = font.metrics();
let line_h = metrics.descent - metrics.ascent + metrics.leading;
let widest = lines
.iter()
.map(|l| font.measure_str(l, None).0)
.fold(0.0f32, f32::max);
let (pad_x, pad_y) = (base::OSD_PAD_X * scale, base::OSD_PAD_Y * scale);
let (x, y) = (base::OSD_MARGIN * scale, base::OSD_MARGIN * scale);
let panel = Rect::from_xywh( let panel = Rect::from_xywh(
x, x,
y, y,
widest + 2.0 * pad_x, widest + 2.0 * pad_x,
line_h * lines.len() as f32 + 2.0 * pad_y, line_h * lines.len() as f32 + 2.0 * pad_y,
); );
let radius = base::OSD_RADIUS * scale;
canvas.draw_rrect( canvas.draw_rrect(
RRect::new_rect_xy(panel, 8.0, 8.0), RRect::new_rect_xy(panel, radius, radius),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None), &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None),
); );
let text_paint = Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None); let text_paint = Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None);
@@ -569,7 +638,7 @@ fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) {
canvas.draw_str( canvas.draw_str(
line, line,
Point::new(x + pad_x, y + pad_y - metrics.ascent + line_h * i as f32), Point::new(x + pad_x, y + pad_y - metrics.ascent + line_h * i as f32),
font, &font,
&text_paint, &text_paint,
); );
} }
@@ -581,7 +650,16 @@ fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) {
/// window. This is the presenter's analog of the Apple client's blur overlay: the overlay /// window. This is the presenter's analog of the Apple client's blur overlay: the overlay
/// composites its own RGBA quad and cannot sample the video to blur it, so an opaque scrim /// composites its own RGBA quad and cannot sample the video to blur it, so an opaque scrim
/// hides the stretched in-between frame instead (same intent, one draw). /// hides the stretched in-between frame instead (same intent, one draw).
fn draw_resize_scrim(canvas: &Canvas, font: &Font, width: u32, height: u32, phase: f64) { fn draw_resize_scrim(
canvas: &Canvas,
base_font: &Font,
width: u32,
height: u32,
phase: f64,
scale: f32,
) {
// Short, centered label — it always fits, so it just takes the display scale as-is.
let font = &chrome_font(base_font, scale);
let (wf, hf) = (width as f32, height as f32); let (wf, hf) = (width as f32, height as f32);
canvas.draw_rect( canvas.draw_rect(
Rect::from_wh(wf, hf), Rect::from_wh(wf, hf),
@@ -602,16 +680,33 @@ fn draw_resize_scrim(canvas: &Canvas, font: &Font, width: u32, height: u32, phas
); );
} }
/// The capture hint / start banner: a centered pill near the bottom edge. /// The capture hint / start banner: a centered pill near the bottom edge. `scale` = the display's
fn draw_hint_pill(canvas: &Canvas, font: &Font, text: &str, width: u32, height: u32, alpha: f32) { /// UI scale (the text size already rides in `font`).
fn draw_hint_pill(
canvas: &Canvas,
base_font: &Font,
text: &str,
width: u32,
height: u32,
alpha: f32,
scale: f32,
) {
// The capture hint is one long line that already fills most of a 1280 px window at 100 %;
// scaled by a 2× display it would overrun both edges, so fit it to the window (a 4 % gutter
// keeps it off the very edge).
let pill_w =
|s: f32| chrome_font(base_font, s).measure_str(text, None).0 + 2.0 * base::PILL_PAD_X * s;
let scale = fit_scale(scale, pill_w(scale), width as f32 * 0.96);
let font = &chrome_font(base_font, scale);
let (_, metrics) = font.metrics(); let (_, metrics) = font.metrics();
let line_h = metrics.descent - metrics.ascent; let line_h = metrics.descent - metrics.ascent;
let text_w = font.measure_str(text, None).0; let text_w = font.measure_str(text, None).0;
let (pad_x, pad_y) = (14.0, 8.0); let (pad_x, pad_y) = (base::PILL_PAD_X * scale, base::PILL_PAD_Y * scale);
let w = text_w + 2.0 * pad_x; let w = text_w + 2.0 * pad_x;
let h = line_h + 2.0 * pad_y; let h = line_h + 2.0 * pad_y;
let x = (width as f32 - w) / 2.0; let x = (width as f32 - w) / 2.0;
let y = height as f32 - h - 24.0; let y = height as f32 - h - base::PILL_BOTTOM * scale;
canvas.draw_rrect( canvas.draw_rrect(
RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0), RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62 * alpha), None), &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62 * alpha), None),
+358 -11
View File
@@ -681,7 +681,7 @@ pub mod frame {
}; };
} }
/// Gamepad shared-memory layouts (host ↔ the UMDF gamepad drivers `pf_xusb` / `pf_dualsense`). /// Gamepad shared-memory layouts (host ↔ the UMDF gamepad drivers `pf_xusb` / `pf_gamepad`).
/// ///
/// These were hand-duplicated as `OFF_*`/`SHM_*` constants in `inject/{gamepad,dualsense}_windows.rs` /// These were hand-duplicated as `OFF_*`/`SHM_*` constants in `inject/{gamepad,dualsense}_windows.rs`
/// and (as bare literals — `*view.add(140)`) in the standalone `xusb-driver`/`dualsense-driver` /// and (as bare literals — `*view.add(140)`) in the standalone `xusb-driver`/`dualsense-driver`
@@ -699,12 +699,12 @@ pub mod gamepad {
/// XUSB section magic — the exact u32 the shipped host + `pf_xusb` driver compare (loosely "PFXU"). /// XUSB section magic — the exact u32 the shipped host + `pf_xusb` driver compare (loosely "PFXU").
pub const XUSB_MAGIC: u32 = 0x5558_4650; pub const XUSB_MAGIC: u32 = 0x5558_4650;
/// Pad section magic — the exact u32 the shipped host + `pf_dualsense` driver compare (loosely /// Pad section magic — the exact u32 the shipped host + `pf_gamepad` driver compare (loosely
/// "PFDS"). (Note: the two magics happen to use opposite byte-order mnemonics in the legacy code; /// "PFDS"). (Note: the two magics happen to use opposite byte-order mnemonics in the legacy code;
/// only the u32 value is the contract.) /// only the u32 value is the contract.)
pub const PAD_MAGIC: u32 = 0x5046_4453; pub const PAD_MAGIC: u32 = 0x5046_4453;
/// `device_type` selector the `pf_dualsense` driver reads to pick its HID identity. The section is /// `device_type` selector the `pf_gamepad` driver reads to pick its HID identity. The section is
/// zeroed, so `0` = DualSense is the default; one driver serves every identity. /// zeroed, so `0` = DualSense is the default; one driver serves every identity.
pub const DEVTYPE_DUALSENSE: u8 = 0; pub const DEVTYPE_DUALSENSE: u8 = 0;
/// `device_type` = DualShock 4 (`VID_054C&PID_09CC` HID identity). /// `device_type` = DualShock 4 (`VID_054C&PID_09CC` HID identity).
@@ -730,7 +730,235 @@ pub mod gamepad {
/// gained `pad_index` (carved from reserved space) so the driver rejects a cross-pad delivery. /// gained `pad_index` (carved from reserved space) so the driver rejects a cross-pad delivery.
/// A v1 driver opens `Global\pf…-shm-<i>` (which no longer exists) and a v1 host never creates /// A v1 driver opens `Global\pf…-shm-<i>` (which no longer exists) and a v1 host never creates
/// the mailbox a v2 driver polls, so a mixed pairing fails closed either way. /// the mailbox a v2 driver polls, so a mixed pairing fails closed either way.
pub const GAMEPAD_PROTO_VERSION: u32 = 2; ///
/// v3: the **channel proof** ([`ChannelProof`]) — the host stopped trusting the mailbox's
/// `driver_pid` and now learns the duplication target over the DEVICE STACK instead. A v2 driver
/// answers no proof, so a v3 host refuses to deliver to it; a v2 host never asks, and a v3 driver
/// refuses the v2 handshake on the `host_proto` check. Mixed pairings fail closed both ways, with
/// the existing "update host + drivers together" diagnostic.
pub const GAMEPAD_PROTO_VERSION: u32 = 3;
// ── the channel proof (v3): who to hand the DATA section to ──────────────────────────────────
//
// WHY THIS EXISTS. Through v2 the host took the duplication target from the mailbox's
// `driver_pid`. The mailbox has to be openable by LocalService (that is what the driver's own
// WUDFHost runs as), and the delivery gate — `verify_is_wudfhost` — only checks that the named
// process's IMAGE is `%SystemRoot%\System32\WUDFHost.exe`, which is world-executable. So any
// LocalService principal, notably the deliberately de-privileged plugin runner, could spawn its
// own WUDFHost, publish that pid, and be handed the pad's DATA section: forged HID input into the
// interactive desktop (the mouse section drives a real absolute pointer) and a read of the remote
// user's controller state (security-review 2026-07-28).
//
// WHY IT HAS TO COME FROM THE DEVICE STACK. That race cannot be closed on the host side alone.
// Everything the real driver can read at LocalService — the devnode's Location, its
// `Device Parameters` key, the object namespace — an attacker at LocalService can read too, so no
// host-published secret tells the two apart. The ONE thing an attacker cannot forge is *being the
// driver bound to our devnode*: only that process answers I/O sent to the device the host itself
// created (and the host looks the device up by the instance id `SwDeviceCreate` handed back, so a
// planted look-alike devnode is not in the running). Asking the devnode "which process are you?"
// therefore yields a pid the host can trust, and the mailbox is demoted to what it always should
// have been: a rendezvous for a handle VALUE that is meaningless anywhere but in that process.
//
// Two transports, because the drivers are two different shapes:
// * `pf_xusb` is a plain UMDF2 driver that owns `GUID_DEVINTERFACE_XUSB` and dispatches its own
// IOCTLs -> [`IOCTL_PF_XUSB_GET_CHANNEL_PROOF`].
// * `pf_gamepad` / `pf_mouse` are HID minidrivers with no control device (hidclass owns the
// stack, and UMDF has no control-device objects), so the reachable read path is a HID string
// -> [`HID_STRING_INDEX_CHANNEL_PROOF`], which needs no report-descriptor change. That
// matters: the pads' descriptors, VID/PID and serials are what Steam and SDL fingerprint,
// and a new feature report there would risk the identity work this driver exists to get right.
/// Proof magic ("PFCP" — punktfunk channel proof), and the `PFCP` prefix of the text form.
pub const PROOF_MAGIC: u32 = 0x5043_4650;
/// Reserved HID string index the `pf_gamepad` / `pf_mouse` minidrivers answer with their
/// [`ChannelProof`], fetched by the host with `HidD_GetIndexedString`.
///
/// ⚠️ MEASURED UNUSABLE on .173 (Win11 26200): hidclass does not carry an arbitrary indexed-string
/// request to a UMDF HID minidriver — `HidD_GetIndexedString` failed for EVERY index, including
/// ones the driver demonstrably serves through the named wrappers. Kept because it costs one
/// failed IOCTL and is the right thing to ask first if a later Windows starts forwarding it; the
/// transports that actually work are [`PF_PAD_CONTROL_INTERFACE_GUID_U128`] (if hidclass lets it
/// through) and, for `pf_mouse`, the serial string ([`proof_is_serial_string`]).
///
/// 16-bit on purpose: both `IOCTL_HID_GET_INDEXED_STRING` and `IOCTL_HID_GET_STRING` pack their
/// argument as `(language_id << 16) | string_index`, so only the low word survives the trip and
/// both drivers mask before comparing. `0x5046` ("PF") is still far outside the 1..=255 range a
/// real USB/HID string-descriptor index can occupy, so it cannot collide with a string the OS, a
/// game, or Steam asks for.
pub const HID_STRING_INDEX_CHANNEL_PROOF: u32 = 0x5046;
// ❌ A private device interface (`WdfDeviceCreateDeviceInterface`) was tried here as a
// hidclass-independent transport for the HID minidrivers and MEASURED DEAD on .173 (Win11
// 26200): it registers and enumerates, but `CreateFile` on it is refused (ERROR_GEN_FAILURE)
// because hidclass owns `IRP_MJ_CREATE` on a devnode it is the FDO for. Do not re-try it for
// `pf_gamepad`/`pf_mouse`; see `pf_umdf_util::hid` for the full measurement.
/// The proof question itself, on whichever interface carries it.
/// `CTL_CODE(0x8000, 0x0FE0, METHOD_BUFFERED, FILE_ANY_ACCESS)`: a function code no xusb22 IOCTL
/// uses, `METHOD_BUFFERED` so the answer is a plain buffer copy, and `FILE_ANY_ACCESS` so the host
/// can ask over a `CreateFile` handle opened with NO access rights (the same way it must open a
/// HID collection). Answering it leaks nothing — a pid is not a secret, and the proof is only
/// worth anything to a process that can already duplicate handles.
pub const IOCTL_PF_GET_CHANNEL_PROOF: u32 = 0x8000_3F80;
/// Whether a driver serves its channel proof AS its HID serial-number string.
///
/// The one transport measured to work against a UMDF HID minidriver today: on .173,
/// `HidD_GetSerialNumberString` succeeds on a zero-access handle and returns the driver's own
/// text, so a proof placed there reaches the host. Enabled for **`pf_mouse` only** — its serial
/// (`PFMOUSE00`) is inert, whereas the pads' serials are what SDL and Steam dedup controllers on,
/// and Steam is already known to mangle a pad's displayed name over serial FORMAT alone.
///
/// `pf_mouse` is also the one that matters most: its section drives a real absolute pointer, so a
/// hijacked mouse channel is desktop control, where a hijacked pad channel is gamepad input.
pub const fn proof_is_serial_string(pad_kind_is_mouse: bool) -> bool {
pad_kind_is_mouse
}
/// The feature report the **PS pad identities** (DualSense / DualShock 4 / Edge) answer the
/// channel proof on.
///
/// `0x85` is already DECLARED as a Feature report in all three captured descriptors and was
/// previously unserved — the driver failed it with `STATUS_INVALID_PARAMETER`. That is what makes
/// this transport free: **no report-descriptor change**, so the VID/PID, report layout, serial and
/// product strings that Steam and SDL fingerprint are untouched, and `HidD_GetFeature` is allowed
/// through by hidclass because the id is in the descriptor. Nothing can have depended on the old
/// failure: SDL reads `0x05`/`0x09`/`0x20` (DualSense) and `0x02`/`0x12`/`0xA3` (DS4); `0x85` is
/// one of Sony's vendor reports neither it nor Steam asks for.
pub const HID_FEATURE_REPORT_CHANNEL_PROOF: u8 = 0x85;
/// The Steam Deck identity's private proof command.
///
/// The Deck descriptor declares ONE unnumbered feature report and Steam drives it as a
/// command/response protocol (`0x83` GET_ATTRIBUTES, `0xAE` GET_STRING_ATTRIBUTE); the driver
/// echoes commands it doesn't know. So the proof rides that same contract — SET_FEATURE this
/// command, then GET_FEATURE the reply — again with no descriptor change. TWO bytes, not one, so
/// a Steam command byte we haven't catalogued can never be mistaken for it.
pub const DECK_PROOF_CMD: [u8; 2] = [0xF9, 0x50];
/// What a driver answers when the host asks, over the device stack, who it is.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
pub struct ChannelProof {
/// [`PROOF_MAGIC`].
pub magic: u32,
/// The driver's [`GAMEPAD_PROTO_VERSION`].
pub proto: u32,
/// The pad index the driver read from its devnode Location — cross-checked against the pad
/// the host is delivering, so a mis-resolved devnode can't cross-wire two pads.
pub pad_index: u32,
/// `GetCurrentProcessId()` of the driver's WUDFHost: the duplication target.
pub wudf_pid: u32,
}
impl ChannelProof {
/// This driver's answer. `pad_index` comes from the devnode Location, `wudf_pid` from
/// `GetCurrentProcessId()`.
pub fn new(pad_index: u32, wudf_pid: u32) -> ChannelProof {
ChannelProof {
magic: PROOF_MAGIC,
proto: GAMEPAD_PROTO_VERSION,
pad_index,
wudf_pid,
}
}
/// Validate an answer against the pad the host is actually delivering, yielding the pid to
/// duplicate into. `Err` carries the operator-facing reason — every rejection is a refusal to
/// deliver, so the host must be able to say precisely which check failed rather than falling
/// back to a pid it cannot trust.
pub fn check(&self, expect_pad_index: u32) -> Result<u32, &'static str> {
if self.magic != PROOF_MAGIC {
return Err(
"the devnode's answer is not a punktfunk channel proof (bad magic) — \
some other driver is bound to this device",
);
}
if self.proto != GAMEPAD_PROTO_VERSION {
return Err(
"the driver bound to this devnode speaks a different gamepad protocol \
— update the host and the drivers together",
);
}
if self.pad_index != expect_pad_index {
return Err(
"the devnode answered for a DIFFERENT pad index — the interface lookup \
resolved the wrong device",
);
}
if self.wudf_pid == 0 {
return Err("the driver reported pid 0");
}
Ok(self.wudf_pid)
}
/// The 16 wire bytes of the `pf_xusb` IOCTL answer. Offered here (rather than leaving each
/// side to reach for `bytemuck`) so the driver crates need no extra dependency and both
/// sides go through one length-checked pair with [`from_bytes`](Self::from_bytes).
pub fn to_bytes(self) -> [u8; 16] {
let mut out = [0u8; 16];
out.copy_from_slice(bytemuck::bytes_of(&self));
out
}
/// Parse [`to_bytes`](Self::to_bytes). `None` if the device returned fewer bytes than a whole
/// proof — a short read must refuse the delivery, never be zero-extended into a pid.
///
/// `pod_read_unaligned`, NOT `from_bytes`: the feature-report form offsets the proof by one
/// byte (the report id sits at 0), so the slice is not 4-aligned and `from_bytes` panics on
/// it. Device I/O buffers carry no alignment guarantee either.
pub fn from_bytes(b: &[u8]) -> Option<ChannelProof> {
(b.len() >= 16).then(|| bytemuck::pod_read_unaligned::<ChannelProof>(&b[..16]))
}
/// The proof as a HID **feature report** of exactly `len` bytes: `[report_id, proof(16), 0…]`.
/// A HID feature reply carries its report id in byte 0 and is sized by the descriptor, so the
/// driver pads to whatever length the caller's buffer declares. `None` if `len` cannot hold
/// the id plus a whole proof.
pub fn to_feature_report(self, report_id: u8, len: usize) -> Option<alloc::vec::Vec<u8>> {
if len < 17 {
return None;
}
let mut out = alloc::vec![0u8; len];
out[0] = report_id;
out[1..17].copy_from_slice(&self.to_bytes());
Some(out)
}
/// Parse [`to_feature_report`](Self::to_feature_report) — skips the leading report id.
pub fn from_feature_report(b: &[u8]) -> Option<ChannelProof> {
Self::from_bytes(b.get(1..)?)
}
/// Render as the ASCII text a HID indexed-string answer carries:
/// `PFCP:<proto>:<pad_index>:<wudf_pid>`. Text rather than the raw struct because
/// `HidD_GetIndexedString` is a string channel, and because a human reading a driver log or
/// poking the device with a HID inspector should be able to see what the pad answered.
pub fn to_hid_string(self) -> String {
alloc::format!("PFCP:{}:{}:{}", self.proto, self.pad_index, self.wudf_pid)
}
/// Parse [`to_hid_string`](Self::to_hid_string). `None` on ANY deviation — a foreign string
/// index answered, a truncated read, a non-decimal field, trailing junk — so a host that
/// cannot read a well-formed proof refuses to deliver instead of guessing at a pid.
pub fn from_hid_string(s: &str) -> Option<ChannelProof> {
let rest = s.strip_prefix("PFCP:")?;
let mut it = rest.split(':');
let proto = it.next()?.parse::<u32>().ok()?;
let pad_index = it.next()?.parse::<u32>().ok()?;
let wudf_pid = it.next()?.parse::<u32>().ok()?;
if it.next().is_some() {
return None; // trailing field: not a shape we minted
}
Some(ChannelProof {
magic: PROOF_MAGIC,
proto,
pad_index,
wudf_pid,
})
}
}
/// Bootstrap-mailbox magic (`"PFBT"` LE) — the host stamps it LAST (after `host_proto`), so a /// Bootstrap-mailbox magic (`"PFBT"` LE) — the host stamps it LAST (after `host_proto`), so a
/// driver only trusts a fully-initialized mailbox. /// driver only trusts a fully-initialized mailbox.
@@ -754,16 +982,22 @@ pub mod gamepad {
/// 1. host creates it (zeroed), stamps `host_proto` then `magic` (in that order); /// 1. host creates it (zeroed), stamps `host_proto` then `magic` (in that order);
/// 2. driver opens it by name (pad index from `pszDeviceLocation`), writes `driver_proto`, and — /// 2. driver opens it by name (pad index from `pszDeviceLocation`), writes `driver_proto`, and —
/// iff `host_proto` matches its own version — publishes `driver_pid`; /// iff `host_proto` matches its own version — publishes `driver_pid`;
/// 3. host polls `driver_pid`, verifies the pid is a genuine WUDFHost, duplicates the unnamed DATA /// 3. host asks the DEVNODE who the driver is ([`ChannelProof`]) — **not** the mailbox — verifies
/// section into it, then writes `data_handle` + `handle_pid` and bumps `handle_seq` LAST; /// that pid is a genuine WUDFHost, duplicates the unnamed DATA section into it, then writes
/// `data_handle` + `handle_pid` and bumps `handle_seq` LAST;
/// 4. driver sees a fresh `handle_seq` addressed to its own pid, maps `data_handle`, and validates /// 4. driver sees a fresh `handle_seq` addressed to its own pid, maps `data_handle`, and validates
/// the mapped section's magic + `pad_index` before use. /// the mapped section's magic + `pad_index` before use.
/// ///
/// Deliberately safe to leave named + LS-openable: it carries only pids (not sensitive) and a /// **Trust boundary (v3).** This mailbox is writable by LocalService — it has to be, since that is
/// handle VALUE (meaningless outside the target WUDFHost's handle table). A sibling LocalService /// what the driver's own WUDFHost runs as — so NOTHING in it may decide where the DATA section
/// that tampers with it can at worst mis-route a delivery — a gamepad DoS, never a read or an /// goes. Through v2 `driver_pid` did decide that, which was the security-review 2026-07-28 hole
/// injection (it cannot place a valid section handle in the WUDFHost, and the driver's /// (see [`ChannelProof`]); step 3 now sources the pid from the device stack and `driver_pid` is
/// magic+`pad_index` validation rejects any handle that doesn't resolve to this pad's section). /// advisory only — a liveness/diagnostic hint. What is left here is a handle VALUE, meaningless
/// outside the one process it was minted for, plus pids and version numbers, none of them secret.
/// A LocalService tamperer can therefore still deny a pad (overwrite the fields, squat the name)
/// but can no longer read or inject: it cannot place a valid section handle in the WUDFHost, the
/// driver's magic + `pad_index` validation rejects any handle that does not resolve to this pad's
/// section, and the delivery target is no longer its to choose.
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
pub struct PadBootstrap { pub struct PadBootstrap {
@@ -942,6 +1176,12 @@ pub mod gamepad {
assert!(offset_of!(PadShm, out_ring) == PAD_SHM_LEGACY_SIZE); assert!(offset_of!(PadShm, out_ring) == PAD_SHM_LEGACY_SIZE);
assert!(size_of::<OutSlot>() == 68); assert!(size_of::<OutSlot>() == 68);
assert!(size_of::<ChannelProof>() == 16);
assert!(offset_of!(ChannelProof, magic) == 0);
assert!(offset_of!(ChannelProof, proto) == 4);
assert!(offset_of!(ChannelProof, pad_index) == 8);
assert!(offset_of!(ChannelProof, wudf_pid) == 12);
assert!(size_of::<PadBootstrap>() == 32); assert!(size_of::<PadBootstrap>() == 32);
assert!(offset_of!(PadBootstrap, magic) == 0); assert!(offset_of!(PadBootstrap, magic) == 0);
assert!(offset_of!(PadBootstrap, host_proto) == 4); assert!(offset_of!(PadBootstrap, host_proto) == 4);
@@ -1486,4 +1726,111 @@ mod tests {
const SUDOVDA: u128 = 0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D; const SUDOVDA: u128 = 0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D;
assert_ne!(PF_VDISPLAY_INTERFACE_GUID_U128, SUDOVDA); assert_ne!(PF_VDISPLAY_INTERFACE_GUID_U128, SUDOVDA);
} }
/// The channel proof is what the host trusts INSTEAD of the mailbox's `driver_pid`
/// (security-review 2026-07-28), so both wire forms — the `pf_xusb` IOCTL struct and the HID
/// indexed-string text the two minidrivers answer — have to survive a round trip byte-for-byte,
/// and every malformed shape has to be rejected rather than half-parsed into a pid the host
/// would then duplicate a live input section into.
#[test]
fn channel_proof_round_trips_in_both_wire_forms() {
use gamepad::*;
let proof = ChannelProof::new(2, 4242);
assert_eq!(proof.magic, PROOF_MAGIC);
assert_eq!(PROOF_MAGIC, u32::from_le_bytes(*b"PFCP"));
assert_eq!(proof.proto, GAMEPAD_PROTO_VERSION);
// XUSB IOCTL form: the raw 16-byte struct.
let bytes = bytemuck::bytes_of(&proof);
assert_eq!(bytes.len(), 16);
assert_eq!(*bytemuck::from_bytes::<ChannelProof>(bytes), proof);
// HID indexed-string form: the same four fields as text.
let s = proof.to_hid_string();
assert_eq!(s, alloc::format!("PFCP:{GAMEPAD_PROTO_VERSION}:2:4242"));
assert_eq!(ChannelProof::from_hid_string(&s), Some(proof));
// Every malformed shape parses to None — the host then refuses to deliver.
for bad in [
"",
"PFCP",
"PFCP:",
"PFCP:3:0", // truncated read
"PFCP:3:0:4242:9", // trailing field we never mint
"PFCP:3:0:-1", // not a u32
"PFCP:3:0:0x10", // not decimal
"PFCP:3:0: 4242", // whitespace is not trimmed away into a valid pid
"NOPE:3:0:4242", // another driver answered this string index
"pfcp:3:0:4242", // prefix is case-sensitive
] {
assert_eq!(
ChannelProof::from_hid_string(bad),
None,
"malformed proof {bad:?} must not parse"
);
}
}
/// `check` is the gate that decides whether a pid is allowed to receive a pad's whole input and
/// rumble surface, so pin each refusal: a foreign driver, a version skew, and — the one that
/// would silently cross-wire two live pads — an answer from the wrong devnode.
#[test]
fn channel_proof_check_refuses_everything_it_should() {
use gamepad::*;
assert_eq!(ChannelProof::new(0, 1234).check(0), Ok(1234));
assert_eq!(ChannelProof::new(3, 1234).check(3), Ok(1234));
// Right shape, WRONG pad: the interface lookup resolved another pad's devnode.
assert!(ChannelProof::new(1, 1234).check(0).is_err());
// A driver that isn't ours answered the reserved string index / IOCTL.
let mut foreign = ChannelProof::new(0, 1234);
foreign.magic = 0xDEAD_BEEF;
assert!(foreign.check(0).is_err());
// Version skew must fail closed, not "probably compatible".
let mut old = ChannelProof::new(0, 1234);
old.proto = GAMEPAD_PROTO_VERSION - 1;
assert!(old.check(0).is_err());
// pid 0 is never a duplication target.
assert!(ChannelProof::new(0, 0).check(0).is_err());
}
/// A v2 driver answers no proof at all and a v2 host never asks, so the version must have moved
/// — this is the tripwire that stops the two halves shipping out of step.
#[test]
fn gamepad_proto_is_at_the_channel_proof_version() {
assert_eq!(gamepad::GAMEPAD_PROTO_VERSION, 3);
}
/// The pad identities carry the proof in a HID FEATURE report — the transport chosen because
/// `0x85` is already declared in the captured descriptors, so nothing about the device's
/// Steam/SDL-visible identity changes. Pin the framing (report id in byte 0, proof in 1..17,
/// zero padding to the descriptor's length) and the short-read refusal.
#[test]
fn channel_proof_feature_report_round_trips_and_refuses_short_reads() {
use gamepad::*;
let proof = ChannelProof::new(1, 4242);
let rep = proof
.to_feature_report(HID_FEATURE_REPORT_CHANNEL_PROOF, 64)
.expect("64 bytes is plenty");
assert_eq!(rep.len(), 64);
assert_eq!(
rep[0], 0x85,
"byte 0 is the report id, as every HID feature reply is"
);
assert!(rep[17..].iter().all(|&b| b == 0), "tail is zero padding");
assert_eq!(ChannelProof::from_feature_report(&rep), Some(proof));
// Exactly big enough, and one byte too small.
assert!(proof.to_feature_report(0x85, 17).is_some());
assert!(proof.to_feature_report(0x85, 16).is_none());
// A truncated read must NOT be zero-extended into a pid.
assert_eq!(ChannelProof::from_feature_report(&rep[..16]), None);
assert_eq!(ChannelProof::from_feature_report(&[]), None);
// The Deck's private command is two bytes so a stray Steam command can't collide, and is
// distinct from the commands the driver already serves.
assert_eq!(DECK_PROOF_CMD.len(), 2);
assert!(!DECK_PROOF_CMD.starts_with(&[0x83]) && !DECK_PROOF_CMD.starts_with(&[0xAE]));
assert!(!DECK_PROOF_CMD.starts_with(&[0xEB]) && !DECK_PROOF_CMD.starts_with(&[0x8F]));
}
} }
+24 -6
View File
@@ -345,11 +345,23 @@ impl NvencEncoder {
}; };
} }
// NV12 / 4:4:4 paths: we do the RGB→YUV conversion ourselves as BT.709 (swscale), so // Colour signalling, written for EVERY session (colorspace/range/primaries/transfer) —
// signal that in the bitstream VUI (colorspace/range/primaries/transfer) — otherwise the // otherwise the client decoder assumes a default and the picture comes out washed-out /
// client decoder assumes a default and the picture comes out washed-out / wrong-contrast. // wrong-contrast. Matches the Windows NV12 path's BT.709 limited-range signalling.
// The RGB-input 4:2:0 path leaves these unset (NVENC's internal CSC writes its own VUI). //
// Matches the Windows NV12 path's BT.709 limited-range signalling. // The packed-RGB 4:2:0 path used to be excluded, on the belief that "NVENC's internal CSC
// writes its own VUI". It does not: libavcodec's nvenc wrapper derives
// `colourDescriptionPresentFlag` from these very AVCodecContext fields, so leaving them
// UNSPECIFIED produced a stream with NO colour description at all. Every punktfunk client
// then falls back to BT.709 (`csc_rows`) and looks fine, but vendor TV decoders guess from
// RESOLUTION — an LG webOS panel reads a 4K SDR stream as BT.2020 and washes it out.
// BT.709 limited is the honest answer for that path too: NVENC's internal RGB→YUV is the
// same conversion both direct-SDK backends feed from an ARGB surface
// (`nvenc_cuda.rs`/`windows/nvenc.rs`), and `nvenc_core.rs` already stamps 709-limited on
// those unconditionally. This only makes the libav sibling consistent with them.
//
// Reachable whenever the direct-SDK path is not: a CPU/dmabuf (non-CUDA) capture, a build
// without `--features nvenc`, or PUNKTFUNK_NVENC_DIRECT=0.
// //
// PUNKTFUNK_444_FULLRANGE=1 (experimental, 4:4:4-only): convert AND signal FULL range — // PUNKTFUNK_444_FULLRANGE=1 (experimental, 4:4:4-only): convert AND signal FULL range —
// recovers the ~12% of code space limited-range quantization gives up, for the exact // recovers the ~12% of code space limited-range quantization gives up, for the exact
@@ -372,7 +384,7 @@ impl NvencEncoder {
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020; (*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020;
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084; (*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084;
} }
} else if matches!(format, PixelFormat::Nv12) || want_444 { } else {
// SAFETY: same `video` builder — `raw = video.as_mut_ptr()` is the non-null, properly- // SAFETY: same `video` builder — `raw = video.as_mut_ptr()` is the non-null, properly-
// aligned, sole-owned, not-yet-opened `AVCodecContext`. We set its four VUI colour enum // aligned, sole-owned, not-yet-opened `AVCodecContext`. We set its four VUI colour enum
// fields to valid `AVColorSpace`/`AVColorRange`/`AVColorPrimaries`/`AVColorTransfer- // fields to valid `AVColorSpace`/`AVColorRange`/`AVColorPrimaries`/`AVColorTransfer-
@@ -984,6 +996,12 @@ impl Drop for QuietLibavLog {
/// takes for a live 4:4:4 stream — and reports whether it succeeded. HEVC-only; the result is cached /// takes for a live 4:4:4 stream — and reports whether it succeeded. HEVC-only; the result is cached
/// by the caller ([`crate::can_encode_444`]). A GPU/driver/ffmpeg without RExt 4:4:4 fails /// by the caller ([`crate::can_encode_444`]). A GPU/driver/ffmpeg without RExt 4:4:4 fails
/// the open here, so the host resolves the session to 4:2:0 before the Welcome (honest downgrade). /// the open here, so the host resolves the session to 4:2:0 before the Welcome (honest downgrade).
///
/// ⚠️ Only consulted when libav will really serve the session (`PUNKTFUNK_NVENC_DIRECT=0`, or a
/// build without `--features nvenc`). A direct-SDK host answers from the driver's caps bit instead
/// (`nvenc_cuda::probe_support`) — running THIS probe there mixes ffmpeg's NVENC client into a
/// direct-SDK process, which is the LOG-3 field bug: one successful `hevc_nvenc` FREXT open+close
/// wedged every later NVENC open process-wide (`NV_ENC_ERR_INVALID_VERSION`) until a host restart.
pub fn probe_can_encode_444(codec: Codec) -> bool { pub fn probe_can_encode_444(codec: Codec) -> bool {
if codec != Codec::H265 { if codec != Codec::H265 {
return false; return false;
@@ -107,6 +107,12 @@ struct EncodeApi {
*mut nv::NV_ENC_CAPS_PARAM, *mut nv::NV_ENC_CAPS_PARAM,
*mut core::ffi::c_int, *mut core::ffi::c_int,
) -> nv::NVENCSTATUS, ) -> nv::NVENCSTATUS,
// The two entry points behind [`probe_support`] — the driver's own list of encode GUIDs
// this chip exposes. Mandatory like every other entry: both have existed since NVENC 1.0, so a
// driver missing them is broken in ways the rest of this table would not survive either.
get_encode_guid_count: unsafe extern "C" fn(*mut c_void, *mut u32) -> nv::NVENCSTATUS,
get_encode_guids:
unsafe extern "C" fn(*mut c_void, *mut nv::GUID, u32, *mut u32) -> nv::NVENCSTATUS,
get_encode_preset_config_ex: unsafe extern "C" fn( get_encode_preset_config_ex: unsafe extern "C" fn(
*mut c_void, *mut c_void,
nv::GUID, nv::GUID,
@@ -168,6 +174,141 @@ fn api() -> &'static EncodeApi {
try_api().expect("NVENC call before a successful try_api() gate") try_api().expect("NVENC call before a successful try_api() gate")
} }
/// Everything the host advertisement asks of this GPU's NVENC, answered by the driver itself on
/// ONE throwaway session: the encode-GUID list (which codecs exist at all) and the HEVC 4:4:4 cap.
#[derive(Clone, Copy)]
pub(crate) struct ProbedSupport {
/// Which codecs this chip's NVENC encodes (`nvEncGetEncodeGUIDs`). All-`false` = the probe
/// could not answer — [`crate::CodecSupport::wire_mask`] turns that into `None` so the caller
/// keeps the static superset (fail open).
pub codecs: crate::CodecSupport,
/// `NV_ENC_CAPS_SUPPORT_YUV444_ENCODE` for the HEVC GUID — whether this chip can encode
/// full-chroma 4:4:4 HEVC. `false` when unanswered (fail CLOSED, unlike `codecs`: the honest
/// downgrade is a 4:2:0 session, not a dead one).
pub hevc_444: bool,
}
/// The cached [`probe_support_uncached`] answer — one throwaway session per process lifetime.
pub(crate) fn probe_support() -> ProbedSupport {
static CACHE: std::sync::OnceLock<ProbedSupport> = std::sync::OnceLock::new();
*CACHE.get_or_init(probe_support_uncached)
}
/// Which codecs **this GPU's** NVENC can actually encode — and whether HEVC can go 4:4:4 — asked
/// of the driver itself (`nvEncGetEncodeGUIDs` + `nvEncGetEncodeCaps`) instead of assumed from the
/// SDK version.
///
/// Why this exists: the host used to advertise a static `H.264 | HEVC | AV1` superset for every
/// NVIDIA box, so a chip without HEVC NVENC (1st-gen Maxwell, e.g. GTX 960M — HEVC needs 2nd-gen
/// Maxwell+, AV1 needs Ada+) still offered HEVC. A client reasonably negotiated H265 and got a dead
/// session: `hevc_nvenc` "No capable devices found", eight pipeline retries, ~15 s of blank video,
/// then a disconnect. The GUID list is a property of the chip+driver, so it is equally right for
/// the direct-SDK backend and the libav `*_nvenc` one.
///
/// ⚠️ Deliberately NOT the VAAPI probe's shape (open a tiny libav encoder per codec). That would run
/// ffmpeg's NVENC client, and mixing it with this direct-SDK client in one process is the prime
/// suspect for the open bug where one `probe_can_encode_444` open wedges NVENC **process-wide**
/// (`NV_ENC_ERR_INVALID_VERSION` on every later session until a host restart — LOG-3, Droff,
/// 0.19.2). This asks the SAME client, on the SAME shared CUDA context, that real sessions use —
/// one extra session open of a kind the encoder already performs per open (`query_caps`), cached
/// once per process by [`probe_support`]. The 4:4:4 cap rides the same session for the same
/// reason: it used to be its own libav `hevc_nvenc` FREXT open — the exact open LOG-3 caught
/// wedging NVENC — and the direct backend re-checks the same cap at session open anyway
/// (`query_caps` → `yuv444_supported`), so the caps bit is the answer the live session will obey.
///
/// Every failure path returns "nothing probed" (see the [`ProbedSupport`] field docs for the
/// per-field fail direction).
fn probe_support_uncached() -> ProbedSupport {
let unknown = ProbedSupport {
codecs: crate::CodecSupport {
h264: false,
h265: false,
av1: false,
},
hevc_444: false,
};
let Ok(api) = try_api() else {
return unknown;
};
let cu_ctx = match cuda::context() {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "NVENC codec probe: no CUDA context");
return unknown;
}
};
// SAFETY: `try_api()` returned Ok, so every fn pointer below is a live entry point from the
// driver's own function list. `params`/`enc`/`count`/`written` are live locals that outlive
// their synchronous calls; `device` is the process-shared CUDA context (`cuda::context()`
// returned Ok), the same handle `query_caps` passes. `guids` is sized to the count the driver
// just reported and its pointer is valid for that many `GUID`s, matching the
// `guidArraySize` argument. The session is destroyed on every path out — including the failed
// open, which the NVENC docs still require (the driver may have taken the slot before
// erroring; skipping it leaks toward the concurrent-session cap).
unsafe {
let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
deviceType: nv::NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_CUDA,
device: cu_ctx,
apiVersion: nv::NVENCAPI_VERSION,
..Default::default()
};
let mut enc: *mut c_void = ptr::null_mut();
if let Err(e) = (api.open_encode_session_ex)(&mut params, &mut enc).nv_ok() {
if !enc.is_null() {
let _ = (api.destroy_encoder)(enc);
}
tracing::warn!(
error = %format!("{:#}", nvenc_status::call_err("open_encode_session_ex (codec probe)", e)),
"NVENC codec probe failed — keeping the static codec advertisement"
);
return unknown;
}
// The handshake with the kernel module succeeded (same latch `query_caps` sets).
nvenc_status::note_session_opened();
let mut count = 0u32;
let counted = (api.get_encode_guid_count)(enc, &mut count).nv_ok().is_ok();
let mut guids = vec![nv::GUID::default(); count as usize];
let mut written = 0u32;
let listed = counted
&& count > 0
&& (api.get_encode_guids)(enc, guids.as_mut_ptr(), count, &mut written)
.nv_ok()
.is_ok();
guids.truncate(written as usize);
// The 4:4:4 cap needs the session that is still open — query it before the destroy. Only
// meaningful against a listed HEVC GUID (a cap query for an absent codec is undefined).
let mut hevc_444 = false;
if listed && guids.contains(&nv::NV_ENC_CODEC_HEVC_GUID) {
let mut param = nv::NV_ENC_CAPS_PARAM {
version: nv::NV_ENC_CAPS_PARAM_VER,
capsToQuery: nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE,
reserved: [0; 62],
};
let mut val: core::ffi::c_int = 0;
hevc_444 = (api.get_encode_caps)(enc, nv::NV_ENC_CODEC_HEVC_GUID, &mut param, &mut val)
.nv_ok()
.is_ok()
&& val != 0;
}
let _ = (api.destroy_encoder)(enc);
if !listed {
tracing::warn!(
"NVENC codec probe: driver listed no encode GUIDs — keeping the static advertisement"
);
return unknown;
}
ProbedSupport {
codecs: crate::CodecSupport {
h264: guids.contains(&nv::NV_ENC_CODEC_H264_GUID),
h265: guids.contains(&nv::NV_ENC_CODEC_HEVC_GUID),
av1: guids.contains(&nv::NV_ENC_CODEC_AV1_GUID),
},
hevc_444,
}
}
}
fn load_api() -> std::result::Result<EncodeApi, String> { fn load_api() -> std::result::Result<EncodeApi, String> {
// SAFETY: `Library::new` runs `libnvidia-encode.so.1`'s initializers — the trusted NVIDIA driver // SAFETY: `Library::new` runs `libnvidia-encode.so.1`'s initializers — the trusted NVIDIA driver
// library, so loading has no unexpected effects; `map_err` handles its absence (AMD/Intel/no // library, so loading has no unexpected effects; `map_err` handles its absence (AMD/Intel/no
@@ -223,6 +364,8 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
reconfigure_encoder: list.nvEncReconfigureEncoder.ok_or(MISSING)?, reconfigure_encoder: list.nvEncReconfigureEncoder.ok_or(MISSING)?,
destroy_encoder: list.nvEncDestroyEncoder.ok_or(MISSING)?, destroy_encoder: list.nvEncDestroyEncoder.ok_or(MISSING)?,
get_encode_caps: list.nvEncGetEncodeCaps.ok_or(MISSING)?, get_encode_caps: list.nvEncGetEncodeCaps.ok_or(MISSING)?,
get_encode_guid_count: list.nvEncGetEncodeGUIDCount.ok_or(MISSING)?,
get_encode_guids: list.nvEncGetEncodeGUIDs.ok_or(MISSING)?,
get_encode_preset_config_ex: list.nvEncGetEncodePresetConfigEx.ok_or(MISSING)?, get_encode_preset_config_ex: list.nvEncGetEncodePresetConfigEx.ok_or(MISSING)?,
create_bitstream_buffer: list.nvEncCreateBitstreamBuffer.ok_or(MISSING)?, create_bitstream_buffer: list.nvEncCreateBitstreamBuffer.ok_or(MISSING)?,
destroy_bitstream_buffer: list.nvEncDestroyBitstreamBuffer.ok_or(MISSING)?, destroy_bitstream_buffer: list.nvEncDestroyBitstreamBuffer.ok_or(MISSING)?,
@@ -2284,6 +2427,46 @@ mod tests {
/// and assert the next AU carries the recovery-anchor tag (the F2 fix) and that `caps()` /// and assert the next AU carries the recovery-anchor tag (the F2 fix) and that `caps()`
/// advertises RFI. Needs an NVIDIA GPU + driver. Run: /// advertises RFI. Needs an NVIDIA GPU + driver. Run:
/// cargo test -p punktfunk-host --features nvenc -- --ignored nvenc_cuda_smoke --nocapture /// cargo test -p punktfunk-host --features nvenc -- --ignored nvenc_cuda_smoke --nocapture
/// ON-HARDWARE: the codec/4:4:4 advertisement probe against the real driver. Asserts the two
/// invariants that matter for what the host advertises — every NVENC-capable GPU ever made can
/// encode H.264, so a probe that comes back with `h264 = false` while NVENC is otherwise
/// working means the enumeration itself is broken (and would silently narrow the host's
/// advertisement); and the answer must be stable across calls (asserted on the UNCACHED fn —
/// the cached [`probe_support`] would make it vacuous), since one cached answer drives every
/// negotiation. Prints the mask so a run on an OLD card (Maxwell GM107 = h264 only, no 4:4:4 —
/// the GPU this probe exists for) is self-documenting. Run:
/// cargo test -p pf-encode --features nvenc -- --ignored nvenc_codec_probe --nocapture
#[test]
#[ignore = "requires an NVIDIA GPU + driver — run manually on an NVIDIA box"]
fn nvenc_codec_probe_reports_real_gpu_support() {
let probed = probe_support_uncached();
let caps = probed.codecs;
eprintln!(
"NVENC probe: h264={} h265={} av1={} hevc_444={}",
caps.h264, caps.h265, caps.av1, probed.hevc_444
);
assert!(
caps.h264,
"every NVENC generation encodes H.264 — a false here means the GUID enumeration \
failed, which would narrow the host's codec advertisement"
);
assert!(
!probed.hevc_444 || caps.h265,
"a 4:4:4-capable HEVC that is not in the GUID list is contradictory"
);
let again = probe_support_uncached();
assert_eq!(
(caps.h264, caps.h265, caps.av1, probed.hevc_444),
(
again.codecs.h264,
again.codecs.h265,
again.codecs.av1,
again.hevc_444
),
"the probe must be stable — it is cached once and drives every later negotiation"
);
}
#[test] #[test]
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"] #[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
fn nvenc_cuda_smoke_rfi_anchor() { fn nvenc_cuda_smoke_rfi_anchor() {
+160 -6
View File
@@ -517,6 +517,28 @@ pub(super) unsafe fn build_parameters_h265(
sps.conf_win_bottom_offset = (h - rh) / 2; // 4:2:0 SubHeightC = 2 sps.conf_win_bottom_offset = (h - rh) / 2; // 4:2:0 SubHeightC = 2
} }
// Colour signalling. This backend's CSC (`rgb2yuv.comp`) is BT.709 LIMITED 8-bit and nothing
// else — `open_amd_intel` routes every HDR session to VAAPI precisely because this path
// hardcodes it — so the SPS can state it as a constant. Without the VUI the stream is
// "unspecified" and each decoder applies its own default: the punktfunk clients fall back to
// BT.709 (`pf_client_core::video_color::csc_rows`), but vendor TV decoders guess from
// RESOLUTION — an LG webOS panel reads a 4K SDR stream as BT.2020 and renders it visibly
// washed out. Every sibling backend (NVENC `nvenc_core.rs`, VAAPI, QSV, the Windows libav
// path) already signals this triplet; this one was the hole.
//
// `vui` must outlive `create_video_session_parameters_khr` below — it does, `sps_arr` only
// copies the pointer and both live to the end of this function.
let mut vui: hh::StdVideoH265SequenceParameterSetVui = std::mem::zeroed();
vui.flags.set_video_signal_type_present_flag(1);
vui.flags.set_video_full_range_flag(0); // limited/studio swing (16-235 luma)
vui.flags.set_colour_description_present_flag(1);
vui.video_format = 5; // unspecified — the CICP triplet below is what matters
vui.colour_primaries = 1; // BT.709
vui.transfer_characteristics = 1; // BT.709
vui.matrix_coeffs = 1; // BT.709
sps.flags.set_vui_parameters_present_flag(1);
sps.pSequenceParameterSetVui = &vui;
let mut pps: hh::StdVideoH265PictureParameterSet = std::mem::zeroed(); let mut pps: hh::StdVideoH265PictureParameterSet = std::mem::zeroed();
pps.flags.set_cu_qp_delta_enabled_flag(1); pps.flags.set_cu_qp_delta_enabled_flag(1);
pps.flags.set_pps_loop_filter_across_slices_enabled_flag(1); pps.flags.set_pps_loop_filter_across_slices_enabled_flag(1);
@@ -703,10 +725,19 @@ fn av1_sequence_header_obu(
w.bit(0); // enable_superres w.bit(0); // enable_superres
w.bit(0); // enable_cdef w.bit(0); // enable_cdef
w.bit(0); // enable_restoration w.bit(0); // enable_restoration
// color_config(): 8-bit 4:2:0, unspecified primaries/transfer/matrix, limited range // color_config() (AV1 spec §5.5.2): 8-bit 4:2:0, BT.709 limited — the CSC this
// backend's `rgb2yuv.comp` actually performs. AV1 has no VUI, so the CICP triplet
// lives here; omitting it (color_description_present_flag = 0) left the stream
// "unspecified" and vendor TV decoders guess colorimetry from resolution.
// CP_BT_709/TC_BT_709/MC_BT_709 avoids the spec's sRGB special case (which would
// force color_range = 1 and drop the explicit range bit), so the field order below
// is the same as the unspecified form plus the three CICP bytes.
w.bit(0); // high_bitdepth w.bit(0); // high_bitdepth
w.bit(0); // mono_chrome w.bit(0); // mono_chrome
w.bit(0); // color_description_present_flag w.bit(1); // color_description_present_flag
w.put(1, 8); // color_primaries = CP_BT_709
w.put(1, 8); // transfer_characteristics = TC_BT_709
w.put(1, 8); // matrix_coefficients = MC_BT_709
w.bit(0); // color_range (studio/limited) w.bit(0); // color_range (studio/limited)
w.put(0, 2); // chroma_sample_position = CSP_UNKNOWN (subsampling_x==subsampling_y==1 for profile 0) w.put(0, 2); // chroma_sample_position = CSP_UNKNOWN (subsampling_x==subsampling_y==1 for profile 0)
w.bit(0); // separate_uv_delta_q w.bit(0); // separate_uv_delta_q
@@ -747,18 +778,21 @@ pub(super) unsafe fn build_parameters_av1(
let seq_level_idx = max_level; // StdVideoAV1Level's numeric value IS the AV1 seq_level_idx let seq_level_idx = max_level; // StdVideoAV1Level's numeric value IS the AV1 seq_level_idx
// ---- Std sequence header (must match the OBU packed below) ---- // ---- Std sequence header (must match the OBU packed below) ----
// BT.709 limited, mirroring the `color_config()` bits `av1_sequence_header_obu` packs — the two
// MUST stay identical or the driver's frame OBUs parse against a header we didn't write.
// `color_range` stays 0 (studio swing); only the description flag + CICP triplet change.
let mut cc_flags: hh::StdVideoAV1ColorConfigFlags = std::mem::zeroed(); let mut cc_flags: hh::StdVideoAV1ColorConfigFlags = std::mem::zeroed();
let _ = &mut cc_flags; // all zero: mono_chrome/color_range/description/separate_uv_delta_q = 0 cc_flags.set_color_description_present_flag(1);
let mut cc: hh::StdVideoAV1ColorConfig = std::mem::zeroed(); let mut cc: hh::StdVideoAV1ColorConfig = std::mem::zeroed();
cc.flags = cc_flags; cc.flags = cc_flags;
cc.BitDepth = 8; cc.BitDepth = 8;
cc.subsampling_x = 1; cc.subsampling_x = 1;
cc.subsampling_y = 1; cc.subsampling_y = 1;
cc.color_primaries = hh::StdVideoAV1ColorPrimaries_STD_VIDEO_AV1_COLOR_PRIMARIES_BT_UNSPECIFIED; cc.color_primaries = hh::StdVideoAV1ColorPrimaries_STD_VIDEO_AV1_COLOR_PRIMARIES_BT_709;
cc.transfer_characteristics = cc.transfer_characteristics =
hh::StdVideoAV1TransferCharacteristics_STD_VIDEO_AV1_TRANSFER_CHARACTERISTICS_UNSPECIFIED; hh::StdVideoAV1TransferCharacteristics_STD_VIDEO_AV1_TRANSFER_CHARACTERISTICS_BT_709;
cc.matrix_coefficients = cc.matrix_coefficients =
hh::StdVideoAV1MatrixCoefficients_STD_VIDEO_AV1_MATRIX_COEFFICIENTS_UNSPECIFIED; hh::StdVideoAV1MatrixCoefficients_STD_VIDEO_AV1_MATRIX_COEFFICIENTS_BT_709;
cc.chroma_sample_position = cc.chroma_sample_position =
hh::StdVideoAV1ChromaSamplePosition_STD_VIDEO_AV1_CHROMA_SAMPLE_POSITION_UNKNOWN; hh::StdVideoAV1ChromaSamplePosition_STD_VIDEO_AV1_CHROMA_SAMPLE_POSITION_UNKNOWN;
@@ -834,3 +868,123 @@ pub(super) unsafe fn build_parameters_av1(
keyframe_prefix.extend_from_slice(&seq_obu); keyframe_prefix.extend_from_slice(&seq_obu);
Ok((params, keyframe_prefix, td)) Ok((params, keyframe_prefix, td))
} }
#[cfg(test)]
mod tests {
use super::*;
/// Walks a bit-packed AV1 sequence header field-by-field (spec §5.5.1 order, for the fixed
/// configuration `av1_sequence_header_obu` emits) and returns the `color_config()` values.
/// Deliberately an INDEPENDENT walk rather than a mirror of the writer: it is the only thing
/// that catches a field width or ordering change upstream of `color_config`, which would leave
/// the colour bits parsing at the wrong offset — the exact desync the module doc warns about.
fn read_color_config(
obu: &[u8],
fwb: u32,
fhb: u32,
seq_level_idx: u32,
) -> (u8, u8, u8, u8, u8) {
// obu_header (1 byte) + leb128 size — the payload starts after both.
assert_eq!(
obu[0], 0x0a,
"obu_header: OBU_SEQUENCE_HEADER + has_size_field"
);
let mut i = 1;
while obu[i] & 0x80 != 0 {
i += 1;
}
let payload = &obu[i + 1..];
let mut pos = 0usize;
let mut take = |bits: u32| -> u32 {
let mut v = 0u32;
for _ in 0..bits {
let byte = payload[pos / 8];
v = (v << 1) | u32::from((byte >> (7 - (pos % 8))) & 1);
pos += 1;
}
v
};
assert_eq!(take(3), 0, "seq_profile = MAIN");
take(1); // still_picture
assert_eq!(take(1), 0, "reduced_still_picture_header");
assert_eq!(take(1), 0, "timing_info_present_flag");
assert_eq!(take(1), 0, "initial_display_delay_present_flag");
assert_eq!(take(5), 0, "operating_points_cnt_minus_1");
take(12); // operating_point_idc[0]
assert_eq!(take(5), seq_level_idx, "seq_level_idx[0]");
if seq_level_idx > 7 {
take(1); // seq_tier[0]
}
assert_eq!(take(4), fwb, "frame_width_bits_minus_1");
assert_eq!(take(4), fhb, "frame_height_bits_minus_1");
take(fwb + 1); // max_frame_width_minus_1
take(fhb + 1); // max_frame_height_minus_1
take(1); // frame_id_numbers_present_flag
take(1); // use_128x128_superblock
take(1); // enable_filter_intra
take(1); // enable_intra_edge_filter
take(1); // enable_interintra_compound
take(1); // enable_masked_compound
take(1); // enable_warped_motion
take(1); // enable_dual_filter
let order_hint = take(1); // enable_order_hint
assert_eq!(
order_hint, 1,
"enable_order_hint (our single-ref P-frame config)"
);
take(1); // enable_jnt_comp
take(1); // enable_ref_frame_mvs
assert_eq!(take(1), 1, "seq_choose_screen_content_tools = SELECT");
// seq_force_screen_content_tools = SELECT (> 0), so seq_choose_integer_mv is present.
assert_eq!(take(1), 1, "seq_choose_integer_mv = SELECT");
take(3); // order_hint_bits_minus_1
take(1); // enable_superres
take(1); // enable_cdef
take(1); // enable_restoration
// color_config()
assert_eq!(take(1), 0, "high_bitdepth (8-bit)");
assert_eq!(take(1), 0, "mono_chrome");
let described = take(1) as u8;
let (cp, tc, mc) = if described == 1 {
(take(8) as u8, take(8) as u8, take(8) as u8)
} else {
(2, 2, 2) // CICP "unspecified"
};
let range = take(1) as u8;
take(2); // chroma_sample_position
assert_eq!(take(1), 0, "separate_uv_delta_q");
assert_eq!(take(1), 0, "film_grain_params_present");
assert_eq!(take(1), 1, "trailing_one_bit");
(described, cp, tc, mc, range)
}
/// The sequence header must SIGNAL BT.709 limited — the CSC `rgb2yuv.comp` actually performs.
/// An unsignalled ("unspecified") AV1 stream makes vendor TV decoders guess colorimetry from
/// resolution: an LG webOS panel reads 4K SDR as BT.2020 and renders it washed out.
///
/// The values here must equal the `StdVideoAV1ColorConfig` in `build_parameters_av1` — the
/// driver packs its frame OBUs against that struct while clients parse this header, so a
/// mismatch desyncs every inter frame.
#[test]
fn av1_sequence_header_signals_bt709_limited() {
// 1920x1080: av_log2 gives 10/10 frame-size bits; level 4.0 (seq_level_idx 8) exercises
// the seq_tier branch, and sb128 both ways since it sits above color_config.
for (sb128, level) in [(false, 8u32), (true, 5u32)] {
let obu = av1_sequence_header_obu(sb128, 10, 10, 1919, 1079, 7, level);
let (described, cp, tc, mc, range) = read_color_config(&obu, 10, 10, level);
assert_eq!(
described, 1,
"color_description_present_flag (sb128={sb128})"
);
assert_eq!(
(cp, tc, mc),
(1, 1, 1),
"CICP BT.709 primaries/transfer/matrix"
);
assert_eq!(range, 0, "color_range = studio/limited swing");
}
}
}
+152 -7
View File
@@ -3,11 +3,15 @@
//! no B-frames (Baseline), bitrate rate-control, in-band SPS/PPS each IDR. //! no B-frames (Baseline), bitrate rate-control, in-band SPS/PPS each IDR.
//! Synchronous: `submit` encodes immediately and stashes the AU for `poll` (no internal queue). //! Synchronous: `submit` encodes immediately and stashes the AU for `poll` (no internal queue).
//! //!
//! The RGB→YUV conversion is OURS, BT.709 limited range: openh264 writes no colour description //! The RGB→YUV conversion is OURS, BT.709 limited range, and the SPS VUI says so
//! into the VUI (unspecified), so decoders fall back to their default — BT.709 limited on every //! ([`VuiConfig::bt709`], applied in `open`). The crate's own `YUVBuffer` converter is BT.601
//! punktfunk client — and the pixels must match that default. The crate's own `YUVBuffer` //! (0.2578/0.5039/0.0977 + 16), which decoded-as-709 is a constant hue error; that's why it is
//! converter is BT.601 (0.2578/0.5039/0.0977 + 16), which decoded-as-709 is a constant hue //! NOT used here.
//! error; that's why it is NOT used here. //!
//! Signalling is not optional. This used to leave the VUI unwritten and lean on decoders
//! defaulting to BT.709 limited — true of every punktfunk client (`csc_rows` falls back to 709 on
//! "unspecified"), but NOT of vendor TV decoders, which guess colorimetry from RESOLUTION: an LG
//! webOS panel reads a 4K SDR stream as BT.2020 and renders it visibly washed out.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
@@ -15,7 +19,7 @@ use super::{EncodedFrame, Encoder};
use anyhow::{bail, ensure, Context, Result}; use anyhow::{bail, ensure, Context, Result};
use openh264::encoder::{ use openh264::encoder::{
BitRate, Complexity, Encoder as Oh264, EncoderConfig, FrameRate, FrameType, IntraFramePeriod, BitRate, Complexity, Encoder as Oh264, EncoderConfig, FrameRate, FrameType, IntraFramePeriod,
Profile, RateControlMode, SpsPpsStrategy, UsageType, Profile, RateControlMode, SpsPpsStrategy, UsageType, VuiConfig,
}; };
use openh264::formats::YUVSlices; use openh264::formats::YUVSlices;
use openh264::OpenH264API; use openh264::OpenH264API;
@@ -100,7 +104,10 @@ impl OpenH264Encoder {
.scene_change_detect(false) // no surprise IDRs (bitrate spikes / freeze) .scene_change_detect(false) // no surprise IDRs (bitrate spikes / freeze)
.adaptive_quantization(true) .adaptive_quantization(true)
.complexity(Complexity::Low) // latency over BD-rate .complexity(Complexity::Low) // latency over BD-rate
.profile(Profile::Baseline); // no B-frames; the VUI carries no colour description .profile(Profile::Baseline) // no B-frames
// video_signal_type + colour_description in the SPS VUI: BT.709 primaries/transfer/
// matrix, video_full_range_flag = 0 — exactly what `convert_bt709` below produces.
.vui(VuiConfig::bt709());
let api = OpenH264API::from_source(); // statically-bundled build (default `source` feature) let api = OpenH264API::from_source(); // statically-bundled build (default `source` feature)
let enc = Oh264::with_api_config(api, cfg).context("openh264 Encoder::with_api_config")?; let enc = Oh264::with_api_config(api, cfg).context("openh264 Encoder::with_api_config")?;
let (w, h) = (width as usize, height as usize); let (w, h) = (width as usize, height as usize);
@@ -364,6 +371,144 @@ mod tests {
assert!(has_sps, "IDR must carry an SPS NAL (type 7)"); assert!(has_sps, "IDR must carry an SPS NAL (type 7)");
} }
/// Strip Annex-B framing + emulation-prevention bytes from the first SPS NAL in `au`.
fn sps_rbsp(au: &[u8]) -> Vec<u8> {
let start = au
.windows(5)
.position(|w| w[..4] == [0, 0, 0, 1] && (w[4] & 0x1f) == 7)
.map(|p| p + 5)
.expect("an SPS NAL");
let end = au[start..]
.windows(4)
.position(|w| w[..3] == [0, 0, 1] || w == [0, 0, 0, 1])
.map_or(au.len(), |p| start + p);
let mut rbsp = Vec::new();
let nal = &au[start..end];
let mut i = 0;
while i < nal.len() {
// 00 00 03 -> the 03 is an emulation-prevention byte, not payload.
if i + 2 < nal.len() && nal[i] == 0 && nal[i + 1] == 0 && nal[i + 2] == 3 {
rbsp.extend_from_slice(&[0, 0]);
i += 3;
} else {
rbsp.push(nal[i]);
i += 1;
}
}
rbsp
}
/// The colour signalling the SPS actually carries, walked per ITU-T H.264 §7.3.2.1.1: returns
/// `(video_full_range_flag, colour_primaries, transfer_characteristics, matrix_coefficients)`.
/// `None` when the stream is unsignalled — which is what this module used to emit.
fn sps_colour(rbsp: &[u8]) -> Option<(u8, u8, u8, u8)> {
// Exp-Golomb ue(v): count leading zeros, then read that many trailing bits.
fn ue(u: &mut dyn FnMut(u32) -> u32) -> u32 {
let mut lz = 0;
while u(1) == 0 {
lz += 1;
assert!(lz < 32, "malformed Exp-Golomb");
}
if lz == 0 {
0
} else {
(1 << lz) - 1 + u(lz)
}
}
let mut pos = 0usize;
let mut u = |bits: u32| -> u32 {
let mut v = 0;
for _ in 0..bits {
v = (v << 1) | u32::from((rbsp[pos / 8] >> (7 - (pos % 8))) & 1);
pos += 1;
}
v
};
let profile_idc = u(8);
u(8); // constraint_set flags + reserved
u(8); // level_idc
ue(&mut u); // seq_parameter_set_id
assert_eq!(
profile_idc, 66,
"this encoder is pinned to Baseline — a profile change adds the chroma_format_idc \
block this walk deliberately omits"
);
ue(&mut u); // log2_max_frame_num_minus4
let poc_type = ue(&mut u);
match poc_type {
0 => {
ue(&mut u);
} // log2_max_pic_order_cnt_lsb_minus4
1 => panic!("pic_order_cnt_type 1 unhandled — openh264 emits 0 or 2"),
_ => {}
}
ue(&mut u); // max_num_ref_frames
u(1); // gaps_in_frame_num_value_allowed_flag
ue(&mut u); // pic_width_in_mbs_minus1
ue(&mut u); // pic_height_in_map_units_minus1
if u(1) == 0 {
u(1); // mb_adaptive_frame_field_flag
}
u(1); // direct_8x8_inference_flag
if u(1) == 1 {
for _ in 0..4 {
ue(&mut u); // frame_crop_*_offset
}
}
if u(1) == 0 {
return None; // vui_parameters_present_flag
}
if u(1) == 1 {
// aspect_ratio_info_present_flag
if u(8) == 255 {
u(16);
u(16);
}
}
if u(1) == 1 {
u(1); // overscan_info_present_flag -> overscan_appropriate_flag
}
if u(1) == 0 {
return None; // video_signal_type_present_flag
}
u(3); // video_format
let full_range = u(1) as u8;
if u(1) == 0 {
return None; // colour_description_present_flag
}
Some((full_range, u(8) as u8, u(8) as u8, u(8) as u8))
}
/// The SPS must SIGNAL BT.709 limited, not merely be encoded that way. `VuiConfig::bt709()`
/// is a request to a C library; this asserts it lands in the emitted bitstream.
///
/// Unsignalled was the old behaviour and it looks fine on every punktfunk client (`csc_rows`
/// defaults to BT.709 on "unspecified"), so nothing in our own stack catches a regression
/// here — but vendor TV decoders guess colorimetry from RESOLUTION, and an LG webOS panel
/// reads a 4K SDR stream as BT.2020 and renders it visibly washed out.
#[test]
fn sps_signals_bt709_limited() {
let (w, h, fps) = (1280u32, 720u32, 60u32);
let mut enc =
OpenH264Encoder::open(PixelFormat::Bgrx, w, h, fps, 8_000_000).expect("open openh264");
let frame = CapturedFrame {
width: w,
height: h,
pts_ns: 0,
format: PixelFormat::Bgrx,
payload: FramePayload::Cpu(vec![0x80u8; (w * h * 4) as usize]),
cursor: None,
};
enc.submit(&frame).expect("submit");
let au = enc.poll().expect("poll").expect("an AU");
let colour = sps_colour(&sps_rbsp(&au.data)).expect(
"the SPS must carry video_signal_type + colour_description — \
see EncoderConfig::vui in `open`",
);
// (video_full_range_flag, colour_primaries, transfer, matrix) — 0 = limited, 1 = BT.709.
assert_eq!(colour, (0, 1, 1, 1), "expected BT.709 limited signalling");
}
/// The modes the software encoder can actually serve — including the portrait orientation, /// The modes the software encoder can actually serve — including the portrait orientation,
/// which a naive per-axis `w <= 3840 && h <= 2160` would wrongly reject. /// which a naive per-axis `w <= 3840 && h <= 2160` would wrongly reject.
#[test] #[test]
+130 -25
View File
@@ -89,6 +89,12 @@ struct EncodeApi {
*mut nv::NV_ENC_CAPS_PARAM, *mut nv::NV_ENC_CAPS_PARAM,
*mut core::ffi::c_int, *mut core::ffi::c_int,
) -> nv::NVENCSTATUS, ) -> nv::NVENCSTATUS,
// The two entry points behind [`probe_codec_support`] — the driver's own list of encode GUIDs
// this chip exposes. Mandatory like every other entry: both have existed since NVENC 1.0, so a
// driver missing them is broken in ways the rest of this table would not survive either.
get_encode_guid_count: unsafe extern "C" fn(*mut c_void, *mut u32) -> nv::NVENCSTATUS,
get_encode_guids:
unsafe extern "C" fn(*mut c_void, *mut nv::GUID, u32, *mut u32) -> nv::NVENCSTATUS,
get_encode_preset_config_ex: unsafe extern "C" fn( get_encode_preset_config_ex: unsafe extern "C" fn(
*mut c_void, *mut c_void,
nv::GUID, nv::GUID,
@@ -203,6 +209,8 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
reconfigure_encoder: list.nvEncReconfigureEncoder.ok_or(MISSING)?, reconfigure_encoder: list.nvEncReconfigureEncoder.ok_or(MISSING)?,
destroy_encoder: list.nvEncDestroyEncoder.ok_or(MISSING)?, destroy_encoder: list.nvEncDestroyEncoder.ok_or(MISSING)?,
get_encode_caps: list.nvEncGetEncodeCaps.ok_or(MISSING)?, get_encode_caps: list.nvEncGetEncodeCaps.ok_or(MISSING)?,
get_encode_guid_count: list.nvEncGetEncodeGUIDCount.ok_or(MISSING)?,
get_encode_guids: list.nvEncGetEncodeGUIDs.ok_or(MISSING)?,
get_encode_preset_config_ex: list.nvEncGetEncodePresetConfigEx.ok_or(MISSING)?, get_encode_preset_config_ex: list.nvEncGetEncodePresetConfigEx.ok_or(MISSING)?,
create_bitstream_buffer: list.nvEncCreateBitstreamBuffer.ok_or(MISSING)?, create_bitstream_buffer: list.nvEncCreateBitstreamBuffer.ok_or(MISSING)?,
destroy_bitstream_buffer: list.nvEncDestroyBitstreamBuffer.ok_or(MISSING)?, destroy_bitstream_buffer: list.nvEncDestroyBitstreamBuffer.ok_or(MISSING)?,
@@ -1965,11 +1973,85 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
probe_encode_cap(codec, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_10BIT_ENCODE) probe_encode_cap(codec, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_10BIT_ENCODE)
} }
/// Query ONE NVENC capability for `codec`: creates a throwaway hardware D3D11 device + NVENC /// Query ONE NVENC capability for `codec` on a throwaway session (see [`with_probe_session`]).
/// session on the **selected render adapter**, reads the cap, and tears everything down. `false` /// `false` on any failure (no loadable NVENC, no device, failed open) — the honest answer for a
/// on any failure (no loadable NVENC, no device, failed open) — the honest answer for a
/// capability that couldn't be confirmed. /// capability that couldn't be confirmed.
fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool { fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
with_probe_session(|enc| {
let mut param = nv::NV_ENC_CAPS_PARAM {
version: nv::NV_ENC_CAPS_PARAM_VER,
capsToQuery: cap,
reserved: [0; 62],
};
let mut val: i32 = 0;
// SAFETY: `get_encode_caps` reads one scalar cap into `val` (live locals) for the live
// session `enc` via the loaded API table (`with_probe_session` sits past `try_api`).
unsafe {
(api().get_encode_caps)(enc, codec_guid(codec), &mut param, &mut val)
.nv_ok()
.is_ok()
&& val != 0
}
})
.unwrap_or(false)
}
/// Which codecs **this GPU's** NVENC can actually encode, asked of the driver itself
/// (`nvEncGetEncodeGUIDs`) on a throwaway session — the Windows twin of the Linux
/// `nvenc_cuda::probe_support` codec half, probing the **selected render adapter** (the GPU the
/// session will really encode on) rather than CUDA device 0. Same field bug on both OSes: the
/// static `H.264 | HEVC | AV1` superset advertised HEVC on a 1st-gen Maxwell, and a client that
/// negotiated it got a dead session instead of a stream.
///
/// Every failure path returns "nothing probed", which [`crate::CodecSupport::wire_mask`] turns
/// into `None` so the caller keeps the old static superset — a broken probe must never be able to
/// narrow an NVIDIA host's advertisement to nothing. Cached per selected GPU by the caller
/// ([`crate::windows_codec_support`]).
pub(crate) fn probe_codec_support() -> crate::CodecSupport {
let unknown = crate::CodecSupport {
h264: false,
h265: false,
av1: false,
};
with_probe_session(|enc| {
// SAFETY: all NVENC calls go through the loaded API table against the live session `enc`;
// `count`/`written` are live locals, and `guids` is sized to the count the driver just
// reported, its pointer valid for that many `GUID`s (matching `guidArraySize`).
unsafe {
let mut count = 0u32;
let counted = (api().get_encode_guid_count)(enc, &mut count)
.nv_ok()
.is_ok();
let mut guids = vec![nv::GUID::default(); count as usize];
let mut written = 0u32;
let listed = counted
&& count > 0
&& (api().get_encode_guids)(enc, guids.as_mut_ptr(), count, &mut written)
.nv_ok()
.is_ok();
if !listed {
tracing::warn!(
"NVENC codec probe: driver listed no encode GUIDs — keeping the static \
advertisement"
);
return unknown;
}
guids.truncate(written as usize);
crate::CodecSupport {
h264: guids.contains(&codec_guid(Codec::H264)),
h265: guids.contains(&codec_guid(Codec::H265)),
av1: guids.contains(&codec_guid(Codec::Av1)),
}
}
})
.unwrap_or(unknown)
}
/// Open a throwaway NVENC session on a fresh hardware D3D11 device, hand it to `f`, and tear
/// everything down. `None` = no loadable NVENC / no device / failed open — the caller supplies
/// the honest "couldn't confirm" answer. Shared by [`probe_encode_cap`] and
/// [`probe_codec_support`], so every advertisement probe opens sessions exactly one way.
fn with_probe_session<T>(f: impl FnOnce(*mut c_void) -> T) -> Option<T> {
// Same exclusion as `init_session`: this opens a real (throwaway) session, so it must never // Same exclusion as `init_session`: this opens a real (throwaway) session, so it must never
// overlap a zombie reap that could be destroying the very address the driver hands us. // overlap a zombie reap that could be destroying the very address the driver hands us.
let _gate = DRIVER_SESSION_GATE let _gate = DRIVER_SESSION_GATE
@@ -1983,20 +2065,20 @@ fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
D3D11CreateDevice, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION, D3D11CreateDevice, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION,
}; };
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4}; use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
// No loadable NVENC on this box (non-NVIDIA / no driver) → the honest 4:4:4 answer is "no". // No loadable NVENC on this box (non-NVIDIA / no driver) → nothing to confirm.
// This is also the `api()` gate for every NVENC call below. // This is also the `api()` gate for every NVENC call below and inside `f`.
if try_api().is_err() { if try_api().is_err() {
return false; return None;
} }
// SAFETY: a self-contained probe owning every handle it creates. `CreateDXGIFactory1`/ // SAFETY: a self-contained probe owning every handle it creates. `CreateDXGIFactory1`/
// `EnumAdapterByLuid` return owned COM objects or err (→ default-adapter fallback). // `EnumAdapterByLuid` return owned COM objects or err (→ default-adapter fallback).
// `D3D11CreateDevice` (explicit adapter + UNKNOWN driver type, or NULL adapter + HARDWARE) // `D3D11CreateDevice` (explicit adapter + UNKNOWN driver type, or NULL adapter + HARDWARE)
// fills `device` or returns Err (→ false). `open_encode_session_ex` opens an NVENC session // fills `device` or returns Err (→ None). `open_encode_session_ex` opens an NVENC session
// against that device's raw pointer (valid while `device` is held) or errors (→ false, after // against that device's raw pointer (valid while `device` is held) or errors (→ None, after
// destroying any residue session the failed open left — the docs require it). // destroying any residue session the failed open left — the docs require it).
// `get_encode_caps` reads one scalar cap into `val` via the loaded API table. // `destroy_encoder` frees the session exactly once, after `f` returns (so `enc` is live for
// `destroy_encoder` frees the session exactly once; `device`/its context drop with the COM // the whole closure call); `device`/its context drop with the COM wrappers. No handle
// wrappers. No handle escapes this call and nothing runs concurrently. // escapes this call and nothing runs concurrently (the gate above).
unsafe { unsafe {
// Probe on the SELECTED render adapter — the GPU the session will actually encode on // Probe on the SELECTED render adapter — the GPU the session will actually encode on
// (web-console preference / PUNKTFUNK_RENDER_ADAPTER / max VRAM). The OS default adapter // (web-console preference / PUNKTFUNK_RENDER_ADAPTER / max VRAM). The OS default adapter
@@ -2032,9 +2114,9 @@ fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
), ),
}; };
if created.is_err() { if created.is_err() {
return false; return None;
} }
let Some(device) = device else { return false }; let device = device?;
let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS { let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER, version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
deviceType: nv::NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_DIRECTX, deviceType: nv::NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_DIRECTX,
@@ -2051,23 +2133,14 @@ fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
if !enc.is_null() { if !enc.is_null() {
let _ = (api().destroy_encoder)(enc); let _ = (api().destroy_encoder)(enc);
} }
return false; return None;
} }
// Availability probe, but a real session open all the same: it proves the driver accepted // Availability probe, but a real session open all the same: it proves the driver accepted
// this build's version word, which is what rules a skew out later (see `nvenc_status`). // this build's version word, which is what rules a skew out later (see `nvenc_status`).
nvenc_status::note_session_opened(); nvenc_status::note_session_opened();
let mut param = nv::NV_ENC_CAPS_PARAM { let out = f(enc);
version: nv::NV_ENC_CAPS_PARAM_VER,
capsToQuery: cap,
reserved: [0; 62],
};
let mut val: i32 = 0;
let ok = (api().get_encode_caps)(enc, codec_guid(codec), &mut param, &mut val)
.nv_ok()
.is_ok()
&& val != 0;
let _ = (api().destroy_encoder)(enc); let _ = (api().destroy_encoder)(enc);
ok Some(out)
} }
} }
@@ -2356,4 +2429,36 @@ mod tests {
"C:\\Users\\Public\\nvenc420_probe.h265", "C:\\Users\\Public\\nvenc420_probe.h265",
); );
} }
/// ON-HARDWARE: the codec-advertisement probe against the real driver — the Windows twin of
/// the Linux `nvenc_codec_probe_reports_real_gpu_support` test, same invariants: every
/// NVENC-capable GPU ever made encodes H.264 (a `false` means the enumeration is broken and
/// would silently narrow the advertisement), and the answer must be stable across calls since
/// one cached answer drives every negotiation. Prints the mask so a run on an OLD card
/// (Maxwell GM107 = h264 only, the GPU this probe exists for) is self-documenting. Run:
/// cargo test -p pf-encode --features nvenc --release -- --ignored nvenc_codec_probe --nocapture
/// (`--release` is REQUIRED on Windows, and pre-dates this test: the debug lib-test link
/// fails LNK2019 because the sdk crate's unused lazy loader references the NvEncodeAPI
/// imports that runtime loading deliberately avoids — debug `/OPT:NOREF` keeps the dead
/// COMDAT, release strips it. Windows CI gates pf-encode with clippy for the same reason.)
#[test]
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.173)"]
fn nvenc_codec_probe_reports_real_gpu_support() {
let caps = probe_codec_support();
eprintln!(
"NVENC (Windows) probe: h264={} h265={} av1={}",
caps.h264, caps.h265, caps.av1
);
assert!(
caps.h264,
"every NVENC generation encodes H.264 — a false here means the GUID enumeration \
failed, which would narrow the host's codec advertisement"
);
let again = probe_codec_support();
assert_eq!(
(caps.h264, caps.h265, caps.av1),
(again.h264, again.h265, again.av1),
"the probe must be stable — it is cached once and drives every later negotiation"
);
}
} }
+91 -16
View File
@@ -108,7 +108,21 @@ impl Codec {
return m & pref_ceiling; return m & pref_ceiling;
} }
} }
// NVENC (static superset, like GameStream) — or an empty VAAPI probe (see above). // NVENC: ask the DRIVER which codecs this chip's encoder exposes, the same way the
// VAAPI arm above does — a static superset advertised HEVC on a 1st-gen Maxwell
// (HEVC needs 2nd-gen Maxwell+, AV1 needs Ada+), and a client that believed it got
// ~15 s of blank video and a disconnect instead of a stream. Fails OPEN: a probe
// that can't answer (no direct-SDK build, no CUDA, an old driver) yields `None` and
// leaves the historical superset standing, so this can only ever narrow the
// advertisement to something the GPU really encodes.
#[cfg(feature = "nvenc")]
if backend == LinuxBackend::Nvenc {
if let Some(m) = nvenc_codec_support().wire_mask() {
return m & pref_ceiling;
}
}
// NVENC without the probe (no `nvenc` feature / probe declined) — or an empty VAAPI
// probe (see above): the static superset, like GameStream.
GPU_SUPERSET & pref_ceiling GPU_SUPERSET & pref_ceiling
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -121,7 +135,8 @@ impl Codec {
return m; return m;
} }
} }
// NVENC (static superset, like GameStream) — or an empty AMF/QSV probe (see above). // An unprobed backend (NVENC without the `nvenc` feature) — or an empty probe
// (see above): the static superset, like GameStream.
GPU_SUPERSET GPU_SUPERSET
} }
// The macOS dev/test host has no GPU encode backend — keep the pre-probe advertisement. // The macOS dev/test host has no GPU encode backend — keep the pre-probe advertisement.
@@ -1263,9 +1278,31 @@ impl CodecSupport {
} }
} }
/// Probe the active NVIDIA GPU for its encodable codecs (cached once per process). Asks the driver
/// for this chip's encode-GUID list over the direct SDK — see [`nvenc_cuda::probe_support`] for
/// why it is not shaped like the VAAPI probe below, and for the fail-open contract. Process-wide
/// (not per selected GPU) on purpose: the direct backend opens on the shared `cuda::context()`,
/// i.e. CUDA device 0, so that is the chip whose answer applies.
#[cfg(all(target_os = "linux", feature = "nvenc"))]
pub fn nvenc_codec_support() -> CodecSupport {
use std::sync::OnceLock;
static LOGGED: OnceLock<()> = OnceLock::new();
let probed = nvenc_cuda::probe_support();
LOGGED.get_or_init(|| {
tracing::info!(
h264 = probed.codecs.h264,
h265 = probed.codecs.h265,
av1 = probed.codecs.av1,
hevc_444 = probed.hevc_444,
"NVENC encode capabilities probed"
);
});
probed.codecs
}
/// Probe the active Linux GPU backend for its encodable codecs (cached; opens a tiny encoder per /// Probe the active Linux GPU backend for its encodable codecs (cached; opens a tiny encoder per
/// codec, once). Only the VAAPI (AMD/Intel) backend is probed — NVENC keeps its Moonlight-validated /// codec, once). The AMD/Intel backend — NVIDIA has [`nvenc_codec_support`] (callers gate on
/// static advertisement (callers gate on [`linux_zero_copy_is_vaapi`]). /// [`linux_zero_copy_is_vaapi`]).
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn vaapi_codec_support() -> CodecSupport { pub fn vaapi_codec_support() -> CodecSupport {
use std::sync::OnceLock; use std::sync::OnceLock;
@@ -1319,10 +1356,32 @@ pub fn can_encode_444(codec: Codec) -> bool {
// Mirror open_video's backend dispatch: VAAPI (AMD/Intel) vs NVENC (NVIDIA). // Mirror open_video's backend dispatch: VAAPI (AMD/Intel) vs NVENC (NVIDIA).
if linux_zero_copy_is_vaapi() { if linux_zero_copy_is_vaapi() {
vaapi::probe_can_encode_444(codec) vaapi::probe_can_encode_444(codec)
} else {
// NVIDIA. On a direct-SDK host the answer comes from the driver's caps bit over
// the direct SDK ([`nvenc_cuda::probe_support`]) — the same
// `NV_ENC_CAPS_SUPPORT_YUV444_ENCODE` the live session re-checks at open
// (`query_caps` → `yuv444_supported`), and the same shape the Windows NVENC arm
// uses (`nvenc::probe_can_encode_444`). It must NOT fall back to the libav
// open-probe: one ffmpeg `hevc_nvenc` FREXT open in a direct-SDK process is the
// LOG-3 field bug — it wedged every later NVENC open process-wide
// (`NV_ENC_ERR_INVALID_VERSION`) until a host restart. Only a host that will
// really serve the session over libav (PUNKTFUNK_NVENC_DIRECT=0, or a build
// without `--features nvenc`) keeps the ffmpeg probe — there it validates the
// actual session path, and ffmpeg's NVENC client runs in that process anyway.
#[cfg(feature = "nvenc")]
{
if nvenc_direct_enabled() {
nvenc_cuda::probe_support().hevc_444
} else { } else {
linux::probe_can_encode_444(codec) linux::probe_can_encode_444(codec)
} }
} }
#[cfg(not(feature = "nvenc"))]
{
linux::probe_can_encode_444(codec)
}
}
}
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
match windows_resolved_backend() { match windows_resolved_backend() {
@@ -1624,17 +1683,20 @@ pub fn resolved_backend_ingests_rgb_444() -> bool {
} }
/// True if the active Windows backend's codec advertisement comes from a **real GPU probe** /// True if the active Windows backend's codec advertisement comes from a **real GPU probe**
/// ([`windows_codec_support`]) rather than the NVENC static superset. AMF always qualifies — the /// ([`windows_codec_support`]) rather than the static superset. AMF always qualifies — the
/// native factory probe (`amf::probe_can_encode`) needs no build feature — while QSV qualifies /// native factory probe (`amf::probe_can_encode`) needs no build feature — while QSV qualifies
/// with either the native VPL build (`qsv`, the authoritative Query probe) or the `amf-qsv` /// with either the native VPL build (`qsv`, the authoritative Query probe) or the `amf-qsv`
/// (libavcodec) build. Formerly `windows_backend_is_ffmpeg`, renamed when the /// (libavcodec) build, and NVENC with the `nvenc` build (the direct-SDK GUID-list probe,
/// native AMF probe replaced the ffmpeg open-probe (design/native-amf-encoder.md §4, Phase 2). /// `nvenc::probe_codec_support` — the Windows twin of the Linux probe that ended the
/// advertise-HEVC-on-a-GM107 dead sessions). Formerly `windows_backend_is_ffmpeg`, renamed when
/// the native AMF probe replaced the ffmpeg open-probe (design/native-amf-encoder.md §4, Phase 2).
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub fn windows_backend_is_probed() -> bool { pub fn windows_backend_is_probed() -> bool {
match windows_resolved_backend() { match windows_resolved_backend() {
WindowsBackend::Amf => true, WindowsBackend::Amf => true,
WindowsBackend::Qsv => cfg!(feature = "qsv") || cfg!(feature = "amf-qsv"), WindowsBackend::Qsv => cfg!(feature = "qsv") || cfg!(feature = "amf-qsv"),
WindowsBackend::Nvenc | WindowsBackend::Software => false, WindowsBackend::Nvenc => cfg!(feature = "nvenc"),
WindowsBackend::Software => false,
} }
} }
@@ -1661,18 +1723,22 @@ fn windows_gpu_vendor() -> Option<GpuVendor> {
.or_else(|| pf_gpu::enumerate().iter().find_map(|g| by_id(g.vendor_id))) .or_else(|| pf_gpu::enumerate().iter().find_map(|g| by_id(g.vendor_id)))
} }
/// Probe the active Windows AMF/QSV backend for its encodable codecs (cached **per (backend, /// Probe the active Windows GPU backend for its encodable codecs (cached **per (backend,
/// selected GPU)** — a web-console preference change re-probes on the newly selected adapter /// selected GPU)** — a web-console preference change re-probes on the newly selected adapter
/// instead of serving the old GPU's answer for the process lifetime). Mirrors /// instead of serving the old GPU's answer for the process lifetime). Mirrors
/// [`vaapi_codec_support`]; called only when [`windows_backend_is_probed`] is true. AV1 is narrow /// [`vaapi_codec_support`]/[`nvenc_codec_support`]; called only when [`windows_backend_is_probed`]
/// (AMD RDNA3+, Intel Arc/Xe2+), so it must be probed, not assumed. /// is true. AV1 is narrow (NVIDIA Ada+, AMD RDNA3+, Intel Arc/Xe2+) and HEVC needs 2nd-gen
/// Maxwell+ on NVIDIA, so both must be probed, not assumed.
/// ///
/// Mirrors the session dispatch (design/native-amf-encoder.md Phase 3): **AMD advertises from the /// Mirrors the session dispatch (design/native-amf-encoder.md Phase 3): **AMD advertises from the
/// native AMF factory probe alone** (`amf::probe_can_encode`, on the selected adapter — the same /// native AMF factory probe alone** (`amf::probe_can_encode`, on the selected adapter — the same
/// path the session opens, so the advertisement can never claim a codec the session can't emit); /// path the session opens, so the advertisement can never claim a codec the session can't emit);
/// **Intel/QSV advertises from the native VPL Query probe** (`qsv::probe_can_encode`, /// **Intel/QSV advertises from the native VPL Query probe** (`qsv::probe_can_encode`,
/// design/native-qsv-encoder.md §4), falling back to the libavcodec probe on builds without the /// design/native-qsv-encoder.md §4), falling back to the libavcodec probe on builds without the
/// `qsv` feature (all-`false` without either feature, matching a build that cannot open QSV). /// `qsv` feature (all-`false` without either feature, matching a build that cannot open QSV);
/// **NVIDIA advertises from the driver's own encode-GUID list** (`nvenc::probe_codec_support`,
/// one throwaway direct-SDK session on the selected adapter — NEVER an ffmpeg open-probe, see the
/// Linux `nvenc_cuda::probe_support` doc for why).
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub fn windows_codec_support() -> CodecSupport { pub fn windows_codec_support() -> CodecSupport {
use std::collections::HashMap; use std::collections::HashMap;
@@ -1706,22 +1772,31 @@ pub fn windows_codec_support() -> CodecSupport {
false false
} }
} }
// Callers gate on `windows_backend_is_probed` — defensively answer "nothing probed" // NVENC answers below from ONE GUID-list session, not per-codec probes; Software is
// (the advertisement then falls back to the static superset). // never probed. Callers gate on `windows_backend_is_probed` — defensively answer
// "nothing probed" (the advertisement then falls back to the static superset).
WindowsBackend::Nvenc | WindowsBackend::Software => false, WindowsBackend::Nvenc | WindowsBackend::Software => false,
} }
}; };
let caps = CodecSupport { let caps = match backend {
// NVIDIA: one throwaway session lists every encode GUID at once — no reason to open
// three sessions through `probe_one`. Featureless builds fall through to `probe_one`'s
// defensive all-false (= "nothing probed" → static superset), matching
// `windows_backend_is_probed`.
#[cfg(feature = "nvenc")]
WindowsBackend::Nvenc => nvenc::probe_codec_support(),
_ => CodecSupport {
h264: probe_one(Codec::H264), h264: probe_one(Codec::H264),
h265: probe_one(Codec::H265), h265: probe_one(Codec::H265),
av1: probe_one(Codec::Av1), av1: probe_one(Codec::Av1),
},
}; };
tracing::info!( tracing::info!(
?backend, ?backend,
h264 = caps.h264, h264 = caps.h264,
h265 = caps.h265, h265 = caps.h265,
av1 = caps.av1, av1 = caps.av1,
"Windows AMF/QSV encode capabilities probed" "Windows encode capabilities probed"
); );
// A concurrent first call may double-probe; both arrive at the same answer, last insert wins. // A concurrent first call may double-probe; both arrive at the same answer, last insert wins.
cache.lock().unwrap().insert(key, caps); cache.lock().unwrap().insert(key, caps);
+9
View File
@@ -61,6 +61,12 @@ pub fn env_on(name: &str) -> Option<bool> {
/// derived `Debug` impl, so the parser can stay a single platform-neutral function. /// derived `Debug` impl, so the parser can stay a single platform-neutral function.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct HostConfig { pub struct HostConfig {
/// `PUNKTFUNK_HOST_NAME` — the name this host shows up under in Moonlight (the serverinfo
/// `<hostname>` element) and in Punktfunk's own clients (the mDNS service *instance* name both
/// adverts carry). Unset/blank = the machine's own hostname, which is what it always was. Free
/// text ("Living Room PC"); the DNS-level `<label>.local.` target keeps using a sanitized
/// machine-safe label, so a spacey display name can't produce an invalid mDNS record.
pub host_name: Option<String>,
/// `PUNKTFUNK_ENCODER` — explicit encoder-backend override (lowercased; empty = auto-detect by GPU vendor). /// `PUNKTFUNK_ENCODER` — explicit encoder-backend override (lowercased; empty = auto-detect by GPU vendor).
pub encoder_pref: String, pub encoder_pref: String,
/// `PUNKTFUNK_RENDER_ADAPTER` — discrete render-GPU pin by description substring (`Some` even when empty: /// `PUNKTFUNK_RENDER_ADAPTER` — discrete render-GPU pin by description substring (`Some` even when empty:
@@ -159,6 +165,9 @@ impl HostConfig {
// (`PUNKTFUNK_IDD_PUSH` was removed: IDD-push is the sole Windows capture path, so the knob // (`PUNKTFUNK_IDD_PUSH` was removed: IDD-push is the sole Windows capture path, so the knob
// only split dispatch — capture ignored it while the vdisplay manager obeyed it, and `=0` // only split dispatch — capture ignored it while the vdisplay manager obeyed it, and `=0`
// produced dead-swap-chain reuse on reconnect. A stale setting in an old host.env is ignored.) // produced dead-swap-chain reuse on reconnect. A stale setting in an old host.env is ignored.)
host_name: val("PUNKTFUNK_HOST_NAME")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
encoder_pref: std::env::var("PUNKTFUNK_ENCODER") encoder_pref: std::env::var("PUNKTFUNK_ENCODER")
.unwrap_or_default() .unwrap_or_default()
.to_ascii_lowercase(), .to_ascii_lowercase(),
+4
View File
@@ -59,6 +59,10 @@ windows = { version = "0.62", features = [
"Win32_Devices_Enumeration_Pnp", "Win32_Devices_Enumeration_Pnp",
# SwDeviceCreate's SW_DEVICE_CREATE_INFO references DEVPROPKEY (Properties). # SwDeviceCreate's SW_DEVICE_CREATE_INFO references DEVPROPKEY (Properties).
"Win32_Devices_Properties", "Win32_Devices_Properties",
# The channel proof: HidD_GetIndexedString + GUID_DEVINTERFACE_HID (the pads/mouse leg) and
# CreateFileW to open the device interface the proof is asked over.
"Win32_Devices_HumanInterfaceDevice",
"Win32_Storage_FileSystem",
"Win32_System_Memory", "Win32_System_Memory",
"Win32_System_IO", "Win32_System_IO",
"Win32_System_StationsAndDesktops", "Win32_System_StationsAndDesktops",
@@ -551,7 +551,7 @@ pub fn deck_unit_id(index: u8) -> u32 {
/// serial passes, so we keep the PunktFunk marker one slot in (`"FVPF"`) — still distinct from a /// serial passes, so we keep the PunktFunk marker one slot in (`"FVPF"`) — still distinct from a
/// real Deck's `"FVZZ"` for the self-detection below while satisfying Steam's format check. /// real Deck's `"FVZZ"` for the self-detection below while satisfying Steam's format check.
/// Derived from [`deck_unit_id`] so the `0xAE` serial reply and the `0x83` unit-id attrs stay /// Derived from [`deck_unit_id`] so the `0xAE` serial reply and the `0x83` unit-id attrs stay
/// consistent. (The Windows UMDF driver mirrors this exact format — see pf-dualsense lib.rs.) /// consistent. (The Windows UMDF driver mirrors this exact format — see pf-gamepad lib.rs.)
pub fn deck_serial(index: u8) -> String { pub fn deck_serial(index: u8) -> String {
format!("FVPF{:08X}", deck_unit_id(index)) format!("FVPF{:08X}", deck_unit_id(index))
} }
@@ -0,0 +1,512 @@
//! Ask a devnode which process is serving it — the host half of
//! [`pf_driver_proto::gamepad::ChannelProof`].
//!
//! This is what the pad channel trusts instead of the bootstrap mailbox's `driver_pid`. The mailbox
//! has to be openable by LocalService (that is what the driver's own WUDFHost runs as), and the
//! delivery gate `verify_is_wudfhost` only checks that the named process's IMAGE is the system
//! `WUDFHost.exe` — which is world-executable. So through gamepad proto v2 any LocalService
//! principal, notably the deliberately de-privileged plugin runner, could spawn its own WUDFHost,
//! publish that pid, and be handed the pad's live DATA section: forged HID input into the interactive
//! desktop and a read of the remote user's controller state (security-review 2026-07-28).
//!
//! No host-side check could tell the two apart. Everything the real driver can read at LocalService —
//! the devnode's Location, its `Device Parameters` key, the object namespace — an attacker at
//! LocalService can read too, and both are session-0 LocalService processes running the same image,
//! so token, session and image checks all discriminate nothing. The one thing an attacker cannot
//! forge is **being the driver bound to the devnode we created**: I/O sent to that device reaches
//! only the driver PnP bound to it, and we look the device up by the instance id `SwDeviceCreate`
//! handed back, so a planted look-alike devnode is not in the running either.
//!
//! Three transports, one per driver shape — see [`ProofTransport`] for what each of them cost
//! and why the obvious ones did not survive contact with hidclass.
use anyhow::{anyhow, bail, Context, Result};
use pf_driver_proto::gamepad::{
ChannelProof, DECK_PROOF_CMD, HID_FEATURE_REPORT_CHANNEL_PROOF, HID_STRING_INDEX_CHANNEL_PROOF,
IOCTL_PF_GET_CHANNEL_PROOF,
};
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use windows::core::{GUID, PCWSTR};
use windows::Win32::Devices::DeviceAndDriverInstallation::{
CM_Get_Child, CM_Get_Device_IDW, CM_Get_Device_Interface_ListW,
CM_Get_Device_Interface_List_SizeW, CM_Get_Sibling, CM_Locate_DevNodeW,
CM_GET_DEVICE_INTERFACE_LIST_PRESENT, CM_LOCATE_DEVNODE_NORMAL, CR_SUCCESS,
};
use windows::Win32::Devices::HumanInterfaceDevice::{
HidD_GetFeature, HidD_GetIndexedString, HidD_GetProductString, HidD_GetSerialNumberString,
HidD_SetFeature, GUID_DEVINTERFACE_HID,
};
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Storage::FileSystem::{
CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
};
use windows::Win32::System::IO::DeviceIoControl;
/// `GUID_DEVINTERFACE_XUSB` {EC87F1E3-C13B-4100-B5F7-8B84D54260CB} — the interface `pf_xusb`
/// registers on its own devnode (and what `xinput1_4` enumerates).
const GUID_DEVINTERFACE_XUSB: GUID = GUID::from_u128(0xEC87F1E3_C13B_4100_B5F7_8B84D54260CB);
/// How a driver answers the proof — one variant per driver shape, because what Windows actually
/// carries to each shape differs (all three measured on .173 / Win11 26200, 2026-07-28).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProofTransport {
/// `pf_xusb`: a private IOCTL on `GUID_DEVINTERFACE_XUSB`, the interface it registers on its own
/// devnode. Works because it is NOT a HID minidriver — nothing sits above it, so it owns both
/// `IRP_MJ_CREATE` and its own IOCTL dispatch.
XusbIoctl,
/// `pf_mouse`: the proof arrives as the HID **serial-number string**. The one transport that
/// survived measurement against a UMDF HID minidriver — `HidD_GetSerialNumberString` on a
/// zero-access handle, verified end to end (`PFCP:3:0:7296`, and 7296 was a real
/// service-spawned `WUDFHost.exe`). Safe here because nothing reads the virtual mouse's serial.
HidSerialString,
/// `pf_gamepad` (DualSense / DualShock 4 / Edge / Deck): a HID **feature report**.
///
/// The pads cannot use the serial string — it is what SDL and Steam dedup controllers on, and
/// Steam is known to mangle a pad's displayed name over serial FORMAT alone. They get a feature
/// report instead, and it costs NO report-descriptor change, because the captured descriptors
/// already declare far more feature ids than the driver ever served: `0x85` is declared as a
/// Feature report on DualSense, DualShock 4 and Edge alike and used to fail with
/// `STATUS_INVALID_PARAMETER`. The Deck declares one UNNUMBERED feature report driven as
/// command→response, so its proof rides that existing contract via a private two-byte command.
HidFeatureReport,
}
/// Ask the devnode `instance_id` (as `SwDeviceCreate` reported it) which process serves it, and
/// return that pid — already checked against `expect_pad_index` and this build's protocol version.
///
/// Every failure is a refusal to deliver, so the errors say exactly which step failed: an operator
/// staring at a dead gamepad needs to know whether the devnode is missing, the interface has not
/// appeared yet, or a driver answered something we did not mint.
pub(super) fn query(
instance_id: &str,
transport: ProofTransport,
expect_pad_index: u32,
) -> Result<u32> {
let paths = match transport {
ProofTransport::XusbIoctl => interface_paths(&GUID_DEVINTERFACE_XUSB, instance_id)
.with_context(|| format!("enumerate the XUSB interface on {instance_id}"))?,
ProofTransport::HidSerialString | ProofTransport::HidFeatureReport => {
// hidclass publishes the collection interface on a CHILD PDO, not on our devnode.
let children = child_device_ids(instance_id)
.with_context(|| format!("enumerate the HID children of {instance_id}"))?;
let mut all = Vec::new();
for child in &children {
all.extend(interface_paths(&GUID_DEVINTERFACE_HID, child).unwrap_or_default());
}
all
}
};
if paths.is_empty() {
bail!(
"no device interface for {instance_id} yet — the driver has not finished starting (or \
is not bound to this devnode at all)"
);
}
// A devnode can publish more than one collection; ask each and take the first well-formed proof.
let mut last_err = None;
for path in &paths {
let r = match transport {
ProofTransport::XusbIoctl => ask_ioctl(path, expect_pad_index),
ProofTransport::HidSerialString => ask_hid_path(path, expect_pad_index),
ProofTransport::HidFeatureReport => ask_feature_path(path, expect_pad_index),
};
match r {
Ok(pid) => return Ok(pid),
Err(e) => last_err = Some(e.context(format!("ask {path}"))),
}
}
Err(last_err.unwrap_or_else(|| anyhow!("no interface answered a channel proof")))
}
/// Open `path` and put the proof IOCTL to it (the private pad-control interface, and `pf_xusb`'s own).
fn ask_ioctl(path: &str, expect_pad_index: u32) -> Result<u32> {
let handle = open_device(path)?;
let proof = ask_xusb(HANDLE(handle.as_raw_handle()))?;
proof
.check(expect_pad_index)
.map_err(|why| anyhow!("{why}"))
}
/// Open a HID collection and read the proof out of a FEATURE report — the pad transport.
fn ask_feature_path(path: &str, expect_pad_index: u32) -> Result<u32> {
let handle = open_device(path)?;
let proof = ask_feature(HANDLE(handle.as_raw_handle()))?;
proof
.check(expect_pad_index)
.map_err(|why| anyhow!("{why}"))
}
/// The PS identities answer on the declared-but-unserved report `0x85`; the Deck answers its
/// unnumbered report after a private SET_FEATURE command. Both are tried — one driver binary serves
/// four identities and the host does not know which one this devnode became until the DATA section
/// is attached, which is precisely what we are trying to earn the right to do.
fn ask_feature(h: HANDLE) -> Result<ChannelProof> {
// Feature buffers are sized by the descriptor; the largest of these reports is 64 bytes, and
// Windows accepts a buffer at least that big.
const BUF: usize = 64;
// PS identities: GET_FEATURE 0x85.
let mut buf = [0u8; BUF];
buf[0] = HID_FEATURE_REPORT_CHANNEL_PROOF;
// SAFETY: `h` is the live HID interface handle; `buf` is a valid BUF-sized in/out buffer.
if unsafe { HidD_GetFeature(h, buf.as_mut_ptr().cast(), BUF as u32) } {
if let Some(p) = ChannelProof::from_feature_report(&buf) {
return Ok(p);
}
}
// Deck: SET the private command, then GET the reply. Byte 0 is the (unnumbered) report id 0.
let mut cmd = [0u8; BUF];
cmd[1..1 + DECK_PROOF_CMD.len()].copy_from_slice(&DECK_PROOF_CMD);
// SAFETY: `h` is live; `cmd` is a valid BUF-sized buffer.
let set_ok = unsafe { HidD_SetFeature(h, cmd.as_mut_ptr().cast(), BUF as u32) };
if set_ok {
let mut reply = [0u8; BUF];
// SAFETY: as above.
if unsafe { HidD_GetFeature(h, reply.as_mut_ptr().cast(), BUF as u32) }
&& reply.starts_with(&DECK_PROOF_CMD)
{
if let Some(p) = ChannelProof::from_bytes(&reply[DECK_PROOF_CMD.len()..]) {
return Ok(p);
}
}
}
bail!(
"this HID collection carries no channel proof (feature 0x{:02x}: no; Deck command: {}) — \
the driver predates the proof (reinstall: punktfunk-host.exe driver install --gamepad)",
HID_FEATURE_REPORT_CHANNEL_PROOF,
if set_ok {
"no matching reply"
} else {
"SET_FEATURE failed"
},
)
}
/// Open a HID collection and read the proof out of a string.
fn ask_hid_path(path: &str, expect_pad_index: u32) -> Result<u32> {
let handle = open_device(path)?;
let proof = ask_hid(HANDLE(handle.as_raw_handle()))?;
proof
.check(expect_pad_index)
.map_err(|why| anyhow!("{why}"))
}
/// [`query`] for the `channel-proof-probe` subcommand — same call the delivery path makes, so the
/// probe reports the production answer rather than a re-implementation of it.
pub fn probe_pid(
instance_id: &str,
transport: ProofTransport,
expect_pad_index: u32,
) -> Result<u32> {
query(instance_id, transport, expect_pad_index)
}
/// A human-readable walk of the same lookup [`query`] does, reporting each step and — for the HID
/// leg — which of the two IOCTLs Windows actually forwarded to the minidriver.
///
/// This exists because exactly one thing in this design could not be settled by reading: whether
/// hidclass forwards `IOCTL_HID_GET_INDEXED_STRING` (and/or an arbitrary-index `IOCTL_HID_GET_STRING`)
/// down to a UMDF HID minidriver. Both are wired up so the answer only has to be "at least one", but
/// a maintainer should be able to find out which on a real box in one command rather than by
/// inference — hence `punktfunk-host channel-proof-probe`.
pub fn diagnose(instance_id: &str, transport: ProofTransport, expect_pad_index: u32) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(out, "devnode : {instance_id}");
let _ = writeln!(
out,
"transport: {transport:?}, expected pad index {expect_pad_index}"
);
let paths = match transport {
ProofTransport::XusbIoctl => match interface_paths(&GUID_DEVINTERFACE_XUSB, instance_id) {
Ok(p) => p,
Err(e) => {
let _ = writeln!(out, " XUSB interface lookup FAILED: {e:#}");
return out;
}
},
ProofTransport::HidSerialString | ProofTransport::HidFeatureReport => {
let children = child_device_ids(instance_id).unwrap_or_default();
let _ = writeln!(out, " hidclass children: {}", children.len());
let mut all = Vec::new();
for c in &children {
let p = interface_paths(&GUID_DEVINTERFACE_HID, c).unwrap_or_default();
let _ = writeln!(out, " {c} -> {} HID interface(s)", p.len());
all.extend(p);
}
all
}
};
if paths.is_empty() {
let _ = writeln!(
out,
" NO device interface — the driver has not started, or is not bound to this devnode"
);
return out;
}
for path in &paths {
let _ = writeln!(out, " interface {path}");
let handle = match open_device(path) {
Ok(h) => h,
Err(e) => {
let _ = writeln!(out, " open FAILED: {e:#}");
continue;
}
};
let h = HANDLE(handle.as_raw_handle());
match transport {
ProofTransport::XusbIoctl => match ask_xusb(h) {
Ok(p) => {
let _ = writeln!(out, " IOCTL_PF_GET_CHANNEL_PROOF -> {p:?}");
let _ = writeln!(out, " check: {:?}", p.check(expect_pad_index));
}
Err(e) => {
let _ = writeln!(out, " IOCTL_PF_GET_CHANNEL_PROOF FAILED: {e:#}");
}
},
ProofTransport::HidFeatureReport => match ask_feature(h) {
Ok(p) => {
let _ = writeln!(out, " feature proof -> {p:?}");
let _ = writeln!(out, " check: {:?}", p.check(expect_pad_index));
}
Err(e) => {
let _ = writeln!(out, " feature proof FAILED: {e:#}");
}
},
ProofTransport::HidSerialString => {
// Report each path separately — this is the whole point of the probe.
let (indexed, serial, control) = ask_hid_both(h);
let _ = writeln!(out, " HidD_GetSerialNumberString -> {serial}");
let _ = writeln!(out, " HidD_GetIndexedString(proof) -> {indexed}");
let _ = writeln!(out, " HidD_GetProductString -> {control}");
}
}
}
out
}
/// The probe's raw material: the proof index alongside a KNOWN-GOOD control, so a failure can be
/// told apart from "user mode cannot reach this driver at all".
///
/// The control is `HidD_GetProductString`, which on-glass succeeds against a UMDF HID minidriver on
/// the very same 0-access handle where `HidD_GetIndexedString` fails for every index — the
/// measurement that established the indexed-string transport is unusable (see [`ask_hid`]).
fn ask_hid_both(h: HANDLE) -> (String, String, String) {
let text = |ok: bool, buf: &[u16]| -> String {
if !ok {
let e = std::io::Error::last_os_error();
return format!("call failed ({e})");
}
let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
String::from_utf16_lossy(&buf[..end])
};
let mut a = [0u16; 128];
let bytes = (a.len() * 2) as u32;
// SAFETY: `h` is the live HID interface handle; `a` is a valid `bytes`-sized out-buffer.
let ok_a = unsafe {
HidD_GetIndexedString(
h,
HID_STRING_INDEX_CHANNEL_PROOF,
a.as_mut_ptr().cast(),
bytes,
)
};
let indexed = match (ok_a, decode_proof(&a)) {
(true, Some(p)) => format!("{p:?}"),
(true, None) => format!("answered, but not a proof: {:?}", text(true, &a)),
(false, _) => text(false, &a),
};
let mut c = [0u16; 128];
// SAFETY: as above — live handle, valid out-buffer.
let ok_c = unsafe { HidD_GetSerialNumberString(h, c.as_mut_ptr().cast(), bytes) };
let serial = match (ok_c, decode_proof(&c)) {
(true, Some(p)) => format!("{p:?}"),
(true, None) => format!("plain serial, no proof: {:?}", text(true, &c)),
(false, _) => text(false, &c),
};
let mut b = [0u16; 128];
// SAFETY: as above — live handle, valid out-buffer.
let ok_b = unsafe { HidD_GetProductString(h, b.as_mut_ptr().cast(), bytes) };
let control = format!(
"{} (control: proves user mode reaches this driver)",
text(ok_b, &b)
);
(indexed, serial, control)
}
/// `CreateFileW` with **no** access rights: enough to send `FILE_ANY_ACCESS` IOCTLs and to run the
/// `HidD_*` helpers, and the only thing that works on a HID mouse/keyboard collection, which Windows
/// refuses to open for read from user mode.
fn open_device(path: &str) -> Result<OwnedHandle> {
let wide: Vec<u16> = path.encode_utf16().chain(std::iter::once(0)).collect();
// SAFETY: `wide` is a valid NUL-terminated UTF-16 path for the duration of the call; the returned
// handle is owned solely by the `OwnedHandle` built from it.
let h = unsafe {
CreateFileW(
PCWSTR(wide.as_ptr()),
0,
FILE_SHARE_READ | FILE_SHARE_WRITE,
None,
OPEN_EXISTING,
FILE_FLAGS_AND_ATTRIBUTES(0),
None,
)
.with_context(|| format!("CreateFileW({path})"))?
};
// SAFETY: `h` is the fresh handle just opened, moved into a single owner that closes it on drop.
Ok(unsafe { OwnedHandle::from_raw_handle(h.0 as _) })
}
/// `pf_xusb`: a private METHOD_BUFFERED IOCTL returning the 16 proof bytes.
fn ask_xusb(h: HANDLE) -> Result<ChannelProof> {
let mut buf = [0u8; 16];
let mut returned = 0u32;
// SAFETY: `h` is the live interface handle; `buf` is a valid 16-byte out-buffer and `returned`
// a valid out-param. METHOD_BUFFERED, so the I/O manager copies — no pointer escapes.
unsafe {
DeviceIoControl(
h,
IOCTL_PF_GET_CHANNEL_PROOF,
None,
0,
Some(buf.as_mut_ptr().cast()),
buf.len() as u32,
Some(&mut returned),
None,
)
.context("IOCTL_PF_GET_CHANNEL_PROOF")?;
}
ChannelProof::from_bytes(&buf[..returned as usize]).ok_or_else(|| {
anyhow!("the XUSB interface returned {returned} bytes, too short for a channel proof")
})
}
/// `pf_gamepad` / `pf_mouse`: the reserved HID string index.
///
/// ⚠️ **ON-HARDWARE FINDING, .173 (Win11 26200), 2026-07-28 — this transport DOES NOT WORK.**
/// Probed against a live `pf_mouse` devnode with the installed driver: on the SAME 0-access handle,
/// `HidD_GetManufacturerString` / `GetProductString` / `GetSerialNumberString` all succeeded and
/// returned the driver's OWN strings (so user mode does reach a UMDF HID minidriver, and hidclass
/// does translate the named string IOCTLs down to its `IOCTL_HID_GET_STRING` handler) — while
/// `HidD_GetIndexedString` failed for EVERY index tried: 1, 2, 4, 0x0E and 0x5046. Indices 2 and
/// 0x0E are ones the driver demonstrably serves through the named wrappers, so this is not our
/// reserved index being out of range: hidclass does not carry an arbitrary indexed-string request to
/// a UMDF HID minidriver at all.
///
/// The call is kept because it costs one failed IOCTL and is the correct thing to ask first if a
/// later Windows starts forwarding it. What was REMOVED here is a second "fallback" that issued
/// `IOCTL_HID_GET_STRING` directly: that IOCTL is METHOD_NEITHER and **kernel-facing** — hidclass
/// sends it DOWN to the minidriver, user mode never sends it up — so it could not have worked, and
/// on-glass it returned `ERROR_INVALID_FUNCTION` for a control index the device definitely serves.
/// Do not re-add it.
///
/// The HID pads/mouse therefore still need a working proof transport; see the module docs.
fn ask_hid(h: HANDLE) -> Result<ChannelProof> {
let mut buf = [0u16; 128];
let bytes = (buf.len() * 2) as u32;
// SAFETY: `h` is the live HID interface handle; `buf` is a valid `bytes`-sized out-buffer.
if unsafe { HidD_GetSerialNumberString(h, buf.as_mut_ptr().cast(), bytes) } {
if let Some(p) = decode_proof(&buf) {
return Ok(p);
}
let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
bail!(
"this HID collection's serial is {:?}, not a channel proof — an old driver is installed \
(reinstall: punktfunk-host.exe driver install --gamepad)",
String::from_utf16_lossy(&buf[..end])
);
}
bail!("HidD_GetSerialNumberString failed on this HID collection")
}
/// Decode a NUL-terminated UTF-16 HID string answer into a proof.
fn decode_proof(buf: &[u16]) -> Option<ChannelProof> {
let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
let s = String::from_utf16(&buf[..end]).ok()?;
ChannelProof::from_hid_string(&s)
}
/// Every present device-interface path of `class` on `device_id`.
fn interface_paths(class: &GUID, device_id: &str) -> Result<Vec<String>> {
let wide: Vec<u16> = device_id.encode_utf16().chain(std::iter::once(0)).collect();
let mut len = 0u32;
// SAFETY: `len` is a valid out-param; `wide` is a NUL-terminated id valid for the call.
let cr = unsafe {
CM_Get_Device_Interface_List_SizeW(
&mut len,
class,
PCWSTR(wide.as_ptr()),
CM_GET_DEVICE_INTERFACE_LIST_PRESENT,
)
};
if cr != CR_SUCCESS || len == 0 {
return Ok(Vec::new());
}
let mut buf = vec![0u16; len as usize];
// SAFETY: `buf` is `len` UTF-16 units as the size call just reported; same id/class as above.
let cr = unsafe {
CM_Get_Device_Interface_ListW(
class,
PCWSTR(wide.as_ptr()),
&mut buf,
CM_GET_DEVICE_INTERFACE_LIST_PRESENT,
)
};
if cr != CR_SUCCESS {
bail!("CM_Get_Device_Interface_ListW failed (CONFIGRET {})", cr.0);
}
// A REG_MULTI_SZ-shaped list: NUL-separated, double-NUL terminated.
Ok(buf
.split(|&c| c == 0)
.filter(|s| !s.is_empty())
.map(String::from_utf16_lossy)
.collect())
}
/// The instance ids of `instance_id`'s immediate children — for a HID minidriver, the collection
/// PDOs hidclass created under our devnode.
fn child_device_ids(instance_id: &str) -> Result<Vec<String>> {
let wide: Vec<u16> = instance_id
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let mut devinst = 0u32;
// SAFETY: `devinst` is a valid out-param; `wide` is a NUL-terminated id valid for the call.
let cr = unsafe {
CM_Locate_DevNodeW(
&mut devinst,
PCWSTR(wide.as_ptr()),
CM_LOCATE_DEVNODE_NORMAL,
)
};
if cr != CR_SUCCESS {
bail!(
"CM_Locate_DevNodeW({instance_id}) failed (CONFIGRET {})",
cr.0
);
}
let mut out = Vec::new();
let mut child = 0u32;
// SAFETY: `devinst` is the devnode just located; `child` is a valid out-param.
if unsafe { CM_Get_Child(&mut child, devinst, 0) } != CR_SUCCESS {
return Ok(out); // no children yet — hidclass has not enumerated the collections
}
loop {
let mut buf = [0u16; 512];
// SAFETY: `child` is a live devnode handle from CM_Get_Child/CM_Get_Sibling; `buf` is a
// valid out-buffer whose length the binding passes along.
if unsafe { CM_Get_Device_IDW(child, &mut buf, 0) } == CR_SUCCESS {
let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
out.push(String::from_utf16_lossy(&buf[..end]));
}
let mut next = 0u32;
// SAFETY: as above — `child` is live, `next` is a valid out-param.
if unsafe { CM_Get_Sibling(&mut next, child, 0) } != CR_SUCCESS {
break;
}
child = next;
}
Ok(out)
}
@@ -1,4 +1,4 @@
//! Virtual Sony DualSense on Windows via the UMDF minidriver (`packaging/windows/drivers/pf-dualsense`). //! Virtual Sony DualSense on Windows via the UMDF minidriver (`packaging/windows/drivers/pf-gamepad`).
//! //!
//! The Windows analogue of the Linux UHID backend ([`super::dualsense`]): same [`DsState`] model and //! The Windows analogue of the Linux UHID backend ([`super::dualsense`]): same [`DsState`] model and
//! the same byte-level report codec ([`super::dualsense_proto`]), but a different transport. Where //! the same byte-level report codec ([`super::dualsense_proto`]), but a different transport. Where
@@ -37,7 +37,7 @@ use windows::Win32::Foundation::{CloseHandle, E_FAIL, WAIT_OBJECT_0};
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject}; use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
/// Shared-section layout — the single source of truth is [`pf_driver_proto::gamepad::PadShm`] (offset /// Shared-section layout — the single source of truth is [`pf_driver_proto::gamepad::PadShm`] (offset
/// asserts pin every field; the `pf_dualsense` driver maps the same struct). Derive the size/offsets/magic /// asserts pin every field; the `pf_gamepad` driver maps the same struct). Derive the size/offsets/magic
/// from it so a layout change is a compile error, not a hand-synced literal (audit §6.1). `pub(super)` so /// from it so a layout change is a compile error, not a hand-synced literal (audit §6.1). `pub(super)` so
/// the sibling DualShock 4 backend ([`super::dualshock4_windows`]) reuses the exact offsets. /// the sibling DualShock 4 backend ([`super::dualshock4_windows`]) reuses the exact offsets.
pub(super) const SHM_SIZE: usize = core::mem::size_of::<pf_driver_proto::gamepad::PadShm>(); pub(super) const SHM_SIZE: usize = core::mem::size_of::<pf_driver_proto::gamepad::PadShm>();
@@ -385,7 +385,7 @@ impl WinDsIdentity {
WinDsIdentity { WinDsIdentity {
devtype: 0, devtype: 0,
instance_prefix: "pf_pad", instance_prefix: "pf_pad",
hwid: "pf_dualsense", hwid: "pf_gamepad",
usb_vid_pid: "VID_054C&PID_0CE6", usb_vid_pid: "VID_054C&PID_0CE6",
description: "punktfunk Virtual DualSense", description: "punktfunk Virtual DualSense",
} }
@@ -444,6 +444,13 @@ impl DsWinPad {
(None, None) (None, None)
} }
}; };
// The DATA section goes to whoever THIS devnode says is serving it — not to whatever pid
// the LocalService-writable mailbox names (security-review 2026-07-28).
channel.bind_devnode(
index as u32,
instance_id.clone(),
super::gamepad_raii::ProofTransport::HidFeatureReport,
);
let _sw = hsw.map(super::gamepad_raii::SwDevice::new); let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
// Bounded eager delivery so the driver holds the DATA section before hidclass asks it for // Bounded eager delivery so the driver holds the DATA section before hidclass asks it for
// descriptors (the driver reads `device_type` from the section to pick its HID identity). // descriptors (the driver reads `device_type` from the section to pick its HID identity).
@@ -453,8 +460,8 @@ impl DsWinPad {
channel, channel,
attach: super::gamepad_raii::DriverAttach::new( attach: super::gamepad_raii::DriverAttach::new(
id.hwid, id.hwid,
"pf_dualsense.inf", // one driver package serves every PS identity "pf_gamepad.inf", // one driver package serves every PS identity
"C:\\Users\\Public\\pfds-driver.log", "C:\\Windows\\ServiceProfiles\\LocalService\\AppData\\Local\\Temp\\pf_gamepad-driver.log",
boot_name, boot_name,
instance_id, instance_id,
), ),
@@ -472,7 +479,7 @@ impl DsWinPad {
serialize_state(&mut r, st, self.seq, self.ts); serialize_state(&mut r, st, self.seq, self.ts);
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. Unlike the // SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. Unlike the
// XUSB `packet` / DualSense `out_seq` fields, the input path has NO driver-polled change-detect // XUSB `packet` / DualSense `out_seq` fields, the input path has NO driver-polled change-detect
// field to publish last: the `pf_dualsense` driver streams the whole `input` region to game // field to publish last: the `pf_gamepad` driver streams the whole `input` region to game
// READ_REPORTs on its ~125 Hz timer, and the report's own sequence counter (r[7], mid-report) // READ_REPORTs on its ~125 Hz timer, and the report's own sequence counter (r[7], mid-report)
// is consumed by the game's HID stack, not the driver — so it cannot serve as a separable // is consumed by the game's HID stack, not the driver — so it cannot serve as a separable
// publish flag without a seqlock generation the driver `Acquire`-reads (a `PadShm` layout + // publish flag without a seqlock generation the driver `Acquire`-reads (a `PadShm` layout +
@@ -622,7 +629,7 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
} }
let inst = format!("pf_deckspike_{index}"); let inst = format!("pf_deckspike_{index}");
let (hsw, _) = create_swdevice(&SwDeviceProfile { let (hsw, spike_instance_id) = create_swdevice(&SwDeviceProfile {
instance: &inst, instance: &inst,
container_tag: 0x5046_4453, // "PFDS" container_tag: 0x5046_4453, // "PFDS"
container_index: index, container_index: index,
@@ -633,6 +640,13 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
usb_mi: Some(2), usb_mi: Some(2),
description: "punktfunk Virtual Steam Deck (spike)", description: "punktfunk Virtual Steam Deck (spike)",
})?; })?;
// The spike drives a real pad channel, so it takes the same devnode-proved delivery a session
// pad does — no special case, and no reason for a bring-up tool to run on the old trust.
channel.bind_devnode(
index as u32,
spike_instance_id,
super::gamepad_raii::ProofTransport::HidFeatureReport,
);
let _sw = super::gamepad_raii::SwDevice::new(hsw); let _sw = super::gamepad_raii::SwDevice::new(hsw);
channel.deliver_eager(std::time::Duration::from_millis(1500)); channel.deliver_eager(std::time::Duration::from_millis(1500));
println!( println!(
@@ -77,6 +77,13 @@ impl Ds4WinPad {
// `gate.on_success()` cleared the backoff, so the create-gate that exists precisely to // `gate.on_success()` cleared the backoff, so the create-gate that exists precisely to
// self-heal a transient PnP failure never retried. The game saw no controller for the whole // self-heal a transient PnP failure never retried. The game saw no controller for the whole
// session unless the client unplugged the pad. Matches the XUSB sibling, which propagates. // session unless the client unplugged the pad. Matches the XUSB sibling, which propagates.
// The DATA section goes to whoever THIS devnode says is serving it — not to whatever pid
// the LocalService-writable mailbox names (security-review 2026-07-28).
channel.bind_devnode(
index as u32,
instance_id.clone(),
super::gamepad_raii::ProofTransport::HidFeatureReport,
);
let _sw = hsw.map(super::gamepad_raii::SwDevice::new); let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
// Bounded eager delivery — for the DS4 this is what closes the identity race: the driver // Bounded eager delivery — for the DS4 this is what closes the identity race: the driver
// must read `device_type = 1` from the delivered DATA section before hidclass asks it for // must read `device_type = 1` from the delivered DATA section before hidclass asks it for
@@ -87,8 +94,8 @@ impl Ds4WinPad {
channel, channel,
attach: super::gamepad_raii::DriverAttach::new( attach: super::gamepad_raii::DriverAttach::new(
"pf_dualshock4", "pf_dualshock4",
"pf_dualsense.inf", // one driver package serves both HID identities "pf_gamepad.inf", // one driver package serves both HID identities
"C:\\Users\\Public\\pfds-driver.log", "C:\\Windows\\ServiceProfiles\\LocalService\\AppData\\Local\\Temp\\pf_gamepad-driver.log",
boot_name, boot_name,
instance_id, instance_id,
), ),
@@ -1,7 +1,7 @@
//! Per-pad Windows resource RAII + the **sealed gamepad channel** broker (DualSense / DualShock 4 / //! Per-pad Windows resource RAII + the **sealed gamepad channel** broker (DualSense / DualShock 4 /
//! XUSB backends). //! XUSB backends).
//! //!
//! Each virtual pad owns three OS resources: the **unnamed** DATA section the `pf_dualsense`/`pf_xusb` //! Each virtual pad owns three OS resources: the **unnamed** DATA section the `pf_gamepad`/`pf_xusb`
//! driver works against (`XusbShm`/`PadShm`), the tiny **named** bootstrap mailbox //! driver works against (`XusbShm`/`PadShm`), the tiny **named** bootstrap mailbox
//! (`pf_driver_proto::gamepad::PadBootstrap`) that hands the driver a duplicated handle to it, and the //! (`pf_driver_proto::gamepad::PadBootstrap`) that hands the driver a duplicated handle to it, and the
//! `SwDeviceCreate`'d software devnode the driver loads on. [`Shm`] and [`SwDevice`] own the resources //! `SwDeviceCreate`'d software devnode the driver loads on. [`Shm`] and [`SwDevice`] own the resources
@@ -15,11 +15,44 @@
//! into its WUDFHost (a duplicated handle carries the source's access, so no LS ACE is needed). The pad //! into its WUDFHost (a duplicated handle carries the source's access, so no LS ACE is needed). The pad
//! drivers are UMDF HID minidrivers with **no control device** (hidclass owns the stack), so unlike the //! drivers are UMDF HID minidrivers with **no control device** (hidclass owns the stack), so unlike the
//! frame channel there is no IOCTL to deliver the handle or learn the WUDFHost pid — hence the //! frame channel there is no IOCTL to deliver the handle or learn the WUDFHost pid — hence the
//! late-bound [`PadBootstrap`] mailbox handshake, the one *named* object left. It carries only pids and //! late-bound [`PadBootstrap`] mailbox handshake, the one *named* object left.
//! a handle VALUE (meaningless outside the target process), so tampering with it yields at worst a //!
//! gamepad DoS, never a read or an injection; the empirical floor from the frame work holds here too //! **What the mailbox is worth to an attacker** (corrected, security-review 2026-07-28). It carries
//! (a LocalService token is DACL-denied `OpenProcess` on a UMDF WUDFHost for every access right). //! pids and a handle VALUE, and a handle value IS meaningless outside its process — but the mailbox
//! also *chooses that process*, and it is writable by LocalService (the SDDL the driver's WUDFHost
//! needs to open it by name). The delivery gate, [`pf_capture::verify_is_wudfhost`], authenticates an
//! IMAGE PATH, and `%SystemRoot%\System32\WUDFHost.exe` is world-executable: a LocalService principal
//! — the de-privileged plugin runner, or any other compromised LocalService service — can spawn its
//! own suspended WUDFHost, publish that pid, and be handed `SECTION_MAP_READ|WRITE` on this pad's DATA
//! section. That is forged HID input into the interactive desktop (the pf-mouse section drives a real
//! absolute pointer) and a read of the remote user's live controller state — so the earlier claim that
//! mailbox tampering "yields at worst a gamepad DoS, never a read or an injection" was wrong. (The
//! frame channel is NOT exposed this way: its WUDFHost pid arrives over the pf-vdisplay control
//! device, whose DACL is SYSTEM + Administrators.)
//!
//! **How v3 closes it.** The mailbox no longer decides anything. [`PadChannel::pump`] asks the
//! DEVNODE which process is serving it — [`channel_proof`], the host half of
//! `pf_driver_proto::gamepad::ChannelProof` — and duplicates into that answer. Only the driver PnP
//! actually bound to the device we `SwDeviceCreate`d can answer that device's I/O, and the lookup is
//! keyed by the instance id PnP handed back, so neither a spawned WUDFHost nor a planted look-alike
//! devnode is in the running. `driver_pid` survives as a liveness hint and nothing more; a tamperer
//! can still deny a pad, but denial was always available (squat the name) and is not what mattered.
//!
//! Two further rules make the state machine hold up around it:
//! * **One live target.** A delivery stands until its target process EXITS, judged on a retained
//! `SYNCHRONIZE` handle so a recycled pid cannot fake it. UMDF's genuine
//! restart-the-host-after-a-driver-crash path still re-attaches; nothing else displaces a live one.
//! * **No unproved delivery.** A pad with no `SwDeviceCreate` devnode (the out-of-band `devgen`
//! bring-up path) has nothing to ask, so it refuses to deliver rather than fall back to the
//! mailbox — unless an operator sets [`TRUST_MAILBOX_ENV`], which says so loudly in the log.
//!
//! What is NOT claimed: this authenticates the *devnode*, not the driver binary. An attacker who can
//! already replace the installed, signed driver package owns the endpoint by definition — that is the
//! same floor the frame channel accepts, and it sits above the SECURITY.md admin/SYSTEM boundary.
use super::channel_proof;
/// Re-exported so a pad backend needs only one `use` to wire up its channel.
pub(super) use super::channel_proof::ProofTransport;
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use pf_driver_proto::gamepad::{PadBootstrap, BOOT_MAGIC, GAMEPAD_PROTO_VERSION}; use pf_driver_proto::gamepad::{PadBootstrap, BOOT_MAGIC, GAMEPAD_PROTO_VERSION};
use std::ffi::c_void; use std::ffi::c_void;
@@ -35,7 +68,7 @@ use windows::Win32::Devices::DeviceAndDriverInstallation::{
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE}; use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE};
use windows::Win32::Foundation::{ use windows::Win32::Foundation::{
DuplicateHandle, GetLastError, LocalFree, SetLastError, DUPLICATE_HANDLE_OPTIONS, DuplicateHandle, GetLastError, LocalFree, SetLastError, DUPLICATE_HANDLE_OPTIONS,
ERROR_ALREADY_EXISTS, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, WIN32_ERROR, ERROR_ALREADY_EXISTS, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, WAIT_OBJECT_0, WIN32_ERROR,
}; };
use windows::Win32::Security::Authorization::{ use windows::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
@@ -46,7 +79,8 @@ use windows::Win32::System::Memory::{
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE, MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
}; };
use windows::Win32::System::Threading::{ use windows::Win32::System::Threading::{
GetCurrentProcess, OpenProcess, SetEvent, PROCESS_DUP_HANDLE, PROCESS_QUERY_LIMITED_INFORMATION, GetCurrentProcess, OpenProcess, SetEvent, WaitForSingleObject, PROCESS_DUP_HANDLE,
PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE,
}; };
/// Least access the pad driver needs on the duplicated DATA section: it only MAPS it read/write, so /// Least access the pad driver needs on the duplicated DATA section: it only MAPS it read/write, so
@@ -223,28 +257,80 @@ impl Drop for Shm {
/// seq twice. Starts at 1. /// seq twice. Starts at 1.
static BOOT_SEQ: AtomicU32 = AtomicU32::new(1); static BOOT_SEQ: AtomicU32 = AtomicU32::new(1);
/// Hard cap on delivery attempts per pad: each attempt duplicates a handle into a WUDFHost, so a /// Hard cap on FAILED delivery attempts per pad: each attempt duplicates a handle into a WUDFHost, so
/// tampered mailbox flapping `driver_pid` must not mint unbounded remote handles (DoS containment). /// a tampered mailbox flapping `driver_pid` must not mint unbounded remote handles (DoS containment).
/// A legitimate pad needs exactly one (a driver restart within one pad lifetime is not a thing — /// Only failures spend this budget — a SUCCESSFUL delivery stands until its target process exits
/// the WUDFHost dies with the devnode). /// ([`PadChannel::delivered`]), so what is left here is purely the retry allowance for a driver that
/// published a pid and then died before we could reach it.
const MAX_DELIVERY_ATTEMPTS: u32 = 16; const MAX_DELIVERY_ATTEMPTS: u32 = 16;
/// How often the delivery state machine may ask the devnode for its channel proof while unattached.
/// The proof query opens a device interface and does real I/O, and the pad service pump ticks every
/// few milliseconds — without this the poll would be a hot loop on the HID stack. A driver takes tens
/// of milliseconds to publish its interface after `SwDeviceCreate`, so a quarter second costs nothing
/// perceptible at pad open and keeps the steady-state cost at zero (once delivered, nothing polls).
const PROOF_PROBE_INTERVAL: Duration = Duration::from_millis(250);
/// Operator escape hatch for the one case the device stack cannot answer: an **out-of-band devnode**
/// (`devgen`/`devcon`, the driver bring-up path) where `SwDeviceCreate` never ran, so the host has no
/// instance id to look an interface up by. Set it and the channel falls back to the pre-v3 behaviour
/// of trusting the mailbox's `driver_pid`, which is exactly the trust the security review removed —
/// hence opt-in, per-boot, and loud in the log. Never needed for a normal host: every pad and the
/// resident mouse are `SwDeviceCreate`d.
const TRUST_MAILBOX_ENV: &str = "PUNKTFUNK_PAD_CHANNEL_TRUST_MAILBOX";
/// Consecutive unanswered proof queries before the debug line escalates to one operator-facing warn.
/// At [`PROOF_PROBE_INTERVAL`] this is ~5 s — comfortably past a healthy driver's attach (tens of
/// milliseconds) and past the eager window, so it only fires when the pad really is not coming up.
const PROOF_FAILURES_BEFORE_WARN: u32 = 20;
/// One pad's sealed host↔driver channel: the unnamed DATA section (the real `XusbShm`/`PadShm`), the /// One pad's sealed host↔driver channel: the unnamed DATA section (the real `XusbShm`/`PadShm`), the
/// named bootstrap mailbox, and the delivery state machine ([`Self::pump`]) that hands the driver's /// named bootstrap mailbox, and the delivery state machine ([`Self::pump`]) that hands the driver's
/// WUDFHost a duplicated DATA handle once it publishes its pid. Owns both sections (RAII teardown — /// WUDFHost a duplicated DATA handle. Owns both sections (RAII teardown — dropping the channel closes
/// dropping the channel closes the mailbox, whose *name* then disappears, which is how a persistent /// the mailbox, whose *name* then disappears, which is how a persistent (out-of-band-devnode) driver
/// (out-of-band-devnode) driver detects the host is gone). /// detects the host is gone).
pub(super) struct PadChannel { pub(super) struct PadChannel {
data: Shm, data: Shm,
boot: Shm, boot: Shm,
boot_name: String, boot_name: String,
/// Last `driver_pid` acted on (delivered or rejected) — never retry the same value, so a failed /// The devnode to ask for a channel proof, and how ([`Self::bind_devnode`]). `None` until the
/// verify can't be spun into a hot loop by a static mailbox. /// caller has a `SwDeviceCreate` instance id — and permanently `None` on the out-of-band devnode
/// path, which is what [`TRUST_MAILBOX_ENV`] exists for.
devnode: Option<(String, ProofTransport)>,
/// The pad index the proof must agree with, so a mis-resolved interface can't cross-wire two pads.
pad_index: u32,
/// When the devnode was last asked (throttle — see [`PROOF_PROBE_INTERVAL`]).
last_probe: Option<Instant>,
/// Last pid acted on (delivered or rejected) — never retry the same value, so a failed verify
/// can't be spun into a hot loop.
last_seen_pid: u32, last_seen_pid: u32,
attempts: u32, attempts: u32,
delivered: bool, /// The WUDFHost the DATA section was duplicated into. `Some` ⇒ this channel is spoken for and no
/// other process is served while that one lives (see [`Self::pump`]).
delivered: Option<Delivered>,
warned_proto: bool, warned_proto: bool,
warned_cap: bool, warned_cap: bool,
warned_takeover: bool,
warned_unproven: bool,
proof_failures: u32,
}
/// A completed delivery: the WUDFHost we handed the DATA section to, pinned by a live handle.
/// Liveness is judged on the HANDLE, never the pid — the handle pins the process object, so a
/// recycled pid can never read as "the process we served has exited".
struct Delivered {
pid: u32,
/// `SYNCHRONIZE` handle to the target; signaled ⇔ it exited. Same liveness idiom the frame
/// channel's `ChannelBroker::driver_alive` uses.
process: OwnedHandle,
}
impl Delivered {
fn exited(&self) -> bool {
// SAFETY: `process` is the live `OwnedHandle` this channel owns (borrowed for this
// synchronous call); a 0 ms wait only reads the handle's signaled state.
unsafe { WaitForSingleObject(HANDLE(self.process.as_raw_handle()), 0) == WAIT_OBJECT_0 }
}
} }
impl PadChannel { impl PadChannel {
@@ -273,11 +359,17 @@ impl PadChannel {
data, data,
boot, boot,
boot_name, boot_name,
devnode: None,
pad_index: 0,
last_probe: None,
last_seen_pid: 0, last_seen_pid: 0,
attempts: 0, attempts: 0,
delivered: false, delivered: None,
warned_proto: false, warned_proto: false,
warned_cap: false, warned_cap: false,
warned_takeover: false,
warned_unproven: false,
proof_failures: 0,
}) })
} }
@@ -299,11 +391,28 @@ impl PadChannel {
unsafe { (*(self.boot.base().add(off) as *const AtomicU32)).load(Ordering::Acquire) } unsafe { (*(self.boot.base().add(off) as *const AtomicU32)).load(Ordering::Acquire) }
} }
/// Bind this channel to the devnode the caller just `SwDeviceCreate`d, so [`Self::pump`] can ask
/// it for a channel proof. Call between `create_swdevice` and [`Self::deliver_eager`].
///
/// `instance_id` is `None` when `SwDeviceCreate` failed and the caller fell back to an
/// out-of-band (`devgen`) devnode: there is then no device to ask, and the channel will refuse to
/// deliver unless [`TRUST_MAILBOX_ENV`] is set.
pub(super) fn bind_devnode(
&mut self,
pad_index: u32,
instance_id: Option<String>,
transport: ProofTransport,
) {
self.pad_index = pad_index;
self.devnode = instance_id.map(|id| (id, transport));
}
/// One tick of the delivery state machine — called from the pad's regular service pump (≤4 ms /// One tick of the delivery state machine — called from the pad's regular service pump (≤4 ms
/// cadence) and from [`Self::deliver_eager`]. Cheap when idle: two atomic loads. /// cadence) and from [`Self::deliver_eager`]. Cheap when idle: an atomic load, and once the
/// channel is delivered, one 0 ms wait.
pub(super) fn pump(&mut self) { pub(super) fn pump(&mut self) {
// Version diagnostics: the driver writes its own proto version even when it refuses to // Version diagnostics: the driver writes its own proto version even when it refuses the
// publish a pid (host/driver mismatch), so the operator sees WHY the pad never attaches. // handshake (host/driver mismatch), so the operator sees WHY the pad never attaches.
let drv_proto = self.boot_load(core::mem::offset_of!(PadBootstrap, driver_proto)); let drv_proto = self.boot_load(core::mem::offset_of!(PadBootstrap, driver_proto));
if drv_proto != 0 && drv_proto != GAMEPAD_PROTO_VERSION && !self.warned_proto { if drv_proto != 0 && drv_proto != GAMEPAD_PROTO_VERSION && !self.warned_proto {
self.warned_proto = true; self.warned_proto = true;
@@ -315,27 +424,56 @@ impl PadChannel {
drivers: punktfunk-host.exe driver install --gamepad" drivers: punktfunk-host.exe driver install --gamepad"
); );
} }
let pid = self.boot_load(core::mem::offset_of!(PadBootstrap, driver_pid));
if pid == 0 || pid == self.last_seen_pid { // A delivery stands until its target process dies. Re-delivering to a *different* live
// process was the pre-v3 takeover (see the module docs); UMDF restarting a host whose driver
// crashed is the one legitimate case, and that one is visible here as an exited handle.
if let Some(d) = self.delivered.as_ref() {
if !d.exited() {
return; return;
} }
self.last_seen_pid = pid; tracing::info!(
mailbox = %self.boot_name,
exited_pid = d.pid,
"the WUDFHost this channel was delivered to has exited (driver crash / host \
restart) re-attaching to the restarted driver"
);
self.delivered = None;
self.attempts = 0; // a genuine restart earns a fresh budget
self.last_seen_pid = 0;
self.last_probe = None;
}
if self.attempts >= MAX_DELIVERY_ATTEMPTS { if self.attempts >= MAX_DELIVERY_ATTEMPTS {
if !self.warned_cap { if !self.warned_cap {
self.warned_cap = true; self.warned_cap = true;
tracing::warn!( tracing::warn!(
mailbox = %self.boot_name, mailbox = %self.boot_name,
attempts = self.attempts, attempts = self.attempts,
"gamepad channel delivery cap reached — the bootstrap mailbox keeps changing \ "gamepad channel delivery cap reached — no further handles will be duplicated"
its driver pid (tampering?); no further handles will be duplicated"
); );
} }
return; return;
} }
// Throttle: asking costs a device open + an IOCTL, and this runs on the pad service pump.
if self
.last_probe
.is_some_and(|t| t.elapsed() < PROOF_PROBE_INTERVAL)
{
return;
}
self.last_probe = Some(Instant::now());
let Some(pid) = self.resolve_driver_pid() else {
return;
};
if pid == self.last_seen_pid {
return; // already tried this one and it failed — don't spin on it
}
self.last_seen_pid = pid;
self.attempts += 1; self.attempts += 1;
match self.deliver_to(pid) { match self.deliver_to(pid) {
Ok(seq) => { Ok((seq, process)) => {
self.delivered = true; self.delivered = Some(Delivered { pid, process });
tracing::info!( tracing::info!(
mailbox = %self.boot_name, mailbox = %self.boot_name,
wudf_pid = pid, wudf_pid = pid,
@@ -349,24 +487,111 @@ impl PadChannel {
mailbox = %self.boot_name, mailbox = %self.boot_name,
pid, pid,
error = %format!("{e:#}"), error = %format!("{e:#}"),
"sealed gamepad channel delivery failed — will retry when the mailbox reports \ "sealed gamepad channel delivery failed"
a different driver pid"
); );
} }
} }
} }
/// Which process to hand this pad's DATA section to.
///
/// The answer comes from the DEVNODE ([`channel_proof`]), never from the bootstrap mailbox. That
/// is the whole security property: the mailbox is writable by LocalService, so anything in it —
/// including `driver_pid` — is attacker-choosable, whereas only the driver PnP actually bound to
/// the device we created can answer that device's I/O. See the module docs for the attack this
/// closes.
fn resolve_driver_pid(&mut self) -> Option<u32> {
let Some((instance_id, transport)) = self.devnode.clone() else {
return self.unproven_mailbox_pid("this pad has no SwDeviceCreate devnode to ask");
};
match channel_proof::query(&instance_id, transport, self.pad_index) {
Ok(pid) => {
self.proof_failures = 0;
Some(pid)
}
Err(e) => {
self.proof_failures += 1;
// Chatty at debug (the driver simply may not have started yet); one warn once it is
// clearly not coming, so a dead pad names its own cause instead of just going quiet.
// `DriverAttach` prints the operator-facing remedy separately.
if self.proof_failures == PROOF_FAILURES_BEFORE_WARN {
tracing::warn!(
mailbox = %self.boot_name,
devnode = %instance_id,
error = %format!("{e:#}"),
"the pad's devnode has not answered a channel proof — the host will NOT \
hand the DATA section to a pid it cannot verify, so this pad stays \
unattached (an old driver? reinstall: punktfunk-host.exe driver install \
--gamepad)"
);
} else {
tracing::debug!(
mailbox = %self.boot_name,
attempt = self.proof_failures,
error = %format!("{e:#}"),
"no channel proof yet"
);
}
None
}
}
}
/// The pre-v3 behaviour — trust the mailbox's `driver_pid` — for the one case that has no
/// devnode to ask: an out-of-band (`devgen`) devnode created outside `SwDeviceCreate`. Refused
/// unless [`TRUST_MAILBOX_ENV`] is set, because this is exactly the trust the security review
/// removed: any LocalService principal can write that field and be handed the pad's live input
/// surface.
fn unproven_mailbox_pid(&mut self, why: &str) -> Option<u32> {
if std::env::var_os(TRUST_MAILBOX_ENV).is_none() {
if !self.warned_unproven {
self.warned_unproven = true;
tracing::warn!(
mailbox = %self.boot_name,
reason = why,
"cannot ask this pad's driver for a channel proof — REFUSING to deliver the \
DATA section (the mailbox pid is not trustworthy: any local service can write \
it). Set {TRUST_MAILBOX_ENV}=1 to accept the old, unverified handshake on a \
driver bring-up box."
);
}
return None;
}
let pid = self.boot_load(core::mem::offset_of!(PadBootstrap, driver_pid));
if pid == 0 {
return None;
}
if !self.warned_unproven {
self.warned_unproven = true;
tracing::warn!(
mailbox = %self.boot_name,
reason = why,
"delivering this pad channel on the UNVERIFIED mailbox pid — a local service that \
wins the startup race can redirect this pad's input section (forged gamepad input \
+ a read of pad state). Documented residual; the virtual MOUSE and the XUSB pad are \
both proved."
);
}
Some(pid)
}
/// Duplicate the DATA section into `pid`'s handle table (after verifying it is a genuine /// Duplicate the DATA section into `pid`'s handle table (after verifying it is a genuine
/// WUDFHost) and publish the handle value + owning pid, bumping `handle_seq` LAST. The driver /// WUDFHost) and publish the handle value + owning pid, bumping `handle_seq` LAST. The driver
/// adopts the handle by consuming the delivery; an unconsumed duplicate dies with the target /// adopts the handle by consuming the delivery; an unconsumed duplicate dies with the target
/// process (nothing to reap — there is no fallible step after the duplication). /// process (nothing to reap — there is no fallible step after the duplication).
fn deliver_to(&self, pid: u32) -> Result<u32> { ///
/// Returns `(handle_seq, process)` — the process handle is RETAINED by the caller so the
/// one-delivery rule in [`Self::pump`] can tell "our driver crashed and UMDF restarted it" from
/// "someone else is claiming this channel" on the handle rather than on a reusable pid.
fn deliver_to(&self, pid: u32) -> Result<(u32, OwnedHandle)> {
// SAFETY: plain FFI; the handle (checked by `?`) is owned solely here and moved into the // SAFETY: plain FFI; the handle (checked by `?`) is owned solely here and moved into the
// `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it for the // `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it for the
// synchronous check and forms no lasting alias. // synchronous check and forms no lasting alias. `SYNCHRONIZE` is requested so the retained
// handle doubles as the incumbent-liveness probe ([`Delivered::exited`]) — the same thing the
// frame channel's `ChannelBroker` asks for.
let process = unsafe { let process = unsafe {
let h = OpenProcess( let h = OpenProcess(
PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE,
false, false,
pid, pid,
) )
@@ -413,7 +638,7 @@ impl PadChannel {
(*(base.add(core::mem::offset_of!(PadBootstrap, handle_seq)) as *const AtomicU32)) (*(base.add(core::mem::offset_of!(PadBootstrap, handle_seq)) as *const AtomicU32))
.store(seq, Ordering::Release); .store(seq, Ordering::Release);
} }
Ok(seq) Ok((seq, process))
} }
/// Bounded wait at pad-open: pump until the mailbox produces a driver pid we act on (delivered or /// Bounded wait at pad-open: pump until the mailbox produces a driver pid we act on (delivered or
@@ -425,7 +650,7 @@ impl PadChannel {
loop { loop {
self.pump(); self.pump();
if self.last_seen_pid != 0 || Instant::now() >= deadline { if self.last_seen_pid != 0 || Instant::now() >= deadline {
if !self.delivered { if self.delivered.is_none() {
tracing::debug!( tracing::debug!(
mailbox = %self.boot_name, mailbox = %self.boot_name,
"eager gamepad-channel delivery window passed without an attach — the \ "eager gamepad-channel delivery window passed without an attach — the \
@@ -620,7 +845,7 @@ impl DriverAttach {
driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \ driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
reads as not-attached: update with punktfunk-host.exe driver install --gamepad \ reads as not-attached: update with punktfunk-host.exe driver install --gamepad \
(driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \ (driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \
PFDS_DEBUG_LOG system env var set + the device restarted)" PFGAMEPAD_DEBUG_LOG / PFMOUSE_DEBUG_LOG system env var set + the device restarted)"
); );
} }
} }
@@ -712,3 +937,49 @@ fn cm_problem_hint(problem: u32) -> &'static str {
_ => "see Device Manager for this code", _ => "see Device Manager for this code",
} }
} }
#[cfg(test)]
mod tests {
use super::*;
/// The one-delivery rule ([`PadChannel::pump`]) rests entirely on [`Delivered::exited`] reading
/// the retained HANDLE rather than the pid, and both of its answers are load-bearing: "alive"
/// is what refuses a LocalService takeover of a live pad channel, and "exited" is what still
/// lets UMDF's restart-the-host-after-a-driver-crash path re-deliver. Neither half is
/// observable from the pad path without a real WUDFHost, so pin the primitive itself.
#[test]
fn delivered_exited_tracks_the_process_object_not_the_pid() {
// A child that parks long enough to be observed alive (no console needed, unlike `pause`).
let mut child = std::process::Command::new("cmd.exe")
.args(["/c", "ping", "-n", "30", "127.0.0.1"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn a parked child");
let pid = child.id();
// SAFETY: plain FFI on our own child's pid; the returned handle is owned solely by the
// `OwnedHandle` built from it (single owner, closes on drop).
let process = unsafe {
let h = OpenProcess(
PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE,
false,
pid,
)
.expect("OpenProcess(SYNCHRONIZE) on our own child");
OwnedHandle::from_raw_handle(h.0 as _)
};
let delivered = Delivered { pid, process };
assert!(
!delivered.exited(),
"a running target must read as ALIVE — this is what refuses a channel takeover"
);
child.kill().expect("kill the child");
// `wait` reaps, and only returns once the process has really exited — so the handle is
// signaled by the time we look.
child.wait().expect("reap the child");
assert!(
delivered.exited(),
"an exited target must read as GONE — this is what lets a crashed driver re-attach"
);
}
}
@@ -173,6 +173,11 @@ impl XusbWinPad {
// created". Returning Err routes it through PadSlots' ERROR + capped-backoff retry — parity // created". Returning Err routes it through PadSlots' ERROR + capped-backoff retry — parity
// with the Linux uinput path, which self-heals for exactly this reason. // with the Linux uinput path, which self-heals for exactly this reason.
let (hsw, instance_id) = create_swdevice(index)?; let (hsw, instance_id) = create_swdevice(index)?;
channel.bind_devnode(
index as u32,
instance_id.clone(),
super::gamepad_raii::ProofTransport::XusbIoctl,
);
let _sw = Some(super::gamepad_raii::SwDevice::new(hsw)); let _sw = Some(super::gamepad_raii::SwDevice::new(hsw));
// Bounded eager delivery: the driver's EvtDeviceAdd publishes its pid right away; handing it // Bounded eager delivery: the driver's EvtDeviceAdd publishes its pid right away; handing it
// the DATA handle before we return means the pad is live for the game's first XInput poll. // the DATA handle before we return means the pad is live for the game's first XInput poll.
@@ -184,7 +189,7 @@ impl XusbWinPad {
attach: super::gamepad_raii::DriverAttach::new( attach: super::gamepad_raii::DriverAttach::new(
"pf_xusb", "pf_xusb",
"pf_xusb.inf", "pf_xusb.inf",
"C:\\Users\\Public\\pfxusb-driver.log", "C:\\Windows\\ServiceProfiles\\LocalService\\AppData\\Local\\Temp\\pfxusb-driver.log",
boot_name, boot_name,
instance_id, instance_id,
), ),
@@ -17,7 +17,7 @@
//! disappears with the host service, which is exactly when nobody is streaming. //! disappears with the host service, which is exactly when nobody is streaming.
use super::dualsense_windows::{create_swdevice, SwDeviceProfile}; use super::dualsense_windows::{create_swdevice, SwDeviceProfile};
use super::gamepad_raii::{DriverAttach, PadChannel}; use super::gamepad_raii::{DriverAttach, PadChannel, ProofTransport};
use anyhow::Result; use anyhow::Result;
use pf_driver_proto::mouse::{input_report, mouse_boot_name, MouseShm, MOUSE_MAGIC}; use pf_driver_proto::mouse::{input_report, mouse_boot_name, MouseShm, MOUSE_MAGIC};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
@@ -74,6 +74,9 @@ impl VirtualMouse {
(None, None) (None, None)
} }
}; };
// The DATA section goes to whoever THIS devnode says is serving it — not to whatever pid
// the LocalService-writable mailbox names (security-review 2026-07-28).
channel.bind_devnode(0, instance_id.clone(), ProofTransport::HidSerialString);
let _sw = hsw.map(super::gamepad_raii::SwDevice::new); let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
channel.deliver_eager(Duration::from_millis(1500)); channel.deliver_eager(Duration::from_millis(1500));
Ok(VirtualMouse { Ok(VirtualMouse {
@@ -82,7 +85,7 @@ impl VirtualMouse {
attach: DriverAttach::new( attach: DriverAttach::new(
"pf_mouse", "pf_mouse",
"pf_mouse.inf", "pf_mouse.inf",
"C:\\Users\\Public\\pfmouse-driver.log", "C:\\Windows\\ServiceProfiles\\LocalService\\AppData\\Local\\Temp\\pfmouse-driver.log",
boot_name, boot_name,
instance_id, instance_id,
), ),
@@ -353,3 +356,76 @@ pub fn spike_hold(secs: u64) -> Result<()> {
); );
Ok(()) Ok(())
} }
/// **Channel-proof probe** — settles, on a real box, the one thing the design could not settle by
/// reading: which HID IOCTL hidclass actually forwards to a UMDF HID minidriver.
///
/// The pad channel hands a pad's whole input surface to whichever process the DEVNODE names
/// (`pf_driver_proto::gamepad::ChannelProof`), because the bootstrap mailbox is writable by
/// LocalService and therefore cannot be trusted to name it (security-review 2026-07-28). For the two
/// HID minidrivers that answer travels as a HID string, and both `HidD_GetIndexedString` and a
/// direct arbitrary-index `IOCTL_HID_GET_STRING` are wired up so only one of them has to work. This
/// prints which one did.
///
/// Self-contained: it spins up its OWN throwaway `pf_mouse_probe` devnode at pad index 9, so it can
/// run alongside a live host without touching the resident mouse (index 0) or its mailbox, and the
/// devnode disappears when the command exits. Needs the pf_mouse driver installed
/// (`punktfunk-host.exe driver install --gamepad`).
pub fn channel_proof_probe() -> Result<()> {
use crate::channel_proof::{self, ProofTransport};
/// A pad index no real pad uses, so the proof's index check is actually exercised and the
/// probe can never be confused with the resident mouse at 0.
const PROBE_INDEX: u8 = 9;
println!("creating a throwaway pf_mouse devnode (pad index {PROBE_INDEX})…");
let (hsw, instance_id) = create_swdevice(&SwDeviceProfile {
instance: "pf_mouse_probe",
container_tag: 0x5046_4D4F, // "PFMO"
container_index: PROBE_INDEX,
hwid: "pf_mouse",
usb_vid_pid: "VID_5046&PID_4D4F",
usb_mi: None,
description: "punktfunk Virtual Mouse (channel-proof probe)",
})?;
let _sw = super::gamepad_raii::SwDevice::new(hsw);
let Some(instance_id) = instance_id else {
anyhow::bail!("SwDeviceCreate reported no instance id — cannot look the devnode up");
};
// PnP has to start the driver and hidclass has to publish the collection interface; both happen
// in tens of milliseconds, but poll rather than sleep a fixed amount so a slow box still reports.
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let report = loop {
let r = channel_proof::diagnose(
&instance_id,
ProofTransport::HidSerialString,
PROBE_INDEX as u32,
);
if r.contains("ChannelProof") || std::time::Instant::now() >= deadline {
break r;
}
std::thread::sleep(Duration::from_millis(250));
};
println!("\n{report}");
match channel_proof::probe_pid(
&instance_id,
ProofTransport::HidSerialString,
PROBE_INDEX as u32,
) {
Ok(pid) => println!(
"RESULT: the devnode proved its driver is pid {pid} — the HID channel proof WORKS on \
this build of Windows, so the pad channel never has to trust the mailbox."
),
Err(e) => println!(
"RESULT: no usable channel proof ({e:#}).\n\
If BOTH HID lines above say \"call failed\", hidclass on this build forwards neither \
IOCTL to a UMDF minidriver and the HID pads/mouse need a different transport (the \
xusb leg is unaffected it owns its own device interface). If one says \"answered, \
but not a proof\", an OLD pf_mouse driver is installed: reinstall with\n\
\x20 punktfunk-host.exe driver install --gamepad"
),
}
Ok(())
}
@@ -79,6 +79,13 @@ impl DeckWinPad {
description: "punktfunk Virtual Steam Deck", description: "punktfunk Virtual Steam Deck",
})?; // Propagate — swallowing latched the slot to a pad with no devnode (see the DS4 twin). })?; // Propagate — swallowing latched the slot to a pad with no devnode (see the DS4 twin).
let (hsw, instance_id) = (Some(hsw), instance_id); let (hsw, instance_id) = (Some(hsw), instance_id);
// The DATA section goes to whoever THIS devnode says is serving it — not to whatever pid
// the LocalService-writable mailbox names (security-review 2026-07-28).
channel.bind_devnode(
index as u32,
instance_id.clone(),
super::gamepad_raii::ProofTransport::HidFeatureReport,
);
let _sw = hsw.map(super::gamepad_raii::SwDevice::new); let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
// Bounded eager delivery — the driver must read `device_type = 3` before hidclass asks // Bounded eager delivery — the driver must read `device_type = 3` before hidclass asks
// it for descriptors, or the pad would enumerate with the default DualSense identity. // it for descriptors, or the pad would enumerate with the default DualSense identity.
@@ -88,8 +95,8 @@ impl DeckWinPad {
channel, channel,
attach: super::gamepad_raii::DriverAttach::new( attach: super::gamepad_raii::DriverAttach::new(
"pf_steamdeck", "pf_steamdeck",
"pf_dualsense.inf", // one driver package serves every identity "pf_gamepad.inf", // one driver package serves every identity
"C:\\Users\\Public\\pfds-driver.log", "C:\\Windows\\ServiceProfiles\\LocalService\\AppData\\Local\\Temp\\pf_gamepad-driver.log",
boot_name, boot_name,
instance_id, instance_id,
), ),
+6
View File
@@ -327,6 +327,12 @@ fn libei_ei_source() -> libei::EiSource {
// Goal-1 stage 6: Linux UHID/uinput/libei/wlr backends under `inject/linux/`, the Windows UMDF/SendInput // Goal-1 stage 6: Linux UHID/uinput/libei/wlr backends under `inject/linux/`, the Windows UMDF/SendInput
// backends under `inject/windows/`, and the transport-independent HID codecs under `inject/proto/`; // backends under `inject/windows/`, and the transport-independent HID codecs under `inject/proto/`;
// `#[path]` keeps every `crate::*` module name flat. // `#[path]` keeps every `crate::*` module name flat.
/// Windows: asks a devnode which process is serving it (`pf_driver_proto::gamepad::ChannelProof`) —
/// the unforgeable answer the sealed pad channel duplicates its DATA section into, replacing the
/// LocalService-writable bootstrap mailbox as the source of that decision.
#[cfg(target_os = "windows")]
#[path = "inject/windows/channel_proof.rs"]
pub mod channel_proof;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/linux/dualsense.rs"] #[path = "inject/linux/dualsense.rs"]
pub mod dualsense; pub mod dualsense;
+6
View File
@@ -33,6 +33,12 @@ pub struct FrameCtx<'a> {
/// Swapchain size in pixels — the overlay renders 1:1. /// Swapchain size in pixels — the overlay renders 1:1.
pub width: u32, pub width: u32,
pub height: u32, pub height: u32,
/// UI scale for the stream chrome: the window's display scale (DPI × the display's content
/// scale — `1.0` at 96 dpi / 100 %), times the `PUNKTFUNK_OSD_SCALE` preference. Because the
/// overlay renders in *physical* pixels, a fixed-pixel OSD shrinks as panel density rises —
/// unreadable at 14 px on a 4K laptop at 200 %. Every chrome metric is multiplied by this.
/// Sanitized and clamped by the run loop (`overlay_scale`), so it is always finite and > 0.
pub scale: f32,
/// Multi-line stats OSD (top-left panel); `None` = hidden. /// Multi-line stats OSD (top-left panel); `None` = hidden.
pub stats: Option<&'a str>, pub stats: Option<&'a str>,
/// The capture hint (bottom-center pill, "click to capture…"); `None` = hidden. /// The capture hint (bottom-center pill, "click to capture…"); `None` = hidden.
+56
View File
@@ -420,6 +420,15 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
println!("{{\"ready\":true}}"); println!("{{\"ready\":true}}");
} }
// Operator preference on top of the display's own DPI scale — for a TV across the room, or to
// shrink chrome that a compositor reports an aggressive scale for. Read once (a preference,
// not session state); the DPI part is re-read per frame.
let osd_scale_pref = std::env::var("PUNKTFUNK_OSD_SCALE")
.ok()
.and_then(|s| s.trim().parse::<f32>().ok())
.filter(|v| v.is_finite() && *v > 0.0)
.unwrap_or(1.0);
let mut overlay = opts.overlay.take(); let mut overlay = opts.overlay.take();
if let Some(o) = overlay.as_mut() { if let Some(o) = overlay.as_mut() {
if let Err(e) = o.init(&presenter.shared_device()) { if let Err(e) = o.init(&presenter.shared_device()) {
@@ -1148,6 +1157,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
let ctx = FrameCtx { let ctx = FrameCtx {
width: pw, width: pw,
height: ph, height: ph,
// Re-read per frame, not once at startup: dragging the window to a second monitor
// with a different scale (or changing the scale setting live) updates this, and the
// overlay's damage gate picks the new value up on the next redraw.
scale: overlay_scale(window.display_scale(), osd_scale_pref),
stats, stats,
hint, hint,
resizing, resizing,
@@ -1810,6 +1823,27 @@ fn content_to_window(
(lx as f32, ly as f32) (lx as f32, ly as f32)
} }
/// The overlay chrome's UI scale (`FrameCtx::scale`): SDL's window display scale — DPI × the
/// display's content scale, `1.0` at 96 dpi / 100 % — times the `PUNKTFUNK_OSD_SCALE` preference.
///
/// Sanitizing is not paranoia: `SDL_GetWindowDisplayScale` returns `0.0` when it cannot resolve the
/// window's display (a headless/offscreen driver, or racing a monitor hotplug), and a 0 multiplier
/// would collapse the OSD to an invisible zero-size panel — worse than the un-scaled chrome it
/// replaces. The 4× ceiling keeps a bogus scale from covering the stream.
fn overlay_scale(display_scale: f32, pref: f32) -> f32 {
let base = if display_scale.is_finite() && display_scale > 0.0 {
display_scale
} else {
1.0
};
let pref = if pref.is_finite() && pref > 0.0 {
pref
} else {
1.0
};
(base * pref).clamp(0.5, 4.0)
}
/// The presenter's share of the unified stats window — folded into each printed line. /// The presenter's share of the unified stats window — folded into each printed line.
#[derive(Default)] #[derive(Default)]
struct PresentedWindow { struct PresentedWindow {
@@ -1907,6 +1941,28 @@ fn stats_text(
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn overlay_scale_follows_dpi_and_survives_a_bogus_display() {
// 100 % / 96 dpi is the identity — the chrome keeps the size it always had.
assert_eq!(overlay_scale(1.0, 1.0), 1.0);
// The common HiDPI settings pass straight through.
assert_eq!(overlay_scale(1.5, 1.0), 1.5);
assert_eq!(overlay_scale(2.0, 1.0), 2.0);
// PUNKTFUNK_OSD_SCALE multiplies the display's own scale, it doesn't replace it.
assert_eq!(overlay_scale(2.0, 1.25), 2.5);
// SDL reports 0.0 when it can't resolve the window's display (offscreen driver, or
// racing a hotplug) — that must NOT collapse the panel to nothing.
assert_eq!(overlay_scale(0.0, 1.0), 1.0);
assert_eq!(overlay_scale(f32::NAN, 1.0), 1.0);
assert_eq!(overlay_scale(-2.0, 1.0), 1.0);
// A garbage preference degrades to "just the DPI", never to zero.
assert_eq!(overlay_scale(1.5, 0.0), 1.5);
assert_eq!(overlay_scale(1.5, f32::NAN), 1.5);
// Clamped both ways so nothing can hide the OSD or bury the stream under it.
assert_eq!(overlay_scale(1.0, 100.0), 4.0);
assert_eq!(overlay_scale(1.0, 0.01), 0.5);
}
#[test] #[test]
fn content_to_window_inverts_the_letterbox() { fn content_to_window_inverts_the_letterbox() {
// 1920×1080 video letterboxed in a 1600×1200 (4:3) window at 2× HiDPI: pillarless // 1920×1080 video letterboxed in a 1600×1200 (4:3) window at 2× HiDPI: pillarless
+58 -31
View File
@@ -171,29 +171,49 @@ impl Compositor {
/// The compositor backends usable on this host *right now*: gamescope wherever its binary is /// The compositor backends usable on this host *right now*: gamescope wherever its binary is
/// installed (it spawns a nested session — independent of the running desktop), plus the live /// installed (it spawns a nested session — independent of the running desktop), plus the live
/// session's own compositor (KWin / Mutter / wlroots) when the host runs inside it. Cheap, /// session's own compositor (KWin / Mutter / wlroots / Hyprland) when the host runs inside it.
/// side-effect-free probes — safe to call per management request. A concrete client preference /// Cheap, side-effect-free probes — safe to call per management request. A concrete client
/// is validated against this set before it's honored (see the punktfunk/1 handshake's resolution). /// preference is validated against this set before it's honored (see the punktfunk/1 handshake's
/// resolution).
///
/// The **live session is the primary signal**, ahead of each backend's own probe. 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 (a `systemd --user`
/// unit, a TTY, ssh) never inherited. It is only retargeted at the live session on the connect path
/// ([`apply_session_env`]), so enumerating before the first client connect reported "unavailable"
/// for the very desktop the operator was sitting in — while [`detect`], which scans `/proc`, marked
/// that same backend the default. The management API showed both badges on one row, and the answer
/// flipped depending on whether anyone had connected yet. Basing both on the same `/proc` scan makes
/// the two agree, and makes the answer independent of how the host was launched.
pub fn available() -> Vec<Compositor> { pub fn available() -> Vec<Compositor> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
let mut v = Vec::new(); let live = compositor_for_kind(detect_active_session().kind);
if kwin::is_available() { // An explicit operator pin counts too: it's what `detect` returns as the default and what
v.push(Compositor::Kwin); // the host will actually drive, so listing it "unavailable" was the same contradiction.
let pinned = pf_host_config::config()
.compositor
.as_deref()
.and_then(compositor_from_pin);
Compositor::all()
.into_iter()
.filter(|&c| {
// Running (or pinned) ⇒ usable, without consulting the env-reading probe. KWin is
// the one backend whose probe checks a real capability beyond "is it up" (the
// privileged `zkde_screencast` grant); a live-but-ungranted KWin now surfaces as
// available and fails at create with that probe's precise message, which beats
// "no usable compositor" on a box that is visibly running KDE.
live == Some(c)
|| pinned == Some(c)
|| match c {
Compositor::Kwin => kwin::is_available(),
Compositor::Gamescope => gamescope::is_available(),
Compositor::Mutter => mutter::is_available(),
Compositor::Wlroots => wlroots::is_available(),
Compositor::Hyprland => hyprland::is_available(),
} }
if gamescope::is_available() { })
v.push(Compositor::Gamescope); .collect()
}
if mutter::is_available() {
v.push(Compositor::Mutter);
}
if wlroots::is_available() {
v.push(Compositor::Wlroots);
}
if hyprland::is_available() {
v.push(Compositor::Hyprland);
}
v
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
{ {
@@ -201,6 +221,21 @@ pub fn available() -> Vec<Compositor> {
} }
} }
/// The backend an explicit `PUNKTFUNK_COMPOSITOR` value names (aliases included), or `None` for an
/// unrecognized value. Shared by [`detect`] (which turns `None` into an error naming the accepted
/// values) and [`available`] (which just ignores a typo'd pin).
fn compositor_from_pin(v: &str) -> Option<Compositor> {
Some(match v.trim().to_ascii_lowercase().as_str() {
"kwin" | "kde" | "plasma" => Compositor::Kwin,
// `hyprland` names the distinct backend (D1); `wlroots`/`sway`/`wlr` stay wlroots-proper.
"hyprland" | "hypr" => Compositor::Hyprland,
"wlroots" | "sway" | "wlr" | "river" => Compositor::Wlroots,
"mutter" | "gnome" => Compositor::Mutter,
"gamescope" => Compositor::Gamescope,
_ => return None,
})
}
/// Serializes ALL process-global env mutation on the per-session setup path. `std::env::set_var` /// Serializes ALL process-global env mutation on the per-session setup path. `std::env::set_var`
/// concurrent with another thread's `set_var` (glibc `environ` realloc) is a data race = UB. With /// concurrent with another thread's `set_var` (glibc `environ` realloc) is a data race = UB. With
/// the default concurrent native sessions each running `resolve_compositor` in its own /// the default concurrent native sessions each running `resolve_compositor` in its own
@@ -223,19 +258,11 @@ pub fn with_env_lock<R>(f: impl FnOnce() -> R) -> R {
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read. /// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
pub fn detect() -> Result<Compositor> { pub fn detect() -> Result<Compositor> {
if let Some(v) = pf_host_config::config().compositor.as_deref() { if let Some(v) = pf_host_config::config().compositor.as_deref() {
return match v.trim().to_ascii_lowercase().as_str() { return compositor_from_pin(v).ok_or_else(|| {
"kwin" | "kde" | "plasma" => Ok(Compositor::Kwin), anyhow::anyhow!(
// `hyprland` names the distinct backend (D1); `wlroots`/`sway`/`wlr` stay wlroots-proper. "unknown PUNKTFUNK_COMPOSITOR '{v}' (kwin|wlroots|hyprland|mutter|gamescope)"
"hyprland" | "hypr" => Ok(Compositor::Hyprland),
"wlroots" | "sway" | "wlr" | "river" => Ok(Compositor::Wlroots),
"mutter" | "gnome" => Ok(Compositor::Mutter),
"gamescope" => Ok(Compositor::Gamescope),
other => {
anyhow::bail!(
"unknown PUNKTFUNK_COMPOSITOR '{other}' (kwin|wlroots|hyprland|mutter|gamescope)"
) )
} });
};
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
if let Some(c) = compositor_for_kind(detect_active_session().kind) { if let Some(c) = compositor_for_kind(detect_active_session().kind) {
@@ -105,8 +105,17 @@ impl MutterDisplay {
} }
/// Mutter is usable when the host runs inside a GNOME session (its `RecordVirtual` D-Bus API /// Mutter is usable when the host runs inside a GNOME session (its `RecordVirtual` D-Bus API
/// drives the *live* compositor). Cheap signal: `XDG_CURRENT_DESKTOP` names GNOME — same basis /// drives the *live* compositor). Cheap env signal, avoiding a blocking D-Bus round-trip on the
/// as [`super::detect`], avoiding a blocking D-Bus round-trip on the enumeration path. /// enumeration path.
///
/// This is the *fallback* answer only: [`crate::available`] treats a running `gnome-shell` (the
/// `/proc` scan) as the authority, because this var belongs to the session and a host launched
/// outside it — `systemd --user`, a TTY, ssh — never inherited it. Deliberately still just the ONE
/// var: `XDG_CURRENT_DESKTOP` is the one [`crate::apply_session_env`] owns end to end (it writes it
/// per connect and *scrubs* it when nothing is live), so sniffing `DESKTOP_SESSION` /
/// `XDG_SESSION_DESKTOP` alongside would resurrect the bug that scrub exists to prevent — a stale
/// `gnome` there after a gnome-shell crash reports Mutter usable and routes the next client into a
/// dead session (45 s create timeouts instead of a crisp handshake error).
pub fn is_available() -> bool { pub fn is_available() -> bool {
std::env::var("XDG_CURRENT_DESKTOP") std::env::var("XDG_CURRENT_DESKTOP")
.map(|d| d.to_ascii_uppercase().contains("GNOME")) .map(|d| d.to_ascii_uppercase().contains("GNOME"))
+121 -26
View File
@@ -11,12 +11,14 @@
//! fd we can `poll()` — readable once the producer's writes complete. This makes zero-copy capture //! fd we can `poll()` — readable once the producer's writes complete. This makes zero-copy capture
//! race-free WITHOUT the producer doing anything, *iff* the driver actually attaches the fence. If it //! race-free WITHOUT the producer doing anything, *iff* the driver actually attaches the fence. If it
//! attaches none, the export yields an already-signaled sync_file (poll returns immediately) — no //! attaches none, the export yields an already-signaled sync_file (poll returns immediately) — no
//! wait, no harm, and `waited=false` tells us the driver doesn't fence (so zero-copy would still race). //! wait, no harm, and `WaitOutcome::NoFence` tells us the driver doesn't fence (so zero-copy
//! would still race).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
use std::os::fd::RawFd; use std::os::fd::RawFd;
use std::time::{Duration, Instant};
// linux/dma-buf.h ioctls on the DMA_BUF_BASE ('b' = 0x62) magic. _IOWR = dir(3)<<30 | size<<16 | base<<8 | nr. // linux/dma-buf.h ioctls on the DMA_BUF_BASE ('b' = 0x62) magic. _IOWR = dir(3)<<30 | size<<16 | base<<8 | nr.
const DMA_BUF_BASE: u64 = 0x62; const DMA_BUF_BASE: u64 = 0x62;
@@ -34,11 +36,25 @@ const DMA_BUF_IOCTL_EXPORT_SYNC_FILE: u64 = iowr(2, std::mem::size_of::<DmaBufEx
/// We will READ the buffer → export the fence(s) we must wait for before reading (the producer's writes). /// We will READ the buffer → export the fence(s) we must wait for before reading (the producer's writes).
const DMA_BUF_SYNC_READ: u32 = 1 << 0; const DMA_BUF_SYNC_READ: u32 = 1 << 0;
/// Wait until the producer's writes to `dmabuf_fd` complete (or `timeout_ms` elapses). Returns: /// What the implicit-fence wait actually observed — the operator-facing diagnostic for "does
/// - `Ok(true)` — a render was still in flight and we waited on its fence (the race was real, now closed). /// implicit fencing work on this box" must not conflate these (a timeout or an interrupted wait
/// - `Ok(false)` — no fence / already signaled (the driver attaches no implicit fence; zero-copy can race). /// proceeds with a possibly mid-render buffer; a signaled fence closed the race for real).
/// - `Err` — the ioctl failed (e.g. the kernel/driver lacks `EXPORT_SYNC_FILE`). #[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub fn wait_read_ready(dmabuf_fd: RawFd, timeout_ms: i32) -> std::io::Result<bool> { pub enum WaitOutcome {
/// No sync_file / already signaled — the driver attaches no implicit fence (or the render
/// finished before we looked); zero-copy can still race.
NoFence,
/// A render was in flight and we waited until its fence signaled — the race was real, now closed.
Signaled,
/// A render was in flight and `timeout_ms` elapsed first — we proceed (fail-open: blocking
/// longer would stall capture), possibly encoding a mid-render buffer.
TimedOut,
}
/// Wait until the producer's writes to `dmabuf_fd` complete (or `timeout_ms` elapses; negative =
/// no timeout). `Err` means the ioctl or the poll itself failed (e.g. the kernel/driver lacks
/// `EXPORT_SYNC_FILE`); see [`WaitOutcome`] for the success cases.
pub fn wait_read_ready(dmabuf_fd: RawFd, timeout_ms: i32) -> std::io::Result<WaitOutcome> {
let mut req = DmaBufExportSyncFile { let mut req = DmaBufExportSyncFile {
flags: DMA_BUF_SYNC_READ, flags: DMA_BUF_SYNC_READ,
fd: -1, fd: -1,
@@ -54,31 +70,90 @@ pub fn wait_read_ready(dmabuf_fd: RawFd, timeout_ms: i32) -> std::io::Result<boo
} }
let sync_fd = req.fd; let sync_fd = req.fd;
if sync_fd < 0 { if sync_fd < 0 {
return Ok(false); // no sync_file exported return Ok(WaitOutcome::NoFence); // no sync_file exported
}
let mut pfd = libc::pollfd {
fd: sync_fd,
events: libc::POLLIN,
revents: 0,
};
// Non-blocking probe: not-yet-signaled (poll==0) means the producer is still rendering.
// SAFETY: `&mut pfd` points at a single live `libc::pollfd` and `nfds == 1` matches that one
// element; `pfd.fd` is `sync_fd`, the sync_file fd just exported (already checked `>= 0`).
// `poll` reads `fd`/`events` and writes `revents` for this non-blocking (timeout 0) probe, then
// returns — `pfd` outlives the call and aliases nothing.
let pending = unsafe { libc::poll(&mut pfd, 1, 0) } == 0;
if pending {
pfd.revents = 0;
// SAFETY: same live single-element `pfd` (its `revents` reset to 0 just above), `nfds == 1`,
// and `sync_fd` still open. This blocking `poll` (up to `timeout_ms`) waits for the render
// fence to signal; it reads `fd`/`events`, writes `revents`, and returns before `pfd` ends.
unsafe { libc::poll(&mut pfd, 1, timeout_ms) }; // block until the render fence signals
} }
let outcome = poll_readable(sync_fd, timeout_ms);
// SAFETY: `sync_fd` is the sync_file fd the EXPORT_SYNC_FILE ioctl created and handed us to own; // SAFETY: `sync_fd` is the sync_file fd the EXPORT_SYNC_FILE ioctl created and handed us to own;
// this point is reached only when `sync_fd >= 0`, this `close` runs exactly once on it, and it is // this point is reached only when `sync_fd >= 0`, this `close` runs exactly once on it, and it is
// never used afterward — no double-close or use-after-close. // never used afterward — no double-close or use-after-close.
unsafe { libc::close(sync_fd) }; unsafe { libc::close(sync_fd) };
Ok(pending) outcome
}
/// Poll `fd` for `POLLIN`: a non-blocking probe first (already-readable ⇒ [`WaitOutcome::NoFence`]
/// — the fence was signaled before we looked), then a blocking wait up to `timeout_ms` (negative =
/// no timeout). `EINTR` retries with the remaining budget instead of silently skipping the wait —
/// the host spawns subprocesses, so a `SIGCHLD` mid-poll is a real occurrence, and skipping here
/// would reopen the exact stale-frame race this file exists to close.
fn poll_readable(fd: RawFd, timeout_ms: i32) -> std::io::Result<WaitOutcome> {
let mut pfd = libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
};
// Non-blocking probe: not-yet-readable (poll == 0) means the producer is still rendering.
let probed = loop {
// SAFETY: `&mut pfd` points at a single live `libc::pollfd` and `nfds == 1` matches that
// one element; `fd` is the caller's live sync_file fd. `poll` reads `fd`/`events` and
// writes `revents` for this non-blocking (timeout 0) probe, then returns — `pfd` outlives
// the call and aliases nothing.
let r = unsafe { libc::poll(&mut pfd, 1, 0) };
if r >= 0 {
break r;
}
let e = std::io::Error::last_os_error();
if e.raw_os_error() != Some(libc::EINTR) {
return Err(e);
}
};
if probed > 0 {
if pfd.revents & libc::POLLIN != 0 {
return Ok(WaitOutcome::NoFence); // signaled before we looked
}
// POLLERR/POLLNVAL without POLLIN — the fd is broken, not signaled.
return Err(std::io::Error::other(format!(
"poll(sync_file) revents {:#x} without POLLIN",
pfd.revents
)));
}
let deadline =
(timeout_ms >= 0).then(|| Instant::now() + Duration::from_millis(timeout_ms as u64));
loop {
let remaining = match deadline {
None => -1, // poll's "no timeout"
Some(d) => match d.checked_duration_since(Instant::now()) {
None => return Ok(WaitOutcome::TimedOut),
// +1: round up so a sub-millisecond remainder still waits instead of busy-polling.
Some(rem) => (rem.as_millis() as i32).saturating_add(1),
},
};
pfd.revents = 0;
// SAFETY: same live single-element `pfd` (its `revents` reset to 0 just above), `nfds == 1`,
// and `fd` still open (closed by the caller only after this function returns). This blocking
// `poll` (up to `remaining` ms) waits for the fence to signal; it reads `fd`/`events`,
// writes `revents`, and returns before `pfd` ends.
let r = unsafe { libc::poll(&mut pfd, 1, remaining) };
match r {
0 => return Ok(WaitOutcome::TimedOut),
r if r > 0 => {
if pfd.revents & libc::POLLIN != 0 {
return Ok(WaitOutcome::Signaled);
}
// POLLERR/POLLNVAL without POLLIN — the fd is broken, not signaled.
return Err(std::io::Error::other(format!(
"poll(sync_file) revents {:#x} without POLLIN",
pfd.revents
)));
}
_ => {
let e = std::io::Error::last_os_error();
if e.raw_os_error() != Some(libc::EINTR) {
return Err(e);
}
// Interrupted — recompute the remaining budget and keep waiting.
}
}
}
} }
#[cfg(test)] #[cfg(test)]
@@ -90,4 +165,24 @@ mod tests {
fn ioctl_number_matches_dma_buf_h() { fn ioctl_number_matches_dma_buf_h() {
assert_eq!(DMA_BUF_IOCTL_EXPORT_SYNC_FILE, 0xC008_6202); assert_eq!(DMA_BUF_IOCTL_EXPORT_SYNC_FILE, 0xC008_6202);
} }
/// The poll state machine, driven by a pipe (readable ⇔ signaled): a not-yet-readable fd that
/// stays quiet is a truthful `TimedOut` (not the old `Ok(true)`), and one already readable at
/// the probe is `NoFence`.
#[test]
fn poll_readable_reports_the_truth() {
use std::io::Write;
use std::os::fd::AsRawFd;
let (r, mut w) = std::io::pipe().unwrap();
assert_eq!(
poll_readable(r.as_raw_fd(), 10).unwrap(),
WaitOutcome::TimedOut
);
w.write_all(b"x").unwrap();
assert_eq!(
poll_readable(r.as_raw_fd(), 10).unwrap(),
WaitOutcome::NoFence
);
}
} }
+155 -41
View File
@@ -1,5 +1,5 @@
//! Host side of the isolated zero-copy GPU import (design: //! Host side of the isolated zero-copy GPU import (design:
//! [`design/zerocopy-worker-isolation.md`]): spawns the `zerocopy-worker` subprocess, mirrors the //! `design/zerocopy-worker-isolation.md`): spawns the `zerocopy-worker` subprocess, mirrors the
//! [`super::egl::EglImporter`] entry points over the [`super::proto`] socket, and materializes //! [`super::egl::EglImporter`] entry points over the [`super::proto`] socket, and materializes
//! the worker's pooled CUDA buffers in this process via CUDA IPC (each buffer's handles are //! the worker's pooled CUDA buffers in this process via CUDA IPC (each buffer's handles are
//! opened exactly once and reused as the pool recycles). A worker death — the whole point of the //! opened exactly once and reused as the pool recycles). A worker death — the whole point of the
@@ -35,7 +35,7 @@ const REPLY_TIMEOUT: Duration = Duration::from_secs(10);
/// close, which is what tells an idle worker to exit. /// close, which is what tells an idle worker to exit.
struct Shared { struct Shared {
sock: OwnedFd, sock: OwnedFd,
mappings: Mutex<HashMap<u32, Mapping>>, mappings: Mutex<HashMap<u32, MapEntry>>,
dead: AtomicBool, dead: AtomicBool,
} }
@@ -49,16 +49,34 @@ struct Mapping {
height: u32, height: u32,
} }
/// A [`Mapping`] plus its lifecycle: how many in-flight [`DeviceBuffer`]s still point into it,
/// and whether its pool generation was retired by a renegotiation ([`RemoteImporter::clear_cache`]).
/// Retired-but-referenced entries linger as a graveyard and close when their last frame releases
/// — without this, every renegotiation (mode change, HDR toggle, client reconnect) permanently
/// pinned a pool's worth of host VA reservations to peer memory the worker had already freed.
/// Worker buffer ids are never reused (its `next_id` only counts up), so retired entries can
/// share the map with the next generation's.
struct MapEntry {
m: Mapping,
refs: u32,
retired: bool,
}
impl Drop for Shared { impl Drop for Shared {
fn drop(&mut self) { fn drop(&mut self) {
// Last reference gone — no DeviceBuffer can still point into these mappings. // Last reference gone — no DeviceBuffer can still point into these mappings (current
for (_, m) in self.mappings.lock().unwrap().drain() { // generation or graveyard alike).
for (_, e) in self.mappings.lock().unwrap().drain() {
close_mapping(&e.m);
}
}
}
fn close_mapping(m: &Mapping) {
cuda::ipc_close(m.y); cuda::ipc_close(m.y);
if let Some((uv, _)) = m.uv { if let Some((uv, _)) = m.uv {
cuda::ipc_close(uv); cuda::ipc_close(uv);
} }
}
}
} }
/// Children whose worker hasn't exited yet at `RemoteImporter` drop time (it exits on socket /// Children whose worker hasn't exited yet at `RemoteImporter` drop time (it exits on socket
@@ -72,19 +90,46 @@ static REAPER: Mutex<Vec<(Child, Instant)>> = Mutex::new(Vec::new());
const REAPER_KILL_DEADLINE: Duration = Duration::from_secs(20); const REAPER_KILL_DEADLINE: Duration = Duration::from_secs(20);
fn sweep_reaper() { fn sweep_reaper() {
// Partition under the lock; kill/reap OUTSIDE it. A worker wedged inside a driver ioctl sits
// in D state and ignores SIGKILL — the old blocking `wait()` under the global mutex would
// then park every later `spawn()` and `drop()` behind a process that may never die.
let mut expired: Vec<Child> = Vec::new();
{
let mut list = REAPER.lock().unwrap(); let mut list = REAPER.lock().unwrap();
let now = Instant::now(); let now = Instant::now();
list.retain_mut(|(c, parked)| { let mut i = 0;
if matches!(c.try_wait(), Ok(Some(_))) { while i < list.len() {
return false; // exited on its own → reaped if matches!(list[i].0.try_wait(), Ok(Some(_))) {
list.swap_remove(i); // exited on its own → reaped
} else if now.duration_since(list[i].1) > REAPER_KILL_DEADLINE {
expired.push(list.swap_remove(i).0);
} else {
i += 1;
} }
if now.duration_since(*parked) > REAPER_KILL_DEADLINE { }
}
for mut c in expired {
let _ = c.kill(); let _ = c.kill();
let _ = c.wait(); // Bounded reap (~100 ms of polls): a SIGKILL'd process reaps near-instantly unless it is
return false; // wedged past the deadline → force-killed + reaped // in D state — then park it again (re-killing later is harmless) so a future sweep reaps
// it once the driver unwedges, instead of blocking anyone here forever.
let mut reaped = false;
for _ in 0..10 {
if matches!(c.try_wait(), Ok(Some(_))) {
reaped = true;
break;
}
std::thread::sleep(Duration::from_millis(10));
}
if !reaped {
tracing::warn!(
pid = c.id(),
"zerocopy worker ignored SIGKILL (likely wedged in a driver call, D state) — \
parked for a later sweep"
);
REAPER.lock().unwrap().push((c, Instant::now()));
}
} }
true
});
} }
/// Fd pinned to this process's own executable image, opened (once, lazily) via the /// Fd pinned to this process's own executable image, opened (once, lazily) via the
@@ -151,7 +196,7 @@ pub struct RemoteImporter {
impl RemoteImporter { impl RemoteImporter {
/// Spawn the worker from this host binary and complete the readiness handshake. The worker /// Spawn the worker from this host binary and complete the readiness handshake. The worker
/// is exec'd through the pinned [`SELF_EXE`] fd, so it is always the exact image this /// is exec'd through the pinned `SELF_EXE` fd, so it is always the exact image this
/// process runs — even after the installed binary was replaced mid-flight. An `Err` here /// process runs — even after the installed binary was replaced mid-flight. An `Err` here
/// means "no isolated zero-copy available" — callers fall back to the CPU path, exactly like /// means "no isolated zero-copy available" — callers fall back to the CPU path, exactly like
/// an in-process `EglImporter::new()` failure. /// an in-process `EglImporter::new()` failure.
@@ -173,13 +218,25 @@ impl RemoteImporter {
cmd.arg0("punktfunk-host"); cmd.arg0("punktfunk-host");
cmd.arg("zerocopy-worker").arg("--fd").arg("3"); cmd.arg("zerocopy-worker").arg("--fd").arg("3");
let raw = worker_end.as_raw_fd(); let raw = worker_end.as_raw_fd();
let parent = std::process::id() as libc::pid_t;
// SAFETY: `pre_exec` runs between fork and exec, so only async-signal-safe calls are // SAFETY: `pre_exec` runs between fork and exec, so only async-signal-safe calls are
// allowed — `dup2` and `fcntl` both are, and the closure captures only the `Copy` int // allowed — `prctl`, `getppid`, `dup2` and `fcntl` all are, and the closure captures only
// `raw` (no allocation, no locks). `dup2(raw, 3)` installs the socket at the fd number // `Copy` ints (no allocation, no locks; the error paths use `from_raw_os_error`, which
// the subcommand expects and clears CLOEXEC on the copy; if the parent's fd already IS 3, // does not allocate). PR_SET_PDEATHSIG makes the kernel SIGKILL the worker when the host
// dies — without it a crashed host left the worker holding its CUcontext + BufferPool
// (order hundreds of MB of VRAM) indefinitely. The `getppid` check closes the standard
// race: if the host died between fork and the prctl, the signal is never delivered, so
// refuse to exec instead. `dup2(raw, 3)` installs the socket at the fd number the
// subcommand expects and clears CLOEXEC on the copy; if the parent's fd already IS 3,
// `dup2(3,3)` would preserve CLOEXEC, so that case clears the flag explicitly instead. // `dup2(3,3)` would preserve CLOEXEC, so that case clears the flag explicitly instead.
unsafe { unsafe {
cmd.pre_exec(move || { cmd.pre_exec(move || {
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 {
return Err(io::Error::last_os_error());
}
if libc::getppid() != parent {
return Err(io::Error::from_raw_os_error(libc::ESRCH));
}
if raw == 3 { if raw == 3 {
let flags = libc::fcntl(3, libc::F_GETFD); let flags = libc::fcntl(3, libc::F_GETFD);
if flags < 0 || libc::fcntl(3, libc::F_SETFD, flags & !libc::FD_CLOEXEC) < 0 { if flags < 0 || libc::fcntl(3, libc::F_SETFD, flags & !libc::FD_CLOEXEC) < 0 {
@@ -414,19 +471,24 @@ impl RemoteImporter {
self.mark_dead(); self.mark_dead();
format!("open CUDA IPC mapping for worker buffer {id}") format!("open CUDA IPC mapping for worker buffer {id}")
})?; })?;
self.shared.mappings.lock().unwrap().insert(id, mapping); self.shared.mappings.lock().unwrap().insert(
id,
MapEntry {
m: mapping,
refs: 0,
retired: false,
},
);
} }
let m = self let m = {
.shared let mut g = self.shared.mappings.lock().unwrap();
.mappings let entry = g.get_mut(&id).ok_or_else(|| {
.lock()
.unwrap()
.get(&id)
.copied()
.ok_or_else(|| {
self.mark_dead(); self.mark_dead();
anyhow::anyhow!("worker delivered unknown buffer id {id} (desync)") anyhow::anyhow!("worker delivered unknown buffer id {id} (desync)")
})?; })?;
entry.refs += 1;
entry.m
};
let shared = self.shared.clone(); let shared = self.shared.clone();
Ok(DeviceBuffer::remote( Ok(DeviceBuffer::remote(
m.y, m.y,
@@ -439,8 +501,17 @@ impl RemoteImporter {
Box::new(move || { Box::new(move || {
// Fire-and-forget recycle; a dead worker just means EPIPE, ignored. The // Fire-and-forget recycle; a dead worker just means EPIPE, ignored. The
// captured `shared` Arc is what keeps the mapping + socket alive until // captured `shared` Arc is what keeps the mapping + socket alive until
// the last frame drops. // the last frame drops. A retired mapping (its generation renegotiated
// away) closes here with its last reference.
let _ = proto::send(shared.sock.as_fd(), &Request::Release { id }, None); let _ = proto::send(shared.sock.as_fd(), &Request::Release { id }, None);
let mut g = shared.mappings.lock().unwrap();
if let Some(entry) = g.get_mut(&id) {
entry.refs = entry.refs.saturating_sub(1);
if entry.retired && entry.refs == 0 {
let entry = g.remove(&id).expect("entry exists");
close_mapping(&entry.m);
}
}
}), }),
)) ))
} }
@@ -452,9 +523,24 @@ impl RemoteImporter {
} }
} }
/// The PipeWire stream renegotiated — reset both sides' per-buffer caches. /// The PipeWire stream renegotiated — reset both sides' per-buffer caches, and retire the
/// outgoing generation's CUDA IPC mappings: the worker replaces its pool, so these host-side
/// mappings pin VA reservations to peer memory that is about to be (or already was) freed.
/// Unreferenced ones close now; ones still under an in-flight frame close with its release.
pub fn clear_cache(&mut self) { pub fn clear_cache(&mut self) {
self.sent_keys.clear(); self.sent_keys.clear();
{
let mut g = self.shared.mappings.lock().unwrap();
g.retain(|_, entry| {
if entry.refs == 0 {
close_mapping(&entry.m);
false
} else {
entry.retired = true;
true
}
});
}
if !self.dead() { if !self.dead() {
if let Err(e) = proto::send(self.shared.sock.as_fd(), &Request::ClearCache, None) { if let Err(e) = proto::send(self.shared.sock.as_fd(), &Request::ClearCache, None) {
tracing::warn!(error = %e, "zerocopy worker ClearCache failed"); tracing::warn!(error = %e, "zerocopy worker ClearCache failed");
@@ -627,8 +713,16 @@ mod tests {
assert_eq!(status.code(), Some(42)); assert_eq!(status.code(), Some(42));
} }
/// A request as the scripted peer saw it, paired with the identity (`st_ino`) of the
/// descriptor that actually arrived via SCM_RIGHTS — the `has_fd` boolean in the JSON body is
/// a *claim*; the received fd is the mechanism the whole worker design rests on, so tests
/// assert on it directly.
type SeenRequest = (Request, Option<u64>);
/// A scripted peer: answers the handshake, then serves canned replies per request. /// A scripted peer: answers the handshake, then serves canned replies per request.
fn scripted_server(replies: Vec<Reply>) -> (RemoteImporter, thread::JoinHandle<Vec<Request>>) { fn scripted_server(
replies: Vec<Reply>,
) -> (RemoteImporter, thread::JoinHandle<Vec<SeenRequest>>) {
let (host, worker) = proto::socketpair_seqpacket().unwrap(); let (host, worker) = proto::socketpair_seqpacket().unwrap();
proto::send( proto::send(
worker.as_fd(), worker.as_fd(),
@@ -642,9 +736,12 @@ mod tests {
let mut buf = Vec::new(); let mut buf = Vec::new();
let mut seen = Vec::new(); let mut seen = Vec::new();
let mut replies = replies.into_iter(); let mut replies = replies.into_iter();
while let Ok((req, _fd)) = proto::recv::<Request>(worker.as_fd(), &mut buf) { while let Ok((req, fd)) = proto::recv::<Request>(worker.as_fd(), &mut buf) {
let needs_reply = matches!(req, Request::Modifiers { .. } | Request::Import { .. }); let needs_reply = matches!(req, Request::Modifiers { .. } | Request::Import { .. });
seen.push(req); let ino = fd
.as_ref()
.map(|f| dmabuf_key(f.as_raw_fd()).expect("fstat received fd"));
seen.push((req, ino));
if needs_reply { if needs_reply {
match replies.next() { match replies.next() {
Some(r) => proto::send(worker.as_fd(), &r, None).unwrap(), Some(r) => proto::send(worker.as_fd(), &r, None).unwrap(),
@@ -669,9 +766,12 @@ mod tests {
let seen = join.join().unwrap(); let seen = join.join().unwrap();
assert_eq!( assert_eq!(
seen, seen,
vec![Request::Modifiers { vec![(
Request::Modifiers {
fourcc: 0x3432_5258 fourcc: 0x3432_5258
}] },
None
)]
); );
} }
@@ -698,17 +798,26 @@ mod tests {
// Second import: no fd (already sent) → worker answers NeedFd → one retry WITH the fd. // Second import: no fd (already sent) → worker answers NeedFd → one retry WITH the fd.
assert!(imp.import(&plane, 64, 64, 1, Some(2)).is_err()); assert!(imp.import(&plane, 64, 64, 1, Some(2)).is_err());
assert!(!imp.dead(), "NeedFd handling must not mark the worker dead"); assert!(!imp.dead(), "NeedFd handling must not mark the worker dead");
// The identity the passed descriptor must carry — SCM_RIGHTS re-numbers the fd but
// preserves the open file description, so st_ino survives the crossing.
let key = dmabuf_key(plane.fd).unwrap();
drop(imp); drop(imp);
let fd_flags: Vec<bool> = join let fd_sends: Vec<(bool, Option<u64>)> = join
.join() .join()
.unwrap() .unwrap()
.iter() .iter()
.map(|r| match r { .map(|(r, ino)| match r {
Request::Import { has_fd, .. } => *has_fd, Request::Import { has_fd, .. } => (*has_fd, *ino),
other => panic!("unexpected request {other:?}"), other => panic!("unexpected request {other:?}"),
}) })
.collect(); .collect();
assert_eq!(fd_flags, vec![true, false, true]); // Not just the has_fd *claim* — the descriptor itself must have crossed, with the same
// identity the worker will key its cache on (`pass = None` at the send site would leave
// has_fd=true with no actual fd, which only this assertion catches).
assert_eq!(
fd_sends,
vec![(true, Some(key)), (false, None), (true, Some(key))]
);
} }
#[test] #[test]
@@ -735,18 +844,23 @@ mod tests {
}; };
assert!(format!("{err:#}").contains("died"), "{err:#}"); assert!(format!("{err:#}").contains("died"), "{err:#}");
assert!(imp.dead()); assert!(imp.dead());
let key = dmabuf_key(plane.fd).unwrap();
drop(imp); drop(imp);
let seen = join.join().unwrap(); let seen = join.join().unwrap();
// First import carried the fd (first sight of the key); the retry didn't re-send it. // First import carried the fd (first sight of the key — and the DESCRIPTOR arrived, with
// the sender's identity); the retry didn't re-send it.
match (&seen[0], &seen[1]) { match (&seen[0], &seen[1]) {
(
( (
Request::Import { Request::Import {
has_fd: true, has_fd: true,
kind: ImportKind::Tiled, kind: ImportKind::Tiled,
.. ..
}, },
Request::Import { has_fd: false, .. }, Some(ino),
) => {} ),
(Request::Import { has_fd: false, .. }, None),
) => assert_eq!(*ino, key),
other => panic!("unexpected requests {other:?}"), other => panic!("unexpected requests {other:?}"),
} }
} }
+55 -15
View File
@@ -1,4 +1,4 @@
//! CUDA driver-side state for the zero-copy path, layered over the raw driver-API FFI in [`ffi`] //! CUDA driver-side state for the zero-copy path, layered over the raw driver-API FFI in `ffi`
//! (the `dlopen`'d `libcuda.so.1` symbol table — hand-rolled because no Rust crate exposes the //! (the `dlopen`'d `libcuda.so.1` symbol table — hand-rolled because no Rust crate exposes the
//! GL-interop calls, and runtime-loaded so one binary runs on NVIDIA *and* on AMD/Intel where //! GL-interop calls, and runtime-loaded so one binary runs on NVIDIA *and* on AMD/Intel where
//! `libcuda` is absent). This facade owns the higher-level pieces on top of that layer: //! `libcuda` is absent). This facade owns the higher-level pieces on top of that layer:
@@ -249,9 +249,14 @@ fn copy_stream() -> CUstream {
/// (the source dmabuf is safe to recycle once this returns), but the wait is scoped to our own /// (the source dmabuf is safe to recycle once this returns), but the wait is scoped to our own
/// stream and the copy carries the high priority hint. /// stream and the copy carries the high priority hint.
unsafe fn copy_blocking(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> { unsafe fn copy_blocking(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> {
// SAFETY: caller contract: the shared context is current and `copy` describes live, in-bounds
// device/host memory (each caller carries that proof). Wrapper -> live table; `&copy` outlives
// the synchronous call.
unsafe {
let stream = copy_stream(); let stream = copy_stream();
ck(cuMemcpy2DAsync_v2(copy, stream), what)?; ck(cuMemcpy2DAsync_v2(copy, stream), what)?;
ck(cuStreamSynchronize(stream), "cuStreamSynchronize") ck(cuStreamSynchronize(stream), "cuStreamSynchronize")
}
} }
/// Issue `copy` on this thread's priority stream WITHOUT waiting — for stream-ordered consumers /// Issue `copy` on this thread's priority stream WITHOUT waiting — for stream-ordered consumers
@@ -259,7 +264,10 @@ unsafe fn copy_blocking(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> {
/// stream, not the CPU, orders completion, so the SOURCE must stay valid until the downstream /// stream, not the CPU, orders completion, so the SOURCE must stay valid until the downstream
/// stream work (the encode) has finished. /// stream work (the encode) has finished.
unsafe fn copy_async(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> { unsafe fn copy_async(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> {
ck(cuMemcpy2DAsync_v2(copy, copy_stream()), what) // SAFETY: caller contract: the shared context is current and `copy` describes live, in-bounds
// device/host memory that stays valid until the stream work completes (each caller carries that
// proof). Wrapper -> live table.
unsafe { ck(cuMemcpy2DAsync_v2(copy, copy_stream()), what) }
} }
/// Block until everything enqueued on THIS THREAD's copy stream completed — the shared tail of /// Block until everything enqueued on THIS THREAD's copy stream completed — the shared tail of
@@ -268,17 +276,24 @@ unsafe fn copy_async(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> {
/// game's GPU load each exposed wait eats scheduling latency). The shared context must be /// game's GPU load each exposed wait eats scheduling latency). The shared context must be
/// current. /// current.
unsafe fn sync_copy_stream() -> Result<()> { unsafe fn sync_copy_stream() -> Result<()> {
ck(cuStreamSynchronize(copy_stream()), "cuStreamSynchronize") // SAFETY: caller contract: the shared context is current. Wrapper -> live table; synchronizing
// the dedicated copy stream touches no Rust memory.
unsafe { ck(cuStreamSynchronize(copy_stream()), "cuStreamSynchronize") }
} }
/// `copy_blocking` when `sync`, else `copy_async` — the shared tail of the public `copy_*_to_device` /// `copy_blocking` when `sync`, else `copy_async` — the shared tail of the public `copy_*_to_device`
/// helpers, whose `sync: false` mode carries `copy_async`'s source-lifetime contract. /// helpers, whose `sync: false` mode carries `copy_async`'s source-lifetime contract.
unsafe fn copy_issue(copy: &CUDA_MEMCPY2D, what: &str, sync: bool) -> Result<()> { unsafe fn copy_issue(copy: &CUDA_MEMCPY2D, what: &str, sync: bool) -> Result<()> {
// SAFETY: caller contract: the shared context is current and `copy` describes live, in-bounds
// memory (each caller carries that proof). The stream handle is the once-created per-process
// copy stream. Wrapper -> live table.
unsafe {
if sync { if sync {
copy_blocking(copy, what) copy_blocking(copy, what)
} else { } else {
copy_async(copy, what) copy_async(copy, what)
} }
}
} }
/// The calling thread's copy/launch stream as a raw handle, for binding external stream-ordering /// The calling thread's copy/launch stream as a raw handle, for binding external stream-ordering
@@ -356,7 +371,10 @@ fn alloc_pitched_nv12(
// SAFETY: two independent `cuMemAllocPitch_v2` calls (wrapper → live table). `&mut y_ptr`/ // SAFETY: two independent `cuMemAllocPitch_v2` calls (wrapper → live table). `&mut y_ptr`/
// `&mut y_pitch` and `&mut uv_ptr`/`&mut uv_pitch` are live, distinct stack out-params the // `&mut y_pitch` and `&mut uv_ptr`/`&mut uv_pitch` are live, distinct stack out-params the
// driver writes each plane's pointer and pitch into; all outlive their synchronous calls. The // driver writes each plane's pointer and pitch into; all outlive their synchronous calls. The
// dimension/element-size args are by-value ints. No aliasing — four separate locals. // dimension/element-size args are by-value ints. No aliasing — four separate locals. If the UV
// allocation fails, the just-created Y allocation is freed before the error propagates — this
// runs per frame under VRAM pressure (`BufferPool::get`'s pool-miss fallback), so a leak here
// would compound exactly when memory is already scarce.
unsafe { unsafe {
ck( ck(
cuMemAllocPitch_v2( cuMemAllocPitch_v2(
@@ -369,7 +387,7 @@ fn alloc_pitched_nv12(
"cuMemAllocPitch_v2(Y)", "cuMemAllocPitch_v2(Y)",
)?; )?;
// Chroma is W/2 samples wide at 2 bytes each = W bytes; H/2 rows. // Chroma is W/2 samples wide at 2 bytes each = W bytes; H/2 rows.
ck( if let Err(e) = ck(
cuMemAllocPitch_v2( cuMemAllocPitch_v2(
&mut uv_ptr, &mut uv_ptr,
&mut uv_pitch, &mut uv_pitch,
@@ -378,7 +396,10 @@ fn alloc_pitched_nv12(
16, 16,
), ),
"cuMemAllocPitch_v2(UV)", "cuMemAllocPitch_v2(UV)",
)?; ) {
let _ = cuMemFree_v2(y_ptr);
return Err(e);
}
} }
Ok(((y_ptr, y_pitch), (uv_ptr, uv_pitch))) Ok(((y_ptr, y_pitch), (uv_ptr, uv_pitch)))
} }
@@ -432,7 +453,7 @@ impl InputSurface {
} }
/// Planar YUV444 (8-bit 4:4:4): one allocation, Y|U|V full-res planes stacked (see /// Planar YUV444 (8-bit 4:4:4): one allocation, Y|U|V full-res planes stacked (see
/// [`alloc_pitched_yuv444`]). /// `alloc_pitched_yuv444`).
pub fn alloc_yuv444(width: u32, height: u32) -> Result<InputSurface> { pub fn alloc_yuv444(width: u32, height: u32) -> Result<InputSurface> {
let (ptr, pitch) = alloc_pitched_yuv444(width, height)?; let (ptr, pitch) = alloc_pitched_yuv444(width, height)?;
Ok(InputSurface { ptr, pitch, height }) Ok(InputSurface { ptr, pitch, height })
@@ -509,7 +530,7 @@ pub struct BufferPool {
pitch: usize, pitch: usize,
/// NV12 pools carry a second (chroma) pitch; `Some` ⇒ buffers from this pool have a UV plane. /// NV12 pools carry a second (chroma) pitch; `Some` ⇒ buffers from this pool have a UV plane.
uv_pitch: Option<usize>, uv_pitch: Option<usize>,
/// YUV444 pools: one allocation of 3·`height` stacked 1-byte planes (see [`alloc_pitched_yuv444`]). /// YUV444 pools: one allocation of 3·`height` stacked 1-byte planes (see `alloc_pitched_yuv444`).
yuv444: bool, yuv444: bool,
} }
@@ -629,10 +650,10 @@ pub struct DeviceBuffer {
pub pitch: usize, pub pitch: usize,
pub width: u32, pub width: u32,
pub height: u32, pub height: u32,
/// NV12 only: the interleaved chroma plane `(ptr, pitch)` paired with the Y plane in [`ptr`]. /// NV12 only: the interleaved chroma plane `(ptr, pitch)` paired with the Y plane in [`ptr`](Self::ptr).
/// `None` for the default 4-byte RGB/BGRx path. When `Some`, [`ptr`] is the Y plane (1 byte/px). /// `None` for the default 4-byte RGB/BGRx path. When `Some`, [`ptr`](Self::ptr) is the Y plane (1 byte/px).
pub uv: Option<(CUdeviceptr, usize)>, pub uv: Option<(CUdeviceptr, usize)>,
/// Planar YUV444: [`ptr`] is ONE allocation of 3·[`height`](Self::height) rows at /// Planar YUV444: [`ptr`](Self::ptr) is ONE allocation of 3·[`height`](Self::height) rows at
/// [`pitch`](Self::pitch) — the full-res 1-byte Y, U, V planes stacked in that order /// [`pitch`](Self::pitch) — the full-res 1-byte Y, U, V planes stacked in that order
/// (`uv` stays `None`; the single-plane wire/IPC path carries it unchanged). /// (`uv` stays `None`; the single-plane wire/IPC path carries it unchanged).
pub yuv444: bool, pub yuv444: bool,
@@ -779,6 +800,10 @@ impl RegisteredTexture {
/// The GL context and the shared CUDA context must both be current on this thread, and /// The GL context and the shared CUDA context must both be current on this thread, and
/// `texture` must be a valid `GL_TEXTURE_2D`. /// `texture` must be a valid `GL_TEXTURE_2D`.
pub unsafe fn register_gl(texture: u32) -> Result<RegisteredTexture> { pub unsafe fn register_gl(texture: u32) -> Result<RegisteredTexture> {
// SAFETY: caller contract: the GL context owning `texture` and the shared CUDA context are
// current on this thread, and `texture` is a live, complete GL texture. The out-param is a
// live stack local; wrapper -> live table.
unsafe {
const GL_TEXTURE_2D: c_uint = 0x0DE1; const GL_TEXTURE_2D: c_uint = 0x0DE1;
const CU_GRAPHICS_REGISTER_FLAGS_READ_ONLY: c_uint = 0x01; const CU_GRAPHICS_REGISTER_FLAGS_READ_ONLY: c_uint = 0x01;
let mut resource: CUgraphicsResource = std::ptr::null_mut(); let mut resource: CUgraphicsResource = std::ptr::null_mut();
@@ -793,6 +818,7 @@ impl RegisteredTexture {
)?; )?;
Ok(RegisteredTexture { resource }) Ok(RegisteredTexture { resource })
} }
}
/// Map the texture for this frame, copy its (already-linear RGBA8) array into `dst`, then /// Map the texture for this frame, copy its (already-linear RGBA8) array into `dst`, then
/// unmap. The copy is synchronized (on our priority stream) before unmap so `dst` is ready /// unmap. The copy is synchronized (on our priority stream) before unmap so `dst` is ready
@@ -1005,8 +1031,15 @@ pub fn copy_nv12_to_device(
// `sync: false` shifts the source-lifetime obligation to the caller (documented above). // `sync: false` shifts the source-lifetime obligation to the caller (documented above).
// Wrappers → live table. // Wrappers → live table.
unsafe { unsafe {
copy_async(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)")?; // On a failed enqueue, drain the stream before propagating: the caller drops `src` on
copy_async(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)")?; // `Err`, which recycles it into its pool — a copy still in flight would then race the
// next frame written into the same allocation.
let r = copy_async(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)")
.and_then(|()| copy_async(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)"));
if r.is_err() {
let _ = sync_copy_stream();
return r;
}
if sync { if sync {
sync_copy_stream()?; sync_copy_stream()?;
} }
@@ -1045,8 +1078,15 @@ pub fn copy_yuv444_to_device(
// `src.ptr + pitch·h·i` stays within the live 3·H-row stacked allocation (`yuv444` // `src.ptr + pitch·h·i` stays within the live 3·H-row stacked allocation (`yuv444`
// checked above), `dst_ptr`/`dst_pitch` is the caller's live NVENC plane; `w`×`h` fits // checked above), `dst_ptr`/`dst_pitch` is the caller's live NVENC plane; `w`×`h` fits
// both. Completion is the trailing stream sync below (`sync`) or the caller's // both. Completion is the trailing stream sync below (`sync`) or the caller's
// stream-ordering obligation (`sync: false`, documented above). Wrapper → live table. // stream-ordering obligation (`sync: false`, documented above). A failed enqueue drains
unsafe { copy_async(&copy, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)")? }; // the stream first — earlier planes are already queued, and the caller drops (recycles)
// `src` on `Err`. Wrapper → live table.
unsafe {
if let Err(e) = copy_async(&copy, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)") {
let _ = sync_copy_stream();
return Err(e);
}
}
} }
if sync { if sync {
// SAFETY: one stream sync after the last enqueue covers all three planes (FIFO) — the // SAFETY: one stream sync after the last enqueue covers all three planes (FIFO) — the
+108 -26
View File
@@ -307,13 +307,19 @@ pub(crate) fn cuda_api() -> Option<&'static CudaApi> {
// present; every other entry runs after `context()` succeeded, so its wrapper always hits `Some`. // present; every other entry runs after `context()` succeeded, so its wrapper always hits `Some`.
pub(crate) unsafe fn cuInit(flags: c_uint) -> CUresult { pub(crate) unsafe fn cuInit(flags: c_uint) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuInit)(flags), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuInit)(flags) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
pub(crate) unsafe fn cuDeviceGet(device: *mut CUdevice, ordinal: c_int) -> CUresult { pub(crate) unsafe fn cuDeviceGet(device: *mut CUdevice, ordinal: c_int) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuDeviceGet)(device, ordinal), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuDeviceGet)(device, ordinal) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
@@ -323,19 +329,28 @@ pub(crate) unsafe fn cuCtxCreate_v2(
dev: CUdevice, dev: CUdevice,
) -> CUresult { ) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuCtxCreate_v2)(pctx, flags, dev), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuCtxCreate_v2)(pctx, flags, dev) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
pub(crate) unsafe fn cuCtxDestroy_v2(ctx: CUcontext) -> CUresult { pub(crate) unsafe fn cuCtxDestroy_v2(ctx: CUcontext) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuCtxDestroy_v2)(ctx), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuCtxDestroy_v2)(ctx) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
pub(crate) unsafe fn cuCtxSetCurrent(ctx: CUcontext) -> CUresult { pub(crate) unsafe fn cuCtxSetCurrent(ctx: CUcontext) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuCtxSetCurrent)(ctx), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuCtxSetCurrent)(ctx) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
@@ -347,25 +362,39 @@ pub(crate) unsafe fn cuMemAllocPitch_v2(
element_size: c_uint, element_size: c_uint,
) -> CUresult { ) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuMemAllocPitch_v2)(dptr, pitch, width_bytes, height, element_size), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe {
(a.cuMemAllocPitch_v2)(dptr, pitch, width_bytes, height, element_size)
},
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
pub(crate) unsafe fn cuMemFree_v2(dptr: CUdeviceptr) -> CUresult { pub(crate) unsafe fn cuMemFree_v2(dptr: CUdeviceptr) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuMemFree_v2)(dptr), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuMemFree_v2)(dptr) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
pub(crate) unsafe fn cuMemcpy2DAsync_v2(copy: *const CUDA_MEMCPY2D, stream: CUstream) -> CUresult { pub(crate) unsafe fn cuMemcpy2DAsync_v2(copy: *const CUDA_MEMCPY2D, stream: CUstream) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuMemcpy2DAsync_v2)(copy, stream), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuMemcpy2DAsync_v2)(copy, stream) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
pub(crate) unsafe fn cuStreamSynchronize(stream: CUstream) -> CUresult { pub(crate) unsafe fn cuStreamSynchronize(stream: CUstream) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuStreamSynchronize)(stream), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuStreamSynchronize)(stream) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
@@ -374,7 +403,10 @@ pub(crate) unsafe fn cuCtxGetStreamPriorityRange(
greatest: *mut c_int, greatest: *mut c_int,
) -> CUresult { ) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuCtxGetStreamPriorityRange)(least, greatest), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuCtxGetStreamPriorityRange)(least, greatest) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
@@ -384,7 +416,10 @@ pub(crate) unsafe fn cuStreamCreateWithPriority(
priority: c_int, priority: c_int,
) -> CUresult { ) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuStreamCreateWithPriority)(stream, flags, priority), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuStreamCreateWithPriority)(stream, flags, priority) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
@@ -395,7 +430,10 @@ pub(crate) unsafe fn cuGraphicsGLRegisterImage(
flags: c_uint, flags: c_uint,
) -> CUresult { ) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuGraphicsGLRegisterImage)(resource, texture, target, flags), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuGraphicsGLRegisterImage)(resource, texture, target, flags) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
@@ -405,7 +443,10 @@ pub(crate) unsafe fn cuGraphicsMapResources(
stream: *mut c_void, stream: *mut c_void,
) -> CUresult { ) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuGraphicsMapResources)(count, resources, stream), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuGraphicsMapResources)(count, resources, stream) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
@@ -415,7 +456,10 @@ pub(crate) unsafe fn cuGraphicsUnmapResources(
stream: *mut c_void, stream: *mut c_void,
) -> CUresult { ) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuGraphicsUnmapResources)(count, resources, stream), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuGraphicsUnmapResources)(count, resources, stream) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
@@ -426,13 +470,21 @@ pub(crate) unsafe fn cuGraphicsSubResourceGetMappedArray(
mip_level: c_uint, mip_level: c_uint,
) -> CUresult { ) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuGraphicsSubResourceGetMappedArray)(array, resource, array_index, mip_level), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe {
(a.cuGraphicsSubResourceGetMappedArray)(array, resource, array_index, mip_level)
},
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
pub(crate) unsafe fn cuGraphicsUnregisterResource(resource: CUgraphicsResource) -> CUresult { pub(crate) unsafe fn cuGraphicsUnregisterResource(resource: CUgraphicsResource) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuGraphicsUnregisterResource)(resource), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuGraphicsUnregisterResource)(resource) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
@@ -441,7 +493,10 @@ pub(crate) unsafe fn cuImportExternalMemory(
mem_handle_desc: *const CUDA_EXTERNAL_MEMORY_HANDLE_DESC, mem_handle_desc: *const CUDA_EXTERNAL_MEMORY_HANDLE_DESC,
) -> CUresult { ) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuImportExternalMemory)(ext_mem_out, mem_handle_desc), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuImportExternalMemory)(ext_mem_out, mem_handle_desc) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
@@ -451,13 +506,19 @@ pub(crate) unsafe fn cuExternalMemoryGetMappedBuffer(
buffer_desc: *const CUDA_EXTERNAL_MEMORY_BUFFER_DESC, buffer_desc: *const CUDA_EXTERNAL_MEMORY_BUFFER_DESC,
) -> CUresult { ) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuExternalMemoryGetMappedBuffer)(dev_ptr, ext_mem, buffer_desc), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuExternalMemoryGetMappedBuffer)(dev_ptr, ext_mem, buffer_desc) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
pub(crate) unsafe fn cuDestroyExternalMemory(ext_mem: CUexternalMemory) -> CUresult { pub(crate) unsafe fn cuDestroyExternalMemory(ext_mem: CUexternalMemory) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuDestroyExternalMemory)(ext_mem), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuDestroyExternalMemory)(ext_mem) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
@@ -466,13 +527,19 @@ pub(crate) unsafe fn cuImportExternalSemaphore(
sem_handle_desc: *const CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC, sem_handle_desc: *const CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC,
) -> CUresult { ) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuImportExternalSemaphore)(ext_sem_out, sem_handle_desc), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuImportExternalSemaphore)(ext_sem_out, sem_handle_desc) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
pub(crate) unsafe fn cuDestroyExternalSemaphore(ext_sem: CUexternalSemaphore) -> CUresult { pub(crate) unsafe fn cuDestroyExternalSemaphore(ext_sem: CUexternalSemaphore) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuDestroyExternalSemaphore)(ext_sem), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuDestroyExternalSemaphore)(ext_sem) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
@@ -483,7 +550,10 @@ pub(crate) unsafe fn cuSignalExternalSemaphoresAsync(
stream: CUstream, stream: CUstream,
) -> CUresult { ) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuSignalExternalSemaphoresAsync)(ext_sems, params, count, stream), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuSignalExternalSemaphoresAsync)(ext_sems, params, count, stream) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
@@ -494,13 +564,19 @@ pub(crate) unsafe fn cuWaitExternalSemaphoresAsync(
stream: CUstream, stream: CUstream,
) -> CUresult { ) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuWaitExternalSemaphoresAsync)(ext_sems, params, count, stream), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuWaitExternalSemaphoresAsync)(ext_sems, params, count, stream) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
pub(crate) unsafe fn cuIpcGetMemHandle(handle: *mut CUipcMemHandle, dptr: CUdeviceptr) -> CUresult { pub(crate) unsafe fn cuIpcGetMemHandle(handle: *mut CUipcMemHandle, dptr: CUdeviceptr) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuIpcGetMemHandle)(handle, dptr), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuIpcGetMemHandle)(handle, dptr) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
@@ -510,13 +586,19 @@ pub(crate) unsafe fn cuIpcOpenMemHandle(
flags: c_uint, flags: c_uint,
) -> CUresult { ) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuIpcOpenMemHandle)(dptr, handle, flags), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuIpcOpenMemHandle)(dptr, handle, flags) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
pub(crate) unsafe fn cuIpcCloseMemHandle(dptr: CUdeviceptr) -> CUresult { pub(crate) unsafe fn cuIpcCloseMemHandle(dptr: CUdeviceptr) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuIpcCloseMemHandle)(dptr), // SAFETY: forwards this unsafe wrapper's arguments to the live dlopen'd entry point (the
// table is never unloaded — `mem::forget(lib)`); the caller upholds the driver-API contract,
// with the site-specific proof at each call site.
Some(a) => unsafe { (a.cuIpcCloseMemHandle)(dptr) },
None => CU_ERROR_NOT_LOADED, None => CU_ERROR_NOT_LOADED,
} }
} }
+23 -14
View File
@@ -15,9 +15,12 @@
// invocation exclusively owns the 32-bit words it read-modify-writes. ARGB: one invocation per // invocation exclusively owns the 32-bit words it read-modify-writes. ARGB: one invocation per
// cursor pixel = one word. NV12/YUV444: one invocation per WORD-ALIGNED 4-px luma span (per two // cursor pixel = one word. NV12/YUV444: one invocation per WORD-ALIGNED 4-px luma span (per two
// rows for NV12, whose 2 chroma bytes-pairs land in one exclusive word). Spans are aligned to the // rows for NV12, whose 2 chroma bytes-pairs land in one exclusive word). Spans are aligned to the
// SURFACE, not the cursor, so neighbouring invocations never share a word even at odd `ox`. // SURFACE, not the cursor, so neighbouring invocations never share a word even at odd `ox`; NV12
// block rows are likewise anchored to the surface chroma grid (even rows), so each UV sample's
// 2x2 footprint is exactly the luma rows it averages, at any `oy`.
// //
// Rebuild: glslc cursor_blend.comp -o cursor_blend.spv (vendored beside this file) // Rebuild: glslangValidator -V cursor_blend.comp -o cursor_blend.spv (vendored beside this
// file; or glslc — CI diffs the disassembly against this source)
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in; layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
@@ -111,15 +114,20 @@ void main() {
return; return;
} }
// NV12: rows walk 2-row luma blocks (row = block row). The span's 4 luma px × 2 rows are // NV12: rows walk 2-row luma blocks anchored to the SURFACE chroma grid (block top = an even
// exclusive words; its 2 chroma samples (4 bytes) are one exclusive word. // surface row), the same way spans are anchored to the surface word grid in x. Anchoring to
int base_cy = row * 2; // the cursor's oy instead put every chroma sample one luma row high whenever oy was odd —
if (base_cy >= int(pc.curH)) return; // and never wrote the cursor's last row's chroma at all. y0 = floor(oy/2)*2; the arithmetic
// Luma: 4 px × 2 rows. // shift keeps that floor for negative oy. blend_geometry counts blocks from this same anchor.
int y0 = (pc.oy >> 1) << 1;
int py_top = y0 + row * 2; // this block's top luma row (even by construction)
if (py_top >= pc.oy + int(pc.curH)) return; // dispatch-padding block below the cursor
// Luma: 4 px × 2 rows. The first block can start one row above the cursor (odd oy) — the
// per-row cy guard skips that row.
for (int j = 0; j < 2; j++) { for (int j = 0; j < 2; j++) {
int cy = base_cy + j; int py = py_top + j;
int py = pc.oy + cy; int cy = py - pc.oy;
if (cy >= int(pc.curH) || py < 0 || py >= int(pc.surfH)) continue; if (cy < 0 || cy >= int(pc.curH) || py < 0 || py >= int(pc.surfH)) continue;
for (int i = 0; i < 4; i++) { for (int i = 0; i < 4; i++) {
int px = px0 + i; int px = px0 + i;
int cx = px - pc.ox; int cx = px - pc.ox;
@@ -131,11 +139,12 @@ void main() {
} }
} }
// Chroma: two UV samples covering the span's 2x2 blocks, alpha-weighted like the .cu kernel. // Chroma: two UV samples covering the span's 2x2 blocks, alpha-weighted like the .cu kernel.
// The UV plane starts at row surfH; sample (uvx, uvy) lives at uv_base + uvy*pitch + uvx*2. // Because py_top sits on the chroma grid, sample uvy covers exactly luma rows py_top and
// py_top+1 — the rows averaged below. The UV plane starts at row surfH; sample (uvx, uvy)
// lives at uv_base + uvy*pitch + uvx*2.
// Guard: only spans whose px0 is 4-aligned own their chroma word (px0 is by construction). // Guard: only spans whose px0 is 4-aligned own their chroma word (px0 is by construction).
int py_top = pc.oy + base_cy;
int uvy = py_top >> 1; int uvy = py_top >> 1;
if (py_top < 0 || uvy < 0 || uvy * 2 >= int(pc.surfH)) return; if (py_top < 0 || uvy * 2 >= int(pc.surfH)) return;
uint uv_base = pc.pitch * pc.surfH; uint uv_base = pc.pitch * pc.surfH;
for (int hf = 0; hf < 2; hf++) { for (int hf = 0; hf < 2; hf++) {
// Each hf = one 2x2 luma block = one UV sample (2 bytes). // Each hf = one 2x2 luma block = one UV sample (2 bytes).
@@ -149,7 +158,7 @@ void main() {
int px = bx + i; int px = bx + i;
int py = py_top + j; int py = py_top + j;
int cx = px - pc.ox; int cx = px - pc.ox;
int cy = base_cy + j; int cy = py - pc.oy;
if (px < 0 || py < 0 || px >= int(pc.surfW) || py >= int(pc.surfH)) continue; if (px < 0 || py < 0 || px >= int(pc.surfW) || py >= int(pc.surfH)) continue;
uvec4 s = cursor_px(cx, cy); uvec4 s = cursor_px(cx, cy);
if (s.a == 0u) continue; if (s.a == 0u) continue;
Binary file not shown.
+182 -52
View File
@@ -8,7 +8,7 @@
//! format is opaque). So we follow OBS/Sunshine: bind the `EGLImage` to a GL texture //! format is opaque). So we follow OBS/Sunshine: bind the `EGLImage` to a GL texture
//! (`glEGLImageTargetTexture2DOES`), render it through a fullscreen-triangle shader into a plain //! (`glEGLImageTargetTexture2DOES`), render it through a fullscreen-triangle shader into a plain
//! immutable `GL_RGBA8` texture (de-tiling and swizzling to the BGRx the encoder wants), then //! immutable `GL_RGBA8` texture (de-tiling and swizzling to the BGRx the encoder wants), then
//! register *that* texture with CUDA ([`MappedTexture`]) and copy it device-to-device into an //! register *that* texture with CUDA (`cuda::RegisteredTexture`) and copy it device-to-device into an
//! owned [`DeviceBuffer`] so the dmabuf can be returned to the compositor immediately. //! owned [`DeviceBuffer`] so the dmabuf can be returned to the compositor immediately.
#![allow(non_upper_case_globals)] #![allow(non_upper_case_globals)]
@@ -18,6 +18,7 @@
use super::cuda::{self, DeviceBuffer}; use super::cuda::{self, DeviceBuffer};
use anyhow::{ensure, Context as _, Result}; use anyhow::{ensure, Context as _, Result};
use khronos_egl as egl; use khronos_egl as egl;
use std::os::fd::{AsRawFd as _, FromRawFd as _};
use std::os::raw::{c_int, c_void}; use std::os::raw::{c_int, c_void};
// EGL_EXT_image_dma_buf_import / _modifiers + platform enums (not defined by khronos-egl). // EGL_EXT_image_dma_buf_import / _modifiers + platform enums (not defined by khronos-egl).
@@ -90,6 +91,75 @@ fn nvidia_render_node_in(
.map(|node| dri.join(node)) .map(|node| dri.join(node))
} }
/// Bare GL names created mid-constructor, deleted on unwind if the constructor fails before its
/// struct (whose `Drop` then owns deletion) exists. The blit constructors interleave GL-object
/// creation with fallible steps (FBO-completeness checks, CUDA registration, pool allocation) and
/// are retried per frame — without this, a late failure leaked every name, unbounded, under
/// exactly the VRAM pressure that causes such failures. `defuse()` hands ownership to the built
/// struct. `RegisteredTexture` locals unwind themselves (their own `Drop`), and they drop before
/// this guard (declared after it), preserving the unregister-before-delete order.
#[derive(Default)]
struct GlNameGuard {
textures: Vec<u32>,
fbos: Vec<u32>,
vaos: Vec<u32>,
programs: Vec<u32>,
}
impl GlNameGuard {
/// Construction succeeded — the final struct's `Drop` owns the names from here.
fn defuse(mut self) {
self.textures.clear();
self.fbos.clear();
self.vaos.clear();
self.programs.clear();
}
}
impl Drop for GlNameGuard {
fn drop(&mut self) {
// SAFETY: every name held was created by the guarded constructor on the GL context still
// current on this thread (constructors run and unwind on the single capture thread). Each
// `glDelete*` gets a count of 1 and a pointer to one live element; names reaching this
// `Drop` were never handed to a final struct (`defuse` clears the Vecs), so each is
// deleted exactly once.
unsafe {
for t in &self.textures {
glDeleteTextures(1, t);
}
for f in &self.fbos {
glDeleteFramebuffers(1, f);
}
for v in &self.vaos {
glDeleteVertexArrays(1, v);
}
for &p in &self.programs {
glDeleteProgram(p);
}
}
}
}
/// The GBM device and the DRM render-node fd it borrows, owned together so every exit path —
/// including each `?` in `EglImporter::new` after their creation (most realistically a host where
/// EGL comes up but CUDA does not) — releases both, in order: destroy the device, then close the
/// fd (`OwnedFd`'s drop). Also what makes `EglImporter` teardown ordering structural: the importer
/// has no `Drop` of its own, so its fields drop in declaration order — GL/CUDA objects first, this
/// device (and the display it backs) last.
struct GbmDevice {
raw: *mut c_void,
_fd: std::os::fd::OwnedFd,
}
impl Drop for GbmDevice {
fn drop(&mut self) {
// SAFETY: `raw` is the non-null `gbm_device*` from `gbm_create_device` (null-checked at
// construction), owned exclusively by this struct and destroyed exactly once here — before
// `_fd` (the render-node fd the device borrows) closes via its own drop.
unsafe { gbm_device_destroy(self.raw) };
}
}
/// Per-size GL machinery to blit a dmabuf EGLImage into a CUDA-registrable `GL_RGBA8` texture. /// Per-size GL machinery to blit a dmabuf EGLImage into a CUDA-registrable `GL_RGBA8` texture.
struct GlBlit { struct GlBlit {
program: u32, program: u32,
@@ -109,14 +179,25 @@ struct GlBlit {
impl GlBlit { impl GlBlit {
unsafe fn new(width: u32, height: u32) -> Result<GlBlit> { unsafe fn new(width: u32, height: u32) -> Result<GlBlit> {
// SAFETY: caller contract (`import_inner`): the GL context and the shared CUDA context are
// current on this thread. Raw GL calls pass live locals whose pointers outlive each
// synchronous call; every created name is owned by `guard` until the struct exists.
unsafe {
// Declared before every GL name so it drops LAST on unwind — after the CUDA registration
// (declared below) has unregistered itself.
let mut guard = GlNameGuard::default();
let program = compile_program()?; let program = compile_program()?;
guard.programs.push(program);
let mut vao = 0u32; let mut vao = 0u32;
glGenVertexArrays(1, &mut vao); // core profile needs a bound VAO for glDrawArrays glGenVertexArrays(1, &mut vao); // core profile needs a bound VAO for glDrawArrays
guard.vaos.push(vao);
let mut fbo = 0u32; let mut fbo = 0u32;
glGenFramebuffers(1, &mut fbo); glGenFramebuffers(1, &mut fbo);
guard.fbos.push(fbo);
let mut dst_tex = 0u32; let mut dst_tex = 0u32;
glGenTextures(1, &mut dst_tex); glGenTextures(1, &mut dst_tex);
guard.textures.push(dst_tex);
glBindTexture(GL_TEXTURE_2D, dst_tex); glBindTexture(GL_TEXTURE_2D, dst_tex);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, width as c_int, height as c_int); glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, width as c_int, height as c_int);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
@@ -124,6 +205,7 @@ impl GlBlit {
let mut src_tex = 0u32; let mut src_tex = 0u32;
glGenTextures(1, &mut src_tex); glGenTextures(1, &mut src_tex);
guard.textures.push(src_tex);
glBindTexture(GL_TEXTURE_2D, src_tex); glBindTexture(GL_TEXTURE_2D, src_tex);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
@@ -148,6 +230,7 @@ impl GlBlit {
// current (the caller makes it current before constructing the blit). // current (the caller makes it current before constructing the blit).
let registered = cuda::RegisteredTexture::register_gl(dst_tex)?; let registered = cuda::RegisteredTexture::register_gl(dst_tex)?;
let pool = cuda::BufferPool::new(width, height)?; let pool = cuda::BufferPool::new(width, height)?;
guard.defuse();
Ok(GlBlit { Ok(GlBlit {
program, program,
vao, vao,
@@ -160,11 +243,15 @@ impl GlBlit {
pool, pool,
}) })
} }
}
/// Bind `image` to the source texture and render it into `dst_tex`. /// Bind `image` to the source texture and render it into `dst_tex`.
/// ///
/// # Safety: the GL context is current on this thread; `image` is a valid `EGLImage`. /// # Safety: the GL context is current on this thread; `image` is a valid `EGLImage`.
unsafe fn run(&self, egl_image_target: EglImageTargetFn, image: *mut c_void) -> Result<()> { unsafe fn run(&self, egl_image_target: EglImageTargetFn, image: *mut c_void) -> Result<()> {
// SAFETY: caller contract (`# Safety` above): GL context current, `image` a valid EGLImage.
// Raw GL calls pass names owned by `self`, created on this same context.
unsafe {
glBindTexture(GL_TEXTURE_2D, self.src_tex); glBindTexture(GL_TEXTURE_2D, self.src_tex);
let _ = glGetError(); let _ = glGetError();
egl_image_target(GL_TEXTURE_2D, image); egl_image_target(GL_TEXTURE_2D, image);
@@ -184,6 +271,7 @@ impl GlBlit {
glFlush(); // submit GL work before CUDA maps the texture glFlush(); // submit GL work before CUDA maps the texture
Ok(()) Ok(())
} }
}
} }
impl Drop for GlBlit { impl Drop for GlBlit {
@@ -193,10 +281,12 @@ impl Drop for GlBlit {
// its GL objects leaked on every size change and on importer teardown. // its GL objects leaked on every size change and on importer teardown.
self.registered.release(); self.registered.release();
// SAFETY: these GL names were all created by THIS `GlBlit` in `GlBlit::new` on the current // SAFETY: these GL names were all created by THIS `GlBlit` in `GlBlit::new` on the current
// GL context, still current here (the owning `EglImporter` drops on its single capture // GL context, still current here (the owning `EglImporter` — which never releases the
// thread and never releases the context). Each `glDelete*` gets a count of 1 and a `&u32` // context — drops on its single capture thread and has no `Drop` of its own, so this blit
// to one live field; the symbols dispatch through libGL to the driver for the current // field drops before the `GbmDevice` backing the display). Each `glDelete*` gets a count
// context. Each name is deleted exactly once, after its CUDA registration was released. // of 1 and a `&u32` to one live field; the symbols dispatch through libGL to the driver
// for the current context. Each name is deleted exactly once, after its CUDA registration
// was released.
unsafe { unsafe {
glDeleteTextures(1, &self.dst_tex); glDeleteTextures(1, &self.dst_tex);
glDeleteTextures(1, &self.src_tex); glDeleteTextures(1, &self.src_tex);
@@ -238,21 +328,33 @@ struct Nv12Blit {
impl Nv12Blit { impl Nv12Blit {
unsafe fn new(width: u32, height: u32) -> Result<Nv12Blit> { unsafe fn new(width: u32, height: u32) -> Result<Nv12Blit> {
// SAFETY: caller contract (`import_inner`): the GL context and the shared CUDA context are
// current on this thread. Raw GL calls pass live locals whose pointers outlive each
// synchronous call; every created name is owned by `guard` until the struct exists.
unsafe {
ensure!( ensure!(
width % 2 == 0 && height % 2 == 0, width % 2 == 0 && height % 2 == 0,
"NV12 convert needs even dimensions (got {width}x{height})" "NV12 convert needs even dimensions (got {width}x{height})"
); );
// Declared before every GL name so it drops LAST on unwind — after the CUDA registrations
// (declared below) have unregistered themselves.
let mut guard = GlNameGuard::default();
let y_program = compile_program_with(FRAG_Y_SRC)?; let y_program = compile_program_with(FRAG_Y_SRC)?;
guard.programs.push(y_program);
let uv_program = compile_program_with(FRAG_UV_SRC)?; let uv_program = compile_program_with(FRAG_UV_SRC)?;
guard.programs.push(uv_program);
let mut vao = 0u32; let mut vao = 0u32;
glGenVertexArrays(1, &mut vao); glGenVertexArrays(1, &mut vao);
guard.vaos.push(vao);
let mut fbos = [0u32; 2]; let mut fbos = [0u32; 2];
glGenFramebuffers(2, fbos.as_mut_ptr()); glGenFramebuffers(2, fbos.as_mut_ptr());
guard.fbos.extend_from_slice(&fbos);
let (y_fbo, uv_fbo) = (fbos[0], fbos[1]); let (y_fbo, uv_fbo) = (fbos[0], fbos[1]);
// Luma target: GL_R8 at full resolution. // Luma target: GL_R8 at full resolution.
let mut y_tex = 0u32; let mut y_tex = 0u32;
glGenTextures(1, &mut y_tex); glGenTextures(1, &mut y_tex);
guard.textures.push(y_tex);
glBindTexture(GL_TEXTURE_2D, y_tex); glBindTexture(GL_TEXTURE_2D, y_tex);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_R8, width as c_int, height as c_int); glTexStorage2D(GL_TEXTURE_2D, 1, GL_R8, width as c_int, height as c_int);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
@@ -261,6 +363,7 @@ impl Nv12Blit {
// Chroma target: GL_RG8 at half resolution (R=U, G=V). // Chroma target: GL_RG8 at half resolution (R=U, G=V).
let mut uv_tex = 0u32; let mut uv_tex = 0u32;
glGenTextures(1, &mut uv_tex); glGenTextures(1, &mut uv_tex);
guard.textures.push(uv_tex);
glBindTexture(GL_TEXTURE_2D, uv_tex); glBindTexture(GL_TEXTURE_2D, uv_tex);
glTexStorage2D( glTexStorage2D(
GL_TEXTURE_2D, GL_TEXTURE_2D,
@@ -275,6 +378,7 @@ impl Nv12Blit {
// Source: GL_LINEAR so the half-res UV pass averages the 2×2 chroma footprint. // Source: GL_LINEAR so the half-res UV pass averages the 2×2 chroma footprint.
let mut src_tex = 0u32; let mut src_tex = 0u32;
glGenTextures(1, &mut src_tex); glGenTextures(1, &mut src_tex);
guard.textures.push(src_tex);
glBindTexture(GL_TEXTURE_2D, src_tex); glBindTexture(GL_TEXTURE_2D, src_tex);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
@@ -294,6 +398,7 @@ impl Nv12Blit {
let y_registered = cuda::RegisteredTexture::register_gl(y_tex)?; let y_registered = cuda::RegisteredTexture::register_gl(y_tex)?;
let uv_registered = cuda::RegisteredTexture::register_gl(uv_tex)?; let uv_registered = cuda::RegisteredTexture::register_gl(uv_tex)?;
let pool = cuda::BufferPool::new_nv12(width, height)?; let pool = cuda::BufferPool::new_nv12(width, height)?;
guard.defuse();
Ok(Nv12Blit { Ok(Nv12Blit {
y_program, y_program,
uv_program, uv_program,
@@ -311,11 +416,15 @@ impl Nv12Blit {
test_src_storage: false, test_src_storage: false,
}) })
} }
}
/// Bind `image` to the source texture and run both convert passes into `y_tex`/`uv_tex`. /// Bind `image` to the source texture and run both convert passes into `y_tex`/`uv_tex`.
/// ///
/// # Safety: the GL context is current on this thread; `image` is a valid `EGLImage`. /// # Safety: the GL context is current on this thread; `image` is a valid `EGLImage`.
unsafe fn run(&self, egl_image_target: EglImageTargetFn, image: *mut c_void) -> Result<()> { unsafe fn run(&self, egl_image_target: EglImageTargetFn, image: *mut c_void) -> Result<()> {
// SAFETY: caller contract (`# Safety` above): GL context current, `image` a valid EGLImage.
// Raw GL calls pass names owned by `self`, created on this same context.
unsafe {
glBindTexture(GL_TEXTURE_2D, self.src_tex); glBindTexture(GL_TEXTURE_2D, self.src_tex);
let _ = glGetError(); let _ = glGetError();
egl_image_target(GL_TEXTURE_2D, image); egl_image_target(GL_TEXTURE_2D, image);
@@ -324,12 +433,16 @@ impl Nv12Blit {
ensure!(e == 0, "glEGLImageTargetTexture2DOES failed ({e:#x})"); ensure!(e == 0, "glEGLImageTargetTexture2DOES failed ({e:#x})");
self.run_passes() self.run_passes()
} }
}
/// Run the two convert passes from whatever is currently in `src_tex` (caller populated it). /// Run the two convert passes from whatever is currently in `src_tex` (caller populated it).
/// Shared by [`run`](Self::run) (EGLImage source) and the self-test (uploaded RGBA source). /// Shared by [`run`](Self::run) (EGLImage source) and the self-test (uploaded RGBA source).
/// ///
/// # Safety: the GL context is current on this thread. /// # Safety: the GL context is current on this thread.
unsafe fn run_passes(&self) -> Result<()> { unsafe fn run_passes(&self) -> Result<()> {
// SAFETY: caller contract (`# Safety` above): GL context current. Raw GL calls pass names
// owned by `self`, created on this same context.
unsafe {
glActiveTexture(GL_TEXTURE0); glActiveTexture(GL_TEXTURE0);
glBindVertexArray(self.vao); glBindVertexArray(self.vao);
// Y pass: full-res into the R8 target. // Y pass: full-res into the R8 target.
@@ -350,6 +463,7 @@ impl Nv12Blit {
glFlush(); // submit GL work before CUDA maps the textures glFlush(); // submit GL work before CUDA maps the textures
Ok(()) Ok(())
} }
}
} }
impl Drop for Nv12Blit { impl Drop for Nv12Blit {
@@ -362,8 +476,9 @@ impl Drop for Nv12Blit {
self.uv_registered.release(); self.uv_registered.release();
// SAFETY: these GL names (textures/FBOs/VAO/programs) were all created by THIS `Nv12Blit` // SAFETY: these GL names (textures/FBOs/VAO/programs) were all created by THIS `Nv12Blit`
// in `Nv12Blit::new` on the current GL context, which is still current because the owning // in `Nv12Blit::new` on the current GL context, which is still current because the owning
// `EglImporter` is dropped on its single capture thread (fields drop before // `EglImporter` (which never releases the context) drops on its single capture thread and
// `EglImporter::drop`, which never releases the context). `glDelete*` takes a count + a // has no `Drop` of its own — its fields drop in declaration order, this blit before the
// `GbmDevice` backing the display. `glDelete*` takes a count + a
// pointer to that many names: `&self.y_tex`/`&self.vao` are `&u32` to one live field (n=1); // pointer to that many names: `&self.y_tex`/`&self.vao` are `&u32` to one live field (n=1);
// `[self.y_fbo, self.uv_fbo].as_ptr()` points at a 2-element temporary that lives for the // `[self.y_fbo, self.uv_fbo].as_ptr()` points at a 2-element temporary that lives for the
// whole `glDeleteFramebuffers` call (n=2 matches). The symbols dispatch through libGL // whole `glDeleteFramebuffers` call (n=2 matches). The symbols dispatch through libGL
@@ -405,23 +520,34 @@ struct Yuv444Blit {
impl Yuv444Blit { impl Yuv444Blit {
unsafe fn new(width: u32, height: u32) -> Result<Yuv444Blit> { unsafe fn new(width: u32, height: u32) -> Result<Yuv444Blit> {
// SAFETY: caller contract (`import_inner`): the GL context and the shared CUDA context are
// current on this thread. Raw GL calls pass live locals whose pointers outlive each
// synchronous call; every created name is owned by `guard` until the struct exists.
unsafe {
ensure!( ensure!(
width % 2 == 0 && height % 2 == 0, width % 2 == 0 && height % 2 == 0,
"YUV444 convert needs even dimensions (got {width}x{height})" "YUV444 convert needs even dimensions (got {width}x{height})"
); );
let full_range = std::env::var("PUNKTFUNK_444_FULLRANGE").is_ok_and(|v| v.trim() == "1"); let full_range =
std::env::var("PUNKTFUNK_444_FULLRANGE").is_ok_and(|v| v.trim() == "1");
let (y_src, u_src, v_src) = yuv444_frag_sources(full_range); let (y_src, u_src, v_src) = yuv444_frag_sources(full_range);
let programs = [ // Declared before every GL name so it drops LAST on unwind — after the CUDA registrations
compile_program_with(&y_src)?, // (declared below) have unregistered themselves.
compile_program_with(&u_src)?, let mut guard = GlNameGuard::default();
compile_program_with(&v_src)?, let mut programs = [0u32; 3];
]; for (p, src) in programs.iter_mut().zip([&y_src, &u_src, &v_src]) {
*p = compile_program_with(src)?;
guard.programs.push(*p);
}
let mut vao = 0u32; let mut vao = 0u32;
glGenVertexArrays(1, &mut vao); glGenVertexArrays(1, &mut vao);
guard.vaos.push(vao);
let mut fbos = [0u32; 3]; let mut fbos = [0u32; 3];
glGenFramebuffers(3, fbos.as_mut_ptr()); glGenFramebuffers(3, fbos.as_mut_ptr());
guard.fbos.extend_from_slice(&fbos);
let mut texs = [0u32; 3]; let mut texs = [0u32; 3];
glGenTextures(3, texs.as_mut_ptr()); glGenTextures(3, texs.as_mut_ptr());
guard.textures.extend_from_slice(&texs);
for &tex in &texs { for &tex in &texs {
glBindTexture(GL_TEXTURE_2D, tex); glBindTexture(GL_TEXTURE_2D, tex);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_R8, width as c_int, height as c_int); glTexStorage2D(GL_TEXTURE_2D, 1, GL_R8, width as c_int, height as c_int);
@@ -432,6 +558,7 @@ impl Yuv444Blit {
// the Nv12Blit source setup. // the Nv12Blit source setup.
let mut src_tex = 0u32; let mut src_tex = 0u32;
glGenTextures(1, &mut src_tex); glGenTextures(1, &mut src_tex);
guard.textures.push(src_tex);
glBindTexture(GL_TEXTURE_2D, src_tex); glBindTexture(GL_TEXTURE_2D, src_tex);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
@@ -452,6 +579,7 @@ impl Yuv444Blit {
cuda::RegisteredTexture::register_gl(texs[2])?, cuda::RegisteredTexture::register_gl(texs[2])?,
]; ];
let pool = cuda::BufferPool::new_yuv444(width, height)?; let pool = cuda::BufferPool::new_yuv444(width, height)?;
guard.defuse();
if full_range { if full_range {
tracing::info!("YUV444 zero-copy convert: FULL range (PUNKTFUNK_444_FULLRANGE=1)"); tracing::info!("YUV444 zero-copy convert: FULL range (PUNKTFUNK_444_FULLRANGE=1)");
} }
@@ -467,11 +595,15 @@ impl Yuv444Blit {
pool, pool,
}) })
} }
}
/// Bind `image` to the source texture and run the three plane passes. /// Bind `image` to the source texture and run the three plane passes.
/// ///
/// # Safety: the GL context is current on this thread; `image` is a valid `EGLImage`. /// # Safety: the GL context is current on this thread; `image` is a valid `EGLImage`.
unsafe fn run(&self, egl_image_target: EglImageTargetFn, image: *mut c_void) -> Result<()> { unsafe fn run(&self, egl_image_target: EglImageTargetFn, image: *mut c_void) -> Result<()> {
// SAFETY: caller contract (`# Safety` above): GL context current, `image` a valid EGLImage.
// Raw GL calls pass names owned by `self`, created on this same context.
unsafe {
glBindTexture(GL_TEXTURE_2D, self.src_tex); glBindTexture(GL_TEXTURE_2D, self.src_tex);
let _ = glGetError(); let _ = glGetError();
egl_image_target(GL_TEXTURE_2D, image); egl_image_target(GL_TEXTURE_2D, image);
@@ -492,6 +624,7 @@ impl Yuv444Blit {
glFlush(); // submit GL work before CUDA maps the textures glFlush(); // submit GL work before CUDA maps the textures
Ok(()) Ok(())
} }
}
} }
impl Drop for Yuv444Blit { impl Drop for Yuv444Blit {
@@ -502,10 +635,11 @@ impl Drop for Yuv444Blit {
r.release(); r.release();
} }
// SAFETY: these GL names were all created by THIS `Yuv444Blit` in `new` on the current GL // SAFETY: these GL names were all created by THIS `Yuv444Blit` in `new` on the current GL
// context (still current — the owning `EglImporter` drops on its single capture thread). // context (still current — the owning `EglImporter` drops on its single capture thread,
// Each `glDelete*` takes a count + a pointer to that many names; the arrays are live // and having no `Drop` of its own, this blit field drops before the `GbmDevice` backing
// fields (or a live temporary for the whole call). Each name is deleted exactly once, // the display). Each `glDelete*` takes a count + a pointer to that many names; the arrays
// after its CUDA registration was released above. // are live fields (or a live temporary for the whole call). Each name is deleted exactly
// once, after its CUDA registration was released above.
unsafe { unsafe {
glDeleteTextures(3, self.texs.as_ptr()); glDeleteTextures(3, self.texs.as_ptr());
glDeleteTextures(1, &self.src_tex); glDeleteTextures(1, &self.src_tex);
@@ -563,8 +697,11 @@ pub struct EglImporter {
/// NV12 twin of [`linear_pool`](Self::linear_pool) for the bridge's compute-CSC output /// NV12 twin of [`linear_pool`](Self::linear_pool) for the bridge's compute-CSC output
/// (T2.5b) — separate pools because a session may fall back RGB mid-stream. /// (T2.5b) — separate pools because a session may fall back RGB mid-stream.
linear_nv12_pool: Option<cuda::BufferPool>, linear_nv12_pool: Option<cuda::BufferPool>,
gbm: *mut c_void, /// Declared LAST deliberately: with no `Drop` impl on `EglImporter`, fields drop in
render_fd: c_int, /// declaration order, so every GL blit / CUDA registration / Vulkan bridge above releases its
/// driver objects BEFORE the gbm device backing the EGLDisplay (and its fd) go away — the
/// stale-driver-state class the blit destructors' ordering comments cite.
_gbm: GbmDevice,
} }
// SAFETY: `EglImporter` owns thread-affine handles — an EGLDisplay/contexts made current on one // SAFETY: `EglImporter` owns thread-affine handles — an EGLDisplay/contexts made current on one
@@ -593,19 +730,25 @@ impl EglImporter {
// retains the pointer nor writes through it, so there is no aliasing or lifetime hazard. // retains the pointer nor writes through it, so there is no aliasing or lifetime hazard.
let render_fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) }; let render_fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
ensure!(render_fd >= 0, "open {} for GBM", node.display()); ensure!(render_fd >= 0, "open {} for GBM", node.display());
// SAFETY: `render_fd` is the live DRM render-node fd just returned by `open` and checked // SAFETY: `open` just returned this fd (checked `>= 0`) and nothing else owns it, so
// `>= 0`. `gbm_create_device` (libgbm, linked above) builds a `gbm_device` over that fd and // `OwnedFd` takes sole ownership — every exit path below (including each `?`) now closes
// returns a `*mut gbm_device` (or null); it borrows but does not take ownership of the fd, // it exactly once, after the gbm device borrowing it is destroyed (`GbmDevice`'s drop
// which `EglImporter` keeps open and closes only in `Drop` after `gbm_device_destroy`. No // order).
// Rust-owned memory is passed, so there is nothing to alias. let render_fd = unsafe { std::os::fd::OwnedFd::from_raw_fd(render_fd) };
let gbm = unsafe { gbm_create_device(render_fd) }; // SAFETY: `render_fd` is the live DRM render-node fd just opened. `gbm_create_device`
if gbm.is_null() { // (libgbm, linked above) builds a `gbm_device` over that fd and returns a
// SAFETY: reached only when `gbm_create_device` failed (null) — the fd was not consumed // `*mut gbm_device` (or null); it borrows but does not take ownership of the fd, which
// and no `EglImporter` exists yet to close it again, so this `close` runs exactly once on // `GbmDevice` keeps open until after `gbm_device_destroy`. No Rust-owned memory is
// the live `render_fd`, releasing it before the error return. No double-close. // passed, so there is nothing to alias.
unsafe { libc::close(render_fd) }; let raw_gbm = unsafe { gbm_create_device(render_fd.as_raw_fd()) };
if raw_gbm.is_null() {
// `render_fd` closes via its own drop.
anyhow::bail!("gbm_create_device failed on {}", node.display()); anyhow::bail!("gbm_create_device failed on {}", node.display());
} }
let gbm = GbmDevice {
raw: raw_gbm,
_fd: render_fd,
};
// SAFETY: `Egl::load_required` dlopens the system libEGL and binds its entry points, // SAFETY: `Egl::load_required` dlopens the system libEGL and binds its entry points,
// trusting that libEGL (libglvnd) is a genuine EGL 1.5 implementation whose core symbols // trusting that libEGL (libglvnd) is a genuine EGL 1.5 implementation whose core symbols
@@ -613,16 +756,16 @@ impl EglImporter {
// returned instance is afterwards used only through the safe `khronos_egl` wrappers. // returned instance is afterwards used only through the safe `khronos_egl` wrappers.
let egl: Egl = let egl: Egl =
unsafe { Egl::load_required() }.context("load libEGL (EGL 1.5 dynamic instance)")?; unsafe { Egl::load_required() }.context("load libEGL (EGL 1.5 dynamic instance)")?;
// SAFETY: `gbm` is the non-null `gbm_device*` created just above (checked), and // SAFETY: `gbm.raw` is the non-null `gbm_device*` created just above (checked), and
// `EGL_PLATFORM_GBM_KHR` is exactly the platform enum that pairs with a GBM device as the // `EGL_PLATFORM_GBM_KHR` is exactly the platform enum that pairs with a GBM device as the
// native-display handle, so the `gbm as NativeDisplayType` cast hands EGL a valid native // native-display handle, so the `gbm.raw as NativeDisplayType` cast hands EGL a valid native
// display for the requested platform. `&[egl::ATTRIB_NONE]` is a properly terminated, empty // display for the requested platform. `&[egl::ATTRIB_NONE]` is a properly terminated, empty
// attribute array borrowed for this synchronous call; EGL only reads it and returns an // attribute array borrowed for this synchronous call; EGL only reads it and returns an
// `EGLDisplay`, retaining no pointer into Rust memory. // `EGLDisplay`, retaining no pointer into Rust memory.
let display = unsafe { let display = unsafe {
egl.get_platform_display( egl.get_platform_display(
EGL_PLATFORM_GBM_KHR, EGL_PLATFORM_GBM_KHR,
gbm as egl::NativeDisplayType, gbm.raw as egl::NativeDisplayType,
&[egl::ATTRIB_NONE], &[egl::ATTRIB_NONE],
) )
} }
@@ -736,8 +879,7 @@ impl EglImporter {
vk: None, vk: None,
linear_pool: None, linear_pool: None,
linear_nv12_pool: None, linear_nv12_pool: None,
gbm, _gbm: gbm,
render_fd,
}) })
} }
@@ -1155,23 +1297,11 @@ impl EglImporter {
} }
} }
impl Drop for EglImporter { // No `Drop` impl on `EglImporter` — deliberately. `Drop::drop` runs BEFORE field drops, so the
fn drop(&mut self) { // old impl destroyed the gbm device and closed the render-node fd while the blits' destructors
if !self.gbm.is_null() { // (which call `cuGraphicsUnregisterResource`/`glDeleteTextures` into the driver) still had to
// SAFETY: `self.gbm` is the non-null `gbm_device*` from `gbm_create_device` in `new` // run. With teardown expressed purely as field order (blits and bridge first, `GbmDevice` last —
// (checked non-null here), owned exclusively by this `EglImporter` and destroyed exactly // see the field comments), the driver objects always release against a live native display.
// once (in `Drop`). It is freed BEFORE `render_fd` is closed below — the correct order,
// since the device borrowed that fd for its lifetime.
unsafe { gbm_device_destroy(self.gbm) };
}
if self.render_fd >= 0 {
// SAFETY: `self.render_fd` is the fd `open` returned in `new` (checked `>= 0`), owned
// exclusively by this `EglImporter`; this `close` runs exactly once, after the gbm device
// that borrowed it has been destroyed. No double-close or use-after-close.
unsafe { libc::close(self.render_fd) };
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
+30 -3
View File
@@ -148,6 +148,10 @@ pub(crate) fn yuv444_frag_sources(full_range: bool) -> (Vec<u8>, Vec<u8>, Vec<u8
} }
pub(crate) unsafe fn compile_shader(kind: u32, src: &[u8]) -> Result<u32> { pub(crate) unsafe fn compile_shader(kind: u32, src: &[u8]) -> Result<u32> {
// SAFETY: caller contract: the GL context is current on this thread. `src` is a live slice; its
// pointer/length locals outlive the synchronous `glShaderSource`; the shader name is deleted on
// the compile-failure path.
unsafe {
let sh = glCreateShader(kind); let sh = glCreateShader(kind);
ensure!(sh != 0, "glCreateShader failed"); ensure!(sh != 0, "glCreateShader failed");
let ptr = src.as_ptr() as *const i8; let ptr = src.as_ptr() as *const i8;
@@ -161,14 +165,31 @@ pub(crate) unsafe fn compile_shader(kind: u32, src: &[u8]) -> Result<u32> {
bail!("GL shader compile failed"); bail!("GL shader compile failed");
} }
Ok(sh) Ok(sh)
}
} }
/// Compile+link the fullscreen-triangle program with fragment source `frag` and bind its `image` /// Compile+link the fullscreen-triangle program with fragment source `frag` and bind its `image`
/// sampler to texture unit 0. /// sampler to texture unit 0.
pub(crate) unsafe fn compile_program_with(frag: &[u8]) -> Result<u32> { pub(crate) unsafe fn compile_program_with(frag: &[u8]) -> Result<u32> {
// SAFETY: caller contract: the GL context is current on this thread. All names are created here
// and deleted on every failure path; `c"image"` is a valid NUL-terminated literal.
unsafe {
// Callers retry per frame, so every error path below must delete what it created — a leak
// here is unbounded, not one-shot.
let vs = compile_shader(GL_VERTEX_SHADER, VERT_SRC)?; let vs = compile_shader(GL_VERTEX_SHADER, VERT_SRC)?;
let fs = compile_shader(GL_FRAGMENT_SHADER, frag)?; let fs = match compile_shader(GL_FRAGMENT_SHADER, frag) {
Ok(fs) => fs,
Err(e) => {
glDeleteShader(vs);
return Err(e);
}
};
let prog = glCreateProgram(); let prog = glCreateProgram();
if prog == 0 {
glDeleteShader(vs);
glDeleteShader(fs);
bail!("glCreateProgram failed");
}
glAttachShader(prog, vs); glAttachShader(prog, vs);
glAttachShader(prog, fs); glAttachShader(prog, fs);
glLinkProgram(prog); glLinkProgram(prog);
@@ -176,7 +197,10 @@ pub(crate) unsafe fn compile_program_with(frag: &[u8]) -> Result<u32> {
glDeleteShader(fs); glDeleteShader(fs);
let mut ok: c_int = 0; let mut ok: c_int = 0;
glGetProgramiv(prog, GL_LINK_STATUS, &mut ok); glGetProgramiv(prog, GL_LINK_STATUS, &mut ok);
ensure!(ok != 0, "GL program link failed"); if ok == 0 {
glDeleteProgram(prog);
bail!("GL program link failed");
}
glUseProgram(prog); glUseProgram(prog);
let loc = glGetUniformLocation(prog, c"image".as_ptr()); let loc = glGetUniformLocation(prog, c"image".as_ptr());
if loc >= 0 { if loc >= 0 {
@@ -184,8 +208,11 @@ pub(crate) unsafe fn compile_program_with(frag: &[u8]) -> Result<u32> {
} }
glUseProgram(0); glUseProgram(0);
Ok(prog) Ok(prog)
}
} }
pub(crate) unsafe fn compile_program() -> Result<u32> { pub(crate) unsafe fn compile_program() -> Result<u32> {
compile_program_with(FRAG_SRC) // SAFETY: caller contract: the GL context is current on this thread (forwarded to
// `compile_program_with`).
unsafe { compile_program_with(FRAG_SRC) }
} }
+97 -12
View File
@@ -24,11 +24,33 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
pub use cuda::DeviceBuffer; pub use cuda::DeviceBuffer;
pub use egl::{DmabufPlane, EglImporter}; pub use egl::{DmabufPlane, EglImporter};
/// Whether a `PUNKTFUNK_*` flag is truthy (`1`/`true`/`yes`/`on`), or `None` when unset. /// Whether a `PUNKTFUNK_*` flag is truthy (`1`/`true`/`yes`/`on`, any case), falsy (`0`/`false`/
/// `no`/`off`, any case), or `None` when unset — and, crucially, `None` for an *unrecognised*
/// spelling too. These flags default ON (`PUNKTFUNK_ZEROCOPY`, `PUNKTFUNK_NV12`), so treating a
/// typo'd `TRUE` as "off" silently inverted the operator's intent host-wide (a systemd drop-in or
/// Nix module is exactly where such spellings come from). Unrecognised values are logged once so
/// they are visible instead of silent.
fn flag_opt(name: &str) -> Option<bool> { fn flag_opt(name: &str) -> Option<bool> {
std::env::var(name) let v = std::env::var(name).ok()?;
.ok() match v.trim().to_ascii_lowercase().as_str() {
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on")) "1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" | "" => Some(false),
_ => {
use std::collections::HashSet;
use std::sync::Mutex;
static WARNED: Mutex<Option<HashSet<String>>> = Mutex::new(None);
let mut g = WARNED.lock().unwrap();
if g.get_or_insert_with(HashSet::new).insert(name.to_string()) {
tracing::warn!(
flag = name,
value = %v,
"unrecognised boolean value for this PUNKTFUNK_* flag — expected \
1/true/yes/on or 0/false/no/off (any case); using the flag's default"
);
}
None
}
}
} }
/// Whether a `PUNKTFUNK_*` flag is truthy (`1`/`true`/`yes`/`on`); unset ⇒ false. /// Whether a `PUNKTFUNK_*` flag is truthy (`1`/`true`/`yes`/`on`); unset ⇒ false.
@@ -38,7 +60,8 @@ fn flag(name: &str) -> bool {
/// True when `PUNKTFUNK_ZEROCOPY` is explicitly truthy — the operator forced the dmabuf offer, so /// True when `PUNKTFUNK_ZEROCOPY` is explicitly truthy — the operator forced the dmabuf offer, so
/// a failed negotiation keeps erroring loudly instead of silently downgrading to the CPU path. /// a failed negotiation keeps erroring loudly instead of silently downgrading to the CPU path.
pub fn vaapi_dmabuf_forced() -> bool { /// Read by BOTH negotiation-timeout latches (the raw passthrough's and the EGL→CUDA offer's).
pub fn zerocopy_forced() -> bool {
flag_opt("PUNKTFUNK_ZEROCOPY") == Some(true) flag_opt("PUNKTFUNK_ZEROCOPY") == Some(true)
} }
@@ -48,11 +71,12 @@ pub fn vaapi_dmabuf_forced() -> bool {
/// Vulkan (LINEAR) imports now run in a per-capture worker subprocess /// Vulkan (LINEAR) imports now run in a per-capture worker subprocess
/// (`design/zerocopy-worker-isolation.md`), so a driver fault on a producer-invalidated dmabuf kills /// (`design/zerocopy-worker-isolation.md`), so a driver fault on a producer-invalidated dmabuf kills
/// the worker and the host degrades to its capture-loss rebuild instead of dying — the reason the /// the worker and the host degrades to its capture-loss rebuild instead of dying — the reason the
/// NVENC path stayed opt-in is gone. Fallbacks stay in place: VAAPI has a one-shot CPU downgrade if /// NVENC path stayed opt-in is gone. Fallbacks stay in place: VAAPI has a one-shot CPU downgrade
/// the LINEAR-dmabuf offer never negotiates ([`note_vaapi_dmabuf_failed`]); NVENC falls back per /// if the LINEAR-dmabuf offer never negotiates ([`note_raw_dmabuf_negotiation_failed`]); NVENC
/// capture when no importer/importable modifier is available and latches the import off after /// falls back per capture when no importer/importable modifier is available, and latches the
/// repeated worker deaths. `PUNKTFUNK_ZEROCOPY=0` opts out; `PUNKTFUNK_FORCE_SHM` forces the /// import off after repeated worker deaths or its own negotiation timeout
/// race-free SHM path. /// ([`note_gpu_dmabuf_negotiation_failed`]). `PUNKTFUNK_ZEROCOPY=0` opts out;
/// `PUNKTFUNK_FORCE_SHM` forces the race-free SHM path.
/// ///
/// This is the GLOBAL switch and nothing but the env var moves it. It used to fall back to /// This is the GLOBAL switch and nothing but the env var moves it. It used to fall back to
/// `!VAAPI_DMABUF_FAILED`, a latch a capture-side dmabuf *negotiation* timeout set — which made one /// `!VAAPI_DMABUF_FAILED`, a latch a capture-side dmabuf *negotiation* timeout set — which made one
@@ -204,7 +228,7 @@ static GPU_IMPORT_DISABLED: AtomicBool = AtomicBool::new(false);
const GPU_IMPORT_DEATH_LATCH: u32 = 3; const GPU_IMPORT_DEATH_LATCH: u32 = 3;
/// Record a worker death (transport-level failure). Latches the process-wide disable after /// Record a worker death (transport-level failure). Latches the process-wide disable after
/// [`GPU_IMPORT_DEATH_LATCH`] consecutive deaths. /// `GPU_IMPORT_DEATH_LATCH` consecutive deaths.
pub fn note_gpu_import_death() { pub fn note_gpu_import_death() {
let streak = GPU_IMPORT_DEATH_STREAK.fetch_add(1, Ordering::Relaxed) + 1; let streak = GPU_IMPORT_DEATH_STREAK.fetch_add(1, Ordering::Relaxed) + 1;
if streak >= GPU_IMPORT_DEATH_LATCH && !GPU_IMPORT_DISABLED.swap(true, Ordering::Relaxed) { if streak >= GPU_IMPORT_DEATH_LATCH && !GPU_IMPORT_DISABLED.swap(true, Ordering::Relaxed) {
@@ -243,7 +267,7 @@ static RAW_DMABUF_DISABLED: AtomicBool = AtomicBool::new(false);
const RAW_DMABUF_FAILURE_LATCH: u32 = 3; const RAW_DMABUF_FAILURE_LATCH: u32 = 3;
/// Record an encoder-side raw-dmabuf import failure. Latches the process-wide disable after /// Record an encoder-side raw-dmabuf import failure. Latches the process-wide disable after
/// [`RAW_DMABUF_FAILURE_LATCH`] consecutive failures. /// `RAW_DMABUF_FAILURE_LATCH` consecutive failures.
pub fn note_raw_dmabuf_import_failure(reason: &str) { pub fn note_raw_dmabuf_import_failure(reason: &str) {
let streak = RAW_DMABUF_FAILURE_STREAK.fetch_add(1, Ordering::Relaxed) + 1; let streak = RAW_DMABUF_FAILURE_STREAK.fetch_add(1, Ordering::Relaxed) + 1;
if streak >= RAW_DMABUF_FAILURE_LATCH && !RAW_DMABUF_DISABLED.swap(true, Ordering::Relaxed) { if streak >= RAW_DMABUF_FAILURE_LATCH && !RAW_DMABUF_DISABLED.swap(true, Ordering::Relaxed) {
@@ -289,6 +313,35 @@ pub fn raw_dmabuf_import_disabled() -> bool {
RAW_DMABUF_DISABLED.load(Ordering::Relaxed) RAW_DMABUF_DISABLED.load(Ordering::Relaxed)
} }
/// The EGL→CUDA twin of the raw-passthrough negotiation latch: the capture advertised the GPU
/// importer's dmabuf-only offer and the compositor never accepted it. Without this, the raw
/// passthrough stopped being asked after one timeout while the GPU-import offer re-ran the same
/// 10 s negotiation timeout on every session, forever — the mirror image of the hybrid-Intel case
/// [`note_raw_dmabuf_negotiation_failed`] was written for.
static GPU_DMABUF_NEGOTIATION_FAILED: AtomicBool = AtomicBool::new(false);
/// Latch the EGL→CUDA dmabuf offer off because it *never negotiated*. One timeout is conclusive
/// for this offer too: a compositor that cannot allocate any of the advertised EGL-importable
/// modifiers refuses them identically on every retry. Scoped deliberately — this gates only
/// `build_importer` (the capture-side EGL→CUDA offer); the raw passthrough, the worker-death
/// latch, and the encoder are untouched.
pub fn note_gpu_dmabuf_negotiation_failed() {
if !GPU_DMABUF_NEGOTIATION_FAILED.swap(true, Ordering::Relaxed) {
tracing::warn!(
"zero-copy EGL→CUDA dmabuf offer disabled for this host process: the compositor never \
accepted the GPU importer's dmabuf-only capture offer, so later captures negotiate \
the CPU path instead of repeating that timeout (the raw-dmabuf passthrough is NOT \
affected)"
);
}
}
/// True once a negotiation timeout latched the EGL→CUDA dmabuf offer off (see
/// [`note_gpu_dmabuf_negotiation_failed`]).
pub fn gpu_dmabuf_negotiation_disabled() -> bool {
GPU_DMABUF_NEGOTIATION_FAILED.load(Ordering::Relaxed)
}
/// DRM FourCC for a packed 32-bit format name (little-endian, e.g. `b"XR24"`). /// DRM FourCC for a packed 32-bit format name (little-endian, e.g. `b"XR24"`).
const fn fourcc(c: &[u8; 4]) -> u32 { const fn fourcc(c: &[u8; 4]) -> u32 {
(c[0] as u32) | ((c[1] as u32) << 8) | ((c[2] as u32) << 16) | ((c[3] as u32) << 24) (c[0] as u32) | ((c[1] as u32) << 8) | ((c[2] as u32) << 16) | ((c[3] as u32) << 24)
@@ -465,6 +518,38 @@ pub fn nv12_selftest() -> anyhow::Result<()> {
mod tests { mod tests {
use super::*; use super::*;
/// [`bt709_limited`] is the sole oracle for the GPU colour self-test — a coefficient typo here
/// would fail the self-test on a correct GPU (operator blames the driver), or, mirrored into
/// the shaders, pass it on genuinely wrong output. Pin it to externally-known BT.709
/// limited-range anchors instead of trusting it to check itself.
#[test]
fn bt709_limited_reference_matches_known_anchors() {
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
let (y, u, v) = bt709_limited(0, 0, 0); // black
assert!(
close(y, 16.0) && close(u, 128.0) && close(v, 128.0),
"black → ({y}, {u}, {v})"
);
let (y, u, v) = bt709_limited(255, 255, 255); // white
assert!(
close(y, 235.0) && close(u, 128.0) && close(v, 128.0),
"white → ({y}, {u}, {v})"
);
// Pure red saturates V (Kr row sums to exactly +0.5), pure blue saturates U.
let (_, _, v) = bt709_limited(255, 0, 0);
assert!(close(v, 240.0), "red V → {v}");
let (_, u, _) = bt709_limited(0, 0, 255);
assert!(close(u, 240.0), "blue U → {u}");
// One mid-scale anchor so a swapped Kr/Kb pair can't cancel out: BT.709 Y of pure green
// is 16 + 219·0.7152.
let (y, _, _) = bt709_limited(0, 255, 0);
assert!(close(y, 16.0 + 219.0 * 0.7152), "green Y → {y}");
}
/// Single test owning the process-global latch statics (they are never reset by design). /// Single test owning the process-global latch statics (they are never reset by design).
#[test] #[test]
fn gpu_import_death_latch() { fn gpu_import_death_latch() {
+1 -1
View File
@@ -1,6 +1,6 @@
//! Wire protocol between the PipeWire capture thread and the isolated zero-copy GPU-import //! Wire protocol between the PipeWire capture thread and the isolated zero-copy GPU-import
//! worker process (`punktfunk-host zerocopy-worker`; design: //! worker process (`punktfunk-host zerocopy-worker`; design:
//! [`design/zerocopy-worker-isolation.md`]). Transport is a `SOCK_SEQPACKET` unix socketpair — //! `design/zerocopy-worker-isolation.md`). Transport is a `SOCK_SEQPACKET` unix socketpair —
//! reliable, ordered, message-framed (one `sendmsg` = one message) — with dmabuf fds riding as //! reliable, ordered, message-framed (one `sendmsg` = one message) — with dmabuf fds riding as
//! `SCM_RIGHTS` control data. Bodies are small serde_json blobs (~200 B/frame); pixels never //! `SCM_RIGHTS` control data. Bodies are small serde_json blobs (~200 B/frame); pixels never
//! cross the socket (they move GPU-side via CUDA IPC, see [`super::cuda::ipc_export`]). //! cross the socket (they move GPU-side via CUDA IPC, see [`super::cuda::ipc_export`]).
+2 -1
View File
@@ -11,7 +11,8 @@
// touch the same word (an 8-bit-storage-free way to write byte planes race-free). All pitches // touch the same word (an 8-bit-storage-free way to write byte planes race-free). All pitches
// and the UV offset are in WORDS and must be word-aligned (the Rust side sizes them so). // and the UV offset are in WORDS and must be word-aligned (the Rust side sizes them so).
// //
// Rebuild: glslc rgb2nv12_buf.comp -o rgb2nv12_buf.spv // Rebuild: glslangValidator -V rgb2nv12_buf.comp -o rgb2nv12_buf.spv (or glslc; CI diffs the
// disassembly against this source, tolerating only the generator header)
layout(local_size_x = 8, local_size_y = 8) in; layout(local_size_x = 8, local_size_y = 8) in;
layout(std430, binding = 0) readonly buffer Src { layout(std430, binding = 0) readonly buffer Src {
+106 -10
View File
@@ -45,7 +45,7 @@ use ash::vk;
pub const CURSOR_MAX: u32 = cuda::CURSOR_MAX; pub const CURSOR_MAX: u32 = cuda::CURSOR_MAX;
/// The vendored SPIR-V for `cursor_blend.comp` (beside this file; rebuild with /// The vendored SPIR-V for `cursor_blend.comp` (beside this file; rebuild with
/// `glslc cursor_blend.comp -o cursor_blend.spv`). /// `glslangValidator -V cursor_blend.comp -o cursor_blend.spv`; CI gates drift).
const CURSOR_SPV: &[u8] = include_bytes!("cursor_blend.spv"); const CURSOR_SPV: &[u8] = include_bytes!("cursor_blend.spv");
/// NVENC input-surface layout — selects the spec-constant `MODE` pipeline and the allocation /// NVENC input-surface layout — selects the spec-constant `MODE` pipeline and the allocation
@@ -84,9 +84,10 @@ impl SlotFormat {
} }
/// What the encoder holds per ring slot: the CUDA view it registers with NVENC plus the id it /// What the encoder holds per ring slot: the CUDA view it registers with NVENC plus the id it
/// hands back to [`VkSlotBlend::blend`]. The backing Vulkan objects + CUDA mapping live in the /// hands back to [`VkSlotBlend::blend_ref`] / [`VkSlotBlend::blend_ref_ordered`]. The backing
/// [`VkSlotBlend`] (freed by [`free_slots`](VkSlotBlend::free_slots) / drop), so this is Copy — /// Vulkan objects + CUDA mapping live in the [`VkSlotBlend`] (freed by
/// the encoder's ring keeps its existing shape. /// [`free_slots`](VkSlotBlend::free_slots) / drop), so this is Copy — the encoder's ring keeps
/// its existing shape.
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct VkSlotRef { pub struct VkSlotRef {
/// Device pointer NVENC registers (CUDA's mapping of the Vulkan memory). /// Device pointer NVENC registers (CUDA's mapping of the Vulkan memory).
@@ -654,15 +655,16 @@ impl VkSlotBlend {
} }
/// Free every allocated slot (encoder teardown, alongside its ring clear). CUDA mappings drop /// Free every allocated slot (encoder teardown, alongside its ring clear). CUDA mappings drop
/// first (field order in [`SlotAlloc`] frees `cuda` via its own `Drop` before we free the VK /// first (field order in `SlotAlloc` frees `cuda` via its own `Drop` before we free the VK
/// objects explicitly here). /// objects explicitly here).
pub fn free_slots(&mut self) { pub fn free_slots(&mut self) {
// Ordered blends return with their work still on the queue — quiesce before freeing the // Ordered blends return with their work still on the queue — quiesce before freeing the
// buffers/sets it references. `device_wait_idle` (not a timeline wait) so a submission // buffers/sets it references. `device_wait_idle` (not a timeline wait) so a submission
// whose CUDA copy-done signal never fired is still covered; this tiny device idles in // whose CUDA copy-done signal never fired is still covered. CPU-synced blends normally
// microseconds outside that pathological case. CPU-synced blends need nothing (they // fence-wait before returning, but a blend whose wait TIMED OUT propagated an error with
// fence-wait before returning), so a `None` timeline skips it. // its submission still executing so the drain runs unconditionally (it idles in
if self.timeline.is_some() && !self.slots.is_empty() { // microseconds on this tiny device outside the pathological cases it exists for).
if !self.slots.is_empty() {
// SAFETY: single-threaded owner; no other thread touches this device or its queue. // SAFETY: single-threaded owner; no other thread touches this device or its queue.
unsafe { unsafe {
let _ = self.device.device_wait_idle(); let _ = self.device.device_wait_idle();
@@ -772,7 +774,14 @@ impl VkSlotBlend {
let x0 = (ox >> 2) << 2; let x0 = (ox >> 2) << 2;
let spans = ((ox + cw as i32) - x0 + 3).div_euclid(4).max(1) as u32; let spans = ((ox + cw as i32) - x0 + 3).div_euclid(4).max(1) as u32;
let rows = match fmt { let rows = match fmt {
SlotFormat::Nv12 => ch.div_ceil(2), SlotFormat::Nv12 => {
// 2-row blocks anchored to the SURFACE chroma grid (cursor_blend.comp
// derives the same y0): count the blocks covering luma rows
// [oy, oy+ch) — one more than ch/2 when oy is odd.
let first = oy.div_euclid(2);
let last = (oy + ch as i32 - 1).div_euclid(2);
(last - first + 1) as u32
}
_ => ch, _ => ch,
}; };
(spans.div_ceil(8), rows.div_ceil(8)) (spans.div_ceil(8), rows.div_ceil(8))
@@ -796,6 +805,11 @@ impl VkSlotBlend {
gx: u32, gx: u32,
gy: u32, gy: u32,
) -> Result<vk::CommandBuffer> { ) -> Result<vk::CommandBuffer> {
// SAFETY: caller contract (`# Safety` above): the slot's previous submission completed, so
// its command buffer is re-recordable. Handles are owned by `self` on this single thread;
// `bytes` reborrows `push` (repr(C), size_of::<Push>()) for the synchronous push-constant
// copy; builder infos are locals outliving each call.
unsafe {
let alloc = self let alloc = self
.slots .slots
.get(id) .get(id)
@@ -865,6 +879,7 @@ impl VkSlotBlend {
d.end_command_buffer(cmd).context("end blend cmd")?; d.end_command_buffer(cmd).context("end blend cmd")?;
Ok(cmd) Ok(cmd)
} }
}
/// Blend the uploaded cursor into `slot` at `(ox, oy)`: record, submit, fence-wait. The /// Blend the uploaded cursor into `slot` at `(ox, oy)`: record, submit, fence-wait. The
/// caller has CPU-synced its CUDA frame copy first; the fence wait makes the shader's writes /// caller has CPU-synced its CUDA frame copy first; the fence wait makes the shader's writes
@@ -899,6 +914,14 @@ impl VkSlotBlend {
d.queue_submit(self.queue, &submit, self.fence) d.queue_submit(self.queue, &submit, self.fence)
.context("submit blend")?; .context("submit blend")?;
let r = d.wait_for_fences(&[self.fence], true, 1_000_000_000); let r = d.wait_for_fences(&[self.fence], true, 1_000_000_000);
if r.is_err() {
// TIMEOUT (or device loss): the submission may still be executing. Resetting a
// fence with a pending signal and re-recording this slot's command buffer next
// frame would race the GPU — drain the device first (what
// `VkBridge::import_linear` does on a failed wait). On this tiny device the
// idle completes in microseconds once the stall clears.
let _ = d.device_wait_idle();
}
d.reset_fences(&[self.fence]).ok(); d.reset_fences(&[self.fence]).ok();
r.context("blend fence wait")?; r.context("blend fence wait")?;
} }
@@ -1062,3 +1085,76 @@ impl Drop for VkSlotBlend {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn slot() -> VkSlotRef {
VkSlotRef {
ptr: 0,
pitch: 2048,
height: 1080,
id: 0,
}
}
fn geo(fmt: SlotFormat, cw: u32, ch: u32, ox: i32, oy: i32) -> Option<(Push, u32, u32)> {
VkSlotBlend::blend_geometry(&slot(), fmt, 1920, cw, ch, ox, oy)
}
/// An empty (clamped-away) cursor rect dispatches nothing.
#[test]
fn empty_rect_is_none() {
assert!(geo(SlotFormat::Argb, 0, 32, 10, 10).is_none());
assert!(geo(SlotFormat::Nv12, 32, 0, 10, 10).is_none());
}
/// Oversized bitmaps clamp to `CURSOR_MAX` — the push constants must agree with the staging
/// buffer's capacity, or the shader reads past the uploaded bitmap.
#[test]
fn cursor_dims_clamp_to_max() {
let (push, _, _) = geo(SlotFormat::Argb, CURSOR_MAX + 100, CURSOR_MAX + 1, 0, 0).unwrap();
assert_eq!(push.cur_w, CURSOR_MAX);
assert_eq!(push.cur_h, CURSOR_MAX);
}
/// ARGB dispatches per cursor pixel; NV12/YUV444 per word-aligned 4-px span, NV12 walking
/// 2-row blocks and YUV444 single rows. A 32×32 cursor at ox=13: spans cover the aligned
/// x∈[12,48) → 9 spans → 2 groups of 8.
#[test]
fn group_counts_per_format() {
let (_, gx, gy) = geo(SlotFormat::Argb, 32, 32, 13, 0).unwrap();
assert_eq!((gx, gy), (4, 4)); // 32/8 in both axes
let (_, gx, gy) = geo(SlotFormat::Nv12, 32, 32, 13, 0).unwrap();
assert_eq!((gx, gy), (2, 2)); // 9 spans → 2 groups; 16 2-row blocks → 2 groups
let (_, gx, gy) = geo(SlotFormat::Yuv444, 32, 32, 13, 0).unwrap();
assert_eq!((gx, gy), (2, 4)); // 9 spans → 2 groups; 32 rows → 4 groups
}
/// Negative `ox` must anchor spans with FLOOR alignment (`>>` on the signed value), not
/// truncating division: at ox=-5 the aligned start is -8, giving 9 spans over a 32-px cursor
/// (truncation would start at -4 and dispatch only 8 — silently dropping the right edge).
#[test]
fn negative_ox_floor_aligns_the_span_origin() {
let (push, gx, _) = geo(SlotFormat::Nv12, 32, 32, -5, 0).unwrap();
assert_eq!(push.ox, -5, "push constants carry the true origin");
assert_eq!(gx, 2, "9 spans from the floor-aligned start → 2 groups");
}
/// NV12 block rows anchor to the SURFACE chroma grid, so an odd `oy` needs one extra block
/// to reach the cursor's last luma row (the cursor-anchored count left that row's chroma
/// unwritten). Negative odd `oy` floors the same way (`div_euclid`).
#[test]
fn odd_oy_nv12_adds_the_straddle_block() {
let (_, _, gy) = geo(SlotFormat::Nv12, 32, 32, 0, 8).unwrap();
assert_eq!(gy, 2, "even oy: 16 chroma-grid blocks → 2 groups");
let (_, _, gy) = geo(SlotFormat::Nv12, 32, 32, 0, 7).unwrap();
assert_eq!(gy, 3, "odd oy: 17 blocks cover luma rows 7..39 → 3 groups");
let (push, _, gy) = geo(SlotFormat::Nv12, 32, 32, 0, -3).unwrap();
assert_eq!(push.oy, -3, "push constants carry the true origin");
assert_eq!(gy, 3, "blocks -4..30 in steps of 2 → 17 → 3 groups");
}
}
+125 -53
View File
@@ -51,7 +51,7 @@ struct Csc {
} }
/// The buffer-to-buffer RGB→NV12 compute shader (see `rgb2nv12_buf.comp` beside this file; /// The buffer-to-buffer RGB→NV12 compute shader (see `rgb2nv12_buf.comp` beside this file;
/// rebuild with `glslc rgb2nv12_buf.comp -o rgb2nv12_buf.spv`). /// rebuild with `glslangValidator -V rgb2nv12_buf.comp -o rgb2nv12_buf.spv`; CI gates drift).
const CSC_SPV: &[u8] = include_bytes!("rgb2nv12_buf.spv"); const CSC_SPV: &[u8] = include_bytes!("rgb2nv12_buf.spv");
pub struct VkBridge { pub struct VkBridge {
@@ -104,25 +104,44 @@ impl VkBridge {
) )
.context("vkCreateInstance")?; .context("vkCreateInstance")?;
// Pick the NVIDIA GPU (matches CUDA device 0 on this single-dGPU host). // Pick the NVIDIA GPU (matches CUDA device 0 on this single-dGPU host). Failure paths
let phys = instance // between instance and device creation destroy the instance explicitly (the shape
// `VkSlotBlend::new` uses) — a box that refuses the bridge retries per frame, so a
// leak here is unbounded, not one-shot.
let phys = match instance
.enumerate_physical_devices() .enumerate_physical_devices()
.context("enumerate GPUs")? .context("enumerate GPUs")
.into_iter() .map(|devs| {
devs.into_iter()
.find(|&p| instance.get_physical_device_properties(p).vendor_id == 0x10DE) .find(|&p| instance.get_physical_device_properties(p).vendor_id == 0x10DE)
.ok_or_else(|| anyhow!("no NVIDIA Vulkan device"))?; }) {
Ok(Some(p)) => p,
Ok(None) => {
instance.destroy_instance(None);
return Err(anyhow!("no NVIDIA Vulkan device"));
}
Err(e) => {
instance.destroy_instance(None);
return Err(e);
}
};
let mem_props = instance.get_physical_device_memory_properties(phys); let mem_props = instance.get_physical_device_memory_properties(phys);
// A COMPUTE-capable family (compute implies transfer): the copy path only needs // A COMPUTE-capable family (compute implies transfer): the copy path only needs
// transfer, but the NV12 CSC dispatch (T2.5b) needs compute — on every NVIDIA // transfer, but the NV12 CSC dispatch (T2.5b) needs compute — on every NVIDIA
// device family 0 is graphics+compute+transfer, so this picks the same family the // device family 0 is graphics+compute+transfer, so this picks the same family the
// old transfer-only predicate did. // old transfer-only predicate did.
let qf = instance let qf = match instance
.get_physical_device_queue_family_properties(phys) .get_physical_device_queue_family_properties(phys)
.iter() .iter()
.position(|q| q.queue_flags.contains(vk::QueueFlags::COMPUTE)) .position(|q| q.queue_flags.contains(vk::QueueFlags::COMPUTE))
.ok_or_else(|| anyhow!("no compute-capable queue family"))? {
as u32; Some(i) => i as u32,
None => {
instance.destroy_instance(None);
return Err(anyhow!("no compute-capable queue family"));
}
};
// Global-priority queue (latency plan §7 LN4, PyroWave's `ac0e7332` lever for the // Global-priority queue (latency plan §7 LN4, PyroWave's `ac0e7332` lever for the
// VkBridge): the LINEAR/gamescope CSC dispatch shares the SM/compute cores with the // VkBridge): the LINEAR/gamescope CSC dispatch shares the SM/compute cores with the
@@ -206,15 +225,34 @@ impl VkBridge {
try_priority = None; try_priority = None;
} }
Err(e) => { Err(e) => {
instance.destroy_instance(None);
return Err(e) return Err(e)
.context("vkCreateDevice (external-memory extensions supported?)") .context("vkCreateDevice (external-memory extensions supported?)");
} }
} }
}; };
// From here teardown-on-error goes through `Drop`, which tolerates null handles
// (Vulkan destroy calls are defined no-ops on VK_NULL_HANDLE) — build the remaining
// objects into an incrementally-filled struct so any `?` below unwinds cleanly
// instead of leaking the instance + device.
let ext_fd = ash::khr::external_memory_fd::Device::new(&instance, &device); let ext_fd = ash::khr::external_memory_fd::Device::new(&instance, &device);
let queue = device.get_device_queue(qf, 0); let queue = device.get_device_queue(qf, 0);
let mut me = VkBridge {
let cmd_pool = device _entry: entry,
instance,
device,
ext_fd,
queue,
cmd_pool: vk::CommandPool::null(),
cmd: vk::CommandBuffer::null(),
fence: vk::Fence::null(),
mem_props,
src_cache: HashMap::new(),
dst: None,
csc: None,
};
me.cmd_pool = me
.device
.create_command_pool( .create_command_pool(
&vk::CommandPoolCreateInfo::default() &vk::CommandPoolCreateInfo::default()
.queue_family_index(qf) .queue_family_index(qf)
@@ -222,33 +260,22 @@ impl VkBridge {
None, None,
) )
.context("create command pool")?; .context("create command pool")?;
let cmd = device me.cmd = me
.device
.allocate_command_buffers( .allocate_command_buffers(
&vk::CommandBufferAllocateInfo::default() &vk::CommandBufferAllocateInfo::default()
.command_pool(cmd_pool) .command_pool(me.cmd_pool)
.level(vk::CommandBufferLevel::PRIMARY) .level(vk::CommandBufferLevel::PRIMARY)
.command_buffer_count(1), .command_buffer_count(1),
) )
.context("allocate command buffer")?[0]; .context("allocate command buffer")?[0];
let fence = device me.fence = me
.device
.create_fence(&vk::FenceCreateInfo::default(), None) .create_fence(&vk::FenceCreateInfo::default(), None)
.context("create fence")?; .context("create fence")?;
tracing::info!("Vulkan bridge ready (dmabuf import → OPAQUE_FD export → CUDA)"); tracing::info!("Vulkan bridge ready (dmabuf import → OPAQUE_FD export → CUDA)");
Ok(VkBridge { Ok(me)
_entry: entry,
instance,
device,
ext_fd,
queue,
cmd_pool,
cmd,
fence,
mem_props,
src_cache: HashMap::new(),
dst: None,
csc: None,
})
} }
} }
@@ -265,6 +292,10 @@ impl VkBridge {
/// Import `fd` (dup'd internally; Vulkan owns the dup) as a transfer-src buffer of `size`. /// Import `fd` (dup'd internally; Vulkan owns the dup) as a transfer-src buffer of `size`.
unsafe fn import_src(&mut self, fd: i32, size: u64) -> Result<()> { unsafe fn import_src(&mut self, fd: i32, size: u64) -> Result<()> {
// SAFETY: caller contract: single-threaded use of handles this bridge owns; every builder
// info is built from locals that outlive the synchronous call reading them, and every
// fallible step destroys what it created before returning (comments below).
unsafe {
use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd}; use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd};
let dup = libc::dup(fd); let dup = libc::dup(fd);
if dup < 0 { if dup < 0 {
@@ -286,7 +317,8 @@ impl VkBridge {
// STORAGE so the NV12 compute CSC can read it as an SSBO (T2.5b); harmless // STORAGE so the NV12 compute CSC can read it as an SSBO (T2.5b); harmless
// for the plain copy path. // for the plain copy path.
.usage( .usage(
vk::BufferUsageFlags::TRANSFER_SRC | vk::BufferUsageFlags::STORAGE_BUFFER, vk::BufferUsageFlags::TRANSFER_SRC
| vk::BufferUsageFlags::STORAGE_BUFFER,
) )
.push_next(&mut ext_info), .push_next(&mut ext_info),
None, None,
@@ -350,9 +382,14 @@ impl VkBridge {
); );
Ok(()) Ok(())
} }
}
/// (Re)create the exportable destination of at least `size` bytes + its CUDA mapping. /// (Re)create the exportable destination of at least `size` bytes + its CUDA mapping.
unsafe fn ensure_dst(&mut self, size: u64) -> Result<()> { unsafe fn ensure_dst(&mut self, size: u64) -> Result<()> {
// SAFETY: caller contract: single-threaded use of handles this bridge owns; every builder
// info is built from locals that outlive the synchronous call reading them; created handles
// are destroyed on the error paths below or owned by `DstBuf`.
unsafe {
if self.dst.as_ref().is_some_and(|d| d.size >= size) { if self.dst.as_ref().is_some_and(|d| d.size >= size) {
return Ok(()); return Ok(());
} }
@@ -370,15 +407,17 @@ impl VkBridge {
.size(size) .size(size)
// STORAGE so the NV12 compute CSC can write it as an SSBO (T2.5b). // STORAGE so the NV12 compute CSC can write it as an SSBO (T2.5b).
.usage( .usage(
vk::BufferUsageFlags::TRANSFER_DST | vk::BufferUsageFlags::STORAGE_BUFFER, vk::BufferUsageFlags::TRANSFER_DST
| vk::BufferUsageFlags::STORAGE_BUFFER,
) )
.push_next(&mut ext_info), .push_next(&mut ext_info),
None, None,
) )
.context("create export buffer")?; .context("create export buffer")?;
let reqs = self.device.get_buffer_memory_requirements(buffer); let reqs = self.device.get_buffer_memory_requirements(buffer);
let mem_type = let mem_type = match self
match self.memory_type(reqs.memory_type_bits, vk::MemoryPropertyFlags::DEVICE_LOCAL) { .memory_type(reqs.memory_type_bits, vk::MemoryPropertyFlags::DEVICE_LOCAL)
{
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
self.device.destroy_buffer(buffer, None); self.device.destroy_buffer(buffer, None);
@@ -444,18 +483,59 @@ impl VkBridge {
}); });
Ok(()) Ok(())
} }
}
/// Build the RGB→NV12 compute pipeline once (T2.5b): two-SSBO descriptor set + a 28-byte /// Build the RGB→NV12 compute pipeline once (T2.5b): two-SSBO descriptor set + a 28-byte
/// push-constant block matching `rgb2nv12_buf.comp`'s `Push`. /// push-constant block matching `rgb2nv12_buf.comp`'s `Push`. A mid-build failure destroys
/// what was already created (the caller retries per frame, so a leak would be unbounded) and
/// leaves `self.csc` `None`.
unsafe fn ensure_csc(&mut self) -> Result<()> { unsafe fn ensure_csc(&mut self) -> Result<()> {
// SAFETY: caller contract: single-threaded use of handles this bridge owns. `build_csc`
// fills `csc` front to back; on failure the destroy calls below run in reverse creation
// order, and Vulkan destroys are defined no-ops on the null handles a partial build left.
unsafe {
if self.csc.is_some() { if self.csc.is_some() {
return Ok(()); return Ok(());
} }
let mut csc = Csc {
module: vk::ShaderModule::null(),
dset_layout: vk::DescriptorSetLayout::null(),
playout: vk::PipelineLayout::null(),
pipeline: vk::Pipeline::null(),
dpool: vk::DescriptorPool::null(),
dset: vk::DescriptorSet::null(),
};
if let Err(e) = self.build_csc(&mut csc) {
// Reverse creation order; Vulkan destroy calls are defined no-ops on the null
// handles a partial build left behind.
let d = &self.device;
d.destroy_descriptor_pool(csc.dpool, None); // frees `csc.dset` with it
d.destroy_pipeline(csc.pipeline, None);
d.destroy_pipeline_layout(csc.playout, None);
d.destroy_descriptor_set_layout(csc.dset_layout, None);
d.destroy_shader_module(csc.module, None);
return Err(e);
}
self.csc = Some(csc);
tracing::info!(
"Vulkan-bridge NV12 compute CSC ready (LINEAR path feeds NVENC native YUV)"
);
Ok(())
}
}
/// The fallible half of [`ensure_csc`](Self::ensure_csc): fills `csc` front to back so the
/// caller knows exactly what to destroy when a step fails.
unsafe fn build_csc(&mut self, csc: &mut Csc) -> Result<()> {
// SAFETY: caller contract (via `ensure_csc`): single-threaded use of handles this bridge
// owns; every builder info is built from locals that outlive the synchronous call reading
// them; partial results land in `csc` for the caller to destroy on failure.
unsafe {
let words: Vec<u32> = CSC_SPV let words: Vec<u32> = CSC_SPV
.chunks_exact(4) .chunks_exact(4)
.map(|c| u32::from_le_bytes(c.try_into().unwrap())) .map(|c| u32::from_le_bytes(c.try_into().unwrap()))
.collect(); .collect();
let module = self csc.module = self
.device .device
.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&words), None) .create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&words), None)
.context("create CSC shader module")?; .context("create CSC shader module")?;
@@ -471,7 +551,7 @@ impl VkBridge {
.descriptor_count(1) .descriptor_count(1)
.stage_flags(vk::ShaderStageFlags::COMPUTE), .stage_flags(vk::ShaderStageFlags::COMPUTE),
]; ];
let dset_layout = self csc.dset_layout = self
.device .device
.create_descriptor_set_layout( .create_descriptor_set_layout(
&vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings), &vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
@@ -481,8 +561,8 @@ impl VkBridge {
let pc = [vk::PushConstantRange::default() let pc = [vk::PushConstantRange::default()
.stage_flags(vk::ShaderStageFlags::COMPUTE) .stage_flags(vk::ShaderStageFlags::COMPUTE)
.size(28)]; .size(28)];
let layouts = [dset_layout]; let layouts = [csc.dset_layout];
let playout = self csc.playout = self
.device .device
.create_pipeline_layout( .create_pipeline_layout(
&vk::PipelineLayoutCreateInfo::default() &vk::PipelineLayoutCreateInfo::default()
@@ -494,22 +574,22 @@ impl VkBridge {
let entry = c"main"; let entry = c"main";
let stage = vk::PipelineShaderStageCreateInfo::default() let stage = vk::PipelineShaderStageCreateInfo::default()
.stage(vk::ShaderStageFlags::COMPUTE) .stage(vk::ShaderStageFlags::COMPUTE)
.module(module) .module(csc.module)
.name(entry); .name(entry);
let pipeline = self csc.pipeline = self
.device .device
.create_compute_pipelines( .create_compute_pipelines(
vk::PipelineCache::null(), vk::PipelineCache::null(),
&[vk::ComputePipelineCreateInfo::default() &[vk::ComputePipelineCreateInfo::default()
.stage(stage) .stage(stage)
.layout(playout)], .layout(csc.playout)],
None, None,
) )
.map_err(|(_, e)| anyhow!("create CSC pipeline: {e}"))?[0]; .map_err(|(_, e)| anyhow!("create CSC pipeline: {e}"))?[0];
let sizes = [vk::DescriptorPoolSize::default() let sizes = [vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::STORAGE_BUFFER) .ty(vk::DescriptorType::STORAGE_BUFFER)
.descriptor_count(2)]; .descriptor_count(2)];
let dpool = self csc.dpool = self
.device .device
.create_descriptor_pool( .create_descriptor_pool(
&vk::DescriptorPoolCreateInfo::default() &vk::DescriptorPoolCreateInfo::default()
@@ -518,25 +598,17 @@ impl VkBridge {
None, None,
) )
.context("create CSC descriptor pool")?; .context("create CSC descriptor pool")?;
let dset = self csc.dset = self
.device .device
.allocate_descriptor_sets( .allocate_descriptor_sets(
&vk::DescriptorSetAllocateInfo::default() &vk::DescriptorSetAllocateInfo::default()
.descriptor_pool(dpool) .descriptor_pool(csc.dpool)
.set_layouts(&layouts), .set_layouts(&layouts),
) )
.context("allocate CSC descriptor set")?[0]; .context("allocate CSC descriptor set")?[0];
self.csc = Some(Csc {
module,
dset_layout,
playout,
pipeline,
dpool,
dset,
});
tracing::info!("Vulkan-bridge NV12 compute CSC ready (LINEAR path feeds NVENC native YUV)");
Ok(()) Ok(())
} }
}
/// Bridge one LINEAR dmabuf frame into a pooled NV12 CUDA buffer (latency plan T2.5b): /// Bridge one LINEAR dmabuf frame into a pooled NV12 CUDA buffer (latency plan T2.5b):
/// instead of the plain byte copy, the compute CSC reads the imported RGB texels and writes /// instead of the plain byte copy, the compute CSC reads the imported RGB texels and writes
+66 -16
View File
@@ -1,5 +1,5 @@
//! The isolated zero-copy GPU-import worker (`punktfunk-host zerocopy-worker`; design: //! The isolated zero-copy GPU-import worker (`punktfunk-host zerocopy-worker`; design:
//! [`design/zerocopy-worker-isolation.md`]). It owns the fragile driver stack — the headless //! `design/zerocopy-worker-isolation.md`). It owns the fragile driver stack — the headless
//! EGLDisplay + GL context, the CUDA context, and the Vulkan bridge — so that a driver fault on a //! EGLDisplay + GL context, the CUDA context, and the Vulkan bridge — so that a driver fault on a
//! producer-invalidated dmabuf (the `cuGraphicsMapResources` SIGSEGV the F44 Game→Desktop switch //! producer-invalidated dmabuf (the `cuGraphicsMapResources` SIGSEGV the F44 Game→Desktop switch
//! reproduced) kills THIS process, not the streaming host. The host observes the dead socket, //! reproduced) kills THIS process, not the streaming host. The host observes the dead socket,
@@ -43,9 +43,24 @@ pub fn run_from_args(args: &[String]) -> Result<()> {
.transpose() .transpose()
.context("parse --fd")? .context("parse --fd")?
.unwrap_or(3); .unwrap_or(3);
// Refuse to adopt anything that cannot be the spawning host's socket: a negative fd is
// outright UB inside `OwnedFd` (its niche), and 02 would make this worker close one of its
// own stdio streams on exit. Then confirm the number actually holds an open socket — the
// subcommand is hidden but runnable by hand, and adopting an arbitrary inherited fd would
// close it behind its real owner.
anyhow::ensure!(fd >= 3, "--fd must be >= 3 (got {fd})");
// SAFETY: `libc::stat` is plain-old-data for which all-zero is a valid value, so
// `mem::zeroed()` is a sound initializer; `fstat` writes into the live, correctly-sized
// `&mut st` and only reads `fd`. `st_mode` is read only after the return value is checked.
let is_socket = unsafe {
let mut st: libc::stat = std::mem::zeroed();
libc::fstat(fd, &mut st) == 0 && (st.st_mode & libc::S_IFMT) == libc::S_IFSOCK
};
anyhow::ensure!(is_socket, "--fd {fd} is not an open socket");
// SAFETY: the spawning host `dup2`'d its socketpair end onto exactly this fd number before // SAFETY: the spawning host `dup2`'d its socketpair end onto exactly this fd number before
// exec (the subcommand's contract) and nothing else in this fresh process owns it, so // exec (the subcommand's contract, just verified to be an open socket ≥ 3) and nothing else
// `OwnedFd` takes sole ownership and closes it exactly once at exit. // in this fresh process owns it, so `OwnedFd` takes sole ownership and closes it exactly
// once at exit.
let sock = unsafe { OwnedFd::from_raw_fd(fd) }; let sock = unsafe { OwnedFd::from_raw_fd(fd) };
run(sock) run(sock)
} }
@@ -179,8 +194,9 @@ struct EglBackend {
inflight: HashMap<u32, DeviceBuffer>, inflight: HashMap<u32, DeviceBuffer>,
/// Buffer id per device allocation. Valid only within one pool generation: pools never free /// Buffer id per device allocation. Valid only within one pool generation: pools never free
/// allocations while alive, so a device VA can't repeat until a size change replaces the pool /// allocations while alive, so a device VA can't repeat until a size change replaces the pool
/// — at which point [`Self::note_dims`] clears this map (ids themselves are never reused; /// — at which point [`Self::note_dims`] clears this map, as does `ClearCache` (the host
/// `next_id` only counts up). /// retires its IPC mappings on that boundary). Ids themselves are never reused; `next_id`
/// only counts up.
ids: HashMap<CUdeviceptr, u32>, ids: HashMap<CUdeviceptr, u32>,
next_id: u32, next_id: u32,
/// The (kind, width, height) of the last import — a change means the importer replaced its /// The (kind, width, height) of the last import — a change means the importer replaced its
@@ -267,6 +283,13 @@ impl ImportBackend for EglBackend {
} }
self.fd_lru.clear(); self.fd_lru.clear();
self.importer.clear_linear_cache(); self.importer.clear_linear_cache();
// ClearCache is the generation boundary the HOST retires its CUDA IPC mappings on: it
// closes every mapping the renegotiated-away generation opened (keeping only ones still
// under an in-flight frame, until they release). Forget the VA→id map so anything
// delivered after this point gets a fresh id WITH its descriptor — re-delivering an old
// id would reference a mapping the host just closed. Ids never repeat (`next_id` only
// counts up), so fresh ids can't collide with the host's graveyard.
self.ids.clear();
} }
} }
@@ -344,6 +367,21 @@ mod tests {
use super::*; use super::*;
use std::sync::mpsc; use std::sync::mpsc;
/// Identity (`st_ino`) of an open fd — what SCM_RIGHTS preserves across the socket while
/// re-numbering the descriptor. Lets the dispatch test assert the fd that ARRIVED is the one
/// the host sent, not merely that the JSON body claimed one.
fn fd_ino(fd: impl AsRawFd) -> u64 {
// SAFETY: `libc::stat` is plain-old-data for which all-zero is a valid value, so
// `mem::zeroed()` is a sound initializer. `fd` is a live descriptor owned by the caller;
// `fstat` writes into the live, correctly-sized `&mut st`, and `st_ino` is read only
// after the return value is checked.
unsafe {
let mut st: libc::stat = std::mem::zeroed();
assert_eq!(libc::fstat(fd.as_raw_fd(), &mut st), 0, "fstat");
st.st_ino
}
}
/// Records calls; import behavior is scripted per key. /// Records calls; import behavior is scripted per key.
struct MockBackend { struct MockBackend {
calls: mpsc::Sender<String>, calls: mpsc::Sender<String>,
@@ -356,11 +394,13 @@ mod tests {
vec![7, 8, 9] vec![7, 8, 9]
} }
fn import(&mut self, req: &ImportReq, fd: Option<OwnedFd>) -> Reply { fn import(&mut self, req: &ImportReq, fd: Option<OwnedFd>) -> Reply {
let received = match &fd {
Some(f) => format!("ino:{}", fd_ino(f.as_raw_fd())),
None => "none".into(),
};
let _ = self.calls.send(format!( let _ = self.calls.send(format!(
"import:key={} kind={:?} fd={}", "import:key={} kind={:?} fd={received}",
req.key, req.key, req.kind,
req.kind,
fd.is_some()
)); ));
if req.key == 0xbad { if req.key == 0xbad {
return Reply::Err { return Reply::Err {
@@ -445,6 +485,15 @@ mod tests {
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap(); let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
assert_eq!(reply, Reply::Frame { id: 1, desc: None }); assert_eq!(reply, Reply::Frame { id: 1, desc: None });
// The descriptor itself must cross the socket: an import WITH an fd rides SCM_RIGHTS and
// the backend receives a live descriptor with the sender's identity — `serve` dropping the
// received fd (e.g. `backend.import(&req, None)`) would only be caught here.
let (pr, _pw) = std::io::pipe().unwrap();
let sent_ino = fd_ino(pr.as_fd().as_raw_fd());
proto::send(host.as_fd(), &import_req(3, true), Some(pr.as_fd())).unwrap();
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
assert_eq!(reply, Reply::Frame { id: 2, desc: None });
// A missing worker-side fd is a NeedFd reply (host resends), not a failure. // A missing worker-side fd is a NeedFd reply (host resends), not a failure.
proto::send(host.as_fd(), &import_req(0xfeed, false), None).unwrap(); proto::send(host.as_fd(), &import_req(0xfeed, false), None).unwrap();
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap(); let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
@@ -470,13 +519,14 @@ mod tests {
assert_eq!( assert_eq!(
calls, calls,
vec![ vec![
"modifiers:42", "modifiers:42".to_string(),
"import:key=1 kind=Tiled fd=false", "import:key=1 kind=Tiled fd=none".to_string(),
"import:key=1 kind=Tiled fd=false", "import:key=1 kind=Tiled fd=none".to_string(),
"import:key=65261 kind=Tiled fd=false", // 0xfeed format!("import:key=3 kind=Tiled fd=ino:{sent_ino}"),
"import:key=2989 kind=Tiled fd=false", // 0xbad "import:key=65261 kind=Tiled fd=none".to_string(), // 0xfeed
"release:0", "import:key=2989 kind=Tiled fd=none".to_string(), // 0xbad
"clear", "release:0".to_string(),
"clear".to_string(),
] ]
); );
} }
+4
View File
@@ -10,7 +10,11 @@
// Unsafe-proof program: every `unsafe {}` / `unsafe impl` must carry a `// SAFETY:` proof. Each // Unsafe-proof program: every `unsafe {}` / `unsafe impl` must carry a `// SAFETY:` proof. Each
// file keeps its own `#![deny(...)]` too; this crate-root deny is the catch-all gate. // file keeps its own `#![deny(...)]` too; this crate-root deny is the catch-all gate.
// `unsafe_op_in_unsafe_fn` closes the gap the clippy lint leaves: operations inside an
// `unsafe fn` body are not "unsafe blocks", so without it ~45 functions' worth of raw driver
// calls sat OUTSIDE the invariant this crate advertises.
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
#![deny(unsafe_op_in_unsafe_fn)]
/// Wait for a dmabuf's implicit read-ready fence (`DMA_BUF_IOCTL_EXPORT_SYNC_FILE` + poll). /// Wait for a dmabuf's implicit read-ready fence (`DMA_BUF_IOCTL_EXPORT_SYNC_FILE` + poll).
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
+10
View File
@@ -327,6 +327,16 @@ pub fn vmouse_spike(args: &[String]) -> Result<()> {
crate::inject::mouse_windows::spike_hold(secs) crate::inject::mouse_windows::spike_hold(secs)
} }
/// Windows CHANNEL-PROOF PROBE: settle, on a real box, which HID IOCTL hidclass forwards to a UMDF
/// HID minidriver — the one assumption in the pad channel's v3 delivery gate that could not be
/// settled by reading. Spins up a throwaway `pf_mouse_probe` devnode at pad index 9 (so it is safe
/// to run beside a live host), asks it for its channel proof over both HID paths, and prints which
/// answered. Needs the CURRENT drivers installed: `punktfunk-host.exe driver install --gamepad`.
#[cfg(target_os = "windows")]
pub fn channel_proof_probe(_args: &[String]) -> Result<()> {
crate::inject::mouse_windows::channel_proof_probe()
}
/// Windows: create a virtual DualSense via the UMDF driver (a SwDeviceCreate per-session /// Windows: create a virtual DualSense via the UMDF driver (a SwDeviceCreate per-session
/// devnode plus the shared-memory channel) and hold it, pushing one fixed frame (Cross + /// devnode plus the shared-memory channel) and hold it, pushing one fixed frame (Cross +
/// LS-right). Drives the real DualSenseWindowsManager, so it validates the device lifecycle /// LS-right). Drives the real DualSenseWindowsManager, so it validates the device lifecycle
+48 -2
View File
@@ -51,6 +51,33 @@ fn mdns_off_value(s: &str) -> bool {
/// Wire protocol id advertised in the `proto` TXT record. /// Wire protocol id advertised in the `proto` TXT record.
pub const NATIVE_PROTO: &str = "punktfunk/1"; pub const NATIVE_PROTO: &str = "punktfunk/1";
/// The DNS label a display name advertises its A record under (`<label>.local.`), which is a
/// different thing from the service *instance* name: an instance name may be free text
/// ("Living Room PC" — see `PUNKTFUNK_HOST_NAME`), a host name may not, and `mdns-sd` rejects the
/// whole `ServiceInfo` if the target isn't a legal name — which would take discovery down entirely
/// rather than just look wrong. Anything outside `[A-Za-z0-9.-]` becomes `-`; a name that is
/// already legal (every machine hostname, i.e. every host without the override) passes through
/// byte-for-byte, so this changes nothing for hosts that don't set the knob.
pub(crate) fn dns_label(name: &str) -> String {
let mut out = String::new();
for c in name.trim().chars() {
if out.len() >= 63 {
break;
}
out.push(if c.is_ascii_alphanumeric() || c == '-' || c == '.' {
c
} else {
'-'
});
}
let out = out.trim_matches(['-', '.']).to_string();
if out.is_empty() {
"punktfunk-host".to_string()
} else {
out
}
}
/// Holds the mDNS daemon; dropping it unregisters the service. /// Holds the mDNS daemon; dropping it unregisters the service.
pub struct Advert { pub struct Advert {
_daemon: ServiceDaemon, _daemon: ServiceDaemon,
@@ -70,7 +97,9 @@ pub fn advertise_native(
mgmt_port: Option<u16>, mgmt_port: Option<u16>,
) -> Result<Advert> { ) -> Result<Advert> {
let daemon = ServiceDaemon::new().context("create mDNS daemon")?; let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
let host_name = format!("{hostname}.local."); // `hostname` is the DISPLAY name (the instance label clients read back); the A-record target
// has to be a legal DNS name, hence the separate sanitized label.
let host_name = format!("{}.local.", dns_label(hostname));
let mut props: HashMap<String, String> = HashMap::new(); let mut props: HashMap<String, String> = HashMap::new();
props.insert("proto".into(), NATIVE_PROTO.into()); props.insert("proto".into(), NATIVE_PROTO.into());
props.insert("fp".into(), fingerprint.to_string()); props.insert("fp".into(), fingerprint.to_string());
@@ -116,7 +145,24 @@ pub fn advertise_native(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::mdns_off_value; use super::{dns_label, mdns_off_value};
#[test]
fn dns_label_passes_machine_names_through_and_tames_display_names() {
// Every host WITHOUT PUNKTFUNK_HOST_NAME must advertise exactly what it did before.
for plain in ["bazzite-htpc", "DESKTOP-1A2B3C", "box.lan", "steamdeck"] {
assert_eq!(dns_label(plain), plain);
}
// A free-text display name becomes a legal label instead of poisoning the record.
assert_eq!(dns_label("Living Room PC"), "Living-Room-PC");
assert_eq!(dns_label(" Ben's Rig! "), "Ben-s-Rig");
assert_eq!(dns_label("Wohnzimmer-PC ☕"), "Wohnzimmer-PC");
// Degenerate input still yields something registerable.
assert_eq!(dns_label("***"), "punktfunk-host");
assert_eq!(dns_label(""), "punktfunk-host");
// DNS caps a label at 63 bytes.
assert!(dns_label(&"a".repeat(200)).len() <= 63);
}
#[test] #[test]
fn mdns_off_grammar() { fn mdns_off_grammar() {
+3 -1
View File
@@ -13,7 +13,9 @@ pub struct Advert {
pub fn advertise(host: &Host) -> Result<Advert> { pub fn advertise(host: &Host) -> Result<Advert> {
let daemon = ServiceDaemon::new().context("create mDNS daemon")?; let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
let host_name = format!("{}.local.", host.hostname); // Instance name = the display name (what Moonlight lists); A-record target = the sanitized
// DNS label, so a free-text `PUNKTFUNK_HOST_NAME` can't produce an illegal record.
let host_name = format!("{}.local.", crate::discovery::dns_label(&host.hostname));
// No TXT records are required for Moonlight discovery; it resolves the A record and then // No TXT records are required for Moonlight discovery; it resolves the A record and then
// GETs /serverinfo for capabilities. // GETs /serverinfo for capabilities.
let props: HashMap<String, String> = HashMap::new(); let props: HashMap<String, String> = HashMap::new();
@@ -395,7 +395,15 @@ pub fn serve(
}) })
} }
/// The name this host shows up under everywhere a human sees it: Moonlight's host tile (the
/// serverinfo `<hostname>` element) and Punktfunk's own client lists (the mDNS service *instance*
/// name of both adverts). `PUNKTFUNK_HOST_NAME` wins — that's the point of the knob, a box whose
/// machine name is `bazzite-htpc` can present itself as "Living Room" — otherwise it's the machine's
/// own hostname, as it always was.
fn hostname_string() -> String { fn hostname_string() -> String {
if let Some(n) = pf_host_config::config().host_name.as_deref() {
return sanitize_display_name(n);
}
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
if let Some(n) = std::env::var_os("COMPUTERNAME") { if let Some(n) = std::env::var_os("COMPUTERNAME") {
let s = n.to_string_lossy().trim().to_string(); let s = n.to_string_lossy().trim().to_string();
@@ -410,6 +418,34 @@ fn hostname_string() -> String {
.unwrap_or_else(|| "punktfunk-host".to_string()) .unwrap_or_else(|| "punktfunk-host".to_string())
} }
/// Make an operator-supplied host name safe to carry as an mDNS service instance name. Spaces and
/// punctuation are fine there ("Living Room PC" is a perfectly legal instance name), but two things
/// are not: a `.` splits the instance label — and clients derive the display name as the first label
/// of the fullname (`pf-client-core::discovery`), so "Ben's PC v1.2" would arrive as "Ben's PC v1" —
/// and DNS-SD caps a label at 63 bytes. Control characters go too.
fn sanitize_display_name(raw: &str) -> String {
let cleaned: String = raw
.trim()
.chars()
.filter(|c| !c.is_control())
.map(|c| if c == '.' { '-' } else { c })
.collect();
// Truncate on a char boundary so multi-byte names can't produce invalid UTF-8.
let mut out = String::new();
for c in cleaned.trim().chars() {
if out.len() + c.len_utf8() > 63 {
break;
}
out.push(c);
}
let out = out.trim().to_string();
if out.is_empty() {
"punktfunk-host".to_string()
} else {
out
}
}
/// Load the persisted host uniqueid, or mint one (from the kernel UUID source) and store it. /// Load the persisted host uniqueid, or mint one (from the kernel UUID source) and store it.
fn load_or_create_uniqueid() -> Result<String> { fn load_or_create_uniqueid() -> Result<String> {
let path = pf_paths::config_dir().join("uniqueid"); let path = pf_paths::config_dir().join("uniqueid");
@@ -489,6 +525,30 @@ pub(crate) fn save_paired(paired: &[Vec<u8>]) {
} }
} }
#[cfg(test)]
mod host_name_tests {
use super::sanitize_display_name;
/// The display name rides the mDNS service INSTANCE label, and clients read it back as the
/// first label of the fullname — so a `.` truncates the name in every client list. Split from
/// the env read: `PUNKTFUNK_HOST_NAME` is process-global and must not race the parallel suite.
#[test]
fn display_name_survives_free_text_but_loses_the_label_breakers() {
assert_eq!(sanitize_display_name("Living Room PC"), "Living Room PC");
assert_eq!(sanitize_display_name(" Wohnzimmer "), "Wohnzimmer");
// A dot would otherwise cut the name short client-side ("Ben's PC v1").
assert_eq!(sanitize_display_name("Ben's PC v1.2"), "Ben's PC v1-2");
assert_eq!(sanitize_display_name("Küche ☕"), "Küche ☕");
assert_eq!(sanitize_display_name("tab\there"), "tabhere");
// Never empty — an empty instance name is not registerable.
assert_eq!(sanitize_display_name(" "), "punktfunk-host");
// 63-byte DNS-SD label ceiling, truncated on a char boundary.
let long = sanitize_display_name(&"ü".repeat(100));
assert!(long.len() <= 63, "{} bytes", long.len());
assert_eq!(long, "ü".repeat(31));
}
}
#[cfg(test)] #[cfg(test)]
mod session_tests { mod session_tests {
use super::*; use super::*;
@@ -94,9 +94,20 @@ fn base_codec_mode_support() -> u32 {
return m; return m;
} }
} }
// Windows AMD/Intel (AMF/QSV): advertise only what the GPU actually encodes (AV1 is narrow, an // Linux NVIDIA: the driver's own encode-GUID list (`nvenc_codec_support`, one throwaway
// old iGPU might lack HEVC). AMF probes natively (no build feature needed); QSV needs the // direct-SDK session, cached) — the same probe `host_wire_caps` consults, so both planes
// libavcodec build. NVENC and the GPU-less software path keep the static superset. // stop advertising HEVC/AV1 on a chip without them (the GM107 dead-session field bug).
// Fail-open like every arm here: an unanswerable probe → `probed_mask` = None → superset.
#[cfg(all(target_os = "linux", feature = "nvenc"))]
if !crate::encode::linux_zero_copy_is_vaapi() {
if let Some(m) = probed_mask(crate::encode::nvenc_codec_support()) {
return m;
}
}
// Windows: advertise only what the GPU actually encodes (AV1 is narrow, an old iGPU might
// lack HEVC, a 1st-gen-Maxwell NVENC is H.264-only). AMF probes natively (no build feature
// needed); QSV needs the libavcodec or VPL build, NVENC the `nvenc` build. The GPU-less
// software path keeps the static superset.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
if crate::encode::windows_backend_is_probed() { if crate::encode::windows_backend_is_probed() {
if let Some(m) = probed_mask(crate::encode::windows_codec_support()) { if let Some(m) = probed_mask(crate::encode::windows_codec_support()) {
+4
View File
@@ -559,6 +559,10 @@ fn real_main() -> Result<()> {
// (validates the resident-mouse cursor-presence fix on-glass). `--seconds N`. // (validates the resident-mouse cursor-presence fix on-glass). `--seconds N`.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
Some("vmouse-spike") => devtest::vmouse_spike(&args), Some("vmouse-spike") => devtest::vmouse_spike(&args),
// Windows: ask a throwaway pf_mouse devnode for its channel proof and report which HID
// transport hidclass forwarded — the pad channel's v3 delivery gate, verified on glass.
#[cfg(target_os = "windows")]
Some("channel-proof-probe") => devtest::channel_proof_probe(&args),
// Windows: create a virtual DualSense (or --ds4/--edge/--deck/--xbox) via the UMDF driver and // Windows: create a virtual DualSense (or --ds4/--edge/--deck/--xbox) via the UMDF driver and
// hold it, driving the real *WindowsManager end to end. `--index N`, `--seconds N`. // hold it, driving the real *WindowsManager end to end. `--index N`, `--seconds N`.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
+14 -3
View File
@@ -319,9 +319,19 @@ pub(crate) struct AvailableCompositor {
) )
)] )]
pub(crate) async fn list_compositors() -> Json<Vec<AvailableCompositor>> { pub(crate) async fn list_compositors() -> Json<Vec<AvailableCompositor>> {
// Compositor backends are a Linux concept. On Windows the pf-vdisplay IddCx driver is the sole
// virtual-display backend and `vdisplay::open` ignores the compositor argument entirely, so the
// list could only ever be five Linux backends flagged unavailable with no default — which reads
// as broken detection rather than "not applicable here". Report none and let the console say so.
#[cfg(not(target_os = "linux"))]
let list = Vec::new();
#[cfg(target_os = "linux")]
// One `/proc` scan backs BOTH columns (see `vdisplay::available`), so a backend can no longer
// be the auto-detect default and "unavailable" on the same row — the contradiction that made
// this list look broken on a box plainly running the compositor it called unavailable.
let list = {
let available = crate::vdisplay::available(); let available = crate::vdisplay::available();
let default = crate::vdisplay::detect().ok(); let default = crate::vdisplay::detect().ok();
Json(
crate::vdisplay::Compositor::all() crate::vdisplay::Compositor::all()
.into_iter() .into_iter()
.map(|c| AvailableCompositor { .map(|c| AvailableCompositor {
@@ -330,8 +340,9 @@ pub(crate) async fn list_compositors() -> Json<Vec<AvailableCompositor>> {
available: available.contains(&c), available: available.contains(&c),
default: default == Some(c), default: default == Some(c),
}) })
.collect(), .collect()
) };
Json(list)
} }
/// Live host status /// Live host status
+7
View File
@@ -652,9 +652,16 @@ async fn compositors_lists_all_backends_with_flags() {
let (status, body) = send(&app, get_req("/api/v1/compositors")).await; let (status, body) = send(&app, get_req("/api/v1/compositors")).await;
assert_eq!(status, StatusCode::OK); assert_eq!(status, StatusCode::OK);
let arr = body.as_array().expect("array"); let arr = body.as_array().expect("array");
// Compositor backends are Linux-only; elsewhere the list is empty on purpose (the console
// renders "not applicable on this host" instead of five greyed-out rows).
#[cfg(not(target_os = "linux"))]
assert!(arr.is_empty(), "non-Linux hosts advertise no compositors");
// Every backend the host knows, in stable order. // Every backend the host knows, in stable order.
#[cfg(target_os = "linux")]
{
let ids: Vec<&str> = arr.iter().map(|c| c["id"].as_str().unwrap()).collect(); let ids: Vec<&str> = arr.iter().map(|c| c["id"].as_str().unwrap()).collect();
assert_eq!(ids, ["kwin", "gamescope", "mutter", "wlroots", "hyprland"]); assert_eq!(ids, ["kwin", "gamescope", "mutter", "wlroots", "hyprland"]);
}
for c in arr { for c in arr {
assert!(c["available"].is_boolean()); assert!(c["available"].is_boolean());
assert!(c["default"].is_boolean()); assert!(c["default"].is_boolean());
+17 -2
View File
@@ -153,6 +153,15 @@ fn install_gamepad(dir: &Path) -> Result<()> {
bail!("no driver .inf in {}", dir.display()); bail!("no driver .inf in {}", dir.display());
} }
trust_cert(dir); trust_cert(dir);
// Retire the PRE-RENAME package first. `pf-dualsense` became `pf-gamepad` (one driver always
// served four identities, so the old name read as if the other three lived elsewhere) and the
// HARDWARE IDS deliberately did not move — they are the binding contract with every devnode the
// host creates and with every installed system. That means an upgraded box would otherwise hold
// TWO store packages claiming `pf_dualsense`/`pf_dualshock4`/…, and PnP would be free to bind
// the stale one. Matched on `pf_dualsense.dll`, a string only the OLD package's INF contains —
// the new INF still mentions the hardware ids, so matching on those would delete what we are
// about to install.
delete_store_drivers(&["pf_dualsense.dll"]);
// Add each package to the store - no /install, no device node: the host SwDeviceCreate's the // Add each package to the store - no /install, no device node: the host SwDeviceCreate's the
// per-session devnode when a client forwards a pad, so PnP binds the store driver on demand. // per-session devnode when a client forwards a pad, so PnP binds the store driver on demand.
for inf in &infs { for inf in &infs {
@@ -187,7 +196,7 @@ fn remove_pad_devnodes() {
// ── `driver uninstall [--gamepad]` ────────────────────────────────────────────────────────────── // ── `driver uninstall [--gamepad]` ──────────────────────────────────────────────────────────────
// The uninstaller's cleanup counterpart (Inno [UninstallRun]) — the field report was that our // The uninstaller's cleanup counterpart (Inno [UninstallRun]) — the field report was that our
// virtual-device drivers survived an uninstall. Removes the pf-vdisplay device node(s) + driver // virtual-device drivers survived an uninstall. Removes the pf-vdisplay device node(s) + driver
// package, or (--gamepad) the pf-dualsense/pf-xusb driver packages (their devnodes are per-session // package, or (--gamepad) the pf-gamepad/pf-xusb driver packages (their devnodes are per-session
// SwDeviceCreate'd and are already gone once the service stopped). Locale-safe by construction: we // SwDeviceCreate'd and are already gone once the service stopped). Locale-safe by construction: we
// never parse pnputil's localized LABELS — devices are matched on the un-localized VALUE side // never parse pnputil's localized LABELS — devices are matched on the un-localized VALUE side
// (instance IDs / device IDs), and driver packages are found by scanning %WINDIR%\INF\oem*.inf // (instance IDs / device IDs), and driver packages are found by scanning %WINDIR%\INF\oem*.inf
@@ -226,7 +235,13 @@ fn uninstall_gamepad() -> Result<()> {
// Devnodes first (incl. phantoms — the same ghost-device complaint the vdisplay uninstall // Devnodes first (incl. phantoms — the same ghost-device complaint the vdisplay uninstall
// fixed), then the store packages. // fixed), then the store packages.
remove_pad_devnodes(); remove_pad_devnodes();
delete_store_drivers(&["pf_dualsense", "pf_dualshock4", "pf_xusb", "pf_mouse"]); delete_store_drivers(&[
"pf_gamepad",
"pf_dualsense",
"pf_dualshock4",
"pf_xusb",
"pf_mouse",
]);
Ok(()) Ok(())
} }
+5 -1
View File
@@ -814,7 +814,11 @@ fn ensure_default_host_env() -> Result<()> {
# PUNKTFUNK_HOST_CMD=serve --gamestream\n\ # PUNKTFUNK_HOST_CMD=serve --gamestream\n\
\n\ \n\
# Force a specific render GPU by name substring (multi-GPU boxes only):\n\ # Force a specific render GPU by name substring (multi-GPU boxes only):\n\
# PUNKTFUNK_RENDER_ADAPTER=4090\n"; # PUNKTFUNK_RENDER_ADAPTER=4090\n\
\n\
# The name this host shows up under in Moonlight and the Punktfunk clients\n\
# (default: the machine's own computer name):\n\
# PUNKTFUNK_HOST_NAME=Living Room\n";
// Write host.env DACL-locked to SYSTEM/Administrators: it controls the SYSTEM service's // Write host.env DACL-locked to SYSTEM/Administrators: it controls the SYSTEM service's
// environment + launched command line, so a local user must not be able to read or tamper with // environment + launched command line, so a local user must not be able to read or tamper with
// it (security-review 2026-06-28 #3). // it (security-review 2026-06-28 #3).
+2
View File
@@ -126,6 +126,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
| Setting | Values | Meaning | | Setting | Values | Meaning |
|---|---|---| |---|---|---|
| `PUNKTFUNK_HOST_NAME` | free text, e.g. `Living Room` | The name this host shows up under in Moonlight and in the Punktfunk clients. Default: the machine's own hostname — so a box called `bazzite-htpc` can present itself as `Living Room` without renaming the machine. Takes effect on host restart. Spaces and accents are fine; `.` becomes `-` (a dot would split the name in client lists) and it's capped at 63 characters. The machine's real hostname is still what the host answers to on the network. |
| `PUNKTFUNK_MDNS` | `1` · `0` *(default on)* | mDNS adverts (native + GameStream). `0` skips them (same as `--no-mdns`) — for networks/containers where multicast doesn't work; add the host by address in the client instead. | | `PUNKTFUNK_MDNS` | `1` · `0` *(default on)* | mDNS adverts (native + GameStream). `0` skips them (same as `--no-mdns`) — for networks/containers where multicast doesn't work; add the host by address in the client instead. |
| `PUNKTFUNK_DATA_PORT` | port | Pin the per-session video data plane to a fixed UDP port and stream direct (no hole-punch) — open exactly that port in the host firewall. Same as `serve --data-port`; see [Troubleshooting](/docs/troubleshooting). Default: random port + hole-punch. | | `PUNKTFUNK_DATA_PORT` | port | Pin the per-session video data plane to a fixed UDP port and stream direct (no hole-punch) — open exactly that port in the host firewall. Same as `serve --data-port`; see [Troubleshooting](/docs/troubleshooting). Default: random port + hole-punch. |
@@ -166,6 +167,7 @@ A few knobs are read by the native **clients**, not the host:
| Setting | Values | Meaning | | Setting | Values | Meaning |
|---|---|---| |---|---|---|
| `PUNKTFUNK_DECODER` | `software` · `vaapi` · `vulkan` (Linux) · `d3d11va` (Windows) | Force the decode path. Default auto-selects hardware per vendor (Linux: VAAPI on Intel/AMD, Vulkan Video on NVIDIA and the Steam Deck; Windows: Vulkan Video on NVIDIA/AMD, D3D11VA on Intel and others) with a software fallback. | | `PUNKTFUNK_DECODER` | `software` · `vaapi` · `vulkan` (Linux) · `d3d11va` (Windows) | Force the decode path. Default auto-selects hardware per vendor (Linux: VAAPI on Intel/AMD, Vulkan Video on NVIDIA and the Steam Deck; Windows: Vulkan Video on NVIDIA/AMD, D3D11VA on Intel and others) with a software fallback. |
| `PUNKTFUNK_OSD_SCALE` | multiplier, e.g. `1.5` *(default `1`)* | Size of the in-stream overlay — the stats OSD, the capture hint and the start banner. They already follow your display's scaling setting (200 % display → twice the pixels), so set this only to nudge that: bigger for a TV across the room, smaller if your compositor reports an aggressive scale. Clamped to 0.5×–4×, and a line that would run off the screen is shrunk to fit. |
## Bitrate ## Bitrate
+5 -49
View File
@@ -1,5 +1,7 @@
Update whenever it suits you — the app and the machine you stream from can be updated one at a time, and everything already paired keeps working. Update whenever it suits you — the app and the machine you stream from can be updated one at a time, and everything already paired keeps working.
The headline: on Linux, Punktfunk's host can now stream one of your machine's own physical monitors instead of always creating a virtual one. This release also carries a KDE Plasma fix found while building it: on Plasma 6.7 and newer, the host had silently been falling back to a slower, less reliable way of arranging displays on every single session.
## New: stream one of your actual monitors, not just a virtual screen ## New: stream one of your actual monitors, not just a virtual screen
Until now, Punktfunk's Linux host always created its own virtual display to stream from — even on a machine you're sitting in front of, with real monitors already showing something. You can now point it at one of your machine's real monitors instead: pick it from a new **Streamed screen** card in the console, or pin it once in the host's configuration for an unattended box. Until now, Punktfunk's Linux host always created its own virtual display to stream from — even on a machine you're sitting in front of, with real monitors already showing something. You can now point it at one of your machine's real monitors instead: pick it from a new **Streamed screen** card in the console, or pin it once in the host's configuration for an unattended box.
@@ -14,44 +16,6 @@ For headless and unattended setups, two new commands help you find and verify th
On current KDE Plasma (6.7 and newer), Punktfunk's direct way of talking to the desktop about display layout stopped seeing any displays at all, with no visible error — every session quietly fell back to an external helper tool that's known to hang under load, exactly the thing the previous release's Plasma fix was meant to stop relying on. Fixed: Punktfunk now recognizes both the way older and newer Plasma releases announce their displays. On current KDE Plasma (6.7 and newer), Punktfunk's direct way of talking to the desktop about display layout stopped seeing any displays at all, with no visible error — every session quietly fell back to an external helper tool that's known to hang under load, exactly the thing the previous release's Plasma fix was meant to stop relying on. Fixed: Punktfunk now recognizes both the way older and newer Plasma releases announce their displays.
## Fixed: installing or updating the Windows host
Three separate reports came in within hours of the last release, all blocking install:
- **`winget install` could fail outright if you'd ever had another streaming host on the machine** — even one you had since disabled or mostly uninstalled. A leftover, disabled service or an empty folder from an old install was enough to trip the "conflicting host" check and abort. Only a host actually set to start on its own counts now.
- **Installing through a package-manager app (like UniGetUI) could fail with a file-path error** before the install wizard ever appeared, because of a broken setting the installer passed itself. Fixed.
- **The package source instructions were incomplete.** `winget install unom.PunktfunkHost` says "no package found" until you've pointed winget at Punktfunk's own source, once per machine — the command for that is now spelled out.
## Fixed: Moonlight compatibility no longer turns on without asking
A fresh Windows install used to enable Moonlight-app compatibility silently, with no checkbox ever shown — even though that mode pairs over a less secure connection and the host warns about it on every start. It's opt-in now. Existing installs are untouched either way; Punktfunk's own clients were never affected.
## Fixed: switching into Steam's Game Mode on Linux could black out the machine
The previous release shipped the permission needed to properly take over the screen for Steam's Game Mode — and surfaced a few sharp edges that only show up once that takeover is actually live:
- **The takeover could kill Punktfunk's own background service a few seconds later**, leaving the display dark with nothing left to bring it back except a physical console or SSH.
- **It could also hang waiting on a permission prompt nobody could see**, delaying recovery until well after the stream had already dropped — or act on a leftover marker file from months ago and jump the machine to the desktop for no reason.
- **Restarting the host mid-stream** — including a routine update — **could leave the display stuck off**, since nothing told the screen to come back.
All three are fixed. Recognizing the machine's built-in Steam Game Mode may now need a one-time permission on some distributions; installing or updating normally takes care of it.
## Fixed: a laptop with its built-in screen turned off stuttered constantly
On a laptop with two graphics chips and the internal screen switched off — a common setup for streaming from a laptop docked to a TV or monitor — streaming stuttered roughly every two seconds, for the whole session. Punktfunk now recognizes a dark-but-connected laptop screen as the cause and points at the actual fix, instead of pointing at an external monitor that isn't the problem.
## Fixed: latency creeping up over the course of a fast, high-bitrate stream
On a fast local network, especially at higher bitrates, latency could climb steadily over a long session instead of staying flat. Several separate causes are fixed at once: the stream's internal clock sync could get starved by a busy connection and let the two ends' clocks drift; a stream mode could put more data on the wire than its bitrate setting actually allowed; a slow-to-decode moment could build up a backlog that took a while to work through instead of catching up immediately; and the one-time startup speed test could be mistaken for real network congestion and permanently cap the stream below what the connection could actually handle.
## Fixed: missing cover art looked broken
A library entry with no cover art showed as an empty grey box, which read as something that failed to load. It now shows a controller icon instead, so "no art yet" looks intentional rather than broken.
## Improved: the Windows app now keeps its own log file
The desktop app writes its own log to `%LOCALAPPDATA%\punktfunk\logs\client.log` now, instead of only printing to a console window that a normal launch never shows. If you're ever asked to send logs for a problem, this is where they are.
## Under the hood (for developers) ## Under the hood (for developers)
- **Nothing changed on the wire.** Streaming protocol stays at version **2**, the embeddable C ABI at **13**, the Windows virtual-display driver protocol at **6** — 0.180.21 hosts and clients keep mixing freely. `GET /display/monitors` is a new endpoint (always 200, an explained empty list on a compositor-less host or a nested gamescope session); the display policy gains an optional `capture_monitor` field, resolved env-over-policy so a host.env pin outranks a console change. Both are additive. - **Nothing changed on the wire.** Streaming protocol stays at version **2**, the embeddable C ABI at **13**, the Windows virtual-display driver protocol at **6** — 0.180.21 hosts and clients keep mixing freely. `GET /display/monitors` is a new endpoint (always 200, an explained empty list on a compositor-less host or a nested gamescope session); the display policy gains an optional `capture_monitor` field, resolved env-over-policy so a host.env pin outranks a console change. Both are additive.
@@ -61,14 +25,6 @@ The desktop app writes its own log to `%LOCALAPPDATA%\punktfunk\logs\client.log`
- **libei absolute-coordinate resolution changed from matching by mode size to `mapping_id` → origin → size → first**, since two outputs can share a size but never a top-left, and a mirrored head's region is not the client's stream size at all. A named anchor that matches nothing falls back down the ladder rather than stranding input, and both a miss and a match now log once per distinct answer. `punktfunk-host anchor-test` exercises the ladder against a live compositor with two same-sized outputs — the one case a unit test can only simulate. - **libei absolute-coordinate resolution changed from matching by mode size to `mapping_id` → origin → size → first**, since two outputs can share a size but never a top-left, and a mirrored head's region is not the client's stream size at all. A named anchor that matches nothing falls back down the ladder rather than stranding input, and both a miss and a match now log once per distinct answer. `punktfunk-host anchor-test` exercises the ladder against a live compositor with two same-sized outputs — the one case a unit test can only simulate.
- **`SWAYSOCK` is now derived rather than required to be inherited** — by the compositor's known PID, then the newest socket owned by the host's user — closing the last session variable a `systemd --user` host lacked for sway. A new drop-in (`PartOf`/`WantedBy` on `graphical-session.target`, opt-in, shipped under `/usr/share`) restarts a desktop-login host with its desktop, so a Wayland socket and portal connection that die with a Plasma/GNOME restart don't leave the daemon silently unable to recover. - **`SWAYSOCK` is now derived rather than required to be inherited** — by the compositor's known PID, then the newest socket owned by the host's user — closing the last session variable a `systemd --user` host lacked for sway. A new drop-in (`PartOf`/`WantedBy` on `graphical-session.target`, opt-in, shipped under `/usr/share`) restarts a desktop-login host with its desktop, so a Wayland socket and portal connection that die with a Plasma/GNOME restart don't leave the daemon silently unable to recover.
- **KWin's in-process output-management path now also binds `kde_output_device_registry_v2`**, not just the per-output `kde_output_device_v2` globals it originally shipped with — KWin 6.7 stopped advertising the latter, so the module written specifically to avoid shelling out to `kscreen-doctor` (0.19.x) saw zero devices and silently degraded to it on every session. Both models are supported now; registry-sourced devices arrive one Wayland round-trip later, so the handshake gains one conditional extra barrier. - **KWin's in-process output-management path now also binds `kde_output_device_registry_v2`**, not just the per-output `kde_output_device_v2` globals it originally shipped with — KWin 6.7 stopped advertising the latter, so the module written specifically to avoid shelling out to `kscreen-doctor` (0.19.x) saw zero devices and silently degraded to it on every session. Both models are supported now; registry-sourced devices arrive one Wayland round-trip later, so the handshake gains one conditional extra barrier.
- **Windows conflict detection** now keys on the competing service's `Start` registry value (02 = boot/system/auto only), matching the tray's own dormant-is-harmless logic from last release; install directories no longer count. - **CI-only fix:** the winget release-verification step's `envs:` allow-list was a step-level sibling of `with:`/`env:` instead of nested inside `with:`, so `appleboy/ssh-action` never actually received it as an input and the step kept failing on every tag. Moved to match how `REGISTRY_TOKEN` is already forwarded elsewhere.
- **The winget `Log` switch used the literal string `|LOGPATH|`** instead of winget's actual `<LOGPATH>` substitution token, so Inno received an illegal filename on any install that requested a log (UniGetUI does by default). Guarded by a new manifest check.
- **`gamestream` installer task lost its default-checked flag**; a silent/unattended install now lands `PUNKTFUNK_HOST_CMD=serve`. `/MERGETASKS=gamestream` opts in. Fresh installs only — `GamestreamParam` already omitted the flag on upgrades. See [v0.20.1](v0.20.1.md) for the fixes carried by that release (Windows install/winget, GameStream opt-in default, the gamescope Game Mode takeover hardening, a laptop-panel stall fix, and the PyroWave latency-creep bundle) — all included here too, since this release supersedes it.
- **The gamescope display-manager takeover** now calls privileged `systemctl` verbs non-interactively (`--no-ask-password`) so a headless polkit prompt can no longer block the stream's own recovery thread; `session_select_requested`'s sentinel baseline is now recorded at takeover time as well as at successful launch, closing a window where a months-old `~/.config/steamos-session-select` write could false-positive a session-select honor; and `punktfunk-host` now handles SIGTERM/SIGINT by restoring the display manager it stopped (20 s grace) before exiting, so a `systemctl --user restart` or package upgrade mid-takeover can't strand the box. The takeover also now ensures the invoking user has systemd lingering enabled before touching the display manager — without it, stopping the DM stops the user's own service manager ~10 s later and takes the host down with it.
- **A new `internal_panel` target class** lets the exclusive-topology stall detector name a deactivated eDP/LVDS laptop panel as a disturbance suspect; previously the suspect list was filtered to external physicals only, so the one display responsible on a hybrid laptop reported as none. `EvtIddCxMonitorI2CTransmit/Receive` now answer every DDC/CI probe against the virtual monitor with an immediate `STATUS_NOT_SUPPORTED`, so third-party brightness/monitor tools stop occupying win32k's serialized physical-monitor path on it. The swap-chain drain thread now falls back to `TIME_CRITICAL` priority if MMCSS registration is refused, and raises `IddMinimumVersionRequired` 4 → 10 to use `IddCxSetRealtimeGPUPriority`.
- **Clock re-sync** now spaces its probe rounds ~7 ms apart, tracks a running best-RTT guard band instead of a static connect-time floor, and applies the best batch of a rejection streak after three consecutive rejections rather than drifting unbounded.
- **A new per-frame `WireBudget`** (shared between encoder backends) tracks the real bitstream-to-wire inflation ratio for datagram-aligned PyroWave sessions and deflates the rate-control target by it, so the configured bitrate pin holds on the wire rather than on the raw codec bitstream.
- **The pre-decode frame channel now drains to the newest AU for all-intra (PyroWave) streams**, since those have no reference chain forbidding a mid-stream drop — capping any standing backlog at roughly one frame instead of ratcheting between the coarse jump-to-live thresholds.
- **The ABR capacity probe's first post-probe report window is now discarded outright**, rather than measured, so the probe's own deliberate overload can no longer read as session congestion and permanently end slow-start early.
- **New CI-only fix:** the winget release-verification step's `envs:` allow-list was a step-level sibling of `with:`/`env:` instead of nested inside `with:`, so `appleboy/ssh-action` never actually received it as an input and the step kept failing on every tag. Moved to match how `REGISTRY_TOKEN` is already forwarded elsewhere.
- **New dev tooling:** `display-disturb` reproduces DDC/CI and modeset-class display disturbances on demand for stall-immunity testing; `punktfunk-host anchor-test`/`mirror-test` are described above. None of this ships to end users.
Generated
+82
View File
@@ -1,5 +1,29 @@
{ {
"nodes": { "nodes": {
"bun2nix": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": [
"nixpkgs"
],
"systems": "systems",
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1784665499,
"narHash": "sha256-9BMxlTxCCDAeoNLtb1a/st7udtTIJep+wpUzquA29VU=",
"owner": "nix-community",
"repo": "bun2nix",
"rev": "0f2a1f0b6f42cebe3b149bf62d38754c5e0e9729",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "2.1.2",
"repo": "bun2nix",
"type": "github"
}
},
"crane": { "crane": {
"locked": { "locked": {
"lastModified": 1784172867, "lastModified": 1784172867,
@@ -15,6 +39,27 @@
"type": "github" "type": "github"
} }
}, },
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"bun2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1782949081,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1784120854, "lastModified": 1784120854,
@@ -33,6 +78,7 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"bun2nix": "bun2nix",
"crane": "crane", "crane": "crane",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay" "rust-overlay": "rust-overlay"
@@ -57,6 +103,42 @@
"repo": "rust-overlay", "repo": "rust-overlay",
"type": "github" "type": "github"
} }
},
"systems": {
"locked": {
"lastModified": 1776166891,
"narHash": "sha256-bI8yrEGjrohR5hkQox7UrxDH7XqrYMwI8SL/LrJ1+S8=",
"owner": "nix-systems",
"repo": "triplet",
"rev": "6de7bc09397911ce03636afbcf6118745ab2cda0",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "triplet",
"type": "github"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"bun2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1784369104,
"narHash": "sha256-47cxbcZODibHv3rELFQ9vZly0vUNkND/atn/U7HLeb0=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "df3c0640565d04a0261253cdd89fce78ec50168a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
} }
}, },
"root": "root", "root": "root",
+66 -21
View File
@@ -8,6 +8,15 @@
url = "github:oxalica/rust-overlay"; url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
# The bun packages' node_modules (punktfunk-web, punktfunk-scripting): one fetchurl per package,
# straight out of `bun.lock`'s integrity hashes — no hand-maintained aggregate deps hash to bump.
# PIN THE TAG. `bun.nix` has no schema stability guarantee across bun2nix versions, so this ref
# must move together with the `bun2nix` devDependency in web/package.json + sdk/package.json
# (which regenerates the file on every `bun install`). See packaging/nix/README.md.
bun2nix = {
url = "github:nix-community/bun2nix?ref=2.1.2";
inputs.nixpkgs.follows = "nixpkgs";
};
}; };
outputs = outputs =
@@ -16,6 +25,7 @@
nixpkgs, nixpkgs,
crane, crane,
rust-overlay, rust-overlay,
bun2nix,
}: }:
let let
# Linux/x86_64 only — the host encodes with desktop NVENC and CI publishes no aarch64 leg # Linux/x86_64 only — the host encodes with desktop NVENC and CI publishes no aarch64 leg
@@ -26,7 +36,9 @@
# The workspace version is the single source of truth (crates/*/Cargo.toml inherit it). # The workspace version is the single source of truth (crates/*/Cargo.toml inherit it).
version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).workspace.package.version; version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).workspace.package.version;
pkgsFor = system: import nixpkgs { pkgsFor =
system:
import nixpkgs {
inherit system; inherit system;
overlays = [ (import rust-overlay) ]; overlays = [ (import rust-overlay) ];
}; };
@@ -38,7 +50,8 @@
craneLibFor = pkgs: (crane.mkLib pkgs).overrideToolchain toolchainFor; craneLibFor = pkgs: (crane.mkLib pkgs).overrideToolchain toolchainFor;
packagesFor = system: packagesFor =
system:
let let
pkgs = pkgsFor system; pkgs = pkgsFor system;
in in
@@ -46,20 +59,35 @@
craneLib = craneLibFor pkgs; craneLib = craneLibFor pkgs;
src = self; src = self;
inherit version; inherit version;
# `.hook` + `.fetchBunDeps` (bun2nix v2 API) — see packages.nix.
bun2nix = bun2nix.packages.${system}.default;
}; };
in in
{ {
packages = forAllSystems (system: packages = forAllSystems (
let pf = packagesFor system; system:
in { let
inherit (pf) punktfunk-host punktfunk-client punktfunk-web punktfunk-scripting punktfunk-tray; pf = packagesFor system;
in
{
inherit (pf)
punktfunk-host
punktfunk-client
punktfunk-web
punktfunk-scripting
punktfunk-tray
;
default = pf.punktfunk-host; default = pf.punktfunk-host;
}); }
);
# `nix run .#punktfunk-host -- serve` / `nix run .#punktfunk-client`. # `nix run .#punktfunk-host -- serve` / `nix run .#punktfunk-client`.
apps = forAllSystems (system: apps = forAllSystems (
let pf = packagesFor system; system:
in { let
pf = packagesFor system;
in
{
punktfunk-host = { punktfunk-host = {
type = "app"; type = "app";
program = "${pf.punktfunk-host}/bin/punktfunk-host"; program = "${pf.punktfunk-host}/bin/punktfunk-host";
@@ -80,19 +108,29 @@
program = "${pf.punktfunk-scripting}/bin/punktfunk-scripting"; program = "${pf.punktfunk-scripting}/bin/punktfunk-scripting";
}; };
default = self.apps.${system}.punktfunk-host; default = self.apps.${system}.punktfunk-host;
}); }
);
# `nix flake check` builds every package (web included — needs its deps hash filled in, see # `nix flake check` builds every package.
# packaging/nix/README.md). checks = forAllSystems (
checks = forAllSystems (system: system:
let pf = packagesFor system; let
in { pf = packagesFor system;
inherit (pf) punktfunk-host punktfunk-client punktfunk-web punktfunk-scripting; in
}); {
inherit (pf)
punktfunk-host
punktfunk-client
punktfunk-web
punktfunk-scripting
;
}
);
# `nix develop` — the pinned toolchain plus every system lib the workspace links, wired so # `nix develop` — the pinned toolchain plus every system lib the workspace links, wired so
# `cargo build` (all crates, host + client) works out of the box. # `cargo build` (all crates, host + client) works out of the box.
devShells = forAllSystems (system: devShells = forAllSystems (
system:
let let
pkgs = pkgsFor system; pkgs = pkgsFor system;
toolchain = toolchainFor pkgs; toolchain = toolchainFor pkgs;
@@ -134,9 +172,16 @@
PF_FFVK_VULKAN_INCLUDE = "${pkgs.vulkan-headers}/include"; PF_FFVK_VULKAN_INCLUDE = "${pkgs.vulkan-headers}/include";
# CMake ≥ 4 rejects the pre-3.5 minimums some vendored C libs (libopus) still declare. # CMake ≥ 4 rejects the pre-3.5 minimums some vendored C libs (libopus) still declare.
CMAKE_POLICY_VERSION_MINIMUM = "3.5"; CMAKE_POLICY_VERSION_MINIMUM = "3.5";
LD_LIBRARY_PATH = "/run/opengl-driver/lib:${pkgs.lib.makeLibraryPath [ pkgs.vulkan-loader pkgs.libGL gbm ]}"; LD_LIBRARY_PATH = "/run/opengl-driver/lib:${
pkgs.lib.makeLibraryPath [
pkgs.vulkan-loader
pkgs.libGL
gbm
]
}";
}; };
}); }
);
formatter = forAllSystems (system: (pkgsFor system).nixfmt-rfc-style); formatter = forAllSystems (system: (pkgsFor system).nixfmt-rfc-style);
+10 -4
View File
@@ -39,10 +39,16 @@ if [ ! -x "$BIN" ]; then
PUNKTFUNK_BUILD_VERSION="$VERSION" cargo build --release -p "$PKG" --locked # stamp --version (build.rs) PUNKTFUNK_BUILD_VERSION="$VERSION" cargo build --release -p "$PKG" --locked # stamp --version (build.rs)
fi fi
TRAY_BIN="target/release/punktfunk-tray" TRAY_BIN="target/release/punktfunk-tray"
if [ ! -x "$TRAY_BIN" ]; then # ALWAYS built here, in its OWN cargo invocation — load-bearing, not tidiness, and deliberately not
echo "==> building punktfunk-tray (release)" # skipped when the artifact already exists. Cargo unifies features across everything in one build,
cargo build --release -p punktfunk-tray --locked # so a caller that co-built the tray with the host (the .deb workflow used to) leaves behind a
fi # binary whose zbus took the host's ashpd -> zbus/tokio while the tray runs ksni's async-io
# executor with no tokio runtime by design — it then panics at every launch with "there is no
# reactor running, must be called from the context of a Tokio 1.x runtime". Skipping the rebuild is
# exactly how that binary shipped. Building it alone keeps its zbus on async-io; cargo no-ops this
# when the existing artifact was already resolved that way, and rebuilds it when it wasn't.
echo "==> building punktfunk-tray (release, own invocation — see comment above)"
cargo build --release -p punktfunk-tray --locked
STAGE="$(mktemp -d)" STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT trap 'rm -rf "$STAGE"' EXIT
+35 -15
View File
@@ -193,7 +193,12 @@ The `${package}/share/punktfunk-host/headless/` helpers (KDE/Sway session script
```sh ```sh
nix develop # pinned toolchain (rust-toolchain.toml) + all system libs nix develop # pinned toolchain (rust-toolchain.toml) + all system libs
cargo build --release -p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-tray cargo build --release -p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
# The tray gets its OWN invocation — co-building it with the host unifies the host's
# ashpd -> zbus/tokio onto the tray's zbus (which runs ksni's async-io executor, no tokio runtime),
# and the resulting binary panics at launch: "there is no reactor running, must be called from the
# context of a Tokio 1.x runtime". Same split the .deb / RPM / Arch packaging does.
cargo build --release -p punktfunk-tray
``` ```
The shell exports `PF_FFVK_VULKAN_INCLUDE` (Vulkan headers for pf-ffvk bindgen) and an The shell exports `PF_FFVK_VULKAN_INCLUDE` (Vulkan headers for pf-ffvk bindgen) and an
@@ -218,20 +223,35 @@ The shell exports `PF_FFVK_VULKAN_INCLUDE` (Vulkan headers for pf-ffvk bindgen)
host would pull the host's `ashpd → zbus/tokio` onto the tray's shared `zbus`, and the tray then host would pull the host's `ashpd → zbus/tokio` onto the tray's shared `zbus`, and the tray then
panics at startup (`there is no reactor running, must be called from the context of a Tokio 1.x panics at startup (`there is no reactor running, must be called from the context of a Tokio 1.x
runtime`). Building it as a separate `-p punktfunk-tray` invocation keeps its `zbus` on async-io; runtime`). Building it as a separate `-p punktfunk-tray` invocation keeps its `zbus` on async-io;
the host package copies the resulting binary into its `$out`. (The deb/rpm/arch builds co-build the the host package copies the resulting binary into its `$out`. (The rpm/arch builds split it the same
two in one `cargo build`, so they share this latent crash on Linux — a separate fix.) way. The **.deb did not**, despite its sibling comments claiming otherwise: `deb.yml` co-built
- **The bun packages (`punktfunk-web`, `punktfunk-scripting`) — their `bun install` deps hashes.** `-p punktfunk-host -p punktfunk-tray`, and `build-deb.sh`'s own standalone build was skipped because
Both build their `node_modules` in a *fixed-output derivation* (`bun install` needs the network + the poisoned artifact already existed — so this shipped as a real crash-at-launch on Debian/Ubuntu,
the read-public `@unom` npm registry). Each `outputHash` (in `packaging/nix/packages.nix`) is not a latent one. Fixed 2026-07-27: the workflow no longer co-builds it and `build-deb.sh` now
pinned to a resolved dependency set and **must be refreshed when its lockfile changes** rebuilds it unconditionally.)
`web/bun.lock` for the console, `sdk/bun.lock` for the runner: set that `outputHash = lib.fakeHash`, - **The bun packages (`punktfunk-web`, `punktfunk-scripting`) use [bun2nix](https://github.com/nix-community/bun2nix).**
run `nix build .#punktfunk-web` (or `.#punktfunk-scripting`), and copy the `got: sha256-…` value Their `node_modules` is fetched **one `fetchurl` per package**, straight from the integrity hashes
Nix prints back into the field. Everything downstream is offline (the console's codegen + vite already in the lockfile, via a generated-and-committed `bun.nix` (`web/bun.nix`, `sdk/bun.nix`).
build; the runner's `bun build --target=bun` bundle), so only the deps FODs ever need network. There is **no aggregate deps hash to bump** — the previous design put `bun install` in a
Both launchers exec `pkgs.bun` from the store — unlike the deb/rpm, which vendor a bun binary fixed-output derivation whose single `outputHash` silently went stale on every lockfile change and
because apt/dnf have none. broke the build. `bun.nix` regenerates itself: `bun2nix` is a devDependency of both packages and
- **Commit `flake.lock`:** it pins the input revisions (nixpkgs / crane / rust-overlay). It is runs on every `bun install` (web's `postinstall`; the SDK's `prepare`, since sdk/ is the
generated on first eval and checked in. *published* `@punktfunk/host` package and a `postinstall` would then fire on consumers' installs).
Regenerate by hand with `cd web && bunx bun2nix -o bun.nix` if a lockfile is ever edited directly.
The `@unom` scope needs no special handling: `web/bun.lock` records those tarballs' full
`https://git.unom.io/api/packages/unom/npm/…` URLs and the registry is read-public (the same
anonymous pull CI's rpm/deb builds do).
> ⚠ **`bun.nix` has no schema stability across bun2nix versions.** The flake input is pinned
> (`github:nix-community/bun2nix?ref=2.1.2`) and the npm devDependency is pinned to the *same*
> exact version in `web/package.json` + `sdk/package.json`. Move both together, then rerun
> `bun install` in `web/` and `sdk/` to regenerate.
Everything past the deps fetch is offline (the console's codegen + vite build; the runner's
`bun build --target=bun` bundle). Both launchers exec `pkgs.bun` from the store — unlike the
deb/rpm, which vendor a bun binary because apt/dnf have none.
- **Commit `flake.lock`:** it pins the input revisions (nixpkgs / crane / rust-overlay / bun2nix).
It is generated on first eval and checked in.
- **Session Skia OSD is off under Nix.** `punktfunk-session`'s default `ui` feature draws its - **Session Skia OSD is off under Nix.** `punktfunk-session`'s default `ui` feature draws its
on-screen stats/console overlay with `skia-safe`, whose build *downloads* a prebuilt Skia from on-screen stats/console overlay with `skia-safe`, whose build *downloads* a prebuilt Skia from
the rust-skia releases — which Nix's network-less build sandbox forbids, and a from-source Skia the rust-skia releases — which Nix's network-less build sandbox forbids, and a from-source Skia
+67 -21
View File
@@ -21,22 +21,36 @@
# for a session with `systemctl --user enable --now punktfunk-host` (or set `autoStart = true` for a # for a session with `systemctl --user enable --now punktfunk-host` (or set `autoStart = true` for a
# headless appliance with `users.users.<u>.linger = true`). # headless appliance with `users.users.<u>.linger = true`).
self: self:
{ config, lib, pkgs, ... }: {
config,
lib,
pkgs,
...
}:
let let
inherit (lib) inherit (lib)
mkEnableOption mkOption mkIf mkMerge mkDefault types mkEnableOption
optional optionals optionalString literalExpression mkOption
concatStringsSep mapAttrsToList genAttrs; mkIf
mkMerge
mkDefault
types
optional
optionals
optionalString
literalExpression
concatStringsSep
mapAttrsToList
genAttrs
;
cfg = config.services.punktfunk; cfg = config.services.punktfunk;
system = pkgs.stdenv.hostPlatform.system; system = pkgs.stdenv.hostPlatform.system;
# host.env rendering: booleans → 1/0 (what PUNKTFUNK_* knobs expect), everything else verbatim. # host.env rendering: booleans → 1/0 (what PUNKTFUNK_* knobs expect), everything else verbatim.
renderVal = v: renderVal = v: if lib.isBool v then (if v then "1" else "0") else toString v;
if lib.isBool v then (if v then "1" else "0") renderEnv =
else toString v; attrs: concatStringsSep "\n" (mapAttrsToList (k: v: "${k}=${renderVal v}") attrs) + "\n";
renderEnv = attrs:
concatStringsSep "\n" (mapAttrsToList (k: v: "${k}=${renderVal v}") attrs) + "\n";
hostSettingsFile = pkgs.writeText "punktfunk-host.env" (renderEnv cfg.host.settings); hostSettingsFile = pkgs.writeText "punktfunk-host.env" (renderEnv cfg.host.settings);
@@ -44,10 +58,21 @@ let
# ephemeral per-session UDP port the host hole-punches, so nothing fixed to open (see # ephemeral per-session UDP port the host hole-punches, so nothing fixed to open (see
# packaging/linux/punktfunk.ufw). # packaging/linux/punktfunk.ufw).
nativeTCP = [ 47990 ]; # mgmt/library REST API (HTTPS + mTLS) nativeTCP = [ 47990 ]; # mgmt/library REST API (HTTPS + mTLS)
nativeUDP = [ 9777 5353 ]; # QUIC control plane + mDNS nativeUDP = [
9777
5353
]; # QUIC control plane + mDNS
# GameStream/Moonlight-compat fixed ports (opt-in with `host.gamestream`). # GameStream/Moonlight-compat fixed ports (opt-in with `host.gamestream`).
gamestreamTCP = [ 47984 47989 48010 ]; gamestreamTCP = [
gamestreamUDP = [ 47998 47999 48000 ]; 47984
47989
48010
];
gamestreamUDP = [
47998
47999
48000
];
in in
{ {
options.services.punktfunk = { options.services.punktfunk = {
@@ -93,7 +118,13 @@ in
}; };
settings = mkOption { settings = mkOption {
type = types.attrsOf (types.oneOf [ types.str types.int types.bool ]); type = types.attrsOf (
types.oneOf [
types.str
types.int
types.bool
]
);
default = { }; default = { };
example = literalExpression '' example = literalExpression ''
{ {
@@ -233,10 +264,12 @@ in
config = mkMerge [ config = mkMerge [
# --- shared: whenever either half is enabled ----------------------------------------------- # --- shared: whenever either half is enabled -----------------------------------------------
(mkIf (cfg.host.enable || cfg.client.enable) { (mkIf (cfg.host.enable || cfg.client.enable) {
assertions = [{ assertions = [
{
assertion = system == "x86_64-linux"; assertion = system == "x86_64-linux";
message = "services.punktfunk is x86_64-linux only (desktop NVENC host; no aarch64 build)."; message = "services.punktfunk is x86_64-linux only (desktop NVENC host; no aarch64 build).";
}]; }
];
# The GPU driver libs the binaries dlopen at runtime (libcuda / libnvidia-encode / libEGL / # The GPU driver libs the binaries dlopen at runtime (libcuda / libnvidia-encode / libEGL /
# the Vulkan ICD) live under /run/opengl-driver/lib — provided by hardware.graphics. # the Vulkan ICD) live under /run/opengl-driver/lib — provided by hardware.graphics.
hardware.graphics.enable = mkDefault true; hardware.graphics.enable = mkDefault true;
@@ -257,11 +290,17 @@ in
services.udev.path = [ pkgs.coreutils ]; services.udev.path = [ pkgs.coreutils ];
# uinput/uhid: the virtual X360 + DualSense nodes. vhci-hcd: the usbip transport that makes # uinput/uhid: the virtual X360 + DualSense nodes. vhci-hcd: the usbip transport that makes
# the virtual Steam Deck a real USB device (Steam Input only adopts USB pads). # the virtual Steam Deck a real USB device (Steam Input only adopts USB pads).
boot.kernelModules = [ "uinput" "uhid" "vhci-hcd" ]; boot.kernelModules = [
"uinput"
"uhid"
"vhci-hcd"
];
# `input` group membership for the virtual-gamepad nodes (mirrors the RPM's usermod hint). # `input` group membership for the virtual-gamepad nodes (mirrors the RPM's usermod hint).
users.groups.input = { }; users.groups.input = { };
users.users = genAttrs cfg.host.users (_: { extraGroups = [ "input" ]; }); users.users = genAttrs cfg.host.users (_: {
extraGroups = [ "input" ];
});
# Status-tray autostart entry (self-gating: `--autostart` exits unless this user runs a host). # Status-tray autostart entry (self-gating: `--autostart` exits unless this user runs a host).
environment.etc."xdg/autostart/io.unom.Punktfunk.Tray.desktop".source = environment.etc."xdg/autostart/io.unom.Punktfunk.Tray.desktop".source =
@@ -281,10 +320,14 @@ in
wantedBy = optional cfg.host.autoStart "default.target"; wantedBy = optional cfg.host.autoStart "default.target";
# The host may exec external helpers (pw-dump, sh, and — for the gamescope/kwin backends — # The host may exec external helpers (pw-dump, sh, and — for the gamescope/kwin backends —
# the compositor). Extend this in your config for a headless gamescope/KWin appliance. # the compositor). Extend this in your config for a headless gamescope/KWin appliance.
path = [ pkgs.bash pkgs.coreutils pkgs.pipewire ]; path = [
pkgs.bash
pkgs.coreutils
pkgs.pipewire
];
serviceConfig = { serviceConfig = {
ExecStart = "${cfg.host.package}/bin/punktfunk-host serve" ExecStart =
+ optionalString cfg.host.gamestream " --gamestream"; "${cfg.host.package}/bin/punktfunk-host serve" + optionalString cfg.host.gamestream " --gamestream";
Restart = "on-failure"; Restart = "on-failure";
RestartSec = 2; RestartSec = 2;
EnvironmentFile = EnvironmentFile =
@@ -339,7 +382,10 @@ in
systemd.user.services.punktfunk-web = { systemd.user.services.punktfunk-web = {
description = "punktfunk management web console"; description = "punktfunk management web console";
documentation = [ "https://git.unom.io/unom/punktfunk" ]; documentation = [ "https://git.unom.io/unom/punktfunk" ];
after = [ "punktfunk-web-init.service" "punktfunk-host.service" ]; after = [
"punktfunk-web-init.service"
"punktfunk-host.service"
];
wants = [ "punktfunk-web-init.service" ]; wants = [ "punktfunk-web-init.service" ];
wantedBy = optional cfg.web.autoStart "default.target"; wantedBy = optional cfg.web.autoStart "default.target";
environment = { environment = {
+87 -104
View File
@@ -60,8 +60,10 @@
bun, bun,
nodejs, nodejs,
makeWrapper, makeWrapper,
cacert,
stdenvNoCC, stdenvNoCC,
# bun2nix (github:nix-community/bun2nix, pinned in flake.nix): `.fetchBunDeps` turns a generated
# `bun.nix` into bun's global install cache, and `.hook` runs an offline `bun install` off it.
bun2nix,
}: }:
let let
gbm = if libgbm != null then libgbm else mesa; gbm = if libgbm != null then libgbm else mesa;
@@ -103,7 +105,10 @@ let
meta = { meta = {
homepage = "https://git.unom.io/unom/punktfunk"; homepage = "https://git.unom.io/unom/punktfunk";
license = with lib.licenses; [ mit asl20 ]; license = with lib.licenses; [
mit
asl20
];
platforms = [ "x86_64-linux" ]; # NVENC desktop host; matches the RPM's ExclusiveArch: x86_64 platforms = [ "x86_64-linux" ]; # NVENC desktop host; matches the RPM's ExclusiveArch: x86_64
maintainers = [ ]; maintainers = [ ];
}; };
@@ -115,7 +120,9 @@ let
# runtime (see crates/punktfunk-tray/Cargo.toml), so a tokio-flavoured zbus panics at startup: # runtime (see crates/punktfunk-tray/Cargo.toml), so a tokio-flavoured zbus panics at startup:
# "there is no reactor running, must be called from the context of a Tokio 1.x runtime". A separate # "there is no reactor running, must be called from the context of a Tokio 1.x runtime". A separate
# invocation keeps the tray's zbus on async-io. (The host package copies this binary into its $out.) # invocation keeps the tray's zbus on async-io. (The host package copies this binary into its $out.)
punktfunk-tray = craneLib.buildPackage (commonArgs // { punktfunk-tray = craneLib.buildPackage (
commonArgs
// {
pname = "punktfunk-tray"; pname = "punktfunk-tray";
cargoExtraArgs = "--locked -p punktfunk-tray"; cargoExtraArgs = "--locked -p punktfunk-tray";
PUNKTFUNK_BUILD_VERSION = buildVersion; PUNKTFUNK_BUILD_VERSION = buildVersion;
@@ -125,17 +132,19 @@ let
description = "punktfunk host status tray (Linux StatusNotifierItem)"; description = "punktfunk host status tray (Linux StatusNotifierItem)";
mainProgram = "punktfunk-tray"; mainProgram = "punktfunk-tray";
}; };
}); }
);
in in
{ {
inherit punktfunk-tray; inherit punktfunk-tray;
punktfunk-host = craneLib.buildPackage (commonArgs // { punktfunk-host = craneLib.buildPackage (
commonArgs
// {
pname = "punktfunk-host"; pname = "punktfunk-host";
# HOST ONLY — the tray is a separate derivation (see the note above; co-building crashes it). # HOST ONLY — the tray is a separate derivation (see the note above; co-building crashes it).
cargoExtraArgs = cargoExtraArgs =
"--locked -p punktfunk-host " "--locked -p punktfunk-host " + "--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode";
+ "--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode";
PUNKTFUNK_BUILD_VERSION = buildVersion; PUNKTFUNK_BUILD_VERSION = buildVersion;
@@ -201,9 +210,12 @@ in
description = "Low-latency desktop/game streaming host (Moonlight-compatible + native punktfunk/1)"; description = "Low-latency desktop/game streaming host (Moonlight-compatible + native punktfunk/1)";
mainProgram = "punktfunk-host"; mainProgram = "punktfunk-host";
}; };
}); }
);
punktfunk-client = craneLib.buildPackage (commonArgs // { punktfunk-client = craneLib.buildPackage (
commonArgs
// {
pname = "punktfunk-client"; pname = "punktfunk-client";
# The session's default `ui` feature pulls skia-safe, whose build DOWNLOADS a prebuilt Skia # The session's default `ui` feature pulls skia-safe, whose build DOWNLOADS a prebuilt Skia
# (rust-skia releases) — impossible in Nix's network-less build sandbox, and a from-source Skia # (rust-skia releases) — impossible in Nix's network-less build sandbox, and a from-source Skia
@@ -264,7 +276,8 @@ in
description = "Native Linux punktfunk/1 streaming client (GTK4 shell + Vulkan session)"; description = "Native Linux punktfunk/1 streaming client (GTK4 shell + Vulkan session)";
mainProgram = "punktfunk-client"; mainProgram = "punktfunk-client";
}; };
}); }
);
# --- management web console (punktfunk-web) ------------------------------------------------------ # --- management web console (punktfunk-web) ------------------------------------------------------
# The browser console every client needs for SPAKE2 PIN pairing + host status: a TanStack Start / # The browser console every client needs for SPAKE2 PIN pairing + host status: a TanStack Start /
@@ -276,56 +289,48 @@ in
# Unlike apt/dnf — which have no bun in their repos and so VENDOR a bun binary into the package — # Unlike apt/dnf — which have no bun in their repos and so VENDOR a bun binary into the package —
# Nix has `pkgs.bun`, so the launcher just execs it from the store (no vendored runtime). The # Nix has `pkgs.bun`, so the launcher just execs it from the store (no vendored runtime). The
# systemd `--user` units + firewall wiring live in the NixOS module, pointed at this store path. # systemd `--user` units + firewall wiring live in the NixOS module, pointed at this store path.
punktfunk-web = punktfunk-web = stdenvNoCC.mkDerivation {
let
# Offline node_modules for the console. `bun install` needs the network AND the @unom npm
# registry (web/.npmrc → https://git.unom.io/api/packages/unom/npm/, read-public: the same
# anonymous pull CI's rpm/deb builds do), so it lives in a fixed-output derivation — FODs get
# network, and `outputHash` pins the result. `--ignore-scripts` skips the install-time
# `prepare` codegen (it wants ../api/openapi.json, outside this web-only src scope); the build
# derivation below runs codegen itself where the whole tree is present.
#
# ⚠ When web/bun.lock changes, this hash must be refreshed: set `outputHash = lib.fakeHash`,
# rebuild, and copy the sha256 Nix reports back here (see packaging/nix/README.md).
webDeps = stdenvNoCC.mkDerivation {
pname = "punktfunk-web-deps";
inherit version;
src = src + "/web";
nativeBuildInputs = [ bun cacert ];
dontConfigure = true;
buildPhase = ''
runHook preBuild
export HOME=$TMPDIR
export BUN_INSTALL_CACHE_DIR=$TMPDIR/bun-cache
export SSL_CERT_FILE=${cacert}/etc/ssl/certs/ca-bundle.crt
export NODE_EXTRA_CA_CERTS=$SSL_CERT_FILE
# copyfile backend ⇒ node_modules is fully materialised (no links into the ephemeral
# cache), so the tree survives the copy into the content-addressed $out.
bun install --frozen-lockfile --ignore-scripts --no-progress --backend=copyfile
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
cp -R node_modules $out/node_modules
runHook postInstall
'';
dontFixup = true;
outputHashMode = "recursive";
outputHashAlgo = "sha256";
outputHash = "sha256-5oVZv65SMvq9i2REzHE8Pyn6qUZaV2FnPQdaouwcwoU="; # web/bun.lock deps (refresh on lockfile change; see README).
};
in
stdenvNoCC.mkDerivation {
pname = "punktfunk-web"; pname = "punktfunk-web";
inherit src version; inherit src version;
# nodejs: the JS build tools' `.bin` shims are `#!/usr/bin/env node`; patchShebangs (below) # nodejs: `bun run codegen` ends in a literal `node tools/check-i18n.mjs`, so `node` must be on
# repoints them at this node so they run in the sandbox. bun is still the RUNTIME (the launcher # PATH. (The JS CLIs' own `#!/usr/bin/env node` shebangs are already rewritten inside the
# execs it); node is build-time only, for orval/paraglide/vite. # bun2nix cache.) bun is the RUNTIME — the launcher execs it; node is build-time only.
nativeBuildInputs = [ bun nodejs makeWrapper ]; nativeBuildInputs = [
bun
nodejs
makeWrapper
bun2nix.hook
];
# No cross-derivation dep cache: codegen + the vite build are fully offline (every input is in # node_modules, materialised offline. `bun.nix` (generated from web/bun.lock by the `bun2nix`
# the vendored node_modules, the checked-in api/openapi.json, and web/project.inlang). # devDependency's postinstall hook, and committed) lists ONE `fetchurl` per package, keyed by
# the lockfile's own integrity hash — including the @unom scope, whose tarball URLs the
# lockfile records in full (web/.npmrc → https://git.unom.io/api/packages/unom/npm/, read-public,
# the same anonymous pull CI's rpm/deb builds do). That replaces the old single fixed-output
# `bun install` derivation whose aggregate `outputHash` had to be hand-bumped on every lockfile
# change — the failure mode this design removes.
bunDeps = bun2nix.fetchBunDeps { bunNix = src + "/web/bun.nix"; };
# `src` is the whole repo; the console (and its lockfile) live in web/.
bunRoot = "web";
# The install-time `prepare` codegen is run explicitly in buildPhase instead (below), matching
# the deb/rpm builders — and the dependency lifecycle scripts stay off, as they were under the
# old `--ignore-scripts` FOD (playwright's would try to download browsers).
dontRunLifecycleScripts = true;
# We drive the build/install ourselves; don't let the hook default them to `bun build`/`bun test`.
dontUseBunBuild = true;
dontUseBunCheck = true;
dontUseBunInstall = true;
# The hook's own patch phase would `patchShebangs .` over the ENTIRE repo checkout, rewriting
# shell scripts we ship verbatim (scripts/web-init.sh). node_modules needs no patching — bun2nix
# already patched each package's shebangs inside the cache.
dontUseBunPatch = true;
# …which also means setting HOME ourselves; bun writes there during install.
preConfigure = ''
export HOME=$TMPDIR
'';
# Everything past the deps fetch is offline: codegen + the vite build take every input from the
# installed node_modules, the checked-in api/openapi.json, and web/project.inlang.
# #
# ⚠ "Offline" is load-bearing and NOT self-enforcing. inlang resolves the plugins in # ⚠ "Offline" is load-bearing and NOT self-enforcing. inlang resolves the plugins in
# web/project.inlang/settings.json `modules`, and a failed import is only a WARNING there: # web/project.inlang/settings.json `modules`, and a failed import is only a WARNING there:
@@ -336,19 +341,12 @@ in
# fails the build on a remote module or a short message count. Keep both properties. # fails the build on a remote module or a short message count. Keep both properties.
buildPhase = '' buildPhase = ''
runHook preBuild runHook preBuild
export HOME=$TMPDIR # node_modules is already in place: the bun2nix hook ran an offline `bun install` in web/
cp -R ${webDeps}/node_modules web/node_modules # (bunRoot) off the store-fetched cache, before this phase.
chmod -R u+w web/node_modules
# The JS CLIs (orval, paraglide-js, vite, …) ship a `#!/usr/bin/env node` shebang, and the
# build sandbox has no /usr/bin/env — rewrite them to the store `node` before running any
# script (else `bun run codegen` dies with "bad interpreter: /usr/bin/env"). Patch the WHOLE
# node_modules, not just .bin: bun's .bin entries are symlinks (skipped by patchShebangs'
# `-type f`); the real shebang lives in each package's `dist/bin/*.js` that they point to.
patchShebangs web/node_modules
cd web cd web
# `codegen` = orval (a typed React-Query client from ../api/openapi.json) + paraglide-js i18n # `codegen` = orval (a typed React-Query client from ../api/openapi.json) + paraglide-js i18n
# compile; both write into src/ and are prerequisites of the build (normally the install-time # compile; both write into src/ and are prerequisites of the build (normally the install-time
# `prepare` hook, which was skipped in the deps FOD). # `prepare` hook, which `dontRunLifecycleScripts` skips above).
bun run codegen bun run codegen
# `build` = vite build ⇒ the Nitro `bun`-preset SSR bundle in .output (our Bun.serve TLS entry). # `build` = vite build ⇒ the Nitro `bun`-preset SSR bundle in .output (our Bun.serve TLS entry).
bun run build bun run build
@@ -392,51 +390,36 @@ in
# #
# Unlike the deb/rpm we don't `bun build` into a bundle + vendor bun; we still bundle (one # Unlike the deb/rpm we don't `bun build` into a bundle + vendor bun; we still bundle (one
# self-contained JS, effect inlined) but the launcher execs `pkgs.bun` from the store. # self-contained JS, effect inlined) but the launcher execs `pkgs.bun` from the store.
punktfunk-scripting = punktfunk-scripting = stdenvNoCC.mkDerivation {
let
# Offline node_modules for the SDK build — same fixed-output pattern as punktfunk-web's webDeps
# (`bun install` needs the network). ⚠ Refresh `outputHash` when sdk/bun.lock changes (set
# lib.fakeHash, rebuild, copy the printed sha256 — see packaging/nix/README.md).
sdkDeps = stdenvNoCC.mkDerivation {
pname = "punktfunk-scripting-deps";
inherit version;
src = src + "/sdk";
nativeBuildInputs = [ bun cacert ];
dontConfigure = true;
buildPhase = ''
runHook preBuild
export HOME=$TMPDIR
export BUN_INSTALL_CACHE_DIR=$TMPDIR/bun-cache
export SSL_CERT_FILE=${cacert}/etc/ssl/certs/ca-bundle.crt
export NODE_EXTRA_CA_CERTS=$SSL_CERT_FILE
bun install --frozen-lockfile --ignore-scripts --no-progress --backend=copyfile
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
cp -R node_modules $out/node_modules
runHook postInstall
'';
dontFixup = true;
outputHashMode = "recursive";
outputHashAlgo = "sha256";
outputHash = "sha256-+KCKCA0q0bwTxr7bsA3X4DbT/8nUjJA/JIoJU6BfiZw="; # sdk/bun.lock deps (refresh on lockfile change; see README).
};
in
stdenvNoCC.mkDerivation {
pname = "punktfunk-scripting"; pname = "punktfunk-scripting";
inherit src version; inherit src version;
nativeBuildInputs = [ bun makeWrapper ]; nativeBuildInputs = [
bun
makeWrapper
bun2nix.hook
];
# Same bun2nix wiring as punktfunk-web, against sdk/bun.lock's generated sdk/bun.nix (kept in
# step by the `bun2nix` devDependency's `prepare` script — `prepare`, not `postinstall`,
# because sdk/ is the PUBLISHED @punktfunk/host package and a postinstall would then run on
# every consumer's install). No aggregate deps hash to bump.
bunDeps = bun2nix.fetchBunDeps { bunNix = src + "/sdk/bun.nix"; };
bunRoot = "sdk";
dontRunLifecycleScripts = true; # matches the deb/rpm/windows SDK builds' `--ignore-scripts`
dontUseBunBuild = true;
dontUseBunCheck = true;
dontUseBunInstall = true;
dontUseBunPatch = true;
preConfigure = ''
export HOME=$TMPDIR
'';
# `bun build --target=bun` bundles the runner CLI to ONE self-contained JS: effect + the SDK # `bun build --target=bun` bundles the runner CLI to ONE self-contained JS: effect + the SDK
# are inlined, and the runner's dynamic `import()` of the operator's plugin files is left as a # are inlined, and the runner's dynamic `import()` of the operator's plugin files is left as a
# runtime import (bun keeps unresolvable dynamic specifiers external). Fully offline. # runtime import (bun keeps unresolvable dynamic specifiers external). Fully offline.
buildPhase = '' buildPhase = ''
runHook preBuild runHook preBuild
export HOME=$TMPDIR # node_modules is already in place (bun2nix hook, offline `bun install` in sdk/).
cp -R ${sdkDeps}/node_modules sdk/node_modules
chmod -R u+w sdk/node_modules
( cd sdk && bun build src/runner-cli.ts --target=bun --outfile=$TMPDIR/runner-cli.js ) ( cd sdk && bun build src/runner-cli.ts --target=bun --outfile=$TMPDIR/runner-cli.js )
grep -q 'attempt=' $TMPDIR/runner-cli.js \ grep -q 'attempt=' $TMPDIR/runner-cli.js \
|| { echo "ERROR: runner bundle missing the dynamic plugin import wrong build" >&2; exit 1; } || { echo "ERROR: runner bundle missing the dynamic plugin import wrong build" >&2; exit 1; }
+3 -3
View File
@@ -80,7 +80,7 @@ parse breakage that silently failed installs on non-English boxes.
- **Uninstall** (Add/Remove Programs): runs `service uninstall` (stop + delete service + remove - **Uninstall** (Add/Remove Programs): runs `service uninstall` (stop + delete service + remove
firewall rules), removes the `PunktfunkWeb` task + its firewall rule, then `driver uninstall` (+ firewall rules), removes the `PunktfunkWeb` task + its firewall rule, then `driver uninstall` (+
`--gamepad`) removes the punktfunk virtual-device drivers — the pf-vdisplay device node(s) and the `--gamepad`) removes the punktfunk virtual-device drivers — the pf-vdisplay device node(s) and the
pf-vdisplay / pf-dualsense / pf-xusb driver-store packages (the field report was that they survived pf-vdisplay / pf-gamepad / pf-xusb driver-store packages (the field report was that they survived
uninstall). **VB-CABLE is intentionally NOT removed** (a third-party shared component the user may uninstall). **VB-CABLE is intentionally NOT removed** (a third-party shared component the user may
use elsewhere — its own uninstaller is `VBCABLE_Setup_x64.exe -u -h`); the `%ProgramData%\punktfunk` use elsewhere — its own uninstaller is `VBCABLE_Setup_x64.exe -u -h`); the `%ProgramData%\punktfunk`
config (incl. `web-password`) is also left in place. config (incl. `web-password`) is also left in place.
@@ -123,7 +123,7 @@ fresh install uses the generated random console password — read it from
| `branding/` | Wizard branding: `gen-branding.ps1` renders the brand mark into the committed `wizard-image-*.bmp` / `wizard-small-*.bmp` (100200% DPI) + `punktfunk.ico`. Re-run only on a brand change. | | `branding/` | Wizard branding: `gen-branding.ps1` renders the brand mark into the committed `wizard-image-*.bmp` / `wizard-small-*.bmp` (100200% DPI) + `punktfunk.ico`. Re-run only on a brand change. |
| `pack-host-installer.ps1` | Orchestrator: cert + sign exe, **build + sign the drivers from source**, stage them + FFmpeg + VB-CABLE + the **web console** (`.output` + bun) + the HDR layer + branding, run ISCC, sign setup.exe. | | `pack-host-installer.ps1` | Orchestrator: cert + sign exe, **build + sign the drivers from source**, stage them + FFmpeg + VB-CABLE + the **web console** (`.output` + bun) + the HDR layer + branding, run ISCC, sign setup.exe. |
| `build-pf-vdisplay.ps1` | Build pf-vdisplay from source (the `drivers/` workspace) + clear FORCE_INTEGRITY + sign `.dll`/`.cat` + export `.cer`. | | `build-pf-vdisplay.ps1` | Build pf-vdisplay from source (the `drivers/` workspace) + clear FORCE_INTEGRITY + sign `.dll`/`.cat` + export `.cer`. |
| `build-gamepad-drivers.ps1` | Sign + catalog the gamepad drivers (`pf-dualsense` + `pf-xusb`) from the same workspace build (`-SkipBuild`), one shared cert. | | `build-gamepad-drivers.ps1` | Sign + catalog the gamepad drivers (`pf-gamepad` + `pf-xusb`) from the same workspace build (`-SkipBuild`), one shared cert. |
| `install-vbcable.ps1` | On-target: seed VB-Audio's cert into `TrustedPublisher`, silently install the bundled VB-CABLE (`-i -h`). Run by the installer's *Install VB-CABLE virtual audio* task; idempotent + always exits 0 (non-fatal). | | `install-vbcable.ps1` | On-target: seed VB-Audio's cert into `TrustedPublisher`, silently install the bundled VB-CABLE (`-i -h`). Run by the installer's *Install VB-CABLE virtual audio* task; idempotent + always exits 0 (non-fatal). |
| `clear-force-integrity.ps1` | Clear the `/INTEGRITYCHECK` PE bit so a self-signed driver loads (reused by every driver build). | | `clear-force-integrity.ps1` | Clear the `/INTEGRITYCHECK` PE bit so a self-signed driver loads (reused by every driver build). |
| `stage-pf-vdisplay.ps1` | Stage the just-built pf-vdisplay bundle + fetch/verify the **pinned** nefcon release. | | `stage-pf-vdisplay.ps1` | Stage the just-built pf-vdisplay bundle + fetch/verify the **pinned** nefcon release. |
@@ -133,7 +133,7 @@ fresh install uses the generated random console password — read it from
| `redeploy-pf-vdisplay.ps1` | **Dev:** one-shot redeploy — (optional) build → stop host → `deploy-dev.ps1 -Install` → reload adapter → start host. | | `redeploy-pf-vdisplay.ps1` | **Dev:** one-shot redeploy — (optional) build → stop host → `deploy-dev.ps1 -Install` → reload adapter → start host. |
| `pf-vkhdr-layer/` | **HDR Vulkan layer** (standalone `cdylib`): lets Vulkan games (Doom: The Dark Ages, etc.) enable HDR over the virtual display by advertising the HDR surface formats the NVIDIA/AMD ICDs hide on an indirect display. Built by the packer, laid into `{app}\vklayer`, registered under `HKLM64\…\Khronos\Vulkan\ImplicitLayers` (opt-out *Install the HDR Vulkan layer* task). Self-gated on the display's HDR state. See its README. | | `pf-vkhdr-layer/` | **HDR Vulkan layer** (standalone `cdylib`): lets Vulkan games (Doom: The Dark Ages, etc.) enable HDR over the virtual display by advertising the HDR surface formats the NVIDIA/AMD ICDs hide on an indirect display. Built by the packer, laid into `{app}\vklayer`, registered under `HKLM64\…\Khronos\Vulkan\ImplicitLayers` (opt-out *Install the HDR Vulkan layer* task). Self-gated on the display's HDR state. See its README. |
> **Drivers are built from source, not vendored.** All three (pf-vdisplay + the gamepad pf-dualsense / > **Drivers are built from source, not vendored.** All three (pf-vdisplay + the gamepad pf-gamepad /
> pf-xusb) are members of the all-Rust `drivers/` workspace (windows-drivers-rs / IddCx) and are > pf-xusb) are members of the all-Rust `drivers/` workspace (windows-drivers-rs / IddCx) and are
> **rebuilt + signed every release** by `build-pf-vdisplay.ps1` + `build-gamepad-drivers.ps1` - the > **rebuilt + signed every release** by `build-pf-vdisplay.ps1` + `build-gamepad-drivers.ps1` - the
> checked-in prebuilt binaries were deleted (a stale `.cat` once stopped covering its `.inf` > checked-in prebuilt binaries were deleted (a stale `.cat` once stopped covering its `.inf`
+3 -3
View File
@@ -1,6 +1,6 @@
<# <#
.SYNOPSIS .SYNOPSIS
Build + sign the punktfunk virtual-gamepad UMDF drivers (pf-dualsense = DualSense/DualShock 4, pf-xusb = Build + sign the punktfunk virtual-gamepad UMDF drivers (pf-gamepad = DualSense/DualShock 4/Edge/Deck, pf-xusb =
Xbox 360 / XInput) FROM SOURCE, in CI, and stage them for the host installer. The gamepad analogue of Xbox 360 / XInput) FROM SOURCE, in CI, and stage them for the host installer. The gamepad analogue of
build-pf-vdisplay.ps1 - replaces the checked-in prebuilt binaries (packaging/windows/gamepad-drivers/) build-pf-vdisplay.ps1 - replaces the checked-in prebuilt binaries (packaging/windows/gamepad-drivers/)
so the .dll/.inf/.cat stay in lockstep with the source and never go stale. so the .dll/.inf/.cat stay in lockstep with the source and never go stale.
@@ -13,7 +13,7 @@
supplied DRIVER_CERT secret) + ONE exported .cer - the layout `punktfunk-host.exe driver install supplied DRIVER_CERT secret) + ONE exported .cer - the layout `punktfunk-host.exe driver install
--gamepad` consumes (per-driver .inf/.cat/.dll + one shared punktfunk-driver.cer). --gamepad` consumes (per-driver .inf/.cat/.dll + one shared punktfunk-driver.cer).
Output (-Out): pf_dualsense.{dll,inf,cat} + pf_xusb.{dll,inf,cat} + pf_mouse.{dll,inf,cat} + Output (-Out): pf_gamepad.{dll,inf,cat} + pf_xusb.{dll,inf,cat} + pf_mouse.{dll,inf,cat} +
punktfunk-driver.cer. (pf_mouse is the resident virtual HID pointer, not a gamepad - it shares punktfunk-driver.cer. (pf_mouse is the resident virtual HID pointer, not a gamepad - it shares
this pipeline + the --gamepad install path.) this pipeline + the --gamepad install path.)
@@ -37,7 +37,7 @@ $DriversDir = (Resolve-Path $DriversDir).Path
$clear = Join-Path $PSScriptRoot 'clear-force-integrity.ps1' $clear = Join-Path $PSScriptRoot 'clear-force-integrity.ps1'
$drivers = @( $drivers = @(
@{ crate = 'pf-dualsense'; dll = 'pf_dualsense.dll'; inx = 'pf-dualsense\pf_dualsense.inx'; inf = 'pf_dualsense.inf'; cat = 'pf_dualsense.cat' } @{ crate = 'pf-gamepad'; dll = 'pf_gamepad.dll'; inx = 'pf-gamepad\pf_gamepad.inx'; inf = 'pf_gamepad.inf'; cat = 'pf_gamepad.cat' }
@{ crate = 'pf-xusb'; dll = 'pf_xusb.dll'; inx = 'pf-xusb\pf_xusb.inx'; inf = 'pf_xusb.inf'; cat = 'pf_xusb.cat' } @{ crate = 'pf-xusb'; dll = 'pf_xusb.dll'; inx = 'pf-xusb\pf_xusb.inx'; inf = 'pf_xusb.inf'; cat = 'pf_xusb.cat' }
# Not a gamepad, but it rides the identical UMDF HID pipeline + the same install path # Not a gamepad, but it rides the identical UMDF HID pipeline + the same install path
# (`driver install --gamepad` adds every staged .inf): the resident virtual HID mouse that # (`driver install --gamepad` adds every staged .inf): the resident virtual HID mouse that
+1 -1
View File
@@ -7,7 +7,7 @@
# crates/pf-driver-proto from the main tree. # crates/pf-driver-proto from the main tree.
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["wdk-probe", "wdk-iddcx", "pf-umdf-util", "pf-vdisplay", "pf-dualsense", "pf-xusb", "pf-mouse"] members = ["wdk-probe", "wdk-iddcx", "pf-umdf-util", "pf-vdisplay", "pf-gamepad", "pf-xusb", "pf-mouse"]
[workspace.package] [workspace.package]
edition = "2024" edition = "2024"
@@ -1,8 +1,10 @@
# pf-dualsense - punktfunk virtual DualSense (DS5) / DualShock 4 (DS4) UMDF2 HID minidriver. # pf-gamepad - punktfunk virtual gamepads (DualSense / DualShock 4 / DualSense Edge / Steam Deck),
# ONE UMDF2 HID minidriver serving all four identities. Named for the role, not for one of them —
# it was `pf-dualsense`, which read as if the other three were somewhere else.
# A member of the in-tree drivers workspace (shares the vendored wdk-sys/wdk-build with the bindgen pin # A member of the in-tree drivers workspace (shares the vendored wdk-sys/wdk-build with the bindgen pin
# + the crt-static .cargo/config), built from source per release like pf-vdisplay. # + the crt-static .cargo/config), built from source per release like pf-vdisplay.
[package] [package]
name = "pf-dualsense" name = "pf-gamepad"
edition.workspace = true edition.workspace = true
version.workspace = true version.workspace = true
license.workspace = true license.workspace = true
@@ -1,4 +1,11 @@
# pf-dualsense — virtual DualSense UMDF2 HID minidriver # pf-gamepad — the virtual-gamepad UMDF2 HID minidriver
> Renamed from **pf-dualsense** (2026-07-28). One driver has always served four identities —
> DualSense, DualShock 4, DualSense Edge and Steam Deck — so the old name read as if the other three
> lived somewhere else. Only the PACKAGE identity moved (crate, INF, CAT, DLL, UMDF service); the
> four **hardware ids** (`pf_dualsense`, `pf_dualshock4`, `pf_dualsenseedge`, `pf_steamdeck`) are
> deliberately unchanged — they bind every devnode the host creates and every installed system.
> `driver install --gamepad` retires the pre-rename store package so the two can't both claim them.
A self-authored **Rust UMDF2 HID minidriver** that presents a virtual Sony **DualSense** A self-authored **Rust UMDF2 HID minidriver** that presents a virtual Sony **DualSense**
(VID `054C` / PID `0CE6`) to Windows, so games drive adaptive triggers / lightbar / rumble — (VID `054C` / PID `0CE6`) to Windows, so games drive adaptive triggers / lightbar / rumble —
@@ -42,24 +49,24 @@ plus the sign steps below, staged for the installer. The original manual dev-box
lore (paths reflect that era's cargo-make layout): lore (paths reflect that era's cargo-make layout):
```powershell ```powershell
cargo make # -> target\debug\pf_dualsense_package\ (.inf/.cat/.dll) cargo make # -> target\debug\pf_gamepad_package\ (.inf/.cat/.dll)
# *** CRITICAL: clear the PE FORCE_INTEGRITY bit *** # *** CRITICAL: clear the PE FORCE_INTEGRITY bit ***
# windows-drivers-rs links the DLL with /INTEGRITYCHECK, which forces a CI-trusted page-hash # windows-drivers-rs links the DLL with /INTEGRITYCHECK, which forces a CI-trusted page-hash
# signature a self-signed cert cannot satisfy (CodeIntegrity 3004 "hash not found" / # signature a self-signed cert cannot satisfy (CodeIntegrity 3004 "hash not found" /
# 3089 VerificationError 7). SudoVDA.dll (third-party VDD prior art, not used by punktfunk) has # 3089 VerificationError 7). SudoVDA.dll (third-party VDD prior art, not used by punktfunk) has
# this bit OFF. Clear bit 0x80 at PE-header offset +0x5e: # this bit OFF. Clear bit 0x80 at PE-header offset +0x5e:
$f = 'target\debug\pf_dualsense_package\pf_dualsense.dll' $f = 'target\debug\pf_gamepad_package\pf_gamepad.dll'
$b = [IO.File]::ReadAllBytes($f); $pe = [BitConverter]::ToInt32($b,0x3c); $off = $pe + 0x5e $b = [IO.File]::ReadAllBytes($f); $pe = [BitConverter]::ToInt32($b,0x3c); $off = $pe + 0x5e
$dc = [BitConverter]::ToUInt16($b,$off); $bb = [BitConverter]::GetBytes([uint16]($dc -band 0xFF7F)) $dc = [BitConverter]::ToUInt16($b,$off); $bb = [BitConverter]::GetBytes([uint16]($dc -band 0xFF7F))
$b[$off]=$bb[0]; $b[$off+1]=$bb[1]; [IO.File]::WriteAllBytes($f,$b) $b[$off]=$bb[0]; $b[$off+1]=$bb[1]; [IO.File]::WriteAllBytes($f,$b)
signtool sign /fd SHA256 /sha1 <cert-thumbprint> $f signtool sign /fd SHA256 /sha1 <cert-thumbprint> $f
Remove-Item target\debug\pf_dualsense_package\pf_dualsense.cat Remove-Item target\debug\pf_gamepad_package\pf_gamepad.cat
Inf2Cat /driver:target\debug\pf_dualsense_package /os:10_x64 Inf2Cat /driver:target\debug\pf_gamepad_package /os:10_x64
signtool sign /fd SHA256 /sha1 <cert-thumbprint> target\debug\pf_dualsense_package\pf_dualsense.cat signtool sign /fd SHA256 /sha1 <cert-thumbprint> target\debug\pf_gamepad_package\pf_gamepad.cat
pnputil /add-driver target\debug\pf_dualsense_package\pf_dualsense.inf /install pnputil /add-driver target\debug\pf_gamepad_package\pf_gamepad.inf /install
devgen /add /hardwareid "root\pf_dualsense" # creates the (transient, SWD) device node devgen /add /hardwareid "root\pf_dualsense" # creates the (transient, SWD) device node
``` ```
@@ -1,6 +1,12 @@
;/*++ ;/*++
; punktfunk virtual PlayStation/Valve pads — UMDF2 HID minidriver INF (M0 spike). ; punktfunk virtual gamepads — UMDF2 HID minidriver INF.
; One package, four hardware ids: DualSense, DualShock 4, DualSense Edge, Steam Deck. ; One package, four hardware ids: DualSense, DualShock 4, DualSense Edge, Steam Deck — which is why
; the package is called pf_gamepad and not pf_dualsense (it never was one identity).
;
; ⚠️ The HARDWARE IDS below deliberately keep their old names (`pf_dualsense`, `pf_dualshock4`,
; `pf_dualsenseedge`, `pf_steamdeck`). They are the binding contract with every devnode the host
; SwDeviceCreate's and with every already-installed system; renaming them would orphan existing
; installs and buy nothing. Only the PACKAGE identity (INF/CAT/DLL/service) moved to pf_gamepad.
; Adapted from the WDK vhidmini2 UMDF2 sample (VhidminiUm.inx). ; Adapted from the WDK vhidmini2 UMDF2 sample (VhidminiUm.inx).
; Depends on MsHidUmdf.inf (build >= 22000). ; Depends on MsHidUmdf.inf (build >= 22000).
; Install: devgen /add /hardwareid "root\pf_dualsense" (after pnputil /add-driver /install) ; Install: devgen /add /hardwareid "root\pf_dualsense" (after pnputil /add-driver /install)
@@ -10,7 +16,7 @@ Signature="$WINDOWS NT$"
Class=HIDClass Class=HIDClass
ClassGuid={745a17a0-74d3-11d0-b6fe-00a0c90f57da} ClassGuid={745a17a0-74d3-11d0-b6fe-00a0c90f57da}
Provider=%ProviderString% Provider=%ProviderString%
CatalogFile=pf_dualsense.cat CatalogFile=pf_gamepad.cat
PnpLockdown=1 PnpLockdown=1
[DestinationDirs] [DestinationDirs]
@@ -20,7 +26,7 @@ DefaultDestDir = 13
1=%Disk_Description%,,, 1=%Disk_Description%,,,
[SourceDisksFiles] [SourceDisksFiles]
pf_dualsense.dll=1 pf_gamepad.dll=1
[Manufacturer] [Manufacturer]
%ManufacturerString%=pf, NT$ARCH$.10.0...22000 %ManufacturerString%=pf, NT$ARCH$.10.0...22000
@@ -30,44 +36,44 @@ pf_dualsense.dll=1
; for the host's SwDeviceCreate'd DualSense (the `root\` prefix is reserved for root enumeration, so ; for the host's SwDeviceCreate'd DualSense (the `root\` prefix is reserved for root enumeration, so
; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` / `pf_dualsenseedge` / `pf_steamdeck` ; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` / `pf_dualsenseedge` / `pf_steamdeck`
; for the host's other virtual pads — ONE driver binds all of them (every model line below installs ; for the host's other virtual pads — ONE driver binds all of them (every model line below installs
; the same `pfDualSense` section) and serves the matching HID identity per the device_type byte the ; the same `pfGamepad` section) and serves the matching HID identity per the device_type byte the
; host stamps into shared memory. ; host stamps into shared memory.
; ;
; Each id carries its OWN description: Device Manager reads this string, and a single shared ; Each id carries its OWN description: Device Manager reads this string, and a single shared
; "Virtual DualSense" made an emulated DualShock 4 look like the controller-type setting had been ; "Virtual DualSense" made an emulated DualShock 4 look like the controller-type setting had been
; ignored. The HID layer (VID/PID, report descriptor, product string) was always per-type; this ; ignored. The HID layer (VID/PID, report descriptor, product string) was always per-type; this
; makes the human-readable name agree with it. ; makes the human-readable name agree with it.
%DeviceDesc%=pfDualSense, root\pf_dualsense, pf_dualsense %DeviceDesc%=pfGamepad, root\pf_dualsense, pf_dualsense
%DeviceDescDS4%=pfDualSense, pf_dualshock4 %DeviceDescDS4%=pfGamepad, pf_dualshock4
%DeviceDescEdge%=pfDualSense, pf_dualsenseedge %DeviceDescEdge%=pfGamepad, pf_dualsenseedge
%DeviceDescDeck%=pfDualSense, pf_steamdeck %DeviceDescDeck%=pfGamepad, pf_steamdeck
[pfDualSense.NT] [pfGamepad.NT]
CopyFiles=UMDriverCopy CopyFiles=UMDriverCopy
Include=MsHidUmdf.inf Include=MsHidUmdf.inf
Needs=MsHidUmdf.NT Needs=MsHidUmdf.NT
Include=WUDFRD.inf Include=WUDFRD.inf
Needs=WUDFRD_LowerFilter.NT Needs=WUDFRD_LowerFilter.NT
[pfDualSense.NT.hw] [pfGamepad.NT.hw]
Include=MsHidUmdf.inf Include=MsHidUmdf.inf
Needs=MsHidUmdf.NT.hw Needs=MsHidUmdf.NT.hw
Include=WUDFRD.inf Include=WUDFRD.inf
Needs=WUDFRD_LowerFilter.NT.hw Needs=WUDFRD_LowerFilter.NT.hw
[pfDualSense.NT.Services] [pfGamepad.NT.Services]
Include=MsHidUmdf.inf Include=MsHidUmdf.inf
Needs=MsHidUmdf.NT.Services Needs=MsHidUmdf.NT.Services
Include=WUDFRD.inf Include=WUDFRD.inf
Needs=WUDFRD_LowerFilter.NT.Services Needs=WUDFRD_LowerFilter.NT.Services
[pfDualSense.NT.Filters] [pfGamepad.NT.Filters]
Include=WUDFRD.inf Include=WUDFRD.inf
Needs=WUDFRD_LowerFilter.NT.Filters Needs=WUDFRD_LowerFilter.NT.Filters
[pfDualSense.NT.Wdf] [pfGamepad.NT.Wdf]
UmdfService="pf_dualsense", pf_dualsense_Install UmdfService="pf_gamepad", pf_gamepad_Install
UmdfServiceOrder=pf_dualsense UmdfServiceOrder=pf_gamepad
UmdfKernelModeClientPolicy=AllowKernelModeClients UmdfKernelModeClientPolicy=AllowKernelModeClients
UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects
UmdfMethodNeitherAction=Copy UmdfMethodNeitherAction=Copy
@@ -76,18 +82,18 @@ UmdfFsContextUsePolicy=CanUseFsContext2
; across multiple simultaneous controllers (multi-pad). ; across multiple simultaneous controllers (multi-pad).
UmdfHostProcessSharing=ProcessSharingDisabled UmdfHostProcessSharing=ProcessSharingDisabled
[pf_dualsense_Install] [pf_gamepad_Install]
UmdfLibraryVersion=$UMDFVERSION$ UmdfLibraryVersion=$UMDFVERSION$
ServiceBinary="%13%\pf_dualsense.dll" ServiceBinary="%13%\pf_gamepad.dll"
[UMDriverCopy] [UMDriverCopy]
pf_dualsense.dll pf_gamepad.dll
[Strings] [Strings]
ProviderString ="punktfunk" ProviderString ="punktfunk"
ManufacturerString ="punktfunk" ManufacturerString ="punktfunk"
ClassName ="HID device" ClassName ="HID device"
Disk_Description ="punktfunk DualSense Installation Disk" Disk_Description ="punktfunk Gamepad Installation Disk"
; One per hardware id — these are what Device Manager shows. Keep them aligned with the product ; One per hardware id — these are what Device Manager shows. Keep them aligned with the product
; strings the driver serves per device_type (src/lib.rs `on_get_string`). ; strings the driver serves per device_type (src/lib.rs `on_get_string`).
DeviceDesc ="punktfunk Virtual DualSense" DeviceDesc ="punktfunk Virtual DualSense"
@@ -358,7 +358,7 @@ static DEVTYPE_WAITED: AtomicBool = AtomicBool::new(false);
/// This pad's channel config (magic/size/pad_index offset + our logger). /// This pad's channel config (magic/size/pad_index offset + our logger).
fn channel_cfg() -> ChannelConfig { fn channel_cfg() -> ChannelConfig {
ChannelConfig { ChannelConfig {
tag: "pf-ds", tag: "pf-gamepad",
boot_name_prefix: "Global\\pfds-boot-", boot_name_prefix: "Global\\pfds-boot-",
data_magic: SHM_MAGIC, data_magic: SHM_MAGIC,
data_size: SHM_SIZE, data_size: SHM_SIZE,
@@ -385,14 +385,14 @@ fn pad_index() -> u8 {
} }
/// Whether the world-writable bring-up file log is enabled (resolved once). OPT-IN — debug builds, /// Whether the world-writable bring-up file log is enabled (resolved once). OPT-IN — debug builds,
/// or the `PFDS_DEBUG_LOG` (system-wide) env var — the same treatment pf-vdisplay got in audit /// or the `PFGAMEPAD_DEBUG_LOG` (system-wide) env var — the same treatment pf-vdisplay got in audit
/// §4.4: a RELEASE driver never writes the Public file (info-leak/DoS surface), and the per-report /// §4.4: a RELEASE driver never writes the Public file (info-leak/DoS surface), and the per-report
/// OUTPUT hex dumps stop being a sustained disk-write path during gameplay. DebugView can't see the /// OUTPUT hex dumps stop being a sustained disk-write path during gameplay. DebugView can't see the
/// UMDF host across session 0, so the file stays the bring-up diagnostic when enabled. /// UMDF host across session 0, so the file stays the bring-up diagnostic when enabled.
fn file_log_enabled() -> bool { fn file_log_enabled() -> bool {
use std::sync::OnceLock; use std::sync::OnceLock;
static ON: OnceLock<bool> = OnceLock::new(); static ON: OnceLock<bool> = OnceLock::new();
*ON.get_or_init(|| cfg!(debug_assertions) || std::env::var_os("PFDS_DEBUG_LOG").is_some()) *ON.get_or_init(|| cfg!(debug_assertions) || std::env::var_os("PFGAMEPAD_DEBUG_LOG").is_some())
} }
/// Process-lifetime append handle to the bring-up log, opened ONCE and shared via a `Mutex` /// Process-lifetime append handle to the bring-up log, opened ONCE and shared via a `Mutex`
@@ -411,7 +411,7 @@ fn file_appender() -> Option<&'static std::sync::Mutex<std::fs::File>> {
std::fs::OpenOptions::new() std::fs::OpenOptions::new()
.create(true) .create(true)
.append(true) .append(true)
.open(std::env::temp_dir().join("pfds-driver.log")) .open(std::env::temp_dir().join("pf_gamepad-driver.log"))
.ok() .ok()
.map(std::sync::Mutex::new) .map(std::sync::Mutex::new)
}) })
@@ -437,7 +437,7 @@ pub unsafe extern "system" fn driver_entry(
driver: PDRIVER_OBJECT, driver: PDRIVER_OBJECT,
registry_path: PCUNICODE_STRING, registry_path: PCUNICODE_STRING,
) -> NTSTATUS { ) -> NTSTATUS {
log("[pf-ds] DriverEntry"); log("[pf-gamepad] DriverEntry");
// SAFETY: zeroed WDF_DRIVER_CONFIG is a valid all-null config; we then set Size + the callback. // SAFETY: zeroed WDF_DRIVER_CONFIG is a valid all-null config; we then set Size + the callback.
let mut config: WDF_DRIVER_CONFIG = unsafe { core::mem::zeroed() }; let mut config: WDF_DRIVER_CONFIG = unsafe { core::mem::zeroed() };
config.Size = core::mem::size_of::<WDF_DRIVER_CONFIG>() as ULONG; config.Size = core::mem::size_of::<WDF_DRIVER_CONFIG>() as ULONG;
@@ -457,7 +457,7 @@ pub unsafe extern "system" fn driver_entry(
} }
extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INIT) -> NTSTATUS { extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INIT) -> NTSTATUS {
log("[pf-ds] EvtDeviceAdd"); log("[pf-gamepad] EvtDeviceAdd");
// Mark as a filter (HID minidriver sits below mshidumdf.sys). // Mark as a filter (HID minidriver sits below mshidumdf.sys).
// SAFETY: device_init is provided by the framework and non-null. // SAFETY: device_init is provided by the framework and non-null.
@@ -474,14 +474,14 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
) )
}; };
if !nt_success(st) { if !nt_success(st) {
dbglog!("[pf-ds] WdfDeviceCreate failed 0x{:08x}", st as u32); dbglog!("[pf-gamepad] WdfDeviceCreate failed 0x{:08x}", st as u32);
return st; return st;
} }
// SAFETY: `device` is the live device just created — the exact contract this fn requires. // SAFETY: `device` is the live device just created — the exact contract this fn requires.
let shm_idx = unsafe { wdf::query_location_index(device) }; let shm_idx = unsafe { wdf::query_location_index(device) };
CHANNEL.set_index(shm_idx); CHANNEL.set_index(shm_idx);
dbglog!("[pf-ds] shm index = {shm_idx}"); dbglog!("[pf-gamepad] shm index = {shm_idx}");
// Default parallel queue handling all IOCTLs. // Default parallel queue handling all IOCTLs.
// SAFETY: zeroed config then fields set; Size matches the struct. // SAFETY: zeroed config then fields set; Size matches the struct.
@@ -507,7 +507,7 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
}; };
if !nt_success(st) { if !nt_success(st) {
dbglog!( dbglog!(
"[pf-ds] default WdfIoQueueCreate failed 0x{:08x}", "[pf-gamepad] default WdfIoQueueCreate failed 0x{:08x}",
st as u32 st as u32
); );
return st; return st;
@@ -531,7 +531,10 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
) )
}; };
if !nt_success(st) { if !nt_success(st) {
dbglog!("[pf-ds] manual WdfIoQueueCreate failed 0x{:08x}", st as u32); dbglog!(
"[pf-gamepad] manual WdfIoQueueCreate failed 0x{:08x}",
st as u32
);
return st; return st;
} }
MANUAL_QUEUE.store(manual_queue, Ordering::SeqCst); MANUAL_QUEUE.store(manual_queue, Ordering::SeqCst);
@@ -558,13 +561,13 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
call_unsafe_wdf_function_binding!(WdfTimerCreate, &mut tcfg, &mut tattr, &mut timer) call_unsafe_wdf_function_binding!(WdfTimerCreate, &mut tcfg, &mut tattr, &mut timer)
}; };
if !nt_success(st) { if !nt_success(st) {
dbglog!("[pf-ds] WdfTimerCreate failed 0x{:08x}", st as u32); dbglog!("[pf-gamepad] WdfTimerCreate failed 0x{:08x}", st as u32);
return st; return st;
} }
// SAFETY: timer valid; -80000 == 8ms relative due time (100ns units, negative = relative). // SAFETY: timer valid; -80000 == 8ms relative due time (100ns units, negative = relative).
let _started = unsafe { call_unsafe_wdf_function_binding!(WdfTimerStart, timer, -80000i64) }; let _started = unsafe { call_unsafe_wdf_function_binding!(WdfTimerStart, timer, -80000i64) };
log("[pf-ds] device ready (DualSense 054C:0CE6)"); log("[pf-gamepad] device ready (DualSense 054C:0CE6)");
STATUS_SUCCESS STATUS_SUCCESS
} }
@@ -582,7 +585,7 @@ extern "C" fn evt_io_device_control(
// Skip the 8ms READ_REPORT cadence so the log stays readable during a game test; // Skip the 8ms READ_REPORT cadence so the log stays readable during a game test;
// the 0x02 OUTPUT report (the gate) and the descriptor handshake still log. // the 0x02 OUTPUT report (the gate) and the descriptor handshake still log.
if ioctl != IOCTL_HID_READ_REPORT { if ioctl != IOCTL_HID_READ_REPORT {
dbglog!("[pf-ds] ioctl 0x{ioctl:08x} out={_output_len} in={_input_len}"); dbglog!("[pf-gamepad] ioctl 0x{ioctl:08x} out={_output_len} in={_input_len}");
} }
// READ_REPORT forwards to the manual queue (the timer completes it) — this CONSUMES the request // READ_REPORT forwards to the manual queue (the timer completes it) — this CONSUMES the request
@@ -618,10 +621,16 @@ extern "C" fn evt_io_device_control(
IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(&request), IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(&request),
IOCTL_UMDF_HID_GET_INPUT_REPORT => request.copy_to_output(&neutral_report(device_type())), IOCTL_UMDF_HID_GET_INPUT_REPORT => request.copy_to_output(&neutral_report(device_type())),
IOCTL_HID_GET_STRING => on_get_string(&request), IOCTL_HID_GET_STRING => on_get_string(&request),
// The channel proof (see `pf_umdf_util::hid`): the host asks THIS devnode which process
// serves it, and duplicates the DATA section into the answer — so it never has to trust the
// LocalService-writable bootstrap mailbox to name its target.
_ => STATUS_NOT_IMPLEMENTED, _ => STATUS_NOT_IMPLEMENTED,
}; };
dbglog!("[pf-ds] ioctl 0x{ioctl:08x} -> 0x{:08x}", status as u32); dbglog!(
"[pf-gamepad] ioctl 0x{ioctl:08x} -> 0x{:08x}",
status as u32
);
request.complete(status); request.complete(status);
} }
@@ -644,7 +653,7 @@ fn on_output_report(request: &Request, ioctl: ULONG) -> NTSTATUS {
} else { } else {
"SET_OUTPUT_REPORT" "SET_OUTPUT_REPORT"
}; };
dbglog!("[pf-ds] *** OUTPUT {kind} reportId={report_id} len={inlen} data: {hex}"); dbglog!("[pf-gamepad] *** OUTPUT {kind} reportId={report_id} len={inlen} data: {hex}");
// Publish the game's 0x02 output report to the sealed DATA section for the host (rumble / // Publish the game's 0x02 output report to the sealed DATA section for the host (rumble /
// lightbar / player-LEDs / adaptive triggers): legacy slot + seq, plus the v2.1 ring. // lightbar / player-LEDs / adaptive triggers): legacy slot + seq, plus the v2.1 ring.
@@ -696,7 +705,7 @@ fn on_set_feature(request: &Request) -> NTSTATUS {
publish_output(view, &out); publish_output(view, &out);
} }
} }
dbglog!("[pf-ds] SET_FEATURE (acked, latched for GET)"); dbglog!("[pf-gamepad] SET_FEATURE (acked, latched for GET)");
STATUS_SUCCESS STATUS_SUCCESS
} }
@@ -718,6 +727,16 @@ fn deck_feature_reply() -> [u8; 64] {
let unit_serial = format!("FVPF{unit_id:08X}"); let unit_serial = format!("FVPF{unit_id:08X}");
let unit_serial = unit_serial.as_bytes(); let unit_serial = unit_serial.as_bytes();
let mut r = [0u8; 64]; let mut r = [0u8; 64];
// The CHANNEL PROOF, Deck flavour: the Deck's ONE feature report is unnumbered and Steam drives
// it as command→response, so the proof rides that same contract instead of a new report id (no
// descriptor change). Two command bytes, so a Steam command we haven't catalogued cannot collide.
if last.starts_with(&pf_driver_proto::gamepad::DECK_PROOF_CMD) {
let proof =
pf_driver_proto::gamepad::ChannelProof::new(CHANNEL.index(), std::process::id());
r[..2].copy_from_slice(&pf_driver_proto::gamepad::DECK_PROOF_CMD);
r[2..18].copy_from_slice(&proof.to_bytes());
return r;
}
match last[0] { match last[0] {
0x83 => { 0x83 => {
// GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9x (attr-id, value u32-LE)]. // GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9x (attr-id, value u32-LE)].
@@ -779,6 +798,24 @@ fn on_get_feature(request: &Request) -> NTSTATUS {
let Some(&report_id) = bytes.first() else { let Some(&report_id) = bytes.first() else {
return STATUS_INVALID_PARAMETER; return STATUS_INVALID_PARAMETER;
}; };
// The CHANNEL PROOF (security-review 2026-07-28): tell the host which process serves this
// devnode, so it never has to trust the LocalService-writable bootstrap mailbox to name its
// duplication target. `0x85` is already declared as a Feature report in all three captured
// descriptors and was previously answered with STATUS_INVALID_PARAMETER, so this costs NO
// report-descriptor change — the identity Steam and SDL fingerprint is untouched. Derived only
// from our own devnode Location + pid: nothing a caller supplies feeds into it.
if report_id == pf_driver_proto::gamepad::HID_FEATURE_REPORT_CHANNEL_PROOF {
let len = request.output_buffer_len();
return match pf_driver_proto::gamepad::ChannelProof::new(
CHANNEL.index(),
std::process::id(),
)
.to_feature_report(report_id, len)
{
Some(rep) => request.copy_to_output(&rep),
None => STATUS_INVALID_PARAMETER, // caller's buffer can't hold id + proof
};
}
// DualSense + Edge use feature ids 0x05/0x09/0x20 (same blobs — SDL forces enhanced-rumble // DualSense + Edge use feature ids 0x05/0x09/0x20 (same blobs — SDL forces enhanced-rumble
// for the Edge PID regardless of the firmware version at 0x20[44..46]); DualShock 4 uses // for the Edge PID regardless of the firmware version at 0x20[44..46]); DualShock 4 uses
// 0x02/0x12/0xa3. // 0x02/0x12/0xa3.
@@ -801,7 +838,7 @@ fn on_get_feature(request: &Request) -> NTSTATUS {
(1, 0x12) => &ds4_pairing, (1, 0x12) => &ds4_pairing,
(1, 0xA3) => &DS4_FEATURE_FIRMWARE, (1, 0xA3) => &DS4_FEATURE_FIRMWARE,
(_, other) => { (_, other) => {
dbglog!("[pf-ds] GET_FEATURE unknown report id 0x{other:02x}"); dbglog!("[pf-gamepad] GET_FEATURE unknown report id 0x{other:02x}");
return STATUS_INVALID_PARAMETER; return STATUS_INVALID_PARAMETER;
} }
}; };
@@ -825,7 +862,7 @@ fn on_get_string(request: &Request) -> NTSTATUS {
}; };
let string_id = id_val & 0xFFFF; let string_id = id_val & 0xFFFF;
let devtype = device_type(); let devtype = device_type();
dbglog!("[pf-ds] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) devtype={devtype}"); dbglog!("[pf-gamepad] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) devtype={devtype}");
let s: String = match string_id { let s: String = match string_id {
0 | 0x000e => match devtype { 0 | 0x000e => match devtype {
1 => "Sony Computer Entertainment".into(), 1 => "Sony Computer Entertainment".into(),
@@ -882,7 +919,7 @@ fn device_type() -> u8 {
std::thread::sleep(std::time::Duration::from_millis(10)); std::thread::sleep(std::time::Duration::from_millis(10));
} }
dbglog!( dbglog!(
"[pf-ds] device_type: sealed channel not attached within 1s — defaulting to the last observed identity" "[pf-gamepad] device_type: sealed channel not attached within 1s — defaulting to the last observed identity"
); );
} }
LAST_DEVTYPE.load(Ordering::Relaxed) as u8 LAST_DEVTYPE.load(Ordering::Relaxed) as u8
@@ -1,6 +1,6 @@
;/*++ ;/*++
; punktfunk virtual HID mouse (absolute pointer) — UMDF2 HID minidriver INF. ; punktfunk virtual HID mouse (absolute pointer) — UMDF2 HID minidriver INF.
; Same skeleton as pf_dualsense.inx (the WDK vhidmini2 UMDF2 shape). ; Same skeleton as pf_gamepad.inx (the WDK vhidmini2 UMDF2 shape).
; Depends on MsHidUmdf.inf (build >= 22000). ; Depends on MsHidUmdf.inf (build >= 22000).
; Install: devgen /add /hardwareid "root\pf_mouse" (after pnputil /add-driver /install) ; Install: devgen /add /hardwareid "root\pf_mouse" (after pnputil /add-driver /install)
;--*/ ;--*/
+26 -12
View File
@@ -9,7 +9,7 @@
// the report path below is exercised by `punktfunk-host vmouse-spike` (validation) and is the // the report path below is exercised by `punktfunk-host vmouse-spike` (validation) and is the
// future higher-fidelity injection route. // future higher-fidelity injection route.
// //
// Structure is pf-dualsense minus the identity zoo: one fixed HID identity (PF:MO, an obviously // Structure is pf-gamepad minus the identity zoo: one fixed HID identity (PF:MO, an obviously
// virtual VID/PID no software matches on), one 8-byte input report (5 buttons + absolute 15-bit // virtual VID/PID no software matches on), one 8-byte input report (5 buttons + absolute 15-bit
// X/Y + wheel + AC-pan), no feature/output reports. The host channel is the **sealed pad channel** // X/Y + wheel + AC-pan), no feature/output reports. The host channel is the **sealed pad channel**
// (design/gamepad-channel-sealing.md) verbatim — mailbox `Global\pfmouse-boot-<i>`, unnamed // (design/gamepad-channel-sealing.md) verbatim — mailbox `Global\pfmouse-boot-<i>`, unnamed
@@ -28,6 +28,7 @@
use core::sync::atomic::{AtomicPtr, AtomicU32, Ordering}; use core::sync::atomic::{AtomicPtr, AtomicU32, Ordering};
use pf_driver_proto::gamepad::ChannelProof;
use pf_driver_proto::mouse::{ use pf_driver_proto::mouse::{
MOUSE_PID, MOUSE_REPORT_ID, MOUSE_REPORT_LEN, MOUSE_VER, MOUSE_VID, MouseShm, MOUSE_PID, MOUSE_REPORT_ID, MOUSE_REPORT_LEN, MOUSE_VER, MOUSE_VID, MouseShm,
}; };
@@ -173,10 +174,10 @@ fn channel_cfg() -> ChannelConfig {
} }
} }
/// Whether the world-writable bring-up file log is enabled (resolved once). OPT-IN — debug builds, /// Whether the bring-up file log is enabled (resolved once). OPT-IN — debug builds, or the
/// or the `PFMOUSE_DEBUG_LOG` (system-wide) env var — the same policy as the pad drivers (audit /// `PFMOUSE_DEBUG_LOG` (system-wide) env var — the same policy as the pad drivers (audit §4.4):
/// §4.4): a RELEASE driver never writes the Public file. DebugView can't see the UMDF host across /// a RELEASE driver never writes it. DebugView can't see the UMDF host across session 0, so the
/// session 0, so the file stays the bring-up diagnostic when enabled. /// file stays the bring-up diagnostic when enabled.
fn file_log_enabled() -> bool { fn file_log_enabled() -> bool {
use std::sync::OnceLock; use std::sync::OnceLock;
static ON: OnceLock<bool> = OnceLock::new(); static ON: OnceLock<bool> = OnceLock::new();
@@ -193,10 +194,15 @@ fn file_appender() -> Option<&'static std::sync::Mutex<std::fs::File>> {
if !file_log_enabled() { if !file_log_enabled() {
return None; return None;
} }
// Write to the WUDFHost's own (LocalService) temp dir — NOT world-writable/readable
// `C:\Users\Public`, where any local reader gets the diagnostics and a non-admin can
// pre-create the path as a hard link to redirect this LocalService appender's writes
// (security-review 2026-07-17; pf-xusb/pf-gamepad/pf-vdisplay were moved then, this
// driver was missed — security-review 2026-07-28). Opt-in/debug only.
std::fs::OpenOptions::new() std::fs::OpenOptions::new()
.create(true) .create(true)
.append(true) .append(true)
.open("C:\\Users\\Public\\pfmouse-driver.log") .open(std::env::temp_dir().join("pfmouse-driver.log"))
.ok() .ok()
.map(std::sync::Mutex::new) .map(std::sync::Mutex::new)
}) })
@@ -325,7 +331,7 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
MANUAL_QUEUE.store(manual_queue, Ordering::SeqCst); MANUAL_QUEUE.store(manual_queue, Ordering::SeqCst);
// Periodic timer (parent = manual queue): sealed-channel pump + health marks + event-driven // Periodic timer (parent = manual queue): sealed-channel pump + health marks + event-driven
// READ_REPORT completion. 8 ms — the proven pf-dualsense cadence; the mouse is presence-first // READ_REPORT completion. 8 ms — the proven pf-gamepad cadence; the mouse is presence-first
// (SendInput injects), so a 125 Hz ceiling on the validation/report path is fine. // (SendInput injects), so a 125 Hz ceiling on the validation/report path is fine.
// SAFETY: zeroed config then fields set. // SAFETY: zeroed config then fields set.
let mut tcfg: WDF_TIMER_CONFIG = unsafe { core::mem::zeroed() }; let mut tcfg: WDF_TIMER_CONFIG = unsafe { core::mem::zeroed() };
@@ -397,6 +403,9 @@ extern "C" fn evt_io_device_control(
// No output reports are declared; ack a stray write instead of failing the sender. // No output reports are declared; ack a stray write instead of failing the sender.
IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => STATUS_SUCCESS, IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => STATUS_SUCCESS,
IOCTL_HID_GET_STRING => on_get_string(&request), IOCTL_HID_GET_STRING => on_get_string(&request),
// The channel proof (see `pf_umdf_util::hid`): the host asks THIS devnode which process
// serves it, and duplicates the DATA section into the answer — so it never has to trust the
// LocalService-writable bootstrap mailbox to name its target.
_ => STATUS_NOT_IMPLEMENTED, _ => STATUS_NOT_IMPLEMENTED,
}; };
@@ -406,7 +415,7 @@ extern "C" fn evt_io_device_control(
// IOCTL_HID_GET_STRING: the input is a ULONG whose low word is the string id and whose high word // IOCTL_HID_GET_STRING: the input is a ULONG whose low word is the string id and whose high word
// is the language id. Windows polls ids 0x0E/0x0F/0x10 (manufacturer/product/serial) as well as // is the language id. Windows polls ids 0x0E/0x0F/0x10 (manufacturer/product/serial) as well as
// the 0/1/2 HID_STRING_ID_* constants — serve both (the pf-dualsense finding). // the 0/1/2 HID_STRING_ID_* constants — serve both (the pf-gamepad finding).
fn on_get_string(request: &Request) -> NTSTATUS { fn on_get_string(request: &Request) -> NTSTATUS {
let (bytes, _) = match request.input_bytes(4) { let (bytes, _) = match request.input_bytes(4) {
Ok(v) => v, Ok(v) => v,
@@ -418,10 +427,15 @@ fn on_get_string(request: &Request) -> NTSTATUS {
0 0
}; };
let string_id = id_val & 0xFFFF; let string_id = id_val & 0xFFFF;
let s: &str = match string_id { let s: String = match string_id {
0 | 0x000E => "punktfunk", 0 | 0x000E => "punktfunk".into(),
2 | 0x0010 => "PFMOUSE00", // (2) The SERIAL carries the channel proof — the one transport measured to reach a UMDF HID
_ => "punktfunk Virtual Mouse", // minidriver from user mode (`HidD_GetSerialNumberString`, zero-access handle, verified on
// .173). Safe HERE and only here: nothing reads the virtual mouse's serial, whereas the pads'
// serials are what SDL and Steam dedup controllers on. The old value was the inert
// "PFMOUSE00"; the proof text is just as inert and does the security work.
2 | 0x0010 => ChannelProof::new(CHANNEL.index(), std::process::id()).to_hid_string(),
_ => "punktfunk Virtual Mouse".into(),
}; };
let mut wide: Vec<u8> = Vec::with_capacity(s.len() * 2 + 2); let mut wide: Vec<u8> = Vec::with_capacity(s.len() * 2 + 2);
for u in s.encode_utf16() { for u in s.encode_utf16() {
@@ -1,7 +1,7 @@
//! The sealed pad channel, driver side (`design/gamepad-channel-sealing.md`, gamepad proto v2): //! The sealed pad channel, driver side (`design/gamepad-channel-sealing.md`, gamepad proto v2):
//! poll the named bootstrap mailbox by index, publish our pid (iff the host's proto version //! poll the named bootstrap mailbox by index, publish our pid (iff the host's proto version
//! matches), adopt the host-delivered DATA-section handle, and validate the mapped section's magic //! matches), adopt the host-delivered DATA-section handle, and validate the mapped section's magic
//! and `pad_index` before use. One implementation shared by `pf-xusb` and `pf-dualsense` (they used //! and `pad_index` before use. One implementation shared by `pf-xusb` and `pf-gamepad` (they used
//! to hand-duplicate it), parameterized by [`ChannelConfig`]. //! to hand-duplicate it), parameterized by [`ChannelConfig`].
//! //!
//! This module **forbids `unsafe`**: the entire state machine is safe Rust over //! This module **forbids `unsafe`**: the entire state machine is safe Rust over
@@ -0,0 +1,32 @@
//! What the HID minidrivers could and could not use to answer the **channel proof**
//! (`pf_driver_proto::gamepad::ChannelProof`) — the measured record, so nobody re-derives it.
//!
//! The proof tells the host which process to duplicate a pad's DATA section into. It cannot come
//! from the bootstrap mailbox: that must be openable by LocalService (the driver's own WUDFHost runs
//! as LocalService), so any LocalService principal could name a process of its own and be handed the
//! pad's live input surface (security-review 2026-07-28). Only the driver actually bound to the
//! devnode the host created can answer that devnode's I/O — hence "ask the device stack".
//!
//! ## Measured on .173 (Win11 26200), 2026-07-28 — three attempts, one survivor
//!
//! * ❌ **`IOCTL_HID_GET_INDEXED_STRING`** (`HidD_GetIndexedString`). Fails for EVERY index — 1, 2,
//! 4, 0x0E, 0x5046 — on the same zero-access handle where the NAMED string calls succeed and
//! return the driver's own text. hidclass does not carry an arbitrary indexed-string request to a
//! UMDF HID minidriver at all.
//! * ❌ **A private device interface** (`WdfDeviceCreateDeviceInterface` + a private IOCTL, the shape
//! `pf_xusb` uses). The interface REGISTERS and enumerates fine —
//! `\\?\SWD#punktfunk#pf_mouse_0#{32244793-…}` was present — but `CreateFile` on it is refused:
//! `ERROR_GEN_FAILURE` (31) for access 0 and GENERIC_READ, `ERROR_FILE_NOT_FOUND` (2) for
//! GENERIC_READ|WRITE. hidclass owns `IRP_MJ_CREATE` on a devnode it is the FDO for, so a second
//! interface on that devnode cannot be opened. This is why `pf_xusb` is fine and these two are not:
//! it is NOT a HID minidriver, so nothing sits above it.
//! * ✅ **The serial-number string.** `HidD_GetSerialNumberString` succeeds on a zero-access handle
//! and returns driver-authored text. Verified end to end: `pf_mouse` answered `PFCP:3:0:7296`, and
//! pid 7296 was a genuine service-spawned `WUDFHost.exe`.
//!
//! So `pf_mouse` serves its proof AS its serial (its `PFMOUSE00` was inert). The pads deliberately do
//! NOT: their serials are what SDL and Steam dedup controllers on, and Steam is already known to
//! mangle a pad's displayed name over serial FORMAT alone. `pf_gamepad` therefore has no working
//! device-stack transport on this Windows build — see `ProofTransport::MailboxUnproven` on the host
//! side for the residual that leaves, and option B (a vendor feature report, which costs a
//! report-descriptor change) if it ever needs closing.
@@ -1,5 +1,5 @@
//! The audited unsafe-primitive layer under the punktfunk UMDF gamepad drivers (`pf-xusb`, //! The audited unsafe-primitive layer under the punktfunk UMDF gamepad drivers (`pf-xusb`,
//! `pf-dualsense`). //! `pf-gamepad`).
//! //!
//! A UMDF driver cannot be literally free of `unsafe` — WDF dispatch, Win32 section mapping and //! A UMDF driver cannot be literally free of `unsafe` — WDF dispatch, Win32 section mapping and
//! cross-process shared memory are FFI by nature. What Rust *can* buy is confining every raw //! cross-process shared memory are FFI by nature. What Rust *can* buy is confining every raw
@@ -15,6 +15,9 @@
//! * [`wdf`] — [`wdf::Request`] + queue/device-property helpers: each framework callback converts //! * [`wdf`] — [`wdf::Request`] + queue/device-property helpers: each framework callback converts
//! its raw `WDFREQUEST` into a token exactly once (`unsafe`, with the framework's validity as the //! its raw `WDFREQUEST` into a token exactly once (`unsafe`, with the framework's validity as the
//! contract); everything after that is safe. //! contract); everything after that is safe.
//! * [`hid`] — the HID minidrivers' **channel proof** answer (also `#[forbid(unsafe_code)]`): how
//! `pf_gamepad`/`pf_mouse` tell the host, over the device stack, which process is serving this
//! devnode. That is what the host trusts instead of the LocalService-writable bootstrap mailbox.
//! //!
//! Lint gates (mirrored in every driver crate, enforced by the drivers CI clippy step): //! Lint gates (mirrored in every driver crate, enforced by the drivers CI clippy step):
//! `unsafe_op_in_unsafe_fn` + `clippy::undocumented_unsafe_blocks` — every remaining `unsafe {}` //! `unsafe_op_in_unsafe_fn` + `clippy::undocumented_unsafe_blocks` — every remaining `unsafe {}`
@@ -24,6 +27,7 @@
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
pub mod channel; pub mod channel;
pub mod hid;
pub mod section; pub mod section;
pub mod wdf; pub mod wdf;
@@ -58,6 +58,14 @@ const IOCTL_XUSB_GET_XINPUT_MANAGEMENT_DRIVER: u32 = 0x8000_6380;
const IOCTL_XUSB_WAIT_FOR_INPUT: u32 = 0x8000_E3AC; const IOCTL_XUSB_WAIT_FOR_INPUT: u32 = 0x8000_E3AC;
const IOCTL_XUSB_GET_INFORMATION_EX: u32 = 0x8000_E3FC; const IOCTL_XUSB_GET_INFORMATION_EX: u32 = 0x8000_E3FC;
/// Our own private IOCTL (NOT part of xusb22): answer the host's **channel proof** — which process
/// is serving this devnode — so the host learns its duplication target from the device stack instead
/// of from the LocalService-writable bootstrap mailbox (see `pf_driver_proto::gamepad::ChannelProof`
/// for why that distinction is the whole security property). Any local caller may ask; the answer is
/// a pid and two version numbers, none of them secret, and it is only worth anything to a process
/// that can already duplicate handles into us.
const IOCTL_PF_GET_CHANNEL_PROOF: u32 = pf_driver_proto::gamepad::IOCTL_PF_GET_CHANNEL_PROOF;
// Xbox 360 wired identity (what GET_INFORMATION reports). 0x0103 unblocks SET_STATE (vibration). // Xbox 360 wired identity (what GET_INFORMATION reports). 0x0103 unblocks SET_STATE (vibration).
const XUSB_VID: u16 = 0x045E; const XUSB_VID: u16 = 0x045E;
const XUSB_PID: u16 = 0x028E; const XUSB_PID: u16 = 0x028E;
@@ -389,6 +397,14 @@ extern "C" fn evt_io_device_control(
} }
} }
IOCTL_XUSB_GET_STATE => request.copy_to_output(&build_get_state(data)), IOCTL_XUSB_GET_STATE => request.copy_to_output(&build_get_state(data)),
// The channel proof — deliberately answered from THIS process's own identity, never from
// anything a caller supplied, so the only way to make this devnode name a process is to BE
// the driver bound to it.
IOCTL_PF_GET_CHANNEL_PROOF => {
let proof =
pf_driver_proto::gamepad::ChannelProof::new(CHANNEL.index(), std::process::id());
request.copy_to_output(&proof.to_bytes())
}
IOCTL_XUSB_GET_LED_STATE => request.copy_to_output(&[0x00, 0x00, 0x06]), IOCTL_XUSB_GET_LED_STATE => request.copy_to_output(&[0x00, 0x00, 0x06]),
IOCTL_XUSB_GET_BATTERY_INFORMATION => request.copy_to_output(&[0x00, 0x01, 0x03, 0x00]), IOCTL_XUSB_GET_BATTERY_INFORMATION => request.copy_to_output(&[0x00, 0x01, 0x03, 0x00]),
IOCTL_XUSB_SET_STATE => on_set_state(&request, data), IOCTL_XUSB_SET_STATE => on_set_state(&request, data),
+1 -1
View File
@@ -183,7 +183,7 @@ if (-not $NoDriver) {
else { Write-Host "-NoDriver: building installer WITHOUT the bundled pf-vdisplay driver" } else { Write-Host "-NoDriver: building installer WITHOUT the bundled pf-vdisplay driver" }
# --- build (from source) + stage the punktfunk virtual-gamepad UMDF drivers -------------------- # --- build (from source) + stage the punktfunk virtual-gamepad UMDF drivers --------------------
# pf-dualsense (DualSense / DualShock 4) + pf-xusb (Xbox 360 / XInput) are members of the same drivers # pf-gamepad (DualSense / DS4 / Edge / Deck) + pf-xusb (Xbox 360 / XInput) are members of the same drivers
# workspace as pf-vdisplay, built from source per release (build-gamepad-drivers.ps1) - same anti-stale # workspace as pf-vdisplay, built from source per release (build-gamepad-drivers.ps1) - same anti-stale
# reasoning as pf-vdisplay; the prior checked-in binaries under gamepad-drivers/ are retired. The # reasoning as pf-vdisplay; the prior checked-in binaries under gamepad-drivers/ are retired. The
# installer adds each to the store via `punktfunk-host.exe driver install --gamepad` (the host # installer adds each to the store via `punktfunk-host.exe driver install --gamepad` (the host
+2 -2
View File
@@ -29,9 +29,9 @@ fi
# 2. UMDF driver workspace — mirrors windows-drivers.yml's gate (the shipped drivers + the # 2. UMDF driver workspace — mirrors windows-drivers.yml's gate (the shipped drivers + the
# audited safe layer; pf-vdisplay is deliberately NOT gated there, so not here either). # audited safe layer; pf-vdisplay is deliberately NOT gated there, so not here either).
if ! (cd "$root/packaging/windows/drivers" \ if ! (cd "$root/packaging/windows/drivers" \
&& cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check >/dev/null 2>&1); then && cargo fmt -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse --check >/dev/null 2>&1); then
fail "driver workspace is not rustfmt-clean (windows-drivers.yml would fail)" \ fail "driver workspace is not rustfmt-clean (windows-drivers.yml would fail)" \
"(cd packaging/windows/drivers && cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense)" "(cd packaging/windows/drivers && cargo fmt -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse)"
fi fi
exit 0 exit 0
+4
View File
@@ -21,6 +21,10 @@ PUNKTFUNK_VIDEO_SOURCE=virtual
# CPU automatically. No need to set it. Set to 0 only to force the CPU path. # CPU automatically. No need to set it. Set to 0 only to force the CPU path.
# PUNKTFUNK_ZEROCOPY=0 # PUNKTFUNK_ZEROCOPY=0
# The name this host shows up under in Moonlight and the Punktfunk clients. Defaults to the
# machine's hostname; set it to give the box a friendly name without renaming the machine.
#PUNKTFUNK_HOST_NAME=Living Room
# --- Session anchors (rarely needed) ------------------------------------------------------- # --- Session anchors (rarely needed) -------------------------------------------------------
# As a `systemctl --user` service the host inherits the correct XDG_RUNTIME_DIR from systemd and # As a `systemctl --user` service the host inherits the correct XDG_RUNTIME_DIR from systemd and
# derives the bus (`unix:path=$XDG_RUNTIME_DIR/bus`) itself. Set these ONLY when running the host # derives the bus (`unix:path=$XDG_RUNTIME_DIR/bus`) itself. Set these ONLY when running the host
+4
View File
@@ -30,6 +30,10 @@ PUNKTFUNK_VDISPLAY=pf
# Capture the secure desktop (UAC / lock / login) so the stream survives those transitions. # Capture the secure desktop (UAC / lock / login) so the stream survives those transitions.
PUNKTFUNK_SECURE_DDA=1 PUNKTFUNK_SECURE_DDA=1
# The name this host shows up under in Moonlight and the Punktfunk clients. Defaults to the
# machine's computer name; set it to give the box a friendly name without renaming Windows.
#PUNKTFUNK_HOST_NAME=Living Room
# Log level (info | debug | trace). Logs land in %ProgramData%\punktfunk\logs\. # Log level (info | debug | trace). Logs land in %ProgramData%\punktfunk\logs\.
RUST_LOG=info RUST_LOG=info
+7
View File
@@ -8,6 +8,7 @@
"@effect/openapi-generator": "4.0.0-beta.98", "@effect/openapi-generator": "4.0.0-beta.98",
"@effect/platform-node": "4.0.0-beta.98", "@effect/platform-node": "4.0.0-beta.98",
"@types/bun": "^1.3.0", "@types/bun": "^1.3.0",
"bun2nix": "2.1.2",
"effect": "^4.0.0-beta.98", "effect": "^4.0.0-beta.98",
"typescript": "^5.9.3", "typescript": "^5.9.3",
}, },
@@ -56,6 +57,8 @@
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"bun2nix": ["bun2nix@2.1.2", "", { "dependencies": { "sade": "^1.8.1" }, "bin": { "bun2nix": "index.ts" } }, "sha512-0wx6Ar5ccrz4aSD5prbShwymjDEXFh7Bucxs+YrpAMa67TnVB95Hv8FV3oaQEbtOx6QGgIAyOmap6Y3WCRqetg=="],
"call-me-maybe": ["call-me-maybe@1.0.2", "", {}, "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="], "call-me-maybe": ["call-me-maybe@1.0.2", "", {}, "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
@@ -100,6 +103,8 @@
"mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="], "msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="],
@@ -136,6 +141,8 @@
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"should": ["should@13.2.3", "", { "dependencies": { "should-equal": "^2.0.0", "should-format": "^3.0.3", "should-type": "^1.4.0", "should-type-adaptors": "^1.0.1", "should-util": "^1.0.0" } }, "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ=="], "should": ["should@13.2.3", "", { "dependencies": { "should-equal": "^2.0.0", "should-format": "^3.0.3", "should-type": "^1.4.0", "should-type-adaptors": "^1.0.1", "should-util": "^1.0.0" } }, "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ=="],
"should-equal": ["should-equal@2.0.0", "", { "dependencies": { "should-type": "^1.4.0" } }, "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA=="], "should-equal": ["should-equal@2.0.0", "", { "dependencies": { "should-type": "^1.4.0" } }, "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA=="],
+364
View File
@@ -0,0 +1,364 @@
# Autogenerated by `bun2nix`, editing manually is not recommended
#
# Set of Bun packages to install
#
# Consume this with `fetchBunDeps` (recommended)
# or `pkgs.callPackage` if you wish to handle
# it manually.
{
copyPathToStore,
fetchFromGitHub,
fetchgit,
fetchurl,
...
}:
{
"@effect/openapi-generator@4.0.0-beta.98" = fetchurl {
url = "https://registry.npmjs.org/@effect/openapi-generator/-/openapi-generator-4.0.0-beta.98.tgz";
hash = "sha512-7bqawr/HqJWqQ8H/bHyzBlLPA3LIIm3Y+cGYlIxnC/QVK795QpiEXb7uxTnP7V7w49V0sBtTerv4/9ZjsMffLQ==";
};
"@effect/platform-node-shared@4.0.0-beta.98" = fetchurl {
url = "https://registry.npmjs.org/@effect/platform-node-shared/-/platform-node-shared-4.0.0-beta.98.tgz";
hash = "sha512-iySXaffnCJX1sNAIp79ghhIeui9E5qwUQyqd1VLPkB9UNO4vdpd9B5fTEXwe7S/GusL4jsk9vSvX38XJgRFG1w==";
};
"@effect/platform-node@4.0.0-beta.98" = fetchurl {
url = "https://registry.npmjs.org/@effect/platform-node/-/platform-node-4.0.0-beta.98.tgz";
hash = "sha512-IQu1TiLXQEDSGkDBllyYjVadf+UqdjptryqX4mmktVTTbGDq7X4uVxe7cSgXuqZvyfG6kagTzwj2lfynxOaKQg==";
};
"@exodus/schemasafe@1.3.0" = fetchurl {
url = "https://registry.npmjs.org/@exodus/schemasafe/-/schemasafe-1.3.0.tgz";
hash = "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==";
};
"@ioredis/commands@1.10.0" = fetchurl {
url = "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz";
hash = "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==";
};
"@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4" = fetchurl {
url = "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz";
hash = "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==";
};
"@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4" = fetchurl {
url = "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz";
hash = "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==";
};
"@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4" = fetchurl {
url = "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz";
hash = "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==";
};
"@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4" = fetchurl {
url = "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz";
hash = "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==";
};
"@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4" = fetchurl {
url = "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz";
hash = "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==";
};
"@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4" = fetchurl {
url = "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz";
hash = "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==";
};
"@standard-schema/spec@1.1.0" = fetchurl {
url = "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz";
hash = "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==";
};
"@types/bun@1.3.14" = fetchurl {
url = "https://registry.npmjs.org/@types/bun/-/bun-1.3.14.tgz";
hash = "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==";
};
"@types/node@26.1.1" = fetchurl {
url = "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz";
hash = "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==";
};
"@types/ws@8.18.1" = fetchurl {
url = "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz";
hash = "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==";
};
"ansi-regex@5.0.1" = fetchurl {
url = "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz";
hash = "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==";
};
"ansi-styles@4.3.0" = fetchurl {
url = "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz";
hash = "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==";
};
"bun-types@1.3.14" = fetchurl {
url = "https://registry.npmjs.org/bun-types/-/bun-types-1.3.14.tgz";
hash = "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==";
};
"bun2nix@2.1.2" = fetchurl {
url = "https://registry.npmjs.org/bun2nix/-/bun2nix-2.1.2.tgz";
hash = "sha512-0wx6Ar5ccrz4aSD5prbShwymjDEXFh7Bucxs+YrpAMa67TnVB95Hv8FV3oaQEbtOx6QGgIAyOmap6Y3WCRqetg==";
};
"call-me-maybe@1.0.2" = fetchurl {
url = "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz";
hash = "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==";
};
"cliui@8.0.1" = fetchurl {
url = "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz";
hash = "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==";
};
"cluster-key-slot@1.1.1" = fetchurl {
url = "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz";
hash = "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==";
};
"color-convert@2.0.1" = fetchurl {
url = "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz";
hash = "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==";
};
"color-name@1.1.4" = fetchurl {
url = "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz";
hash = "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==";
};
"debug@4.4.3" = fetchurl {
url = "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz";
hash = "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==";
};
"denque@2.1.0" = fetchurl {
url = "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz";
hash = "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==";
};
"detect-libc@2.1.2" = fetchurl {
url = "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz";
hash = "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==";
};
"effect@4.0.0-beta.98" = fetchurl {
url = "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.98.tgz";
hash = "sha512-oz+bsG5h+6RNrw4t5GMfQrk/xBS8ROoqkYsuvRhBr5O7mCOrpvH/hbw+QrDzvKIpX4HJClwm86F94c87W0sJxg==";
};
"emoji-regex@8.0.0" = fetchurl {
url = "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz";
hash = "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==";
};
"es6-promise@3.3.1" = fetchurl {
url = "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz";
hash = "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==";
};
"escalade@3.2.0" = fetchurl {
url = "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz";
hash = "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==";
};
"fast-check@4.9.0" = fetchurl {
url = "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz";
hash = "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==";
};
"fast-safe-stringify@2.1.1" = fetchurl {
url = "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz";
hash = "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==";
};
"find-my-way-ts@0.1.6" = fetchurl {
url = "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz";
hash = "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==";
};
"get-caller-file@2.0.5" = fetchurl {
url = "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz";
hash = "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==";
};
"http2-client@1.3.5" = fetchurl {
url = "https://registry.npmjs.org/http2-client/-/http2-client-1.3.5.tgz";
hash = "sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA==";
};
"ini@7.0.0" = fetchurl {
url = "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz";
hash = "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==";
};
"ioredis@5.11.1" = fetchurl {
url = "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz";
hash = "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==";
};
"is-fullwidth-code-point@3.0.0" = fetchurl {
url = "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz";
hash = "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==";
};
"kubernetes-types@1.30.0" = fetchurl {
url = "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz";
hash = "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==";
};
"mime@4.1.0" = fetchurl {
url = "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz";
hash = "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==";
};
"mri@1.2.0" = fetchurl {
url = "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz";
hash = "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==";
};
"ms@2.1.3" = fetchurl {
url = "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz";
hash = "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==";
};
"msgpackr-extract@3.0.4" = fetchurl {
url = "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz";
hash = "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==";
};
"msgpackr@2.0.4" = fetchurl {
url = "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.4.tgz";
hash = "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==";
};
"multipasta@0.2.8" = fetchurl {
url = "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz";
hash = "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==";
};
"node-fetch-h2@2.3.0" = fetchurl {
url = "https://registry.npmjs.org/node-fetch-h2/-/node-fetch-h2-2.3.0.tgz";
hash = "sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==";
};
"node-fetch@2.7.0" = fetchurl {
url = "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz";
hash = "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==";
};
"node-gyp-build-optional-packages@5.2.2" = fetchurl {
url = "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz";
hash = "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==";
};
"node-readfiles@0.2.0" = fetchurl {
url = "https://registry.npmjs.org/node-readfiles/-/node-readfiles-0.2.0.tgz";
hash = "sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==";
};
"oas-kit-common@1.0.8" = fetchurl {
url = "https://registry.npmjs.org/oas-kit-common/-/oas-kit-common-1.0.8.tgz";
hash = "sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ==";
};
"oas-linter@3.2.2" = fetchurl {
url = "https://registry.npmjs.org/oas-linter/-/oas-linter-3.2.2.tgz";
hash = "sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ==";
};
"oas-resolver@2.5.6" = fetchurl {
url = "https://registry.npmjs.org/oas-resolver/-/oas-resolver-2.5.6.tgz";
hash = "sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ==";
};
"oas-schema-walker@1.1.5" = fetchurl {
url = "https://registry.npmjs.org/oas-schema-walker/-/oas-schema-walker-1.1.5.tgz";
hash = "sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ==";
};
"oas-validator@5.0.8" = fetchurl {
url = "https://registry.npmjs.org/oas-validator/-/oas-validator-5.0.8.tgz";
hash = "sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw==";
};
"pure-rand@8.4.2" = fetchurl {
url = "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz";
hash = "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==";
};
"redis-errors@1.2.0" = fetchurl {
url = "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz";
hash = "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==";
};
"redis-parser@3.0.0" = fetchurl {
url = "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz";
hash = "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==";
};
"reftools@1.1.9" = fetchurl {
url = "https://registry.npmjs.org/reftools/-/reftools-1.1.9.tgz";
hash = "sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==";
};
"require-directory@2.1.1" = fetchurl {
url = "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz";
hash = "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==";
};
"sade@1.8.1" = fetchurl {
url = "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz";
hash = "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==";
};
"should-equal@2.0.0" = fetchurl {
url = "https://registry.npmjs.org/should-equal/-/should-equal-2.0.0.tgz";
hash = "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==";
};
"should-format@3.0.3" = fetchurl {
url = "https://registry.npmjs.org/should-format/-/should-format-3.0.3.tgz";
hash = "sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q==";
};
"should-type-adaptors@1.1.0" = fetchurl {
url = "https://registry.npmjs.org/should-type-adaptors/-/should-type-adaptors-1.1.0.tgz";
hash = "sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA==";
};
"should-type@1.4.0" = fetchurl {
url = "https://registry.npmjs.org/should-type/-/should-type-1.4.0.tgz";
hash = "sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ==";
};
"should-util@1.0.1" = fetchurl {
url = "https://registry.npmjs.org/should-util/-/should-util-1.0.1.tgz";
hash = "sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g==";
};
"should@13.2.3" = fetchurl {
url = "https://registry.npmjs.org/should/-/should-13.2.3.tgz";
hash = "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ==";
};
"standard-as-callback@2.1.0" = fetchurl {
url = "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz";
hash = "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==";
};
"string-width@4.2.3" = fetchurl {
url = "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz";
hash = "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==";
};
"strip-ansi@6.0.1" = fetchurl {
url = "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz";
hash = "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==";
};
"swagger2openapi@7.0.8" = fetchurl {
url = "https://registry.npmjs.org/swagger2openapi/-/swagger2openapi-7.0.8.tgz";
hash = "sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==";
};
"toml@4.3.0" = fetchurl {
url = "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz";
hash = "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==";
};
"tr46@0.0.3" = fetchurl {
url = "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz";
hash = "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==";
};
"typescript@5.9.3" = fetchurl {
url = "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz";
hash = "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==";
};
"undici-types@8.3.0" = fetchurl {
url = "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz";
hash = "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==";
};
"undici@7.28.0" = fetchurl {
url = "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz";
hash = "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==";
};
"undici@8.7.0" = fetchurl {
url = "https://registry.npmjs.org/undici/-/undici-8.7.0.tgz";
hash = "sha512-N7iQtfyLhIMOFgQubvmLV26svHpO0bqKnAiWotTQCVKCmWrcGbBotPuW1x+xwYZ2VHdSTVUfPQQnlEt1/LouTQ==";
};
"uuid@14.0.1" = fetchurl {
url = "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz";
hash = "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==";
};
"webidl-conversions@3.0.1" = fetchurl {
url = "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz";
hash = "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==";
};
"whatwg-url@5.0.0" = fetchurl {
url = "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz";
hash = "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==";
};
"wrap-ansi@7.0.0" = fetchurl {
url = "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz";
hash = "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==";
};
"ws@8.21.1" = fetchurl {
url = "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz";
hash = "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==";
};
"y18n@5.0.8" = fetchurl {
url = "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz";
hash = "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==";
};
"yaml@1.10.3" = fetchurl {
url = "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz";
hash = "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==";
};
"yaml@2.9.0" = fetchurl {
url = "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz";
hash = "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==";
};
"yargs-parser@21.1.1" = fetchurl {
url = "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz";
hash = "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==";
};
"yargs@17.7.3" = fetchurl {
url = "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz";
hash = "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==";
};
}
+2
View File
@@ -34,6 +34,7 @@
"registry": "https://git.unom.io/api/packages/unom/npm/" "registry": "https://git.unom.io/api/packages/unom/npm/"
}, },
"scripts": { "scripts": {
"prepare": "bun2nix -o bun.nix",
"gen": "openapigen --spec ../api/openapi.json --name Punktfunk --format httpclient > src/gen/punktfunk.ts", "gen": "openapigen --spec ../api/openapi.json --name Punktfunk --format httpclient > src/gen/punktfunk.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.build.json", "build": "tsc -p tsconfig.build.json",
@@ -49,6 +50,7 @@
"@effect/openapi-generator": "4.0.0-beta.98", "@effect/openapi-generator": "4.0.0-beta.98",
"@effect/platform-node": "4.0.0-beta.98", "@effect/platform-node": "4.0.0-beta.98",
"@types/bun": "^1.3.0", "@types/bun": "^1.3.0",
"bun2nix": "2.1.2",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
"optionalDependencies": { "optionalDependencies": {
+7
View File
@@ -33,6 +33,7 @@
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
"bun2nix": "2.1.2",
"orval": "^8.22.0", "orval": "^8.22.0",
"playwright": "^1.61.1", "playwright": "^1.61.1",
"storybook": "^10.5.3", "storybook": "^10.5.3",
@@ -1133,6 +1134,8 @@
"buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="],
"bun2nix": ["bun2nix@2.1.2", "", { "dependencies": { "sade": "^1.8.1" }, "bin": { "bun2nix": "index.ts" } }, "sha512-0wx6Ar5ccrz4aSD5prbShwymjDEXFh7Bucxs+YrpAMa67TnVB95Hv8FV3oaQEbtOx6QGgIAyOmap6Y3WCRqetg=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
"busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
@@ -1767,6 +1770,8 @@
"motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
@@ -1995,6 +2000,8 @@
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
+4894
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -46,6 +46,7 @@
"compositor_available": "Verfügbar", "compositor_available": "Verfügbar",
"compositor_unavailable": "Nicht verfügbar", "compositor_unavailable": "Nicht verfügbar",
"compositor_default": "Standard", "compositor_default": "Standard",
"compositor_none": "Dieser Host hat keine Compositor-Backends die gibt es nur unter Linux. Virtuelle Displays laufen hier über den mitgelieferten pf-vdisplay-Treiber.",
"host_gpus": "GPUs", "host_gpus": "GPUs",
"host_gpus_help": "Die GPU, auf der der Host aufnimmt und encodiert. Automatisch wählt die beste GPU; eine bevorzugte GPU bindet Aufnahme + Encoding an sie. Eine Änderung gilt ab der nächsten Sitzung.", "host_gpus_help": "Die GPU, auf der der Host aufnimmt und encodiert. Automatisch wählt die beste GPU; eine bevorzugte GPU bindet Aufnahme + Encoding an sie. Eine Änderung gilt ab der nächsten Sitzung.",
"gpu_automatic": "Automatisch", "gpu_automatic": "Automatisch",
@@ -136,6 +137,12 @@
"display_preset_update": "Auf aktuelle Einstellungen aktualisieren", "display_preset_update": "Auf aktuelle Einstellungen aktualisieren",
"display_preset_delete": "Löschen", "display_preset_delete": "Löschen",
"display_preset_delete_confirm": "Diese eigene Voreinstellung löschen?", "display_preset_delete_confirm": "Diese eigene Voreinstellung löschen?",
"display_custom_title": "Eigene Einstellungen",
"display_unsaved": "Nicht gespeicherte Änderungen",
"display_unsaved_hint": "Diese Optionen greifen erst, wenn du speicherst.",
"display_all_saved": "Alle Änderungen gespeichert",
"display_revert": "Änderungen verwerfen",
"display_discard_confirm": "Du hast nicht gespeicherte eigene Einstellungen. Verwerfen?",
"clients_title": "Gekoppelte Geräte", "clients_title": "Gekoppelte Geräte",
"clients_empty": "Noch keine gekoppelten Geräte.", "clients_empty": "Noch keine gekoppelten Geräte.",
"clients_name": "Name", "clients_name": "Name",
+7
View File
@@ -46,6 +46,7 @@
"compositor_available": "Available", "compositor_available": "Available",
"compositor_unavailable": "Unavailable", "compositor_unavailable": "Unavailable",
"compositor_default": "Default", "compositor_default": "Default",
"compositor_none": "This host has no compositor backends — they are a Linux concept. Virtual displays here run on the bundled pf-vdisplay driver.",
"host_gpus": "GPUs", "host_gpus": "GPUs",
"host_gpus_help": "The GPU the host captures and encodes on. Automatic picks the best GPU; preferring one pins capture + encode to it. A change applies to the next session.", "host_gpus_help": "The GPU the host captures and encodes on. Automatic picks the best GPU; preferring one pins capture + encode to it. A change applies to the next session.",
"gpu_automatic": "Automatic", "gpu_automatic": "Automatic",
@@ -136,6 +137,12 @@
"display_preset_update": "Update to current settings", "display_preset_update": "Update to current settings",
"display_preset_delete": "Delete", "display_preset_delete": "Delete",
"display_preset_delete_confirm": "Delete this custom preset?", "display_preset_delete_confirm": "Delete this custom preset?",
"display_custom_title": "Custom settings",
"display_unsaved": "Unsaved changes",
"display_unsaved_hint": "These options only take effect once you save.",
"display_all_saved": "All changes saved",
"display_revert": "Discard changes",
"display_discard_confirm": "You have unsaved custom settings. Discard them?",
"clients_title": "Paired clients", "clients_title": "Paired clients",
"clients_empty": "No paired clients yet.", "clients_empty": "No paired clients yet.",
"clients_name": "Name", "clients_name": "Name",
+2
View File
@@ -5,6 +5,7 @@
"description": "punktfunk management console \u2014 TanStack Start + React Query (orval) + @unom/ui + Paraglide i18n", "description": "punktfunk management console \u2014 TanStack Start + React Query (orval) + @unom/ui + Paraglide i18n",
"scripts": { "scripts": {
"prepare": "bun run codegen", "prepare": "bun run codegen",
"postinstall": "bun2nix -o bun.nix",
"codegen": "orval --config orval.config.ts && paraglide-js compile --project ./project.inlang --outdir ./src/paraglide && node tools/check-i18n.mjs", "codegen": "orval --config orval.config.ts && paraglide-js compile --project ./project.inlang --outdir ./src/paraglide && node tools/check-i18n.mjs",
"predev": "orval --config orval.config.ts", "predev": "orval --config orval.config.ts",
"dev": "vite dev --port 47992", "dev": "vite dev --port 47992",
@@ -47,6 +48,7 @@
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
"bun2nix": "2.1.2",
"orval": "^8.22.0", "orval": "^8.22.0",
"playwright": "^1.61.1", "playwright": "^1.61.1",
"storybook": "^10.5.3", "storybook": "^10.5.3",
+3
View File
@@ -12,6 +12,9 @@ const badgeVariants = cva(
destructive: destructive:
"border-transparent bg-destructive text-destructive-foreground", "border-transparent bg-destructive text-destructive-foreground",
success: "border-transparent bg-[var(--success)] text-white", success: "border-transparent bg-[var(--success)] text-white",
// Attention, not failure — pending/unsaved state. Dark text: amber is a light
// colour in both themes, and white on it fails contrast.
warning: "border-transparent bg-[var(--warning)] text-black",
outline: "text-foreground", outline: "text-foreground",
}, },
}, },
+288 -67
View File
@@ -2,10 +2,18 @@ import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button"; import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast"; import { toast } from "@unom/ui/toast";
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { type FC, type MouseEvent, type ReactNode, useEffect, useRef, useState } from "react";
import { import {
getGetDisplayStateQueryKey, type FC,
type MouseEvent,
type ReactNode,
useEffect,
useRef,
useState,
} from "react";
import { ApiError } from "@/api/fetcher";
import {
getGetDisplaySettingsQueryKey, getGetDisplaySettingsQueryKey,
getGetDisplayStateQueryKey,
useCreateCustomPreset, useCreateCustomPreset,
useDeleteCustomPreset, useDeleteCustomPreset,
useGetDisplaySettings, useGetDisplaySettings,
@@ -28,7 +36,6 @@ import type {
Preset, Preset,
Topology, Topology,
} from "@/api/gen/model"; } from "@/api/gen/model";
import { ApiError } from "@/api/fetcher";
import { QueryState } from "@/components/query-state"; import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -89,15 +96,47 @@ export const DisplaySection: FC = () => {
}, },
); );
// Pending edits: the Custom fields do NOT auto-apply (unlike a preset click or an experimental
// toggle), so the draft can silently diverge from what the host is actually running. Reading the
// ref during render is safe here because every write to it is paired with a `setDraft`, so a
// changed seed always comes with the re-render that reads it.
const dirty =
draft !== null &&
seeded.current !== null &&
!deepEqual(draft, seeded.current);
const revert = () => {
if (seeded.current) setDraft(seeded.current);
};
// Last line of defence: a reload/close with pending edits loses them silently otherwise. The
// browser shows its own generic wording — the text is ignored, only returning a value counts.
useEffect(() => {
if (!dirty) return;
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [dirty]);
return ( return (
<div className="flex flex-col gap-card"> <div className="flex flex-col gap-card">
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle>{m.display_config_title()}</CardTitle> <CardTitle>{m.display_config_title()}</CardTitle>
{/* Visible without scrolling to the save button the card is taller than the
viewport, which is exactly how the pending edits went unnoticed. */}
{dirty && <Badge variant="warning">{m.display_unsaved()}</Badge>}
</div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<p className="max-w-prose text-sm text-muted-foreground">{m.host_displays_help()}</p> <p className="max-w-prose text-sm text-muted-foreground">
<QueryState isLoading={q.isLoading} error={q.error} refetch={q.refetch}> {m.host_displays_help()}
</p>
<QueryState
isLoading={q.isLoading}
error={q.error}
refetch={q.refetch}
>
{q.data && draft && ( {q.data && draft && (
<DisplayForm <DisplayForm
draft={draft} draft={draft}
@@ -106,6 +145,8 @@ export const DisplaySection: FC = () => {
customPresets={q.data.custom_presets} customPresets={q.data.custom_presets}
apply={apply} apply={apply}
busy={save.isPending} busy={save.isPending}
dirty={dirty}
revert={revert}
error={apiErrorMessage(save.error)} error={apiErrorMessage(save.error)}
/> />
)} )}
@@ -141,8 +182,22 @@ const DisplayForm: FC<{
customPresets: CustomPreset[]; customPresets: CustomPreset[];
apply: (p: DisplayPolicy) => void; apply: (p: DisplayPolicy) => void;
busy: boolean; busy: boolean;
/** The draft differs from what the host has stored — drives the save bar + the discard guard. */
dirty: boolean;
/** Throw the draft away and go back to the stored policy. */
revert: () => void;
error?: string; error?: string;
}> = ({ draft, setDraft, presets, customPresets, apply, busy, error }) => { }> = ({
draft,
setDraft,
presets,
customPresets,
apply,
busy,
dirty,
revert,
error,
}) => {
const qc = useQueryClient(); const qc = useQueryClient();
const createPreset = useCreateCustomPreset(); const createPreset = useCreateCustomPreset();
const updatePreset = useUpdateCustomPreset(); const updatePreset = useUpdateCustomPreset();
@@ -169,11 +224,19 @@ const DisplayForm: FC<{
max_displays: draft.max_displays ?? 4, max_displays: draft.max_displays ?? 4,
}; };
const effective: EffectivePolicy = const effective: EffectivePolicy =
(isCustom ? undefined : presets.find((p) => p.id === preset)?.fields) ?? customFields; (isCustom ? undefined : presets.find((p) => p.id === preset)?.fields) ??
customFields;
// The five named presets apply in ONE click; "Custom" reveals the fields, seeded from the current // The five named presets apply in ONE click; "Custom" reveals the fields, seeded from the current
// effective behavior (nothing changes until you Save). // effective behavior (nothing changes until you Save).
const pickPreset = (id: string) => { const pickPreset = (id: string) => {
// A preset click overwrites the whole policy, so hand-edits that were never saved would
// vanish without a word — the same failure as not finding the Save button, one click later.
if (dirty && id !== "custom" && !confirm(m.display_discard_confirm()))
return;
// Already hand-editing: re-seeding would fill in defaults for fields the stored policy
// leaves unset and flag "unsaved changes" for a click that changed nothing.
if (id === "custom" && isCustom) return;
if (id === "custom") { if (id === "custom") {
setDraft({ setDraft({
version: 1, version: 1,
@@ -201,7 +264,8 @@ const DisplayForm: FC<{
// Applying a custom preset writes a `Custom` policy carrying its saved fields + game-session (the // Applying a custom preset writes a `Custom` policy carrying its saved fields + game-session (the
// one axis a preset DOES set) — the host has no separate apply route (design/gamemode-and-…). // one axis a preset DOES set) — the host has no separate apply route (design/gamemode-and-…).
const applyCustomPreset = (p: CustomPreset) => const applyCustomPreset = (p: CustomPreset) => {
if (dirty && !confirm(m.display_discard_confirm())) return;
apply({ apply({
version: 1, version: 1,
preset: "custom", preset: "custom",
@@ -216,6 +280,7 @@ const DisplayForm: FC<{
// (found on-glass, .136). Every orthogonal axis has to be listed. // (found on-glass, .136). Every orthogonal axis has to be listed.
capture_monitor: draft.capture_monitor ?? null, capture_monitor: draft.capture_monitor ?? null,
}); });
};
// A custom card is "current" when the in-force policy is a Custom one whose fields + game-session // A custom card is "current" when the in-force policy is a Custom one whose fields + game-session
// value-match this preset (there is no id on DisplayPolicy — match by value). // value-match this preset (there is no id on DisplayPolicy — match by value).
@@ -231,7 +296,11 @@ const DisplayForm: FC<{
if (!name) return; // cancelled or empty if (!name) return; // cancelled or empty
createPreset.mutate( createPreset.mutate(
{ {
data: { name, fields: effective, game_session: draft.game_session ?? "auto" }, data: {
name,
fields: effective,
game_session: draft.game_session ?? "auto",
},
}, },
{ onSuccess: invalidateSettings }, { onSuccess: invalidateSettings },
); );
@@ -240,7 +309,14 @@ const DisplayForm: FC<{
const name = prompt(m.display_preset_name(), p.name)?.trim(); const name = prompt(m.display_preset_name(), p.name)?.trim();
if (!name) return; if (!name) return;
updatePreset.mutate( updatePreset.mutate(
{ id: p.id, data: { name, fields: p.fields, game_session: p.game_session ?? "auto" } }, {
id: p.id,
data: {
name,
fields: p.fields,
game_session: p.game_session ?? "auto",
},
},
{ onSuccess: invalidateSettings }, { onSuccess: invalidateSettings },
); );
}; };
@@ -248,7 +324,11 @@ const DisplayForm: FC<{
updatePreset.mutate( updatePreset.mutate(
{ {
id: p.id, id: p.id,
data: { name: p.name, fields: effective, game_session: draft.game_session ?? "auto" }, data: {
name: p.name,
fields: effective,
game_session: draft.game_session ?? "auto",
},
}, },
{ onSuccess: invalidateSettings }, { onSuccess: invalidateSettings },
); );
@@ -259,21 +339,27 @@ const DisplayForm: FC<{
const ka = customFields.keep_alive; const ka = customFields.keep_alive;
// The duration value, remembered across the Off/Keep toggle so switching back restores it. // The duration value, remembered across the Off/Keep toggle so switching back restores it.
const [keepSecs, setKeepSecs] = useState(ka.mode === "duration" ? ka.seconds : 300); const [keepSecs, setKeepSecs] = useState(
ka.mode === "duration" ? ka.seconds : 300,
);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* One-click presets — a 2-up grid so each has room to breathe */} {/* One-click presets — a 2-up grid so each has room to breathe */}
<div className="space-y-4"> <div className="space-y-4">
<Label className="mb-1 block text-base font-semibold">{m.display_preset()}</Label> <Label className="mb-1 block text-base font-semibold">
{m.display_preset()}
</Label>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{PRESET_ORDER.map((id) => { {PRESET_ORDER.map((id) => {
const p = presets.find((x) => x.id === id); const p = presets.find((x) => x.id === id);
const fields = id === "custom" ? undefined : p?.fields; const fields = id === "custom" ? undefined : p?.fields;
const summary = id === "custom" ? m.display_custom_desc() : p?.summary; const summary =
id === "custom" ? m.display_custom_desc() : p?.summary;
// The built-in "Custom" card is the hand-edit mode; when the active Custom policy // The built-in "Custom" card is the hand-edit mode; when the active Custom policy
// value-matches a saved preset, that preset's card owns the "current" ring instead. // value-matches a saved preset, that preset's card owns the "current" ring instead.
const selected = preset === id && !(id === "custom" && anyCustomSelected); const selected =
preset === id && !(id === "custom" && anyCustomSelected);
const soon = DISABLED_PRESETS.has(id); const soon = DISABLED_PRESETS.has(id);
const disabled = busy || soon; const disabled = busy || soon;
const pick = () => { const pick = () => {
@@ -310,18 +396,30 @@ const DisplayForm: FC<{
)} )}
</span> </span>
{selected && ( {selected && (
<Badge variant="success">{m.display_preset_current()}</Badge> <Badge variant="success">
{m.display_preset_current()}
</Badge>
)} )}
</div> </div>
{summary && ( {summary && (
<p className="mt-1 text-sm text-muted-foreground">{summary}</p> <p className="mt-1 text-sm text-muted-foreground">
{summary}
</p>
)} )}
{fields && ( {fields && (
<div className="mt-auto flex flex-wrap gap-1.5 pt-3"> <div className="mt-auto flex flex-wrap gap-1.5 pt-3">
<Badge variant="secondary">{fmtKeepAlive(fields.keep_alive)}</Badge> <Badge variant="secondary">
<Badge variant="secondary">{tr(TOPOLOGY_LABEL, fields.topology)}</Badge> {fmtKeepAlive(fields.keep_alive)}
<Badge variant="outline">{tr(CONFLICT_LABEL, fields.mode_conflict)}</Badge> </Badge>
<Badge variant="outline">{tr(IDENTITY_LABEL, fields.identity)}</Badge> <Badge variant="secondary">
{tr(TOPOLOGY_LABEL, fields.topology)}
</Badge>
<Badge variant="outline">
{tr(CONFLICT_LABEL, fields.mode_conflict)}
</Badge>
<Badge variant="outline">
{tr(IDENTITY_LABEL, fields.identity)}
</Badge>
</div> </div>
)} )}
</Card> </Card>
@@ -364,20 +462,40 @@ const DisplayForm: FC<{
</div> </div>
)} )}
{presetError && ( {presetError && (
<p className="text-sm text-amber-600 dark:text-amber-500">{presetError}</p> <p className="text-sm text-amber-600 dark:text-amber-500">
{presetError}
</p>
)} )}
</div> </div>
{/* Custom: every option by hand */} {/* Custom: every option by hand. Unlike everything else on this page these fields do NOT
auto-apply, so the block is titled, ringed while dirty, and ends in a sticky save bar. */}
{isCustom && ( {isCustom && (
<div className="space-y-6 rounded-lg border p-5"> <div
<Field label={m.display_keep_alive()} help={m.display_keep_alive_help()}> className={cn(
"space-y-6 rounded-lg border p-5",
dirty && "border-[var(--warning)]",
)}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-base font-semibold">
{m.display_custom_title()}
</h3>
{dirty && <Badge variant="warning">{m.display_unsaved()}</Badge>}
</div>
<Field
label={m.display_keep_alive()}
help={m.display_keep_alive_help()}
>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button <Button
size="sm" size="sm"
variant={ka.mode === "off" ? "default" : "outline"} variant={ka.mode === "off" ? "default" : "outline"}
disabled={busy} disabled={busy}
onClick={() => setDraft({ ...draft, keep_alive: { mode: "off" } })} onClick={() =>
setDraft({ ...draft, keep_alive: { mode: "off" } })
}
> >
{m.display_keep_alive_off()} {m.display_keep_alive_off()}
</Button> </Button>
@@ -386,7 +504,10 @@ const DisplayForm: FC<{
variant={ka.mode === "duration" ? "default" : "outline"} variant={ka.mode === "duration" ? "default" : "outline"}
disabled={busy} disabled={busy}
onClick={() => onClick={() =>
setDraft({ ...draft, keep_alive: { mode: "duration", seconds: keepSecs } }) setDraft({
...draft,
keep_alive: { mode: "duration", seconds: keepSecs },
})
} }
> >
{m.display_keep_alive_keep()} {m.display_keep_alive_keep()}
@@ -395,7 +516,9 @@ const DisplayForm: FC<{
size="sm" size="sm"
variant={ka.mode === "forever" ? "default" : "outline"} variant={ka.mode === "forever" ? "default" : "outline"}
disabled={busy} disabled={busy}
onClick={() => setDraft({ ...draft, keep_alive: { mode: "forever" } })} onClick={() =>
setDraft({ ...draft, keep_alive: { mode: "forever" } })
}
> >
{m.display_keep_alive_forever()} {m.display_keep_alive_forever()}
</Button> </Button>
@@ -410,7 +533,10 @@ const DisplayForm: FC<{
onChange={(e) => { onChange={(e) => {
const n = Math.max(0, Number(e.target.value) || 0); const n = Math.max(0, Number(e.target.value) || 0);
setKeepSecs(n); setKeepSecs(n);
setDraft({ ...draft, keep_alive: { mode: "duration", seconds: n } }); setDraft({
...draft,
keep_alive: { mode: "duration", seconds: n },
});
}} }}
/> />
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
@@ -437,7 +563,9 @@ const DisplayForm: FC<{
options={["separate", "steal", "join", "reject"]} options={["separate", "steal", "join", "reject"]}
labels={CONFLICT_LABEL} labels={CONFLICT_LABEL}
disabled={busy} disabled={busy}
onPick={(v) => setDraft({ ...draft, mode_conflict: v as ModeConflict })} onPick={(v) =>
setDraft({ ...draft, mode_conflict: v as ModeConflict })
}
/> />
<Choice <Choice
label={m.display_identity()} label={m.display_identity()}
@@ -458,7 +586,10 @@ const DisplayForm: FC<{
onPick={(v) => onPick={(v) =>
setDraft({ setDraft({
...draft, ...draft,
layout: { mode: v as LayoutMode, positions: draft.layout?.positions ?? {} }, layout: {
mode: v as LayoutMode,
positions: draft.layout?.positions ?? {},
},
}) })
} }
/> />
@@ -474,14 +605,39 @@ const DisplayForm: FC<{
onChange={(e) => onChange={(e) =>
setDraft({ setDraft({
...draft, ...draft,
max_displays: Math.min(16, Math.max(1, Number(e.target.value) || 1)), max_displays: Math.min(
16,
Math.max(1, Number(e.target.value) || 1),
),
}) })
} }
/> />
</Field> </Field>
<div className="border-t pt-4"> {/* Sticky: the Custom block is taller than most viewports, and a save button parked
<Button onClick={() => apply(draft)} disabled={busy}> at its bottom edge is invisible until you scroll all the way down people
edited, navigated away, and lost the lot. `bottom-0` pins it to the viewport
while any part of the block is on screen, and it settles into place at the end. */}
<div
className={cn(
"sticky bottom-0 z-10 -mx-5 -mb-5 flex flex-wrap items-center justify-end gap-3 rounded-b-lg border-t px-5 py-3 backdrop-blur",
dirty ? "bg-[var(--warning)]/10" : "bg-neutral/80",
)}
>
<span
className={cn(
"mr-auto text-sm",
dirty ? "font-medium" : "text-muted-foreground",
)}
>
{dirty ? m.display_unsaved_hint() : m.display_all_saved()}
</span>
{dirty && (
<Button variant="ghost" onClick={revert} disabled={busy}>
{m.display_revert()}
</Button>
)}
<Button onClick={() => apply(draft)} disabled={busy || !dirty}>
{m.display_save()} {m.display_save()}
</Button> </Button>
</div> </div>
@@ -537,15 +693,27 @@ const DisplayForm: FC<{
{/* What's in force right now */} {/* What's in force right now */}
<div className="flex flex-wrap items-center gap-2 border-t pt-3"> <div className="flex flex-wrap items-center gap-2 border-t pt-3">
<span className="text-sm text-muted-foreground">{m.display_effective()}:</span> <span className="text-sm text-muted-foreground">
{m.display_effective()}:
</span>
<Badge variant="secondary">{fmtKeepAlive(effective.keep_alive)}</Badge> <Badge variant="secondary">{fmtKeepAlive(effective.keep_alive)}</Badge>
<Badge variant="secondary">{tr(TOPOLOGY_LABEL, effective.topology)}</Badge> <Badge variant="secondary">
<Badge variant="outline">{tr(CONFLICT_LABEL, effective.mode_conflict)}</Badge> {tr(TOPOLOGY_LABEL, effective.topology)}
<Badge variant="outline">{tr(IDENTITY_LABEL, effective.identity)}</Badge> </Badge>
<Badge variant="outline">{tr(LAYOUT_LABEL, effective.layout.mode)}</Badge> <Badge variant="outline">
{tr(CONFLICT_LABEL, effective.mode_conflict)}
</Badge>
<Badge variant="outline">
{tr(IDENTITY_LABEL, effective.identity)}
</Badge>
<Badge variant="outline">
{tr(LAYOUT_LABEL, effective.layout.mode)}
</Badge>
<Badge variant="outline">{`${effective.max_displays}×`}</Badge> <Badge variant="outline">{`${effective.max_displays}×`}</Badge>
{(draft.game_session ?? "auto") === "dedicated" && ( {(draft.game_session ?? "auto") === "dedicated" && (
<Badge variant="secondary">{m.display_game_session_dedicated()}</Badge> <Badge variant="secondary">
{m.display_game_session_dedicated()}
</Badge>
)} )}
{(draft.ddc_power_off ?? false) && ( {(draft.ddc_power_off ?? false) && (
<Badge variant="outline">{m.display_ddc_badge()}</Badge> <Badge variant="outline">{m.display_ddc_badge()}</Badge>
@@ -555,8 +723,12 @@ const DisplayForm: FC<{
)} )}
</div> </div>
<p className="max-w-prose text-xs text-muted-foreground">{m.display_pending_note()}</p> <p className="max-w-prose text-xs text-muted-foreground">
{error && <p className="text-sm text-amber-600 dark:text-amber-500">{error}</p>} {m.display_pending_note()}
</p>
{error && (
<p className="text-sm text-amber-600 dark:text-amber-500">{error}</p>
)}
</div> </div>
); );
}; };
@@ -571,7 +743,9 @@ const Field: FC<{ label: string; help?: string; children: ReactNode }> = ({
<div className="space-y-3"> <div className="space-y-3">
<Label className="block">{label}</Label> <Label className="block">{label}</Label>
{children} {children}
{help && <p className="max-w-prose text-xs text-muted-foreground">{help}</p>} {help && (
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
)}
</div> </div>
); );
@@ -682,9 +856,13 @@ const CustomPresetCard: FC<{
)} )}
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<span className="min-w-0 truncate text-base font-semibold">{preset.name}</span> <span className="min-w-0 truncate text-base font-semibold">
{preset.name}
</span>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{selected && <Badge variant="success">{m.display_preset_current()}</Badge>} {selected && (
<Badge variant="success">{m.display_preset_current()}</Badge>
)}
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
@@ -720,10 +898,14 @@ const CustomPresetCard: FC<{
<div className="mt-auto flex flex-wrap gap-1.5 pt-3"> <div className="mt-auto flex flex-wrap gap-1.5 pt-3">
<Badge variant="secondary">{fmtKeepAlive(fields.keep_alive)}</Badge> <Badge variant="secondary">{fmtKeepAlive(fields.keep_alive)}</Badge>
<Badge variant="secondary">{tr(TOPOLOGY_LABEL, fields.topology)}</Badge> <Badge variant="secondary">{tr(TOPOLOGY_LABEL, fields.topology)}</Badge>
<Badge variant="outline">{tr(CONFLICT_LABEL, fields.mode_conflict)}</Badge> <Badge variant="outline">
{tr(CONFLICT_LABEL, fields.mode_conflict)}
</Badge>
<Badge variant="outline">{tr(IDENTITY_LABEL, fields.identity)}</Badge> <Badge variant="outline">{tr(IDENTITY_LABEL, fields.identity)}</Badge>
{(preset.game_session ?? "auto") !== "auto" && ( {(preset.game_session ?? "auto") !== "auto" && (
<Badge variant="secondary">{tr(GAME_SESSION_LABEL, preset.game_session)}</Badge> <Badge variant="secondary">
{tr(GAME_SESSION_LABEL, preset.game_session)}
</Badge>
)} )}
</div> </div>
</Card> </Card>
@@ -744,14 +926,21 @@ const LiveDisplays: FC = () => {
const doRelease = (slot?: number) => const doRelease = (slot?: number) =>
release.mutate( release.mutate(
{ data: { slot: slot ?? null } }, { data: { slot: slot ?? null } },
{ onSuccess: () => qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() }) }, {
onSuccess: () =>
qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() }),
},
); );
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* Wrap in QueryState (like the settings card) so a failed/in-flight `/display/state` {/* Wrap in QueryState (like the settings card) so a failed/in-flight `/display/state`
fetch surfaces as loading/error instead of masquerading as "no live displays". */} fetch surfaces as loading/error instead of masquerading as "no live displays". */}
<QueryState isLoading={state.isLoading} error={state.error} refetch={state.refetch}> <QueryState
isLoading={state.isLoading}
error={state.error}
refetch={state.refetch}
>
{kept.length > 0 && ( {kept.length > 0 && (
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
@@ -765,7 +954,9 @@ const LiveDisplays: FC = () => {
</div> </div>
)} )}
{displays.length === 0 ? ( {displays.length === 0 ? (
<p className="text-sm text-muted-foreground">{m.display_none_live()}</p> <p className="text-sm text-muted-foreground">
{m.display_none_live()}
</p>
) : ( ) : (
<ul className="divide-y rounded-md border"> <ul className="divide-y rounded-md border">
{displays.map((d) => ( {displays.map((d) => (
@@ -790,18 +981,24 @@ const LiveDisplays: FC = () => {
* `PUT /display/layout`, which switches the host to a manual layout and applies from the next connect. * `PUT /display/layout`, which switches the host to a manual layout and applies from the next connect.
* Shown only for a 2-display group arranging a single display is moot. * Shown only for a 2-display group arranging a single display is moot.
*/ */
const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ displays }) => { const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({
displays,
}) => {
const qc = useQueryClient(); const qc = useQueryClient();
const saveLayout = useSetDisplayLayout(); const saveLayout = useSetDisplayLayout();
// Only displays with a stable identity slot can be pinned (shared/anonymous ones have no key). // Only displays with a stable identity slot can be pinned (shared/anonymous ones have no key).
const arrangeable = displays.filter((d) => d.identity_slot != null); const arrangeable = displays.filter((d) => d.identity_slot != null);
// Local edit buffer keyed by identity-slot string → {x, y}, seeded once from the current positions. // Local edit buffer keyed by identity-slot string → {x, y}, seeded once from the current positions.
const [pos, setPos] = useState<Record<string, { x: number; y: number }> | null>(null); const [pos, setPos] = useState<Record<
string,
{ x: number; y: number }
> | null>(null);
useEffect(() => { useEffect(() => {
if (pos === null && arrangeable.length > 0) { if (pos === null && arrangeable.length > 0) {
const seed: Record<string, { x: number; y: number }> = {}; const seed: Record<string, { x: number; y: number }> = {};
for (const d of arrangeable) seed[String(d.identity_slot)] = { x: d.x, y: d.y }; for (const d of arrangeable)
seed[String(d.identity_slot)] = { x: d.x, y: d.y };
setPos(seed); setPos(seed);
} }
}, [arrangeable, pos]); }, [arrangeable, pos]);
@@ -831,15 +1028,21 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ displays }) =>
return ( return (
<div className="space-y-2 border-t pt-4"> <div className="space-y-2 border-t pt-4">
<h4 className="text-sm font-medium">{m.display_arrange()}</h4> <h4 className="text-sm font-medium">{m.display_arrange()}</h4>
<p className="text-xs text-muted-foreground">{m.display_arrange_help()}</p> <p className="text-xs text-muted-foreground">
{m.display_arrange_help()}
</p>
<div className="space-y-2"> <div className="space-y-2">
{arrangeable.map((d) => { {arrangeable.map((d) => {
const slot = d.identity_slot as number; const slot = d.identity_slot as number;
const p = cur[String(slot)] ?? { x: d.x, y: d.y }; const p = cur[String(slot)] ?? { x: d.x, y: d.y };
return ( return (
<div key={d.slot} className="flex flex-wrap items-center gap-2 text-sm"> <div
key={d.slot}
className="flex flex-wrap items-center gap-2 text-sm"
>
<span className="w-44 truncate"> <span className="w-44 truncate">
{d.mode} <code className="text-xs text-muted-foreground">#{slot}</code> {d.mode}{" "}
<code className="text-xs text-muted-foreground">#{slot}</code>
</span> </span>
<Label className="text-xs" htmlFor={`disp-x-${slot}`}> <Label className="text-xs" htmlFor={`disp-x-${slot}`}>
X X
@@ -850,7 +1053,9 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ displays }) =>
className="w-24" className="w-24"
value={p.x} value={p.x}
disabled={saveLayout.isPending} disabled={saveLayout.isPending}
onChange={(e) => setXY(slot, "x", Math.trunc(Number(e.target.value) || 0))} onChange={(e) =>
setXY(slot, "x", Math.trunc(Number(e.target.value) || 0))
}
/> />
<Label className="text-xs" htmlFor={`disp-y-${slot}`}> <Label className="text-xs" htmlFor={`disp-y-${slot}`}>
Y Y
@@ -861,7 +1066,9 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ displays }) =>
className="w-24" className="w-24"
value={p.y} value={p.y}
disabled={saveLayout.isPending} disabled={saveLayout.isPending}
onChange={(e) => setXY(slot, "y", Math.trunc(Number(e.target.value) || 0))} onChange={(e) =>
setXY(slot, "y", Math.trunc(Number(e.target.value) || 0))
}
/> />
</div> </div>
); );
@@ -879,11 +1086,11 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ displays }) =>
); );
}; };
const DisplayRow: FC<{ d: ApiDisplayInfo; busy: boolean; onRelease: () => void }> = ({ const DisplayRow: FC<{
d, d: ApiDisplayInfo;
busy, busy: boolean;
onRelease, onRelease: () => void;
}) => { }> = ({ d, busy, onRelease }) => {
const active = d.state === "active"; const active = d.state === "active";
const stateLabel = const stateLabel =
d.state === "active" d.state === "active"
@@ -898,7 +1105,9 @@ const DisplayRow: FC<{ d: ApiDisplayInfo; busy: boolean; onRelease: () => void }
<span className="font-medium">{d.mode}</span> <span className="font-medium">{d.mode}</span>
<Badge variant={active ? "success" : "secondary"}>{stateLabel}</Badge> <Badge variant={active ? "success" : "secondary"}>{stateLabel}</Badge>
{active && d.sessions > 0 && ( {active && d.sessions > 0 && (
<Badge variant="outline">{m.display_sessions({ count: d.sessions })}</Badge> <Badge variant="outline">
{m.display_sessions({ count: d.sessions })}
</Badge>
)} )}
</div> </div>
<code className="text-xs text-muted-foreground"> <code className="text-xs text-muted-foreground">
@@ -974,17 +1183,29 @@ const GAME_SESSION_LABEL: Record<string, () => string> = {
* (handles the nested `keep_alive` variants + `layout.positions` map; key order doesn't matter). */ * (handles the nested `keep_alive` variants + `layout.positions` map; key order doesn't matter). */
const deepEqual = (a: unknown, b: unknown): boolean => { const deepEqual = (a: unknown, b: unknown): boolean => {
if (a === b) return true; if (a === b) return true;
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false; if (
typeof a !== "object" ||
typeof b !== "object" ||
a === null ||
b === null
)
return false;
const ak = Object.keys(a as object); const ak = Object.keys(a as object);
const bk = Object.keys(b as object); const bk = Object.keys(b as object);
if (ak.length !== bk.length) return false; if (ak.length !== bk.length) return false;
return ak.every((k) => return ak.every((k) =>
deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]), deepEqual(
(a as Record<string, unknown>)[k],
(b as Record<string, unknown>)[k],
),
); );
}; };
/** Look up a localized label, tolerating an unknown/undefined key (falls back to the raw value). */ /** Look up a localized label, tolerating an unknown/undefined key (falls back to the raw value). */
const tr = (map: Record<string, () => string>, key: string | null | undefined): string => { const tr = (
map: Record<string, () => string>,
key: string | null | undefined,
): string => {
const fn = key == null ? undefined : map[key]; const fn = key == null ? undefined : map[key];
return fn ? fn() : String(key ?? ""); return fn ? fn() : String(key ?? "");
}; };
+8
View File
@@ -94,6 +94,13 @@ export const HostView: FC<{
error={compositors.error} error={compositors.error}
refetch={compositors.refetch} refetch={compositors.refetch}
> >
{/* Empty is a real answer, not a load failure: a Windows host drives the
pf-vdisplay driver and has no compositor backends at all. */}
{compositors.data?.length === 0 ? (
<p className="rounded-md border p-4 text-sm text-muted-foreground">
{m.compositor_none()}
</p>
) : (
<ul className="divide-y rounded-md border"> <ul className="divide-y rounded-md border">
{compositors.data?.map((c) => ( {compositors.data?.map((c) => (
<li <li
@@ -121,6 +128,7 @@ export const HostView: FC<{
</li> </li>
))} ))}
</ul> </ul>
)}
</QueryState> </QueryState>
</CardContent> </CardContent>
</Card> </Card>
+7 -1
View File
@@ -61,7 +61,13 @@ export const LibraryGrid: FC<{
> >
{games.length === 0 ? ( {games.length === 0 ? (
<Card> <Card>
<CardContent className="p-8 text-center text-sm text-muted-foreground"> {/* `flush`, not a bare `p-8`: the default `sm:pt-0` would survive the override
(tailwind-merge only resolves conflicts within a variant) and eat the top
inset at 640px see the CardContent doc comment. */}
<CardContent
flush
className="p-8 text-center text-sm text-muted-foreground"
>
{m.library_empty()} {m.library_empty()}
</CardContent> </CardContent>
</Card> </Card>
+4 -1
View File
@@ -110,7 +110,10 @@ export const RecordingsCard: FC<{
refetch={recordings.refetch} refetch={recordings.refetch}
> >
{rows.length === 0 ? ( {rows.length === 0 ? (
<CardContent className="p-8 text-center text-sm text-muted-foreground"> <CardContent
flush
className="p-8 text-center text-sm text-muted-foreground"
>
{m.stats_recordings_empty()} {m.stats_recordings_empty()}
</CardContent> </CardContent>
) : ( ) : (
+4 -1
View File
@@ -93,7 +93,10 @@ export const BrowseTab: FC<{
> >
{shown.length === 0 ? ( {shown.length === 0 ? (
<Card> <Card>
<CardContent className="p-8 text-center text-sm text-muted-foreground"> <CardContent
flush
className="p-8 text-center text-sm text-muted-foreground"
>
{entries.length === 0 ? m.store_empty() : m.store_no_match()} {entries.length === 0 ? m.store_empty() : m.store_no_match()}
</CardContent> </CardContent>
</Card> </Card>
+1
View File
@@ -5,6 +5,7 @@ const VARIANTS = [
"default", "default",
"secondary", "secondary",
"success", "success",
"warning",
"destructive", "destructive",
"outline", "outline",
] as const; ] as const;
+5
View File
@@ -16,6 +16,11 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {}; export const Default: Story = {};
/** A non-Linux host: compositor backends don't exist there, so the list is empty by design. */
export const NoCompositors: Story = {
args: { compositors: { data: [], isLoading: false, error: null } },
};
export const Loading: Story = { export const Loading: Story = {
args: { args: {
host: { data: undefined, isLoading: true, error: null }, host: { data: undefined, isLoading: true, error: null },
+5
View File
@@ -56,6 +56,9 @@
--primary-foreground: #ffffff; --primary-foreground: #ffffff;
--success: oklch(0.6 0.14 160); --success: oklch(0.6 0.14 160);
/* Amber "needs your attention but nothing is broken" (unsaved edits), the badge
counterpart of the inline amber warning text. */
--warning: oklch(0.72 0.15 75);
--destructive: oklch(0.55 0.22 18); --destructive: oklch(0.55 0.22 18);
--destructive-foreground: #ffffff; --destructive-foreground: #ffffff;
@@ -101,6 +104,7 @@
--primary-foreground: #141019; --primary-foreground: #141019;
--success: oklch(0.7 0.15 160); --success: oklch(0.7 0.15 160);
--warning: oklch(0.78 0.16 75);
--destructive: oklch(0.62 0.21 18); --destructive: oklch(0.62 0.21 18);
--destructive-foreground: oklch(0.985 0 0); --destructive-foreground: oklch(0.985 0 0);
} }
@@ -142,6 +146,7 @@
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success); --color-success: var(--success);
--color-warning: var(--warning);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);