177 Commits

Author SHA1 Message Date
enricobuehler 3947d5b07a fix(host/audio): drive the Linux virtual mic with RT_PROCESS (was silent)
apple / swift (push) Successful in 1m1s
ci / rust (push) Successful in 4m36s
ci / web (push) Successful in 48s
ci / docs-site (push) Successful in 55s
apple / screenshots (push) Successful in 5m9s
ci / bench (push) Successful in 4m36s
windows-host / package (push) Successful in 7m8s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m19s
release / apple (push) Successful in 9m52s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m16s
android / android (push) Successful in 3m21s
decky / build-publish (push) Successful in 11s
deb / build-publish (push) Successful in 2m45s
flatpak / build-publish (push) Successful in 4m11s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m48s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m35s
docker / deploy-docs (push) Successful in 18s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
The punktfunk-mic PipeWire source connected without RT_PROCESS, so it ran as an
async/main-loop node. In the host's busy multi-stream graph (desktop audio + video
capture + the session) it never acquired a driver, stayed suspended, and its
process() callback never fired — every recorder reading the remote mic heard pure
silence (the long-standing "Linux host mic broken"). Connect the mic stream with
RT_PROCESS so it is a synchronous node that joins its consumer's driver group and
is actually driven.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:46:06 +00:00
enricobuehler 238501597e feat(host/gamestream): follow Desktop<->Game session switches
apple / swift (push) Successful in 59s
android / android (push) Successful in 4m49s
ci / rust (push) Successful in 4m52s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 56s
apple / screenshots (push) Successful in 5m16s
windows-host / package (push) Successful in 7m1s
deb / build-publish (push) Successful in 2m30s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 42s
ci / bench (push) Successful in 4m41s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m7s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m59s
The GameStream/Moonlight video plane is a separate encode loop that lacked the
session-following the native punktfunk/1 plane has, so a mid-stream Desktop<->Game
switch killed the stream ("video stream failed") instead of following it.

* Normalize the session env like the native plane: extract open_gs_virtual_source,
  which detects the LIVE compositor + apply_session_env/apply_input_env (gamescope
  ATTACH default -> resize-on-attach to the box's own game-mode session at the
  client mode; KWin/Mutter retargeting). GameStream previously ran a bare detect()
  against raw process env, so in game mode it bare-spawned a COMPETING gamescope
  instead of attaching to the box's session.

* In-place capture-loss rebuild: replace the `?` that ended the stream with a
  bounded rebuild (re-detect the live compositor via the same factory, build the
  new source BEFORE dropping the old, reopen the encoder, force an IDR) — keeping
  the send thread + packetizer + socket + RTP clock. A same-resolution
  Desktop<->Game toggle is now FOLLOWED with no Moonlight reconnect.

Protocol limit (unchanged): a mid-stream RESOLUTION change is impossible on
GameStream (WxH locked at ANNOUNCE; no Reconfigure) — a session toggle keeps the
negotiated mode, so this isn't hit. The portal/synthetic source passes no rebuild
closure (propagates as before).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:22:12 +00:00
enricobuehler 04dd3e3a19 docs: refresh Windows host page for new users; drop stale Status/NVIDIA-only/SudoVDA
Rewrite the Windows host docs page for first-time setup, on par with the
other host guides: remove the standout "Status:" banner, restructure into
Requirements / Install (web console + pairing + configure) / How it works /
Notes & limits.

Bring the content up to date with the shipping host:
- encode is all-vendor (NVENC/AMF/QSV + software fallback), not NVIDIA-only
- virtual display is punktfunk's own pf-vdisplay IDD (SudoVDA removed)
- gamepads need no prerequisite — UMDF drivers bundled; ViGEmBus is gone
- add HDR10 + Vulkan-game HDR layer coverage

Fix the same stale claims where other pages cross-reference the Windows host
(requirements, running-as-a-service, install, roadmap, status).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:22:50 +00:00
enricobuehler 61aa1053e7 feat(host/gamescope): headless game mode that follows the box + matches the client
apple / swift (push) Successful in 1m2s
android / android (push) Successful in 4m43s
ci / rust (push) Successful in 4m53s
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 57s
apple / screenshots (push) Successful in 5m6s
deb / build-publish (push) Successful in 2m31s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
windows-host / package (push) Successful in 9m2s
ci / bench (push) Successful in 4m41s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m6s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m43s
Make Steam game mode work on a display-less streaming host and stream it at the
client's resolution:

* Ship /etc/gamescope-session-plus/sessions.d/steam (packaging/bazzite/
  gamescope-headless-session, installed by the RPM + Arch PKGBUILD): fall back to
  gamescope's headless backend when no display is connected, so "Switch to Game
  Mode" boots offscreen instead of crashing on the missing panel (and 5-striking
  back to desktop). No-op on display-attached boxes; only sets unset values so
  the host's per-client mode still wins.

* Default Bazzite/SteamOS to ATTACH (PUNKTFUNK_GAMESCOPE_ATTACH=1 in host.env):
  the box owns its session (Desktop<->Game, persistent), the host follows +
  captures it and never tears it down — so switching is rock-solid and a
  disconnect leaves the box in its mode (reconnect returns there).

* Resize-on-attach (gamescope.rs): on connect, ensure the box's own game-mode
  session runs at the CLIENT's resolution — reuse it when already matching (fast
  path, no restart), else reconfigure + restart the box's own autologin
  gamescope-session-plus@<client> at the client mode (cooperative: no competing
  unit, so no autologin-respawn fight). Detect the live gamescope's -W/-H via
  argv[0] in /proc (its /proc/<pid>/exe is unreadable for that process).

Validated live on a headless bazzite-deck-nvidia box: game mode boots headless +
stable (0 strikes); the host attaches + streams video/audio/EIS input; a
5120x1440 client reuses the matching session and streams at 5120x1440.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:09:45 +00:00
enricobuehler 50e17b3508 fix(host/capture): hold the session through a slow compositor switch
apple / swift (push) Successful in 1m1s
android / android (push) Successful in 4m41s
ci / rust (push) Successful in 4m52s
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 54s
apple / screenshots (push) Successful in 5m14s
windows-host / package (push) Successful in 7m54s
deb / build-publish (push) Successful in 2m30s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m34s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m10s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m7s
A Bazzite/SteamOS Gaming↔Desktop switch tears the old compositor down and can
take 15s+ to bring the new one up — longer than the capture-loss rebuild's
~10s window, so the session failed mid-switch ("disconnect — session failed")
and forced the client to cold-reconnect. Retry the rebuild within a 40s budget
instead of giving up after one round, and re-detect the live compositor on
each attempt so the stream follows the box to whatever session comes up (a new
instance of the same compositor, or a different one — the kind-change case).
The QUIC keepalive runs on its own thread, so the client stays connected
(frozen on the last frame) and the stream resumes when the new output appears,
with no reconnect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 09:31:47 +00:00
enricobuehler 94c556f0e3 fix(host/capture): recover from compositor loss instead of freezing
apple / swift (push) Successful in 1m1s
apple / screenshots (push) Successful in 5m7s
windows-host / package (push) Successful in 7m26s
android / android (push) Successful in 4m50s
ci / rust (push) Successful in 4m51s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 54s
deb / build-publish (push) Successful in 2m29s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m37s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m1s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m47s
When the compositor is torn down mid-stream (a Gaming↔Desktop switch removes
the virtual output), its PipeWire stream leaves Streaming for Paused rather
than disconnecting. try_latest treated that as Ok(None) ("static desktop —
repeat the last frame"), so the stream froze on the last frame forever and
neither recovery path fired: the capture-loss rebuild keys on Err, and the
session watcher keys on a session-KIND change (a desktop→desktop new KWin
instance is the same kind).

Track the PipeWire stream state via state_changed (a `streaming` flag) and,
in try_latest, surface a sustained non-Streaming state (1.5s grace for a
transient renegotiation blip) as a capture-loss Err — which the encode loop
already handles by rebuilding the pipeline in place. A static desktop stays
Streaming, so no false trigger. Complements the now-default session watcher.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 09:00:35 +00:00
enricobuehler 32c1929948 feat(host/session-watch): default Gaming↔Desktop follow on for Bazzite/SteamOS
apple / swift (push) Successful in 1m2s
android / android (push) Successful in 4m52s
ci / rust (push) Successful in 5m3s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 54s
decky / build-publish (push) Successful in 22s
windows-host / package (push) Successful in 9m7s
ci / bench (push) Successful in 4m40s
apple / screenshots (push) Successful in 5m20s
deb / build-publish (push) Successful in 2m31s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m40s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m39s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m24s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 47s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m19s
docker / deploy-docs (push) Successful in 22s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m29s
The mid-stream session watcher (rebuild the backend in place when the box
flips Gaming↔Desktop) was opt-in via PUNKTFUNK_SESSION_WATCH, so it never
ran on a stock Bazzite/SteamOS box — switching modes froze the stream on the
now-dead compositor. Default it ON when os-release ID/ID_LIKE is
bazzite/steamos (the platforms that flip sessions); still off on plain
desktops. Also parse the env properly so PUNKTFUNK_SESSION_WATCH=0 actually
disables it (was: any value, including "0", enabled it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 08:43:27 +00:00
enricobuehler 3915a82780 fix(host/input): route KWin auto-detect to the fake_input backend
apple / swift (push) Successful in 1m1s
apple / screenshots (push) Successful in 5m2s
windows-host / package (push) Successful in 6m56s
android / android (push) Successful in 4m42s
ci / rust (push) Successful in 4m52s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 56s
deb / build-publish (push) Successful in 2m31s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m29s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m4s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m0s
apply_input_env() hard-pinned PUNKTFUNK_INPUT_BACKEND=libei for KWin, and
default_backend() reads that env first — so the auto-detecting host (the
normal `serve` service) ignored the new KwinFakeInput backend and fell back
to the RemoteDesktop portal path that needs a user to approve. Route KWin to
"kwin" (org_kde_kwin_fake_input); GNOME/Mutter stay on libei (no fake_input
there).

Validated live on a Bazzite KDE box via the auto-detect path:
backend=KwinFakeInput, "KWin fake_input ready (no portal)", input events
forwarded with no errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:52:02 +00:00
enricobuehler a4833e4780 feat(android/touch): trackpad-relative cursor (default), with a direct-touch toggle
apple / swift (push) Successful in 1m10s
android / android (push) Successful in 4m53s
ci / rust (push) Successful in 5m1s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 55s
apple / screenshots (push) Successful in 5m28s
deb / build-publish (push) Successful in 2m30s
windows-host / package (push) Successful in 8m41s
decky / build-publish (push) Successful in 29s
ci / bench (push) Successful in 4m27s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m43s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m35s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m25s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 48s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m46s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 10m1s
docker / deploy-docs (push) Successful in 24s
One-finger touch was absolute "direct pointing" — the host cursor jumped to the
finger and was recomputed from each touch-start, so you couldn't precisely reach a
target. Now a relative trackpad: the cursor stays put on touch-down and moves by the
finger delta (host MouseMove via nativeSendPointerMove, already supported — no
protocol change), with mild pointer acceleration and sub-pixel remainder
accumulation so slow precise moves aren't lost to Int truncation. Swipe, lift, and
re-swipe to walk it across; tap = left-click at the cursor's current position.
Two-finger scroll / right-click, three-finger HUD toggle, and tap-then-hold-drag are
preserved unchanged; finger-id re-anchoring keeps multi-touch transitions jump-free.

Added Settings → Pointer → "Trackpad mode" (default on); turning it off restores the
old direct-pointing path verbatim.

:app:compileDebugKotlin green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:34:03 +00:00
enricobuehler 4e79e6cdad fix(android/audio): kill the AAudio crackle (RT-safe ring + deeper buffer + XRun sizing)
The jitter ring was a port of the Linux client's, but Linux runs on PipeWire
(adaptive resampling masks host↔DAC drift + a shallow buffer); AAudio hands us a
raw realtime callback and we own the buffer, so the same code crackled only on
Android. Three converging causes, all fixed:

- Heap free on the realtime audio thread every quantum (Android's Scudo free() has
  unbounded tail latency → XRun → click). Decoded buffers are now recycled back to
  the producer via a free-list instead of freed on the audio thread; the ring is
  pre-reserved so extend() never reallocates there.
- The ring collapsed to ~15 ms on the tiny LowLatency burst and re-primed (a fresh
  silence) on every single empty callback. Now ~40 ms prime / ~150 ms hard cap,
  decoupled from the burst size, with de-prime hysteresis (re-prime only after a
  sustained drain).
- AAudio's anti-glitch knobs were unused: prime the HW buffer above its 2-burst
  default and grow it on getXRunCount(). The post-open log now reports
  perf/sharing/buffer so a fall to a resampled legacy path is visible.

Steady-state audio latency ~15 → ~40 ms (within lip-sync tolerance; matches the
Moonlight/Sunshine operating point). cargo-ndk build both ABIs + fmt + clippy green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:33:51 +00:00
enricobuehler f74bc4a3f1 feat(host/input): headless KDE input via org_kde_kwin_fake_input
Desktop-mode (KWin) streaming had no input: the path was libei via the
RemoteDesktop portal, which (a) isn't reachable from the host service env
and (b) requires a human to approve "Allow remote control?" — a
non-starter on a headless box. KWin's own headless RDP server (krdpserver)
solves this with org_kde_kwin_fake_input, authorized by the exact same
.desktop X-KDE-Wayland-Interfaces grant we already ship
(org_kde_kwin_fake_input is listed alongside zkde_screencast_unstable_v1).

Add a fake_input injector: vendor the protocol XML, bind the global as an
ordinary Wayland client, authenticate (auto-accepted for an
interface-authorized client — no dialog), and translate pointer (rel/abs),
button, scroll, keyboard (raw evdev keycodes resolved by KWin's own keymap)
and touch. Select it for KWin (compositor=="kwin" or XDG_CURRENT_DESKTOP
KDE); GNOME stays on libei (it has neither fake_input nor the wlr
protocols). PUNKTFUNK_INPUT_BACKEND=kwin forces it.

cargo check + clippy + fmt green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:26:04 +00:00
enricobuehler 8e18d01af5 fix(host/kwin): authorize Desktop-mode streaming via a shipped .desktop
Streaming the KDE *Desktop* (KWin) session failed on a real interactive
Plasma session with "KWin does not expose zkde_screencast_unstable_v1":
KWin treats the screencast/virtual-output and fake_input globals as
restricted and advertises them only to a client whose installed .desktop
lists them under X-KDE-Wayland-Interfaces (matched by /proc/<pid>/exe ->
Exec, and cached per-executable on first connect). The host shipped no
.desktop, so it was permanently denied; it only ever worked on the
headless dev box via KWIN_WAYLAND_NO_PERMISSION_CHECKS=1.

Ship packaging/linux/io.unom.Punktfunk.Host.desktop (least-privilege:
only the host, only zkde_screencast_unstable_v1 + org_kde_kwin_fake_input)
and install it from the RPM/.deb/Arch host packaging so it is present
before the host first connects. Drop the blunt session-wide
NO_PERMISSION_CHECKS hack from kde-desktop-setup.sh (it now only seeds the
RemoteDesktop input grant) and fix the now-misleading kwin.rs docs/errors.

Validated live on a Bazzite Kinoite box (KWin 6.6.4): probe-compositor +
spike --source kwin-virtual succeed against a KWin running WITHOUT the
permission bypass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:15:39 +00:00
enricobuehler 3477cbe7ce fix(audio/windows): stop the client mic echoing back through the loopback
The Windows virtual mic fakes a capture endpoint by writing the client's
uplinked PCM into a virtual device's *render* endpoint, while the
desktop-audio plane loopback-captures the *default render* endpoint — with
no mutual exclusion between the two. WASAPI loopback captures the mixed
output of an endpoint (everything any app renders to it, including our mic
writes), so when both resolve to the same device — VB-CABLE used for both,
or the auto-installed Steam Streaming Microphone being the default render on
a headless box — the injected mic is captured straight back into the
host->client audio stream: an infinite echo.

find_device() now resolves the loopback's endpoint id (default render) and
skips any candidate matching it, scanning on to the next non-loopback match,
so the mic can never land on the device the loopback reads. The auto-install
path now provisions the full Steam pair (Streaming Microphone + Streaming
Speakers) so a bare host gets two distinct devices instead of one shared
one. Errors distinguish "no device" from "only candidate is the loopback
device". Linux was already immune (its mic is a dedicated Audio/Source node,
structurally separate from the monitored sink).

Windows-only (#[cfg(windows)]); rustfmt-clean, compile-checked in
windows-host CI, needs on-glass validation on the RTX box. Does not force
the system default playback onto Steam Streaming Speakers (IPolicyConfig) —
not required to break the echo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 23:51:46 +00:00
enricobuehler 5a2e07e865 style(windows): rustfmt install.rs to unbreak cargo fmt --all --check
apple / swift (push) Successful in 1m3s
ci / rust (push) Successful in 4m52s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 59s
apple / screenshots (push) Successful in 5m12s
ci / bench (push) Successful in 4m40s
windows-host / package (push) Successful in 6m28s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m13s
release / apple (push) Successful in 10m9s
deb / build-publish (push) Successful in 2m44s
decky / build-publish (push) Successful in 11s
android / android (push) Successful in 3m33s
flatpak / build-publish (push) Successful in 4m9s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m5s
docker / deploy-docs (push) Successful in 7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m16s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m41s
The pnputil /add-driver call in windows/install.rs was committed unwrapped;
`cargo fmt --all --check` (which checks cfg(windows) files too) flagged it and
failed the `rust` CI job at the Format step, skipping clippy/build/test. Apply
rustfmt — no behavior change. Clears the way to cut the v0.2.0 release from
green main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 23:19:12 +00:00
enricobuehler 6e949b6748 fix(readme): make the logo readable on light + dark themes
apple / swift (push) Successful in 1m3s
apple / screenshots (push) Successful in 5m25s
ci / rust (push) Failing after 1m5s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 1m0s
android / android (push) Successful in 3m59s
deb / build-publish (push) Successful in 2m31s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 4m35s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m55s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m46s
docker / deploy-docs (push) Successful in 7s
The wordmark was light violet only — low-contrast on a light README
background. Swap to a single theme-adaptive SVG: an internal
`prefers-color-scheme` media query paints it deep violet (the brand-mark
palette) on light backgrounds and the original light violet on dark, so it
reads on both GitHub/Gitea themes with no markup change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:54:03 +00:00
enricobuehler 8ae161fe61 docs(windows): README - install via punktfunk-host.exe driver install / web setup (not .ps1)
apple / swift (push) Successful in 1m0s
windows-host / package (push) Successful in 6m20s
apple / screenshots (push) Successful in 5m26s
ci / rust (push) Failing after 26s
ci / web (push) Successful in 54s
deb / build-publish (push) Successful in 2m30s
ci / docs-site (push) Successful in 1m3s
android / android (push) Successful in 3m19s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m35s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m2s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m48s
docker / deploy-docs (push) Successful in 6s
Option A removed install-pf-vdisplay.ps1 / install-gamepad-drivers.ps1 / web-setup.ps1;
the installer now calls the exe subcommands. Drop the stale table rows + reword the
install-flow + 'thin installer' notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:46:05 +00:00
enricobuehler 3a89ee8cd7 docs(readme): add logo banner + refresh Windows-host status
- Add the centered punktfunk wordmark banner at the top (assets/punktfunk-logo.svg,
  the same logo + layout the marketing site's README uses).
- Refresh the now-stale Windows-host facts: all-vendor (NVENC + AMF/QSV), its own
  all-Rust pf-vdisplay IddCx virtual display (was SudoVDA), bundled UMDF virtual-gamepad
  drivers (ViGEmBus gone), HDR incl. Vulkan-game HDR; x64-only, no longer NVIDIA-only.
- Note punktfunk-host covers Linux + Windows; point design/ at its new README index.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:45:29 +00:00
enricobuehler dac0fee4e3 docs(windows): reflect the install-via-exe (Option A) landing in the build/packaging doc
apple / swift (push) Successful in 1m3s
apple / screenshots (push) Successful in 5m31s
ci / web (push) Successful in 49s
decky / build-publish (push) Successful in 14s
ci / rust (push) Failing after 32s
ci / docs-site (push) Successful in 1m1s
android / android (push) Successful in 3m21s
deb / build-publish (push) Successful in 2m30s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
ci / bench (push) Successful in 4m49s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m32s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m47s
docker / deploy-docs (push) Successful in 6s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:44:47 +00:00
enricobuehler 125a51d81d feat(windows-installer): move driver + web install into the host exe (ASCII root fix)
apple / swift (push) Successful in 1m0s
apple / screenshots (push) Successful in 5m16s
windows-host / package (push) Successful in 6m25s
ci / rust (push) Failing after 28s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 1m1s
android / android (push) Successful in 3m21s
deb / build-publish (push) Successful in 2m31s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m39s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m2s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m50s
docker / deploy-docs (push) Successful in 17s
Port the three install-time PowerShell *files* (install-pf-vdisplay.ps1,
install-gamepad-drivers.ps1, web-setup.ps1) into punktfunk-host.exe subcommands:
`driver install [--gamepad] --dir <stage>` and `web setup --app-dir <app>
[--password-file <f>]` (windows/install.rs).

Why: PowerShell 5.1 reads a BOM-less .ps1 FILE in the machine ANSI codepage, so a
stray non-ASCII byte mis-decodes and aborts on a non-English box - exactly how the
pf-vdisplay driver install silently failed. A compiled subcommand drives the same
external tools (certutil/pnputil/nefconc/schtasks/netsh/icacls) as fixed string
literals, with no file-codepage surface. (The .iss's INLINE -Command PowerShell is a
command-line string, not a file read, so it's unaffected and stays.)

- windows/install.rs: faithful port - cert trust, gated nefconc node create + pnputil
  for pf-vdisplay; pnputil per-inf for gamepads; web-password ACL, the PunktfunkWeb task
  (generated UTF-16 XML), firewall rule, start. Best-effort (a hiccup warns, never aborts).
- punktfunk-host.iss [Run]: call the exe instead of `powershell -File`; drop the
  web-setup.ps1 staging + WebSetup define; WebSetupParams emits --app-dir/--password-file.
- pack-host-installer.ps1: stop copying the three install scripts into the stages.
- delete the three .ps1 files.

The `mod install;` + dispatch arms in main.rs landed in the preceding docs commit
(swept up by a concurrent commit); this commit adds the module + installer wiring.
CI-compile-validated via windows-host; the install path is on-glass-validated on the
next canary install (the test box is offline).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:43:18 +00:00
enricobuehler 7b99b41ede docs(design): trim shipped plans, consolidate cluster, add index
Much of design/ described work that has since shipped. Trim each doc to
its durable rationale + still-open items (the code is the source of truth
for shipped detail; git history holds the full originals).

- Shipped plans -> status stubs: stats-capture, gamestream-host-plan,
  apple-stage2-presenter, windows-service.
- Trimmed completed-out / open-kept: implementation-plan, hdr-pipeline,
  host-latency, gpu-contention (fixed stale status table), game-library,
  linux-setup (fixed m0->spike + stale zero-copy claim),
  session-aware-host-followups, windows-client-bootstrap,
  windows-dualsense-{scoping,game-detection}, windows-virtual-display,
  security-review (per-finding status table; #12 still open),
  apollo-comparison (shipped backlog collapsed to one-liners).
- Windows-host cluster consolidated: windows-host.md -> redirect into
  windows-host-rewrite.md (whose stale scorecard is corrected -- goal1 is
  merged, M4 done); windows-secure-desktop.md archived (now a fallback
  behind IDD-push primary).
- Kept evergreen: ci.md, gamescope-multiuser.md, windows-build-and-packaging.md.
- New design/README.md: per-doc status table + consolidated open-items
  roll-up so nothing is tracked in only one buried doc.
- Repoint 5 code comments to the archived secure-desktop doc path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:39:06 +00:00
enricobuehler 9ea2c17419 docs(windows): add design/windows-build-and-packaging.md + refresh packaging README
apple / swift (push) Successful in 1m0s
apple / screenshots (push) Successful in 5m19s
windows-host / package (push) Successful in 6m20s
android / android (push) Successful in 4m42s
ci / rust (push) Successful in 4m47s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 58s
deb / build-publish (push) Successful in 2m30s
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 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
ci / bench (push) Successful in 4m40s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m16s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m3s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m0s
docker / deploy-docs (push) Successful in 22s
A single repo-internal source of truth for the Windows build/packaging: what ships, the
all-Rust driver workspace built FROM SOURCE in CI (+ the anti-stale rationale), the
toolchain (clang 22 + bindgen 0.72, no LLVM pin), the Inno installer, the web console
bundle, the CI workflows, signing, and the dev loop. (design/, not the docs-site.)

packaging/windows/README.md: drop the deleted vendored-driver dir + its "Vendored driver"
callout, add the build-* / install-gamepad / clear-force-integrity rows, point at the new
design doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:22:40 +00:00
enricobuehler a9cca82fb8 chore(windows): clean up build/packaging - drop vendored driver binaries + the LLVM-21 pin
windows-drivers-provision / provision (push) Successful in 13s
windows-drivers / probe-and-proto (push) Successful in 17s
android / android (push) Failing after 40s
apple / swift (push) Successful in 1m0s
ci / web (push) Successful in 58s
windows-drivers / driver-build (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m18s
ci / rust (push) Successful in 4m25s
apple / screenshots (push) Successful in 5m24s
deb / build-publish (push) Successful in 2m29s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
ci / bench (push) Successful in 4m48s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
windows-host / package (push) Successful in 6m38s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m24s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m31s
docker / deploy-docs (push) Successful in 18s
Now that the drivers build from source in CI, remove the dead checked-in binaries and
the toolchain cruft they left behind:

- Delete packaging/windows/{pf-vdisplay,gamepad-drivers}/ (the prebuilt .dll/.inf/.cat/.cer).
  pack-host-installer.ps1 builds + signs all three drivers from the drivers/ workspace and
  nothing reads the vendored dirs anymore; stage-pf-vdisplay.ps1's -VendorDir is now a
  mandatory build-output path, not a vendored default.
- Drop the LLVM-21 pin. The vendored bindgen 0.71->0.72 bump (the shipping pack already
  builds green on the runner-default clang 22) retired the bindgen-0.71 layout-test overflow
  that needed LLVM 21.1.2, so windows-drivers.yml + provision-windows-wdk.ps1 no longer
  install/point at C:\llvm-21 (~898 MB off a fresh provision) - both driver builds now use one
  toolchain (clang 22 + bindgen 0.72).
- pack -SkipBuild on the gamepad build (build-pf-vdisplay.ps1 already builds the whole
  workspace), build-web.ps1 reaps a stale node too, deploy-dev.ps1 nefconc path + comments.
- Reword the vendored-driver references (build scripts, .iss, READMEs, the vite web-bundle
  comment) to the build-from-source reality.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:16:46 +00:00
enricobuehler 7ab0661ddc fix(windows-installer): escape the brace in the [UninstallRun] PowerShell so ISCC compiles
windows-drivers / probe-and-proto (push) Successful in 21s
apple / swift (push) Successful in 1m4s
windows-drivers / driver-build (push) Successful in 1m9s
android / android (push) Successful in 4m25s
ci / web (push) Successful in 53s
apple / screenshots (push) Successful in 5m32s
ci / rust (push) Successful in 4m45s
ci / docs-site (push) Successful in 52s
windows-host / package (push) Successful in 6m47s
deb / build-publish (push) Successful in 2m28s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m45s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m55s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m46s
The Bug C [UninstallRun] one-liner had `ForEach-Object { Stop-Process ... }`; Inno
Setup parses `{...}` as a constant in [Run]/[UninstallRun] sections, so ISCC aborted
with "Unknown constant" and the windows-host pack failed at the ISCC step (the host
build, clippy, driver build + web smoke-boot all passed). Escape `{` as `{{`. The
same one-liner in the [Code] StopWebConsole proc is inside a Pascal string literal,
so its brace is literal and must NOT be escaped. Validated: ISCC now parses past
[UninstallRun] + [Code] (fails only later on the absent dummy payload).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:15:07 +00:00
enricobuehler 92e68024f1 fix(windows-installer): build the gamepad drivers from source in CI too
Fold the pf-dualsense (DualSense / DualShock 4) and pf-xusb (Xbox 360 / XInput)
UMDF drivers into the in-tree drivers workspace (their source had stale
../../crates/wdk-* path-deps from before the wdk vendoring reorg and could no
longer build at all) and build them from source per release, exactly like
pf-vdisplay - same anti-stale reasoning. One `cargo build --release` now builds
all three drivers against the vendored wdk-sys (incl. the bindgen 0.72 pin), and
build-gamepad-drivers.ps1 signs pf_dualsense + pf_xusb (clear FORCE_INTEGRITY ->
sign dll -> stampinf -> Inf2Cat -> sign cat) with one shared cert + .cer,
matching the layout install-gamepad-drivers.ps1 expects. pack-host-installer.ps1
builds + stages them instead of the retired checked-in binaries.

Validated on the runner: the whole workspace (pf-vdisplay + pf-dualsense +
pf-xusb) builds with CARGO_TARGET_DIR=C:\t set, and build-gamepad-drivers.ps1
produces signed pf_dualsense.{dll,inf,cat} + pf_xusb.{dll,inf,cat} + the .cer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:08:40 +00:00
enricobuehler 64abce6daa fix(windows-installer): pf-vdisplay CI build - default target dir + non-fatal cat guard
apple / swift (push) Successful in 59s
android / android (push) Successful in 4m23s
ci / rust (push) Successful in 4m43s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 54s
windows-host / package (push) Failing after 5m39s
apple / screenshots (push) Successful in 5m15s
deb / build-publish (push) Successful in 2m31s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m39s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m6s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m52s
The CI driver build panicked in wdk-sys's build script - "a Cargo.lock file should
exist in the same directory as the top-level Cargo.toml". wdk-build's
find_top_level_cargo_manifest() walks UP from OUT_DIR for the first ancestor holding a
Cargo.lock and explicitly does NOT support non-default target dirs - but
build-pf-vdisplay.ps1 pointed CARGO_TARGET_DIR at an out-of-tree dir (to isolate from
CI's shared C:\t), so no ancestor of OUT_DIR had a Cargo.lock. Build into the driver
workspace's DEFAULT target dir instead (its ancestors include the driver Cargo.lock);
the driver's own [workspace] already isolates it and it has no CMake deps needing C:\t.
Also make the Test-FileCatalog coverage guard non-fatal (it can't open a catalog
signed by a not-yet-trusted cert). Validated on the runner with CARGO_TARGET_DIR=C:\t.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:58:20 +00:00
enricobuehler bdfab8e0d5 fix(windows-installer): build pf-vdisplay from source in CI; ASCII scripts; upgrade-safe web console
windows-drivers / probe-and-proto (push) Successful in 24s
apple / swift (push) Successful in 1m4s
windows-drivers / driver-build (push) Successful in 1m8s
android / android (push) Successful in 4m4s
ci / rust (push) Successful in 4m39s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 53s
apple / screenshots (push) Successful in 5m10s
windows-host / package (push) Failing after 5m35s
deb / build-publish (push) Successful in 2m29s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m42s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m57s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m46s
The pf-vdisplay virtual-display driver shipped as a checked-in PREBUILT binary
that went stale - two field failures on a fresh install (live-repro'd on a
German-locale Dell laptop):

  * Bug A (every box): a repo-wide rename edited the vendored pf_vdisplay.inf
    but never re-signed pf_vdisplay.cat, so the catalog stopped covering the INF
    -> `pnputil /add-driver` fails SPAPI_E_FILE_HASH_NOT_IN_CATALOG -> driver
    never installs -> every session dies "pf-vdisplay driver interface not
    found".
  * the prebuilt binary also predated IOCTL_SET_RENDER_ADAPTER (added to the
    driver source after the vendor freeze) that the host needs to pin the IDD
    render GPU on hybrid/Optimus boxes.

Fix: build the driver FROM SOURCE every release (build-pf-vdisplay.ps1, wired
into pack-host-installer.ps1) so .dll/.inf/.cat are always in lockstep and
current driver features ship. The runner's clang 22 made the driver's pinned
bindgen 0.71 emit opaque structs (157 layout-assert errors), so bump the
vendored wdk-sys/wdk-build bindgen 0.71 -> 0.72 (+ lock). The build self-signs
the driver per build (installer trusts the bundled .cer); a stable
DRIVER_CERT_PFX_B64 secret can override.

  * Bug B (non-English boxes): the installer runs install-pf-vdisplay.ps1 etc.
    via powershell.exe (5.1), which reads a BOM-less script in the ANSI codepage
    - an em-dash's trailing 0x94 byte becomes a curly quote on German
    Windows-1252 and the script aborts "unterminated string", so the driver
    never installed (the gamepad script survived only because it was already
    ASCII). Scrub every installer-run .ps1/.cmd to ASCII + add a CI gate that
    fails on any non-ASCII so it can't regress.

  * Bug C (upgrades): nothing stopped the OLD web console before re-registering
    its task, so a stale server kept :3000 (the new one restart-looped on
    EADDRINUSE) and served a broken old bundle (500 on /login). Stop + reap it
    (runtime-agnostic, by the :3000 listener owner) in web-setup.ps1 and in the
    .iss before the file copy + on uninstall.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:33:34 +00:00
enricobuehler 8e87e617df fix(windows-host): force EXTEND topology so a new IddCx display isn't cloned
A freshly-added IddCx virtual display lands in CLONE/duplicate mode when a
physical display is already active (a laptop panel, an attached monitor): the
cloned output shares that display's source, so the OS never commits a distinct
path for it, never calls ASSIGN_SWAPCHAIN, and capture sees no frames - the
session fails "not an active display path / needs a WDDM GPU to activate" and
tears down with 0 frames (seen live on an Intel-iGPU + NVIDIA-Optimus laptop).

force_extend_topology() applies the EXTEND preset (the programmatic Win+P
"Extend") right after ADD so the IDD comes up as its own active path; the
existing resolve_gdi_name -> set_active_mode -> isolate_displays_ccd bring-up
then proceeds. Idempotent / no-op on a sole-display (headless single-GPU) box,
so it's safe on the path that already worked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:33:15 +00:00
enricobuehler 5bf787eb2b feat(host): web-console performance capture — record stream stats, graph them
apple / swift (push) Successful in 1m1s
android / android (push) Successful in 4m13s
ci / rust (push) Successful in 4m42s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 53s
windows-host / package (push) Successful in 5m51s
apple / screenshots (push) Successful in 5m1s
deb / build-publish (push) Successful in 2m29s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 4m35s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m9s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m10s
Arm streaming-perf-stats capture from the web console, play, stop, and review the
run as graphs; finished captures are saved to disk as browsable/exportable
recordings. Covers both the native punktfunk/1 path and GameStream.

- stats_recorder.rs: one shared Arc<StatsRecorder> ring (created in gamestream::serve,
  shared with the mgmt API + both streaming loops, mirroring NativePairing). The
  hot-path gate is a runtime AtomicBool that replaces the startup-only PUNKTFUNK_PERF
  for *recording* (PERF stdout logging unchanged); bounded ring (~3 h); atomic
  temp+rename writes to ~/.config/punktfunk/captures/*.json; path-traversal-safe ids;
  poison-resilient locks.
- native (punktfunk1.rs) + GameStream (stream.rs) emit a StatsSample at their existing
  ~2 s / ~1 s aggregation boundary — per-stage latency p50/p99, fps new/repeat, goodput,
  loss/FEC deltas — with no new per-frame work beyond the cheap atomic check.
  FrameMsg.was_measured keeps pre-arm in-flight frames out of the first window's
  percentiles (without zeroing the Windows-relay path's fps/encode).
- mgmt.rs: 7 bearer-only /api/v1/stats/* endpoints (capture start/stop/status/live;
  recordings list/get/delete); api/openapi.json regenerated, in sync.
- web: new "Performance" page (recharts, rendered SSR-safe) — capture control, live
  graphs while armed, recordings table (view / download-JSON / delete), and a detail
  view with the latency stacked-area bottleneck breakdown (p50/p99 toggle) + throughput
  + health. Charts adapt to either path's stage set.

Design: design/stats-capture-plan.md. Built and adversarially reviewed via a multi-agent
workflow; workspace build/clippy(-D warnings)/fmt/tests green, OpenAPI no-drift. Not yet
on-glass validated against a live session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:59:39 +00:00
enricobuehler 0a6c9d8852 docs: point Android install at Discord for beta access + add community links
apple / swift (push) Successful in 1m32s
apple / screenshots (push) Successful in 3m26s
android / android (push) Successful in 4m7s
ci / rust (push) Successful in 4m36s
ci / web (push) Successful in 44s
ci / docs-site (push) Successful in 53s
deb / build-publish (push) Successful in 2m18s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 42s
ci / bench (push) Successful in 4m42s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m12s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m8s
docker / deploy-docs (push) Successful in 6s
The Android app is in Google Play Internal Testing, so the public Play Store URL
doesn't resolve for non-testers. Lead the Android install instructions with a
"request a tester invite on Discord" CTA (the Play listing unlocks once a Google
account is added to the test track), and surface the Discord + r/Punktfunk
community links in the README, the docs intro, and the docs-site nav.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:59:25 +00:00
enricobuehler 0eedfb3c1f docs: first-class Linux + Windows positioning + IDD-push differentiator
apple / swift (push) Failing after 0s
apple / screenshots (push) Has been skipped
windows-drivers-provision / provision (push) Successful in 13s
windows-drivers / probe-and-proto (push) Successful in 17s
windows-drivers / driver-build (push) Successful in 1m10s
android / android (push) Successful in 3m19s
ci / web (push) Successful in 39s
ci / docs-site (push) Successful in 53s
windows-host / package (push) Successful in 6m6s
ci / bench (push) Successful in 5m9s
ci / rust (push) Successful in 11m12s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 43s
deb / build-publish (push) Successful in 7m31s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m14s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m12s
release / apple (push) Failing after 1s
docker / deploy-docs (push) Successful in 19s
flatpak / build-publish (push) Successful in 4m43s
Drop the "Linux-first" framing across the README and docs site in favor of
first-class Linux AND Windows hosts, and surface the Windows IDD-push
virtual-display path as a distinct differentiator (punktfunk's own indirect
display driver the host pushes frames into — a real virtual display, no physical
monitor or dummy plug, even on the secure desktop).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:53:02 +00:00
enricobuehler f6490f4c28 fix: complete the docs/→design/ and openapi→api/ rename references
The file moves (docs/ → design/, docs/api/openapi.json → api/openapi.json) landed
in d01a8fd, but the matching reference updates did not — so mgmt.rs's drift-test
`include_str!("../../../docs/api/openapi.json")` pointed at a path that no longer
exists and the host failed to build. This restores it and updates every reference:

  - mgmt.rs include_str! → ../../../api/openapi.json (fixes the build)
  - web/orval.config.ts codegen target, web/Dockerfile, .dockerignore
  - deb/rpm/Arch packaging install paths
  - CLAUDE.md, the .gitea CI workflows, code doc-comments, design-doc cross-links

docs-site route URLs (/docs/...) untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:53:02 +00:00
enricobuehler d01a8fd17a feat(host): HDR Vulkan layer so Vulkan games get HDR on the virtual display
windows-host / package (push) Failing after 4m16s
ci / rust (push) Failing after 4m56s
ci / web (push) Failing after 22s
ci / docs-site (push) Successful in 1m7s
android / android (push) Successful in 9m19s
ci / bench (push) Successful in 4m47s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Failing after 3s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / deploy-docs (push) Has been skipped
deb / build-publish (push) Failing after 6m29s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 7m4s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m17s
apple / swift (push) Successful in 1m13s
apple / screenshots (push) Successful in 5m27s
NVIDIA/AMD Vulkan ICDs refuse to *advertise* an HDR color space for a surface on an
IddCx indirect/virtual display, so Vulkan games (Doom: The Dark Ages, id Tech, Indiana
Jones, …) report "device does not support HDR" — even though Windows HDR, DWM compose,
and the client PQ stream all work, and the ICD happily *accepts + presents* a forced HDR
swapchain there. The whole gap is enumeration; the community (Apollo/Sunshine/VDD) wrote
this off as kernel-side / unfixable.

Add VK_LAYER_PUNKTFUNK_hdr_inject (packaging/windows/pf-vkhdr-layer/): a standalone
cdylib Vulkan implicit layer that appends {A2B10G10R10, HDR10_ST2084} + {RGBA16F, scRGB}
to vkGetPhysicalDeviceSurfaceFormats[2]KHR (no need to hook vkCreateSwapchainKHR — the
ICD doesn't validate the color space there). Self-gated on the surface monitor's actual
advanced-color state (DisplayConfig GET_ADVANCED_COLOR_INFO), so it is a complete no-op
on SDR sessions and real monitors (dedup). Always-on (registry-discovered) so it works
regardless of how a game is launched — env-scoping silently fails for already-running
Steam. Escape hatches: DISABLE_PF_VKHDR, PF_VKHDR_EXCLUDE, and a built-in kernel-anti-
cheat denylist.

The installer builds/signs/stages it and registers it under
HKLM64\SOFTWARE\Khronos\Vulkan\ImplicitLayers (opt-out "Install the HDR Vulkan layer"
task); windows-host CI fmt+clippy-gates it (msvc-only FFI).

Live-validated on the RTX box: Doom: The Dark Ages enables HDR over the pf-vdisplay
virtual display.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:33:20 +00:00
enricobuehler 3e7c9bd059 fix(host): remove unsound unsafe impl Sync for HelperRelay
apple / swift (push) Failing after 0s
release / apple (push) Failing after 0s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 29s
audit / cargo-audit (push) Failing after 1m20s
windows-drivers / driver-build (push) Successful in 1m14s
android / android (push) Failing after 2m5s
ci / web (push) Successful in 46s
ci / docs-site (push) Successful in 1m3s
windows-host / package (push) Successful in 6m46s
ci / bench (push) Successful in 4m34s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m25s
ci / rust (push) Successful in 8m36s
decky / build-publish (push) Successful in 22s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m11s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m37s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m3s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
deb / build-publish (push) Successful in 7m50s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m52s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m33s
flatpak / build-publish (push) Successful in 3m56s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m46s
docker / deploy-docs (push) Successful in 22s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m26s
The one genuine soundness defect the unsafe-proof program surfaced (flagged
SUSPECT in program 3/N). `HelperRelay` holds an `rx: Receiver<RelayAu>`, which is
`!Sync` (std mpsc is single-consumer), so asserting `Sync` claimed more than the
fields support — an `Arc<HelperRelay>` recv'd from two threads would compile and
be UB.

It was never live-exploited, and it turns out `Sync` is also unnecessary: the
relay is a single-owner `mut relay` local in the punktfunk1 two-process mux loop
(recv_timeout/try_recv/request_keyframe all called on the owning thread; no `Arc`,
no `thread::spawn` capturing it). So the fix is simply to delete the impl — the
struct keeps its sound `unsafe impl Send` (needed for the raw `HANDLE` fields),
which is all the code uses.

Box-verified: cargo clippy -p punktfunk-host --features nvenc --target
x86_64-pc-windows-msvc -- -D warnings stays green without the Sync impl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:00:40 +00:00
enricobuehler 7aa787a789 docs(host): prove the last 3 files + crate-root deny (unsafe-proof program 4/N, final)
Completes the unsafe-proof program now that the parallel WIP has landed:

- idd_push.rs (25 sites), nvenc.rs (7), punktfunk1.rs (21): a SAFETY proof on
  every unsafe block — D3D11/DXGI COM (same-device textures, immediate-context
  single-thread, keyed-mutex-held convert), the NVENC SDK table (versioned POD,
  register/map/lock-bitstream pairing), cross-process shm reads (atomic
  magic/generation handshake), and the C-ABI harness (each call cross-checked
  against its abi.rs `# Safety` doc). No SUSPECT (UB) blocks.
- capture.rs / encode.rs: the parent-module deny is restored (their WIP children
  are now proven), and main.rs gains a crate-root
  #![deny(clippy::undocumented_unsafe_blocks)] — the permanent catch-all gate so
  no future unsafe block anywhere in the crate can land without a proof.
- Fixed 4 blocks the agents missed: unsafe blocks nested inside `assert_eq!(...)`
  macro args (the comment-above-statement didn't associate) — hoisted to a `let`.
- rustfmt-canonicalized the Windows files (the agents' SAFETY comments + some
  pre-existing 1.9.0 drift) so `cargo fmt --all --check` is clean.

Verified: cargo clippy -p punktfunk-host --all-targets -- -D warnings AND
cargo fmt -p punktfunk-host --check both green with the crate-root deny active.
Windows cfg(windows) re-verified on the box next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:57:00 +00:00
enricobuehler 3514702d8c feat(windows-host): IDD-push encodes native NV12/P010 (skip NVENC's SM-side CSC)
GPU-contention work (host-latency plan §5.A): the IDD-push output ring now hands
NVENC native YUV instead of RGB, so NVENC skips its internal RGB→YUV colour
conversion on the SM/3D engine the running game saturates.

- idd_push.rs: out_ring is now NV12 (SDR, BT.709 limited) via a D3D11 VIDEO-engine
  BGRA→NV12 VideoConverter (keeps the CSC off the contended 3D/compute engine), or
  P010 (HDR, BT.2020 PQ limited) via the FP16→P010 shader (NVIDIA's VideoProcessor
  can't do RGB→P010). The ring drops its per-slot RTV (textures only), matching the
  WGC YUV ring; converters rebuild on a size/HDR flip.
- nvenc.rs: NV12 input forces bit_depth=8 so an HDR→SDR toggle (or a 10-bit-
  negotiated client on an SDR display) re-inits the session at the matching depth —
  NV12 can't feed a 10-bit session (register_resource rejects it).
- punktfunk1.rs: per-stage latency instrumentation under PUNKTFUNK_PERF
  (cap=try_latest, submit=encode_picture, wait=lock_bitstream µs p50/p99/max) to
  pinpoint where capture→encoded latency goes under GPU saturation.
2026-06-26 09:35:23 +00:00
enricobuehler 327a5fa828 docs(host): prove unsafe blocks in the Windows + cross-platform files + gate them (unsafe-proof program 3/N)
Continues the unsafe-proof program across the Windows/cross-platform host files
(~75 blocks, 21 files), each with a SAFETY proof of the real invariant and a
per-file #![deny(clippy::undocumented_unsafe_blocks)] gate:

  capture/windows: dxgi.rs, wgc_relay.rs, wgc.rs, desktop_watch.rs, composed_flip.rs
                   (windows-rs COM: interface validity, same-D3D11-device textures,
                    immediate-context single-thread, borrowed args outlive the call)
  windows: service.rs (SCM/token/CreateProcessAsUserW/event handles — OwnedHandle
           liveness, no double-close/signal race), win_display, wgc_helper, interactive
  vdisplay/windows: manager.rs, pf_vdisplay.rs (SwDeviceCreate/IddCx/ioctl handle
                    liveness via the OnceLock VDM singleton + OwnedHandle)
  encode/windows: ffmpeg_win.rs (full AVBufferRef refcount audit — balanced, NO leaks,
                  unlike the vaapi sibling), sw.rs
  cross-platform: gamestream/audio.rs (libopus), gamestream/stream.rs (sendmmsg),
                  inject/windows/sendinput.rs, audio/windows/wasapi_mic.rs,
                  session_tuning.rs, vdisplay.rs

Two findings (handled separately):
- wgc_relay.rs `unsafe impl Sync for HelperRelay` is UNSOUND (its mpsc Receiver is
  !Sync) though not live-exploited — marked SUSPECT inline; fix pending box check
  (it touches the in-flight punktfunk1.rs).
- capture.rs / encode.rs (PARENT modules of the WIP idd_push.rs / nvenc.rs) do NOT
  get the file deny yet — it would propagate the lint into the undocumented WIP
  children. The deny lands there once those are documented (after the WIP commits).

Linux-visible parts verified green (cargo clippy -p punktfunk-host --all-targets
-- -D warnings). The cfg(windows) deny gates are box-verified next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:23:25 +00:00
enricobuehler 9777ed7fb3 fix(host/vaapi): plug two AVBufferRef leaks in DmabufInner::open
Surfaced while writing the unsafe-soundness proofs (2/N): both are refcount
leaks (sound — never dangling/double-free — so the SAFETY proofs held, but real
bugs on the persistent punktfunk1-host listener that opens a fresh encoder per
session).

1. Per-session leak: `par->hw_frames_ctx = av_buffer_ref(drm_frames)` created a
   second owned ref. `av_buffersrc_parameters_set` takes its OWN ref of
   `par->hw_frames_ctx`, and `av_free(par)` frees only the struct, not the ref —
   so the extra ref leaked every session, pinning the DRM frames ctx + device.
   Fix: assign `drm_frames` borrowed (the standard ffmpeg pattern); our single
   owned ref lives in DmabufInner and is unref'd in Drop.

2. Error-path leak: the final `open_vaapi_encoder(...)?` returned without the
   unref ladder every other error path runs, leaking graph/drm_frames/
   vaapi_device/drm_device on encoder-open failure. Fix: match + clean up before
   returning (nv12_ctx is borrowed from the sink → freed by graph teardown).

cargo clippy -p punktfunk-host --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:02:54 +00:00
enricobuehler ba68a98873 docs(host): prove every unsafe block in the Linux FFI files + gate them (unsafe-proof program 2/N)
Continues the structural unsafe-proof program (every unsafe carries a documented
proof of soundness; the file gains #![deny(clippy::undocumented_unsafe_blocks)]
so it stays proven). This batch covers all 10 remaining pure-Linux files
(104 blocks), each proof stating the REAL invariant — not boilerplate:

  zerocopy/cuda.rs (26)   leaked process-lifetime libcuda fn-ptr table; opaque
                          CUcontext never dereferenced; free-exactly-once via the
                          Arc<Mutex<PoolInner>> ownership graph; dmabuf fd take/close split
  zerocopy/egl.rs (18)    eglGetProcAddress'd procs with the GL context current;
                          EGLImage liveness; the two-call modifier-query bounds
  zerocopy/vulkan.rs (4)  copy-bounds arithmetic (src_size>=span); Send = thread
                          confinement to the punktfunk-pipewire thread
  dmabuf_fence.rs (4)     poll/ioctl/close fd liveness + ownership
  capture/linux/mod.rs (16)  spa_data repr(transparent) cast; null-checked spa
                          derefs; single-loop-thread buffer ownership until requeue
  inject/linux/gamepad.rs (10)  uinput ioctl request-number ↔ struct-size match
                          (static-asserted); InputEventRaw no-padding for the byte cast
  encode/linux/vaapi.rs (15) + encode/linux/mod.rs (9)  ffmpeg object ownership/
                          free ladders; VAAPI/DRM graph; Send = single-thread transfer
  inject/linux/wlr.rs (2), vdisplay/linux/kwin.rs (1)

No memory-unsafety SUSPECT blocks were found — the unsafe is sound. The vaapi
agent did flag two real AVBufferRef *leaks* (not UB) in DmabufInner::open; marked
inline with NOTE(leak) and addressed in a follow-up.

Verified: cargo clippy -p punktfunk-host --all-targets -- -D warnings is clean
(each file's deny gate hard-errors on any undocumented block).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:00:30 +00:00
enricobuehler 22359f5dc8 docs(host): prove every unsafe block in drm_sync.rs + gate it (unsafe-proof program 1/N)
Start of the structural unsafe-proof program (per the "every unsafe needs a
documented proof of soundness" goal): each `unsafe` block gets an accurate
`// SAFETY:` proof of WHY it is sound, and the file gains
`#![deny(clippy::undocumented_unsafe_blocks)]` so the proof requirement is
permanently enforced (a future undocumented unsafe in this file fails CI).

drm_sync.rs (10 blocks: libc open/ioctl/clock_gettime/close + 3 in tests): each
proof states the real invariant — fd liveness/ownership, the ioctl request number
encoding the matching struct size, the `&mut req` being a live correctly-sized
`#[repr(C)]` struct, and (for the timeline ioctls) the `handles`/`points` arrays
outliving the synchronous call with `count_handles` matching their length.

The gate grows file-by-file (CI stays green; undone files don't carry the lint
yet); it promotes to a crate-root deny once every file is done. ~122 Linux blocks
+ the Windows files remain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 08:35:32 +00:00
enricobuehler 7e9023faad feat(gamestream): launch apps on Windows + Linux non-gamescope hosts
GameStream's apps.json `cmd` is delivered via set_launch_command, which ONLY the Linux
gamescope backend nests. On Windows (no gamescope) and Linux kwin/mutter/wlroots (which
stream the existing desktop) the command was silently dropped. Now, after capture is live,
stream.rs spawns it via library::launch_gamestream_command for those backends — Windows:
into the interactive USER session (spawn_in_active_session, since the host is SYSTEM);
Linux: a plain `sh -c` spawn into the host's own graphical session so the app lands on the
streamed (primary) output. Linux gamescope keeps nesting via set_launch_command and is
skipped here to avoid a double launch. The command is operator-typed apps.json (trusted),
never client-set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 08:12:53 +00:00
enricobuehler 5acc12d9e9 feat(library): shared cover-art warmer + cache (GOG + Xbox art)
A disk-backed art cache (library-art-cache.json in the canonical host config dir) is the
source of truth read by all_games(), so the library list + launch-resolve never block on
the network. A host-lifetime background warmer (start_art_warmer, started in serve())
fetches uncached art OFF the hot path: GOG via the public no-auth api.gog.com product API,
Xbox via the unofficial no-auth displaycatalog (keyed by StoreId). Both best-effort
(protocol-relative URLs normalized to https; results cached even when empty so they aren't
re-fetched). The GOG + Xbox providers now read cached_art() (title-only until warmed).

Cross-platform (ureq blocking HTTP — no tokio on this path) so the fetch/parse code is
compiled + checked everywhere; a host whose stores all self-provide art (Steam CDN /
Heroic CDN / Lutris data: URLs) does no fetching. Dep: ureq (webpki roots, no system certs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 08:00:31 +00:00
enricobuehler aed0bf0c2a feat(library): Windows Xbox / Game Pass store provider
XboxProvider scans each fixed drive's <drive>:\XboxGames for GDK games (presence of
Content\MicrosoftGame.config marks a game vs. an ordinary UWP app), parsing title /
Identity name / Executable Id / StoreId via roxmltree. The PackageFamilyName is READ
from the AppRepository\Packages\<PackageFullName> dir name (reduced to Name_Hash) —
never computed from the publisher. Launch via the AUMID (shell:AppsFolder\<PFN>!<AppId>)
through explorer in the interactive user session (UWP activation needs the user token,
which spawn_in_active_session already provides). Cover art (displaycatalog) is deferred
→ title-only. Known v1 gaps: custom .GamingRoot install folders + non-GDK pure-UWP Store
games (under the ACL-locked WindowsApps) aren't enumerated.

New windows_launch_for `aumid` arm; XboxProvider wired into all_games() under cfg(windows).
Dep: roxmltree (Windows). Windows unit tests cover MicrosoftGame.config parsing (incl. the
ms-resource title fallback), the PackageFullName→PFN reduction, and the aumid launch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:49:03 +00:00
enricobuehler b65745284e feat(library): Windows Epic + GOG store providers
EpicProvider reads the launcher's local .item manifests under %ProgramData% (no auth,
launcher need not run) with Playnite's exclusion filter (skip UE_* components +
non-launchable addons + dead install dirs); cover art from the base64 catcache.bin
(public Epic CDN, best-effort). Launch via the com.epicgames.launcher:// URI opened
through explorer.exe — the namespace:catalogItemId:appName triple, with a bare-appName
fallback so a launch is never dropped.

GogProvider enumerates HKLM\SOFTWARE\WOW6432Node\GOG.com\Games (winreg) + each
goggame-<id>.info primary FileTask into a direct-exe spawn (no Galaxy, dodges its
cold-start/anti-cheat). GOG cover art (public api.gog.com) is deferred — it needs an
HTTP fetch + cache off the hot all_games() path — so GOG is title-only for now.

windows_launch_for gains epic/gog arms; both providers wired into all_games() under
cfg(windows). Deps: base64 moved to the cross-platform table (Epic catcache decode +
Lutris art encode both need it); winreg added on the Windows target. Windows unit tests
cover the Epic exclusion filter + URI builder and the GOG spawn + play-task parsing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:37:30 +00:00
enricobuehler 8ca695eb4c docs(windows-host): SCM event redesign done + runtime-validated (D2 complete)
The service.rs STOP/SESSION events are now OnceLock<OwnedHandle> (61c02e6) — the
last host-side raw-handle smuggle retired. Runtime-validated on the RTX box: swap
in, sc start -> RUNNING, sc stop -> clean STOPPED in ~1s, original restored. D2
(OwnedHandle/RAII rollout) is complete; only the deferred host P0 lints remain in
Goal 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:28:29 +00:00
enricobuehler 61c02e695e refactor(windows-host): OwnedHandle for the SCM STOP/SESSION events (Goal-3, last unsafe reduction)
The service's STOP/SESSION manual-reset events were smuggled across the C SCM
control-handler boundary as raw `isize` in `AtomicIsize` statics (the handler is a
capture-free `'static` closure, so it can't hold a non-`Send` `HANDLE` — it has to
reach the events through statics), reconstructed via `load_event`, and explicitly
`CloseHandle`d at `run_service` end.

Replace the raw-`isize` statics with `OnceLock<OwnedHandle>`:
- `run_service` creates each event, wraps it in an `OwnedHandle`, derives a borrowed
  `HANDLE` for `supervise` (unchanged signature), and `set`s the OnceLock (once per
  process) — all BEFORE the handler is registered, so the handler always sees `Some`.
- The handler reads `event_handle(&STOP_EVENT)` (a borrow) and `SetEvent`s it, with a
  defensive `None` guard (matches the old `SetEvent(HANDLE(0))` no-op if it ever fired
  pre-init).
- The events are owned by the OnceLocks for the process lifetime (the service process
  exits right after `run_service` returns, so the OS reaps them at exit). Dropping the
  explicit `CloseHandle` also removes the latent close-then-signal window the old
  statics had (the raw isize lingered after the close).

Deletes the `AtomicIsize`/`Ordering` import + `load_event` + the raw-isize smuggle —
the last host-side raw-handle reduction. Behaviour-preserving (same events, same
signal/wait/reset, same once-per-process init order). Linux check + fmt clean; the
file is #[cfg(windows)] → to be box-validated (compile + a service stop/restart).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:22:46 +00:00
enricobuehler 203ad8069d fix(web): library badge shows the actual store, not always "Steam"
The GameCard badge hard-coded steam-vs-custom, so any non-Steam non-custom store
rendered with the "Steam" label. Add storeLabel(store): steam/custom keep their
localized strings, every other store is shown as a capitalized proper noun — so the
new Lutris/Heroic providers (and future ones) surface correctly with no per-store
translation. tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:22:28 +00:00
enricobuehler 5f8c6b6147 feat(library): Lutris + Heroic store providers (Linux)
LutrisProvider reads the local pga.db (rusqlite, read-only/immutable so a running
Lutris can't block us) → installed games, launch via `lutris lutris:rungameid/<id>`,
cover art from Lutris's on-disk cache inlined as data: URLs (no public CDN keyed by a
stable id, unlike Steam/Heroic). HeroicProvider parses Heroic's store_cache JSON —
legendary/gog/nile = Epic+GOG+Amazon in one provider — installed-only with an
install-dir existence cross-check (works around Heroic's gog is_installed bug #2691),
free public CDN cover art, launch via `heroic --no-gui heroic://launch?...` (the
single-instance-Electron gamescope-escape caveat is documented; needs live confirm).

New command_for arms (lutris_id digits-guard, heroic runner+appName-guard) + both
providers wired into all_games(); everything Linux-gated (the launchers are
Linux-only), so the Windows/macOS host build is unaffected. Deps rusqlite (bundled
SQLite, no system dep) + base64 added to the Linux target only. Unit tests with
sqlite/json fixtures (installed-only filtering, CDN-art mapping, launch guards); live
`library` enumeration returns [] gracefully on a box without the launchers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:20:58 +00:00
enricobuehler cd3368fc71 docs(windows-host): KeyedMutexGuard done + record the on-glass build validation
Goal 3: the IDD-push hot-loop KeyedMutexGuard (6585643) landed, and the whole
session's Windows + driver work is now ON-GLASS BUILD-VALIDATED on the RTX box —
host clippy -D warnings clean + driver build clean (the gate that surfaced + got
11 lints fixed in bd05bc8). Only the deferred host P0 lints + the deliberately-
left service.rs SCM-handler event smuggling remain, plus an optional latency A/B.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:16:23 +00:00
enricobuehler bd05bc8c30 fix(windows): clippy/build cleanups the on-glass build surfaced (-D warnings)
Built the host crate (`cargo clippy --features nvenc -D warnings`) and the driver
workspace (`cargo build`) on the RTX box — the project's intended Windows gate,
which `cargo check` (what the goal1/§2.5 work used) never runs. It surfaced lint
issues accumulated across the goal1 / §2.5 / this-session Windows work:

- 9× redundant `as *mut c_void` after `.as_raw_handle()` (already `*mut c_void`):
  idd_push.rs (3, this session), service.rs (3, this session), manager.rs (3,
  pre-existing §2.5 — my OwnedHandle work copied the idiom). Removed the casts +
  the now-unused `use std::ffi::c_void` in idd_push.rs / manager.rs (service still
  uses it).
- `if_same_then_else` in session_plan.rs::resolve_topology (pre-existing goal1
  stage 3): collapsed the two `false` arms into one condition (behavior identical).
- `unused_unsafe` in the driver `pod_init!` macro: it expands at call sites already
  inside an `unsafe` block, where its own `unsafe` is redundant — `#[allow(
  unused_unsafe)]` (needed at the non-unsafe sites, redundant at the nested ones).

After these, BOTH builds are clean on the box — validating the whole session's
blind Windows + driver work compiles + passes clippy on real hardware.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:15:00 +00:00
enricobuehler 658564353c refactor(windows-host): KeyedMutexGuard RAII for the IDD-push consume hot loop (Goal-3, hw-validated)
The IDD-push consume loop acquired the slot's keyed mutex by hand
(`AcquireSync(0,8)` … work … `ReleaseSync(0)`), with a comment warning that a
`?`-return between acquire and release would leak the lock and stall the driver
on that slot — the reason the HDR converter is built *before* the acquire.

Replace with a `KeyedMutexGuard` RAII (acquire → `ReleaseSync` on drop), scoped
to JUST the convert/copy block so the lock releases at the EXACT same point as
before (the driver gets the slot back immediately; not held across the rest of
`try_consume`). Now the release can't be skipped on any early return/panic — the
leak footgun is gone by construction, and the hot loop has no raw `ReleaseSync`.

Behavior/latency-equivalent (same acquire params, same release point). Windows-
only (CI + on-glass gated); to be validated on the RTX box (host clippy build +
a PERF=1 latency A/B vs the shipping binary — the change should show no delta).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:02:05 +00:00
enricobuehler 6b3cbce120 wip: host latency/GPU-contention notes + Windows packaging tweaks
Pre-existing working-tree changes committed to the branch on request: the
gpu-contention investigation doc, host-latency-plan additions, and small
pack-host-installer / stage-pf-vdisplay packaging-script edits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:53:09 +00:00
enricobuehler 739fa74e68 docs(library): game-store provider design (Xbox/Epic/EA, Heroic/Lutris, …)
Web-researched + adversarially-verified design for extending library.rs with more
store providers: the LibraryProvider extension point, the two cross-cutting pieces
(Windows interactive-session launch wiring + a layered artwork strategy), new
LaunchSpec kinds, per-store enumeration/launch/art recipes with priority/effort/
confidence, a phased plan, and the verification corrections.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:53:09 +00:00
enricobuehler c87ca577a3 feat(windows-host): launch the chosen library title into the interactive session
Make the no-op Windows `set_launch_command` real. New `windows/interactive.rs`
`spawn_in_active_session` (WTSGetActiveConsoleSessionId → WTSQueryUserToken →
CreateProcessAsUserW(winsta0\default) under the LOGGED-IN USER token, factored from
the wgc_relay primitive) + `library::launch_title` resolving a store-qualified id to
a concrete process via `windows_launch_for` (steam_appid → Steam.exe/explorer.exe
steam:// URI; command → cmd.exe /c). Threaded as `SessionContext.launch` into both
native data-plane paths (`virtual_stream`, `virtual_stream_relay`) and fired after
capture is live so the title renders onto the captured desktop and grabs foreground.

Security invariant intact: the client sends only the store-qualified id; the host
resolves the recipe from its own library and the URI/flags are handed to a concrete
EXE as plain args (never cmd /c of a client string). Linux unchanged (gamescope
nesting via the handshake PUNKTFUNK_GAMESCOPE_APP path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:51:10 +00:00
enricobuehler e68b7330ae docs(windows-host): record the shared gamepad RAII reduction (e5c2b4e)
Goal 3 scorecard + §4 P2: the OwnedHandle/RAII rollout now covers the three
gamepad backends via the shared inject/windows/gamepad_raii.rs (Shm + SwDevice).
Scratched the IOCTL-dispatcher item (control.rs's read_input/write_output_complete
are already generic — would be churn, not reduction). The only remaining unsafe
reductions are the deliberately-left service.rs SCM-handler event smuggling and
the on-glass-gated KeyedMutexGuard hot-loop RAII.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:38:19 +00:00
enricobuehler e5c2b4e7f5 refactor(windows-host): shared Shm/SwDevice RAII for the 3 gamepad backends (Goal-3 unsafe reduction)
The DualSense, DualShock 4, and XUSB Windows pad backends each hand-rolled the
SAME per-pad resource handling: a `CreateFileMappingW` + `MapViewOfFile` shared
section (with the permissive D:(A;;GA;;;WD) SDDL the restricted-token driver
needs) and an identical `Drop` doing `SwDeviceClose` + `UnmapViewOfFile` +
`CloseHandle` — three copies, each a chance to drift or leak on an error path.

New `inject/windows/gamepad_raii.rs` owns both resources with RAII:
- `Shm` — the section handle (`OwnedHandle`) + its view; `Shm::create(name, size)`
  does the SDDL + map + zero-fill leak-safely, `base()` gives the mapped pointer,
  `Drop` unmaps then closes (in that order).
- `SwDevice` — the `SwDeviceCreate`'d devnode; `Drop` calls `SwDeviceClose`.

All three backends now hold `_sw: Option<SwDevice>` + `shm: Shm` instead of raw
`hsw`/`map`/`view`, access the section via `self.shm.base()`, and have NO manual
`Drop`. Deletes the duplicated `create_shm_section` (DualSense/DS4 now use
`Shm::create`) and the three hand-written Drops; the DS4 device-type byte is still
written before the magic, the SwDeviceCreate `None` fallback still works, and the
field drop order (devnode removed, then section unmapped+closed) matches the old
manual order.

Net: 3 manual `Drop`s + a duplicated section-creation path → one shared RAII
module; fewer unsafe ops, leak-on-error fixed by construction. Linux `cargo check`
clean (the inject mod wiring); the backends are #[cfg(windows)] → CI-gated.
Drafted + adversarially verified (no double-free, imports correct under
-D warnings, behavior preserved); my own spot-checks confirm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:36:57 +00:00
enricobuehler 7ad3a57e68 fix theme 2026-06-26 06:20:21 +00:00
enricobuehler 22bef1fd0a docs(windows-host): record the Goal-3 unsafe reductions (OwnedHandle rollout + pod_init!)
Scorecard Goal 3 + §4 P2: the OwnedHandle RAII rollout (idd_push 011607e — also a
view-leak fix; service child/job 4c95ba7) and the driver pod_init! macro (bf57704,
27→1) landed. Recorded the remaining items (service SCM-handler event smuggling,
driver IOCTL-dispatch / KeyedMutexGuard levers, the deferred D1-host lint sweep)
and that ThreadBound was skipped as not-a-clean-win.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:02:06 +00:00
enricobuehler bf577044f1 refactor(windows-drivers): pod_init! macro — 27 unsafe { mem::zeroed() } POD inits -> 1 (Goal-3 #3)
The driver zero-initialised C POD structs (IddCx/WDF descriptors) with 27
scattered `let mut x: T = unsafe { core::mem::zeroed() };`, each carrying its own
`// SAFETY` about the all-zero bit pattern being valid + the caller setting `.Size`
etc. right after.

Replace with one `pod_init!(T)` macro (in log.rs, reachable everywhere via the
existing `#[macro_use] mod log;` — same mechanism as `dbglog!`) that owns the
single `unsafe { zeroed::<T>() }` + the SAFETY rationale. All 27 sites
(adapter 6, callbacks 3, entry 4, monitor 10, swap_chain_processor 4) now read
`let mut x = pod_init!(T)`. Zero behavior change (mem::zeroed semantics identical);
the type is passed explicitly so no inference depends on the removed annotation.

27 `unsafe` blocks → 1. Driver still `deny(unsafe_op_in_unsafe_fn)`-clean (the
macro expands to an explicit `unsafe {}`; the one nested-in-user-unsafe site is
fine — no `unused_unsafe` for macro-generated blocks). Driver-only (CI-gated);
adversarially reviewed (macro scoping, all sites, no leftover raw zeroed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:01:02 +00:00
enricobuehler 4c95ba72a3 refactor(windows-host): OwnedHandle for the service child + job handles (Goal-3 unsafe reduction #2)
The SCM supervisor scattered manual `CloseHandle(pi.hProcess)`/`(pi.hThread)`
across ~5 supervise-loop match arms and hand-closed the job object — easy to miss
an arm (leak) or double-close.

- `spawn_host` returns an owned `Child { process: OwnedHandle, _thread: OwnedHandle,
  pid }` instead of raw `PROCESS_INFORMATION`; the supervise loop borrows
  `child.process` (`HANDLE(as_raw_handle() as *mut c_void)`) for wait/Terminate and
  the `Child` auto-closes both handles when it drops / is replaced each iteration.
- The job object → `OwnedHandle` (borrowed for AssignProcessToJobObject), auto-closed.
- Deletes ~9 manual `CloseHandle` calls. The `_thread` handle is RAII-only (`_`-prefixed
  so `dead_code`/`-D warnings` doesn't flag it).

Deliberately LEFT the `STOP_EVENT`/`SESSION_EVENT` `AtomicIsize` statics as-is — they
are smuggled into the C SCM control handler, so `OwnedHandle`-ifying them is a separate,
riskier supervisor redesign out of scope here (noted in a comment).

Behavior preserved (the supervise state machine / wait semantics / restart-on-
session-change / kill-on-close are unchanged). Windows-only (CI-gated); adversarially
reviewed (no double-close, handles outlive their borrows, idiom matches manager.rs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:01:02 +00:00
enricobuehler 011607ec10 refactor(windows-host): RAII for IDD-push handles/views — fix a leak (Goal-3 unsafe reduction #1)
The IDD-push capturer held raw `HANDLE`s for the shared header mapping, the
frame-ready event, the debug section, and each ring slot's shared texture, with
manual `CloseHandle` scattered across two `Drop` impls — and the MapViewOfFile
VIEWS (header/dbg_block) were never UnmapViewOfFile'd (a real view leak).

- New `MappedSection { handle: OwnedHandle, view }` RAII: `Drop` UnmapViewOfFile's
  the view THEN the `OwnedHandle` closes the mapping (unmap-before-close).
- `map`+`header` → `section: MappedSection` (+ a cached `header` ptr borrowing into
  it, declared after `section` for drop order); same for `dbg_map`+`dbg_block`.
- `event: HANDLE` → `OwnedHandle` (borrowed as `HANDLE(as_raw_handle() as *mut
  c_void)` for WaitForSingleObject); `HostSlot.shared` → `OwnedHandle` (its manual
  `Drop` deleted). Removed the manual `CloseHandle`s + the `CloseHandle` import.

Net: deletes two `Drop` impls' worth of manual handle/view teardown and fixes the
view leak — fewer unsafe ops, RAII-correct. Behavior preserved (recreate_ring
writes the header in place; the keepalive still drops last so REMOVE is last).
Windows-only (CI-gated); adversarially reviewed (no double-free / UAF / dangling
header; handle interop matches manager.rs). Linux check unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:01:02 +00:00
enricobuehler 803573b4ec improve web ui 2026-06-26 05:43:34 +00:00
enricobuehler 00cf51d610 refactor: rename pf-vdisplay-proto -> pf-driver-proto (it spans all drivers)
The shared host<->driver ABI crate already contains more than the virtual
display: the IDD-push frame ring + control plane AND the gamepad shared-memory
layouts (XusbShm / PadShm). "pf-vdisplay-proto" was a misnomer — the name now
represents all the drivers it serves.

Mechanical rename, no behavior change:
- git mv crates/pf-vdisplay-proto -> crates/pf-driver-proto (package name +
  path-deps in the host crate and the driver workspace).
- pf_vdisplay_proto -> pf_driver_proto across host + driver Rust, both Cargo.lock
  files, the workspace members, the CI path triggers (windows-drivers.yml), and
  the docs/INF comments. The runtime Global\pfvd-* shared-object names are a
  SEPARATE contract and are deliberately untouched (host<->driver name matching).
- The pf-vdisplay DRIVER crate + its INF service name (Root\pf_vdisplay,
  UmdfService=pf_vdisplay, pf_vdisplay.dll) are unchanged — only the full
  `pf_vdisplay_proto` token was replaced, never the `pf_vdisplay` driver name.

Linux-verified: cargo test -p pf-driver-proto (const size-asserts compile) +
cargo clippy -p punktfunk-host -D warnings clean; Cargo.lock regenerated. The
driver-workspace side (path-dep + imports + its Cargo.lock) is Windows-CI-gated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:38:21 +00:00
enricobuehler 84a3b95f17 refactor(windows-host): delete the SudoVDA backend — pf-vdisplay is the sole vdisplay (Goal 2)
Goal 2 ("drop every trace of SudoVDA") is done. The SudoVDA driver is no longer
shipped (only pf-vdisplay; the old vdisplay-driver tree was deleted in a2bd0cd),
and F1 (d638a93/e60cda3) already moved the display-utility helpers out of the
backend into neutral modules (win_adapter/win_display), breaking the reach-in.
So the backend is now cleanly removable:

- Deleted crates/punktfunk-host/src/vdisplay/windows/sudovda.rs (350 lines: the
  SudoVdaDisplay VirtualDisplay impl + its VdisplayDriver/probe).
- vdisplay::open()/probe() are now unconditional pf-vdisplay; deleted the
  windows_use_pf_vdisplay() backend selector. open() now ensure!s
  pf_vdisplay::is_available() with a clear "driver not installed" error instead
  of the old silent SudoVDA fallback (no fallback driver exists anymore).
- Scrubbed the dangling references to the deleted symbols (manager/sendinput/dxgi
  comments, the config + host.env PUNKTFUNK_VDISPLAY docs); the var stays as an
  informational forward-seam. Updated the F1 module docs (Goal 2 now done).

All changes are #[cfg(windows)] except the config doc; Linux clippy
-p punktfunk-host -D warnings clean; zero `sudovda::`/`SudoVdaDisplay` code refs
remain (comments only). Windows build is CI-gated.

Scorecard Goal 2 -> DONE; recorded the E1 "do NOT do it" stability decision in
windows-host-rewrite.md §4 (the process-global driver design is sound given
ProcessSharingDisabled; a device-owned variant adds a use-after-free window for
no gain).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:36:10 +00:00
enricobuehler 8cde8621ce fix(windows-drivers): reclaim pf-vdisplay monitor ids on REMOVE (P1, slot-reclaim)
The driver assigned each virtual monitor a monotonically-increasing NEXT_ID used
as the EDID serial / IddCx ConnectorIndex / container GUID, and never reclaimed
it on REMOVE. Under sustained ADD/REMOVE churn the connector index kept climbing,
so IddCx/PnP allocated a NEW OS target slot every cycle and orphaned the old one
(ghost "Generic Monitor (punktfunk)" nodes) until the adapter's target capacity
was exhausted and ADD failed 0x80070490 ERROR_NOT_FOUND.

Fix: `create_monitor` now allocates the LOWEST free id (`alloc_monitor_id`,
computed under the MONITOR_MODES lock with the push) instead of a counter, so a
departed monitor's id is reclaimed and a fresh ADD reuses its target slot rather
than orphaning it. With <= N live monitors the id stays bounded to 1..=N+1.
Deleted the now-unused NEXT_ID + AtomicU32/Ordering import.

CI-compile-gated only — the wedge reproduces solely under sustained churn on the
RTX box, so this needs an on-glass reconnect-storm A/B to confirm (box is
ephemeral/down). Marked on-glass-pending in windows-host-rewrite.md §4; keep
reset-pf-vdisplay.ps1 as the recovery until validated. NOT to be relied on (or
merged to main) until that A/B passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:11:36 +00:00
enricobuehler 0bf3984614 feat(windows-host): IDD-push is the default capture path for fresh installs (P1)
Make the validated IDD-push zero-copy path the default for a fresh install,
without penalising dev / non-pf-driver runs:

- The shipped default config now enables it. Both seed sites set
  `PUNKTFUNK_VDISPLAY=pf` + `PUNKTFUNK_IDD_PUSH=1`: the hardcoded default the
  service writes on `service install` (`ensure_default_host_env`) AND the
  `host.env.example` template the installer bundles. A fresh install therefore
  runs the validated path (the installer also bundles the pf-vdisplay driver);
  it falls back to DDA if the driver can't attach.
- `idd_push` is now **value-aware** instead of a bare presence flag, so an
  operator can turn it OFF with `PUNKTFUNK_IDD_PUSH=0` in host.env — a `var_os`
  presence check read `=0` as "on". Unset still ⇒ off (the code default is
  unchanged, so existing host.env files and dev/CI runs are unaffected; only the
  shipped default config opts in).

Also scrubbed the stale "SudoVDA" wording in host.env.example. Linux cargo
clippy -p punktfunk-host -D warnings clean; the service.rs default string is
Windows-only (CI-gated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:08:45 +00:00
enricobuehler 75ee53d1dd feat(web): Storybook for offline UI design + light theme + brand spinner
Stand up Storybook so the management console can be designed without a running
host, plus the design-system work that surfaced along the way.

Storybook (@storybook/react-vite):
- Slim Start/Nitro-free vite config; the preview imports the app's real
  src/styles.css directly so the design tokens stay single-sourced (no mirror).
- Stories for the @unom/ui primitives (Button/Card/Inputs/Badge), brand marks,
  the AppShell (throwaway in-memory TanStack router), and every data-driven page
  (Dashboard/Host/Clients/Library/Settings) rendered offline via a window.fetch
  stub + typed fixtures. The route page components are exported so stories can
  render them.

Light theme:
- styles.css now carries a light :root (lavender, from the docs palette) with the
  existing violet chrome moved to .dark; the live console still pins html.dark by
  default, so this only adds the option (Storybook's toolbar toggles it).
- Fixes a stray `*/` inside a comment that prematurely closed it and silently
  broke Tailwind's @theme processing.

Spinner:
- The punktfunk lens recreated with motion/react: two circles surge through one
  another in depth (JS perspective scale + z-index — robust where mix-blend-mode
  flattens CSS preserve-3d) with a screen-blend lens highlight. Replaces the
  skeleton loading state in QueryState; removes ui/skeleton.tsx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:58:36 +00:00
enricobuehler 0255a8289c docs(windows-host): consolidate 5 scattered docs into one current source of truth
The Windows-host docs were scattered across a design plan, a staged-refactor
plan, an audit, an audit-remediation tracker, and a game-capture-bug analysis —
several badly stale (the audit/remediation predate the Goal-1 branch landing and
call DONE items "not started"). Verified the true state of every audit finding /
goal / milestone against current code+git (4-agent workflow), then rewrote
windows-host-rewrite.md as ONE consolidated, accurate doc:

- §1 Status scorecard (Goals 1-3, M0-M6, GB1, audit P0/P1/P2) with DONE/PARTIAL/
  OPEN + commit evidence.
- §2 Architecture as-built (layering, HostConfig→SessionPlan→SessionContext, the
  VirtualDisplayManager ownership model, IDD-push-primary capture incl. secure
  desktop + GB1 recovery, encode/EncoderCaps, pf-vdisplay-proto, the driver,
  service/packaging).
- §3 Validated invariants (the jewels).
- §4 Prioritized open tasks (the genuine remaining work).
- §5 Operations (RTX-box recipe, CI, env, build).
- §6 Deep reference (/INTEGRITYCHECK answer, the 6 iddcx bindgen knobs, the driver
  port checklist, resolved decisions).

Deleted the four now-redundant docs (content folded in; history in git):
windows-host-goal1-plan.md, windows-host-rewrite-audit.md,
windows-host-rewrite-remediation.md, windows-host-rewrite-game-capture-bug.md.
Repointed the 6 code/proto/driver doc-comment refs that targeted them at the
consolidated windows-host-rewrite.md sections. Linux cargo check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:57:23 +00:00
enricobuehler 6bed5d9e8e docs(windows-rewrite): secure desktop validated on glass — mark M3 done, retire the biggest risk
Owner-confirmed on glass (2026-06-25, "works great"): the IDD-push primary path
captures the lock/UAC secure desktop AND input reaches the streamed console
session. This was the single biggest open risk — the whole capture strategy
(Decision B: IDD-push primary for everything incl. secure desktop, WGC/DDA
demoted) rested on it. Now proven, not asserted.

- §15: M3 row → DONE (secure desktop); removed the secure-desktop gate from
  "What genuinely remains" (renumbered); added it to "Resolved since §11".
- §11 "IDD-push input + secure desktop" open item → RESOLVED.
- §14 critique "SINGLE BIGGEST RISK: the secure-desktop claim" → RESOLVED.

The WGC-relay / secure-DDA path is no longer load-bearing — kept only as a
non-IddCx-hardware fallback. Remaining rewrite work is migration/cleanup (M4
gamepad drivers, M5/M6, slot-reclaim), none blocking the validated path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:42:25 +00:00
enricobuehler 48202a0f89 docs(windows-rewrite): mark game-capture bug FIXED + bring rewrite status current (§15)
The fullscreen-game-breaks-IDD-push bug is FIXED by the resolution-listening
recovery (c87bfe0: the 250ms poll now follows the display's actual resolution
and recreates the ring on any descriptor change, recover-or-drop), backed by
open-time first-frame DDA failover (f98ab07) and the driver publish() width/
height guard + flushed logging (789ad49). No protocol bump was needed — the host
reads the real resolution straight from Windows (CCD/GDI), so the bug doc's
Stage-1 composing capturer + Stage-2 protocol bump were unnecessary. Bug doc
marked FIXED with a Resolution section; the staged plan kept as superseded record.

windows-host-rewrite.md: the progress log was stale (ended at "M1 cont."). Added
§15 Current status — the driver STEP 0-8 port landed on main on-glass HDR-
validated; the host was refactored *in place* via windows-host-goal1 (not the §10
greenfield rebuild); §2.5 ownership model resolved the swap-chain-reuse / monitor-
leak open item; iddcx + /INTEGRITYCHECK CI-green. Remaining: the secure-desktop
on-glass gate (the single biggest unproven claim), M4 gamepad-driver migration,
M5/M6 cleanup, and the pf-vdisplay slot-reclaim driver fix. Top Status flipped
proposed → largely implemented.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:35:55 +00:00
enricobuehler bf57aa4000 docs(windows-host-goal1): Stage 5 tightening 3 (EncoderCaps) DONE; refresh Remaining
The Goal-1 host refactor is now functionally complete — all 6 stages, §2.5, and
all three Stage-5 seam-trait tightenings have landed (EncoderCaps = 0ccd0fe).
Remaining is non-blocking: the optional namespace collapse (decision: skip —
pure churn), the merge to main (confirm with the user — outward-facing), and the
pf-vdisplay slot-reclaim driver fix (reassigned to windows-host-rewrite.md, the
greenfield driver rewrite, alongside the fullscreen-game capture bug).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:28:30 +00:00
enricobuehler 0ccd0fe676 feat(windows-host): EncoderCaps — query RFI/HDR-SEI caps (Goal-1 stage 5, tightening 3)
The last §2.3 seam-trait tightening: give `Encoder` a `caps() -> EncoderCaps`
so the session glue routes by *query* instead of relying on the no-op/`false`
defaults of `invalidate_ref_frames`/`set_hdr_meta`.

`EncoderCaps { supports_rfi, supports_hdr_metadata }` is a cheap `Copy` struct.
The trait gains a default `caps()` returning `EncoderCaps::default()` (all
false) — correct for every SDR/libavcodec backend (Linux NVENC, VAAPI, AMF/QSV,
software openh264), so they need no change. Only the Windows direct-NVENC path
(`NvencD3d11Encoder`) overrides it, reporting the real `rfi_supported` (probed
once at open via `nvEncGetEncodeCaps`) and `hdr` (HDR-SEI on keyframes).

Consumer: the GameStream encode loop (`gamestream/stream.rs`) hoists
`supports_rfi` once before the loop and gates the loss-recovery path on it —
`!(supports_rfi && enc.invalidate_ref_frames(..))` forces a keyframe directly
on non-RFI encoders instead of making an always-`false` call every loss event.
Behaviour-preserving (same keyframe/RFI outcome), one fewer no-op call, intent
explicit. The native host (punktfunk1) uses FEC+keyframes, no RFI consumer.

Linux `cargo clippy -p punktfunk-host --all-targets -D warnings` clean; the
three edited files are rustfmt-clean. The NVENC override is Windows-only
(1:1 with the existing impl style) → CI/on-glass gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:27:20 +00:00
enricobuehler e1ca2e4d3c docs(windows-host-goal1): record §2.5 done + on-glass results + Remaining list
The plan tracker referenced "§2.5 — see below" but had no §2.5 section and no "what's left". Add:
  * a Status banner (all 6 stages + §2.5 done; branch not merged),
  * the §2.5 section — the 3-step ownership-model rewrite (VirtualDisplayManager/MonitorLease,
    the deleted globals), the CURRENT_MON_GEN-write-only finding, and the on-glass reconnect-leak
    result (the vdm-init-order panic found+fixed, 0 leaks, IDD-push zero-copy verified),
  * a "Remaining (next session)" list: EncoderCaps, optional namespace collapse, merge to main, and
    the pf-vdisplay driver slot-reclaim fix (driver WIP, not the host refactor) with the dev scripts.
Mark §2.5 IMPLEMENTED in the design doc (windows-host-rewrite.md) with the write-only-gen deviation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:04:48 +00:00
enricobuehler e119aa50e9 feat(windows-packaging): dev-iteration scripts — reset + redeploy pf-vdisplay driver
Today's manual driver recovery (wedged under ADD/REMOVE churn → ERROR_NOT_FOUND) and the manual
host-stop/install/host-start dance around drivers/deploy-dev.ps1 are now two scripts:

  * reset-pf-vdisplay.ps1   — recover a wedged driver: stop host → pnputil /remove-device the ghost
                              "Generic Monitor (punktfunk)" nodes → Disable+Enable the adapter
                              (Restart-PnpDevice doesn't exist on the box PS) → start host. No reboot
                              (the box boots to Proxmox). -Verify probes to confirm ADD recovered.
  * redeploy-pf-vdisplay.ps1 — one-shot dev redeploy wrapping deploy-dev.ps1 with the host stop/start
                              (the running host holds the driver DLL) + a post-install adapter reload
                              (pnputil updates the store but the live device keeps the old binary).

Both standalone (don't touch deploy-dev.ps1). README gains a "Dev iteration on the test box" section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:48:32 +00:00
enricobuehler 683c81be03 fix(windows-host): §2.5 — open the backend before the IDD-push preempt (vdm() init order)
On-glass caught a runtime panic the box compile couldn't: `VirtualDisplayManager used before a
backend initialised it`. Step 3 put the preempt (`vdm().begin_idd_setup`) BEFORE
`vdisplay::open` in virtual_stream, but vdisplay::open is what constructs the backend that calls
manager::init() — so vdm() was reached before init and panicked on the first IDD-push session.
(The old IDD_SETUP_LOCK/IDD_SESSION_STOP globals needed no init, so the prior ordering was fine.)

Fix: open the backend first (it does no monitor work — just constructs the marker + opens the
control device, initialising the manager), THEN run the preempt, THEN build the pipeline (which
creates the monitor). The preempt still precedes this session's monitor creation, so the
semantics are unchanged. Validates why §2.5 needs the on-glass gate, not just the compile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:06:41 +00:00
enricobuehler fe61597d92 refactor(windows-host): §2.5 step 3 — isolate the IDD-push preempt into the manager
The last two virtual-display globals lived in punktfunk1: IDD_SETUP_LOCK (serialize IDD-push
setup against a reconnect flood) + IDD_SESSION_STOP (the prior session's stop flag, signalled +
waited-on so a reconnect preempts the stale session cleanly). Both move onto VirtualDisplayManager
as fields, behind one `vdm().begin_idd_setup(stop)` method that locks the setup gate, registers
this session's stop while signalling the prior one, waits for the monitor to release, and hands
back the setup guard the session holds across the pipeline build. punktfunk1 no longer reaches
into vdisplay internals for the preempt — it just calls the manager and holds the guard.

Behaviour-identical (same lock/signal/wait order, same guard lifetime). Completes §2.5's
"delete the smeared globals": CURRENT_MON_GEN/MON_GEN/MGR x2/IDD_PERSIST/IDD_SETUP_LOCK/
IDD_SESSION_STOP are all gone, replaced by the one OnceLock VirtualDisplayManager with a typed
OwnedHandle device. Box build to follow; on-glass reconnect-leak test pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:58:02 +00:00
enricobuehler d9b8b88a42 refactor(windows-host): §2.5 step 2 — unify both backends behind VirtualDisplayManager (OnceLock)
The two Windows virtual-display backends (sudovda + pf_vdisplay) carried VERBATIM-DUPLICATED
~250-line Idle/Active/Lingering refcount state machines in two `MGR: Mutex<Mgr>` globals, each
smuggling the control HANDLE across the pinger/linger threads as a raw `isize` (HANDLE is !Send).

New `vdisplay/windows/manager.rs`: one host-lifetime `VirtualDisplayManager` (OnceLock singleton,
user-approved) owns the earned state machine + the linger timer + a TYPED `Arc<OwnedHandle>`
control device (the raw-isize smuggle is gone — OwnedHandle is Send+Sync and also CloseHandle's
the device on drop, fixing a latent leak). The only backend-specific code left is the IOCTL
surface behind a small `VdisplayDriver` trait (open/add_monitor/remove_monitor/ping) + the
per-monitor REMOVE key (`MonitorKey::Guid` for sudovda, `::Session(u64)` for pf-vdisplay). The
render-adapter pin decision, the GDI/CCD glue (crate::win_display), and the gen-stamped
MonitorLease are backend-neutral and live once in the manager.

  * sudovda.rs / pf_vdisplay.rs: shrink to a `VdisplayDriver` impl + a thin `VirtualDisplay`
    wrapper (new() -> manager::init(driver); create() -> manager::vdm().acquire(mode)). Their
    IOCTL ops + structs + open_device stay in place (no transcription).
  * MON_GEN -> a manager field; the preempt's wait_for_monitor_released moves onto the manager
    (punktfunk1 calls vdm().wait_for_monitor_released). MonitorLease.drop -> vdm().release(gen),
    with the stale-lease no-op preserved verbatim.

Behaviour-preserving: the state machine (acquire/release/reconfigure/teardown/linger/preempt) is
the canonical sudovda copy with the IOCTLs routed through the driver seam. Box build to follow
(Windows-only; Linux check is a no-op for these files).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:52:22 +00:00
enricobuehler 15202011c1 refactor(windows-host): §2.5 step 1 — delete the dead/write-only monitor-lifecycle code
Removes the cruft the §2.5 ownership-model rewrite would otherwise carry forward, and corrects a
false invariant the docs described:

  * CURRENT_MON_GEN (sudovda) — the "current monitor generation" global was WRITE-ONLY. It was
    stored on every mgr_acquire (both backends) but its only reader, idd_push's `my_gen`, was set
    and NEVER read. The "session capturer re-checks the monitor gen each frame and bails on a
    reconnect" behaviour the doc describes was never wired — per-frame staleness is the SEPARATE
    ring FrameToken.generation / IDD_GENERATION mechanism (which works and is untouched). So the
    monitor-gen-via-WinCaptureTarget carry the design proposed is unnecessary. Deleted the static,
    its stores in both backends, the pf_vdisplay import, and idd_push's dead `my_gen` field/read.
    (MON_GEN — the lease-generation counter behind the stale-lease no-op — is REAL and kept.)

  * IDD_PERSIST + open_or_reuse + IddReuseHandle (idd_push) — a persistent-capturer reuse path
    from an early prototype, defined but with ZERO callers across the crate. Deleted, plus the now
    -orphaned `use std::sync::Mutex` and the now-dead `set_client_10bit` setter.

Windows-only; grep confirms no remaining references to any deleted symbol. Box build to follow.
First of the incremental §2.5 steps (user-approved OnceLock VirtualDisplayManager design).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:26:17 +00:00
enricobuehler 05e87e6ab0 chore(windows-host): fix two stale file-path comments after the stage-6 move
capture/dxgi.rs -> capture/windows/dxgi.rs, inject/gamepad_windows.rs -> inject/windows/gamepad_windows.rs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:55:46 +00:00
enricobuehler 38c68c33e5 refactor(windows-host): confine platform code under windows/ + linux/ folders (Goal-1 stage 6)
Move 36 platform-specific files into per-module `windows/` and `linux/` subfolders (and the
shared HID codecs into `inject/proto/`):
  capture/{windows,linux}/  encode/{windows,linux}/  inject/{windows,linux,proto}/
  audio/{windows,linux}/  vdisplay/{windows,linux}/
  src/windows/ (service, wgc_helper, win_adapter, win_display)
  src/linux/  (dmabuf_fence, drm_sync, zerocopy/)

Done with `#[path]`, NOT a module rename: every file moves into its folder while the
`crate::*::*` module names stay FLAT, so all caller paths and every internal `super::`/`crate::`
reference are unchanged — only the parent `mod` decls gained `#[path = "..."]`. This is the
codebase's existing pattern (inject's gamepad_windows) and makes the move byte-identical in
behaviour with ZERO reference churn, far lower risk than collapsing to a single
`crate::capture::windows::` namespace (that deeper rename is an optional follow-on; this delivers
the cfg-sprawl folder confinement the stage is about). Done LAST, after the semantic stages, so
the path churn didn't fight them.

Verified: Linux cargo check + clippy (-D warnings) clean; my mod-decl changes fmt-clean (the 3
remaining fmt diffs are pre-existing local-rustfmt-version skew that moved with their files); all
36 `#[path]` targets exist; no internal `#[path]`/`include!`/file-child-mod in any moved file
(the inline `mod X {` blocks are self-contained). Box build to follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:53:45 +00:00
enricobuehler a0427cd2a3 feat(windows-host): OutputFormat into the capturer — kill the dxgi back-reference (Goal-1 stage 5, tightening 1)
The headline §2.3 seam tightening (the explicit Stage-3 deferral; §5's "highest-severity
coupling"): the capturer is now TOLD its output format instead of re-deriving the encode backend.

New `capture::OutputFormat { gpu, hdr }`, resolved once per session and passed INTO
capture_virtual_output:
  * native punktfunk/1 path: `SessionPlan::output_format()` (gpu = encoder.is_gpu(), from the
    already-resolved plan.encoder — no second probe; hdr = plan.hdr).
  * GameStream + spike paths: `OutputFormat::resolve(hdr)` (gpu from the single `gpu_encode()`
    source, which maps windows_resolved_backend()).

`capture/dxgi.rs DuplCapturer::open` takes `gpu` in and its internal
`!matches!(windows_resolved_backend(), Software)` recompute is DELETED — the capture layer no
longer re-calls the encode layer (the back-reference that could let capture and encode disagree
on whether frames are GPU-resident, plan §2.3/§5). The relay's secure-desktop DDA passes
`gpu_encode()` likewise.

Behavior-preserving: the `gpu` passed in equals the value the capturer used to compute (same
encode-backend resolution). The DDA opens keep `want_hdr=false` (the SDR fallback, unchanged).

Tightenings 2 (HDR/release -> VirtualLease) and 3 (EncoderCaps) split off: (2) needs the
monitor-generation carried on the lease + the keepalive becoming Box<dyn VirtualLease> — that's
the §2.5 ownership-model change (CURRENT_MON_GEN / sudovda::wait_for_monitor_released), so it
moves there; (3) is a small additive follow-on. Documented in the plan.

Verified: Linux cargo check + clippy (-D warnings) + fmt clean. Box build to follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:37:48 +00:00
enricobuehler a4c85af155 feat(windows-host): SessionContext — bundle the 13-arg session entry (Goal-1 stage 4)
Bundle the 13-positional-argument `#[allow(too_many_arguments)]` session entry (virtual_stream
AND virtual_stream_relay) into one owned SessionContext struct, moved into the stream thread.
The reconfig/keyframe receivers move IN (virtual_stream is their only consumer), retiring the
&Receiver borrow plumbing. Behavior-identical by construction: each function destructures the
context into the same local names at the top, so the ~400-line loop bodies are byte-for-byte
unchanged. Both `#[allow(too_many_arguments)]` attrs removed.

Scoped deliberately: the plan's SessionFactory.build() owning a `vdm.lease -> open_capturer ->
open_encoder -> spawn` RAII chain with Session::drop as the ONLY teardown is coupled to §2.5's
ownership-model rewrite — it needs a host-side VirtualDisplayManager/MonitorLease that doesn't
exist yet (the lifecycle still lives in CURRENT_MON_GEN/IDD_SETUP_LOCK globals + the
per-compositor vdisplay backends). The current teardown is ALREADY drop-based (the capturer owns
the keepalive whose Drop releases the monitor — "restore displays before REMOVE" lives there;
only send_thread.join() is explicit) and is the validated shipping path, so wrapping the deployed
reconfig/switch/rebuild loop in a Session::drop for a behavior-preserving change would add real
regression risk for marginal gain. The SessionFactory/Session::drop/vdm.lease work folds into
§2.5; this stage delivers the concrete, safe arg-bundling.

Verified: Linux cargo check + clippy (-D warnings) + fmt clean. Box build to follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:23:57 +00:00
enricobuehler 9ba90d4b77 docs(windows-host-goal1): Stage 3 DONE — on-glass validated (SessionPlan resolves correctly; A/B vs shipping proves the env-only no-frame is not a regression)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:10:49 +00:00
enricobuehler 5358ef9fee docs(windows-host-goal1): record Stage 3 box build green (cargo check --features nvenc clean on the RTX box)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:55:42 +00:00
enricobuehler 0a63154293 feat(windows-host): SessionPlan — resolve capture/topology/encoder once per session (Goal-1 stage 3)
New src/session_plan.rs: a Copy `SessionPlan { capture, topology, encoder, bit_depth, hdr }`
resolved ONCE from HostConfig (+ the negotiated bit_depth) at the top of `virtual_stream`,
logged, and threaded through build_pipeline_with_retry/build_pipeline. The three scattered
Windows dispatch points now read this one typed artifact instead of re-deriving from config
(plan §2.4, the "capture and encode disagree on the backend" hazard):

  * capture: capture::capture_virtual_output takes a CaptureBackend IN (was re-reading
    config().idd_push / capture_backend / no_wgc internally). CaptureBackend::resolve() is the
    single resolver, shared with the GameStream + spike call sites.
  * topology: virtual_stream reads plan.topology; should_use_helper is deleted (its body is
    session_plan::resolve_topology, verbatim). The IDD-push reconnect-preempt guard reads
    plan.capture too.
  * encoder: recorded as EncoderBackend from encode::windows_resolved_backend (config-backed +
    GPU-vendor cached since stage 2 -> already a single source). Threading encoder/input_format
    into the encoder+capturer opens (which removes the capture->windows_resolved_backend()
    back-reference recomputed in dxgi.rs) is stage 5.

Behavior-preserving by construction: each resolved decision is provably equivalent to the
pre-stage-3 reads (same config() + the same cached running_as_system()/GPU-vendor probes), so
old==new. SessionPlan is platform-neutral so it threads the shared virtual_stream/build_pipeline
signatures; on Linux it resolves to the single portal/single-process path.

Also fixes a pre-existing mod-ordering fmt drift in main.rs (mod config; / mod capture;).

Verified: Linux cargo check + clippy (-D warnings) + fmt clean on the touched files. Box build
(Windows compile) + on-glass (NVENC + IDD-push + mode switch) pending on the RTX box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:47:48 +00:00
enricobuehler e5057f6cc1 feat(windows-host): finish HostConfig migration — resolve operator/dispatch knobs once (Goal-1 stage 2)
Migrate 31 genuinely-constant operator/dispatch env::var sites onto HostConfig, so the
capture/topology/encoder decision reads ONE owner instead of being recomputed at each call
site (the latent bug where capture and encode could disagree on the resolved backend, plan §2.4):
idd_push x7, no_wgc, capture_backend, render_adapter, encoder_pref (Linux open_video +
linux_zero_copy_is_vaapi), the Windows vdisplay-backend select, plus the plan-named
secure_dda/idd_depth/zerocopy/ten_bit and the multi-site perf x4 / compositor x5 /
video_source x3 / gamepad. Each HostConfig field's parser is byte-identical to the read it
replaced, so old==new by construction (the plan's "a flipped bool is a silent regression" guard).

Scope correction — the plan's "~64 sites / Linux XDG+compositor included / grep env::var -> 0"
was unsafe as written. Two classes are deliberately KEPT as live reads and documented in config.rs:

  * Runtime-mutated session vars. vdisplay::apply_session_env REWRITES the process env on every
    connect (the Bazzite Gaming<->Desktop follow): WAYLAND_DISPLAY, XDG_CURRENT_DESKTOP,
    XDG_RUNTIME_DIR, DBUS_SESSION_BUS_ADDRESS, and the derived PUNKTFUNK_INPUT_BACKEND,
    GAMESCOPE_SESSION/NODE, KWIN/MUTTER_VIRTUAL_PRIMARY, FORCE_SHM. Parsing these once would
    freeze them at startup and silently break session-following — they are NOT constant.
  * Single-use local tuning with no resolve-once benefit (and FEC_PCT even has two different
    semantics): FEC_PCT, VIDEO_DROP, VBV_FRAMES, SPLIT_ENCODE, PACE_BURST_KB, the dxgi timing
    knobs, the *_LIVE/test gates, plus path/dynamic reads (config-dir, PATH search,
    env-forward-to-child). PUNKTFUNK_ZEROCOPY is split on purpose: Windows presence-semantics
    moved to the field; Linux keeps its own truthy (1|true|yes|on) parser.

Verified: Linux cargo check + clippy (-D warnings) + fmt clean on the touched files. The
Windows-only edits are 1:1 substitutions; they get a real Windows compile on the box with Stage 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:24:00 +00:00
enricobuehler a3eefc2374 feat(windows-host): HostConfig foundation + staged Goal-1 roadmap (Goal-1 stage 1)
config.rs: typed HostConfig parsed ONCE from env (idd_push/encoder_pref/no_helper/force_helper), replacing per-call env::var re-reads (PUNKTFUNK_ENCODER was re-read on EVERY windows_resolved_backend() call; PUNKTFUNK_IDD_PUSH is read 8x across the host — the recompute that lets capture + encode disagree on the backend, plan §2.4). Migrated the two highest-churn dispatch reads onto it (encode::windows_resolved_backend, punktfunk1::should_use_helper). Behavior-identical: the env is constant for the process lifetime (the service loads host.env before launch), so a lazily-parsed global == parsed-once-at-startup.

docs/windows-host-goal1-plan.md: the ORDERED, independently-shippable execution plan for Goal-1 (the plan's biggest unstarted goal — a from-scratch layered host architecture). Six behavior-preserving, box-verified stages (HostConfig -> SessionPlan -> SessionContext/SessionFactory -> seam-trait tightenings -> src/windows tree), because the host is live-validated and a monolithic rewrite would strand it broken. Stage 1 done here; stages 3-5 rewire the deployed path and require on-glass re-test.

Verified: Linux + box (--features nvenc) cargo check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:02:16 +00:00
enricobuehler cd591514ad feat(windows-drivers): EvtCleanupCallback + single-identity dedup; document state ownership (E1)
EvtCleanupCallback on the WDFDEVICE (entry.rs + callbacks::device_cleanup): on device removal (PnP/unload) drop every monitor's swap-chain worker via monitor::cleanup_for_device_removal (joins threads, IddCx-free — the framework tears the monitors down with the device). Worker threads no longer linger into teardown.

Single identity per session (create_monitor): a re-ADD of a still-live session_id departs the stale monitor first, so one session maps to exactly one monitor (no duplicate EDID/target).

DeviceContext-owned state (audit §2.5): documented decision NOT to migrate the globals to a Box/AtomicPtr device-owned allocation. The IddCx monitor/mode DDIs receive only an IddCx handle (never the WDFDEVICE/context), so the state MUST be globally reachable (upstream virtual-display-rs is a process-static for the same reason); the globals are already module-encapsulated; and with one devnode + UmdfHostProcessSharing=ProcessSharingDisabled they die with the host process on removal anyway. A pointer variant would only add a host-gone-watchdog-race use-after-free for zero benefit.

Verified: driver workspace builds clean on the RTX box (.173).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:48:23 +00:00
enricobuehler a2bd0cd77c refactor(windows-packaging): delete the superseded vdisplay-driver/ tree (M6)
The old all-Rust IddCx driver tree (packaging/windows/vdisplay-driver/ — the wdf-umdf-sys 'oracle', 7896 lines) is fully superseded by packaging/windows/drivers/ (wdk-sys / windows-drivers-rs + the owned pf-vdisplay-proto ABI), which is the source of the vendored + installed driver. It was in NO cargo workspace (never built) and NO CI workflow; only stale doc/script refs pointed at it (the confusion the audit + game-capture-bug doc both flagged).

Delete it + repoint the build-relevant refs (packaging/windows/README.md, stage-pf-vdisplay.ps1, pack-host-installer.ps1) at drivers/ + drivers/deploy-dev.ps1. The vendored driver (packaging/windows/pf-vdisplay/) is unaffected; docs/windows-virtual-display-rust-port.md keeps its historical mentions as narrative.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:37:00 +00:00
enricobuehler 48f980ebb1 feat(packaging): deploy-dev.ps1 for the new-tree pf-vdisplay driver
Build/sign/install script for the wdk-sys/windows-drivers-rs driver in packaging/windows/drivers/ (the new tree lacked one). Like the old vdisplay-driver/deploy-dev.ps1 but adds the FORCE_INTEGRITY clear (this tree links /INTEGRITYCHECK) and a 9.9.MMdd.HHmm DriverVer (the vendored build is 9.5.*). Verified: deployed the rebuilt driver to the RTX box (.173).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:09:27 +00:00
enricobuehler 1cd87066d7 docs(windows-rewrite): track GB1/GB3 progress + box IP floats (DHCP)
Record GB1 (host-side recover-or-drop) + GB3 groundwork (driver descriptor guard/logging) in the tracker; note the RTX validation box IP floats (DHCP/ephemeral, recently .173/.158) instead of hardcoding .158.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:35:27 +00:00
enricobuehler 789ad49bc4 feat(windows-drivers): publish() descriptor guard + log appender (game-capture GB3 groundwork)
publish() now guards width/height alongside format (CopyResource needs matching DIMS too, else garbage): drops a surface whose descriptor no longer matches the host ring (a fullscreen game mode-set the display) AND logs the actual descriptor once per mismatch episode, so a repro shows exactly what changed (GB1/Stage-0 diagnostic + the Stage-2 width/height guard).

log.rs: a process-lifetime, flushed, Mutex-shared append handle (opened ONCE) replaces the per-call open/append — so the swap-chain WORKER thread's lines land. They were hidden (per-call open raced the control thread / could fail under the worker's restricted token), which is exactly why a game-break repro showed no swap-chain-processor lines (bug doc S3). This is the observability foundation the bug doc gates Stage S (S1/S2 driver resilience) on.

Needs a driver rebuild + re-vendor to deploy (separate from the GB1 host-only fix). Stage 3 (trim default_modes) deprioritized: GB1 recovers from mode-sets, and trimming risks the live display-activation path.

Verified: driver workspace builds clean on the RTX box (.173).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:33:11 +00:00
enricobuehler c87bfe0e7b feat(windows-host): IDD-push recovers from a game mode-set, else drops (game-capture bug GB1)
The bug: a fullscreen game mode-sets the virtual display (format/size); the driver's publish() guard then drops every frame; the host's ring — fixed at the session-negotiated mode — never adapts -> frozen picture, then black on reconnect.

RECOVER (no DDA, per the chosen design): the ring now TRACKS the display's actual mode. At open it is sized to the display's actual resolution (new win_display::active_resolution, CCD/GDI) — so reconnecting while a game holds a different mode just works. Mid-session, the 250ms poll (was HDR-only) now also follows the active resolution; on any descriptor change (size or HDR) it recreates the ring at the new mode (recreate_ring generalized to a new size) -> the driver re-attaches -> frames resume at the game's mode. No freeze, no reconnect needed.

DROP if unrecoverable: a descriptor change starts a recovery clock (recovering_since); if no fresh frame resumes within 3s (e.g. an exclusive-flip the host can't follow), try_consume bails -> the session ends cleanly -> the client reconnects, instead of freezing forever. A pure idle desktop (no mode change) never triggers this.

Verified: host clippy (nvenc) clean on the RTX box. NEEDS ON-GLASS (Doom repro on .158): confirm the poll sees the mode-set, the ring recreates + recovers, the encoder+client adapt to the size change; tune the 3s window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:12:48 +00:00
enricobuehler f98ab07dd6 feat(windows-host): IDD-push first-frame failover to DDA (game-capture bug GB1 pt1)
wait_for_attach now requires the driver to publish a FIRST frame, not just attach (DRV_STATUS_OPENED). A fullscreen game can leave the virtual display in a format/size the driver's publish() guard rejects -> the driver ATTACHES but silently drops every frame; previously the host sailed past open() and only died on next_frame's 20s deadline (the 'reconnect = black + working audio' symptom). Now open() fails -> capture.rs falls back to DDA (reusing the C1 fallback) -> the game is captured + visible after a reconnect.

Safe at open: the OS composites the freshly-activated virtual display, so a frame arrives within ~1s — a normal/idle open isn't false-failed; only a genuinely-broken display (no frame in 4s) falls back (and DDA is a working path, so even a false-positive degrades gracefully).

GB1 Stage 1a (docs/windows-host-rewrite-game-capture-bug.md P3). The mid-session-without-reconnect live failover (composing capturer) is the next piece.

Verified: host clippy (nvenc) clean on the RTX box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:50:12 +00:00
enricobuehler dbab1f98ba docs(windows-rewrite): track the fullscreen-game capture bug as a related workstream
Cross-reference docs/windows-host-rewrite-game-capture-bug.md from the remediation tracker, with the intersections that matter for whoever implements it: Stage 1 builds on (doesn't duplicate) our C1 mid-/open-time fallback; the bug doc is written against pre-remediation main (a11b0dd) so its line refs are stale; Stage 2's new SharedHeader fields must update A's offset asserts (in lib.rs frame mod); Stage 0/S3 diagnostics need the driver log B3 gated off in release; S1/S2 is adjacent to E1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:40:16 +00:00
enricobuehler 5d279f8886 docs(windows-rewrite): audit-remediation hand-off tracker
Living progress/hand-off doc (docs/windows-host-rewrite-remediation.md): the 9 committed remediation commits with audit refs + how each was verified, the remaining tasks (D2, D1-host, E1, G) with scope / on-glass-gating / verification notes, the box verification recipe, and the new modules introduced. Cross-linked from the audit doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:30:43 +00:00
enricobuehler e60cda3939 refactor(windows-host): move CCD/HDR display helpers to a neutral module — F1 complete (audit §9)
Moved the remaining 6 SudoVDA reach-in helpers + SavedConfig (resolve_gdi_name, set_advanced_color, advanced_color_enabled, set_active_mode, isolate/restore_displays_ccd) verbatim from vdisplay::sudovda into a backend-neutral crate::win_display module (the plan's windows/display_ccd.rs). The capturers (idd_push/dxgi/wgc), pf_vdisplay, and punktfunk1 now depend on these as PEERS via crate::win_display instead of reaching into the SudoVDA backend.

With win_adapter (F1 pt1), all 7 reach-in helpers are now neutral — the circular reach-in is broken, so SudoVDA can eventually be deleted (Goal 2) without losing the display utilities. sudovda re-exports the ones it still uses internally; its now-unused CCD/GDI imports were removed.

Verified: host clippy (nvenc) clean on the RTX box; Linux check clean (the new modules are #[cfg(windows)]).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:26:25 +00:00
enricobuehler d638a93e04 refactor(windows-host): move resolve_render_adapter_luid to a neutral module (audit §9 / F1 pt 1)
The discrete-render-GPU LUID picker was display-utility living in the SudoVDA backend; moved it verbatim to a backend-neutral crate::win_adapter module (the plan's windows/adapter.rs). The IDD-push capturer + pf-vdisplay backend now depend on it as a PEER instead of reaching into vdisplay::sudovda — the first step in breaking the circular reach-in so SudoVDA can eventually be dropped (Goal 2). sudovda re-exports it for its own callers.

Remaining F1 increments: the CCD/HDR helpers (resolve_gdi_name, set_advanced_color, advanced_color_enabled, set_active_mode, isolate/restore_displays_ccd) → a neutral win_display module.

Verified: host clippy (nvenc) clean on the RTX box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:33:23 +00:00
enricobuehler a755d6eab7 chore(windows-drivers): deny(unsafe_op_in_unsafe_fn) on the driver crates (audit §8 P0)
Lock in the explicit-unsafe-block discipline so a fn-level 'unsafe' never silently blesses its whole body (the per-site // SAFETY: comments already landed in STEP 8). Builds clean on the RTX box — no fallout. The host-wide unsafe-lint sweep + clippy::undocumented_unsafe_blocks (hundreds of blocks across Linux+Windows) are a larger dedicated follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:19:38 +00:00
enricobuehler b0d28380b5 feat(windows-host): rotate out-ring on repeat + size HDR ring at open (audit §5.3/§5.4)
§5.3 (C3): repeat_last() now copies the last frame into a FRESH rotated out-ring slot instead of re-handing last_present's slot, so a repeat (static desktop) never re-hands a slot still encoding under pipeline_depth>1. OUT_RING(3) > max depth(2) keeps the rotated slot free — the out-ring rotation contract now holds for repeats too, not just the synchronous-loop assumption.

§5.4 (C4): when enabling advanced color for a 10-bit client, trust set_advanced_color success and size the ring FP16 directly, instead of racing the advanced_color_enabled poll (which could size SDR while the driver composes FP16 -> format mismatch -> an immediate ring recreate + dropped first frames).

Verified: host clippy (nvenc) clean on the RTX box. On-glass to confirm: HDR-client first-frame + static-desktop pipelining.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:18:05 +00:00
enricobuehler ed583650a6 feat(windows-host): IDD-push attach fallback to DDA, not the 20s black bail (audit §5.1)
open() now hands the keepalive BACK on failure (the WGC attach_keepalive pattern) so the caller can fall back instead of tearing the virtual display down. Added a bounded wait_for_attach() that polls the driver's DRV_STATUS_OPENED — it checks ATTACH status, not frame arrival, so it never false-fails on an idle desktop that has composed no frame yet.

An attach failure (e.g. a hybrid-GPU render mismatch -> DRV_STATUS_TEX_FAIL, or the driver never opening the ring within 4s) now fails open() -> capture.rs falls back to DDA, instead of next_frame's 20s deadline leaving the session black. Pairs with the driver SET_RENDER_ADAPTER fix (0a7ae5e).

Verified: host clippy (nvenc) clean on the RTX box. Behavioral validation (fallback trigger + happy-path attach timing) needs an on-glass session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:09:28 +00:00
enricobuehler e5c9ee8327 feat(windows-host): activate render-adapter pin; gamepad SHM from proto (audit §4.2h/§6.1)
§4.2h (C2): the host already pins the discrete GPU via IOCTL_SET_RENDER_ADAPTER on the IDD-push path; now that the pf-vdisplay driver implements it (0a7ae5e), correct the stale 'driver returns STATUS_NOT_IMPLEMENTED / STEP-4 stub' comments. Hybrid iGPU+dGPU boxes now actually pin the NVENC GPU.

§6.1 (C5): switch the host gamepad SHM consumers (inject/{dualsense,gamepad}_windows.rs) to derive size/offsets/magic/name from pf_vdisplay_proto::gamepad::{PadShm,XusbShm} via offset_of!/size_of!/helpers, instead of hand-literal OFF_*/140 — proto is now the single source of truth (driver-side switch follows with the gamepad-driver unification). The DualShock4 backend reuses the same pub(super) consts unchanged.

Verified: host clippy (nvenc) clean on the RTX box (x86_64-pc-windows-msvc).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:02:22 +00:00
enricobuehler 0a7ae5ef09 feat(windows-drivers): host-gone watchdog, SET_RENDER_ADAPTER, log gate, mode bounds
Audit §4.1: implement the host-gone watchdog — it was dead code (WATCHDOG_PINGS bumped but never sampled, no thread). Every IOCTL now bumps a liveness counter; a watchdog thread reap_orphaned()s monitors (created_at grace) if no IOCTL arrives within WATCHDOG_TIMEOUT_S, so a crashed/TerminateProcess'd host no longer leaves its virtual monitor + swap-chain worker + pooled D3D device wedged until the next CLEAR_ALL. Removes the false 'watchdog thread' comments.

Audit §4.2: implement SET_RENDER_ADAPTER (was STATUS_NOT_IMPLEMENTED) via IddCxAdapterSetRenderAdapter, so the host can pin the IDD render to the NVENC GPU on a hybrid iGPU+dGPU box (else the OS-picked iGPU makes the host ring textures un-openable -> DRV_STATUS_TEX_FAIL).

Audit §4.4: gate the world-writable C:\Users\Public\pfvd-driver.log behind debug builds / PFVD_DEBUG_LOG (a release build never writes it).

Audit §4.5: bounds-check the requested mode in IOCTL_ADD; compute display_info clock_rate in u64 + saturate (the old u32 refresh*(h+4)^2 overflowed/aborted the mode DDI for large modes).

Verified: driver workspace builds clean on the RTX box (WDK 26100 + LLVM 21.1.2, MSVC). On-glass functional validation of the watchdog/render-pin is a follow-up (needs a driver reinstall + session).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:49:49 +00:00
enricobuehler 95dcef3515 fix(pf-vdisplay-proto): offset asserts + own the gamepad SHM layouts (audit §6.1/§6.2)
§6.2: add offset_of! asserts to SharedHeader/AddReply/control structs so a same-size field reorder is a compile error, not silent corruption (size+Pod alone miss it).

§6.1: add XusbShm (64B) + PadShm (256B, incl device_type@140) layouts + Global\ name helpers + magics to the proto crate as the single source of truth, with offset asserts pinned to the shipped wire layout — kills the hand-duplicated literal-140 host/driver drift hazard. Enables bytemuck min_const_generics for the >32-byte reserved tails. Host + driver consumers switch in a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:39:42 +00:00
enricobuehler 0badc17d87 docs(windows-rewrite): audit the IDD-push rewrite against its plan
Driver track (M0+M1, STEPs 0-7) landed and is on-glass-validated, but the host-side goals (clean architecture, SudoVDA removal, unsafe reduction) and several driver-spec items (host-gone watchdog, SET_RENDER_ADAPTER, ownership model) are not yet done. Full findings + a prioritized P0-P2 fix list in the doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:39:42 +00:00
enricobuehler a11b0dd3c7 feat(windows-drivers): STEP 8 (3/n) — re-vendor the installer driver from the new wdk-sys tree
apple / swift (push) Successful in 1m9s
apple / screenshots (push) Failing after 1m49s
windows-host / package (push) Successful in 5m12s
ci / rust (push) Successful in 1m22s
ci / web (push) Successful in 47s
android / android (push) Successful in 3m12s
ci / docs-site (push) Successful in 53s
deb / build-publish (push) Successful in 2m48s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m50s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m29s
docker / deploy-docs (push) Successful in 5s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m14s
The installer's vendored driver binary (packaging/windows/pf-vdisplay/) was STALE — built from the OLD
oracle tree (packaging/windows/vdisplay-driver/, wdf-umdf, SudoVDA-compat GUID), so it was
ABI-mismatched with the host (which opens the owned proto GUID 70667664). Re-vendor it from the NEW
drivers/ tree so the rewrite's ACTUAL driver is what the installer ships.

Built RELEASE on the RTX box from the new tree + the new .inx: cargo build --release -p pf-vdisplay ->
FORCE_INTEGRITY clear -> stampinf (DriverVer 06/25/2026,9.5.0625.1614, > the old 06/22) -> Inf2Cat
/os:10_X64 -> signtool sign the .cat with punktfunk-ds-test (.cat sig Valid). Replaces the stale
.dll/.inf/.cat; the .cer is unchanged (same cert).

ON-GLASS VALIDATED (install-test): pnputil /add-driver /install the release package -> clean WUDFHost
reload -> Status=OK, init_adapter -> IddCxAdapterInitAsync -> 0x0 (FP16 accepted),
IddCxMonitorCreate(id=1) -> 0x0. The shipping installer now installs + loads the real wdk-sys
proto-GUID driver, FP16/HDR-capable, monitor-create working.

Remaining STEP 8 (recorded in memory, deferred): re-point the stale "built from vdisplay-driver/"
comments in stage-pf-vdisplay.ps1 / pack-host-installer.ps1 / packaging README; selector default ->
pf-vdisplay unconditional; CI build-sign-or-stale-vendored drift guard; then DELETE the oracle tree.
KEEP sudovda.rs (runtime fallback + the backend-neutral CCD helpers pf_vdisplay.rs reuses) and the
WGC-relay/DDA secure path (the secure-desktop lock/UAC gate is not yet proven on glass for IDD-push).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:18:48 +00:00
enricobuehler 3b21d8ecf8 feat(windows-drivers): STEP 8 (2/n) — give the new pf-vdisplay tree its own .inx
apple / swift (push) Successful in 1m12s
windows-drivers / probe-and-proto (push) Successful in 18s
windows-drivers / driver-build (push) Successful in 1m8s
apple / screenshots (push) Failing after 2m56s
windows-host / package (push) Successful in 5m16s
ci / rust (push) Successful in 1m22s
ci / web (push) Successful in 47s
android / android (push) Successful in 3m39s
ci / docs-site (push) Successful in 51s
deb / build-publish (push) Successful in 2m38s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m42s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m23s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m18s
docker / deploy-docs (push) Successful in 17s
The new wdk-sys driver tree (packaging/windows/drivers/pf-vdisplay/) had no INF — it borrowed the
oracle's (packaging/windows/vdisplay-driver/.../pf_vdisplay.inx), which blocked deleting the oracle.
Port it verbatim: the proto-vs-SudoVDA control GUID is registered in CODE
(WdfDeviceCreateDeviceInterface), so the INF is GUID-agnostic and identical — HWID Root\pf_vdisplay,
UmdfExtensions=IddCx0102, the control-device security DACL, UpperFilters=IndirectKmd,
UmdfHostProcessSharing=ProcessSharingDisabled. Prerequisite for the STEP-8 re-vendor (build ->
stampinf -> Inf2Cat -> sign the .dll/.cat from the NEW tree into packaging/windows/pf-vdisplay/,
replacing the stale oracle-built binary) and for deleting the oracle tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:03:26 +00:00
enricobuehler 83d3d6384a refactor(windows-drivers): STEP 8 (1/n) — unsafe-reduction pass (per-site // SAFETY)
windows-drivers / probe-and-proto (push) Successful in 19s
apple / swift (push) Successful in 1m7s
ci / rust (push) Successful in 1m14s
windows-drivers / driver-build (push) Successful in 1m8s
ci / web (push) Successful in 40s
ci / docs-site (push) Successful in 1m1s
android / android (push) Successful in 3m13s
apple / screenshots (push) Successful in 3m14s
deb / build-publish (push) Successful in 2m38s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
windows-host / package (push) Successful in 5m18s
ci / bench (push) Successful in 4m35s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m26s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m16s
docker / deploy-docs (push) Successful in 31s
Audit pass over the new pf-vdisplay driver's unsafe surface: 92 per-site // SAFETY comments added
across adapter.rs / monitor.rs / entry.rs / callbacks.rs / swap_chain_processor.rs /
frame_transport.rs / direct_3d_device.rs (control.rs already had full coverage). COMMENTS ONLY — zero
logic, signature, or control-flow change (verified via git diff: every added line is a // SAFETY
comment or blank).

The dominant gap was the pervasive `core::mem::zeroed()` FFI-struct builds (IDDCX_*/WDF_*/
DISPLAYCONFIG_* C PODs whose all-zero bit pattern is a valid uninitialized/Invalid state, with the
required .Size/fields set immediately after) — each now carries a one-line // SAFETY. Plus explicit
notes on the two stack/local-pointer-into-FFI hazards (adapter.rs `version` ptr into
IddCxAdapterInitAsync; monitor.rs `edid` Vec ptr into IddCxMonitorCreate — both read synchronously
before the local drops) and the frame_transport.rs raw-HANDLE / mapped-header derefs + cleanup paths.
The already-justified Send/Sync wrappers (SendAdapter, CtxTypeInfo/DevCtxInfo, MonitorObject,
Sendable, FramePublisher) were audited — each already carried a // SAFETY. No site needed a code
change.

First slice of STEP 8 (the SudoVDA drop). Comments-only ⇒ build-neutral; windows-drivers.yml verifies
on the next runner build. Remaining STEP 8: re-vendor the installer's driver binary from the new
drivers/ tree (the shipping packaging/windows/pf-vdisplay/ binary is still built from the OLD oracle
tree with the SudoVDA-compat GUID — ABI-mismatched with the host's proto GUID), add an .inx to the
new tree, re-point scripts/README from vdisplay-driver/ to drivers/, flip the selector default to
pf-vdisplay, then delete the old oracle tree. Keep sudovda.rs (the runtime fallback + the
backend-neutral CCD helpers pf_vdisplay.rs reuses) and the WGC-relay/DDA secure path (the
secure-desktop gate is not yet passed on glass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:00:55 +00:00
enricobuehler 6399d2817d feat(windows-drivers): STEP 7 — HDR/FP16 (validated on-glass: Mac connects WITH HDR)
apple / swift (push) Failing after 4s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 18s
ci / rust (push) Successful in 1m13s
windows-drivers / driver-build (push) Successful in 1m9s
ci / web (push) Successful in 42s
ci / docs-site (push) Successful in 59s
android / android (push) Successful in 3m16s
deb / build-publish (push) Successful in 2m37s
decky / build-publish (push) Successful in 10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
windows-host / package (push) Successful in 5m25s
ci / bench (push) Successful in 4m39s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m29s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m12s
The pf-vdisplay driver now advertises HDR/FP16 and the full glass-to-glass HDR path works
end-to-end — validated LIVE: the Mac client connected to the .173 host WITH HDR (display_hdr=true,
FP16 ring -> NVENC P010). The STEP-3 assumption that FP16 needs a higher UmdfExtensions was WRONG:
IddCx0102 + CAN_PROCESS_FP16 + the *2 DDIs works (the oracle proved it; confirmed on-glass
IddCxAdapterInitAsync -> 0x0 WITH the FP16 cap set). Driver-only change — the host FP16-ring ->
NVENC-P010 path and the HDR EDID were already in place.

- adapter.rs: caps.Flags = IDDCX_ADAPTER_FLAGS_CAN_PROCESS_FP16.
- entry.rs: register the 6 *2/HDR callbacks (ParseMonitorDescription2, MonitorQueryTargetModes2,
  AdapterCommitModes2, AdapterQueryTargetInfo, MonitorSetDefaultHdrMetaData, MonitorSetGammaRamp)
  ALONGSIDE the v1 set (matching the oracle — CAN_PROCESS_FP16 OBLIGATES the *2 DDIs or the
  framework rejects the adapter at init; STEP 3 rejected FP16 only because they weren't registered).
- callbacks.rs: parse_monitor_description2 + monitor_query_modes2 now fill IDDCX_MONITOR_MODE2 /
  IDDCX_TARGET_MODE2 with BitsPerComponent (8|10 bpc RGB); query_target_info already reports
  IDDCX_TARGET_CAPS_HIGH_COLOR_SPACE; set_default_hdr_metadata + set_gamma_ramp accept (the gamma
  one is mandatory under FP16).
- monitor.rs: wire_bits() (Rgb 8|10, no YCbCr) + target_mode2().
- EDID + INF UNCHANGED (the EDID already carries the CTA-861.3 BT.2020 + ST.2084/PQ block; the INF
  stays UmdfExtensions=IddCx0102).

Built via the ultracode flow (STEP-7 map workflow -> agent-implement -> box build [driver green] ->
deploy -> on-glass HDR). OPERATIONAL NOTE: do NOT Disable/Enable the IddCx devnode to reload it —
that leaves the adapter STOPPED in the persisted WUDFHost process (ADAPTER OnceLock survives), so
monitor-create then fails with 0xc00002b6 (INDIRECT_DISPLAY_DEVICE_STOPPED). Kill the pf_vdisplay
WUDFHost process (or reboot) for a clean adapter re-init.

This completes the pf-vdisplay rewrite STEP 0-7, all on-glass validated (loads, adapter inits,
monitor appears, swap-chain drain, IDD-push frames at ~235fps, and HDR). Remaining: STEP 8 (unsafe-
reduction + delete the old vdisplay-driver tree + the vendored SudoVDA driver + unbundle from the
installer = the SudoVDA drop).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:31:28 +00:00
enricobuehler e2f004589c feat(windows-drivers): STEP 6 — IDD-push FramePublisher (driver) + host migration to proto::frame
apple / swift (push) Failing after 1s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 19s
windows-drivers / driver-build (push) Successful in 1m9s
ci / rust (push) Successful in 1m31s
ci / web (push) Successful in 42s
ci / docs-site (push) Successful in 1m2s
android / android (push) Successful in 3m50s
deb / build-publish (push) Successful in 2m37s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
windows-host / package (push) Successful in 5m20s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m37s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m32s
docker / deploy-docs (push) Successful in 16s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m19s
The driver now publishes each acquired swap-chain surface into the host-created shared ring (the
IDD-push path) — the full glass-to-glass transport is code-complete. Both sides use the canonical
pf_vdisplay_proto::frame layout (lockstep by compile-error, not "must match" comments). Driver compiles
+ LOADS on-glass (adapter inits, Status=OK; no regression — the publisher is dormant until a frame is
acquired); host cargo check green; adversarially reviewed (no blockers — token layout, keyed-mutex key 0,
names by target_id, and the format guard all match the host consumer).

- new driver frame_transport.rs: FramePublisher OPENS the host ring by target_id (OpenFileMapping header
  + magic Acquire readiness gate + OpenEvent + OpenSharedResourceByName RING_LEN keyed-mutex textures),
  writes its render LUID + DRV_STATUS back into the header; publish() is NON-BLOCKING (round-robin 0ms
  try-acquire -> CopyResource -> ReleaseSync -> FrameToken::pack store Release -> SetEvent; drops the
  frame if every slot is busy or the surface format != the ring format). Manual handle/view cleanup on
  every try_open early return; RAII Drop (slots -> unmap -> CloseHandle). Layout/consts/names/token all
  from pf_vdisplay_proto::frame.
- swap_chain_processor.rs run_core: lazy rate-limited attach (every ~30 frames) + is_stale re-attach
  (mid-session HDR ring recreate); publishes buffer.MetaData.pSurface via IDXGIResource::from_raw_borrowed
  (preserves IddCx's refcount) BEFORE IddCxSwapChainFinishedProcessingFrame. run/run_core gain the render
  LUID; callbacks.rs assign_swap_chain passes it.
- host idd_push.rs migrated onto pf_vdisplay_proto::frame (deleted the hand-rolled SharedHeader / MAGIC /
  VERSION / RING_LEN / DRV_STATUS_* / name fns / token packing) — pure refactor, byte-identical, no
  behavior or gating change. DebugBlock + DXGI_SHARED_RESOURCE_RW kept local (not in the proto).
- driver windows crate gains Win32_System_Memory (MapViewOfFile/OpenFileMappingW/...); rustfmt'd the whole
  driver workspace (incl. wdk-probe — fmt-only).

Built via the ultracode flow: STEP-6 map workflow -> agent-implement -> box build (driver + host both
green; caught nothing this time) -> adversarial-verify-agent (no blockers) -> FrameToken::pack hardening
-> deploy (loads). Glass-to-glass frame validation awaits a composited session (per the parity finding:
this headless box yields 0 frames for the proven SudoVDA path too). FOLLOW-UPs: port the optional
Global\pfvd-dbg DebugBlock triage channel to the new driver; STEP 7 HDR; STEP 8 drop SudoVDA.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:28:47 +00:00
enricobuehler 590ceaa850 fix(windows-drivers): driver Cargo.lock — pf-vdisplay gains windows + thiserror edges (STEP 5)
apple / swift (push) Failing after 1s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 19s
windows-drivers / driver-build (push) Successful in 1m9s
windows-host / package (push) Successful in 5m16s
ci / rust (push) Successful in 1m30s
ci / web (push) Successful in 40s
android / android (push) Successful in 3m16s
ci / docs-site (push) Successful in 52s
deb / build-publish (push) Successful in 2m38s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m44s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m34s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m26s
STEP 5 (d8a453f) added the windows + thiserror deps to pf-vdisplay/Cargo.toml but the
workspace lock was not updated (driver is windows-only, cant build on the Linux dev box).
Regenerated on the RTX box. Both crates were already resolved in the lock (pulled by
wdk-build), so this is purely the pf-vdisplay dependency edges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:32:12 +00:00
enricobuehler d8a453f6ca feat(windows-drivers): STEP 5 — SwapChainProcessor + Direct3DDevice (swap-chain drain)
apple / swift (push) Failing after 1s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 18s
ci / rust (push) Successful in 1m14s
windows-drivers / driver-build (push) Successful in 1m11s
ci / web (push) Successful in 41s
ci / docs-site (push) Successful in 1m1s
android / android (push) Successful in 3m22s
deb / build-publish (push) Successful in 2m37s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
windows-host / package (push) Successful in 5m52s
ci / bench (push) Successful in 4m47s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m28s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m18s
docker / deploy-docs (push) Successful in 17s
The pf-vdisplay driver now consumes the OS swap-chain so a virtual monitor is a usable
display rather than a stalled one. Compiles + loads on-glass (no regression: adapter still
inits, Status=OK); adversarially reviewed — no blockers, the leak/deadlock invariants preserved.

- new swap_chain_processor.rs: a worker thread (MMCSS "Distribution") that binds the render D3D
  device (IddCxSwapChainSetDevice, single-borrow 60x@50ms retry) then drains the swap-chain
  (ReleaseAndAcquireBuffer2 -> FinishedProcessingFrame; E_PENDING waits 16ms on the surface
  event). NO frame publisher yet (STEP 6). RAII terminate+join Drop; the load-bearing
  top-of-loop terminate check (the oracle's reconnect-leak fix). Fixed a Rust-2021 disjoint-
  capture bug: `.0` field access bypassed the Sendable Send wrapper -> rebind the whole wrappers.
- new direct_3d_device.rs: CreateDXGIFactory2 -> EnumAdapterByLuid(render LUID) -> D3D11CreateDevice;
  a DEVICE_POOL of one Arc<Direct3DDevice> per render LUID (the NVIDIA-UMD-worker-thread leak fix).
- monitor.rs: MonitorObject gains swap_chain_processor; set/take helpers return it for the caller
  to drop OUTSIDE the MONITOR_MODES lock (dropping joins the worker — must never happen under the
  lock); remove_monitor/clear_all drop it before IddCxMonitorDeparture.
- callbacks.rs: assign_swap_chain spawns the processor (pooled device per RenderAdapterLuid;
  WdfObjectDelete on D3D-init failure so the OS retries); unassign_swap_chain drops it. Fixed the
  stale `panic = "abort"` doc (workspace is unwind; the extern "C" boundary aborts on unwind).
- Cargo.toml: windows 0.58 + thiserror (both already resolved in the driver lock). The 3 needed
  swap-chain DDIs were already wrapped in wdk-iddcx; their HRESULT-shaped NTSTATUS is classified
  by hand (hr>=0 success, 0x8000000A E_PENDING).
- Also rustfmt'd the whole driver workspace (it had never been driver-fmt'd).

Built via the ultracode flow: STEP-5 map workflow -> agent-implement -> box build (caught the
Send-capture bug) -> adversarial-verify-agent -> deploy (loads). Session-1 on-glass validation
(the drain loop servicing an ACTIVE monitor) is the next gate — assign_swap_chain only fires
under an interactive session. Note for STEP 6: target_id_for_object uses the MONITOR_MODES handle
lookup the oracle moved to a WDF context; revisit before target_id keys the shared frame ring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:29:20 +00:00
enricobuehler 024e709191 fix(windows-host): rustfmt pf_vdisplay.rs + Cargo.lock for the new host deps
apple / swift (push) Failing after 1s
apple / screenshots (push) Has been skipped
audit / cargo-audit (push) Failing after 1m6s
android / android (push) Successful in 4m28s
ci / web (push) Successful in 45s
windows-host / package (push) Successful in 5m13s
ci / docs-site (push) Successful in 1m8s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m12s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m3s
ci / rust (push) Successful in 9m49s
ci / bench (push) Successful in 4m36s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 2m36s
flatpak / build-publish (push) Successful in 4m40s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m21s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m5s
release / apple (push) Failing after 1s
94e82df shipped the agent-written pf_vdisplay.rs unformatted (cargo fmt --all --check
gate) and omitted the Cargo.lock edges for the new windows-only deps (pf-vdisplay-proto +
bytemuck). cargo fmt --all is now clean; Cargo.lock records the host dep edges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:00:52 +00:00
enricobuehler 94e82df9f3 feat(windows-host): STEP 4 (3/n) — host pf_vdisplay backend (talks to the new driver)
apple / swift (push) Failing after 1s
apple / screenshots (push) Has been skipped
ci / rust (push) Failing after 28s
ci / web (push) Successful in 39s
android / android (push) Successful in 3m28s
ci / docs-site (push) Successful in 56s
deb / build-publish (push) Failing after 25s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
windows-host / package (push) Successful in 6m32s
ci / bench (push) Successful in 4m34s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 3m21s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
The host can now drive the new pf-vdisplay IddCx driver instead of SudoVDA. Compiles
clean on BOTH Windows (cargo check -p punktfunk-host green) and Linux (cfg(windows)-gated,
main CI unaffected); adversarially reviewed (no blockers, lockstep with the driver).

- new vdisplay/pf_vdisplay.rs: cloned from the proven sudovda.rs, repointed to
  pf_vdisplay_proto — interface GUID 70667664 (not e5bcc234), IOCTL 0x900-0x905 (not the
  gappy 0x800/0x888/0x8FF), AddRequest/AddReply/RemoveRequest/SetRenderAdapterRequest
  (bytemuck Pod, not the GUID-keyed AddParams), a u64 session_id monitor key (not a minted
  GUID), and a single IOCTL_GET_INFO handshake that HARD-asserts protocol_version (vs
  SudoVDA two-IOCTL best-effort). Full MGR/linger/refcount/teardown lifecycle preserved.
- reuses sudovda.rs backend-neutral CCD/DXGI helpers (set_active_mode, isolate/restore_
  displays_ccd, resolve_gdi_name, resolve_render_adapter_luid, MON_GEN/CURRENT_MON_GEN,
  SavedConfig) — widened to pub(crate), not duplicated.
- vdisplay::open()/probe() select the backend: PUNKTFUNK_VDISPLAY=pf|sudovda forces one;
  default auto-detects (prefer pf-vdisplay if its interface enumerates, else SudoVDA stays
  the shipping fallback).

Notes: SET_RENDER_ADAPTER is tolerated as the driver returns NOT_IMPLEMENTED today (STEP 4
tail); the cross-MGR wait_for_monitor_released only paces sudovda's MGR (benign until
IDD-push lands on pf-vdisplay, STEP 6 — documented in-code). On-glass "monitor appears at
WxH@Hz" gate is next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:50:41 +00:00
enricobuehler bbc891e50a feat(windows-drivers): STEP 4 (2/n) — create_monitor + real mode DDIs + ADD/REMOVE
windows-drivers / probe-and-proto (push) Successful in 33s
windows-drivers / driver-build (push) Successful in 1m10s
android / android (push) Successful in 4m2s
ci / rust (push) Successful in 4m39s
ci / web (push) Successful in 44s
ci / docs-site (push) Successful in 52s
deb / build-publish (push) Successful in 2m17s
windows-host / package (push) Successful in 6m16s
decky / build-publish (push) Successful in 25s
ci / bench (push) Successful in 4m44s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
apple / swift (push) Successful in 1m13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m51s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m51s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m18s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 47s
apple / screenshots (push) Successful in 5m45s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m49s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m47s
docker / deploy-docs (push) Successful in 21s
The virtual-monitor lifecycle is now code-complete on the driver side (CI-green;
deployed — no load/adapter-init regression, Status=OK):

- new monitor.rs: the monitor/mode model (Mode/MonitorObject/MONITOR_MODES), ported from
  upstream virtual-display-rs with guid:u128 -> session_id:u64. create_monitor builds an
  EDID (serial=id) -> IddCxMonitorCreate -> IddCxMonitorArrival, stores the monitor, and
  returns the OS target id + adapter LUID for AddReply. remove_monitor / clear_all depart
  + drop. display_info/target_mode build the DISPLAYCONFIG timing (the union videoStandard
  u32 set directly — bindgen-API-agnostic, vs the oracle new_bitfield_1 transmute).
- callbacks.rs: parse_monitor_description (EDID-serial lookup -> count-then-fill
  IDDCX_MONITOR_MODE) + monitor_query_modes (pointer-match -> IDDCX_TARGET_MODE) are real.
- control.rs: IOCTL_ADD -> create_monitor + AddReply, REMOVE -> remove_monitor, CLEAR_ALL
  -> clear_all, via read_input/write_output_complete WDF buffer helpers. SET_RENDER_ADAPTER
  still stubbed (hybrid-GPU pin, next) + the watchdog thread (next).
- DISPLAYCONFIG_* resolve at the wdk_sys root (pub use types::*), not iddcx.

Warnings are the STEP-7 *2/HDR stubs + created_at (read by the watchdog, next). The
on-glass "monitor appears at WxH@Hz" gate awaits the host switch to pf_vdisplay_proto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:19:39 +00:00
enricobuehler 3e535f1de4 feat(windows-drivers): STEP 4 (1/n) — control-plane IOCTL dispatch (GET_INFO + PING)
apple / swift (push) Successful in 1m8s
windows-drivers / probe-and-proto (push) Successful in 18s
apple / screenshots (push) Failing after 1m34s
windows-drivers / driver-build (push) Successful in 1m3s
windows-host / package (push) Successful in 5m11s
android / android (push) Successful in 4m7s
ci / web (push) Successful in 48s
ci / rust (push) Successful in 4m42s
ci / docs-site (push) Successful in 55s
deb / build-publish (push) Successful in 2m15s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
ci / bench (push) Successful in 4m42s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m20s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m39s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m38s
docker / deploy-docs (push) Successful in 6s
EvtIddCxDeviceIoControl now dispatches the pf-vdisplay-proto control plane (new
src/control.rs): IOCTL_GET_INFO writes InfoReply{protocol_version, watchdog_timeout_s}
(the host asserts the version + fails loudly on mismatch), IOCTL_PING bumps the watchdog
keepalive. ADD/REMOVE/SET_RENDER_ADAPTER/CLEAR_ALL are dispatched but stubbed
(STATUS_NOT_IMPLEMENTED) pending create_monitor + the real mode DDIs (next). Unknown
IOCTLs -> STATUS_NOT_FOUND. Builds CI-green; warnings are the *2/HDR stubs (STEP 7) +
the stored adapter handle (read by create_monitor, next).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:21:25 +00:00
enricobuehler c94a81d523 chore(windows-drivers): clean up STEP-3 debugging artifacts; restore device interface
windows-drivers / probe-and-proto (push) Successful in 20s
apple / swift (push) Successful in 1m9s
windows-drivers / driver-build (push) Successful in 1m5s
apple / screenshots (push) Failing after 2m7s
android / android (push) Successful in 4m10s
ci / rust (push) Successful in 4m35s
ci / web (push) Successful in 46s
ci / docs-site (push) Successful in 53s
windows-host / package (push) Successful in 5m13s
deb / build-publish (push) Successful in 2m17s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
ci / bench (push) Successful in 4m43s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m20s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m24s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m24s
docker / deploy-docs (push) Successful in 19s
Verified on-glass after cleanup: adapter still inits (IddCxAdapterInitAsync 0x0,
Status OK) and WdfDeviceCreateDeviceInterface 0x0.

- RESTORE WdfDeviceCreateDeviceInterface (regression from debugging): the proto control
  plane sends IOCTLs via EvtIddCxDeviceIoControl, which needs the device interface for the
  host to open. Upstream omits it only because it uses a socket; ours is IOCTL-based.
- Drop the framework_struct_size / version-table machinery + size.rs: size_of suffices
  (these are IddCx 1.10 structs on a 1.10 framework, matching upstream). The version-table
  reads were added chasing a size mismatch that was never the bug (GammaSupport was).
- Drop /OPT:NOICF (ICF folding was a non-issue) + fix the stale stub-pick comment (the
  1.10 stub is needed for the dispatch table, not size.rs symbols).
- Debug-wait/PID-file/go-file gate already removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:16:25 +00:00
enricobuehler df32060655 fix(windows-drivers): STEP 3 DONE — IddCx adapter inits on-glass (Status=OK)
apple / swift (push) Failing after 2s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 20s
windows-drivers / driver-build (push) Successful in 1m6s
android / android (push) Successful in 4m32s
ci / rust (push) Successful in 4m33s
ci / web (push) Successful in 41s
ci / docs-site (push) Successful in 56s
windows-host / package (push) Successful in 5m23s
deb / build-publish (push) Successful in 2m15s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m37s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m28s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m10s
The all-Rust wdk-sys IddCx driver now initializes its adapter on the RTX box:
IddCxAdapterInitAsync -> 0x0, EvtIddCxAdapterInitFinished fires, device Status=OK.

ROOT CAUSE (found via cdb wt-trace of iddcx!IddCxImplAdapterInitAsync + the upstream
virtual-display-rs source): IDDCX_ENDPOINT_DIAGNOSTIC_INFO.GammaSupport was left zeroed
= IDDCX_FEATURE_IMPLEMENTATION_UNINITIALIZED (0), which the framework adapter validator
(ddivalidation.cpp:797) rejects with STATUS_INVALID_PARAMETER. Must be NONE (1).

Also required (matched to the proven-working upstream virtual-display-rs, installed +
verified Status=OK on the same box):
- caps Flags = NONE (SDR). CAN_PROCESS_FP16 needs a newer contract than UmdfExtensions=
  IddCx0102 grants; deferred to STEP 7 (HDR).
- SDR config: only the 7 required callbacks (+ DeviceIoControl for the proto control
  plane). The *2/gamma/HDR-metadata/query-target-info callbacks are FP16-obligated and
  rejected without FP16 caps; they return in STEP 7.
- device WDF context type on WdfDeviceCreate; adapter WDF context type on the init attrs.

Debugging note: cdb is reliable via live-attach (go-file gate to avoid the
IsDebuggerPresent race) but cdb -z static hangs on the VM; iddcx WPP needs the control
GUID (TMF GUIDs are not it). Diagnostics trimmed; log.rs dbglog kept for STEP 4+.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:39:49 +00:00
enricobuehler 55899bf73f test(windows-drivers): adapter-init isolated to wdk-sys IddCx binding (Rust IddCx PROVEN to work on-box)
apple / swift (push) Failing after 0s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 33s
windows-drivers / driver-build (push) Successful in 1m10s
windows-host / package (push) Successful in 6m13s
android / android (push) Successful in 4m7s
ci / rust (push) Successful in 4m24s
ci / web (push) Successful in 42s
ci / docs-site (push) Successful in 53s
deb / build-publish (push) Successful in 2m15s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m45s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m27s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m14s
DECISIVE: installed the pre-built UPSTREAM virtual-display-rs (Rust wdf-umdf IddCx)
driver on the SAME box -> Status=OK. So a Rust IddCx driver inits an adapter here,
self-signed, right now. My wdk-sys driver still fails ONLY at IddCxAdapterInitAsync
(0xc000000d) despite matching virtual-display-rs on EVERY inspectable dimension:
- same iddcx 1.10 headers+stub
- IDDCX_ADAPTER_CAPS + IDD_CX_CLIENT_CONFIG byte-perfect (offsets match C header)
- runtime pointers all valid/non-null (names .rdata, version stack, dev handle)
- identical IddFunctions[idx]+IddDriverGlobals dispatch; indices 0/1/2
- matched the minimal link (tested vendored wdk-build WITHOUT OneCoreUAP/
  NODEFAULTLIB/OPT/INTEGRITYCHECK -> still fails; export pollution ruled out)
- device context, no device interface (control via EvtIddCxDeviceIoControl), init order

The IddCx ClassExtension ETW provider emits no decodable reason (WPP/kernel-debugger
only). The remaining difference is the wdk-sys IddCx binding itself, invisible to
inspection. This commit keeps the upstream-matching structure (device context, no
interface) + the on-glass instrumentation; vendored wdk-build reverted to pristine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:40:26 +00:00
enricobuehler 725e596d2b feat(windows-drivers): adapter WDF context type + init-before-interface (match SudoVDA)
apple / swift (push) Failing after 1s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 18s
windows-drivers / driver-build (push) Successful in 1m4s
windows-host / package (push) Successful in 5m10s
android / android (push) Successful in 4m16s
ci / web (push) Successful in 41s
ci / rust (push) Successful in 4m34s
ci / docs-site (push) Successful in 53s
deb / build-publish (push) Successful in 2m16s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m44s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m25s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m13s
docker / deploy-docs (push) Successful in 18s
On-glass diagnosis narrowed decisively. PROVEN it is the driver, NOT the box:
enabling the installed SudoVDA devnode -> Status=OK (the box inits a self-signed
IddCx adapter right now). SudoVDA uses the IDENTICAL UmdfExtensions=IddCx0102 and is
built against IddCx 1.10 (DriverVer 1.10.9.289) — exactly our config.

Matched SudoVDA/the oracle on every inspectable dimension, none fixed the
IddCxAdapterInitAsync INVALID_PARAMETER: caps byte-perfect (offsets+sizes vs C +
framework table), minimal SDR adapter fails identically, dispatch byte-identical to
the oracle (IddFunctions[idx] + IddDriverGlobals), IddMinimumVersionRequired=4 (same
as oracle), version pointers, ObjectAttributes, init order, and now an adapter WDF
context type (this commit). The remaining difference is the Rust binary itself vs
SudoVDA C++. Next: capture IddCx ETW/WPP rejection reason (or kernel debugger), or
build the oracle (wdf-umdf Rust) on-glass to isolate Rust-wide vs wdk-sys-specific.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:25:07 +00:00
enricobuehler d17aeefd1c fix(windows-drivers): wstr! const->static (latent dangling .as_ptr) + record adapter-init ruleouts
apple / swift (push) Failing after 1s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 18s
windows-drivers / driver-build (push) Successful in 1m5s
windows-host / package (push) Successful in 5m12s
android / android (push) Successful in 4m11s
ci / web (push) Successful in 41s
ci / rust (push) Successful in 4m26s
ci / docs-site (push) Successful in 52s
deb / build-publish (push) Successful in 2m16s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m44s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m32s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m17s
docker / deploy-docs (push) Successful in 5s
wstr! used `const W; W.as_ptr()` which points to a temporary dropped at the end of
the statement (dangling) — fixed to `static W` (stable address). On-glass it did NOT
change the IddCxAdapterInitAsync INVALID_PARAMETER, and a minimal SDR adapter
(Flags=NONE + required callbacks only) fails identically, so the caps content +
callbacks are NOT the blocker (offsets are byte-perfect vs C; sizes match the
framework table; dispatch + device are correct). Config restored to FP16 + full HDR
callbacks. Remaining suspects: IDARG_IN_ADAPTER_INIT layout, the missing DeviceContext
(oracle always sets one), or a box/framework regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:07:15 +00:00
enricobuehler 1b0a13c25e test(windows-drivers): offset-audit the IddCx caps — bindgen layout is byte-perfect
apple / swift (push) Failing after 1s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 18s
windows-drivers / driver-build (push) Successful in 1m5s
windows-host / package (push) Successful in 5m14s
android / android (push) Successful in 3m42s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m4s
ci / rust (push) Successful in 4m34s
deb / build-publish (push) Successful in 2m16s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m41s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m26s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m18s
docker / deploy-docs (push) Successful in 6s
IddCxAdapterInitAsync still INVALID_PARAMETER. Logged offset_of! for every
IDDCX_ADAPTER_CAPS + IDDCX_ENDPOINT_DIAGNOSTIC_INFO field on the box: ALL match the
expected C x64 layout exactly (caps Flags=4 MaxRate=8 MaxMon=16 Diag=24 Static=80;
diag Trans=4 Friendly=8 Model=16 Manuf=24 HwVer=32 FwVer=40 Gamma=48). So the wdk-sys
bindgen lays the struct out correctly — NOT a layout bug. The caps are byte-identical
to C + match the framework size table + the oracle, yet rejected. Next: runtime
compare vs the oracle (does it init an adapter on this box now?) + WDK-docs deep-dive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:54:11 +00:00
enricobuehler 3d3dd3627c feat(windows-drivers): STEP 3 on-glass — driver LOADS + runs full IddCx init chain
apple / swift (push) Failing after 2s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 19s
windows-drivers / driver-build (push) Successful in 1m7s
windows-host / package (push) Successful in 5m27s
android / android (push) Successful in 4m5s
ci / web (push) Successful in 47s
ci / rust (push) Successful in 4m41s
ci / docs-site (push) Successful in 57s
deb / build-publish (push) Successful in 2m26s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 5m1s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m21s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m23s
docker / deploy-docs (push) Successful in 6s
Major on-glass progress on the RTX box. The all-Rust wdk-sys IddCx driver now LOADS
under Secure Boot and runs the ENTIRE init chain: DriverEntry -> WdfDriverCreate ->
driver_add -> IddCxDeviceInitConfig(0x0) -> WdfDeviceCreate -> CreateDeviceInterface
-> IddCxDeviceInitialize -> D0Entry -> init_adapter. Findings:
- Signing was a RED HERRING (the driver loads); std works in WUDFHost (DualSense uses
  it too).
- THE unblock: link the iddcx **1.10** IddCxStub (build.rs now picks the highest
  version-aware), not 1.0 — the 1.0 stub lacks the version-table symbols AND its
  dispatch table mismatched the 1.10 framework, which made IddCxDeviceInitConfig
  return INVALID_PARAMETER. With 1.10 the whole chain runs.
- Added a file/OutputDebugString logger (log.rs, matches the DualSense driver) — the
  driver was silent; this is how the chain was traced.
- size.rs: framework_struct_size() reads the frameworks authoritative struct sizes
  from IddStructures[] (the config keeps size_of=208, validated working).
- adapter.rs: version ptrs + ObjectAttributes(InheritFromParent) + FP16 + framework
  caps/diag/version sizes — matches the oracle.

KNOWN WIP: IddCxAdapterInitAsync still returns INVALID_PARAMETER though caps match
the framework size table (88/56/24) + the oracle exactly — likely a subtle wdk-sys
bindgen field-layout detail in IDDCX_ADAPTER_CAPS/IDDCX_ENDPOINT_DIAGNOSTIC_INFO.
CI gate (compile+link) stays green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:46:58 +00:00
enricobuehler ad27174027 feat(windows-drivers): STEP 3 — IddCx adapter init (deferred D0, FP16 caps)
apple / swift (push) Failing after 1s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 18s
windows-drivers / driver-build (push) Successful in 1m5s
windows-host / package (push) Successful in 5m13s
android / android (push) Successful in 3m42s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 1m3s
ci / rust (push) Successful in 4m27s
deb / build-publish (push) Successful in 2m14s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m41s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m33s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m24s
docker / deploy-docs (push) Successful in 17s
adapter.rs: init_adapter(device) builds IDDCX_ADAPTER_CAPS (CAN_PROCESS_FP16,
MaxMonitorsSupported=16, endpoint diagnostics with wstr! PCWSTR names) +
IDARG_IN_ADAPTER_INIT and calls IddCxAdapterInitAsync; EvtDeviceD0Entry triggers it
(idempotent), EvtIddCxAdapterInitFinished stashes the adapter in a OnceLock for
later DDIs. zeroed()+named-field construction dodges the Default-derive +
field-order questions. Compiles + links clean on the box (pf_vdisplay.dll 268KB).
CI gate = compile+link; the on-glass load/enumerate gate needs the box + an INF +
SwDeviceCreate (next).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:28:10 +00:00
enricobuehler d0d31b1040 fix(windows-drivers): size_of config size (1.0 IddCxStub lacks the version table) + CI builds pf-vdisplay
apple / swift (push) Failing after 2s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 19s
windows-drivers / driver-build (push) Successful in 1m5s
windows-host / package (push) Successful in 5m24s
android / android (push) Successful in 3m43s
ci / web (push) Successful in 47s
ci / docs-site (push) Successful in 1m4s
ci / rust (push) Successful in 4m21s
deb / build-publish (push) Successful in 2m14s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m39s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m31s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m16s
docker / deploy-docs (push) Successful in 17s
The versioned IDD_STRUCTURE_SIZE path referenced IddClientVersionHigherThanFramework/
IddStructureCount/IddStructures — LNK2019 unresolved, because the WDK links the iddcx
1.0 IddCxStub which lacks those (they are >=1.4). We target 1.10 against a current
framework (higher==false) where size_of is exactly the versioned result, so use it
directly (the surface-assert refs linked only because they were DCE-eliminated).
pf-vdisplay now COMPILES + LINKS IddCxStub on the box (263,680B). Point
windows-drivers.yml at the whole workspace + clear FORCE_INTEGRITY on pf_vdisplay.dll;
drop the obsolete UINT diagnostic dump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:16:22 +00:00
enricobuehler 4f10f3439d feat(windows-drivers): pf-vdisplay STEP 2 — IddCx device skeleton
apple / swift (push) Failing after 2s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 21s
windows-drivers / driver-build (push) Successful in 1m5s
windows-host / package (push) Successful in 5m16s
android / android (push) Successful in 3m40s
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 1m2s
ci / rust (push) Successful in 4m32s
deb / build-publish (push) Successful in 2m14s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
ci / bench (push) Successful in 4m44s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m31s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m22s
docker / deploy-docs (push) Successful in 18s
DriverEntry -> driver_add builds the full IDD_CX_CLIENT_CONFIG (14 IddCx callbacks +
PnP EvtDeviceD0Entry, all stubs with correct PFN signatures) sized via the ported
IDD_STRUCTURE_SIZE! (size.rs), runs IddCxDeviceInitConfig -> WdfDeviceCreate ->
WdfDeviceCreateDeviceInterface(the owned pf-vdisplay GUID, not SudoVDA) ->
IddCxDeviceInitialize. callbacks.rs has all 14 + device_d0_entry; query_target_info
implements HIGH_COLOR_SPACE. edid.rs salvaged verbatim from the oracle. proto gains
interface_guid_fields() (u128 -> Windows GUID fields). Links IddCxStub (the CI gate);
adapter/monitor/swapchain/IDD-push fill the stubs in STEP 3-6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:12:20 +00:00
enricobuehler 788e4acbb5 feat(windows-drivers): STEP 1 — wdk-iddcx with all 11 IddCx DDI wrappers
apple / swift (push) Failing after 2s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 18s
windows-host / package (push) Successful in 5m48s
windows-drivers / driver-build (push) Successful in 1m4s
android / android (push) Successful in 3m37s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 1m3s
ci / rust (push) Successful in 4m21s
deb / build-publish (push) Successful in 2m16s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m37s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m47s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m40s
docker / deploy-docs (push) Successful in 6s
Graduate the proven iddcx_rt.rs dispatch into wdk-iddcx + add the full DDI set the
pf-vdisplay driver needs: DeviceInitConfig/Initialize, AdapterInitAsync,
MonitorCreate/Arrival/Departure, AdapterSetRenderAdapter (void-returning DDI — its
PFN returns ()), SwapChainSetDevice/ReleaseAndAcquireBuffer2/FinishedProcessingFrame.
One dispatch macro pins each (_IDDFUNCENUM index, PFN_* type) pair exactly once
(the only place table dispatch can be UB). Box-compiles green; IddCxStub link gets
validated when pf-vdisplay (cdylib) consumes it in STEP 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:55:45 +00:00
enricobuehler d7a9fbf0b6 feat(windows-drivers): pf-vdisplay STEP 0 scaffold + std-under-UMDF link gate
apple / swift (push) Failing after 2s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 19s
windows-drivers / driver-build (push) Successful in 1m5s
windows-host / package (push) Successful in 5m25s
android / android (push) Has been cancelled
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 1m12s
ci / rust (push) Successful in 4m8s
deb / build-publish (push) Successful in 2m18s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
ci / bench (push) Successful in 4m47s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m23s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m47s
docker / deploy-docs (push) Successful in 18s
M1 step 2 begins. Add the wdk-iddcx (lib, re-exports wdk_sys::iddcx) + pf-vdisplay
(cdylib) workspace members. pf-vdisplay STEP 0 = DriverEntry + WdfDeviceCreate
skeleton + a #[used] _std_link_gate forcing std::thread + OwnedHandle to link, so
the build proves the std surface resolves under the wdk-build UMDF link settings
(kernel32 is /NODEFAULTLIB - std must come via OneCoreUAP). If std fails to link
here, the SwapChainProcessor worker-thread design needs a CreateThread shim before
any callback work (port-plan critique gap #9).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:49:03 +00:00
enricobuehler f652617f30 docs(windows-rewrite): M1 step-2 pf-vdisplay port plan (workflow-mapped + critiqued)
apple / swift (push) Failing after 1s
apple / screenshots (push) Has been skipped
android / android (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
Record the full driver port plan from the iddcx-driver-port-map workflow: the 11
DDIs to wrap, the 15 IDD_CX_CLIENT_CONFIG callbacks, the DeviceContext-owned state
model (single Monitor identity + monitor EvtCleanupCallback RAII), the
pf-vdisplay-proto frame transport, and the 8-step CI/box-gated checklist. Fold in
the adversarial critique: secure-desktop is a BLOCKING gate (do not retire the WGC
relay until proven), define the recreate/concurrency/Reconfigure failure branches,
host<->driver protocol_version lockstep. De-risk status: the full IddCx symbol
surface + .Size machinery is CI-proven present (ae803b2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:40:42 +00:00
enricobuehler ae803b24d5 test(windows-drivers): CI-assert the full IddCx driver symbol surface
apple / swift (push) Failing after 2s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 19s
windows-drivers / driver-build (push) Successful in 1m6s
windows-host / package (push) Successful in 5m17s
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
android / android (push) Has been cancelled
deb / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
decky / build-publish (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
Port-plan critique #1: convert "the (?i).*iddcx.* allowlist may miss a symbol the
full driver needs" from a box-only surprise into a CI compile gate. New
wdk-probe/src/iddcx_surface_assert.rs size_of-asserts every *2/HDR struct
(IDDCX_TARGET_MODE2/PATH2/METADATA2, IDARG_*RELEASEANDACQUIREBUFFER2 — these embed
DISPLAYCONFIG_*/LUID, which RESOLVE from crate::types: no allowlist gap),
None-asserts all 14 inbound PFN_IDD_CX_* callbacks, and confirms the .Size
machinery (IddStructures/IddStructureCount/IddClientVersionHigherThanFramework/
_IDDSTRUCTENUM::INDEX_*) + the FP16/HIGH_COLOR_SPACE flags. Box-built green; the
wdk-sys binding is proven complete for the ENTIRE driver, not just init. Also
silence the bindgen naming lints in the iddcx module.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:39:03 +00:00
enricobuehler 3fbabc854c feat(windows-drivers): IddCx link probe — call init DDIs via table dispatch
apple / swift (push) Failing after 1s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 19s
windows-drivers / driver-build (push) Successful in 1m5s
windows-host / package (push) Successful in 5m19s
ci / rust (push) Successful in 4m13s
ci / web (push) Successful in 41s
ci / docs-site (push) Successful in 53s
android / android (push) Successful in 9m59s
ci / bench (push) Successful in 4m48s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
deb / build-publish (push) Successful in 2m19s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m33s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m13s
docker / deploy-docs (push) Has been cancelled
First USE of the iddcx binding: a minimal table-dispatch (src/iddcx_rt.rs) over
wdk_sys::iddcx — IddFunctions[_IDDFUNCENUM::<Name>TableIndex] cast to PFN_*,
IddDriverGlobals as implicit arg 1 (the WDF model; ModuleConsts i32 index, not the
oracle NewType .0). The probe EvtDeviceAdd now calls IddCxDeviceInitConfig →
WdfDeviceCreate → IddCxDeviceInitialize → IddCxAdapterInitAsync, exports
IddMinimumVersionRequired=4, and build.rs links IddCxStub (globbed from the SDK
Lib dir that ships iddcx). CI gate = compile + link IddCxStub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:18:48 +00:00
enricobuehler 8c4e7b07bf docs(windows-rewrite): M1 IddCx make-or-break RESOLVED (the 6 working knobs)
apple / swift (push) Failing after 1s
apple / screenshots (push) Has been skipped
ci / web (push) Successful in 48s
android / android (push) Successful in 3m16s
ci / docs-site (push) Successful in 54s
ci / bench (push) Successful in 4m53s
ci / rust (push) Successful in 1m20s
decky / build-publish (push) Successful in 24s
deb / build-publish (push) Successful in 7m19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m45s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 31s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m17s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m56s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 46s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m56s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m55s
docker / deploy-docs (push) Has been cancelled
CI-green @ 6d8c7a5 (run 5548): IddCx bindgens + compiles in wdk-sys with WDF
type-identity. Record the exact generate_iddcx recipe (c++ parse, IDD_STUB,
allowlist_recursively(false), DXGI/OPM/D3D local emit, UINT alias,
translate_enum_integer_types) and that the wdf-umdf fallback is unneeded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:04:46 +00:00
enricobuehler 6d8c7a5185 fix(windows-drivers): translate iddcx enum repr ints (UINT in nested mods)
apple / swift (push) Failing after 1s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 17s
windows-drivers / driver-build (push) Successful in 1m3s
windows-host / package (push) Successful in 5m31s
ci / rust (push) Successful in 1m22s
android / android (push) Successful in 3m18s
ci / web (push) Successful in 40s
ci / docs-site (push) Successful in 54s
deb / build-publish (push) Successful in 3m22s
decky / build-publish (push) Successful in 10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m48s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m35s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m34s
docker / deploy-docs (push) Has been cancelled
Last UINT errors were all `pub type Type = UINT;` inside bindgen enum modules
(pub mod _DXGI_X {..}) — the top-level UINT alias cannot reach nested modules. C++
parsing made bindgen keep the UINT typedef as the enum underlying repr (C mode
emits a primitive). translate_enum_integer_types(true) emits native u32 reprs, so
the enum modules are self-contained; struct-field UINT stays covered by the
src/iddcx.rs alias.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:01:22 +00:00
enricobuehler 2f7847ce9b ci(windows-drivers): dump generated iddcx.rs structure on failure (diagnostic)
apple / swift (push) Failing after 1s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 20s
windows-drivers / driver-build (push) Failing after 58s
android / android (push) Failing after 37s
ci / web (push) Successful in 41s
ci / rust (push) Successful in 1m11s
ci / docs-site (push) Successful in 52s
deb / build-publish (push) Successful in 3m19s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
ci / bench (push) Successful in 4m48s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m13s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m9s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m19s
docker / deploy-docs (push) Has been cancelled
UINT fails to resolve despite a top-level `pub type UINT` in the same scope as the
working `use crate::types::*` — error count byte-identical before/after the fix.
Add an if:always() step dumping the generated module structure + UINT-use context
to pinpoint the scope mismatch (RTX box rebooted to Proxmox, so CI is the only
validator).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:55:20 +00:00
enricobuehler c6a818e985 fix(windows-drivers): DXGI enum-modules + define UINT in iddcx module
apple / swift (push) Failing after 1s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 19s
windows-drivers / driver-build (push) Failing after 57s
windows-host / package (push) Successful in 5m15s
ci / rust (push) Successful in 1m18s
android / android (push) Successful in 3m21s
ci / web (push) Successful in 38s
ci / docs-site (push) Successful in 52s
deb / build-publish (push) Successful in 3m19s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m53s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m55s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m52s
docker / deploy-docs (push) Has been cancelled
Last iddcx type gaps: (1) DXGI enum newtypes are `pub use self::_DXGI_X::Type as
DXGI_X` — the `_DXGI_X` module needs allowlisting too (broaden DXGI_.* to
_?DXGI_.*, matching the OPM fix); (2) UINT bindgen raw_line landed in a scope the
bindings cannot see — define `pub type UINT` directly in src/iddcx.rs next to
`use crate::types::*` instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:49:11 +00:00
enricobuehler f34e956818 fix(windows-drivers): emit OPM struct tags + D3DCOLORVALUE + shim UINT
apple / swift (push) Failing after 1s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 19s
windows-drivers / driver-build (push) Failing after 57s
windows-host / package (push) Successful in 5m14s
android / android (push) Failing after 58s
ci / web (push) Successful in 1m28s
ci / rust (push) Successful in 1m55s
ci / docs-site (push) Successful in 53s
deb / build-publish (push) Successful in 3m19s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
ci / bench (push) Successful in 4m41s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m17s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m31s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m22s
docker / deploy-docs (push) Successful in 17s
DXGI resolved. Remaining iddcx type gaps: OPM typedefs need their _OPM_* struct
tags too (recursively(false) drops them), D3DCOLORVALUE (an OPM field), and UINT
(unsigned int — absent from crate::types, and allowlist_type does not emit bare
primitive aliases). Broaden to _?OPM_.* + _?D3DCOLORVALUE and raw_line the UINT
alias.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:40:25 +00:00
enricobuehler 04e52b0c22 fix(windows-drivers): emit IddCx DXGI/OPM/UINT types locally
apple / swift (push) Failing after 2s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 19s
windows-drivers / driver-build (push) Failing after 58s
windows-host / package (push) Successful in 5m19s
ci / rust (push) Successful in 1m15s
ci / web (push) Successful in 42s
ci / docs-site (push) Successful in 1m2s
android / android (push) Successful in 3m19s
deb / build-publish (push) Successful in 3m21s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
ci / bench (push) Successful in 4m44s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m16s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m35s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m14s
docker / deploy-docs (push) Successful in 22s
The iddcx bindgen now SUCCEEDS (C++ fix). Generated module had 38 unresolved-type
errors — a bounded set wdk-sys does not bindgen: UINT, DXGI_FORMAT,
DXGI_COLOR_SPACE_TYPE, IDXGIDevice/Resource, 6 OPM_* types. No WDF type is
missing, so the crate::types sharing (type-identity) holds. Allowlist those
families so they emit locally in iddcx.rs (non-conflicting — absent from
crate::types), keeping allowlist_recursively(false).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:33:59 +00:00
enricobuehler 2df3c0f2b4 fix(windows-drivers): parse the iddcx bindgen as C++ (clears struct-tag)
apple / swift (push) Failing after 2s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 21s
windows-drivers / driver-build (push) Failing after 58s
windows-host / package (push) Successful in 5m20s
ci / rust (push) Successful in 1m18s
ci / web (push) Successful in 40s
android / android (push) Successful in 3m48s
ci / docs-site (push) Successful in 52s
deb / build-publish (push) Successful in 3m19s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m50s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m57s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m48s
docker / deploy-docs (push) Successful in 17s
Direct clang test on the box proved IddCx.h parses with 0 errors as C++ but fails
as C (wdk_default has no --language=c++) — the IDARG_* typedef names hit "must use
struct tag" in C mode. Fix generate_iddcx: --language=c++ + keep -DIDD_STUB +
allowlist_recursively(false) + full codegen, so it emits ONLY IddCx items
(structs, the IddFunctions table enums, DDI fn-ptr typedefs) and references
WDF/Win/DXGI types from wdk-sys via `use crate::types::*` (no re-emission, no
blocklist). Reverted the ENABLED_API_SUBSETS Iddcx entry (it wrongly pulled
IddCx into the C-mode constants/types passes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:28:58 +00:00
enricobuehler 60df3c9c52 fix(windows-drivers): define IDD_STUB for the iddcx bindgen pass
apple / swift (push) Failing after 3s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 19s
windows-drivers / driver-build (push) Failing after 42s
windows-host / package (push) Successful in 5m21s
ci / rust (push) Successful in 1m17s
ci / web (push) Successful in 43s
android / android (push) Successful in 3m16s
ci / docs-site (push) Successful in 53s
deb / build-publish (push) Successful in 3m20s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
ci / bench (push) Successful in 4m47s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m19s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m33s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m41s
docker / deploy-docs (push) Successful in 19s
The iddcx bindgen failed with IddCxFuncEnum.h "IDDCX_VERSION_MAJOR is not defined"
+ a cascade of "must use struct tag" on IDARG_* types — NOT the feared #515
header conflict (IddCx parsed fine alongside Base+Wdf). IddCx.h needs STUB mode
(function-table dispatch) for the version macros to resolve; add -DIDD_STUB to
generate_iddcx, matching the wdf-umdf oracle. Deliberately NOT WDF_STUB (wdk-sys
parses wdf non-stubbed; desyncing only here would break WDF type-identity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:18:03 +00:00
enricobuehler 9fd19b90a9 feat(windows-drivers): vendor wdk 0.5.1 + add ApiSubset::Iddcx (M1 spike)
windows-drivers / probe-and-proto (push) Successful in 24s
apple / swift (push) Successful in 1m8s
windows-drivers / driver-build (push) Failing after 43s
ci / rust (push) Successful in 1m31s
ci / web (push) Successful in 1m5s
ci / docs-site (push) Successful in 52s
apple / screenshots (push) Failing after 2m35s
windows-host / package (push) Successful in 5m23s
ci / bench (push) Successful in 4m48s
android / android (push) Successful in 10m1s
decky / build-publish (push) Successful in 26s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
deb / build-publish (push) Successful in 3m29s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m21s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m23s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m18s
docker / deploy-docs (push) Successful in 21s
Vendor the published, self-contained windows-drivers-rs 0.5.1 crates
(wdk-build, wdk-sys) under vendor/ and add a first-class ApiSubset::Iddcx that
bindgens iddcx/1.10/IddCx.h in an extra pass reusing bindgen::Builder::wdk_default
(allowlist_file (?i).*iddcx.* — emits only IddCx items; WDF/DXGI types resolve to
the shared base/wdf bindings, type-identity by construction). Mirrors the existing
gpio/hid/spb subsets exactly: wdk-build gets the enum variant + iddcx_headers()
(UMDF-only), wdk-sys gets generate_iddcx + the iddcx feature + pub mod iddcx.
[patch.crates-io] redirects all wdk-sys/wdk-build (incl. wdk 0.4.1 transitive) to
the patched copies. wdk-probe enables the iddcx feature.

MAKE-OR-BREAK: does IddCx.h bindgen in wdk-sys config without a header conflict
(issue #515) + does the generated module compile (type-identity)? CI answers it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:12:43 +00:00
enricobuehler 6975691f7d docs(windows-rewrite): M0-complete log + M1 IddCx-binding recipe
apple / swift (push) Successful in 1m4s
ci / rust (push) Successful in 1m11s
ci / web (push) Successful in 42s
ci / docs-site (push) Successful in 1m0s
android / android (push) Successful in 3m30s
apple / screenshots (push) Successful in 3m2s
ci / bench (push) Successful in 5m10s
deb / build-publish (push) Successful in 4m34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 17s
decky / build-publish (push) Successful in 20s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m12s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m17s
docker / deploy-docs (push) Successful in 17s
M0 done (proto + runner/box toolchain incl LLVM 21.1.2 + driver builds green +
/INTEGRITYCHECK cleared). M1 recipe: vendor windows-drivers-rs 0.5.1 + add an
ApiSubset::Iddcx reusing wdk_default (type identity by construction; IddCx is
table-dispatched like WDF). Make-or-break spike = can IddCx.h bindgen in wdk-sys
config (upstream #514/#516, PR #654 unmerged); fallback = keep wdf-umdf for
pf-vdisplay only. RTX box is ephemeral (Proxmox on reboot) — CI is the persistent
validator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:14:27 +00:00
enricobuehler f896f70bb8 feat(windows-drivers): clear FORCE_INTEGRITY for self-signed driver load (M0)
windows-drivers / probe-and-proto (push) Successful in 18s
apple / swift (push) Successful in 1m7s
ci / rust (push) Successful in 1m10s
windows-drivers / driver-build (push) Successful in 57s
ci / web (push) Successful in 44s
ci / docs-site (push) Successful in 1m0s
android / android (push) Successful in 3m32s
apple / screenshots (push) Successful in 3m9s
deb / build-publish (push) Successful in 3m19s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
windows-host / package (push) Successful in 5m52s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m45s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m43s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m40s
docker / deploy-docs (push) Successful in 18s
wdk-build links UMDF drivers with /INTEGRITYCHECK unconditionally (no opt-out),
so the self-signed DLL would be refused by Code Integrity (3004/3089). Add a
deterministic, idempotent, reusable packaging step
(packaging/windows/clear-force-integrity.ps1) that clears the PE
IMAGE_DLLCHARACTERISTICS_FORCE_INTEGRITY bit (0x0080 @ e_lfanew+0x5e) and verifies
— the gamepad recipe, no longer hand-run. driver-build now inspects the bit
(before) then clears+verifies it. Real drivers will: build -> clear -> sign .dll
-> Inf2Cat -> sign .cat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:38:57 +00:00
enricobuehler b24c10a723 ci(windows-drivers): LLVM via portable tar.xz + self-provision driver-build
apple / swift (push) Failing after 1s
apple / screenshots (push) Has been skipped
windows-drivers-provision / provision (push) Successful in 1m26s
windows-drivers / probe-and-proto (push) Successful in 16s
windows-drivers / driver-build (push) Successful in 56s
ci / rust (push) Successful in 1m16s
ci / web (push) Successful in 40s
android / android (push) Successful in 3m12s
ci / docs-site (push) Successful in 58s
deb / build-publish (push) Successful in 3m22s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m44s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m36s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m24s
docker / deploy-docs (push) Successful in 6s
The LLVM NSIS .exe /S silent install HANGS in the headless SYSTEM CI session
(stuck >15min after download, blocking the single runner). Switch to the portable
clang+llvm-21.1.2-x86_64-pc-windows-msvc.tar.xz (curl + Win11 tar -xf, strip 1) —
deterministic, no installer. And make driver-build run the provision script itself
(idempotent) so it self-provisions LLVM and never races a separate provision run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:27:42 +00:00
enricobuehler 1682b83b3f ci(windows-drivers): point driver-build LIBCLANG_PATH at LLVM 21.1.2
apple / swift (push) Failing after 1s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 19s
windows-drivers / driver-build (push) Failing after 35s
ci / rust (push) Successful in 1m12s
ci / web (push) Successful in 41s
ci / docs-site (push) Successful in 1m0s
android / android (push) Successful in 3m12s
deb / build-publish (push) Successful in 3m21s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m46s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m29s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m27s
docker / deploy-docs (push) Successful in 6s
Use the provisioned C:\\llvm-21 libclang for the driver build so wdk-sys bindgen
builds clean (the runner default LLVM is a ToT/22-dev with the E0080 layout-test
overflow bug). Queues behind the in-progress LLVM provision on the single runner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:08:04 +00:00
enricobuehler 838cac4f69 ci(windows-drivers): provision LLVM 21.1.2 for wdk-sys bindgen
apple / swift (push) Failing after 1s
apple / screenshots (push) Has been skipped
ci / web (push) Successful in 41s
windows-drivers-provision / provision (push) Has been cancelled
android / android (push) Failing after 32s
ci / rust (push) Successful in 1m12s
ci / docs-site (push) Successful in 53s
deb / build-publish (push) Successful in 3m19s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m41s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m48s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m51s
docker / deploy-docs (push) Successful in 5s
wdk-sys bindgen layout tests overflow (E0080 on threadlocaleinfostruct etc.) with
the runner default LLVM (a ToT/22-dev build). windows-drivers-rs maintainers
confirm released LLVM 21.1.2 builds clean (discussion #591). Install it to
C:\\llvm-21 (dedicated path; client LLVM untouched); the driver-build job will set
LIBCLANG_PATH there. Idempotent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:01:09 +00:00
enricobuehler 4f62643c82 ci(windows-drivers): static-CRT .cargo/config (fixes StaticCrtNotEnabled)
apple / swift (push) Failing after 4s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 18s
windows-drivers / driver-build (push) Failing after 49s
windows-host / package (push) Successful in 5m19s
android / android (push) Successful in 3m41s
ci / web (push) Successful in 39s
ci / rust (push) Successful in 1m11s
ci / docs-site (push) Successful in 52s
deb / build-publish (push) Successful in 3m21s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m48s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m48s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m47s
docker / deploy-docs (push) Successful in 5s
wdk-build errored StaticCrtNotEnabled + the generated wdk-sys layout asserts
overflowed (E0080) — UMDF needs the static CRT. Add the canonical
windows-drivers-rs .cargo/config.toml: explicit target = x86_64-pc-windows-msvc
(separates host proc-macros, which stay dynamic-CRT, from the driver) +
target-feature=+crt-static scoped to that target. DLL now under the triple subdir.

The WDK bindgen itself now runs (it generated out/types.rs) — this is the last
build-config layer before the /INTEGRITYCHECK verdict.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:52:40 +00:00
enricobuehler c91e7a0e38 ci(windows-drivers): workspace-level WDK driver-model (fixes wdk-sys build)
apple / swift (push) Failing after 4s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 15s
windows-drivers / driver-build (push) Failing after 41s
windows-host / package (push) Successful in 5m22s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
ci / rust (push) Successful in 1m20s
ci / web (push) Successful in 44s
android / android (push) Successful in 3m20s
ci / docs-site (push) Successful in 54s
deb / build-publish (push) Successful in 3m24s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m44s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m14s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m49s
docker / deploy-docs (push) Successful in 5s
wdk-sys build script: "missing field driver-model" deserializing
workspace_metadata[wdk] — a workspace build reads the model from the WORKSPACE
metadata, not the package. Set [workspace.metadata.wdk.driver-model] = UMDF 2.31
(all our drivers are UMDF 2.x incl. pf-vdisplay IddCx). Past the Cargo.lock fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:49:25 +00:00
enricobuehler bed4711096 ci(windows-drivers): in-tree target dir for driver-build (find the lock)
apple / swift (push) Failing after 4s
windows-drivers / probe-and-proto (push) Successful in 17s
apple / screenshots (push) Has been skipped
windows-drivers / driver-build (push) Failing after 27s
ci / rust (push) Successful in 1m15s
ci / web (push) Successful in 39s
ci / docs-site (push) Successful in 59s
android / android (push) Successful in 3m16s
deb / build-publish (push) Successful in 3m20s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 4m40s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m33s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m34s
docker / deploy-docs (push) Successful in 18s
wdk-build find_top_level_cargo_manifest() walks UP from OUT_DIR to the first
ancestor with a Cargo.lock; the relocated CARGO_TARGET_DIR=C:\\t\\drvws hid the
workspace lock (ancestors C:\\t, C:\\ have none) -> the "Cargo.lock should exist"
panic. Drop the override; the driver deps have no deep CMake crates so the
in-tree target stays under MAX_PATH.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:45:00 +00:00
enricobuehler 5d3cb5e63f ci(windows-drivers): commit driver workspace Cargo.lock
apple / swift (push) Failing after 10s
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 18s
windows-drivers / driver-build (push) Failing after 11s
windows-host / package (push) Successful in 5m16s
ci / rust (push) Successful in 1m17s
ci / web (push) Successful in 47s
android / android (push) Successful in 3m16s
ci / docs-site (push) Successful in 53s
deb / build-publish (push) Successful in 3m20s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m42s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m36s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m34s
docker / deploy-docs (push) Successful in 16s
wdk-build requires a Cargo.lock next to the top-level Cargo.toml (it panics
otherwise — "a Cargo.lock file should exist..."). Generated on Linux
(resolution is platform-independent; only the build needs the WDK). Everything
else compiled on the runner — pf-vdisplay-proto, bindgen, wdk-build/sys/macros.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:36:30 +00:00
enricobuehler d3e4ea0118 feat(windows-drivers): driver workspace + wdk-probe on windows-drivers-rs (M1)
apple / screenshots (push) Failing after 2m46s
windows-drivers / probe-and-proto (push) Successful in 16s
windows-drivers / driver-build (push) Failing after 36s
apple / swift (push) Successful in 1m5s
windows-host / package (push) Successful in 6m19s
ci / rust (push) Successful in 1m20s
ci / web (push) Successful in 40s
android / android (push) Successful in 3m17s
ci / docs-site (push) Successful in 59s
deb / build-publish (push) Successful in 3m21s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m49s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m32s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m23s
docker / deploy-docs (push) Successful in 18s
Stand up packaging/windows/drivers/ — the unified driver workspace on crates.io
windows-drivers-rs (wdk 0.4.1 / wdk-sys + wdk-build 0.5.1), retiring the dev-box
../../crates/wdk* path-deps. First member: wdk-probe, the smallest UMDF2 driver
(DriverEntry -> WdfDriverCreate -> EvtDeviceAdd -> WdfDeviceCreate) that
force-links the shared pf-vdisplay-proto ABI crate. It validates on the runner:
wdk-sys bindgen + WDF stub link against the WDK + LLVM, the cross-workspace
no_std proto path-dep, and the produced DLL's PE FORCE_INTEGRITY bit.

windows-drivers.yml gains a driver-build job: cargo build -p wdk-probe (pinning
Version_Number=10.0.26100.0) + a PE inspection that prints whether /INTEGRITYCHECK
is set — the M0 self-signed-load question.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:33:38 +00:00
enricobuehler 43144203fa ci(windows-drivers): fix WDK verification paths (WDK installed fine)
windows-drivers-provision / provision (push) Successful in 11s
apple / swift (push) Successful in 1m2s
apple / screenshots (push) Successful in 5m22s
ci / rust (push) Successful in 1m15s
ci / web (push) Successful in 40s
ci / docs-site (push) Successful in 58s
android / android (push) Successful in 3m24s
deb / build-publish (push) Successful in 3m21s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m46s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m30s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m21s
docker / deploy-docs (push) Successful in 18s
The first provision run installed the WDK (iddcx headers + stampinf appeared) +
cargo-wdk, but the verification threw on two wrong checks: UMDF wdf.h lives at
Include\wdf\umdf\<ver>\ (not under the SDK-version dir), and inf2cat is x86-only
(the search filtered \x64\). Rewrite verification to enumerate the real layout
(wdf\umdf versions, km dir, iddcx versions, tool paths) and fail only on the
build-essential pieces (wdf.h + km + iddcx + cargo-wdk). Skip-check now keys off
iddcx presence (the reliable "WDK installed" signal), so a re-run skips the install.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:26:36 +00:00
enricobuehler d8a7d6f3a2 ci(windows-drivers): provision WDK + cargo-wdk on the runner (rewrite M0)
apple / swift (push) Successful in 1m4s
ci / rust (push) Successful in 1m16s
ci / web (push) Successful in 43s
windows-drivers-provision / provision (push) Failing after 2m25s
ci / docs-site (push) Successful in 1m0s
android / android (push) Successful in 3m21s
apple / screenshots (push) Successful in 5m31s
deb / build-publish (push) Successful in 3m19s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m46s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m32s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m30s
docker / deploy-docs (push) Successful in 17s
The windows-amd64 runner has the base Windows SDK + MSVC + LLVM + Rust but NOT
the WDK (probed: km=False, no um/iddcx, no inf2cat/stampinf/devgen) or cargo-wdk,
so the all-Rust UMDF drivers can't build there yet. Adds an idempotent
provisioning script (scripts/ci/provision-windows-wdk.ps1: download wdksetup 26100
-> /q /norestart, cargo install --locked cargo-wdk, then verify km/wdf + iddcx
headers + inf2cat/stampinf + cargo-wdk) and a workflow_dispatch/push workflow that
runs it on the persistent runner (one-time; install persists).

cargo-wdk (not cargo-make) is windows-drivers-rs's current build+package tool
(cargo build -> stampinf/inf2cat/signtool). Driver builds must pin
Version_Number=10.0.26100.0 (the runner also has 10.0.28000.0, which lacks km/crt).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:20:55 +00:00
enricobuehler 8a04db9844 ci(windows-drivers): probe runner driver toolchain + build proto (rewrite M0)
windows-drivers / probe-and-proto (push) Successful in 42s
apple / swift (push) Successful in 1m4s
audit / cargo-audit (push) Failing after 1m19s
android / android (push) Successful in 4m7s
ci / web (push) Successful in 40s
ci / docs-site (push) Successful in 1m12s
ci / rust (push) Successful in 5m43s
windows-host / package (push) Successful in 6m26s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m17s
release / apple (push) Successful in 7m58s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m12s
deb / build-publish (push) Successful in 3m22s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 58s
decky / build-publish (push) Successful in 22s
ci / bench (push) Successful in 4m50s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m4s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m37s
apple / screenshots (push) Successful in 5m24s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 47s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m20s
flatpak / build-publish (push) Successful in 3m53s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m29s
docker / deploy-docs (push) Successful in 22s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m9s
Stage-1 CI for the Windows-host rewrite: a probe job on the self-hosted
windows-amd64 runner that reports the driver toolchain (WDK Include km/ +
iddcx versions, inf2cat/stampinf/devgen/signtool, EWDK, LLVM/clang version,
cargo-make, installed Rust targets) so we know what's provisioned BEFORE
writing driver code, and builds+tests+lints pf-vdisplay-proto on MSVC to prove
the owned ABI crate compiles cross-OS and the CI wiring works. No RTX GPU needed
for any of this (only live NVENC encode needs one — that defers to the RTX box).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 06:51:59 +00:00
enricobuehler 0b663cefb6 feat(windows): pf-vdisplay-proto — owned host<->driver ABI crate (rewrite M0)
First foundation of the Windows-host rewrite (docs/windows-host-rewrite.md): a
self-contained, no_std + bytemuck crate that defines the host<->driver binary
contract ONCE — the control-plane IOCTLs (add/remove/set-render-adapter/ping/
get-info/clear-all) and the IDD-push frame transport (SharedHeader, the
(gen<<40|seq<<8|slot) FrameToken, the Global\pfvd-* name scheme, driver-status
codes). Previously these were hand-duplicated byte-for-byte across
idd_push.rs/frame_transport.rs and sudovda.rs/control.rs with only "must match"
comments; here const size-asserts + bytemuck round-trips make any drift a COMPILE
error.

Clean break from SudoVDA: a freshly-minted interface GUID (not e5bcc234), a
contiguous 0x900 op space (not the gappy 0x800/0x888/0x8FF), a u64 session id (not
the 16-byte GUID + pid-mangling), a single u32 protocol version. Self-contained
(no workspace inheritance, no Windows deps) so the out-of-workspace driver build
graph can path-dep it identically. 7 tests green on Linux; clippy + fmt clean.

Also lands the full rewrite plan in docs/windows-host-rewrite.md (decisions:
greenfield; IDD-push primary incl. secure desktop, WGC+DDA demoted to fallbacks;
unify drivers on windows-drivers-rs + solve /INTEGRITYCHECK; keep GameStream,
default secure).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 06:49:50 +00:00
enricobuehler e2c9bfd3d9 feat(windows): pf-vdisplay IDD-push — HDR + pipelined zero-copy capture
apple / swift (push) Successful in 1m4s
windows-host / package (push) Successful in 6m28s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m14s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
release / apple (push) Successful in 7m53s
android / android (push) Successful in 10m33s
ci / web (push) Successful in 44s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m4s
ci / docs-site (push) Successful in 53s
ci / rust (push) Successful in 12m22s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m11s
apple / screenshots (push) Successful in 5m24s
deb / build-publish (push) Successful in 3m16s
decky / build-publish (push) Successful in 21s
ci / bench (push) Successful in 4m42s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m34s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m42s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m13s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 47s
flatpak / build-publish (push) Successful in 4m24s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m5s
docker / deploy-docs (push) Successful in 25s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m44s
HDR (display-driven, matching the WGC path):
- CTA-861.3 HDR EDID (BT.2020 primaries + HDR Static Metadata block) so Windows
  offers "Use HDR" on the virtual display. The host FOLLOWS the display's live
  advanced-color state, recreating the shared ring at the matching format
  (FP16 in HDR / BGRA in SDR) on a toggle — no freeze.
- Always emit Main10/BT.2020-PQ Rgb10a2 while the display is HDR; the client
  auto-detects PQ from the HEVC VUI (clients under-report VIDEO_CAP_10BIT).
  Generic HDR10 mastering SEI on every IDR.
- Generation-tagged `latest` (gen<<40|seq<<8|slot) + driver `is_stale` re-attach
  kill the toggle-time garbage frame and any stale-ring read.

Perf:
- Pipeline the encode loop (Capturer::pipeline_depth; IDD-push = 2): submit N+1
  before polling N so the convert/copy on the 3D engine overlaps the NVENC encode
  of N on the ASIC. PUNKTFUNK_IDD_DEPTH overrides (1 = synchronous).
- Rotating host output ring (OUT_RING) so the in-flight encode and the next
  convert never touch the same texture.
- HDR converts directly from the keyed-mutex slot's SRV into the output ring
  (drops the redundant slot->fp16 scratch copy); SDR copies the BGRA slot in.
  The slot mutex is held only across the convert/copy, not the encode.
  RING_LEN 3->6 for publish headroom.
- Capture-health diagnostic: new_fps vs repeat_fps under PUNKTFUNK_PERF (a low
  new_fps at a high send rate means the source isn't compositing, not an encode
  stall).

Validated live on the RTX box: 5120x1440@240 HDR streams; driver composes
~180 new fps, encode 240 fps @ ~4.3 ms p50.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:39:28 +02:00
enricobuehler c5dab484df feat(windows): bundle pf-vdisplay in the host installer; drop SudoVDA
Switch the Inno Setup installer's virtual-display driver from the vendored SudoVDA
C++ binary to our own all-Rust pf-vdisplay (validated streaming at 5120x1440@240).

- packaging/windows/pf-vdisplay/: vendored SIGNED driver (pf_vdisplay.dll/inf/cat +
  punktfunk-driver.cer, the same cert the gamepad drivers ship), built from
  vdisplay-driver/ via deploy-dev.ps1.
- install-pf-vdisplay.ps1 / stage-pf-vdisplay.ps1: mirror the SudoVDA scripts -
  trust cert -> gated ROOT\pf_vdisplay node via nefconc (NEVER devgen) -> pnputil
  /add-driver /install. Idempotent, best-effort (never aborts the install).
- punktfunk-host.iss + pack-host-installer.ps1: install the pf-vdisplay bundle
  under the existing installdriver task.
- Removed the vendored SudoVDA driver + install-sudovda.ps1 + stage-sudovda.ps1.
- README + windows-host.yml: SudoVDA -> pf-vdisplay.

The host's vdisplay/sudovda.rs backend is unchanged - it drives whichever driver
provides the {e5bcc234} interface, now pf-vdisplay. Live installer build/test on
the runner is the remaining step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:39:28 +02:00
enricobuehler e27abc065e feat(windows): pf-vdisplay CLEAR_ALL — reap orphaned virtual monitors on startup
The "5-6 stale monitors that never tear down" failure (also seen with SudoVDA):
an orphan from a crashed/killed previous host lingers because the driver watchdog
is kept reset by a still-pinging new session, so it never fires for the orphan.

- Driver (pf-vdisplay control.rs): new IOCTL_CLEAR_ALL (0x804) -> tear down every
  monitor. A pf-vdisplay extension; SudoVDA returns invalid for it (ignored), so
  the host can issue it unconditionally.
- Host (vdisplay/sudovda.rs): send IOCTL_CLEAR_ALL once on startup (best-effort)
  to reap orphans before creating ours; and surface a failing keepalive PING (the
  old `let _ =` swallowed it, masking a lost control handle).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:36:21 +02:00
enricobuehler d39da4bc06 feat(windows): pf-vdisplay — all-Rust IddCx virtual display (replaces SudoVDA)
P1 done: a pure-Rust UMDF2 IddCx driver, drop-in compatible with the host's
existing vdisplay/sudovda.rs control plane (the {e5bcc234} interface + the
SudoVDA IOCTL ABI), so the host drives it unchanged. Validated streaming on
glass at 5120x1440@240 — steady 240 fps, ~2.4 ms encode, clean teardown, full
parity with SudoVDA.

- Vendored wdf-umdf-sys / wdf-umdf bindgen crates (MIT, from virtual-display-rs)
  + the SDK-version build.rs fix that resolves the IddCxStub lib path by the WDK
  version actually containing um\x64\iddcx, not the max base SDK.
- pf-vdisplay crate: entry/callbacks/context/control/monitor/edid/
  swap_chain_processor. Our OWN 128-byte EDID (manufacturer PNK, product
  punktfunk — no SudoVDA bytes), a real swap-chain drain (faithful vdd port,
  required so DWM keeps compositing), the SudoVDA-compatible IOCTL control plane
  (ADD/REMOVE/PING/GET_WATCHDOG/GET_VERSION/SET_RENDER_ADAPTER) + a watchdog that
  tears down orphaned monitors when the host stops pinging.
- deploy-dev.ps1: stage + sign + stampinf (date.time DriverVer) + Inf2Cat +
  install, codifying the "bump DriverVer or pnputil keeps the old binary" gotcha.
- docs/windows-virtual-display-rust-port.md: investigation, the on-glass
  validation, and the two traps that cost time (Session-0 measurement +
  accumulated device-state needing a reboot).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:36:21 +02:00
enricobuehler 095540efc2 feat(android): native mDNS discovery, host naming, touch mouse, stock selects
apple / swift (push) Successful in 1m1s
android / android (push) Successful in 4m14s
ci / web (push) Successful in 39s
ci / docs-site (push) Successful in 54s
windows-host / package (push) Successful in 5m45s
ci / rust (push) Successful in 6m1s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m11s
release / apple (push) Successful in 7m45s
deb / build-publish (push) Successful in 2m40s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m9s
ci / bench (push) Successful in 4m43s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m18s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 46s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m56s
apple / screenshots (push) Successful in 5m22s
flatpak / build-publish (push) Successful in 6m32s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m32s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m47s
audit / cargo-audit (push) Failing after 1m13s
Discovery: replace the flaky per-OEM NsdManager with the same mdns-sd browse
the Linux/Windows clients use, in the Rust core over JNI and polled by Kotlin
(discovery.rs + nativeDiscovery{Start,Poll,Stop}); Kotlin keeps only the Wi-Fi
MulticastLock + permission UX. IPv4-only (the core can't dial a bare/scoped v6
literal); daemon + fold-thread cleanup on every failure path; field
sanitization so a rogue advert can't corrupt the picker snapshot. Discovery
now starts regardless of NEARBY_WIFI_DEVICES (raw multicast only needs the
MulticastLock) — a denial no longer kills it forever. ParseTxtTest replaced by
ParseRecordTest.

Hosts: hide already-saved hosts from the "Discovered" section (match by
fingerprint, else address:port — mirrors the Apple client); add an optional
Name field to the Add-host sheet and a Rename action on saved cards.

Input: touch -> absolute mouse "direct pointing" like the Apple client — the
host cursor follows the finger (new nativeSendPointerAbs -> MouseMoveAbs). Tap
= left click, two-finger tap = right click, two-finger drag = scroll,
tap-then-drag = left-drag, three-finger tap = HUD toggle.

Settings: revert the dropdowns to the stock ExposedDropdownMenuBox look (a
controller-focus UI will come separately); even out the Add-host field gaps.

Docs updated (CLAUDE.md, client READMEs, docs-site status).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:48:45 +02:00
enricobuehler de232ec2f7 fix(web): bundle deps into the server (noExternals) — kill the 47k-file install
apple / swift (push) Successful in 1m0s
ci / rust (push) Successful in 1m18s
ci / web (push) Successful in 43s
ci / docs-site (push) Successful in 1m4s
android / android (push) Successful in 3m26s
deb / build-publish (push) Successful in 2m37s
apple / screenshots (push) Successful in 5m9s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 25s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
windows-host / package (push) Successful in 6m51s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
ci / bench (push) Successful in 4m35s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 47s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m3s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m8s
docker / deploy-docs (push) Successful in 19s
The Windows installer ballooned to 154 MB and installed forever because the node-server
bundle externalized the WHOLE @unom/ui dependency tree (payload, lexical, date-fns,
prismjs…) to .output/server/node_modules — 47,567 files / 730 MB copied into Program
Files. Set Nitro `noExternals: true` so every dependency is bundled + tree-shaken into the
server output: .output drops to ~75 files / 10 MB, and the bare external imports
(srvx, seroval…) bun couldn't resolve at runtime are gone — so the console runs on bun
(no node, no node_modules), which is the issue we previously worked around with node.

Windows installer now ships bun.exe + the ~75-file .output (was node.exe + a node_modules
forest) and runs `bun .output\server\index.mjs`:
- windows-host.yml: fetch a pinned portable bun (build tool AND shipped runtime); drop the
  node fetch + the .output/server install; smoke-boot under the bundled bun.
- pack-host-installer.ps1 / punktfunk-host.iss: -NodeExe -> -BunExe; stage {app}\bun\bun.exe.
- web-run.cmd / build-web.ps1: run/restart on bun; docs updated.

Net win everywhere: the Linux .deb shrinks (node still runs the self-contained output), and
the docker web image — which already ran `bun run .output/server/index.mjs` with only
.output copied — is fixed (the externals had no node_modules to resolve at runtime).

Validated locally: noExternals build = 75 files / 10 MB; node AND bun both serve /login
(200) + static assets (200) + gate /api (401).

(A true single binary via `bun build --compile` is blocked for now: Nitro serves public
assets from an import.meta-relative path `--compile` doesn't embed (/$bunfs/public); the
75-file payload is the clean result.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:19:32 +02:00
enricobuehler e4e34fdb48 fix(apple/ci): create the Simulator on demand; scope CI shots to iPhone+iPad
apple / swift (push) Successful in 57s
release / apple (push) Successful in 7m19s
ci / rust (push) Successful in 1m25s
ci / web (push) Successful in 46s
android / android (push) Successful in 3m18s
ci / docs-site (push) Successful in 52s
apple / screenshots (push) Successful in 5m5s
deb / build-publish (push) Successful in 2m35s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
ci / bench (push) Successful in 4m32s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m13s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m28s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m20s
Diagnosed from the first run: only the iPad shots were produced. The runner
lacks an "iPhone 16 Pro Max" device, is headless (no window server -> the macOS
window capture's app window never appears), and the Tier-3 tvOS build-std slice
failed.

- screenshots.sh: shoot_sim now creates a throwaway Simulator (matching device
  type + newest available runtime) when the runner has no matching device, so
  the iPhone 6.9" shots are reproducible instead of skipped.
- apple.yml: scope the CI job to the two REQUIRED iOS sizes (iPhone 6.9" +
  iPad 13"), captured via `simctl io screenshot` (no Screen Recording grant
  needed). Drop macOS (headless runner has no window server) and tvOS (build-std
  slice) from CI — generate those locally with `tools/screenshots.sh macos tvos`.
  Faster, deterministic xcframework build (BUILD_IOS=1 only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:46:41 +02:00
enricobuehler 3ec462c2ea ci(apple): use upload-artifact@v3 for screenshots (Gitea has no v4 backend)
apple / swift (push) Successful in 1m0s
ci / rust (push) Successful in 1m18s
ci / web (push) Successful in 36s
ci / docs-site (push) Successful in 1m0s
android / android (push) Successful in 3m15s
deb / build-publish (push) Successful in 2m34s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
apple / screenshots (push) Successful in 5m30s
ci / bench (push) Successful in 4m42s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m35s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m24s
docker / deploy-docs (push) Successful in 17s
Gitea's artifact storage identifies as GHES, which @actions/artifact v2+
(upload-artifact@v4) refuses outright. v3 uses the older artifact API Gitea
supports; the downloaded artifact is still a zip. (The capture itself already
worked — 5 macOS scenes were produced; only the v4 upload failed.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:41:14 +02:00
enricobuehler 58f4dccc02 fix(windows-host): ISCC [Code] — don't put {tmp} inside a Pascal comment
apple / swift (push) Successful in 1m1s
ci / rust (push) Successful in 1m14s
ci / web (push) Successful in 37s
ci / docs-site (push) Successful in 1m1s
android / android (push) Successful in 3m22s
deb / build-publish (push) Successful in 2m42s
decky / build-publish (push) Successful in 48s
apple / screenshots (push) Failing after 5m50s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m45s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m38s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m11s
windows-host / package (push) Successful in 23m16s
ISCC aborted compiling the installer at the web-console [Code] section: a comment
`{ ... {tmp} is auto-cleaned. }` — Pascal `{ }` comments don't nest, so the `}` in
`{tmp}` closed the comment early and `is auto-cleaned. }` parsed as code ("Identifier
expected"). Reword to drop the brace. (All other {app}/{tmp} uses are `;` line-comments
or code strings, which are fine.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:21:41 +02:00
enricobuehler 32879f45bf feat(apple): App Store screenshot harness + CI zip artifact
apple / swift (push) Successful in 54s
release / apple (push) Successful in 8m1s
apple / screenshots (push) Failing after 6m42s
ci / rust (push) Successful in 1m25s
ci / web (push) Successful in 42s
android / android (push) Successful in 3m27s
ci / docs-site (push) Successful in 53s
ci / bench (push) Failing after 3m1s
deb / build-publish (push) Successful in 2m33s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m13s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m26s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m7s
A DEBUG-only "shot mode" renders one mock-populated screen full-bleed
(PUNKTFUNK_SHOT_SCENE=<name> -> ScreenshotHostView instead of ContentView),
so the OS can screenshot the REAL, fully-rendered UI. tools/screenshots.sh
drives it: screencapture for the mac window, `simctl io booted screenshot`
for the iOS/iPad/tvOS Simulators, at exactly the App Store Connect sizes.

ImageRenderer was tried first and rejected: it can't rasterize this app's
chrome (NavigationStack, Form/TabView, Liquid-Glass/NSVisualEffect all render
black or the "can't render" placeholder). Capturing the live window/Simulator
avoids that. Only the stream hero is synthetic (StreamView needs a live
connection) - a synthwave frame + the real glass HUD, overridable via
PUNKTFUNK_SHOT_HERO.

CI: a new `screenshots` job in apple.yml builds the iOS (+ tvOS best-effort)
xcframework slices, runs the harness per platform best-effort, and attaches
the result as a single zip artifact (punktfunk-appstore-screenshots). It is
isolated from the build/test job and skipped on PRs, so a capture gap (missing
Simulator runtime, or no Screen Recording grant for the mac window capture)
never reds the core signal.

Generated PNGs (clients/apple/screenshots/) are gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 19:44:03 +02:00
enricobuehler b54f781524 ci(windows-host): bootstrap bun + supply @unom token for the web build
apple / swift (push) Successful in 55s
ci / rust (push) Successful in 1m18s
ci / web (push) Successful in 36s
ci / docs-site (push) Successful in 1m1s
android / android (push) Successful in 3m24s
deb / build-publish (push) Successful in 2m37s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m44s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m25s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m19s
docker / deploy-docs (push) Successful in 17s
windows-host / package (push) Has been cancelled
The first windows-host run with the bundled console failed at "bun not found": the
self-hosted runner executes as SYSTEM, so the dev user's bun (and its ~/.npmrc with the
@unom registry token) aren't on PATH. Make the web-build step self-sufficient:

- Install bun via bun.sh/install.ps1 when it isn't already present (checking PATH +
  the SYSTEM/Public profile locations first), like deb.yml bootstraps it.
- Write the private @unom registry mapping + auth token (REGISTRY_TOKEN) into the SYSTEM
  home .npmrc so `bun install` can fetch the @unom packages — kept out of the project
  tree and the shipped .output bundle (.output\server\.npmrc stays mapping-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:39:23 +02:00
enricobuehler 5e106c51cf feat(windows-host): bundle + auto-run the web console in the installer
apple / swift (push) Successful in 56s
ci / rust (push) Successful in 1m15s
ci / web (push) Successful in 39s
windows-host / package (push) Failing after 2m30s
ci / docs-site (push) Successful in 59s
android / android (push) Successful in 3m16s
deb / build-publish (push) Successful in 2m37s
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 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
ci / bench (push) Successful in 4m40s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 46s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m22s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m25s
docker / deploy-docs (push) Successful in 22s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m23s
The Windows host installer shipped only the host exe + SudoVDA driver + FFmpeg, so a
fresh install had no web management console — required for basically every user (status,
paired devices, the PIN pairing flow). The console was only ever set up by hand on the
dev box (build-web.ps1 + a hand-made PunktfunkWeb task whose web-run.cmd wasn't even
committed). Bundle it into the same installer, mirroring the proven Linux punktfunk-web
deploy.

- windows-host.yml builds the Nitro node-server console (bun, deb.yml's shape) + fetches
  a pinned portable Node, smoke-boots it under node (/login == 200) to gate the build, and
  hands web/.output + node.exe to the pack script.
- pack-host-installer.ps1 gains -WebDir/-NodeExe and stages the .output tree, node, and
  the two new scripts into the non-WOW64-redirected build area.
- punktfunk-host.iss lays the payload into {app}\web\.output + {app}\node\node.exe, adds
  a wizard page for the console login password pre-filled with a crypto-random default
  (shown on the finish page; kept on upgrade), and runs web-setup.ps1.
- web-setup.ps1 writes the ACL'd %ProgramData%\punktfunk\web-password (Administrators +
  SYSTEM), registers the PunktfunkWeb scheduled task (boot, SYSTEM, restart-on-failure ->
  web-run.cmd -> node on :3000), opens inbound TCP 3000, and starts it. web-run.cmd
  sources the host's mgmt-token + the password and runs the bundled node.
- The console proxies the host's loopback mgmt API with the host's own
  %ProgramData%\punktfunk\mgmt-token (no host-code change). Uninstall removes the task +
  firewall rule.

Validated locally: bun build -> node-server bundle, node boot serves /login (200) and
gates /api (401). The Windows-only bits (ISCC compile, scheduled task, password page,
firewall) validate on the Windows runner CI + on-glass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:28:47 +02:00
enricobuehler d2746bd65a docs(roadmap): add WAN access, VRR passthrough, desktop QoL items
apple / swift (push) Successful in 57s
ci / docs-site (push) Successful in 1m2s
android / android (push) Successful in 3m26s
ci / rust (push) Successful in 1m14s
ci / web (push) Successful in 37s
ci / bench (push) Successful in 4m36s
deb / build-publish (push) Successful in 4m10s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 39s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m37s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m30s
WAN/anywhere access (NAT traversal + relay + QUIC migration), VRR/
adaptive-sync passthrough, and a desktop quality-of-life bullet
covering clipboard sync, multi-monitor, and virtual-webcam redirection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:35:41 +00:00
enricobuehler 9b840151e4 docs(roadmap): add surround & spatial (object) audio plan
apple / swift (push) Successful in 59s
ci / rust (push) Successful in 1m15s
ci / web (push) Successful in 37s
ci / docs-site (push) Successful in 1m1s
android / android (push) Successful in 3m17s
deb / build-publish (push) Successful in 2m36s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 40s
ci / bench (push) Successful in 4m39s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m23s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m27s
Near-term 7.1 channel bed; moonshot object-based spatial audio via
Wine/Proton (where dynamic objects are currently discarded) with
client-side head-tracked spatialization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:24:12 +00:00
enricobuehler a12c6e0ba4 docs(roadmap): add Magic multi-user support to planned
apple / swift (push) Successful in 56s
ci / web (push) Successful in 36s
ci / docs-site (push) Successful in 57s
deb / build-publish (push) Successful in 2m35s
ci / rust (push) Successful in 1m22s
android / android (push) Successful in 3m14s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 47s
ci / bench (push) Successful in 4m40s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m30s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m17s
docker / deploy-docs (push) Successful in 18s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:55:25 +00:00
enricobuehler b0c82333d2 feat(gamepad): pure-user-mode Windows DualShock 4 + Xbox 360 (drop ViGEm) + installer + multi-pad
audit / cargo-audit (push) Successful in 17s
apple / swift (push) Successful in 57s
android / android (push) Successful in 4m36s
ci / web (push) Successful in 34s
ci / docs-site (push) Successful in 52s
release / apple (push) Successful in 7m31s
ci / rust (push) Successful in 8m37s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
deb / build-publish (push) Successful in 2m35s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m18s
flatpak / build-publish (push) Successful in 4m0s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m31s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m22s
windows-host / package (push) Successful in 2m56s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m13s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m15s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m3s
Windows virtual gamepads now have zero external dependencies - ViGEmBus is removed.

- DualShock 4: Windows UMDF backend (inject/dualshock4_windows.rs + dualshock4_proto.rs),
  reusing the DualSense SwDeviceCreate game-detection identity fix. The one UMDF driver serves
  the DS5 or DS4 identity/descriptor/features/strings per a device_type byte the host stamps into
  shared memory. Driver also gains IOCTL_HID_GET_STRING and a 41-byte calibration feature.
- Xbox 360: a new UMDF2 XUSB companion driver (packaging/windows/xusb-driver/) that registers
  GUID_DEVINTERFACE_XUSB and answers the buffered XInput IOCTLs from a shared section, so classic
  XInputGetState/SetState work with no kernel bus driver. inject/gamepad_windows.rs is rewritten
  to drive it and the vigem-client dependency is removed. Xbox One folds to the 360 XInput path.
- Installer: vendor + pnputil-install the three UMDF drivers (packaging/windows/gamepad-drivers/
  + install-gamepad-drivers.ps1, wired into pack-host-installer.ps1 + punktfunk-host.iss).
- Multi-pad: the host stamps each pad index into the device Location (pszDeviceLocation); the
  driver reads it via WdfDeviceAllocAndQueryProperty to map its own *-shm-<index>, with
  UmdfHostProcessSharing=ProcessSharingDisabled giving each pad its own host (per-pad statics).

Validated live on the Windows host: Cyberpunk native DualSense detection, DS4 identity + descriptor,
XInputGetState + rumble round-trip, two pads -> two distinct XInput slots, and a full installer build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:35:03 +02:00
enricobuehler f208f3d92e style(host): blank line before the uniq comment so rustfmt is clean
apple / swift (push) Successful in 56s
ci / web (push) Successful in 48s
ci / docs-site (push) Successful in 1m13s
ci / rust (push) Successful in 4m11s
deb / build-publish (push) Successful in 2m16s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 3s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m57s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m46s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m37s
docker / deploy-docs (push) Successful in 5s
android / android (push) Successful in 3m3s
windows-host / package (push) Successful in 3m3s
dualshock4.rs left `cargo fmt --all --check` red on main (it landed with the
Windows-host DualSense work): a standalone comment placed directly after a line
ending in a trailing comment gets absorbed and re-aligned to the trailing-comment
column. A blank line before the comment block keeps rustfmt happy — and the
comment readable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:52:28 +02:00
enricobuehler 51de8ccbdb ci(release): run tvOS on the canary track alongside iOS/macOS
ci / rust (push) Failing after 29s
apple / swift (push) Successful in 55s
ci / web (push) Successful in 37s
ci / docs-site (push) Successful in 59s
android / android (push) Successful in 3m13s
deb / build-publish (push) Successful in 2m20s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m40s
release / apple (push) Successful in 7m27s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m47s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m39s
docker / deploy-docs (push) Successful in 16s
The canary/stable split (0205c7b) gated the tvOS archive/upload — and its
xcframework slice — to vX.Y.Z tags, while moving iOS/macOS onto canary main
pushes. No tag has been cut since (both existing tags predate the split), so
tvOS stopped reaching TestFlight entirely while iOS/macOS kept shipping on canary.

Build the tvOS tier-3 slice unconditionally again (BUILD_TVOS=1; the nightly
-Zbuild-std std is cached on the self-hosted runner) and drop the tag gate on the
tvOS step so its if: matches the iOS / macOS App Store steps exactly — tvOS now
uploads on canary main pushes + stable tags + dispatch, same as the others.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:47:14 +02:00
enricobuehler 118752c136 fix(apple): drive DualSense rumble over raw HID (CoreHaptics is silent on macOS)
apple / swift (push) Successful in 54s
release / apple (push) Successful in 5m3s
ci / rust (push) Failing after 31s
ci / web (push) Successful in 38s
ci / docs-site (push) Successful in 1m1s
android / android (push) Successful in 3m32s
deb / build-publish (push) Successful in 2m16s
decky / build-publish (push) Successful in 10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 3s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m41s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m27s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m2s
GameController's CHHapticEngine never reaches the DualSense's motors on macOS — its
adaptive triggers and lightbar work, but rumble stays silent (a documented platform
gap). Drive the motors directly via the DualSense HID output report instead, the way
SDL and the Linux hid-playstation driver do — the same report that already rumbles
the pad on a Linux host. Confirmed live on macOS.

- DualSenseHID (macOS): opens the Sony DualSense via IOHIDManager and writes the USB
  (0x02, 48 bytes) and Bluetooth (0x31, 78 bytes + CRC32) output reports through
  IOHIDDeviceSetReport. Allowed under the App Sandbox by the existing device.usb +
  device.bluetooth entitlements; coexists with GameController (non-seized open).
  Flags mirror the kernel driver (COMPATIBLE_VIBRATION | HAPTICS_SELECT +
  COMPATIBLE_VIBRATION2); valid_flag1 = 0 so a rumble report leaves the
  GameController-managed lightbar / triggers / player LEDs untouched.
- RumbleRenderer routes a DualSense to the HID backend and keeps CoreHaptics for
  every other pad, fixing both live sessions and the test panel (shared renderer).
- CoreHaptics path reworked too: bake the target intensity + an explicit sharpness
  into the continuous event (the dynamic-parameter scaling is silent on controller
  engines) and tear down outside the inout access to fix a latent exclusivity hazard.

Adds a DEBUG-only Settings -> Controllers -> "Test Controller" panel (ControllerTestView
+ ControllerTester) that shows live input and fires rumble / adaptive triggers /
lightbar / player LEDs straight at the pad, with a readout of the active rumble backend
("DualSense HID - USB/Bluetooth"). Used to validate the fix.

Tests: DualSenseHIDTests pins the USB/BT report layout and the BT CRC32 (canonical
0xCBF43926 check vector). Debug + release build clean; gamepad suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:16:41 +02:00
enricobuehler 9af8e9a7d9 docs(windows): point DualSense handoff at the deploy scripts
apple / swift (push) Successful in 54s
ci / rust (push) Failing after 30s
ci / web (push) Successful in 43s
android / android (push) Successful in 3m21s
ci / docs-site (push) Successful in 53s
deb / build-publish (push) Successful in 2m17s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m39s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m29s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m4s
docker / deploy-docs (push) Successful in 6s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:53:51 +00:00
enricobuehler e466814ef8 fix(windows): deploy-host reads build env from Machine scope
apple / swift (push) Successful in 55s
ci / rust (push) Failing after 31s
ci / web (push) Successful in 38s
deb / build-publish (push) Successful in 2m18s
decky / build-publish (push) Successful in 11s
ci / docs-site (push) Successful in 1m2s
android / android (push) Successful in 3m23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m37s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m13s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m21s
docker / deploy-docs (push) Successful in 17s
Reads PUNKTFUNK_NVENC_LIB_DIR/LIBCLANG_PATH/CMAKE_POLICY_VERSION_MINIMUM directly from
Machine scope into the process, so the build is correct even when the SSH/parent shell
predates setup-build-env.ps1 (env is inherited at spawn).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:48:29 +00:00
enricobuehler 95c6ceb072 chore(windows): persistent build env + one-call host/web deploy scripts
apple / swift (push) Successful in 54s
ci / rust (push) Failing after 30s
ci / web (push) Successful in 37s
ci / docs-site (push) Successful in 57s
android / android (push) Successful in 3m24s
deb / build-publish (push) Successful in 2m19s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m39s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m24s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m19s
docker / deploy-docs (push) Successful in 6s
scripts/windows/: setup-build-env.ps1 persists the NVENC build env (Machine scope:
PUNKTFUNK_NVENC_LIB_DIR, LIBCLANG_PATH, CMAKE_POLICY_VERSION_MINIMUM -- no FFMPEG_DIR, the
nvenc build doesn't link libavcodec). deploy-host.ps1 rebuilds --release --features nvenc and
restarts the PunktfunkHost service with .bak rollback on build/start failure. build-web.ps1
rebuilds the Nitro web console (bun build, node runtime) and restarts the PunktfunkWeb task.
README documents the flow -- a redeploy is now a single script call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:47:40 +00:00
enricobuehler e919fa6a2e docs(windows): DualSense in-game detection handoff
apple / swift (push) Successful in 57s
android / android (push) Failing after 43s
ci / rust (push) Failing after 30s
ci / web (push) Successful in 33s
ci / docs-site (push) Successful in 52s
deb / build-publish (push) Successful in 2m17s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m39s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m22s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m22s
docker / deploy-docs (push) Successful in 17s
windows-host / package (push) Successful in 2m51s
The virtual DualSense is a correct, complete DS5 at the HID level (SDL3 reports PS5) and
input works, but a game's native DualSense path (Cyberpunk) doesn't detect the
software-enumerated (SWD) device that SDL/HIDAPI accept. Captures the diagnosis, the on-box
layout + tools (SDL oracle, dualsense-windows-test, driver rebuild recipe), and the on-glass
next experiments (WGI/RawInput/GameInput enumeration) so the work continues from any machine
without agent memory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:34:58 +00:00
enricobuehler 6db3525e29 fix(gamepad): working per-session SwDeviceCreate for the Windows DualSense
create_swdevice now succeeds. The two requirements (each E_INVALIDARG otherwise): the
enumerator name must have no underscore (use "punktfunk"), and the completion callback is
mandatory (the docs mark pCallback [in], not optional -- NULL is rejected). Back on the
typed windows-rs SwDeviceCreate (a raw-FFI diagnosis confirmed it's the OS, not the
binding), parameterized by pad index (instance pf_pad_<index>), waiting on the callback.
Per-session device: created on connect, SwDeviceClose'd on drop -- no leftovers, no phantom.

Live-verified on the RTX box: device materializes, the UMDF driver binds, SDL3 identifies it
as a PS5 ("DualSense Wireless Controller"), input flows; removed on disconnect. The
dualsense-windows-test CLI now cycles input + prints any 0x02 feedback for diagnosis.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:34:58 +00:00
355 changed files with 43512 additions and 7491 deletions
+2 -2
View File
@@ -1,9 +1,9 @@
# Root build context is used only by web/Dockerfile, which needs web/ and # Root build context is used only by web/Dockerfile, which needs web/ and
# docs/api/openapi.json. Allowlist those; keep everything else (target/, .git, crates) # api/openapi.json. Allowlist those; keep everything else (target/, .git, crates)
# out of the context upload. # out of the context upload.
* *
!web !web
!docs/api/openapi.json !api/openapi.json
web/node_modules web/node_modules
web/.output web/.output
web/dist web/dist
+57
View File
@@ -2,6 +2,11 @@
# see scripts/ci/setup-macos-runner.sh). Builds the Rust core into # see scripts/ci/setup-macos-runner.sh). Builds the Rust core into
# PunktfunkCore.xcframework, then builds + tests the Swift package. Network-dependent # PunktfunkCore.xcframework, then builds + tests the Swift package. Network-dependent
# tests (RemoteFirstLightTests) self-skip without PUNKTFUNK_REMOTE_HOST. # tests (RemoteFirstLightTests) self-skip without PUNKTFUNK_REMOTE_HOST.
#
# A second job (`screenshots`) captures the App Store Connect screenshots of the REAL UI
# (mac window + iOS/iPad/tvOS Simulators, see clients/apple/tools/screenshots.sh) and attaches
# them to the run as a single zip artifact (`punktfunk-appstore-screenshots`). It is isolated
# from the build/test job and best-effort, so a capture gap never reds the core signal.
name: apple name: apple
on: on:
@@ -37,3 +42,55 @@ jobs:
- name: Test (unit + real-codec round trip; remote tests self-skip) - name: Test (unit + real-codec round trip; remote tests self-skip)
working-directory: clients/apple working-directory: clients/apple
run: swift test run: swift test
# App Store screenshots of the real UI, zipped and attached to the run as a build artifact.
# Skipped on PRs (cost); runs on main pushes + manual dispatch. Needs the build/test job green
# first, and is a separate job so a capture hiccup can never red the core signal.
#
# Scope = the two REQUIRED iOS sizes (iPhone 6.9" + iPad 13"), captured on the Simulator
# (`simctl io screenshot`, no Screen Recording grant needed). macOS and tvOS are deliberately
# NOT in CI: the self-hosted runner is headless (no window-server session), so the mac window
# capture can't run there; tvOS needs the Tier-3 build-std slice. Generate those two locally on
# a GUI Mac with `clients/apple/tools/screenshots.sh macos tvos`.
screenshots:
needs: swift
if: gitea.event_name != 'pull_request'
runs-on: macos-arm64
timeout-minutes: 75
steps:
- uses: actions/checkout@v4
- name: Rust toolchain + iOS Simulator targets
run: |
if ! command -v rustup >/dev/null && [ ! -x "$HOME/.cargo/bin/rustup" ]; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal
fi
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
dirname "$RUSTUP" >> "$GITHUB_PATH"
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
- name: Build PunktfunkCore.xcframework (mac + iOS slices)
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
- name: Capture screenshots (iPhone 6.9" + iPad 13"; auto-creates the Simulators)
working-directory: clients/apple
env:
SETTLE: "8" # Simulators settle slower than a local run
run: |
# Independent invocations: one platform failing skips it, not the other.
bash tools/screenshots.sh ios || echo "::warning::iOS (iPhone 6.9\") screenshots skipped"
bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped"
echo "Produced:"; ls -la screenshots || true
- name: Upload screenshots (zip artifact)
if: always()
# v3, not v4: Gitea's artifact backend identifies as GHES, which @actions/artifact v2+
# (upload-artifact@v4) refuses. v3 uses the older API Gitea supports; download is still a zip.
uses: actions/upload-artifact@v3
with:
name: punktfunk-appstore-screenshots
path: clients/apple/screenshots
if-no-files-found: warn
retention-days: 30
+1 -1
View File
@@ -24,7 +24,7 @@ on:
push: push:
branches: [main] branches: [main]
# The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every # The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every
# docs/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too. # design/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too.
paths: paths:
- 'clients/linux/**' - 'clients/linux/**'
- 'crates/punktfunk-core/**' - 'crates/punktfunk-core/**'
+10 -15
View File
@@ -46,8 +46,8 @@ name: release
on: on:
push: push:
# Canary: a relevant main push uploads the iOS + macOS builds to TestFlight (Apple's own # Canary: a relevant main push uploads the iOS + macOS + tvOS builds to TestFlight (Apple's
# canary channel) — no notarized DMG, no tvOS (those are stable-only; see the per-step gates). # own canary channel) — no notarized DMG (that's stable-only; see the per-step gates).
# Heavy on the shared mac-mini runner, so paths-filtered; the TestFlight steps are # Heavy on the shared mac-mini runner, so paths-filtered; the TestFlight steps are
# continue-on-error until the App Store Connect record exists, so this no-ops until then. # continue-on-error until the App Store Connect record exists, so this no-ops until then.
branches: [main] branches: [main]
@@ -118,16 +118,11 @@ jobs:
"$RUSTUP" toolchain install nightly --profile minimal "$RUSTUP" toolchain install nightly --profile minimal
"$RUSTUP" component add rust-src --toolchain nightly "$RUSTUP" component add rust-src --toolchain nightly
- name: Build PunktfunkCore.xcframework (mac + iOS; + tvOS on stable tags) - name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
# tvOS uses nightly -Zbuild-std (slow) — build it only for a real release, not on every # tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
# canary main push. # the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
run: | # same track as iOS/macOS (the nightly toolchain is installed unconditionally above).
TV="" run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh
case "$GITHUB_REF" in refs/tags/v*) TV="BUILD_TVOS=1" ;; esac
# `env` (not a bare prefix): a $TV-expanded `NAME=val` word is NOT re-promoted to a shell
# assignment, so `BUILD_IOS=1 $TV bash …` would try to RUN `BUILD_TVOS=1` (exit 127). env
# treats its leading NAME=val args as assignments post-expansion; empty $TV is a no-op.
env BUILD_IOS=1 $TV bash scripts/build-xcframework.sh
- name: Stage App Store Connect API key - name: Stage App Store Connect API key
env: env:
@@ -293,9 +288,9 @@ jobs:
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
- name: tvOS — archive + upload to TestFlight - name: tvOS — archive + upload to TestFlight
# Stable only — the tvOS xcframework slice is built just for releases (above), and the # Canary + stable, the same track as iOS/macOS — the tvOS xcframework slice is now built
# App Store Connect record + runner platform are tvOS prerequisites. # on every apple push (above), so this matches the iOS step's gate exactly.
if: startsWith(gitea.ref, 'refs/tags/v') && (gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true') if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
# Needs tvOS added to the App Store Connect app record + the tvOS platform installed # Needs tvOS added to the App Store Connect app record + the tvOS platform installed
# on the runner (xcodebuild -downloadPlatform tvOS). # on the runner (xcodebuild -downloadPlatform tvOS).
continue-on-error: true continue-on-error: true
@@ -0,0 +1,27 @@
# One-shot provisioning of the WDK + cargo-wdk onto the persistent self-hosted windows-amd64 runner, so
# the all-Rust UMDF drivers can build there (design/windows-host-rewrite.md, M0). The runner has the base
# Windows SDK + MSVC + LLVM + Rust but NOT the WDK (no km/wdf/iddcx headers) or cargo-wdk.
#
# Dispatch manually (workflow_dispatch). Idempotent: re-running is a near no-op once provisioned. The
# install persists on the runner (real box, not an ephemeral container), so this runs once, not per build.
name: windows-drivers-provision
on:
workflow_dispatch:
push:
branches: [main]
paths:
- 'scripts/ci/provision-windows-wdk.ps1'
- '.gitea/workflows/windows-drivers-provision.yml'
jobs:
provision:
runs-on: windows-amd64
timeout-minutes: 60
defaults:
run:
shell: pwsh
steps:
- uses: actions/checkout@v4
- name: Install WDK + cargo-wdk on the runner
run: ./scripts/ci/provision-windows-wdk.ps1
+150
View File
@@ -0,0 +1,150 @@
# Windows driver workspace CI — runs on the self-hosted Windows runner (home-windows-1, host mode;
# label windows-amd64). Part of the Windows-host rewrite (design/windows-host-rewrite.md, M0).
#
# Stage 1 (this file): PROBE the runner's driver toolchain (WDK / EWDK / cargo-make / LLVM / the
# inf2cat/stampinf/devgen/signtool tools) so we know what's provisioned BEFORE writing driver code,
# and build+test the owned ABI crate (pf-driver-proto) on MSVC to prove it compiles cross-OS and the
# CI wiring works. The runner has no RTX GPU — that's fine: builds, the IddCx bindgen/link, the
# /INTEGRITYCHECK self-sign-load, and (later) IDD-push frame flow on the basic display do not need one;
# only live NVENC encode does, which defers to the RTX box.
#
# shell: pwsh deliberately (PowerShell 5.1's Out-File -Encoding utf8 prepends a BOM that corrupts the
# first GITHUB_ENV line — see windows.yml).
name: windows-drivers
on:
workflow_dispatch:
push:
branches: [main]
paths:
- '.gitea/workflows/windows-drivers.yml'
- 'crates/pf-driver-proto/**'
- 'packaging/windows/drivers/**'
pull_request:
paths:
- '.gitea/workflows/windows-drivers.yml'
- 'crates/pf-driver-proto/**'
- 'packaging/windows/drivers/**'
# Driver builds need the WDK on the runner (provision once via windows-drivers-provision.yml).
jobs:
probe-and-proto:
runs-on: windows-amd64
timeout-minutes: 30
defaults:
run:
shell: pwsh
steps:
- uses: actions/checkout@v4
- name: Probe driver toolchain (informational — never fails the job)
continue-on-error: true
run: |
$ErrorActionPreference = 'Continue'
function head($t) { Write-Host ""; Write-Host "===== $t =====" }
head "Windows Kits roots"
$kits = @('C:\Program Files (x86)\Windows Kits\10', 'C:\Program Files\Windows Kits\10')
foreach ($k in $kits) { if (Test-Path $k) { Write-Host "found: $k" } }
head "SDK Include versions (um vs km — km => WDK present)"
foreach ($k in $kits) {
$inc = Join-Path $k 'Include'
if (Test-Path $inc) {
Get-ChildItem $inc -Directory | ForEach-Object {
$hasUm = Test-Path (Join-Path $_.FullName 'um')
$hasKm = Test-Path (Join-Path $_.FullName 'km')
$wdf = Test-Path (Join-Path $_.FullName 'km\wdf\umdf\2.31')
$iddcx = (Get-ChildItem (Join-Path $_.FullName 'um\iddcx') -Directory -ErrorAction SilentlyContinue | ForEach-Object { $_.Name }) -join ','
Write-Host ("{0,-16} um={1,-5} km={2,-5} wdf2.31={3,-5} iddcx=[{4}]" -f $_.Name, $hasUm, $hasKm, $wdf, $iddcx)
}
}
}
head "Driver tooling (inf2cat / stampinf / signtool / devgen / InfVerif)"
foreach ($tool in 'inf2cat.exe','stampinf.exe','signtool.exe','devgen.exe','InfVerif.exe','makecat.exe') {
$hits = @()
foreach ($k in $kits) {
$hits += Get-ChildItem -Path $k -Filter $tool -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1 -ExpandProperty FullName
}
$hits = $hits | Where-Object { $_ } | Select-Object -First 1
Write-Host ("{0,-14} -> {1}" -f $tool, ($(if ($hits) { $hits } else { 'NOT FOUND' })))
}
head "EWDK"
Write-Host ("EWDKROOT = " + ($env:EWDKROOT ?? '<unset>'))
head "LLVM / clang (bindgen 0.72 builds on the runner default clang)"
Write-Host ("LIBCLANG_PATH = " + ($env:LIBCLANG_PATH ?? '<unset>'))
$clang = Get-Command clang -ErrorAction SilentlyContinue
if ($clang) { & clang --version } else { Write-Host "clang: NOT on PATH" }
head "cargo-make (the gamepad drivers' build driver)"
$cm = & cargo make --version 2>&1; Write-Host $cm
head "Rust + targets"
& rustc -V; & cargo -V
Write-Host "installed targets:"; & rustup target list --installed
head "Env knobs the WDK build cares about"
Write-Host ("Version_Number = " + ($env:Version_Number ?? '<unset>'))
Write-Host ("CARGO_HOME = " + ($env:CARGO_HOME ?? '<unset>'))
Write-Host ("CARGO_TARGET_DIR (daemon) = " + ($env:CARGO_TARGET_DIR ?? '<unset>'))
- name: Build + test pf-driver-proto (MSVC)
run: |
# Short target dir to dodge MAX_PATH inside the deep act host workdir (see windows.yml).
$env:CARGO_TARGET_DIR = "C:\t\drv"
cargo build -p pf-driver-proto
cargo test -p pf-driver-proto
cargo clippy -p pf-driver-proto --all-targets -- -D warnings
cargo fmt -p pf-driver-proto -- --check
# Build the UMDF driver workspace (wdk-probe) on windows-drivers-rs: proves wdk-sys bindgen/link works
# on the runner's WDK + LLVM, that pf-driver-proto path-deps into a driver, and exposes the produced
# DLL's FORCE_INTEGRITY (/INTEGRITYCHECK) bit — the M0 self-signed-load question.
driver-build:
runs-on: windows-amd64
timeout-minutes: 45
defaults:
run:
shell: pwsh
# In-tree target dir on purpose: wdk-build's find_top_level_cargo_manifest() walks UP from OUT_DIR
# to the first ancestor with a Cargo.lock, so a relocated CARGO_TARGET_DIR (C:\t\…) hides the
# workspace lock and it panics. The driver deps have no deep CMake-from-source crates, so the
# default in-tree target stays well under MAX_PATH (unlike the SDL3/audiopus client build).
working-directory: packaging/windows/drivers
env:
# wdk-build otherwise picks 10.0.28000.0 (no km/crt) and bindgen fails — pin the WDK SDK version.
Version_Number: '10.0.26100.0'
# No LIBCLANG_PATH pin: the vendored bindgen 0.72 builds clean on the runner's default clang 22
# (the shipping pack proves it). A 0.71-era layout-test overflow once needed LLVM 21; the 0.72 bump
# retired that — see design/windows-build-and-packaging.md.
steps:
- uses: actions/checkout@v4
- name: Ensure WDK + cargo-wdk (idempotent self-provision)
# Run the provisioning script here too so driver-build is self-sufficient and never races a
# separate provision run on the single runner. Path is relative to the job working-directory
# (packaging/windows/drivers). Near-noop once the toolchain is present.
run: ../../../scripts/ci/provision-windows-wdk.ps1
- name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay)
# Whole workspace: wdk-probe (toolchain/surface-assert probe) + wdk-iddcx (DDI wrappers) +
# pf-vdisplay (the real IddCx driver). pf-vdisplay linking proves the IddCx call sites resolve
# against IddCxStub end-to-end (M1 step 2 gate).
run: cargo build -v
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
run: |
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
$dll = "target\x86_64-pc-windows-msvc\debug\pf_vdisplay.dll"
if (-not (Test-Path $dll)) { throw "pf_vdisplay.dll not produced at $dll" }
$b = [IO.File]::ReadAllBytes($dll)
$pe = [BitConverter]::ToInt32($b, 0x3c)
$dllchar = [BitConverter]::ToUInt16($b, $pe + 0x5e) # OptionalHeader.DllCharacteristics
Write-Host ("pf_vdisplay.dll built OK ({0:N0} bytes)" -f (Get-Item $dll).Length)
Write-Host ("BEFORE: DllCharacteristics = 0x{0:X4}; FORCE_INTEGRITY = {1}" -f $dllchar, (($dllchar -band 0x0080) -ne 0))
- name: Clear FORCE_INTEGRITY (self-signed-load fix) + verify
# wdk-build sets /INTEGRITYCHECK unconditionally -> a self-signed driver won't load. Clear the PE
# bit deterministically (the reusable packaging step; signing/.cat happen later for real drivers).
run: ../clear-force-integrity.ps1 -Path target\x86_64-pc-windows-msvc\debug\pf_vdisplay.dll
+85 -2
View File
@@ -1,6 +1,7 @@
# Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic # Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic
# package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled # package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled
# SudoVDA virtual-display driver) from one signed setup.exe. Runs on the self-hosted Windows runner # pf-vdisplay virtual-display driver + the web management console, run by a scheduled task on a bundled
# bun) from one signed setup.exe. Runs on the self-hosted Windows runner
# (host mode; scripts/ci/setup-windows-runner.ps1) — same MSVC/Windows-SDK/LLVM env as windows.yml. # (host mode; scripts/ci/setup-windows-runner.ps1) — same MSVC/Windows-SDK/LLVM env as windows.yml.
# #
# Why an installer and not MSIX (like the client): the host installs a LocalSystem SCM service that # Why an installer and not MSIX (like the client): the host installs a LocalSystem SCM service that
@@ -35,7 +36,8 @@ on:
- 'crates/punktfunk-host/**' - 'crates/punktfunk-host/**'
- 'crates/punktfunk-core/**' - 'crates/punktfunk-core/**'
- 'packaging/windows/**' - 'packaging/windows/**'
- 'scripts/windows/host.env.example' - 'scripts/windows/**'
- 'web/**'
- 'Cargo.lock' - 'Cargo.lock'
- 'Cargo.toml' - 'Cargo.toml'
- '.gitea/workflows/windows-host.yml' - '.gitea/workflows/windows-host.yml'
@@ -54,6 +56,22 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Locale-safety gate (installer-run scripts must be ASCII)
shell: pwsh
# The installer runs these via powershell.exe (Windows PowerShell 5.1) and cmd.exe on the END
# USER's box. PS 5.1 reads a BOM-less script in the active ANSI codepage, so on a non-UTF-8 locale
# (e.g. German Windows-1252) a stray em-dash mis-decodes into a curly quote and the script aborts
# with "unterminated string" - exactly how the pf-vdisplay driver install silently failed in the
# field. Keep every installer-run script pure ASCII (matches install-gamepad-drivers.ps1).
run: |
$bad = Get-ChildItem packaging/windows/*.ps1, scripts/windows/*.ps1, scripts/windows/*.cmd -ErrorAction SilentlyContinue |
Where-Object { [IO.File]::ReadAllText($_.FullName) -match '[^\x00-\x7F]' }
if ($bad) {
$bad.FullName | ForEach-Object { Write-Output "::error::non-ASCII in installer-run script: $_" }
throw "installer-run scripts must be pure ASCII (PS 5.1 mis-parses them on non-UTF-8 locales)"
}
Write-Output "installer-run scripts are ASCII-clean"
- name: Configure + version - name: Configure + version
shell: pwsh shell: pwsh
run: | run: |
@@ -94,6 +112,18 @@ jobs:
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code). # First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
run: cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings run: cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
shell: pwsh
# Standalone cdylib (own [workspace]) the installer bundles + registers (it lets Vulkan games
# like Doom use HDR on the virtual display). Lint here so a regression fails CI instead of
# silently shipping the host without the layer (pack-host-installer.ps1 builds it non-fatally).
# Windows-only FFI (user32 + the vk_layer loader glue) → can't be linted on the Linux CI.
run: |
Push-Location packaging/windows/pf-vkhdr-layer
cargo fmt --check; if ($LASTEXITCODE) { throw "pf-vkhdr-layer rustfmt" }
cargo clippy --release -- -D warnings; if ($LASTEXITCODE) { throw "pf-vkhdr-layer clippy" }
Pop-Location
- name: Ensure Inno Setup - name: Ensure Inno Setup
shell: pwsh shell: pwsh
run: | run: |
@@ -102,6 +132,59 @@ jobs:
choco install innosetup -y --no-progress choco install innosetup -y --no-progress
} }
- name: Fetch portable bun runtime (build tool + bundled to run the console)
shell: pwsh
run: |
# ONE pinned bun, used both to BUILD the console and shipped in the installer to RUN it. The
# .output is self-contained (Nitro noExternals — deps bundled + tree-shaken, no node_modules),
# so the installer ships just bun + a ~75-file .output instead of node + a node_modules forest.
$ver = 'bun-v1.3.14'
$url = "https://github.com/oven-sh/bun/releases/download/$ver/bun-windows-x64.zip"
New-Item -ItemType Directory -Force -Path C:\t | Out-Null
$zip = 'C:\t\bun.zip'; $dst = 'C:\t\bundist'
Invoke-WebRequest -Uri $url -OutFile $zip
if (Test-Path $dst) { Remove-Item $dst -Recurse -Force }
Expand-Archive -Path $zip -DestinationPath $dst -Force
$bun = (Get-ChildItem -Path $dst -Recurse -Filter bun.exe | Select-Object -First 1).FullName
if (-not $bun) { throw "bun.exe not found in $url" }
"BUN_EXE=$bun" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
& $bun --version
- name: Build + smoke-boot web console (bun)
shell: pwsh
env:
# PAT with read access to the unom org packages — the @unom npm registry needs auth to BUILD.
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
# The bun fetched above builds the Nitro server AND runs it. noExternals (vite.config) makes the
# output self-contained, so there's no .output/server install — the installer ships bun + the
# ~75-file .output. The runner is SYSTEM with no ~/.npmrc, so supply the private @unom token in
# the SYSTEM home .npmrc to BUILD (kept OUT of the shipped bundle — web\.npmrc has only the
# registry mapping, and nothing copies it into .output).
run: |
$bun = $env:BUN_EXE
if ($env:REGISTRY_TOKEN) {
$rc = Join-Path $env:USERPROFILE '.npmrc'
Add-Content -Path $rc -Value '@unom:registry=https://git.unom.io/api/packages/unom/npm/'
Add-Content -Path $rc -Value "//git.unom.io/api/packages/unom/npm/:_authToken=$env:REGISTRY_TOKEN"
}
Push-Location web
& $bun install --frozen-lockfile; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
& $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" }
if (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet) {
throw "web build is a bun bundle (Bun.serve) - need the node-server preset"
}
Pop-Location
# Gate the installer on a real boot under the BUNDLED bun (the runtime it ships), serving /login.
$env:PORT = '3009'; $env:HOST = '127.0.0.1'; $env:PUNKTFUNK_UI_PASSWORD = 'ci'
$server = (Resolve-Path 'web\.output\server\index.mjs').Path
$p = Start-Process -FilePath $bun -ArgumentList $server -PassThru -WindowStyle Hidden
Start-Sleep -Seconds 4
try { $code = (Invoke-WebRequest -Uri 'http://127.0.0.1:3009/login' -UseBasicParsing -TimeoutSec 10).StatusCode } catch { $code = 0 }
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
Write-Output "web console smoke (bun): /login -> $code"
if ($code -ne 200) { throw "web console failed to boot under bun" }
"WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Pack + sign installer - name: Pack + sign installer
shell: pwsh shell: pwsh
env: env:
+2
View File
@@ -11,6 +11,8 @@ dist/
clients/apple/.build/ clients/apple/.build/
clients/apple/PunktfunkCore.xcframework/ clients/apple/PunktfunkCore.xcframework/
clients/apple/.swiftpm/ clients/apple/.swiftpm/
# Generated App Store screenshots (tools/screenshots.sh output; uploaded as a CI artifact)
clients/apple/screenshots/
# Xcode per-user state # Xcode per-user state
xcuserdata/ xcuserdata/
+44 -14
View File
@@ -2,7 +2,7 @@
Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protocol core Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protocol core
(`punktfunk-core`) exposed over a C ABI and native clients per platform. Full design: (`punktfunk-core`) exposed over a C ABI and native clients per platform. Full design:
[`docs/implementation-plan.md`](docs/implementation-plan.md). Status table: `README.md`. [`design/implementation-plan.md`](design/implementation-plan.md). Status table: `README.md`.
## Where the work stands ## Where the work stands
@@ -27,7 +27,15 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
Input: mouse/keyboard (libei via RemoteDesktop portal on KWin/GNOME, gamescope's own EIS Input: mouse/keyboard (libei via RemoteDesktop portal on KWin/GNOME, gamescope's own EIS
socket, wlr protocols on Sway) and **gamepads** (uinput X-Box-360 pads + rumble socket, wlr protocols on Sway) and **gamepads** (uinput X-Box-360 pads + rumble
back-channel; validated live — pad created/destroyed with the session). Management REST API + back-channel; validated live — pad created/destroyed with the session). Management REST API +
checked-in OpenAPI doc (`mgmt.rs`). checked-in OpenAPI doc (`mgmt.rs`). **Web-console performance capture** (`stats_recorder.rs`,
design: [`design/stats-capture-plan.md`](design/stats-capture-plan.md)): the operator arms stats
recording from the web console, plays, stops, and reviews the run as graphs (per-stage latency
breakdown · fps new/repeat · goodput · loss/FEC). A shared `Arc<StatsRecorder>` ring (the hot-path
gate is a runtime `AtomicBool`, replacing the startup-only `PUNKTFUNK_PERF`) is fed by **both** the
native `virtual_stream` and the GameStream encode loop at their existing ~2 s/~1 s aggregation
boundary, and finished captures are saved as on-disk recordings
(`~/.config/punktfunk/captures/*.json`) browsable/exportable from the console's **Performance** page
(recharts). Endpoints `/api/v1/stats/*` (bearer-only). *Implemented; not yet on-glass validated.*
- **Native protocol (`punktfunk/1`): full session planes, validated live.** QUIC - **Native protocol (`punktfunk/1`): full session planes, validated live.** QUIC
control plane (`punktfunk-core` `quic` feature: Hello{mode}/Welcome{full Config}/Start), data control plane (`punktfunk-core` `quic` feature: Hello{mode}/Welcome{full Config}/Start), data
plane = the hardened core `Session` over raw UDP with **GF(2¹⁶) Leopard FEC + AES-GCM** plane = the hardened core `Session` over raw UDP with **GF(2¹⁶) Leopard FEC + AES-GCM**
@@ -70,10 +78,23 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
capability; impulse-trigger rumble is unreachable through a virtual pad), and the UHID capability; impulse-trigger rumble is unreachable through a virtual pad), and the UHID
`hid-playstation` pads — **DualSense** (adaptive triggers, lightbar, touchpad, motion) and `hid-playstation` pads — **DualSense** (adaptive triggers, lightbar, touchpad, motion) and
**DualShock 4** (lightbar, touchpad, motion, rumble; DualSense minus adaptive triggers / player **DualShock 4** (lightbar, touchpad, motion, rumble; DualSense minus adaptive triggers / player
LEDs / mute). The UHID pads need a Linux host; off Linux they (and One/Series) fold into Xbox 360. LEDs / mute). DualSense and DualShock 4 each have a Linux (UHID `hid-playstation`) **and a Windows
Clients auto-resolve the type from the physical controller (DS5→DualSense, DS4→DualShock 4, (UMDF minidriver)** backend — `inject/dualsense_windows.rs` + `inject/dualshock4_windows.rs`, one
Xbox One→Xbox One). Windows-host DualShock 4 (ViGEm) is not yet wired — Windows clients asking for driver serving either identity per a `device_type` byte the host stamps into shared memory (the DS4
DS4 get Xbox 360 for now. reuses the same SwDeviceCreate game-detection identity fix as the DualSense). One/Series stays
Linux-only and folds into Xbox 360 off it. Clients auto-resolve the type from the physical controller
(DS5→DualSense, DS4→DualShock 4, Xbox One→Xbox One). **Windows uses ZERO external gamepad
dependencies — ViGEmBus is gone.** Xbox 360 (XInput) runs on a UMDF2 **XUSB companion** driver
(`packaging/windows/xusb-driver/`, `inject/gamepad_windows.rs`) that registers `GUID_DEVINTERFACE_XUSB`
and answers the buffered XInput IOCTLs from a shared section, so classic `XInputGetState`/`SetState`
work with no kernel bus driver (validated live: slot connected, state + rumble round-trip; Xbox One
folds to this 360 path). All three UMDF drivers (DualSense/DS4 + XUSB) are bundled + pnputil-installed
by the Inno Setup installer (`packaging/windows/gamepad-drivers/` + `install-gamepad-drivers.ps1`).
**Multi-pad ready**: the host stamps each pad's index into the device Location (`pszDeviceLocation`),
the driver reads it (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-<index>`, and
`UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own host (per-pad statics) —
validated live with 2 distinct XInput slots + 2 DualSense pads. (Client-side multi-pad forwarding is
the remaining piece.)
- **Windows host: implemented and shipping (all-vendor, x64-only).** `#[cfg(windows)]` backends - **Windows host: implemented and shipping (all-vendor, x64-only).** `#[cfg(windows)]` backends
behind the same traits as Linux — DXGI Desktop Duplication capture (`capture/dxgi.rs`), **SudoVDA** behind the same traits as Linux — DXGI Desktop Duplication capture (`capture/dxgi.rs`), **SudoVDA**
virtual display per session (`vdisplay/sudovda.rs`), GPU encode (NVENC `--features nvenc`; AMD/Intel virtual display per session (`vdisplay/sudovda.rs`), GPU encode (NVENC `--features nvenc`; AMD/Intel
@@ -91,9 +112,16 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
captures the HDR desktop as FP16/Rgb10a2 (DDA FP16 for the secure desktop), the encoder forces HEVC captures the HDR desktop as FP16/Rgb10a2 (DDA FP16 for the secure desktop), the encoder forces HEVC
Main10 + BT.2020 PQ (NVENC ABGR10/P010; AMF/QSV P010 + a swscale Rgb10a2→P010 fallback), the client Main10 + BT.2020 PQ (NVENC ABGR10/P010; AMF/QSV P010 + a swscale Rgb10a2→P010 fallback), the client
auto-detects PQ from the HEVC VUI — gated by `PUNKTFUNK_10BIT` + client `VIDEO_CAP_10BIT`; **Windows auto-detects PQ from the HEVC VUI — gated by `PUNKTFUNK_10BIT` + client `VIDEO_CAP_10BIT`; **Windows
host only** (the Linux host stays 8-bit, blocked upstream). **AMF/QSV is CI-green but not yet host only** (the Linux host stays 8-bit, blocked upstream). **Vulkan-game HDR over the virtual
on-glass validated** (no AMD/Intel Windows box in the lab); NVENC is live-validated. Newer/less display**: NVIDIA/AMD Vulkan ICDs refuse to *advertise* an HDR color space for a surface on an IddCx
battle-tested than the Linux host. Packaging: `packaging/windows/`. indirect display (so Vulkan games — Doom: The Dark Ages, id Tech, etc. — say "device does not support
HDR"), even though the ICD happily *accepts + presents* a forced HDR swapchain there. A tiny always-on
Vulkan **implicit layer** (`packaging/windows/pf-vkhdr-layer/`, `VK_LAYER_PUNKTFUNK_hdr_inject`)
injects the `HDR10_ST2084`/scRGB surface formats into `vkGetPhysicalDeviceSurfaceFormats[2]KHR`,
self-gated on the display's actual advanced-color state (no-op on SDR / real monitors); bundled +
HKLM-registered by the installer. **Live-validated: Doom: The Dark Ages enables HDR over the virtual
display.** **AMF/QSV is CI-green but not yet on-glass validated** (no AMD/Intel Windows box in the
lab); NVENC is live-validated. Newer/less battle-tested than the Linux host. Packaging: `packaging/windows/`.
## What's left ## What's left
@@ -186,7 +214,9 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
`punktfunk-core`; phone + Android TV): NDK `AMediaCodec` hardware HEVC decode → `SurfaceView` incl. `punktfunk-core`; phone + Android TV): NDK `AMediaCodec` hardware HEVC decode → `SurfaceView` incl.
**HDR10** (Main10/BT.2020 PQ) with low-latency tuning + a live stats HUD (`decode.rs`/`stats.rs`), **HDR10** (Main10/BT.2020 PQ) with low-latency tuning + a live stats HUD (`decode.rs`/`stats.rs`),
Opus/Oboe audio + mic uplink (`audio.rs`/`mic.rs`), gamepad input with rumble/HID feedback Opus/Oboe audio + mic uplink (`audio.rs`/`mic.rs`), gamepad input with rumble/HID feedback
(`feedback.rs`), `NsdManager` mDNS discovery, SPAKE2 PIN pairing + TOFU (Keystore identity + (`feedback.rs`), **native `mdns-sd` mDNS discovery** (`discovery.rs`, polled over JNI — the same
browse the Linux/Windows clients use, replacing the flaky per-OEM `NsdManager`; Kotlin keeps only
the `MulticastLock` + permission UX), SPAKE2 PIN pairing + TOFU (Keystore identity +
known-host store), Compose UI (Connect/Settings/Stream) with D-pad/controller focus nav. Built for known-host store), Compose UI (Connect/Settings/Stream) with D-pad/controller focus nav. Built for
`arm64-v8a` + `x86_64`; published to Google Play (Internal Testing) via `android.yml` `arm64-v8a` + `x86_64`; published to Google Play (Internal Testing) via `android.yml`
(`ci/play-upload.py`). Next: real-device gamepad/HDR live-verify, presenter/latency polish. (`ci/play-upload.py`). Next: real-device gamepad/HDR live-verify, presenter/latency polish.
@@ -230,8 +260,8 @@ bash crates/punktfunk-core/tests/c/run.sh # standalone C-ABI link + round-trip
``` ```
Generated artifacts are **checked in** and CI fails on drift: `include/punktfunk_core.h` Generated artifacts are **checked in** and CI fails on drift: `include/punktfunk_core.h`
(cbindgen from `punktfunk-core/src/abi.rs`) and `docs/api/openapi.json` (regenerate with (cbindgen from `punktfunk-core/src/abi.rs`) and `api/openapi.json` (regenerate with
`cargo run -p punktfunk-host -- openapi > docs/api/openapi.json`; spec lives in `mgmt.rs`). `cargo run -p punktfunk-host -- openapi > api/openapi.json`; spec lives in `mgmt.rs`).
CI is Gitea Actions (`.gitea/workflows/`, guide: docs-site `ci.md`): `ci.yml` runs the CI is Gitea Actions (`.gitea/workflows/`, guide: docs-site `ci.md`): `ci.yml` runs the
workspace checks inside the `git.unom.io/unom/punktfunk-rust-ci` image plus web/docs-site workspace checks inside the `git.unom.io/unom/punktfunk-rust-ci` image plus web/docs-site
@@ -253,7 +283,7 @@ crates/punktfunk-host/
zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan) zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
inject/{libei,wlr,gamepad,dualsense}.rs input backends (uinput xpad + UHID DualSense) inject/{libei,wlr,gamepad,dualsense}.rs input backends (uinput xpad + UHID DualSense)
encode/{nvenc,linux,vaapi,ffmpeg_win,sw}.rs per-GPU encoders (NVENC · Linux NVENC/CUDA · VAAPI · AMF/QSV · openh264) encode/{nvenc,linux,vaapi,ffmpeg_win,sw}.rs per-GPU encoders (NVENC · Linux NVENC/CUDA · VAAPI · AMF/QSV · openh264)
capture.rs · encode.rs · audio.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs capture.rs · encode.rs · audio.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs · stats_recorder.rs
clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool) clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool)
clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3) clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3) clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3)
@@ -261,7 +291,7 @@ clients/apple/ native macOS/iOS/tvOS client (Swift · VideoToolbox · GameCon
clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core) clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core)
clients/decky/ Steam Deck Decky plugin clients/decky/ Steam Deck Decky plugin
crates/punktfunk-host/src/{capture/dxgi,vdisplay/sudovda,encode/ffmpeg_win,inject/gamepad_windows,audio/wasapi_*,service}.rs Windows host backends crates/punktfunk-host/src/{capture/dxgi,vdisplay/sudovda,encode/ffmpeg_win,inject/gamepad_windows,audio/wasapi_*,service}.rs Windows host backends
web/ TanStack web console over the mgmt API (status · devices · pairing) web/ TanStack web console over the mgmt API (status · devices · pairing · performance graphs)
packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs) packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs)
tools/{loss-harness,latency-probe}/ measurement (plan §10) tools/{loss-harness,latency-probe}/ measurement (plan §10)
scripts/ 60-punktfunk.rules · punktfunk-host.service · host.env.example · headless/ scripts/ 60-punktfunk.rules · punktfunk-host.service · host.env.example · headless/
Generated
+446 -33
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 3
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "aead" name = "aead"
version = "0.5.2" version = "0.5.2"
@@ -453,6 +459,20 @@ name = "bytemuck"
version = "1.25.0" version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
dependencies = [
"bytemuck_derive",
]
[[package]]
name = "bytemuck_derive"
version = "1.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "bytes" name = "bytes"
@@ -721,6 +741,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "criterion" name = "criterion"
version = "0.5.1" version = "0.5.1"
@@ -996,6 +1025,18 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]] [[package]]
name = "fastbloom" name = "fastbloom"
version = "0.14.1" version = "0.14.1"
@@ -1074,6 +1115,16 @@ version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]] [[package]]
name = "flume" name = "flume"
version = "0.11.1" version = "0.11.1"
@@ -1097,6 +1148,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -1572,7 +1629,16 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [ dependencies = [
"foldhash", "foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"foldhash 0.2.0",
] ]
[[package]] [[package]]
@@ -1580,6 +1646,18 @@ name = "hashbrown"
version = "0.17.1" version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "hashlink"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b"
dependencies = [
"hashbrown 0.17.1",
]
[[package]] [[package]]
name = "heck" name = "heck"
@@ -1698,12 +1776,115 @@ dependencies = [
"tower-service", "tower-service",
] ]
[[package]]
name = "icu_collections"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]] [[package]]
name = "id-arena" name = "id-arena"
version = "2.3.0" version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]] [[package]]
name = "if-addrs" name = "if-addrs"
version = "0.15.0" version = "0.15.0"
@@ -1952,12 +2133,29 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "libsqlite3-sys"
version = "0.38.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.12.1" version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -2052,6 +2250,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.1" version = "1.2.1"
@@ -2404,6 +2612,13 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-driver-proto"
version = "0.0.1"
dependencies = [
"bytemuck",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@@ -2477,6 +2692,15 @@ dependencies = [
"universal-hash", "universal-hash",
] ]
[[package]]
name = "potential_utf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
"zerovec",
]
[[package]] [[package]]
name = "powerfmt" name = "powerfmt"
version = "0.2.0" version = "0.2.0"
@@ -2547,6 +2771,7 @@ dependencies = [
"jni", "jni",
"libc", "libc",
"log", "log",
"mdns-sd",
"ndk", "ndk",
"opus", "opus",
"punktfunk-core", "punktfunk-core",
@@ -2633,6 +2858,8 @@ dependencies = [
"audiopus_sys", "audiopus_sys",
"axum", "axum",
"axum-server", "axum-server",
"base64",
"bytemuck",
"cbc", "cbc",
"ffmpeg-next", "ffmpeg-next",
"futures-util", "futures-util",
@@ -2647,13 +2874,16 @@ dependencies = [
"nvidia-video-codec-sdk", "nvidia-video-codec-sdk",
"openh264", "openh264",
"opus", "opus",
"pf-driver-proto",
"pipewire", "pipewire",
"punktfunk-core", "punktfunk-core",
"quinn", "quinn",
"rand 0.8.6", "rand 0.8.6",
"rcgen", "rcgen",
"reis", "reis",
"roxmltree",
"rsa", "rsa",
"rusqlite",
"rustls", "rustls",
"rustls-pemfile", "rustls-pemfile",
"rusty_enet", "rusty_enet",
@@ -2665,10 +2895,10 @@ dependencies = [
"tower", "tower",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"ureq",
"utoipa", "utoipa",
"utoipa-axum", "utoipa-axum",
"utoipa-scalar", "utoipa-scalar",
"vigem-client",
"wasapi", "wasapi",
"wayland-backend", "wayland-backend",
"wayland-client", "wayland-client",
@@ -2677,6 +2907,7 @@ dependencies = [
"wayland-scanner", "wayland-scanner",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
"windows-service", "windows-service",
"winreg",
"x509-parser", "x509-parser",
"xkbcommon", "xkbcommon",
] ]
@@ -2979,6 +3210,15 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "roxmltree"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "rpkg-config" name = "rpkg-config"
version = "0.1.2" version = "0.1.2"
@@ -3005,6 +3245,31 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rsqlite-vfs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.18",
]
[[package]]
name = "rusqlite"
version = "0.40.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.2" version = "2.1.2"
@@ -3455,6 +3720,12 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.3" version = "1.0.3"
@@ -3525,6 +3796,24 @@ dependencies = [
"der", "der",
] ]
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"cc",
"js-sys",
"rsqlite-vfs",
"wasm-bindgen",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"
@@ -3677,6 +3966,16 @@ dependencies = [
"time-core", "time-core",
] ]
[[package]]
name = "tinystr"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]] [[package]]
name = "tinytemplate" name = "tinytemplate"
version = "1.2.1" version = "1.2.1"
@@ -3982,6 +4281,40 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"url",
"webpki-roots 0.26.11",
]
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "utf8parse" name = "utf8parse"
version = "0.2.2" version = "0.2.2"
@@ -4072,15 +4405,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vigem-client"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b857e6f99efe1e1eb1e4dfb035de8ae7ec8ec56bd1928edcbd7c6e4427634d52"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "wait-timeout" name = "wait-timeout"
version = "0.2.1" version = "0.2.1"
@@ -4318,6 +4642,24 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.8",
]
[[package]]
name = "webpki-roots"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "wide" name = "wide"
version = "0.7.33" version = "0.7.33"
@@ -4334,22 +4676,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.11" version = "0.1.11"
@@ -4359,12 +4685,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]] [[package]]
name = "windows" name = "windows"
version = "0.62.2" version = "0.62.2"
@@ -4865,6 +5185,16 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "winreg"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.51.0" version = "0.51.0"
@@ -4959,6 +5289,12 @@ dependencies = [
"wasmparser", "wasmparser",
] ]
[[package]]
name = "writeable"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]] [[package]]
name = "x509-parser" name = "x509-parser"
version = "0.16.0" version = "0.16.0"
@@ -5002,6 +5338,29 @@ dependencies = [
"time", "time",
] ]
[[package]]
name = "yoke"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]] [[package]]
name = "zbus" name = "zbus"
version = "5.16.0" version = "5.16.0"
@@ -5078,12 +5437,66 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]] [[package]]
name = "zeroize" name = "zeroize"
version = "1.8.2" version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
+1
View File
@@ -3,6 +3,7 @@ resolver = "2"
members = [ members = [
"crates/punktfunk-core", "crates/punktfunk-core",
"crates/punktfunk-host", "crates/punktfunk-host",
"crates/pf-driver-proto",
"clients/probe", "clients/probe",
"clients/linux", "clients/linux",
"clients/windows", "clients/windows",
+21 -9
View File
@@ -1,13 +1,20 @@
# punktfunk <p align="center">
<img src="assets/punktfunk-logo.svg" alt="punktfunk" width="320" />
</p>
**Low-latency desktop and game streaming, Linux-first.** Run the host on a Linux machine — or a <p align="center"><b>Low-latency desktop and game streaming with first-class Linux and Windows hosts.</b></p>
Windows PC — with an NVIDIA GPU, connect from a Mac, PC, phone, tablet, or TV, and stream your desktop
or games — each device at its **own native resolution and refresh rate**, over your local network. Run the host on a Linux machine or a Windows PC, connect from a Mac, PC, phone, tablet, or TV, and
stream your desktop or games — each device at its **own native resolution and refresh rate**, over
your local network.
📖 **Documentation: [docs.punktfunk.unom.io](https://docs.punktfunk.unom.io)** — start with 📖 **Documentation: [docs.punktfunk.unom.io](https://docs.punktfunk.unom.io)** — start with
[How It Works](https://docs.punktfunk.unom.io/docs/how-it-works) or the [How It Works](https://docs.punktfunk.unom.io/docs/how-it-works) or the
[Quick Start](https://docs.punktfunk.unom.io/docs/quickstart). [Quick Start](https://docs.punktfunk.unom.io/docs/quickstart).
💬 **Community: [Discord](https://discord.gg/kaPNvzMuGU)** — chat, support, and **Android beta
access** · **[r/Punktfunk](https://www.reddit.com/r/Punktfunk/)**.
punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works
day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a
@@ -19,6 +26,11 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
- **Your device's exact mode.** For each client that connects, the host spins up a virtual display - **Your device's exact mode.** For each client that connects, the host spins up a virtual display
sized to that device — 1080p60 to a laptop, 1440p120 to a desktop, 4K to a TV, all at once. No sized to that device — 1080p60 to a laptop, 1440p120 to a desktop, 4K to a TV, all at once. No
letterboxing, no scaling, no rearranging your real monitors. letterboxing, no scaling, no rearranging your real monitors.
- **A real virtual display on Windows, too.** On Linux the host uses per-compositor virtual outputs;
on Windows you get the same on-the-fly virtual display — at the client's exact mode, no physical
monitor or dummy HDMI plug, even on the secure desktop (UAC / lock screen). It also has **its own
indirect display driver (IDD)** the host pushes finished frames straight into, rather than scraping
a screen — tight, push-based integration that's unusual for a Windows streaming host.
- **Low latency, GPU end to end.** Frames go straight from the compositor to the NVENC encoder with - **Low latency, GPU end to end.** Frames go straight from the compositor to the NVENC encoder with
zero CPU copies (dmabuf → CUDA/Vulkan → NVENC), over a transport tuned for responsiveness rather zero CPU copies (dmabuf → CUDA/Vulkan → NVENC), over a transport tuned for responsiveness rather
than throughput. Stable 240 fps at 5120×1440; sub-millisecond capture-to-reassembly on a LAN. than throughput. Stable 240 fps at 5120×1440; sub-millisecond capture-to-reassembly on a LAN.
@@ -35,7 +47,7 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
| **Core**`punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened | | **Core**`punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads | | **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
| **Native protocol**`punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation | | **Native protocol**`punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
| **Windows host** (NVIDIA, x64) | 🟡 Implemented & shipping as a signed installer (DXGI capture · SudoVDA virtual display · NVENC · WASAPI · ViGEm); NVIDIA-only, newer than the Linux host | | **Windows host** (x64) | 🟡 Implemented & shipping as a signed installer: DXGI/WGC capture · its own all-Rust IddCx **virtual display** (secure-desktop capable) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test | | **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test |
| **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch | | **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch |
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, Oboe audio, controllers, discovery, pairing | | **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, Oboe audio, controllers, discovery, pairing |
@@ -61,14 +73,14 @@ roadmap: **[/docs/roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
Pick your platform and install from its package registry — the per-platform guide covers adding the Pick your platform and install from its package registry — the per-platform guide covers adding the
repo, first run, and the web console. The Linux host is the primary, most battle-tested path; a repo, first run, and the web console. The Linux host is the primary, most battle-tested path; a
Windows host (NVIDIA-only) also ships as a signed installer. Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
| Platform | Install | Guide | | Platform | Install | Guide |
|--------|---------|-------| |--------|---------|-------|
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) | | **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) |
| **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) | | **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
| **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) | | **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
| **Windows** (NVIDIA, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) | | **Windows** (x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status). `punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
After install, run `punktfunk-host serve` inside your desktop session (the secure native default; After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
@@ -113,7 +125,7 @@ and the [docs site](https://docs.punktfunk.unom.io).
``` ```
crates/ crates/
punktfunk-core/ protocol · FEC · pacing · crypto · QUIC control plane — the C ABI (lib + cdylib + staticlib) punktfunk-core/ protocol · FEC · pacing · crypto · QUIC control plane — the C ABI (lib + cdylib + staticlib)
punktfunk-host/ Linux host: virtual displays · capture · encode · input · GameStream · punktfunk/1 · mgmt punktfunk-host/ the host (Linux + Windows): virtual displays · capture · encode · input · GameStream · punktfunk/1 · mgmt
clients/ clients/
apple/ macOS / iOS / tvOS app (Swift · VideoToolbox · Metal · GameController) apple/ macOS / iOS / tvOS app (Swift · VideoToolbox · Metal · GameController)
linux/ Linux desktop app (Rust · GTK4/libadwaita · FFmpeg/VAAPI · PipeWire · SDL3) linux/ Linux desktop app (Rust · GTK4/libadwaita · FFmpeg/VAAPI · PipeWire · SDL3)
@@ -124,7 +136,7 @@ clients/
web/ web console (TanStack) over the management API — status · devices · pairing web/ web console (TanStack) over the management API — status · devices · pairing
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
docs/ design notes & deep-dive plans design/ design notes & deep-dive plans (index: design/README.md)
include/punktfunk_core.h cbindgen-generated C header (checked in) include/punktfunk_core.h cbindgen-generated C header (checked in)
tools/ latency-probe · loss-harness (measurement) tools/ latency-probe · loss-harness (measurement)
``` ```
+528
View File
@@ -978,6 +978,309 @@
} }
} }
}, },
"/api/v1/stats/capture/live": {
"get": {
"tags": [
"stats"
],
"summary": "Live in-progress capture",
"description": "The full sample time-series of the capture currently recording, for live graphing. `404` when\nnothing is armed.",
"operationId": "statsCaptureLive",
"responses": {
"200": {
"description": "The in-progress capture (meta + samples so far)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Capture"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No capture is currently recording",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/stats/capture/start": {
"post": {
"tags": [
"stats"
],
"summary": "Start a stats capture",
"description": "Arms a new performance-stats capture. Idempotent: if a capture is already running this returns\nthe current status unchanged. While armed, the streaming loops emit aggregated samples (~ every\n12 s) into the in-progress capture, readable live via `GET /stats/capture/live`.",
"operationId": "statsCaptureStart",
"responses": {
"200": {
"description": "Capture armed (or already running)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StatsStatus"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/stats/capture/status": {
"get": {
"tags": [
"stats"
],
"summary": "Stats capture status",
"description": "Whether a capture is armed, its sample count, and start time. Poll this (e.g. every 2 s) to\ndrive the capture-control UI.",
"operationId": "statsCaptureStatus",
"responses": {
"200": {
"description": "In-progress capture status (idle when not armed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StatsStatus"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/stats/capture/stop": {
"post": {
"tags": [
"stats"
],
"summary": "Stop the stats capture",
"description": "Disarms the in-progress capture and writes it to disk atomically, returning its summary. If\nnothing was recording, returns `204 No Content`.",
"operationId": "statsCaptureStop",
"responses": {
"200": {
"description": "Capture stopped and saved",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CaptureMeta"
}
}
}
},
"204": {
"description": "Nothing was recording"
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not write the recording to disk",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/stats/recordings": {
"get": {
"tags": [
"stats"
],
"summary": "List saved recordings",
"description": "Every saved capture's summary (the `meta` head only — not the sample body), newest first.",
"operationId": "statsRecordingsList",
"responses": {
"200": {
"description": "Saved capture summaries, newest first",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CaptureMeta"
}
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/stats/recordings/{id}": {
"get": {
"tags": [
"stats"
],
"summary": "Get a saved recording",
"description": "The full capture (meta + samples) for `id`, for graphing or download.",
"operationId": "statsRecordingGet",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The recording id (its filename stem)",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The full capture",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Capture"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No recording with that id",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "The recording file is unreadable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
},
"delete": {
"tags": [
"stats"
],
"summary": "Delete a saved recording",
"description": "Removes the recording `id` from disk. `404` if there is no such recording.",
"operationId": "statsRecordingDelete",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The recording id (its filename stem)",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "Recording deleted"
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No recording with that id",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not delete the recording",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/status": { "/api/v1/status": {
"get": { "get": {
"tags": [ "tags": [
@@ -1125,6 +1428,89 @@
} }
} }
}, },
"Capture": {
"type": "object",
"description": "A full capture: summary + the sample time-series. The wire + on-disk shape.",
"required": [
"meta",
"samples"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/CaptureMeta"
},
"samples": {
"type": "array",
"items": {
"$ref": "#/components/schemas/StatsSample"
}
}
}
},
"CaptureMeta": {
"type": "object",
"description": "Capture summary — the filename stem plus the negotiated mode/codec/client. Stored at the head\nof each on-disk recording and listed standalone (without the sample body) by\n[`StatsRecorder::list`].",
"required": [
"id",
"started_unix_ms",
"duration_ms",
"kind",
"width",
"height",
"fps",
"codec",
"client",
"sample_count"
],
"properties": {
"client": {
"type": "string",
"description": "Short label / fingerprint prefix, or `\"\"` if unknown."
},
"codec": {
"type": "string",
"description": "`\"h264\" | \"hevc\" | \"av1\"`."
},
"duration_ms": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"fps": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"height": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"id": {
"type": "string",
"description": "e.g. `\"2026-06-26T20-14-03Z_5120x1440\"` — also the filename stem."
},
"kind": {
"type": "string",
"description": "`\"native\" | \"gamestream\"`."
},
"sample_count": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"started_unix_ms": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"width": {
"type": "integer",
"format": "int32",
"minimum": 0
}
}
},
"CustomEntry": { "CustomEntry": {
"type": "object", "type": "object",
"description": "A user-added title, persisted in `~/.config/punktfunk/library.json`. Same shape the API\nreturns and the web console edits.", "description": "A user-added title, persisted in `~/.config/punktfunk/library.json`. Same shape the API\nreturns and the web console edits.",
@@ -1595,6 +1981,144 @@
} }
} }
}, },
"StageTiming": {
"type": "object",
"description": "One pipeline stage's latency in an aggregation window (microseconds).",
"required": [
"name",
"p50_us",
"p99_us"
],
"properties": {
"name": {
"type": "string",
"description": "`\"capture\" | \"submit\" | \"encode\" | \"packetize\" | \"send\"` (path-dependent)."
},
"p50_us": {
"type": "number",
"format": "float"
},
"p99_us": {
"type": "number",
"format": "float"
}
}
},
"StatsSample": {
"type": "object",
"description": "One aggregated sample (~ every 2 s native, ~ every 1 s GameStream).",
"required": [
"t_ms",
"session_id",
"stages",
"fps",
"repeat_fps",
"mbps",
"bitrate_kbps",
"frames_dropped",
"packets_dropped",
"send_dropped",
"fec_recovered"
],
"properties": {
"bitrate_kbps": {
"type": "integer",
"format": "int32",
"description": "Configured target bitrate.",
"minimum": 0
},
"fec_recovered": {
"type": "integer",
"format": "int32",
"description": "FEC shards recovered this window (delta).",
"minimum": 0
},
"fps": {
"type": "number",
"format": "float",
"description": "Genuine NEW frames/s from the source."
},
"frames_dropped": {
"type": "integer",
"format": "int32",
"description": "Frames dropped this window (delta).",
"minimum": 0
},
"mbps": {
"type": "number",
"format": "float",
"description": "Transmit goodput (Mb/s)."
},
"packets_dropped": {
"type": "integer",
"format": "int32",
"description": "Packets dropped this window (receiver-side / reassembler, where known).",
"minimum": 0
},
"repeat_fps": {
"type": "number",
"format": "float",
"description": "Re-encoded holds/s (source-starvation indicator)."
},
"send_dropped": {
"type": "integer",
"format": "int32",
"description": "Host send-buffer overflow / EAGAIN this window (delta).",
"minimum": 0
},
"session_id": {
"type": "integer",
"format": "int32",
"description": "Disambiguates concurrent sessions (usually constant).",
"minimum": 0
},
"stages": {
"type": "array",
"items": {
"$ref": "#/components/schemas/StageTiming"
},
"description": "Ordered pipeline stages for this path."
},
"t_ms": {
"type": "integer",
"format": "int64",
"description": "Milliseconds since capture start (monotonic; stamped by [`StatsRecorder::push_sample`]).",
"minimum": 0
}
}
},
"StatsStatus": {
"type": "object",
"description": "Snapshot of the in-progress capture for the management API.",
"required": [
"armed",
"sample_count",
"started_unix_ms",
"kind"
],
"properties": {
"armed": {
"type": "boolean",
"description": "Capture currently running."
},
"kind": {
"type": "string",
"description": "Path of the in-progress capture (`\"\"` if idle)."
},
"sample_count": {
"type": "integer",
"format": "int32",
"description": "Samples in the in-progress capture.",
"minimum": 0
},
"started_unix_ms": {
"type": "integer",
"format": "int64",
"description": "Unix start time of the in-progress capture (`0` if idle).",
"minimum": 0
}
}
},
"StreamInfo": { "StreamInfo": {
"type": "object", "type": "object",
"description": "RTSP-negotiated stream parameters.", "description": "RTSP-negotiated stream parameters.",
@@ -1696,6 +2220,10 @@
{ {
"name": "library", "name": "library",
"description": "Game library: installed-store titles (Steam) plus user-curated custom entries" "description": "Game library: installed-store titles (Steam) plus user-curated custom entries"
},
{
"name": "stats",
"description": "Streaming performance-stats capture: arm/stop a recording, read the live + saved time-series for graphing"
} }
] ]
} }
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="100%" height="100%" viewBox="0 0 579 298" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<style>
/* Theme-adaptive so the logo stays readable on both light and dark README
backgrounds: deep violet (the brand-mark palette) on light, the original
light violet on dark. Evaluated by the viewer's color scheme. */
.pf-wm { fill: #6c5bf3; }
.pf-back { fill: #a79ff8; }
.pf-deep { fill: #6c5bf3; }
@media (prefers-color-scheme: dark) {
.pf-wm { fill: #cec9fb; }
.pf-back { fill: #f2f1fe; }
.pf-deep { fill: #8c7ef5; }
}
</style>
<g>
<g>
<path class="pf-wm" style="fill-rule:nonzero;" d="M21.144,176.635l0,102.687l31.253,0l0,-35.563l73.436,0l0,-23.555l-73.436,0l0,-19.398l77.285,0l0,-24.171l-108.537,0Z"/>
<path class="pf-wm" style="fill-rule:nonzero;" d="M136.148,176.635l0,47.264c0.154,16.627 0.154,16.627 0.308,20.014c0.77,15.087 2.463,21.4 7.544,26.634c7.698,8.16 20.014,10.315 59.272,10.315c23.863,0 34.178,-0.616 43.415,-2.463c11.7,-2.463 19.552,-10.623 21.246,-22.323c0.924,-7.236 1.078,-8.929 1.54,-32.176l0,-47.264l-31.253,0l0,47.264c0,2.155 -0.154,7.082 -0.308,10.623c-0.462,9.699 -1.232,12.47 -3.695,15.087c-3.387,3.695 -9.853,4.619 -31.407,4.619c-26.634,0 -32.638,-1.693 -34.332,-9.853c-0.77,-4.157 -0.77,-4.311 -1.078,-20.476l0,-47.264l-31.253,0Z"/>
<path class="pf-wm" style="fill-rule:nonzero;" d="M275.938,176.527l0,102.687l31.868,0l-0.77,-76.669l3.387,0l54.038,76.669l54.346,0l0,-102.687l-31.868,0l0.77,76.515l-3.233,0l-53.73,-76.515l-54.808,0Z"/>
<path class="pf-wm" style="fill-rule:nonzero;" d="M425.273,176.527l0,102.687l31.253,0l0,-39.258l17.089,0l46.032,39.258l47.418,0l-64.353,-52.344l59.426,-50.959l-47.88,0l-40.644,37.873l-17.089,0l0,-37.257l-31.253,0Z"/>
</g>
<path class="pf-back" style="fill-rule:nonzero;" d="M65.442,150.143c24.514,0 44.298,-19.784 44.298,-44.298c0,-24.514 -19.784,-44.298 -44.298,-44.298c-24.514,0 -44.298,19.784 -44.298,44.298c0,24.514 19.784,44.298 44.298,44.298Z"/>
<path class="pf-deep" style="fill-rule:nonzero;" d="M141.063,92.871c17.334,-17.334 17.334,-45.312 0,-62.647c-17.334,-17.334 -45.312,-17.334 -62.647,-0c-17.334,17.334 -17.334,45.312 0,62.647c17.334,17.334 45.312,17.334 62.647,-0Z"/>
<path style="fill:url(#_Linear1);" d="M121.228,104.359c-14.777,3.965 -31.187,0.136 -42.811,-11.488c-11.624,-11.624 -15.453,-28.034 -11.488,-42.811c14.777,-3.965 31.187,-0.136 42.811,11.488c11.624,11.624 15.453,28.034 11.488,42.811Z"/>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(31.323323,-31.323323,31.323323,31.323323,78.416832,92.870811)">
<stop offset="0" style="stop-color:#cec9fb;stop-opacity:0"/>
<stop offset="1" style="stop-color:#fcfcff;stop-opacity:1"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+5 -4
View File
@@ -11,8 +11,8 @@ machine, trust logic) instead of re-porting it into Kotlin.
| Side | Owns | | Side | Owns |
|------|------| |------|------|
| **Rust** (`clients/android/native``libpunktfunk_android.so`) | the JNI seam, `NativeClient` (QUIC control + UDP data plane), AnnexB→`AMediaCodec` decode, Opus+Oboe audio, VK keymap, latency math, trust/pairing | | **Rust** (`clients/android/native``libpunktfunk_android.so`) | the JNI seam, `NativeClient` (QUIC control + UDP data plane), AnnexB→`AMediaCodec` decode, Opus+Oboe audio, VK keymap, latency math, trust/pairing, **mDNS discovery** (`mdns-sd`, the same browse the Linux/Windows clients use) |
| **Kotlin** (`clients/android`) | Compose UI (host grid / settings / stream), `SurfaceView` lifecycle, input capture, `NsdManager` discovery, Keystore identity, permissions | | **Kotlin** (`clients/android`) | Compose UI (host grid / settings / stream), `SurfaceView` lifecycle, input capture, the Wi-Fi `MulticastLock` + permission UX, Keystore identity, permissions |
The single seam is `io.unom.punktfunk.kit.NativeBridge``Java_io_unom_punktfunk_kit_NativeBridge_*`. The single seam is `io.unom.punktfunk.kit.NativeBridge``Java_io_unom_punktfunk_kit_NativeBridge_*`.
@@ -30,7 +30,7 @@ clients/android/native/ Rust cdylib (workspace member) — links punktf
clients/android/ Gradle project (this dir) clients/android/ Gradle project (this dir)
settings.gradle.kts · build.gradle.kts · gradle.properties · gradlew settings.gradle.kts · build.gradle.kts · gradle.properties · gradlew
app/ :app — Compose UI: Connect / Settings / Stream screens (phone + TV) app/ :app — Compose UI: Connect / Settings / Stream screens (phone + TV)
kit/ :kit — NativeBridge · discovery (NsdManager) · Gamepad · Keymap · kit/ :kit — NativeBridge · discovery (native mdns-sd, polled) · Gamepad · Keymap ·
security (Keystore identity + known-host store) · cargo-ndk build security (Keystore identity + known-host store) · cargo-ndk build
``` ```
@@ -74,7 +74,8 @@ streaming experience:
- **Audio** — Opus + Oboe playback with a jitter ring, plus mic uplink to the host. - **Audio** — Opus + Oboe playback with a jitter ring, plus mic uplink to the host.
- **Input** — game controllers (buttons + axes) with rumble and HID feedback; D-pad / - **Input** — game controllers (buttons + axes) with rumble and HID feedback; D-pad /
game-controller focus navigation for the couch (TV + phone). game-controller focus navigation for the couch (TV + phone).
- **Discovery & trust** — `NsdManager` mDNS host list, SPAKE2 PIN pairing and TOFU, with a - **Discovery & trust** — native `mdns-sd` mDNS host list (polled over JNI; the same browse the
Linux/Windows clients use, not `NsdManager`), SPAKE2 PIN pairing and TOFU, with a
Keystore-wrapped client identity and a known-host store. Keystore-wrapped client identity and a known-host store.
- **UI** — Compose host list / settings / stream screens, Material You theming. - **UI** — Compose host list / settings / stream screens, Material You theming.
- **Shipping** — built for `arm64-v8a` + `x86_64`; published to Google Play (Internal Testing). - **Shipping** — built for `arm64-v8a` + `x86_64`; published to Google Play (Internal Testing).
@@ -4,11 +4,13 @@
<!-- punktfunk/1 QUIC/UDP data plane. --> <!-- punktfunk/1 QUIC/UDP data plane. -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- mDNS discovery of _punktfunk._udp on the LAN (NsdManager). --> <!-- mDNS discovery of _punktfunk._udp on the LAN (native mdns-sd browse). Requested
opportunistically — raw multicast reception needs only the MulticastLock, not this. -->
<uses-permission <uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES" android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" /> android:usesPermissionFlags="neverForLocation" />
<!-- Hold a MulticastLock while NsdManager discovery runs (OEM Wi-Fi power-save hedge). --> <!-- HostDiscovery holds a MulticastLock while the native mDNS browse runs — raw multicast
reception needs it (also an OEM Wi-Fi power-save hedge). -->
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" /> <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket. <!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket.
@@ -84,30 +84,33 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
var host by remember { mutableStateOf("") } var host by remember { mutableStateOf("") }
var hostName by remember { mutableStateOf("") }
var port by remember { mutableStateOf("9777") } var port by remember { mutableStateOf("9777") }
var connecting by remember { mutableStateOf(false) } var connecting by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<String?>(null) } var status by remember { mutableStateOf<String?>(null) }
// The host streams at exactly this mode; "Native" settings resolve from the device display. // The host streams at exactly this mode; "Native" settings resolve from the device display.
val (w, h, hz) = settings.effectiveMode(context) val (w, h, hz) = settings.effectiveMode(context)
// mDNS discovery scoped to this screen; NsdManager callbacks arrive on the main thread, so the // mDNS discovery scoped to this screen, via the native mdns-sd browse (HostDiscovery) — its
// onChange callback can set Compose state directly. (Emulator SLIRP drops multicast → empty.) // onChange fires on the main thread, so it can set Compose state directly. (Emulator SLIRP drops
// NsdManager discovery needs NEARBY_WIFI_DEVICES on Android 13+ (a runtime permission) — without // multicast → empty; that's the network, not the API.) Raw multicast reception only needs the
// it discoverServices silently finds nothing. Request it once, then (re)start discovery on grant. // Wi-Fi MulticastLock (HostDiscovery holds it), NOT NEARBY_WIFI_DEVICES — that gated the old
// NsdManager path. We still request NEARBY_WIFI_DEVICES opportunistically (some OEMs filter
// multicast without it; harmless where it isn't), but never block discovery on the grant — a
// denial used to leave discovery dead forever.
val discovery = remember { HostDiscovery(context) } val discovery = remember { HostDiscovery(context) }
var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) } var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) }
var nearbyGranted by remember { mutableStateOf(hasNearbyPermission(context)) }
val nearbyLauncher = rememberLauncherForActivityResult( val nearbyLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(), ActivityResultContracts.RequestPermission(),
) { granted -> nearbyGranted = granted } ) { _ -> /* best-effort hint; discovery runs regardless of the result */ }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (!nearbyGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasNearbyPermission(context)) {
nearbyLauncher.launch(Manifest.permission.NEARBY_WIFI_DEVICES) nearbyLauncher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
} }
} }
DisposableEffect(nearbyGranted) { DisposableEffect(Unit) {
discovery.onChange = { discovered = it } discovery.onChange = { discovered = it }
if (nearbyGranted) discovery.start() discovery.start()
onDispose { onDispose {
discovery.onChange = null discovery.onChange = null
discovery.stop() discovery.stop()
@@ -127,6 +130,13 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
} }
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing). // A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing).
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) } var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
// A saved host whose label is being edited (the Rename dialog).
var renameTarget by remember { mutableStateOf<KnownHost?>(null) }
// Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts",
// not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so
// it survives a DHCP address change; else by address:port). Mirrors the Apple client.
val discoveredUnsaved = discovered.filter { dh -> savedHosts.none { it.matches(dh) } }
// Issue the actual connect with identity + (optional) pin. On a TOFU connect (pinHex null), // Issue the actual connect with identity + (optional) pin. On a TOFU connect (pinHex null),
// pin the fingerprint the host presented (as an unpaired known host) so the next connect goes // pin the fingerprint the host presented (as an unpaired known host) so the next connect goes
@@ -176,10 +186,17 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
// keyed by address:port, so a discovered and a manually-typed connection to the same host share // keyed by address:port, so a discovered and a manually-typed connection to the same host share
// one record. Trust-on-first-use is permitted ONLY when the host advertised pair=optional; a // one record. Trust-on-first-use is permitted ONLY when the host advertised pair=optional; a
// pair=required host, or a manual/unknown-policy host, must pair by PIN. // pair=required host, or a manual/unknown-policy host, must pair by PIN.
fun connect(targetHost: String, targetPort: Int, dh: DiscoveredHost? = null) { fun connect(
targetHost: String,
targetPort: Int,
dh: DiscoveredHost? = null,
manualName: String? = null,
) {
val known = knownHostStore.get(targetHost, targetPort) val known = knownHostStore.get(targetHost, targetPort)
val adv = dh?.fingerprint?.lowercase() val adv = dh?.fingerprint?.lowercase()
val name = dh?.name ?: targetHost // Label precedence: a saved host keeps its (possibly user-renamed) name; else the discovered
// mDNS name; else the name typed in the Add-host sheet; else the bare address.
val name = known?.name ?: dh?.name ?: manualName?.trim()?.takeIf { it.isNotEmpty() } ?: targetHost
when { when {
// Known host whose advertised fp still matches the pin → silent pinned reconnect. // Known host whose advertised fp still matches the pin → silent pinned reconnect.
known != null && (adv == null || adv == known.fpHex) -> known != null && (adv == null || adv == known.fpHex) ->
@@ -260,7 +277,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
} }
} }
if (savedHosts.isEmpty() && discovered.isEmpty()) { if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
EmptyHostsState() EmptyHostsState()
} }
@@ -281,16 +298,17 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
knownHostStore.remove(kh.address, kh.port) knownHostStore.remove(kh.address, kh.port)
savedHosts = knownHostStore.all() savedHosts = knownHostStore.all()
}, },
onRename = { renameTarget = kh },
) )
} }
} }
if (discovered.isNotEmpty()) { if (discoveredUnsaved.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
SectionLabel("Discovered on the network") SectionLabel("Discovered on the network")
} }
items(discovered, key = { "disc-${it.host}-${it.port}" }) { dh -> items(discoveredUnsaved, key = { "disc-${it.host}-${it.port}" }) { dh ->
HostCard( HostCard(
name = dh.name, name = dh.name,
address = "${dh.host}:${dh.port}", address = "${dh.host}:${dh.port}",
@@ -302,9 +320,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
} }
} }
// Active-discovery hint: when we're scanning but nothing's turned up yet, show it's // Active-discovery hint: discovery runs whenever this screen is up, so while it's
// working rather than looking idle/empty. // scanning but nothing's turned up yet (and we're not mid-connect), show it's working
if (nearbyGranted && discovered.isEmpty()) { // rather than looking idle/empty.
if (!connecting && discovered.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
@@ -363,6 +382,15 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
OutlinedTextField(
value = hostName,
onValueChange = { hostName = it },
label = { Text("Name (optional)") },
placeholder = { Text("e.g. Living Room") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
OutlinedTextField( OutlinedTextField(
value = host, value = host,
onValueChange = { host = it }, onValueChange = { host = it },
@@ -370,7 +398,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(16.dp))
OutlinedTextField( OutlinedTextField(
value = port, value = port,
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) }, onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
@@ -385,9 +413,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
onClick = { onClick = {
val h = host.trim() val h = host.trim()
val p = port.toIntOrNull() ?: 9777 val p = port.toIntOrNull() ?: 9777
val n = hostName
scope.launch { sheetState.hide() }.invokeOnCompletion { scope.launch { sheetState.hide() }.invokeOnCompletion {
showManualSheet = false showManualSheet = false
connect(h, p) connect(h, p, manualName = n)
} }
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -507,10 +536,57 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
} }
} }
} }
// Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a
// friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
renameTarget?.let { kh ->
var newName by remember(kh) { mutableStateOf(kh.name) }
AlertDialog(
onDismissRequest = { renameTarget = null },
title = { Text("Rename host") },
text = {
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
label = { Text("Name") },
placeholder = { Text(kh.address) },
singleLine = true,
)
},
confirmButton = {
TextButton(
enabled = newName.isNotBlank(),
onClick = {
knownHostStore.rename(kh.address, kh.port, newName.trim())
savedHosts = knownHostStore.all()
renameTarget = null
},
) { Text("Save") }
},
dismissButton = {
TextButton(onClick = { renameTarget = null }) { Text("Cancel") }
},
)
}
} }
/** NsdManager discovery needs NEARBY_WIFI_DEVICES on API 33+; below that it doesn't apply. */ /**
* Whether NEARBY_WIFI_DEVICES is held (API 33+; not applicable below). We request it opportunistically
* as a multicast-reception hedge on OEMs that filter multicast without it, but discovery (raw mDNS via
* the native core + MulticastLock) does not depend on it.
*/
fun hasNearbyPermission(context: Context): Boolean = fun hasNearbyPermission(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) == ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
PackageManager.PERMISSION_GRANTED PackageManager.PERMISSION_GRANTED
/**
* True when a saved host and a discovered advert are the same machine — matched by certificate
* fingerprint when both carry it (so it survives a DHCP address change), else by address:port.
* Mirrors the Apple client's `StoredHost.matches`; de-dupes "Discovered" against "Saved hosts".
*/
private fun KnownHost.matches(dh: DiscoveredHost): Boolean {
val advFp = dh.fingerprint?.lowercase()
if (!advFp.isNullOrEmpty() && fpHex.isNotEmpty() && fpHex.lowercase() == advFp) return true
return address == dh.host && port == dh.port
}
@@ -19,6 +19,12 @@ data class Settings(
val micEnabled: Boolean = false, val micEnabled: Boolean = false,
/** Show the live stats overlay (FPS / throughput / latency) during a stream. */ /** Show the live stats overlay (FPS / throughput / latency) during a stream. */
val statsHudEnabled: Boolean = true, val statsHudEnabled: Boolean = true,
/**
* Touch input model. `true` (default) = trackpad: the cursor stays put on touch-down and moves
* by the finger's relative delta (swipe to nudge, lift and re-swipe to walk it across), tap to
* click where it is. `false` = direct pointing: the cursor jumps to the finger (the old behaviour).
*/
val trackpadMode: Boolean = true,
) )
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */ /** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
@@ -35,6 +41,7 @@ class SettingsStore(context: Context) {
gamepad = prefs.getInt(K_GAMEPAD, 0), gamepad = prefs.getInt(K_GAMEPAD, 0),
micEnabled = prefs.getBoolean(K_MIC, false), micEnabled = prefs.getBoolean(K_MIC, false),
statsHudEnabled = prefs.getBoolean(K_HUD, true), statsHudEnabled = prefs.getBoolean(K_HUD, true),
trackpadMode = prefs.getBoolean(K_TRACKPAD, true),
) )
fun save(s: Settings) { fun save(s: Settings) {
@@ -47,6 +54,7 @@ class SettingsStore(context: Context) {
.putInt(K_GAMEPAD, s.gamepad) .putInt(K_GAMEPAD, s.gamepad)
.putBoolean(K_MIC, s.micEnabled) .putBoolean(K_MIC, s.micEnabled)
.putBoolean(K_HUD, s.statsHudEnabled) .putBoolean(K_HUD, s.statsHudEnabled)
.putBoolean(K_TRACKPAD, s.trackpadMode)
.apply() .apply()
} }
@@ -59,6 +67,7 @@ class SettingsStore(context: Context) {
const val K_GAMEPAD = "gamepad" const val K_GAMEPAD = "gamepad"
const val K_MIC = "mic_enabled" const val K_MIC = "mic_enabled"
const val K_HUD = "stats_hud_enabled" const val K_HUD = "stats_hud_enabled"
const val K_TRACKPAD = "trackpad_mode"
} }
} }
@@ -5,9 +5,7 @@ import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -16,14 +14,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Surface import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -33,7 +31,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -122,6 +119,16 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
) )
} }
SettingsGroup("Pointer") {
ToggleRow(
title = "Trackpad mode",
subtitle = "Relative cursor like a laptop touchpad — swipe to nudge, tap to click. " +
"Off = the cursor jumps to your finger.",
checked = s.trackpadMode,
onCheckedChange = { on -> update(s.copy(trackpadMode = on)) },
)
}
SettingsGroup("Overlay") { SettingsGroup("Overlay") {
ToggleRow( ToggleRow(
title = "Stats overlay", title = "Stats overlay",
@@ -174,12 +181,8 @@ private fun ToggleRow(
} }
} }
/** /** A labelled read-only dropdown over [options] (value → label); calls [onSelect] on a pick. */
* A labelled value that opens a menu on click. Uses a clickable [Surface] + [DropdownMenu] rather @OptIn(ExperimentalMaterial3Api::class)
* than `ExposedDropdownMenuBox` — that component's read-only text field traps D-pad / controller
* focus (directional keys never leave it), so you can't navigate past it on a TV. Calls [onSelect]
* on a pick. A primary-colour border marks D-pad focus.
*/
@Composable @Composable
private fun <T> SettingDropdown( private fun <T> SettingDropdown(
label: String, label: String,
@@ -188,35 +191,20 @@ private fun <T> SettingDropdown(
onSelect: (T) -> Unit, onSelect: (T) -> Unit,
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
var focused by remember { mutableStateOf(false) }
val selectedLabel = options.firstOrNull { it.first == selected }?.second val selectedLabel = options.firstOrNull { it.first == selected }?.second
?: options.firstOrNull()?.second.orEmpty() ?: options.firstOrNull()?.second.orEmpty()
Box(modifier = Modifier.fillMaxWidth()) { ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
Surface( OutlinedTextField(
onClick = { expanded = true }, value = selectedLabel,
shape = MaterialTheme.shapes.small, onValueChange = {},
color = MaterialTheme.colorScheme.surfaceVariant, readOnly = true,
border = if (focused) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.onFocusChanged { focused = it.isFocused }, .fillMaxWidth(),
) { )
Row( ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(selectedLabel, style = MaterialTheme.typography.bodyLarge)
}
Icon(Icons.Filled.ArrowDropDown, contentDescription = null)
}
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { (value, lbl) -> options.forEach { (value, lbl) ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(lbl) }, text = { Text(lbl) },
@@ -26,7 +26,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -42,8 +41,25 @@ import io.unom.punktfunk.kit.NativeBridge
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.hypot
import kotlin.math.roundToInt import kotlin.math.roundToInt
// Touch-gesture tuning (px / ms). TAP_SLOP: movement under this still counts as a tap, not a drag.
// TAP_DRAG_MS: a new touch within this long after a tap starts a left-button drag. SCROLL_DIV: px of
// two-finger pan per wheel notch (smaller = faster scroll).
private const val TAP_SLOP = 12f
private const val TAP_DRAG_MS = 250L
private const val SCROLL_DIV = 4f
// Trackpad-mode pointer ballistics (relative one-finger motion). POINTER_SENS: base finger-px →
// host-px gain (~1:1, never twitchy). The rest is mild acceleration so a flick crosses the screen
// while a slow drag stays precise: above ACCEL_SPEED_FLOOR px/ms the gain ramps by ACCEL_GAIN per
// px/ms, capped at ACCEL_MAX (so a fast swipe can't fling the cursor uncontrollably).
private const val POINTER_SENS = 1.3f
private const val ACCEL_GAIN = 0.6f
private const val ACCEL_SPEED_FLOOR = 0.3f
private const val ACCEL_MAX = 3.0f
@Composable @Composable
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
@@ -62,8 +78,11 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// Live decode stats for the HUD. Poll once a second for the whole stream (cheap, and each call // Live decode stats for the HUD. Poll once a second for the whole stream (cheap, and each call
// drains+resets the native window so it never grows unbounded even while the overlay is hidden); // drains+resets the native window so it never grows unbounded even while the overlay is hidden);
// `showStats` only gates rendering. A 3-finger tap toggles it live; the default comes from Settings. // `showStats` only gates rendering. A 3-finger tap toggles it live; the default comes from Settings.
val initialSettings = remember { SettingsStore(context).load() }
var stats by remember { mutableStateOf<DoubleArray?>(null) } var stats by remember { mutableStateOf<DoubleArray?>(null) }
var showStats by remember { mutableStateOf(SettingsStore(context).load().statsHudEnabled) } var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
val trackpad = initialSettings.trackpadMode
LaunchedEffect(handle) { LaunchedEffect(handle) {
while (true) { while (true) {
delay(1000) delay(1000)
@@ -139,41 +158,154 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
if (showStats) { if (showStats) {
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) } stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) }
} }
// Touch virtual-trackpad overlay: 1-finger drag → relative mouse move; tap → left click; // Touch → mouse. Two models, chosen by the Trackpad-mode setting:
// 2-finger drag → scroll; 3-finger tap → toggle the stats HUD. (Physical-mouse pointer // • trackpad (default): the cursor STAYS where it is on touch-down and moves by the finger's
// capture comes in a later increment.) // relative delta (MouseMove) with mild pointer acceleration — swipe to nudge, lift and
// re-swipe to walk it across, tap to click where it is. This is what makes the cursor
// reachable on a small screen.
// • direct (opt-out): the cursor jumps to the finger and follows it (MouseMoveAbs,
// host-normalized against the overlay size), the old "direct pointing" behaviour.
// Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
// two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
// windows); three-finger tap = toggle the stats HUD.
Box( Box(
Modifier.fillMaxSize().pointerInput(handle) { Modifier.fillMaxSize().pointerInput(handle, trackpad) {
var lastTapUp = 0L
var lastTapX = 0f
var lastTapY = 0f
fun moveAbs(x: Float, y: Float) {
val sw = size.width
val sh = size.height
if (sw <= 0 || sh <= 0) return
NativeBridge.nativeSendPointerAbs(
handle,
x.coerceIn(0f, (sw - 1).toFloat()).roundToInt(),
y.coerceIn(0f, (sh - 1).toFloat()).roundToInt(),
sw,
sh,
)
}
awaitEachGesture { awaitEachGesture {
val first = awaitFirstDown(requireUnconsumed = false) val down = awaitFirstDown(requireUnconsumed = false)
val startX = down.position.x
val startY = down.position.y
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
// button for this whole gesture (laptop-trackpad convention).
val isDrag = down.uptimeMillis - lastTapUp < TAP_DRAG_MS &&
abs(startX - lastTapX) < TAP_SLOP && abs(startY - lastTapY) < TAP_SLOP
lastTapUp = 0L // consume the arming either way
// Direct mode jumps the cursor to the finger; trackpad mode leaves it put (the
// whole point — you nudge it with swipes instead).
if (!trackpad) moveAbs(startX, startY)
if (isDrag) NativeBridge.nativeSendPointerButton(handle, 1, true)
var moved = false var moved = false
var maxFingers = 1 var maxFingers = 1
var scrolling = false
var prevCx = startX
var prevCy = startY
var upTime = down.uptimeMillis
// Trackpad relative-motion state: the tracked finger, its last position/time, and
// the sub-pixel remainder so a slow drag isn't lost to Int truncation.
var trackId = down.id
var prevX = startX
var prevY = startY
var prevT = down.uptimeMillis
var accX = 0f
var accY = 0f
while (true) { while (true) {
val ev = awaitPointerEvent() val ev = awaitPointerEvent()
val fingers = ev.changes.count { it.pressed } val pressed = ev.changes.filter { it.pressed }
if (fingers == 0) break if (pressed.isEmpty()) {
if (fingers > maxFingers) maxFingers = fingers upTime = ev.changes.firstOrNull()?.uptimeMillis ?: upTime
val primary = ev.changes.firstOrNull { it.id == first.id } ?: ev.changes.first() break
val d = primary.positionChange() }
if (abs(d.x) > 0.5f || abs(d.y) > 0.5f) { if (pressed.size > maxFingers) maxFingers = pressed.size
moved = true
if (fingers >= 2) { if (pressed.size >= 2) {
// screen +y down → wire +up, so negate y. Coarse divisor; tune live. // Two fingers → scroll by the centroid delta; never move the cursor.
val sy = (-d.y / 4f).toInt() val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
val sx = (d.x / 4f).toInt() val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
if (sy != 0) NativeBridge.nativeSendScroll(handle, 0, sy * 120) if (!scrolling) {
if (sx != 0) NativeBridge.nativeSendScroll(handle, 1, sx * 120) scrolling = true
prevCx = cx
prevCy = cy
}
val sy = ((prevCy - cy) / SCROLL_DIV).toInt() // finger up → wheel up
val sx = ((cx - prevCx) / SCROLL_DIV).toInt()
if (sy != 0) {
NativeBridge.nativeSendScroll(handle, 0, sy * 120)
prevCy = cy
moved = true
}
if (sx != 0) {
NativeBridge.nativeSendScroll(handle, 1, sx * 120)
prevCx = cx
moved = true
}
} else if (!scrolling) {
// One finger (skipped once a gesture turned into a scroll, so dropping
// back to one finger doesn't jerk the cursor).
val p = pressed.firstOrNull { it.id == down.id } ?: pressed.first()
if (abs(p.position.x - startX) > TAP_SLOP ||
abs(p.position.y - startY) > TAP_SLOP
) {
moved = true
}
if (trackpad) {
// Relative: move by the finger delta × (sensitivity × acceleration),
// carrying the sub-pixel remainder. Re-anchor (zero delta this frame)
// if the tracked finger changed, so lifting one of several fingers
// never jumps the cursor.
if (p.id != trackId) {
trackId = p.id
prevX = p.position.x
prevY = p.position.y
prevT = p.uptimeMillis
}
val dx = p.position.x - prevX
val dy = p.position.y - prevY
val dt = (p.uptimeMillis - prevT).coerceAtLeast(1L)
prevX = p.position.x
prevY = p.position.y
prevT = p.uptimeMillis
val speed = hypot(dx, dy) / dt // finger px per ms
val accel = (1f + ACCEL_GAIN * (speed - ACCEL_SPEED_FLOOR).coerceAtLeast(0f))
.coerceAtMost(ACCEL_MAX)
accX += dx * POINTER_SENS * accel
accY += dy * POINTER_SENS * accel
val outX = accX.toInt() // truncates toward zero → remainder kept w/ sign
val outY = accY.toInt()
if (outX != 0 || outY != 0) {
NativeBridge.nativeSendPointerMove(handle, outX, outY)
accX -= outX
accY -= outY
}
} else { } else {
NativeBridge.nativeSendPointerMove(handle, d.x.toInt(), d.y.toInt()) moveAbs(p.position.x, p.position.y) // direct: cursor follows the finger
} }
} }
ev.changes.forEach { it.consume() } ev.changes.forEach { it.consume() }
} }
if (!moved && maxFingers == 1) {
NativeBridge.nativeSendPointerButton(handle, 1, true) if (isDrag) {
NativeBridge.nativeSendPointerButton(handle, 1, false) NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
} else if (!moved && maxFingers >= 3) { } else if (!moved) {
showStats = !showStats // quick in-stream HUD toggle when {
maxFingers >= 3 -> showStats = !showStats // in-stream HUD toggle
maxFingers == 2 -> { // two-finger tap → right click
NativeBridge.nativeSendPointerButton(handle, 3, true)
NativeBridge.nativeSendPointerButton(handle, 3, false)
}
else -> { // tap → left click (at the cursor's current spot), arm tap-drag
NativeBridge.nativeSendPointerButton(handle, 1, true)
NativeBridge.nativeSendPointerButton(handle, 1, false)
lastTapUp = upTime
lastTapX = startX
lastTapY = startY
}
}
} }
} }
}, },
@@ -49,7 +49,7 @@ fun SectionLabel(text: String) {
/** /**
* A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for * A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for
* saved hosts) an overflow menu with Forget. Tapping the card connects. * saved hosts) an overflow menu with Rename / Forget. Tapping the card connects.
*/ */
@Composable @Composable
fun HostCard( fun HostCard(
@@ -59,6 +59,7 @@ fun HostCard(
enabled: Boolean, enabled: Boolean,
onConnect: () -> Unit, onConnect: () -> Unit,
onForget: (() -> Unit)?, onForget: (() -> Unit)?,
onRename: (() -> Unit)? = null,
) { ) {
// D-pad / controller focus highlight: a clickable card is focusable, but the default state // D-pad / controller focus highlight: a clickable card is focusable, but the default state
// layer is too subtle on a TV across a room — draw a clear primary-colour border when focused. // layer is too subtle on a TV across a room — draw a clear primary-colour border when focused.
@@ -106,7 +107,7 @@ fun HostCard(
StatusPill(status) StatusPill(status)
} }
if (onForget != null) { if (onForget != null || onRename != null) {
var menu by remember { mutableStateOf(false) } var menu by remember { mutableStateOf(false) }
Box(modifier = Modifier.align(Alignment.TopEnd)) { Box(modifier = Modifier.align(Alignment.TopEnd)) {
IconButton(enabled = enabled, onClick = { menu = true }) { IconButton(enabled = enabled, onClick = { menu = true }) {
@@ -118,13 +119,24 @@ fun HostCard(
) )
} }
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem( if (onRename != null) {
text = { Text("Forget") }, DropdownMenuItem(
onClick = { text = { Text("Rename") },
menu = false onClick = {
onForget() menu = false
}, onRename()
) },
)
}
if (onForget != null) {
DropdownMenuItem(
text = { Text("Forget") },
onClick = {
menu = false
onForget()
},
)
}
} }
} }
} }
@@ -67,6 +67,27 @@ object NativeBridge {
/** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */ /** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */
external fun nativeClose(handle: Long) external fun nativeClose(handle: Long)
// ---- LAN discovery: mDNS browse of `_punktfunk._udp` in Rust (mdns-sd), polled by Kotlin ----
// Replaces NsdManager. The caller holds the Wi-Fi MulticastLock for the browse lifetime; raw
// multicast *reception* needs it. See io.unom.punktfunk.kit.discovery.HostDiscovery.
/**
* Start browsing `_punktfunk._udp` on the LAN. Returns an opaque discovery handle, or `0` on
* failure. Pair with exactly one [nativeDiscoveryStop]. Cheap + non-blocking (spawns the mDNS
* daemon + a fold thread).
*/
external fun nativeDiscoveryStart(): Long
/**
* The current resolved-host snapshot for [handle]: newline-joined records, each
* `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts / `0` handle. Poll ~1 Hz;
* cheap (a lock + string build), safe to call on the main thread.
*/
external fun nativeDiscoveryPoll(handle: Long): String
/** Stop the browse, shut the mDNS daemon down and join its thread. No-op on `0`. */
external fun nativeDiscoveryStop(handle: Long)
/** /**
* Start the HEVC decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs * Start the HEVC decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. No-op if already started. * entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. No-op if already started.
@@ -108,6 +129,13 @@ object NativeBridge {
/** Relative mouse move; dx/dy are device-pixel deltas (screen +y down). */ /** Relative mouse move; dx/dy are device-pixel deltas (screen +y down). */
external fun nativeSendPointerMove(handle: Long, dx: Int, dy: Int) external fun nativeSendPointerMove(handle: Long, dx: Int, dy: Int)
/**
* Absolute mouse position — the host moves the cursor to (x, y) in a [surfaceWidth]×[surfaceHeight]
* pixel space (it normalizes against that size and maps into the output region). Touch
* "direct pointing": the cursor jumps to the finger. Parity with the Apple client's absolute touch.
*/
external fun nativeSendPointerAbs(handle: Long, x: Int, y: Int, surfaceWidth: Int, surfaceHeight: Int)
/** One mouse-button transition. button: 1=left 2=middle 3=right 4=X1 5=X2. */ /** One mouse-button transition. button: 1=left 2=middle 3=right 4=X1 5=X2. */
external fun nativeSendPointerButton(handle: Long, button: Int, down: Boolean) external fun nativeSendPointerButton(handle: Long, button: Int, down: Boolean)
@@ -1,17 +1,13 @@
package io.unom.punktfunk.kit.discovery package io.unom.punktfunk.kit.discovery
import android.content.Context import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.net.wifi.WifiManager import android.net.wifi.WifiManager
import android.os.Build import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import io.unom.punktfunk.kit.NativeBridge
private const val TAG = "PunktfunkNsd" private const val TAG = "PunktfunkMdns"
/** DNS-SD service type punktfunk hosts advertise (host: `_punktfunk._udp.local.`). */
const val PUNKTFUNK_SERVICE_TYPE = "_punktfunk._udp"
const val PUNKTFUNK_PROTO = "punktfunk/1"
/** One resolved host fit for the picker. [key] is the stable dedup id. */ /** One resolved host fit for the picker. [key] is the stable dedup id. */
data class DiscoveredHost( data class DiscoveredHost(
@@ -23,165 +19,115 @@ data class DiscoveredHost(
val pairingRequired: Boolean = false, val pairingRequired: Boolean = false,
) )
/** Parsed TXT fields. Pure — unit-testable without Android (see ParseTxtTest). */ /** Field separator the native browse uses inside one record (ASCII Unit Separator). */
data class TxtFields( private const val FIELD_SEP = '\u001F'
val proto: String?,
val fp: String?,
val pair: String?,
val id: String?,
) {
val pairingRequired: Boolean get() = pair == "required"
val isPunktfunk: Boolean get() = proto == PUNKTFUNK_PROTO
}
/** /**
* Pure TXT parser. NSD hands TXT as a `Map<String, ByteArray?>` (a null/empty value = present-but- * Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair`), or null
* empty key). Decode UTF-8; missing keys are null, never an error. * if it's malformed. Pure — unit-tested without Android (see ParseRecordTest). The native side
* already applied the protocol gate and address selection, so this is just field marshaling.
*/ */
fun parseTxt(attrs: Map<String, ByteArray?>): TxtFields { fun parseHostRecord(record: String): DiscoveredHost? {
fun s(k: String): String? = attrs[k]?.takeIf { it.isNotEmpty() }?.toString(Charsets.UTF_8) val f = record.split(FIELD_SEP)
return TxtFields(proto = s("proto"), fp = s("fp"), pair = s("pair"), id = s("id")) if (f.size < 6) return null
val addr = f[2]
val port = f[3].toIntOrNull() ?: return null
if (addr.isBlank() || port !in 1..65535) return null
return DiscoveredHost(
key = f[0].ifBlank { "$addr:$port" },
name = f[1].ifBlank { addr },
host = addr,
port = port,
fingerprint = f[4].ifBlank { null },
pairingRequired = f[5] == "required",
)
} }
/** /**
* Browses `_punktfunk._udp` via NsdManager, resolves each service (the reliable * Browses `_punktfunk._udp` for punktfunk/1 hosts via the native `mdns-sd` core (the same browse the
* `registerServiceInfoCallback` path on API 34+, legacy `resolveService` on 3133 where its TXT is * Linux/Windows clients use), exposed over JNI — *not* `NsdManager`, whose per-OEM system daemon
* often empty), and pushes the live host set to [onChange] (invoked on the main thread). * made discovery "mostly broken". [start] spins up the native browse and polls it ~1 Hz on the main
* thread, pushing the live host set to [onChange] (also on the main thread, only when it changes);
* [stop] tears it down.
* *
* Lifecycle: [start] when the picker appears, [stop] when it leaves / on connect — holds a * We hold a Wi-Fi [WifiManager.MulticastLock] for the browse lifetime — raw multicast *reception*
* MulticastLock while running (an OEM Wi-Fi power-save hedge). Note: the Android emulator's SLIRP * needs it. (The Android emulator's SLIRP NAT drops multicast, so on the emulator discovery starts
* NAT drops multicast, so on the emulator discovery starts but never finds a LAN host. * but never finds a LAN host — same as before; that's the network, not the API.)
*/ */
class HostDiscovery(context: Context) { class HostDiscovery(context: Context) {
private val appCtx = context.applicationContext private val appCtx = context.applicationContext
private val nsd = appCtx.getSystemService(Context.NSD_SERVICE) as NsdManager
/** Invoked on the main thread whenever the resolved host set changes. */ /** Invoked on the main thread whenever the resolved host set changes. */
var onChange: ((List<DiscoveredHost>) -> Unit)? = null var onChange: ((List<DiscoveredHost>) -> Unit)? = null
private val resolved = LinkedHashMap<String, DiscoveredHost>() // key -> host private val handler = Handler(Looper.getMainLooper())
private var multicastLock: WifiManager.MulticastLock? = null private var multicastLock: WifiManager.MulticastLock? = null
private var discoveryListener: NsdManager.DiscoveryListener? = null private var nativeHandle = 0L
private val infoCallbacks = mutableListOf<NsdManager.ServiceInfoCallback>() // API 34+ registrations
private var running = false private var running = false
private var last: List<DiscoveredHost> = emptyList()
@Synchronized private val poll = object : Runnable {
fun start() { override fun run() {
if (running) return if (!running) return
running = true val hosts = snapshot()
acquireMulticastLock() if (hosts != last) {
val listener = makeDiscoveryListener() last = hosts
discoveryListener = listener onChange?.invoke(hosts)
runCatching { }
nsd.discoverServices(PUNKTFUNK_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener) handler.postDelayed(this, POLL_MS)
}.onFailure {
Log.e(TAG, "discoverServices failed", it)
stop()
} }
} }
@Synchronized fun start() {
fun stop() { if (running) return
if (!running) return acquireMulticastLock()
running = false val h = runCatching { NativeBridge.nativeDiscoveryStart() }
discoveryListener?.let { runCatching { nsd.stopServiceDiscovery(it) } } .onFailure { Log.e(TAG, "nativeDiscoveryStart threw", it) }
discoveryListener = null .getOrDefault(0L)
if (Build.VERSION.SDK_INT >= 34) { if (h == 0L) {
for (cb in infoCallbacks) runCatching { nsd.unregisterServiceInfoCallback(cb) } Log.e(TAG, "native mDNS discovery failed to start")
releaseMulticastLock()
return
} }
infoCallbacks.clear() nativeHandle = h
running = true
last = emptyList()
handler.post(poll)
}
fun stop() {
if (!running && nativeHandle == 0L) return
running = false
handler.removeCallbacks(poll)
val h = nativeHandle
nativeHandle = 0L
if (h != 0L) runCatching { NativeBridge.nativeDiscoveryStop(h) }
.onFailure { Log.e(TAG, "nativeDiscoveryStop threw", it) }
releaseMulticastLock() releaseMulticastLock()
resolved.clear() last = emptyList()
onChange?.invoke(emptyList()) onChange?.invoke(emptyList())
} }
private fun publish() { private fun snapshot(): List<DiscoveredHost> {
onChange?.invoke(resolved.values.sortedBy { it.name.lowercase() }) val h = nativeHandle
} if (h == 0L) return emptyList()
// getOrNull (not getOrDefault): the JNI returns a platform String!, so a (near-impossible)
private fun makeDiscoveryListener() = object : NsdManager.DiscoveryListener { // native null is a *success* value here — coalesce it so the main-thread poll can't NPE.
override fun onDiscoveryStarted(type: String) { val blob = runCatching { NativeBridge.nativeDiscoveryPoll(h) }
Log.d(TAG, "discovery started: $type") .onFailure { Log.e(TAG, "nativeDiscoveryPoll threw", it) }
} .getOrNull() ?: ""
override fun onDiscoveryStopped(type: String) { if (blob.isEmpty()) return emptyList()
Log.d(TAG, "discovery stopped: $type") return blob.split('\n')
} .filter { it.isNotBlank() }
override fun onStartDiscoveryFailed(type: String, code: Int) { .mapNotNull { parseHostRecord(it) }
Log.e(TAG, "start discovery failed: $code") .associateBy { it.key } // dedup by stable key (id, or addr:port)
runCatching { nsd.stopServiceDiscovery(this) } .values
} .sortedBy { it.name.lowercase() }
override fun onStopDiscoveryFailed(type: String, code: Int) {
Log.e(TAG, "stop discovery failed: $code")
}
override fun onServiceFound(info: NsdServiceInfo) {
Log.d(TAG, "found: ${info.serviceName}")
resolve(info)
}
override fun onServiceLost(info: NsdServiceInfo) {
Log.d(TAG, "lost: ${info.serviceName}")
// onServiceLost carries no TXT, so drop by the instance-name fallback key only.
if (resolved.remove(info.serviceName) != null) publish()
}
}
private fun resolve(found: NsdServiceInfo) {
if (Build.VERSION.SDK_INT >= 34) resolveViaCallback(found) else resolveViaLegacy(found)
}
private fun resolveViaCallback(found: NsdServiceInfo) {
val cb = object : NsdManager.ServiceInfoCallback {
override fun onServiceUpdated(info: NsdServiceInfo) = ingest(info)
override fun onServiceLost() {}
override fun onServiceInfoCallbackRegistrationFailed(code: Int) {
Log.e(TAG, "ServiceInfoCallback reg failed: $code")
}
override fun onServiceInfoCallbackUnregistered() {}
}
runCatching {
nsd.registerServiceInfoCallback(found, appCtx.mainExecutor, cb)
infoCallbacks.add(cb)
}.onFailure { Log.e(TAG, "registerServiceInfoCallback failed", it) }
}
private fun resolveViaLegacy(found: NsdServiceInfo) {
// A ResolveListener can't be reused — allocate one per resolve. TXT may be empty pre-34.
val listener = object : NsdManager.ResolveListener {
override fun onServiceResolved(info: NsdServiceInfo) = ingest(info)
override fun onResolveFailed(info: NsdServiceInfo, code: Int) {
Log.e(TAG, "resolve failed: $code")
}
}
runCatching { nsd.resolveService(found, listener) }
.onFailure { Log.e(TAG, "resolveService failed", it) }
}
@Suppress("DEPRECATION") // info.host is deprecated at API 34 (replaced by hostAddresses)
private fun ingest(info: NsdServiceInfo) {
val txt = parseTxt(info.attributes)
// Reject an incompatible protocol IF the host advertised one; tolerate empty TXT (pre-34).
if (txt.proto != null && !txt.isPunktfunk) {
Log.d(TAG, "skip non-punktfunk proto=${txt.proto}")
return
}
val ip = (if (Build.VERSION.SDK_INT >= 34) info.hostAddresses.firstOrNull() else info.host)
?.hostAddress ?: return
val key = txt.id?.takeIf { it.isNotBlank() } ?: info.serviceName
resolved[key] = DiscoveredHost(
key = key,
name = info.serviceName.removeSuffix("."),
host = ip,
port = info.port,
fingerprint = txt.fp,
pairingRequired = txt.pairingRequired,
)
Log.d(TAG, "resolved: ${resolved[key]}")
publish()
} }
private fun acquireMulticastLock() { private fun acquireMulticastLock() {
val wifi = appCtx.getSystemService(Context.WIFI_SERVICE) as WifiManager val wifi = appCtx.getSystemService(Context.WIFI_SERVICE) as WifiManager
multicastLock = wifi.createMulticastLock("punktfunk-nsd").apply { multicastLock = wifi.createMulticastLock("punktfunk-mdns").apply {
setReferenceCounted(true) setReferenceCounted(true)
runCatching { acquire() } runCatching { acquire() }
} }
@@ -191,4 +137,8 @@ class HostDiscovery(context: Context) {
multicastLock?.takeIf { it.isHeld }?.let { runCatching { it.release() } } multicastLock?.takeIf { it.isHeld }?.let { runCatching { it.release() } }
multicastLock = null multicastLock = null
} }
private companion object {
const val POLL_MS = 1000L
}
} }
@@ -50,6 +50,12 @@ class KnownHostStore(context: Context) {
prefs.edit().remove(key(address, port)).apply() prefs.edit().remove(key(address, port)).apply()
} }
/** Set a saved host's display name, keeping its pin + paired flag. No-op if not saved. */
fun rename(address: String, port: Int, newName: String) {
val h = get(address, port) ?: return
save(h.copy(name = newName))
}
/** All trusted hosts, name-sorted — backs the saved-hosts list. */ /** All trusted hosts, name-sorted — backs the saved-hosts list. */
fun all(): List<KnownHost> = fun all(): List<KnownHost> =
prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() } prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() }
@@ -0,0 +1,62 @@
package io.unom.punktfunk.kit.discovery
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pure JVM test of the native-record parser (`key␟name␟addr␟port␟fp␟pair`), the Kotlin half of the
* discovery JNI seam. No Android types. Run: `./gradlew :kit:testDebugUnitTest`.
*/
class ParseRecordTest {
private val s = '\u001F' // field separator (must match the Rust side, discovery.rs FIELD_SEP)
private fun rec(vararg f: String) = f.joinToString(s.toString())
@Test
fun parsesFullRecord() {
val fp = "a".repeat(64)
val h = parseHostRecord(rec("host-123", "home-worker-2", "192.168.1.70", "9777", fp, "required"))!!
assertEquals("host-123", h.key)
assertEquals("home-worker-2", h.name)
assertEquals("192.168.1.70", h.host)
assertEquals(9777, h.port)
assertEquals(fp, h.fingerprint)
assertTrue(h.pairingRequired)
}
@Test
fun optionalPairingAndEmptyFingerprint() {
val h = parseHostRecord(rec("id", "name", "10.0.0.5", "9777", "", "optional"))!!
assertNull(h.fingerprint)
assertEquals(false, h.pairingRequired)
}
@Test
fun emptyKeyFallsBackToAddrPort() {
// Host advertised no `id` TXT → the native side leaves the key blank; we synthesize addr:port.
val h = parseHostRecord(rec("", "name", "10.0.0.5", "9777", "", "required"))!!
assertEquals("10.0.0.5:9777", h.key)
}
@Test
fun emptyNameFallsBackToAddr() {
val h = parseHostRecord(rec("k", "", "10.0.0.5", "9777", "", "optional"))!!
assertEquals("10.0.0.5", h.name)
}
@Test
fun rejectsTooFewFields() {
assertNull(parseHostRecord("only${'\u001F'}three${'\u001F'}fields"))
assertNull(parseHostRecord(""))
}
@Test
fun rejectsBadPortOrAddress() {
assertNull(parseHostRecord(rec("k", "n", "10.0.0.5", "notaport", "", "required")))
assertNull(parseHostRecord(rec("k", "n", "10.0.0.5", "0", "", "required")))
assertNull(parseHostRecord(rec("k", "n", "10.0.0.5", "70000", "", "required")))
assertNull(parseHostRecord(rec("k", "n", "", "9777", "", "required")))
}
}
@@ -1,63 +0,0 @@
package io.unom.punktfunk.kit.discovery
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Pure JVM test of the mDNS TXT parser (no Android types). Run: `./gradlew :kit:testDebugUnitTest`. */
class ParseTxtTest {
private fun b(s: String): ByteArray = s.toByteArray(Charsets.UTF_8)
@Test
fun parsesFullRecord() {
val fp = "a".repeat(64)
val t = parseTxt(
mapOf(
"proto" to b("punktfunk/1"),
"fp" to b(fp),
"pair" to b("required"),
"id" to b("host-123"),
),
)
assertEquals("punktfunk/1", t.proto)
assertEquals(fp, t.fp)
assertEquals("host-123", t.id)
assertTrue(t.isPunktfunk)
assertTrue(t.pairingRequired)
}
@Test
fun optionalPairingAndMissingKeys() {
val t = parseTxt(mapOf("proto" to b("punktfunk/1"), "pair" to b("optional")))
assertFalse(t.pairingRequired)
assertNull(t.fp)
assertNull(t.id)
}
@Test
fun emptyMapYieldsAllNull() {
val t = parseTxt(emptyMap())
assertNull(t.proto)
assertNull(t.fp)
assertNull(t.pair)
assertNull(t.id)
assertFalse(t.isPunktfunk)
assertFalse(t.pairingRequired)
}
@Test
fun nullAndEmptyValuesTreatedAsAbsent() {
// NSD delivers present-but-empty TXT keys as null / empty ByteArray.
val t = parseTxt(mapOf("fp" to null, "id" to ByteArray(0), "proto" to b("punktfunk/1")))
assertNull(t.fp)
assertNull(t.id)
assertTrue(t.isPunktfunk)
}
@Test
fun nonPunktfunkProtoIsNotAccepted() {
assertFalse(parseTxt(mapOf("proto" to b("moonlight/7"))).isPunktfunk)
}
}
+6
View File
@@ -19,6 +19,12 @@ crate-type = ["cdylib"]
punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"] } punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"] }
jni = "0.21" jni = "0.21"
log = "0.4" log = "0.4"
# LAN host discovery: browse the host's `_punktfunk._udp` mDNS advert — the SAME crate + service the
# Linux/Windows clients use (`clients/linux/src/discovery.rs`), replacing Android's per-OEM
# `NsdManager` system daemon with one tested browse path. Pure Rust (socket2/if-addrs/mio), so it
# cross-compiles to the Android targets AND builds on the host (the JNI seam links into
# `cargo build --workspace`). Kotlin keeps only the Wi-Fi `MulticastLock` + permission UX.
mdns-sd = "0.20"
# Android-only deps. Gated so `cargo build --workspace` on the Linux/macOS dev boxes + CI still # Android-only deps. Gated so `cargo build --workspace` on the Linux/macOS dev boxes + CI still
# compiles this crate (as a host cdylib) — the Android-framework glue (logging now; AMediaCodec via # compiles this crate (as a host cdylib) — the Android-framework glue (logging now; AMediaCodec via
+117 -15
View File
@@ -1,8 +1,17 @@
//! Android audio playback (android-only): pull Opus packets from the connector, decode to //! Android audio playback (android-only): pull Opus packets from the connector, decode to
//! interleaved f32 stereo, and feed AAudio (LowLatency) via its realtime data callback through a //! interleaved f32 stereo, and feed AAudio (LowLatency) via its realtime data callback through a
//! jitter ring. Mirrors [`crate::decode`]: one thread we own (the Opus decode producer) plus a //! jitter ring. Mirrors [`crate::decode`]: one thread we own (the Opus decode producer) plus a
//! shutdown flag; the realtime callback thread is owned by AAudio. Ring logic ported from //! shutdown flag; the realtime callback thread is owned by AAudio.
//! `punktfunk-client-linux/src/audio.rs` (prime ~3 quanta, drop-oldest cap, re-prime on drain). //!
//! The ring started as a port of `punktfunk-client-linux/src/audio.rs`, but AAudio — unlike
//! PipeWire, which adaptively rate-matches the stream and absorbs a shallow buffer — hands us a raw
//! realtime callback and makes us own the buffer. So this client diverges deliberately to stop the
//! Android-only crackle: (1) the callback is allocation/free-free — decoded buffers are recycled to
//! the producer via a free-list instead of being freed on the audio thread (Android's Scudo `free`
//! has unbounded tail latency); (2) the jitter ring is deeper (~40 ms prime / ~150 ms hard cap) and
//! decoupled from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain
//! doesn't manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and
//! grown on XRuns (Google's anti-glitch technique).
use ndk::audio::{ use ndk::audio::{
AudioCallbackResult, AudioDirection, AudioFormat, AudioPerformanceMode, AudioSharingMode, AudioCallbackResult, AudioDirection, AudioFormat, AudioPerformanceMode, AudioSharingMode,
@@ -13,7 +22,7 @@ use punktfunk_core::error::PunktfunkError;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::ffi::c_void; use std::ffi::c_void;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{sync_channel, SyncSender, TrySendError}; use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -24,6 +33,29 @@ const RING_CHUNKS: usize = 64;
/// Opus decode scratch: worst-case 120 ms stereo frame (5760 samples/ch × 2 ch). /// Opus decode scratch: worst-case 120 ms stereo frame (5760 samples/ch × 2 ch).
const PCM_SCRATCH: usize = 5760 * CHANNELS; const PCM_SCRATCH: usize = 5760 * CHANNELS;
// --- Jitter-ring depths, in interleaved-f32 samples (all expressed in ms via `MS`). -----------
// Unlike the Linux client (PipeWire adaptively rate-matches the stream to the graph clock, masking
// host↔DAC drift + a shallow ring), AAudio hands us a raw callback and we own the buffer: drift and
// WiFi power-save bunching land as underruns/overflows = crackle. So Android runs a deliberately
// deeper, smoothly-managed ring than Linux — keep the two clients' depths intentionally divergent.
/// Interleaved f32 samples per millisecond (48 kHz × 2 ch).
const MS: usize = (SAMPLE_RATE as usize / 1000) * CHANNELS; // 96
/// Prime/target floor: fill to ~40 ms before playing (and after a sustained drain). Deep enough to
/// ride out WiFi arrival jitter + clock drift; the dominant Android-only anti-crackle lever.
const PRIME_FLOOR: usize = 40 * MS;
/// Ceiling for the burst-scaled target (so a large quantum can't push the prime depth too high).
const PRIME_CEIL: usize = 80 * MS;
/// Drop-oldest headroom above the target before trimming — a ~80 ms band swallows an arrival burst
/// without overflowing.
const JITTER_HEADROOM: usize = 80 * MS;
/// Hard latency bound: never let the ring exceed ~150 ms (the only thing that caps added latency).
const HARD_CAP: usize = 150 * MS;
/// Re-prime (go silent to refill) only after this many CONSECUTIVE empty callbacks, so one transient
/// drain doesn't manufacture a fresh 40 ms silence (the old `if ring.is_empty()` re-primed instantly).
const DEPRIME_AFTER_CALLBACKS: u32 = 5;
/// Throttle the AAudio XRun-driven HW-buffer grow check (cheap, but no need to poll every quantum).
const XRUN_CHECK_EVERY: u32 = 128;
/// Diagnostics — written by the decode thread + the realtime callback, logged periodically. The /// Diagnostics — written by the decode thread + the realtime callback, logged periodically. The
/// audio analogue of the video `fed`/`rendered` counters (we can't "screenshot" sound). /// audio analogue of the video `fed`/`rendered` counters (we can't "screenshot" sound).
#[derive(Default)] #[derive(Default)]
@@ -47,22 +79,41 @@ impl AudioPlayback {
pub fn start(client: Arc<NativeClient>) -> Option<AudioPlayback> { pub fn start(client: Arc<NativeClient>) -> Option<AudioPlayback> {
let counters = Arc::new(Counters::default()); let counters = Arc::new(Counters::default());
let (tx, rx) = sync_channel::<Vec<f32>>(RING_CHUNKS); let (tx, rx) = sync_channel::<Vec<f32>>(RING_CHUNKS);
// Recycle free-list: drained PCM buffers go BACK to the decode thread to be refilled, so the
// realtime callback never frees heap (Android's Scudo allocator has unbounded free() tail
// latency — a free on the audio thread is an XRun = a click) and the decode thread rarely
// allocates. Same depth as the data channel.
let (free_tx, free_rx) = sync_channel::<Vec<f32>>(RING_CHUNKS);
// Realtime consumer state, owned by the callback (FnMut) — no lock: AAudio calls it from a // Realtime consumer state, owned by the callback (FnMut) — no lock: AAudio calls it from a
// single high-priority thread, and the decode thread only touches `tx`. // single high-priority thread, and the decode thread only touches `tx`/`free_rx`.
let cb_counters = counters.clone(); let cb_counters = counters.clone();
let mut ring: VecDeque<f32> = VecDeque::with_capacity(PCM_SCRATCH); // Pre-reserve the ring so `extend` never reallocates on the realtime thread. Worst transient
// before the trim below = the hard cap plus one full channel of 5 ms (480-f32) frames — the
// punktfunk protocol always sends 5 ms Opus frames (host `audio_thread`); a larger frame
// would force a one-time realloc, asserted (not silently corrupted) in `decode_loop`.
let mut ring: VecDeque<f32> = VecDeque::with_capacity(HARD_CAP + RING_CHUNKS * 5 * MS);
let mut primed = false; let mut primed = false;
let callback = move |_s: &AudioStream, data: *mut c_void, num_frames: i32| { let mut empties: u32 = 0; // consecutive empty callbacks (de-prime hysteresis)
let mut cb_count: u32 = 0; // callbacks since open (throttles the XRun grow check)
let mut last_xrun: i32 = 0; // last AAudio XRun count we grew the buffer for
let callback = move |s: &AudioStream, data: *mut c_void, num_frames: i32| {
let want = num_frames as usize * CHANNELS; let want = num_frames as usize * CHANNELS;
// SAFETY: AAudio provides `num_frames * channel_count` F32 slots at `data`. // SAFETY: AAudio provides `num_frames * channel_count` F32 slots at `data`.
let out = unsafe { std::slice::from_raw_parts_mut(data as *mut f32, want) }; let out = unsafe { std::slice::from_raw_parts_mut(data as *mut f32, want) };
while let Ok(chunk) = rx.try_recv() { // Drain decoded chunks into the ring WITHOUT freeing on the RT thread: `drain(..)` empties
ring.extend(chunk); // each Vec but keeps its capacity, then the empty buffer is handed back for reuse. The
// only RT-thread free is the rare case where the recycle channel is momentarily full.
while let Ok(mut chunk) = rx.try_recv() {
ring.extend(chunk.drain(..));
let _ = free_tx.try_send(chunk);
} }
// Prime to ~3 quanta (15 ms; floor 15 ms / ceiling 200 ms); drop OLDEST above the cap. // Jitter buffer: prime to ~40 ms (PRIME_FLOOR) before playing and after a sustained drain;
let target = (3 * want).clamp(720 * CHANNELS, 9600 * CHANNELS); // drop-oldest only above a wide ~120 ms band. Decoupled from the AAudio burst `want` (tiny
while ring.len() > target.max(want) + want { // on the LowLatency MMAP path) so the depth doesn't collapse to a single quantum.
let target = (3 * want).clamp(PRIME_FLOOR, PRIME_CEIL);
let hard_cap = (target + JITTER_HEADROOM).min(HARD_CAP);
while ring.len() > hard_cap {
ring.pop_front(); ring.pop_front();
} }
if !primed && ring.len() >= target { if !primed && ring.len() >= target {
@@ -79,12 +130,34 @@ impl AudioPlayback {
out.fill(0.0); out.fill(0.0);
cb_counters.underruns.fetch_add(1, Ordering::Relaxed); cb_counters.underruns.fetch_add(1, Ordering::Relaxed);
} }
// Re-prime only after a RUN of empty callbacks, not a single transient one — otherwise
// every momentary drain costs a fresh 40 ms silence (the old behaviour, self-inflicted
// crackle on any jitter spike).
if ring.is_empty() { if ring.is_empty() {
primed = false; // re-prime after a genuine drain (avoids sustained crackle on loss) empties += 1;
if empties >= DEPRIME_AFTER_CALLBACKS {
primed = false;
}
} else {
empties = 0;
} }
cb_counters cb_counters
.ring_depth .ring_depth
.store(ring.len() as u64, Ordering::Relaxed); .store(ring.len() as u64, Ordering::Relaxed);
// Google's AAudio anti-glitch technique: when the device reports new XRuns, grow the HW
// buffer by one burst (up to capacity). getXRunCount + setBufferSizeInFrames are both
// callback-safe / non-blocking, and set clamps to capacity so it self-limits. Throttled.
cb_count = cb_count.wrapping_add(1);
if cb_count % XRUN_CHECK_EVERY == 0 {
let xr = s.x_run_count();
if xr > last_xrun {
last_xrun = xr;
let burst = s.frames_per_burst().max(1);
let grown =
(s.buffer_size_in_frames() + burst).min(s.buffer_capacity_in_frames());
let _ = s.set_buffer_size_in_frames(grown);
}
}
AudioCallbackResult::Continue AudioCallbackResult::Continue
}; };
@@ -109,19 +182,31 @@ impl AudioPlayback {
log::error!("audio: request_start: {e}"); log::error!("audio: request_start: {e}");
return None; return None;
} }
// Lift the AAudio HW buffer off its brittle ~2-burst LowLatency default so a single late
// callback doesn't immediately underrun; the in-callback XRun loop grows it further if the
// device still glitches. set_buffer_size_in_frames clamps to capacity.
let burst = stream.frames_per_burst().max(1);
let _ =
stream.set_buffer_size_in_frames((burst * 3).min(stream.buffer_capacity_in_frames()));
// perf != LowLatency or rate != 48000 means AAudio silently fell to a resampled legacy path
// (different burst behaviour) — surface it so the field can tell that apart from plain jitter.
log::info!( log::info!(
"audio: AAudio started rate={} ch={} fmt={:?} burst={}", "audio: AAudio started rate={} ch={} fmt={:?} perf={:?} share={:?} burst={} buf={}/{}",
stream.sample_rate(), stream.sample_rate(),
stream.channel_count(), stream.channel_count(),
stream.format(), stream.format(),
stream.performance_mode(),
stream.sharing_mode(),
stream.frames_per_burst(), stream.frames_per_burst(),
stream.buffer_size_in_frames(),
stream.buffer_capacity_in_frames(),
); );
let shutdown = Arc::new(AtomicBool::new(false)); let shutdown = Arc::new(AtomicBool::new(false));
let sd = shutdown.clone(); let sd = shutdown.clone();
let join = std::thread::Builder::new() let join = std::thread::Builder::new()
.name("pf-audio".into()) .name("pf-audio".into())
.spawn(move || decode_loop(client, tx, sd, counters)) .spawn(move || decode_loop(client, tx, free_rx, sd, counters))
.ok(); .ok();
Some(AudioPlayback { Some(AudioPlayback {
@@ -143,9 +228,12 @@ impl Drop for AudioPlayback {
} }
/// Producer: `next_audio` → Opus `decode_float` → push interleaved f32 into the ring channel. /// Producer: `next_audio` → Opus `decode_float` → push interleaved f32 into the ring channel.
/// Buffers come from (and return to) the realtime callback's recycle free-list so the steady state
/// is allocation-free on both threads.
fn decode_loop( fn decode_loop(
client: Arc<NativeClient>, client: Arc<NativeClient>,
tx: SyncSender<Vec<f32>>, tx: SyncSender<Vec<f32>>,
free_rx: Receiver<Vec<f32>>,
shutdown: Arc<AtomicBool>, shutdown: Arc<AtomicBool>,
counters: Arc<Counters>, counters: Arc<Counters>,
) { ) {
@@ -166,8 +254,22 @@ fn decode_loop(
for &s in &pcm[..n] { for &s in &pcm[..n] {
window_peak = window_peak.max(s.abs()); window_peak = window_peak.max(s.abs());
} }
// The ring's pre-reservation in `start` assumes the protocol's 5 ms (≤480-f32)
// frames; a larger frame would force a one-time realloc on the RT thread. Catch a
// future host frame-size change here in debug, not as a silent audio glitch.
debug_assert!(
n <= 5 * MS,
"audio frame {n} f32 exceeds the 5 ms ring reserve"
);
let count = counters.opus_decoded.fetch_add(1, Ordering::Relaxed) + 1; let count = counters.opus_decoded.fetch_add(1, Ordering::Relaxed) + 1;
match tx.try_send(pcm[..n].to_vec()) { // Reuse a recycled buffer if the callback handed one back; only allocate when the
// free-list is momentarily empty (startup / after a backpressure drop).
let mut buf = free_rx
.try_recv()
.unwrap_or_else(|_| Vec::with_capacity(PCM_SCRATCH));
buf.clear();
buf.extend_from_slice(&pcm[..n]);
match tx.try_send(buf) {
Ok(()) | Err(TrySendError::Full(_)) => {} // drop-newest under backpressure Ok(()) | Err(TrySendError::Full(_)) => {} // drop-newest under backpressure
Err(TrySendError::Disconnected(_)) => break, Err(TrySendError::Disconnected(_)) => break,
} }
+303
View File
@@ -0,0 +1,303 @@
//! LAN host discovery over mDNS, in Rust via `mdns-sd` — the same crate + service type the
//! Linux/Windows clients use (`clients/linux/src/discovery.rs`), exposed to Kotlin over JNI.
//!
//! Why not `NsdManager`: that API delegates to a per-OEM system mDNS daemon whose reliability
//! varies wildly (the Android client's discovery was "mostly broken"). Browsing in our own Rust
//! core — the crate is already linked for the whole protocol — gives one tested code path across
//! every desktop + mobile client and removes the system-daemon dependency. Kotlin still holds the
//! Wi-Fi `MulticastLock` for the browse lifetime (raw multicast *reception* needs it) and owns the
//! permission UX; this module owns the socket + resolve.
//!
//! Shape: [`Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart`] spins up a
//! [`ServiceDaemon`] browsing `_punktfunk._udp.local.` on a background thread that folds
//! resolve/remove events into a shared map; Kotlin polls `nativeDiscoveryPoll` ~1 Hz for a
//! newline-joined snapshot and calls `nativeDiscoveryStop` to tear it down. Polling (not a JVM
//! callback) mirrors `nativeVideoStats`: no `AttachCurrentThread`/global-ref lifecycle to get
//! wrong, and 1 Hz is plenty for a host picker.
use crate::session::jni_guard;
use jni::objects::JObject;
use jni::sys::jlong;
use jni::JNIEnv;
use mdns_sd::{ResolvedService, ServiceDaemon, ServiceEvent};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
/// DNS-SD service type punktfunk hosts advertise (host side: `punktfunk_host::discovery`).
const SERVICE_TYPE: &str = "_punktfunk._udp.local.";
/// Wire protocol id in the `proto` TXT record; a host advertising anything else is skipped.
const PROTO: &str = "punktfunk/1";
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
const FIELD_SEP: char = '\u{1f}';
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair` (`␟` = [`FIELD_SEP`]).
/// Records are newline-joined in a poll snapshot; [`Host::encode`] strips the framing bytes from
/// every field so no value can break it.
#[derive(Clone, PartialEq)]
struct Host {
key: String,
name: String,
addr: String,
port: u16,
fp: String,
pair: String,
}
impl Host {
fn encode(&self) -> String {
// mDNS instance labels + TXT values are arbitrary UTF-8 from an UNauthenticated source, so
// strip the field/record separators: a rogue advert that smuggled '\n'/U+001F could otherwise
// inject or suppress picker rows. (Trust is still gated on connect — this only protects the
// list's integrity.)
fn clean(s: &str) -> String {
s.replace(['\n', '\r', FIELD_SEP], "")
}
format!(
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
clean(&self.key),
clean(&self.name),
clean(&self.addr),
self.port,
clean(&self.fp),
clean(&self.pair),
)
}
}
/// A running browse behind the `jlong` handle: the daemon, the shared resolved-host map keyed by
/// mDNS fullname (stable across re-announce and present on both resolve *and* remove — which fixes
/// the old `NsdManager` key mismatch that leaked stale hosts), and the event-fold thread.
struct Discovery {
daemon: ServiceDaemon,
hosts: Arc<Mutex<HashMap<String, Host>>>,
thread: Option<JoinHandle<()>>,
}
impl Discovery {
fn start() -> Option<Discovery> {
let daemon = match ServiceDaemon::new() {
Ok(d) => d,
Err(e) => {
log::error!("mDNS daemon failed — discovery disabled: {e}");
return None;
}
};
let rx = match daemon.browse(SERVICE_TYPE) {
Ok(r) => r,
Err(e) => {
log::error!("mDNS browse failed — discovery disabled: {e}");
let _ = daemon.shutdown();
return None;
}
};
let hosts: Arc<Mutex<HashMap<String, Host>>> = Arc::new(Mutex::new(HashMap::new()));
let map = hosts.clone();
let spawned = std::thread::Builder::new()
.name("pf-mdns".into())
.spawn(move || {
// Exits when the daemon is shut down (the browse channel closes → recv errors).
while let Ok(event) = rx.recv() {
match event {
ServiceEvent::ServiceResolved(info) => {
if let Some(host) = resolve(&info) {
map.lock()
.unwrap()
.insert(info.get_fullname().to_string(), host);
}
}
ServiceEvent::ServiceRemoved(_ty, fullname) => {
map.lock().unwrap().remove(&fullname);
}
_ => {}
}
}
});
let thread = match spawned {
Ok(t) => t,
Err(e) => {
// The daemon thread + bound :5353 socket outlive a dropped handle (no Drop impl), so
// shut it down explicitly — same cleanup as the browse-failure path above.
log::error!("mDNS fold thread spawn failed: {e}");
let _ = daemon.shutdown();
return None;
}
};
log::info!("native mDNS discovery started ({SERVICE_TYPE})");
Some(Discovery {
daemon,
hosts,
thread: Some(thread),
})
}
/// Current resolved-host set, newline-joined (empty string = none). Sorted for a stable order
/// across polls; Kotlin re-sorts by display name.
fn snapshot(&self) -> String {
let mut records: Vec<String> = self
.hosts
.lock()
.unwrap()
.values()
.map(Host::encode)
.collect();
records.sort();
records.join("\n")
}
fn stop(mut self) {
let _ = self.daemon.shutdown(); // closes the browse channel → the fold thread exits
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
/// Build a [`Host`] from a resolved mDNS record, or `None` if it isn't a usable punktfunk host
/// (incompatible advertised proto, or no IPv4 address). IPv4 only on purpose: the core dials with
/// `format!("{host}:{port}").parse::<SocketAddr>()`, which can't parse a bare/scoped IPv6 literal
/// (it needs the `[addr%scope]:port` form), so surfacing a v6-only host would present a card that
/// fails on every tap. Dropping it shows the honest "not found" instead.
fn resolve(info: &ResolvedService) -> Option<Host> {
let val = |k: &str| info.get_property_val_str(k).unwrap_or("").to_string();
let proto = val("proto");
if !proto.is_empty() && proto != PROTO {
return None; // some other DNS-SD service sharing the type — ignore
}
let addr = info
.get_addresses_v4()
.iter()
.next()
.map(|a| a.to_string())?;
let id = val("id");
let fullname = info.get_fullname();
Some(Host {
key: if id.is_empty() {
fullname.to_string()
} else {
id
},
name: fullname.split('.').next().unwrap_or("?").to_string(),
addr,
port: info.get_port(),
fp: val("fp"),
pair: val("pair"),
})
}
/// `NativeBridge.nativeDiscoveryStart(): Long` — start browsing `_punktfunk._udp`; returns an opaque
/// handle, or `0` on failure (logged). Pair with exactly one [`nativeDiscoveryStop`]. Kotlin must
/// hold the Wi-Fi `MulticastLock` for the browse lifetime.
///
/// [`nativeDiscoveryStop`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart(
_env: JNIEnv,
_this: JObject,
) -> jlong {
jni_guard(0, || match Discovery::start() {
Some(d) => Box::into_raw(Box::new(d)) as jlong,
None => 0,
})
}
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts /
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jni::sys::jstring {
jni_guard(std::ptr::null_mut(), || {
let out = if handle == 0 {
String::new()
} else {
// SAFETY: live handle per the start/stop contract — Kotlin owns the lifecycle and never
// polls after stop (it nulls the handle first).
let d = unsafe { &*(handle as *const Discovery) };
d.snapshot()
};
match env.new_string(out) {
Ok(s) => s.into_raw(),
Err(_) => std::ptr::null_mut(),
}
})
}
/// `NativeBridge.nativeDiscoveryStop(handle)` — stop the browse, shut the daemon down and join its
/// thread. No-op on `0`.
///
/// # Safety contract
/// `handle` must be `0` or a live handle from [`nativeDiscoveryStart`], stopped exactly once and not
/// concurrently with [`nativeDiscoveryPoll`] (Kotlin owns this; all calls are on the main thread).
///
/// [`nativeDiscoveryStart`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart
/// [`nativeDiscoveryPoll`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop(
_env: JNIEnv,
_this: JObject,
handle: jlong,
) {
jni_guard((), || {
if handle != 0 {
// SAFETY: live handle from nativeDiscoveryStart, stopped exactly once per the contract.
let d = unsafe { Box::from_raw(handle as *mut Discovery) };
d.stop();
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_round_trips_all_fields_with_unit_separator() {
let h = Host {
key: "host-123".into(),
name: "home-worker-2".into(),
addr: "192.168.1.70".into(),
port: 9777,
fp: "ab".repeat(32),
pair: "required".into(),
};
let encoded = h.encode();
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
assert_eq!(fields.len(), 6);
assert_eq!(fields[0], "host-123");
assert_eq!(fields[1], "home-worker-2");
assert_eq!(fields[2], "192.168.1.70");
assert_eq!(fields[3], "9777");
assert_eq!(fields[4], "ab".repeat(32));
assert_eq!(fields[5], "required");
assert!(
!encoded.contains('\n'),
"a record must never contain the record separator"
);
}
#[test]
fn encode_strips_injected_separators_from_a_hostile_advert() {
// A rogue advert could carry framing bytes in its instance label / TXT; encode must strip
// them so the snapshot stays exactly one record of exactly six fields.
let h = Host {
key: "k\u{1f}injected".into(),
name: "evil\nhost\r".into(),
addr: "10.0.0.5".into(),
port: 9777,
fp: "ab\u{1f}cd".into(),
pair: "required\n".into(),
};
let encoded = h.encode();
assert_eq!(encoded.matches(FIELD_SEP).count(), 5, "exactly six fields");
assert!(!encoded.contains('\n') && !encoded.contains('\r'));
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
assert_eq!(fields[0], "kinjected");
assert_eq!(fields[1], "evilhost");
assert_eq!(fields[4], "abcd");
assert_eq!(fields[5], "required");
}
}
+10 -3
View File
@@ -3,13 +3,17 @@
//! Architecture: the **Rust-heavy** client model (like `punktfunk-client-linux`, *not* the //! Architecture: the **Rust-heavy** client model (like `punktfunk-client-linux`, *not* the
//! thin-native-over-C-ABI Apple model). This `cdylib` links `punktfunk-core` directly and drives //! thin-native-over-C-ABI Apple model). This `cdylib` links `punktfunk-core` directly and drives
//! the whole `punktfunk/1` protocol through [`punktfunk_core::client::NativeClient`]; Kotlin owns //! the whole `punktfunk/1` protocol through [`punktfunk_core::client::NativeClient`]; Kotlin owns
//! only the Android-framework surface (Compose UI, `SurfaceView` lifecycle, input capture, //! only the Android-framework surface (Compose UI, `SurfaceView` lifecycle, input capture, the
//! `NsdManager` discovery, Keystore). The JNI seam below is the one place the two languages meet. //! Wi-Fi `MulticastLock` + permission UX, Keystore). The JNI seam below is the one place the two
//! languages meet.
//! //!
//! Why Rust-heavy: Kotlin cannot `import` the cbindgen C header the way Swift can, so a native //! Why Rust-heavy: Kotlin cannot `import` the cbindgen C header the way Swift can, so a native
//! bridge is unavoidable. Writing it in Rust lets the Android client reuse the Linux client's //! bridge is unavoidable. Writing it in Rust lets the Android client reuse the Linux client's
//! orchestration verbatim — audio jitter ring, the VK keymap inverse, latency/skew math, the //! orchestration verbatim — audio jitter ring, the VK keymap inverse, latency/skew math, the
//! input capture state machine, trust/pairing logic — instead of re-porting it into Kotlin. //! input capture state machine, trust/pairing logic, **mDNS discovery** ([`discovery`], the same
//! `mdns-sd` browse the Linux/Windows clients use) — instead of re-porting it into Kotlin. Kotlin
//! keeps only the Android-framework surface it must (Compose UI, `SurfaceView`, input capture, the
//! Wi-Fi `MulticastLock` + permission UX, Keystore identity).
//! //!
//! JNI symbols map to `io.unom.punktfunk.kit.NativeBridge` in the `:kit` Gradle module //! JNI symbols map to `io.unom.punktfunk.kit.NativeBridge` in the `:kit` Gradle module
//! (`clients/android`). The current surface is the scaffold's native-link proof //! (`clients/android`). The current surface is the scaffold's native-link proof
@@ -25,6 +29,9 @@ use jni::JNIEnv;
mod audio; mod audio;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
mod decode; mod decode;
// Ungated: pure `mdns-sd` + `jni`, so the browse + its JNI seam link into the host workspace build
// (and its unit test runs there) exactly like `session`/`stats`. Kotlin only ever calls it on device.
mod discovery;
mod feedback; mod feedback;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
mod mic; mod mic;
+32
View File
@@ -557,6 +557,38 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
}); });
} }
/// `NativeBridge.nativeSendPointerAbs(handle, x, y, surfaceWidth, surfaceHeight)` — absolute cursor
/// position: the host moves the pointer to `x`/`y` in a `surfaceWidth`×`surfaceHeight` pixel space,
/// normalizing against the size packed into `flags` as `(w << 16) | h` and mapping into the output
/// region (it drops the event if that size is zero). This is the touch "direct pointing" path — the
/// cursor jumps to the finger — and matches the Apple client's absolute touch forwarding.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerAbs(
_env: JNIEnv,
_this: JObject,
handle: jlong,
x: jint,
y: jint,
surface_width: jint,
surface_height: jint,
) {
if handle == 0 {
return;
}
// SAFETY: live handle per the contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let w = (surface_width.max(0) as u32) & 0xffff;
let ht = (surface_height.max(0) as u32) & 0xffff;
let _ = h.client.send_input(&InputEvent {
kind: InputKind::MouseMoveAbs,
_pad: [0; 3],
code: 0,
x,
y,
flags: (w << 16) | ht,
});
}
/// `NativeBridge.nativeSendPointerButton(handle, button, down)` — one button transition. /// `NativeBridge.nativeSendPointerButton(handle, button, down)` — one button transition.
/// `button`: GameStream id (1=left, 2=middle, 3=right, 4=X1, 5=X2). `down`: 1=press, 0=release. /// `button`: GameStream id (1=left, 2=middle, 3=right, 4=X1, 5=X2). `down`: 1=press, 0=release.
#[no_mangle] #[no_mangle]
+53 -1
View File
@@ -174,6 +174,58 @@ signing, bundle id `io.unom.punktfunk`. Notes:
in a simulator via `xcrun simctl install/launch``SIMCTL_CHILD_PUNKTFUNK_AUTOCONNECT=…` in a simulator via `xcrun simctl install/launch``SIMCTL_CHILD_PUNKTFUNK_AUTOCONNECT=…`
passes the dev autoconnect env through). passes the dev autoconnect env through).
## App Store screenshots
Automated, faithful screenshots of the real UI for App Store Connect — one set per platform at
exactly the accepted pixel sizes. Driver: **`tools/screenshots.sh`**.
```sh
tools/screenshots.sh all # macOS + (if full Xcode) iOS, iPadOS, tvOS → ./screenshots
tools/screenshots.sh macos # just macOS
OUT=~/Desktop/shots tools/screenshots.sh ios ipad tvos
PUNKTFUNK_SHOT_HERO=~/frame.png tools/screenshots.sh ios # real captured frame behind the hero
```
How it works: the app has a DEBUG-only **shot mode** (`Sources/PunktfunkClient/Screenshots/`).
Launched with `PUNKTFUNK_SHOT_SCENE=<name>` it renders **one** mock-populated screen full-bleed
(`ScreenshotHostView`) instead of `ContentView`, then the OS screenshots the *real, fully-rendered*
window — `screencapture` on macOS, `xcrun simctl io booted screenshot` on the Simulators. The five
scenes (`ShotScenes.all`): `01-stream` (the stream hero — a synthetic frame + the glass HUD, since
`StreamView` needs a live connection), `02-hosts`, `03-pair`, `04-trust`, `05-settings`. Mock data
is in `ShotMock`; nothing touches a host.
Output pixels are App Store Connect's required/largest sizes (Apple auto-derives the smaller ones):
`mac` 2880×1800 · `iphone-6.9` 1320×2868 (hero 2868×1320) · `ipad-13` 2064×2752 (hero 2752×2064) ·
`appletv` 1920×1080.
Why not `ImageRenderer` (the obvious offscreen route)? It can't rasterize this app's chrome —
`NavigationStack`, `Form`/`TabView`, and Liquid-Glass/`NSVisualEffect` materials all render black or
SwiftUI's "can't render" placeholder. Capturing the live window/Simulator avoids that entirely.
Requirements / gotchas:
- **macOS**: only the Swift toolchain is needed, **plus a one-time Screen Recording grant** for
your terminal (System Settings → Privacy & Security → Screen Recording) — without it
`screencapture -l` fails with "could not create image from window". (A no-permission fallback,
`PUNKTFUNK_SHOT_SELFCAPTURE=<dir>`, uses `cacheDisplay` — but it omits material blur and can't
read `ScrollView` content, so it's for quick checks, not submission.)
- **iOS/iPadOS/tvOS**: needs **full Xcode** (xcodebuild + Simulators), not just Command Line Tools,
and the matching device Simulators installed (iPhone 16 Pro Max, iPad Pro 13", Apple TV). Run it
on a full-Xcode Mac (e.g. the `macos-arm64` CI mini).
- The hero defaults to a synthetic synthwave frame — set `PUNKTFUNK_SHOT_HERO` to a real captured
frame for a production-quality lead screenshot.
**CI**: the `apple` workflow's **`screenshots`** job runs on the `macos-arm64` runner on every main
push + manual dispatch (skipped on PRs), and attaches the result as a single zip artifact,
**`punktfunk-appstore-screenshots`** (download it from the run's Artifacts; `upload-artifact@v3`
Gitea's backend rejects v4). It captures the two **required iOS sizes — iPhone 6.9" + iPad 13"**
on the Simulator (auto-creating the device if the runner lacks it), and is isolated from the
build/test job so a capture hiccup never reds the build.
**macOS and tvOS are NOT in CI**, by design: the self-hosted runner is **headless** (no
window-server session), so the macOS window capture can't run there, and tvOS needs the Tier-3
build-std slice. Generate those on a GUI Mac: `tools/screenshots.sh macos tvos`. (If the runner is
ever switched to a logged-in GUI session, re-adding macOS to the job's capture step is one line.)
## Notes for whoever picks this up next ## Notes for whoever picks this up next
1. **cbindgen import quirk** (the predicted "small compile fixes", now fixed): the 1. **cbindgen import quirk** (the predicted "small compile fixes", now fixed): the
@@ -309,4 +361,4 @@ signing, bundle id `io.unom.punktfunk`. Notes:
- Mid-stream renegotiation (resolution change without reconnect) is designed-for but not - Mid-stream renegotiation (resolution change without reconnect) is designed-for but not
implemented (the Welcome is one-shot today). implemented (the Welcome is one-shot today).
- Host-side gamepad injection needs `/dev/uinput` access on the box (udev rule from - Host-side gamepad injection needs `/dev/uinput` access on the box (udev rule from
`docs/linux-setup.md`). `design/linux-setup.md`).
@@ -0,0 +1,387 @@
// DEBUG-only controller test panel, reached from Settings Controllers "Test Controller".
// It shows the live input of the active controller and lets you fire the hostclient feedback
// channels rumble, DualSense adaptive triggers, lightbar, player LEDs straight at the
// physical pad (no host needed), so the rendering paths a session uses can be confirmed
// on-device. Driven by PunktfunkKit's `ControllerTester`, which reuses the real renderers.
//
// tvOS is excluded for now (it has no segmented picker / the panel wants a pointer-style
// layout); macOS + iOS/iPadOS cover the validation need.
#if DEBUG && !os(tvOS)
import GameController
import PunktfunkKit
import SwiftUI
@MainActor
struct ControllerTestView: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject private var gamepads = GamepadManager.shared
@StateObject private var tester = ControllerTester()
@State private var heavyOn = false
@State private var lightOn = false
@State private var intensity = 0.75
@State private var triggerTarget = TriggerTarget.both
@State private var playerLED = -1
private enum TriggerTarget: String, CaseIterable, Identifiable {
case left = "L2", right = "R2", both = "Both"
var id: String { rawValue }
}
private struct TriggerDemo: Identifiable {
let label: String
let effect: DualSenseTriggerEffect
var id: String { label }
}
private static let triggerDemos: [TriggerDemo] = [
.init(label: "Off", effect: .off),
.init(label: "Resistance", effect: .feedback(start: 0.3, strength: 0.7)),
.init(label: "Weapon", effect: .weapon(start: 0.4, end: 0.7, strength: 0.9)),
.init(label: "Vibration", effect: .vibration(start: 0.1, amplitude: 0.8, frequency: 0.5)),
.init(label: "Bow", effect: .slope(start: 0.2, end: 0.9, startStrength: 0.2, endStrength: 0.9)),
]
// (display name, hardware colour, swatch colour)
private static let lightSwatches: [(String, GCColor, Color)] = [
("Red", GCColor(red: 1, green: 0, blue: 0), .red),
("Green", GCColor(red: 0, green: 1, blue: 0), .green),
("Blue", GCColor(red: 0, green: 0.2, blue: 1), .blue),
("White", GCColor(red: 1, green: 1, blue: 1), .white),
]
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Test Controller").font(.headline)
Spacer()
Button("Done") { dismiss() }.keyboardShortcut(.cancelAction)
}
.padding()
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let active = gamepads.active {
header(active)
inputCard
rumbleCard()
triggerCard(active)
extrasCard(active)
} else {
ContentUnavailableView(
"No controller",
systemImage: "gamecontroller",
description: Text("Connect a controller and pick it under "
+ "Settings → Controllers → Use controller."))
.frame(maxWidth: .infinity, minHeight: 220)
}
}
.padding()
}
}
.frame(minWidth: 420, minHeight: 540)
.onAppear { tester.target(gamepads.active?.controller) }
.onDisappear { tester.stop() }
.onChange(of: gamepads.active?.id) { _, _ in
heavyOn = false
lightOn = false
playerLED = -1
tester.target(gamepads.active?.controller)
}
}
// MARK: Header
private func header(_ c: GamepadManager.DiscoveredController) -> some View {
HStack(spacing: 10) {
Image(systemName: c.isDualSense ? "playstation.logo" : "gamecontroller.fill")
.font(.title2)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
Text(c.name).font(.headline)
Text(c.productCategory).font(.caption).foregroundStyle(.secondary)
}
Spacer()
}
}
// MARK: Input
private var inputCard: some View {
card("Input") {
// Poll the live controller at 30 Hz no handlers installed, so nothing else's
// capture is disturbed.
TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { _ in
if let gp = gamepads.active?.controller.extendedGamepad {
inputReadout(gp, controller: gamepads.active?.controller)
} else {
Text("Not an extended gamepad").foregroundStyle(.secondary)
}
}
}
}
@ViewBuilder
private func inputReadout(_ g: GCExtendedGamepad, controller: GCController?) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 20) {
stick("L", x: g.leftThumbstick.xAxis.value, y: g.leftThumbstick.yAxis.value,
pressed: g.leftThumbstickButton?.isPressed ?? false)
stick("R", x: g.rightThumbstick.xAxis.value, y: g.rightThumbstick.yAxis.value,
pressed: g.rightThumbstickButton?.isPressed ?? false)
VStack(spacing: 8) {
triggerBar("L2", value: g.leftTrigger.value)
triggerBar("R2", value: g.rightTrigger.value)
}
}
buttonGrid(g)
if let tp = Self.touchpad(g) {
touchpadView(tp)
}
if let m = controller?.motion {
motionReadout(m)
}
}
}
private func stick(_ label: String, x: Float, y: Float, pressed: Bool) -> some View {
VStack(spacing: 4) {
ZStack {
Circle().stroke(Color.secondary.opacity(0.3))
Circle()
.fill(pressed ? Color.accentColor : Color.secondary)
.frame(width: 12, height: 12)
.offset(x: CGFloat(x) * 22, y: CGFloat(-y) * 22) // GC y is +up
}
.frame(width: 56, height: 56)
Text("\(label) \(sgn(x)),\(sgn(y))").font(.caption2.monospaced()).foregroundStyle(.secondary)
}
}
private func triggerBar(_ label: String, value: Float) -> some View {
HStack(spacing: 6) {
Text(label).font(.caption2.monospaced()).frame(width: 22, alignment: .leading)
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.secondary.opacity(0.15))
Capsule().fill(Color.accentColor).frame(width: geo.size.width * CGFloat(value))
}
}
.frame(height: 10)
Text(mag(value)).font(.caption2.monospaced()).frame(width: 34, alignment: .trailing)
.foregroundStyle(.secondary)
}
.frame(width: 150)
}
private func buttonGrid(_ g: GCExtendedGamepad) -> some View {
var items: [(String, Bool)] = [
("A", g.buttonA.isPressed), ("B", g.buttonB.isPressed),
("X", g.buttonX.isPressed), ("Y", g.buttonY.isPressed),
("LB", g.leftShoulder.isPressed), ("RB", g.rightShoulder.isPressed),
("L3", g.leftThumbstickButton?.isPressed ?? false),
("R3", g.rightThumbstickButton?.isPressed ?? false),
("Menu", g.buttonMenu.isPressed),
("Opts", g.buttonOptions?.isPressed ?? false),
("", g.dpad.up.isPressed), ("", g.dpad.down.isPressed),
("", g.dpad.left.isPressed), ("", g.dpad.right.isPressed),
]
if let tp = Self.touchpad(g) { items.append(("Pad", tp.button.isPressed)) }
return LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 5), spacing: 6
) {
ForEach(items.indices, id: \.self) { i in
Text(items[i].0)
.font(.caption.monospaced())
.frame(maxWidth: .infinity, minHeight: 24)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(items[i].1 ? Color.accentColor : Color.secondary.opacity(0.15)))
.foregroundStyle(items[i].1 ? Color.white : Color.secondary)
}
}
}
private func touchpadView(
_ tp: (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
button: GCControllerButtonInput)
) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
.font(.caption2).foregroundStyle(.secondary)
ZStack {
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
fingerDot(tp.primary, color: .accentColor)
fingerDot(tp.secondary, color: .orange)
}
.frame(width: 150, height: 74)
}
}
private func fingerDot(_ pad: GCControllerDirectionPad, color: Color) -> some View {
let x = pad.xAxis.value, y = pad.yAxis.value
let active = !(x == 0 && y == 0) // GC snaps a lifted finger to exactly (0, 0)
return Circle().fill(color).frame(width: 10, height: 10)
.offset(x: CGFloat(x) * 71, y: CGFloat(-y) * 33)
.opacity(active ? 1 : 0)
}
private func motionReadout(_ m: GCMotion) -> some View {
let a = Self.totalAccel(m)
return VStack(alignment: .leading, spacing: 2) {
Text("Motion").font(.caption2).foregroundStyle(.secondary)
Text(String(format: "gyro %+.2f %+.2f %+.2f",
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
.font(.caption2.monospaced())
Text(String(format: "accel %+.2f %+.2f %+.2f", a.0, a.1, a.2))
.font(.caption2.monospaced())
}
}
// MARK: Rumble
private func rumbleCard() -> some View {
card("Rumble") {
VStack(alignment: .leading, spacing: 12) {
Picker("Strength", selection: $intensity) {
Text("25%").tag(0.25)
Text("50%").tag(0.5)
Text("75%").tag(0.75)
Text("100%").tag(1.0)
}
.pickerStyle(.segmented)
Toggle("Heavy motor (left)", isOn: $heavyOn)
Toggle("Light motor (right)", isOn: $lightOn)
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
.font(.caption).foregroundStyle(.secondary)
Text("Toggle a motor to feel it. The host maps a game's low/high-frequency "
+ "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics "
+ "can't reach its motors on macOS).")
.font(.caption).foregroundStyle(.secondary)
}
.onChange(of: heavyOn) { _, _ in applyRumble() }
.onChange(of: lightOn) { _, _ in applyRumble() }
.onChange(of: intensity) { _, _ in applyRumble() }
}
}
private func applyRumble() {
tester.rumble(low: heavyOn ? Float(intensity) : 0, high: lightOn ? Float(intensity) : 0)
}
// MARK: Adaptive triggers
private func triggerCard(_ c: GamepadManager.DiscoveredController) -> some View {
card("Adaptive triggers") {
if c.hasAdaptiveTriggers {
VStack(alignment: .leading, spacing: 12) {
Picker("Apply to", selection: $triggerTarget) {
ForEach(TriggerTarget.allCases) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 96), spacing: 8)], spacing: 8
) {
ForEach(Self.triggerDemos) { demo in
Button(demo.label) { applyTrigger(demo.effect) }
.buttonStyle(.bordered)
}
}
Text("Pick an effect, then pull L2/R2 to feel the resistance.")
.font(.caption).foregroundStyle(.secondary)
}
} else {
Text("Adaptive triggers need a DualSense.")
.font(.caption).foregroundStyle(.secondary)
}
}
}
private func applyTrigger(_ e: DualSenseTriggerEffect) {
switch triggerTarget {
case .left: tester.applyTrigger(e, right: false)
case .right: tester.applyTrigger(e, right: true)
case .both:
tester.applyTrigger(e, right: false)
tester.applyTrigger(e, right: true)
}
}
// MARK: Lightbar + player LED
@ViewBuilder
private func extrasCard(_ c: GamepadManager.DiscoveredController) -> some View {
if c.hasLight {
card("Lightbar & player LED") {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
ForEach(Self.lightSwatches.indices, id: \.self) { i in
Button { tester.setLight(Self.lightSwatches[i].1) } label: {
Circle().fill(Self.lightSwatches[i].2)
.frame(width: 26, height: 26)
.overlay(Circle().stroke(Color.secondary.opacity(0.4)))
}
.buttonStyle(.plain)
}
Button("Off") { tester.setLight(nil) }.buttonStyle(.bordered)
}
Picker("Player LED", selection: $playerLED) {
Text("Off").tag(-1)
Text("1").tag(0)
Text("2").tag(1)
Text("3").tag(2)
Text("4").tag(3)
}
.pickerStyle(.segmented)
.onChange(of: playerLED) { _, v in
tester.setPlayerIndex(GCControllerPlayerIndex(rawValue: v) ?? .indexUnset)
}
}
}
}
}
// MARK: Helpers
private func card<Content: View>(
_ title: String, @ViewBuilder _ content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(title).font(.subheadline.weight(.semibold))
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(RoundedRectangle(cornerRadius: 12).fill(Color.secondary.opacity(0.08)))
}
private func sgn(_ v: Float) -> String { String(format: "%+.2f", v) }
private func mag(_ v: Float) -> String { String(format: "%.2f", v) }
/// The touchpad surface of a PlayStation pad `GCDualSenseGamepad` and `GCDualShockGamepad`
/// don't share a touchpad type, so downcast either. `nil` for any other controller.
private static func touchpad(
_ g: GCExtendedGamepad
) -> (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
button: GCControllerButtonInput)? {
if let ds = g as? GCDualSenseGamepad {
return (ds.touchpadPrimary, ds.touchpadSecondary, ds.touchpadButton)
}
if let ds4 = g as? GCDualShockGamepad {
return (ds4.touchpadPrimary, ds4.touchpadSecondary, ds4.touchpadButton)
}
return nil
}
/// Total acceleration in g: gravity + user when the pad splits them, else the raw vector.
private static func totalAccel(_ m: GCMotion) -> (Double, Double, Double) {
if m.hasGravityAndUserAcceleration {
return (m.gravity.x + m.userAcceleration.x,
m.gravity.y + m.userAcceleration.y,
m.gravity.z + m.userAcceleration.z)
}
return (m.acceleration.x, m.acceleration.y, m.acceleration.z)
}
}
#endif
@@ -14,7 +14,18 @@ struct PunktfunkClientApp: App {
var body: some Scene { var body: some Scene {
WindowGroup("Punktfunk") { WindowGroup("Punktfunk") {
#if DEBUG
// PUNKTFUNK_SHOT_SCENE=<name> show that single mock-populated screen full-bleed for
// the App Store screenshot capture (tools/screenshots.sh). Normal launch otherwise;
// the whole path is absent from Release builds.
if let scene = ScreenshotMode.requestedScene {
ScreenshotHostView(scene: scene)
} else {
ContentView()
}
#else
ContentView() ContentView()
#endif
} }
// The Stream menu (Disconnect D, Show/Hide Statistics S) a real menu bar on // The Stream menu (Disconnect D, Show/Hide Statistics S) a real menu bar on
// macOS, hardware-keyboard shortcuts on iPad. tvOS has neither. // macOS, hardware-keyboard shortcuts on iPad. tvOS has neither.
@@ -0,0 +1,57 @@
// App Store screenshot harness device catalog.
//
// The harness captures the REAL running UI (not an offscreen ImageRenderer snapshot, which can't
// rasterize NavigationStack / Form / Liquid-Glass they come out black). The app is launched in
// "shot mode" (PUNKTFUNK_SHOT_SCENE=<name>, see ScreenshotHost) showing one mock-populated scene
// full-bleed, and the OS screenshots it: `xcrun simctl io booted screenshot` on the iOS/tvOS
// simulators (native pixels = the exact App Store size), `screencapture` for the mac window.
// tools/screenshots.sh drives it. DEBUG-only none of this ships in Release.
//
// This catalog records the target App Store sizes; on Apple platforms only the mac size is read
// at runtime (to size the capture window) the simulator IS the device, so iOS/tvOS pixels are
// whatever the booted device is.
#if DEBUG
import CoreGraphics
enum ShotOrientation { case natural, portrait, landscape }
/// A target App Store canvas: a natural-orientation pixel size + backing scale.
struct ShotDevice {
let id: String
let naturalWidth: Int
let naturalHeight: Int
let scale: CGFloat
func pixels(_ o: ShotOrientation) -> (w: Int, h: Int) {
let long = max(naturalWidth, naturalHeight)
let short = min(naturalWidth, naturalHeight)
switch o {
case .natural: return (naturalWidth, naturalHeight)
case .portrait: return (short, long)
case .landscape: return (long, short)
}
}
/// Logical point size (pixels / scale) used to size the mac capture window so that a
/// `screencapture` on a 2× display yields exactly `pixels(_:)`.
func points(_ o: ShotOrientation) -> CGSize {
let (w, h) = pixels(o)
return CGSize(width: CGFloat(w) / scale, height: CGFloat(h) / scale)
}
/// Mac: 2880×1800 (16:10 Retina) an accepted size; on a 1× display the window capture is
/// 1440×900, also accepted.
static let mac = ShotDevice(id: "mac", naturalWidth: 2880, naturalHeight: 1800, scale: 2)
/// iPhone 6.9" (required) for reference / the driver script's simulator choice.
static let iphone69 = ShotDevice(id: "iphone-6.9", naturalWidth: 1320, naturalHeight: 2868,
scale: 3)
/// iPad 13" (required).
static let ipad13 = ShotDevice(id: "ipad-13", naturalWidth: 2064, naturalHeight: 2752,
scale: 2)
/// Apple TV (always landscape).
static let appleTV = ShotDevice(id: "appletv", naturalWidth: 1920, naturalHeight: 1080,
scale: 1)
}
#endif
@@ -0,0 +1,147 @@
// App Store screenshot harness the in-app "shot mode" root.
//
// Launched with PUNKTFUNK_SHOT_SCENE=<name> (one of ShotScenes.all), the app shows that single
// mock-populated scene full-bleed instead of ContentView, so the OS can screenshot the REAL,
// fully-rendered UI (materials, NavigationStack, glass all the things ImageRenderer can't
// rasterize offscreen). tools/screenshots.sh drives one launch per scene per device.
//
// Capture per platform:
// iOS / tvOS simulator `xcrun simctl io booted screenshot` (native pixels = exact size).
// macOS `screencapture -l<windowID>` of the borderless capture window (the configurator
// prints `PF_SHOT_WINDOW=<id>`), or the no-permission self-capture fallback
// (PUNKTFUNK_SHOT_SELFCAPTURE=<dir> cacheDisplay; renders the real hierarchy but, like all
// non-window-server capture, omits material blur).
//
// Every screen prints `PF_SHOT_READY scene=<name>` to stdout once it has settled, so the driver
// can wait for layout instead of guessing with a fixed sleep.
#if DEBUG
import SwiftUI
#if os(macOS)
import AppKit
import ImageIO
#endif
@MainActor
enum ScreenshotMode {
/// The scene requested via PUNKTFUNK_SHOT_SCENE, or nil for a normal launch.
static var requestedScene: ShotScene? {
let name = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? ""
guard !name.isEmpty else { return nil }
return ShotScenes.all.first { $0.name == name }
}
}
/// Full-bleed host for a single scene, with per-platform window sizing / orientation and a
/// readiness ping for the capture script.
struct ScreenshotHostView: View {
let scene: ShotScene
var body: some View {
scene.make()
.environment(\.colorScheme, scene.colorScheme)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
.ignoresSafeArea()
#if os(macOS)
.background(MacShotWindowConfigurator(scene: scene))
#elseif os(iOS)
.background(IOSOrientationConfigurator(orientation: scene.orientation))
#endif
.task {
// Let layout + materials settle, then signal the driver.
try? await Task.sleep(nanoseconds: 900_000_000)
announceReady()
}
}
private func announceReady() {
print("PF_SHOT_READY scene=\(scene.name)")
fflush(stdout)
#if os(macOS)
MacSelfCapture.captureIfRequested(scene: scene)
#endif
}
}
#if os(macOS)
/// Sizes the hosting window to the mac canvas, strips the title bar to a clean full-bleed
/// surface, and prints the CGWindowID for `screencapture -l`.
private struct MacShotWindowConfigurator: NSViewRepresentable {
let scene: ShotScene
func makeNSView(context: Context) -> NSView { NSView() }
func updateNSView(_ view: NSView, context: Context) {
DispatchQueue.main.async {
guard let window = view.window, !context.coordinator.configured else { return }
context.coordinator.configured = true
// NavigationStack / Form / material chrome follow the WINDOW's appearance, not the
// SwiftUI colorScheme without this the dark scenes render on a light window (white
// background, washed-out materials).
window.appearance = NSAppearance(named: scene.colorScheme == .dark ? .darkAqua : .aqua)
let size = ShotDevice.mac.points(scene.orientation)
window.styleMask = [.titled, .fullSizeContentView]
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
window.isMovable = false
for button in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] {
window.standardWindowButton(button)?.isHidden = true
}
window.setContentSize(size)
window.center()
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
print("PF_SHOT_WINDOW=\(window.windowNumber) scene=\(scene.name) "
+ "size=\(Int(size.width))x\(Int(size.height))pt")
fflush(stdout)
}
}
func makeCoordinator() -> Coordinator { Coordinator() }
final class Coordinator { var configured = false }
}
/// No-permission fallback: capture the window's view tree via cacheDisplay. Renders the real
/// hierarchy (NavigationStack/Form/cards unlike ImageRenderer) but omits material blur, which
/// only the window server (screencapture) composites. Used when PUNKTFUNK_SHOT_SELFCAPTURE is set.
enum MacSelfCapture {
static func captureIfRequested(scene: ShotScene) {
guard let dir = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SELFCAPTURE"],
!dir.isEmpty,
let window = NSApp.windows.first(where: { $0.isVisible }),
let content = window.contentView else { return }
let outDir = URL(fileURLWithPath: (dir as NSString).expandingTildeInPath, isDirectory: true)
try? FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true)
guard let rep = content.bitmapImageRepForCachingDisplay(in: content.bounds) else { return }
content.cacheDisplay(in: content.bounds, to: rep)
let url = outDir.appendingPathComponent("\(ShotDevice.mac.id)-\(scene.name).png")
if let dest = CGImageDestinationCreateWithURL(
url as CFURL, "public.png" as CFString, 1, nil), let cg = rep.cgImage {
CGImageDestinationAddImage(dest, cg, nil)
CGImageDestinationFinalize(dest)
print("PF_SHOT_SAVED \(url.path) \(rep.pixelsWide)x\(rep.pixelsHigh)px")
}
fflush(stdout)
exit(0)
}
}
#endif
#if os(iOS)
/// Best-effort orientation lock for the requested scene (landscape for the stream hero, portrait
/// for chrome). Requires the app to allow those orientations in Info.plist.
private struct IOSOrientationConfigurator: UIViewControllerRepresentable {
let orientation: ShotOrientation
func makeUIViewController(context: Context) -> UIViewController { UIViewController() }
func updateUIViewController(_ vc: UIViewController, context: Context) {
guard let scene = vc.view.window?.windowScene else { return }
let mask: UIInterfaceOrientationMask = orientation == .landscape ? .landscapeRight : .portrait
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
vc.setNeedsUpdateOfSupportedInterfaceOrientations()
}
}
#endif
#endif
@@ -0,0 +1,284 @@
// App Store screenshot scenes the actual screens we render, each wired with mock data so it
// looks populated without a live host. Every scene is built from the REAL app views (HomeView,
// SettingsView, PairSheet, TrustCardView) so the screenshots track the shipping UI; only the
// live stream is faked (StreamView needs a real punktfunk/1 connection see ShotStreamHero).
#if DEBUG
import PunktfunkKit
import SwiftUI
/// One screen to capture: a name ( file suffix), the canvas orientation, a color scheme, and a
/// factory that builds the populated view on the main actor.
struct ShotScene {
let name: String
let orientation: ShotOrientation
let colorScheme: ColorScheme
let make: @MainActor () -> AnyView
}
@MainActor
enum ShotScenes {
static let all: [ShotScene] = [
ShotScene(name: "01-stream", orientation: .landscape, colorScheme: .dark) {
AnyView(ShotStreamHero())
},
ShotScene(name: "02-hosts", orientation: .natural, colorScheme: .dark) {
AnyView(ShotHome())
},
ShotScene(name: "03-pair", orientation: .natural, colorScheme: .dark) {
AnyView(ShotPair())
},
ShotScene(name: "04-trust", orientation: .landscape, colorScheme: .dark) {
AnyView(ShotTrust())
},
ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) {
AnyView(ShotSettings())
},
]
}
// MARK: - Mock data
@MainActor
enum ShotMock {
/// A populated saved-host grid: a pinned recent host, a couple more, mixed online state.
static func hostStore() -> HostStore {
let store = HostStore()
store.hosts = [
StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420)),
StoredHost(name: "Living Room PC", address: "192.168.1.41", port: 9777,
pinnedSHA256: fingerprint),
StoredHost(name: "Workshop", address: "10.0.0.7", port: 9777),
]
return store
}
static let host = StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: fingerprint)
/// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs.
static let fingerprint = Data((0..<32).map { UInt8(($0 &* 37 &+ 0x1d) & 0xff) })
}
// MARK: - Home
private struct ShotHome: View {
@StateObject private var store = ShotMock.hostStore()
@StateObject private var model = SessionModel()
@StateObject private var discovery = HostDiscovery()
var body: some View {
#if os(macOS)
HomeView(
store: store, model: model, discovery: discovery,
showAddHost: .constant(false), pairingTarget: .constant(nil),
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
connect: { _ in }, connectDiscovered: { _ in },
onPaired: { _, _ in }, onLaunchTitle: { _, _ in })
#else
HomeView(
store: store, model: model, discovery: discovery,
showAddHost: .constant(false), pairingTarget: .constant(nil),
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
showSettings: .constant(false),
connect: { _ in }, connectDiscovered: { _ in },
onPaired: { _, _ in }, onLaunchTitle: { _, _ in })
#endif
}
}
// MARK: - Settings
private struct ShotSettings: View {
var body: some View {
#if os(macOS)
// The mac Settings window is a fixed-size tabbed panel float it over a dimmed host
// grid so the shot reads as the preferences window over the running app.
ZStack {
ShotHome().blur(radius: 24).overlay(Color.black.opacity(0.45))
SettingsView()
.fixedSize()
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(radius: 40, y: 16)
}
#elseif os(iOS)
NavigationStack {
SettingsView()
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
}
#else
NavigationStack { SettingsView() }
#endif
}
}
// MARK: - Pair (PIN ceremony)
private struct ShotPair: View {
var body: some View {
ZStack {
ShotHome().blur(radius: 28).overlay(Color.black.opacity(0.5))
PairSheet(host: ShotMock.host, onPaired: { _ in })
.frame(maxWidth: 460)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
.clipShape(RoundedRectangle(cornerRadius: 18))
.shadow(radius: 40, y: 16)
.padding(40)
}
}
}
// MARK: - Trust (TOFU card over the blurred live stream)
private struct ShotTrust: View {
var body: some View {
ZStack {
ShotDesktopFrame()
.blur(radius: 32)
.overlay(Color.black.opacity(0.45))
TrustCardView(
fingerprint: ShotMock.fingerprint, hostName: "Battlestation",
onCancel: {}, onTrust: {}, onPairInstead: {})
}
}
}
// MARK: - Stream hero
/// The marketing hero: a stand-in streamed frame with the real glass HUD chip on top.
/// StreamView can't render here (it needs a live punktfunk/1 connection), so the frame is
/// synthetic set `PUNKTFUNK_SHOT_HERO=/path/to/frame.png` to drop in a real captured frame.
private struct ShotStreamHero: View {
var body: some View {
ZStack(alignment: .topTrailing) {
ShotDesktopFrame()
ShotHUD()
}
.background(Color.black)
}
}
/// A faithful copy of StreamHUDView's overlay (which needs a live PunktfunkConnection for the
/// mode line) with representative numbers, reusing the app's real `.glassBackground`.
private struct ShotHUD: View {
var body: some View {
VStack(alignment: .trailing, spacing: 4) {
HStack(spacing: 6) {
Circle().fill(Color.accentColor).frame(width: 7, height: 7)
Text("5120×1440@240 240 fps 812.4 Mb/s")
.font(.system(.caption, design: .monospaced))
}
Text("capture→client 1.3/2.1 ms p50/p95")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
#if os(macOS)
Text("⌘⎋ releases the mouse")
.font(.caption2).foregroundStyle(.secondary)
#elseif os(tvOS)
Text("Press Menu to disconnect")
.font(.caption).foregroundStyle(.secondary)
#endif
}
.padding(10)
.glassBackground(RoundedRectangle(cornerRadius: 10))
.padding(10)
}
}
/// A synthetic "streamed frame" a synthwave scene that reads as game content without shipping
/// any real art. Replaced wholesale when `PUNKTFUNK_SHOT_HERO` points at a real PNG.
private struct ShotDesktopFrame: View {
var body: some View {
if let image = Self.overrideImage {
image.resizable().scaledToFill()
} else {
synthetic
}
}
private var synthetic: some View {
ZStack {
LinearGradient(
colors: [
Color(red: 0.05, green: 0.02, blue: 0.16),
Color(red: 0.35, green: 0.05, blue: 0.42),
Color(red: 0.95, green: 0.30, blue: 0.35),
Color(red: 0.99, green: 0.62, blue: 0.32),
],
startPoint: .top, endPoint: .bottom)
Canvas { ctx, size in
let horizon = size.height * 0.52
// Sun.
let sunR = size.height * 0.20
let sun = CGRect(x: size.width / 2 - sunR, y: horizon - sunR * 1.6,
width: sunR * 2, height: sunR * 2)
ctx.fill(Path(ellipseIn: sun),
with: .linearGradient(
Gradient(colors: [Color(red: 1, green: 0.95, blue: 0.5),
Color(red: 1, green: 0.35, blue: 0.45)]),
startPoint: CGPoint(x: sun.midX, y: sun.minY),
endPoint: CGPoint(x: sun.midX, y: sun.maxY)))
// Sun scanlines clip a copy so the base context stays unclipped (GraphicsContext
// is a value type; there is no resetClip).
var sunCtx = ctx
sunCtx.clip(to: Path(ellipseIn: sun))
for i in 0..<7 {
let y = sun.minY + sun.height * (0.55 + Double(i) * 0.07)
let bar = CGRect(x: sun.minX, y: y, width: sun.width,
height: sun.height * (0.012 + Double(i) * 0.006))
sunCtx.fill(Path(bar), with: .color(.black.opacity(0.85)))
}
// Perspective grid below the horizon.
ctx.opacity = 0.55
let cx = size.width / 2
for col in -10...10 {
var p = Path()
p.move(to: CGPoint(x: cx, y: horizon))
p.addLine(to: CGPoint(x: cx + Double(col) * size.width * 0.11,
y: size.height))
ctx.stroke(p, with: .color(Color(red: 0.6, green: 0.95, blue: 1)),
lineWidth: 1.5)
}
var row = horizon
var step = size.height * 0.012
while row < size.height {
var p = Path()
p.move(to: CGPoint(x: 0, y: row))
p.addLine(to: CGPoint(x: size.width, y: row))
ctx.stroke(p, with: .color(Color(red: 0.6, green: 0.95, blue: 1)),
lineWidth: 1.5)
step *= 1.32
row += step
}
}
}
.overlay(alignment: .bottomLeading) {
// A small "now playing" chip so the frame reads as live content, not a wallpaper.
HStack(spacing: 8) {
Image(systemName: "gamecontroller.fill")
Text("Streaming from Battlestation")
.font(.system(.callout, weight: .semibold))
}
.padding(.horizontal, 14).padding(.vertical, 9)
.glassBackground(Capsule())
.padding(18)
}
.ignoresSafeArea()
}
/// `PUNKTFUNK_SHOT_HERO=/abs/path.png` use a real captured frame as the hero background.
static var overrideImage: Image? {
guard let path = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_HERO"],
!path.isEmpty, FileManager.default.fileExists(atPath: path) else { return nil }
#if os(macOS)
guard let ns = NSImage(contentsOfFile: path) else { return nil }
return Image(nsImage: ns)
#else
guard let ui = UIImage(contentsOfFile: path) else { return nil }
return Image(uiImage: ui)
#endif
}
}
#endif
@@ -28,6 +28,9 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true @AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue @AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
@ObservedObject private var gamepads = GamepadManager.shared @ObservedObject private var gamepads = GamepadManager.shared
#if DEBUG && !os(tvOS)
@State private var showControllerTest = false
#endif
#if os(macOS) #if os(macOS)
@AppStorage(DefaultsKey.speakerUID) private var speakerUID = "" @AppStorage(DefaultsKey.speakerUID) private var speakerUID = ""
@AppStorage(DefaultsKey.micUID) private var micUID = "" @AppStorage(DefaultsKey.micUID) private var micUID = ""
@@ -411,6 +414,11 @@ struct SettingsView: View {
Text(option.label).tag(option.tag) Text(option.label).tag(option.tag)
} }
} }
#if DEBUG && !os(tvOS)
Button("Test Controller…") { showControllerTest = true }
.disabled(gamepads.active == nil)
.sheet(isPresented: $showControllerTest) { ControllerTestView() }
#endif
} header: { } header: {
Text("Controllers") Text("Controllers")
} footer: { } footer: {
@@ -0,0 +1,153 @@
// Raw-HID DualSense rumble for macOS.
//
// Apple's GameController/CHHapticEngine path does NOT drive the DualSense's rumble motors on
// macOS a documented platform gap: adaptive triggers, lightbar and player LEDs all work
// (different APIs), but `CHHapticEngine` output never reaches the motors. So we write the motor
// amplitudes straight into the DualSense HID output report, exactly the way SDL and the Linux
// `hid-playstation` driver do (the same report that already rumbles this pad on a Linux host).
//
// USB (report 0x02, 48 bytes, no CRC) and Bluetooth (report 0x31, 78 bytes, trailing CRC32) are
// both handled. The App Sandbox permits the raw-HID access via the app's `device.usb` +
// `device.bluetooth` entitlements, and this coexists with GameController holding the same device
// (non-seized open). Output-only, so no run-loop scheduling is needed.
//
// macOS-only: IOKit HID device access isn't available to apps on iOS/tvOS.
#if os(macOS)
import Foundation
import IOKit
import IOKit.hid
import os
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
/// Opens the first connected Sony DualSense and forwards motor rumble to it over raw HID.
/// Single-pad model (we forward exactly one controller), so the first match is the right one.
final class DualSenseHID {
private let manager: IOHIDManager
private var device: IOHIDDevice?
private var bluetooth = false
private var closed = false
private static let vendorSony = 0x054C
// DualSense (0x0CE6) and DualSense Edge (0x0DF2). The DualShock 4 uses a different report
// layout and is intentionally not handled here.
private static let productIDs = [0x0CE6, 0x0DF2]
/// "USB" or "Bluetooth" for logs / the debug panel. Valid after a successful `open()`.
var transport: String { bluetooth ? "Bluetooth" : "USB" }
init() {
manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
}
deinit { close() }
/// Find and open the first connected DualSense. Returns false if none is present or it can't
/// be opened (caller then falls back to CoreHaptics).
func open() -> Bool {
let matches = Self.productIDs.map { pid in
[kIOHIDVendorIDKey: Self.vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
}
IOHIDManagerSetDeviceMatchingMultiple(manager, matches as CFArray)
guard IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else {
log.info("rumble: DualSense HID manager open failed — falling back to CoreHaptics")
return false
}
guard let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice>,
let dev = devices.first
else {
log.info("rumble: no DualSense HID device found — falling back to CoreHaptics")
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
return false
}
device = dev
let transport = IOHIDDeviceGetProperty(dev, kIOHIDTransportKey as CFString) as? String
bluetooth = transport?.lowercased().contains("bluetooth") ?? false
log.info("rumble: DualSense raw-HID rumble active (transport=\(self.transport, privacy: .public))")
return true
}
/// Drive the motors. `low` = left/heavy (low-frequency), `high` = right/light (high-frequency),
/// each 0...255. (0, 0) stops.
func rumble(low: UInt8, high: UInt8) {
guard let dev = device else { return }
let report = bluetooth
? Self.bluetoothReport(low: low, high: high)
: Self.usbReport(low: low, high: high)
let rc = report.withUnsafeBufferPointer { buf in
IOHIDDeviceSetReport(
dev, kIOHIDReportTypeOutput, CFIndex(report[0]), buf.baseAddress!, buf.count)
}
if rc != kIOReturnSuccess {
log.error("rumble: IOHIDDeviceSetReport failed (0x\(String(format: "%08x", rc), privacy: .public))")
}
}
func close() {
guard !closed else { return }
closed = true
if device != nil { rumble(low: 0, high: 0) } // silence the motors before releasing
device = nil
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
}
// MARK: - Report builders
// DualSense effects payload (DS5EffectsState_t / hid-playstation `common`) offsets relative
// to the payload start:
// 0 flag0 (enable bits) 2 motor_right (high-freq) 3 motor_left (low-freq)
// 1 flag1 38 flag2 (enhanced enable)
// We mirror the Linux driver: flag0 = COMPATIBLE_VIBRATION | HAPTICS_SELECT, flag2 =
// COMPATIBLE_VIBRATION2 (the enhanced-firmware path), motors sent directly. valid_flag1 stays
// 0 so this rumble-only report leaves the lightbar / triggers / player LEDs (driven by
// GameController) untouched.
private static func fillEffects(_ data: inout [UInt8], at base: Int, low: UInt8, high: UInt8) {
data[base + 0] = 0x03 // COMPATIBLE_VIBRATION (0x01) | HAPTICS_SELECT (0x02)
data[base + 2] = high // motor_right
data[base + 3] = low // motor_left
data[base + 38] = 0x04 // COMPATIBLE_VIBRATION2 (enhanced rumble, firmware 0x0224)
}
// `usbReport` / `bluetoothReport` / `crc32` are internal (not private) so the unit tests can
// pin the exact wire layout against the SDL / hid-playstation spec without a physical pad.
static func usbReport(low: UInt8, high: UInt8) -> [UInt8] {
var d = [UInt8](repeating: 0, count: 48)
d[0] = 0x02 // report id
fillEffects(&d, at: 1, low: low, high: high)
return d
}
static func bluetoothReport(low: UInt8, high: UInt8) -> [UInt8] {
var d = [UInt8](repeating: 0, count: 78)
d[0] = 0x31 // report id
d[1] = 0x00 // seq/tag (static, as SDL)
d[2] = 0x10 // magic
fillEffects(&d, at: 3, low: low, high: high)
// Trailing CRC32 over a 0xA2 seed byte + the report minus its 4 CRC bytes, little-endian.
let crc = Self.crc32(seed: 0xA2, d[0..<(d.count - 4)])
d[74] = UInt8(crc & 0xFF)
d[75] = UInt8((crc >> 8) & 0xFF)
d[76] = UInt8((crc >> 16) & 0xFF)
d[77] = UInt8((crc >> 24) & 0xFF)
return d
}
/// Standard reflected CRC32 (zlib poly 0xEDB88320, init 0xFFFFFFFF, final XOR) over `seed`
/// followed by `bytes` the DualSense Bluetooth output-report checksum (seed 0xA2). Matches
/// SDL's `SDL_crc32`/the kernel's `crc32_le` framing.
static func crc32<S: Sequence>(seed: UInt8, _ bytes: S) -> UInt32
where S.Element == UInt8 {
var crc: UInt32 = 0xFFFF_FFFF
func step(_ b: UInt8) {
crc ^= UInt32(b)
for _ in 0..<8 {
crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB8_8320 : crc >> 1
}
}
step(seed)
for b in bytes { step(b) }
return ~crc
}
}
#endif
@@ -50,10 +50,12 @@ private final class FeedbackStopFlag: @unchecked Sendable {
private final class RumbleRenderer: @unchecked Sendable { private final class RumbleRenderer: @unchecked Sendable {
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive) private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
/// One actuator's started engine plus the player currently driving it (nil = idle). The
/// player is rebuilt per level change `drive` bakes the target intensity into a fresh
/// continuous event rather than scaling a long-lived one with a dynamic parameter.
private struct Motor { private struct Motor {
let engine: CHHapticEngine let engine: CHHapticEngine
let player: CHHapticAdvancedPatternPlayer var player: CHHapticAdvancedPatternPlayer?
var playing = false
} }
private var controller: GCController? private var controller: GCController?
@@ -67,11 +69,30 @@ private final class RumbleRenderer: @unchecked Sendable {
/// Last logged active/silent state for a one-line transition log, not per-event spam. /// Last logged active/silent state for a one-line transition log, not per-event spam.
private var wasActive = false private var wasActive = false
func retarget(_ c: GCController?) { /// CHHapticEvent sharpness = actuator frequency. A DualSense's voice-coil motors need a
/// defined frequency to move at all an intensity-only event (no sharpness) left them
/// silent, while a classic Xbox rotor (which ignores sharpness) rumbled fine. 0.5 is the mid
/// value the known-working macOS DualSense rumble implementations use. (Used only on the
/// CoreHaptics path a DualSense on macOS is driven over raw HID instead, see below.)
private static let sharpness: Float = 0.5
#if os(macOS)
/// Set when the active pad is a DualSense: its motors are driven over raw HID (CoreHaptics
/// does not reach them on macOS adaptive triggers/lightbar work, rumble is silent). nil for
/// every other controller, which keeps the CoreHaptics path.
private var dualSenseHID: DualSenseHID?
#endif
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
/// rumble backend now in use for the debug controller-test panel.
func retarget(_ c: GCController?, onBackend: ((String) -> Void)? = nil) {
queue.async { queue.async {
self.teardown() self.teardown()
self.closeHID()
self.controller = c self.controller = c
self.broken = false self.broken = false
_ = self.openHIDIfDualSense(c)
onBackend?(self.backendNote(for: c))
} }
} }
@@ -83,22 +104,36 @@ private final class RumbleRenderer: @unchecked Sendable {
log.debug( log.debug(
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)") "rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
} }
// A DualSense on macOS is driven over raw HID; CoreHaptics is the path for every
// other pad (and for a DualSense whose HID device could not be opened).
if self.hidRumble(low: lowAmp, high: highAmp) { return }
guard !self.broken else { return } guard !self.broken else { return }
if active, self.low == nil, self.high == nil { if active, self.low == nil, self.high == nil {
self.setup() self.setup()
} }
let ok: Bool
if self.high != nil { if self.high != nil {
self.drive(&self.low, Float(lowAmp) / 65535) // Per-handle: low = left/heavy motor, high = right/light the XInput convention
self.drive(&self.high, Float(highAmp) / 65535) // the wire carries.
let okLow = self.drive(&self.low, Float(lowAmp) / 65535)
let okHigh = self.drive(&self.high, Float(highAmp) / 65535)
ok = okLow && okHigh
} else { } else {
// Combined engine: whichever motor is stronger wins. // Combined engine: whichever motor is stronger wins.
self.drive(&self.low, Float(max(lowAmp, highAmp)) / 65535) ok = self.drive(&self.low, Float(max(lowAmp, highAmp)) / 65535)
} }
// Rebuild on the next nonzero amplitude if an engine errored and tear down OUTSIDE
// the `inout` accesses above, so teardown() never mutates a motor that a `drive` call
// still holds an exclusive reference to.
if !ok { self.teardown() }
} }
} }
func stop() { func stop() {
queue.sync { self.teardown() } queue.sync {
self.teardown()
self.closeHID()
}
} }
/// Engines per handle when the pad distinguishes them (low = left/heavy motor, /// Engines per handle when the pad distinguishes them (low = left/heavy motor,
@@ -144,44 +179,51 @@ private final class RumbleRenderer: @unchecked Sendable {
self?.queue.async { self?.teardown() } self?.queue.async { self?.teardown() }
} }
do { do {
// Start the engine now; the player that actually moves the motor is built per level
// change in `drive` (a fresh event baked at the target intensity).
try engine.start() try engine.start()
let event = CHHapticEvent( return Motor(engine: engine, player: nil)
eventType: .hapticContinuous,
parameters: [CHHapticEventParameter(parameterID: .hapticIntensity, value: 1)],
relativeTime: 0,
duration: TimeInterval(GCHapticDurationInfinite))
let player = try engine.makeAdvancedPlayer(with: CHHapticPattern(events: [event], parameters: []))
return Motor(engine: engine, player: player)
} catch { } catch {
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)") log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
return nil return nil
} }
} }
private func drive(_ motor: inout Motor?, _ amplitude: Float) { /// Drive one motor at `amplitude` (0...1) by (re)building a continuous player whose intensity
guard var m = motor else { return } /// is BAKED into the event. On a DualSense this is what actually moves the actuators: a
/// fixed-intensity event scaled by a dynamic `.hapticIntensityControl` parameter (the old
/// path) drives the iPhone Taptic Engine but is silent on a controller's haptic engine. The
/// event carries an explicit sharpness (frequency) so the voice coils respond, and an infinite
/// duration so a single host update the host sends rumble only when the level changes
/// sustains until the next one. Returns false if the engine errored; the caller tears down for
/// a rebuild (done outside this `inout` access to avoid an exclusivity violation).
private func drive(_ motor: inout Motor?, _ amplitude: Float) -> Bool {
guard var m = motor else { return true }
// Replace any running player: stop the old, and for a zero level leave the motor idle.
try? m.player?.stop(atTime: CHHapticTimeImmediate)
m.player = nil
guard amplitude > 0 else { motor = m; return true }
do { do {
if amplitude > 0 { let event = CHHapticEvent(
if !m.playing { eventType: .hapticContinuous,
try m.player.start(atTime: CHHapticTimeImmediate) parameters: [
m.playing = true CHHapticEventParameter(parameterID: .hapticIntensity, value: amplitude),
} CHHapticEventParameter(parameterID: .hapticSharpness, value: Self.sharpness),
try m.player.sendParameters( ],
[CHHapticDynamicParameter( relativeTime: 0,
parameterID: .hapticIntensityControl, duration: TimeInterval(GCHapticDurationInfinite))
value: amplitude, relativeTime: 0)], let player = try m.engine.makeAdvancedPlayer(
atTime: CHHapticTimeImmediate) with: CHHapticPattern(events: [event], parameters: []))
} else if m.playing { try player.start(atTime: CHHapticTimeImmediate)
try m.player.stop(atTime: CHHapticTimeImmediate) m.player = player
m.playing = false
}
motor = m motor = m
return true
} catch { } catch {
// A transient failure (the engine stopped/reset between its handler firing and now). // A transient failure (the engine stopped/reset between its handler firing and now).
// Tear down so the next nonzero amplitude rebuilds do NOT latch rumble off for the // Signal a rebuild do NOT latch rumble off for the session (the old "spotty" bug).
// session (that was the old "spotty" behaviour).
log.warning("rumble: haptic update failed — rebuilding: \(error, privacy: .public)") log.warning("rumble: haptic update failed — rebuilding: \(error, privacy: .public)")
teardown() motor = m
return false
} }
} }
@@ -191,12 +233,56 @@ private final class RumbleRenderer: @unchecked Sendable {
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.) // (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
m.engine.stoppedHandler = { _ in } m.engine.stoppedHandler = { _ in }
m.engine.resetHandler = {} m.engine.resetHandler = {}
try? m.player.stop(atTime: CHHapticTimeImmediate) try? m.player?.stop(atTime: CHHapticTimeImmediate)
m.engine.stop() m.engine.stop()
} }
low = nil low = nil
high = nil high = nil
} }
// MARK: - DualSense raw-HID rumble (macOS)
//
// On macOS the DualSense's motors aren't reachable through CHHapticEngine, so for a DualSense
// we drive them over raw HID (see `DualSenseHID`); every other pad keeps the CoreHaptics path.
// All three run on the serial `queue`, like the rest of the renderer state.
private func openHIDIfDualSense(_ c: GCController?) -> Bool {
#if os(macOS)
guard let c, c.extendedGamepad is GCDualSenseGamepad else { return false }
let hid = DualSenseHID()
guard hid.open() else { return false }
dualSenseHID = hid
return true
#else
return false
#endif
}
/// Drive the DualSense's motors over HID if that's the active backend; false not a HID pad,
/// so the caller uses CoreHaptics. The wire's 0...0xFFFF amplitudes scale to the pad's 0...255.
private func hidRumble(low: UInt16, high: UInt16) -> Bool {
#if os(macOS)
guard let hid = dualSenseHID else { return false }
hid.rumble(low: UInt8(low >> 8), high: UInt8(high >> 8))
return true
#else
return false
#endif
}
private func closeHID() {
#if os(macOS)
dualSenseHID?.close()
dualSenseHID = nil
#endif
}
private func backendNote(for c: GCController?) -> String {
#if os(macOS)
if let hid = dualSenseHID { return "DualSense HID · \(hid.transport)" }
#endif
return c == nil ? "" : "CoreHaptics"
}
} }
public final class GamepadFeedback { public final class GamepadFeedback {
@@ -369,3 +455,74 @@ public final class GamepadFeedback {
return which == 0 ? ds.leftTrigger : ds.rightTrigger return which == 0 ? ds.leftTrigger : ds.rightTrigger
} }
} }
#if DEBUG
/// Local feedback driver for the Settings Controllers "Test Controller" panel (DEBUG builds
/// only). It drives the SAME CoreHaptics rumble renderer and `DualSenseTriggerEffect` path a
/// live session uses just aimed at the physically-connected controller instead of the
/// hostclient feedback planes so rumble, the adaptive triggers, the lightbar and the player
/// LEDs can be confirmed on-device without a host. Reusing the real renderers is the point:
/// a passing test exercises the exact code a session runs.
@MainActor
public final class ControllerTester: ObservableObject {
private let renderer = RumbleRenderer()
private weak var controller: GCController?
/// The rumble backend now in use "DualSense HID · USB/Bluetooth", "CoreHaptics", or ""
/// for the test panel to display so it's obvious which path a given pad takes.
@Published public private(set) var rumbleBackend = ""
public init() {}
/// Aim the feedback at a controller (nil releases it). Idempotent safe to call on every
/// active-controller change.
public func target(_ c: GCController?) {
guard c !== controller else { return }
controller = c
renderer.retarget(c) { [weak self] note in
Task { @MainActor in self?.rumbleBackend = note }
}
}
/// Drive both motors at 0...1 amplitudes low = left/heavy, high = right/light mapped to
/// the 0...0xFFFF wire range the session carries, through the real `RumbleRenderer`.
public func rumble(low: Float, high: Float) {
func u16(_ v: Float) -> UInt16 { UInt16((min(max(v, 0), 1) * 65535).rounded()) }
renderer.apply(low: u16(low), high: u16(high))
}
public func stopRumble() { renderer.apply(low: 0, high: 0) }
/// Replay an adaptive-trigger effect on a DualSense via the real `DualSenseTriggerEffect`
/// renderer. `right == false` L2, `true` R2. No-op on a non-DualSense pad.
public func applyTrigger(_ effect: DualSenseTriggerEffect, right: Bool) {
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return }
effect.apply(to: right ? ds.rightTrigger : ds.leftTrigger)
}
public func resetTriggers() {
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return }
ds.leftTrigger.setModeOff()
ds.rightTrigger.setModeOff()
}
/// Lightbar colour (DualSense / DualShock 4); nil turns it off. No-op without a light.
public func setLight(_ color: GCColor?) {
controller?.light?.color = color ?? GCColor(red: 0, green: 0, blue: 0)
}
/// Player-indicator LEDs (`.index1`...`.index4`, or `.indexUnset` to clear).
public func setPlayerIndex(_ index: GCControllerPlayerIndex) {
controller?.playerIndex = index
}
/// Silence every channel and release the controller call on the panel's disappear.
public func stop() {
resetTriggers()
setPlayerIndex(.indexUnset)
setLight(nil)
renderer.retarget(nil) // async teardown: stops the motors + drops the controller ref
controller = nil
}
}
#endif
@@ -0,0 +1,47 @@
// Locks the DualSense raw-HID rumble report layout to the SDL / Linux hid-playstation spec.
// The motors can only be confirmed on a physical pad, but these guard against a silent byte
// error in the offsets, enable flags, lengths, and the Bluetooth CRC32 the parts most likely
// to regress unnoticed. macOS-only (DualSenseHID isn't compiled elsewhere).
#if os(macOS)
import XCTest
@testable import PunktfunkKit
final class DualSenseHIDTests: XCTestCase {
func testUSBReportLayout() {
let r = DualSenseHID.usbReport(low: 0xAA, high: 0xBB)
XCTAssertEqual(r.count, 48)
XCTAssertEqual(r[0], 0x02) // report id
XCTAssertEqual(r[1], 0x03) // flag0: COMPATIBLE_VIBRATION | HAPTICS_SELECT
XCTAssertEqual(r[2], 0x00) // flag1 (untouched leaves lightbar/LEDs alone)
XCTAssertEqual(r[3], 0xBB) // motor_right = high
XCTAssertEqual(r[4], 0xAA) // motor_left = low
XCTAssertEqual(r[39], 0x04) // flag2: COMPATIBLE_VIBRATION2 (payload offset 38 + report id)
}
func testBluetoothReportLayoutAndCRC() {
let r = DualSenseHID.bluetoothReport(low: 0xAA, high: 0xBB)
XCTAssertEqual(r.count, 78)
XCTAssertEqual(r[0], 0x31) // report id
XCTAssertEqual(r[1], 0x00) // seq/tag
XCTAssertEqual(r[2], 0x10) // magic
XCTAssertEqual(r[3], 0x03) // flag0
XCTAssertEqual(r[5], 0xBB) // motor_right = high (payload offset 2 + 3-byte BT header)
XCTAssertEqual(r[6], 0xAA) // motor_left = low
XCTAssertEqual(r[41], 0x04) // flag2 (payload offset 38 + 3)
// Trailing CRC32 = standard CRC32 over (0xA2 seed + report[0..<74]), little-endian.
let expected = DualSenseHID.crc32(seed: 0xA2, r[0..<74])
let stored = UInt32(r[74]) | (UInt32(r[75]) << 8) | (UInt32(r[76]) << 16) | (UInt32(r[77]) << 24)
XCTAssertEqual(stored, expected)
}
func testCRC32MatchesStandardCheckVector() {
// The canonical CRC32 check value: CRC32("123456789") == 0xCBF43926. Our helper folds a
// seed byte in first, so feed seed='1' and the rest proving poly/reflection/init/final.
let crc = DualSenseHID.crc32(seed: UInt8(ascii: "1"), Array("23456789".utf8))
XCTAssertEqual(crc, 0xCBF4_3926)
}
}
#endif
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env bash
# App Store screenshot driver for the Punktfunk Apple client.
#
# Launches the app in "shot mode" (PUNKTFUNK_SHOT_SCENE=<name> → one mock-populated screen,
# full-bleed; see Sources/PunktfunkClient/Screenshots/) once per scene per device, and lets the OS
# capture the REAL rendered UI:
# • macOS → `screencapture` of the app's borderless window.
# • iOS/iPadOS/tvOS → a booted Simulator + `xcrun simctl io booted screenshot` (native pixels =
# the exact App Store size for that device).
#
# The captured pixels are exactly App Store Connect's required sizes:
# mac 2880×1800 (a 1× display yields 1440×900 — also accepted)
# iphone-6.9 1320×2868 (portrait) / 2868×1320 (the landscape hero)
# ipad-13 2064×2752 (portrait) / 2752×2064 (the landscape hero)
# appletv 1920×1080
#
# Requirements:
# • macOS target: just the Swift toolchain (`swift build`) + a one-time Screen Recording grant
# for your terminal (System Settings → Privacy & Security → Screen Recording).
# • iOS/iPadOS/tvOS targets: full Xcode (xcodebuild + Simulators), not just Command Line Tools.
#
# Usage:
# tools/screenshots.sh all # every platform this machine can build
# tools/screenshots.sh macos # just macOS
# tools/screenshots.sh ios ipad tvos # specific platforms
# OUT=~/Desktop/shots tools/screenshots.sh all
# PUNKTFUNK_SHOT_HERO=~/frame.png tools/screenshots.sh ios # real captured frame for the hero
#
# Keep SCENES in sync with ShotScenes.all.
set -euo pipefail
APPLE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$APPLE_DIR"
OUT="${OUT:-$APPLE_DIR/screenshots}"
BUNDLE_ID="io.unom.punktfunk"
SCENES=(01-stream 02-hosts 03-pair 04-trust 05-settings)
SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing
mkdir -p "$OUT"
log() { printf '\033[1;36m[shots]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[shots]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[shots]\033[0m %s\n' "$*" >&2; exit 1; }
require_xcode() {
xcrun --find simctl >/dev/null 2>&1 \
|| die "Full Xcode required for simulator capture (have Command Line Tools only).
Install Xcode, then: sudo xcode-select -s /Applications/Xcode.app"
}
# ---------------------------------------------------------------------------- macOS
shoot_macos() {
log "macOS — building (swift build -c release)…"
swift build -c release >/dev/null
local bin=".build/release/PunktfunkClient"
[ -x "$bin" ] || die "build produced no $bin"
for scene in "${SCENES[@]}"; do
local logf; logf="$(mktemp)"
PUNKTFUNK_SHOT_SCENE="$scene" "$bin" >"$logf" 2>&1 &
local pid=$!
# Wait for the window to exist and the scene to settle.
local win=""
for _ in $(seq 1 50); do
win="$(grep -o 'PF_SHOT_WINDOW=[0-9]*' "$logf" | head -1 | cut -d= -f2 || true)"
[ -n "$win" ] && grep -q PF_SHOT_READY "$logf" && break
sleep 0.2
done
if [ -z "$win" ]; then
kill -9 "$pid" 2>/dev/null || true
warn "macOS/$scene: app never reported a window — skipping"; cat "$logf" >&2; continue
fi
local dest="$OUT/mac-$scene.png"
if screencapture -x -o -l"$win" "$dest" 2>/dev/null && [ -s "$dest" ]; then
log "macOS/$scene$dest ($(pixels "$dest"))"
else
warn "macOS/$scene: screencapture failed — grant your terminal Screen Recording permission
(System Settings → Privacy & Security → Screen Recording), then re-run."
fi
kill -9 "$pid" 2>/dev/null || true
rm -f "$logf"
done
}
# ------------------------------------------------------------------ iOS / iPadOS / tvOS
# $1 device-type regex (matches both existing device names and the device-type catalog)
# $2 scheme $3 sdk $4 file prefix $5 runtime platform (iOS|tvOS — for the create fallback)
shoot_sim() {
require_xcode
local match="$1" scheme="$2" sdk="$3" prefix="$4" platform="$5"
# Reuse an existing device of this type; else create a throwaway one against the newest
# available runtime for the platform. CI runners commonly ship a runtime but not every device
# (the iPhone 16 Pro Max is absent on ours), so create-on-demand is what makes it reproducible.
local udid
udid="$(xcrun simctl list devices available | grep -E "$match" | grep -oE '[0-9A-F-]{36}' | head -1 || true)"
if [ -z "$udid" ]; then
local devtype rt
devtype="$(xcrun simctl list devicetypes | grep -E "$match" \
| grep -oE 'com\.apple\.CoreSimulator\.SimDeviceType\.[A-Za-z0-9.-]+' | head -1 || true)"
rt="$(xcrun simctl list runtimes available | grep -E "^$platform " \
| grep -oE 'com\.apple\.CoreSimulator\.SimRuntime\.[A-Za-z0-9.-]+' | tail -1 || true)"
if [ -n "$devtype" ] && [ -n "$rt" ]; then
udid="$(xcrun simctl create "pf-shot-$prefix" "$devtype" "$rt" 2>/dev/null || true)"
[ -n "$udid" ] && log "$prefix — created Simulator $udid ($devtype)"
fi
fi
[ -n "$udid" ] || die "$prefix: no Simulator matching /$match/, and none could be created
(needs a $platform runtime + a matching device type — check 'xcrun simctl list')."
log "$prefix — Simulator $udid"
xcrun simctl boot "$udid" 2>/dev/null || true
xcrun simctl bootstatus "$udid" -b >/dev/null 2>&1 || true
log "$prefix — building ($scheme)…"
local dd; dd="$(mktemp -d)"
xcodebuild -project Punktfunk.xcodeproj -scheme "$scheme" -configuration Debug \
-sdk "$sdk" -destination "id=$udid" -derivedDataPath "$dd" \
CODE_SIGNING_ALLOWED=NO build >/dev/null \
|| die "$prefix: xcodebuild failed"
local app; app="$(find "$dd/Build/Products" -maxdepth 2 -name '*.app' -type d | head -1)"
[ -n "$app" ] || die "$prefix: no .app built"
xcrun simctl install "$udid" "$app"
for scene in "${SCENES[@]}"; do
xcrun simctl terminate "$udid" "$BUNDLE_ID" 2>/dev/null || true
SIMCTL_CHILD_PUNKTFUNK_SHOT_SCENE="$scene" \
${PUNKTFUNK_SHOT_HERO:+SIMCTL_CHILD_PUNKTFUNK_SHOT_HERO="$PUNKTFUNK_SHOT_HERO"} \
xcrun simctl launch "$udid" "$BUNDLE_ID" >/dev/null
sleep "$SETTLE"
local dest="$OUT/$prefix-$scene.png"
xcrun simctl io "$udid" screenshot "$dest" >/dev/null
log "$prefix/$scene$dest ($(pixels "$dest"))"
done
xcrun simctl terminate "$udid" "$BUNDLE_ID" 2>/dev/null || true
rm -rf "$dd"
}
pixels() { sips -g pixelWidth -g pixelHeight "$1" 2>/dev/null | awk '/pixel/{print $2}' | paste -sd× -; }
# ---------------------------------------------------------------------------- dispatch
[ $# -gt 0 ] || set -- all
for target in "$@"; do
case "$target" in
macos) shoot_macos ;;
ios) shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS ;;
ipad) shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS ;;
tvos) shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS ;;
all)
shoot_macos
if xcrun --find simctl >/dev/null 2>&1; then
shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS
shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS
shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS
else
warn "Skipping iOS/iPadOS/tvOS — full Xcode not found (Command Line Tools only)."
fi
;;
*) die "unknown target '$target' (use: all macos ios ipad tvos)" ;;
esac
done
log "Done. Screenshots in $OUT"
ls -1 "$OUT" 2>/dev/null || true
+20
View File
@@ -0,0 +1,20 @@
# Shared host<->driver binary contract for the punktfunk pf-vdisplay virtual display.
#
# Deliberately self-contained (no `*.workspace = true` inheritance, no Windows deps): this crate is a
# path dependency of BOTH the host workspace (crates/punktfunk-host) AND the out-of-workspace driver
# workspace (packaging/windows/drivers/), so it must resolve identically from either build graph. It is
# `no_std` (+ alloc) and platform-neutral; the GUID/LUID are plain integers each side converts to its
# own OS type. Defining every wire struct ONCE here — with `const` size/offset asserts + bytemuck
# round-trips — makes host<->driver ABI drift a COMPILE error instead of a silent frame/IOCTL corruption.
[package]
name = "pf-driver-proto"
version = "0.0.1"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
description = "Shared host<->driver binary contract for the punktfunk pf-vdisplay virtual display (control IOCTLs + IDD-push frame transport)."
publish = false
[dependencies]
# `min_const_generics`: Pod/Zeroable for `[u8; N]` of any N (the gamepad SHM reserved tails are >32).
bytemuck = { version = "1.19", features = ["derive", "min_const_generics"] }
+485
View File
@@ -0,0 +1,485 @@
//! Shared binary contract between the punktfunk host and the `pf-vdisplay` IddCx driver.
//!
//! Two planes:
//! * [`control`] — the low-frequency `DeviceIoControl` plane (add/remove a virtual monitor, pin the
//! render adapter, keepalive, info, clear-all). Owned, clean, versioned — NOT the SudoVDA ABI.
//! * [`frame`] — the IDD-push frame transport: the host creates a ring of shared keyed-mutex textures
//! (+ a header + a frame-ready event) and the driver opens them and publishes composited frames into
//! them. This crate owns the [`frame::SharedHeader`] layout, the [`frame::FrameToken`] packing, the
//! `Global\` object-name scheme, and the driver-status codes.
//!
//! Both planes were previously hand-duplicated, byte-for-byte, across `idd_push.rs`/`frame_transport.rs`
//! and `vdisplay/sudovda.rs`/`control.rs` with only "must match" comments guarding them. Defining them
//! once here — with bytemuck `Pod` derives and `const` size asserts — makes any drift a compile error.
//!
//! The GUID and LUID are carried as plain integers; the host converts to `windows::core::GUID` /
//! `windows::Win32::Foundation::LUID` and the driver to its own bindgen types via the same constants.
#![cfg_attr(not(test), no_std)]
extern crate alloc;
/// Freshly-minted pf-vdisplay device-interface GUID — `{70667664-7044-5350-a1b2-c3d4e5f60001}`.
/// Deliberately NOT SudoVDA's `{e5bcc234-…}`: we own the driver, so a private interface GUID signals
/// it and removes any accidental coexistence with a real SudoVDA install. Construct on each side via
/// `GUID::from_u128(PF_VDISPLAY_INTERFACE_GUID_U128)`.
pub const PF_VDISPLAY_INTERFACE_GUID_U128: u128 = 0x7066_7664_7044_5350_a1b2_c3d4_e5f6_0001;
/// The interface GUID split into Windows `GUID` fields — `(Data1, Data2, Data3, Data4)` — so the driver
/// (and host) can build a `windows`/`wdk_sys` `GUID` without re-deriving the byte layout. Standard GUID
/// layout from the u128: `Data1` = high 32 bits, `Data2`/`Data3` = next two 16-bit groups, `Data4` =
/// the low 64 bits big-endian. (This crate is `no_std` + provider-agnostic, so it returns the fields
/// rather than depend on a `GUID` type.)
#[must_use]
pub const fn interface_guid_fields() -> (u32, u16, u16, [u8; 8]) {
let g = PF_VDISPLAY_INTERFACE_GUID_U128;
(
(g >> 96) as u32,
(g >> 80) as u16,
(g >> 64) as u16,
(g as u64).to_be_bytes(),
)
}
/// Bumped on any incompatible change to either plane. Exchanged via [`control::IOCTL_GET_INFO`]; host
/// and driver assert a match at startup so a mismatched pair fails loudly instead of corrupting.
pub const PROTOCOL_VERSION: u32 = 1;
/// `CTL_CODE(FILE_DEVICE_UNKNOWN = 0x22, func, METHOD_BUFFERED = 0, FILE_ANY_ACCESS = 0)`.
pub const fn ctl_code(func: u32) -> u32 {
(0x22u32 << 16) | (func << 2)
}
/// The control (`DeviceIoControl`) plane: add/remove a virtual monitor + adapter pin + keepalive.
pub mod control {
use super::ctl_code;
use bytemuck::{Pod, Zeroable};
// Contiguous op space at 0x900 — distinct from SudoVDA's gappy 0x800/0x888/0x8FF numbering.
/// Add a virtual monitor at a mode → [`AddReply`]. Input [`AddRequest`].
pub const IOCTL_ADD: u32 = ctl_code(0x900);
/// Remove a virtual monitor by session id. Input [`RemoveRequest`].
pub const IOCTL_REMOVE: u32 = ctl_code(0x901);
/// Pin the IddCx render adapter (hybrid-GPU IDD-push). Input [`SetRenderAdapterRequest`].
pub const IOCTL_SET_RENDER_ADAPTER: u32 = ctl_code(0x902);
/// Keepalive (resets the driver watchdog). No payload.
pub const IOCTL_PING: u32 = ctl_code(0x903);
/// Version + watchdog handshake → [`InfoReply`]. No input.
pub const IOCTL_GET_INFO: u32 = ctl_code(0x904);
/// Tear down every virtual monitor (host-startup orphan reap). No payload. First-class op — NOT the
/// SudoVDA "send-and-hope-it's-ignored" hack.
pub const IOCTL_CLEAR_ALL: u32 = ctl_code(0x905);
/// `IOCTL_ADD` input. A monotonic `session_id` keys the monitor (the host's refcount manager owns
/// collision safety — no more SudoVDA's 16-byte GUID + pid-mangling). The driver advertises this
/// mode as preferred; the host still CCD-forces the active mode (the OS activates IDDs at a default).
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
pub struct AddRequest {
pub session_id: u64,
pub width: u32,
pub height: u32,
pub refresh_hz: u32,
pub _reserved: u32,
}
/// `IOCTL_ADD` reply: the OS target id + the adapter LUID the IDD landed on (split low/high to
/// match `windows` `LUID { LowPart: u32, HighPart: i32 }`).
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
pub struct AddReply {
pub adapter_luid_low: u32,
pub adapter_luid_high: i32,
pub target_id: u32,
pub _reserved: u32,
}
/// `IOCTL_REMOVE` input.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
pub struct RemoveRequest {
pub session_id: u64,
}
/// `IOCTL_SET_RENDER_ADAPTER` input (the GPU the IddCx swap-chain should render on).
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
pub struct SetRenderAdapterRequest {
pub luid_low: u32,
pub luid_high: i32,
}
/// `IOCTL_GET_INFO` reply: the protocol version (asserted against [`super::PROTOCOL_VERSION`]) and
/// the watchdog timeout the host must ping within.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
pub struct InfoReply {
pub protocol_version: u32,
pub watchdog_timeout_s: u32,
}
// Layout is load-bearing across the process boundary — pin it. (bytemuck's Pod derive already
// rejects any internal padding; these assert the externally-visible sizes too.) The `offset_of!`
// asserts additionally catch a SAME-SIZE field reorder, which the size+Pod checks alone miss.
const _: () = {
use core::mem::{offset_of, size_of};
assert!(size_of::<AddRequest>() == 24);
assert!(offset_of!(AddRequest, session_id) == 0);
assert!(offset_of!(AddRequest, width) == 8);
assert!(offset_of!(AddRequest, height) == 12);
assert!(offset_of!(AddRequest, refresh_hz) == 16);
assert!(size_of::<AddReply>() == 16);
assert!(offset_of!(AddReply, adapter_luid_low) == 0);
assert!(offset_of!(AddReply, adapter_luid_high) == 4);
assert!(offset_of!(AddReply, target_id) == 8);
assert!(size_of::<RemoveRequest>() == 8);
assert!(offset_of!(RemoveRequest, session_id) == 0);
assert!(size_of::<SetRenderAdapterRequest>() == 8);
assert!(offset_of!(SetRenderAdapterRequest, luid_low) == 0);
assert!(offset_of!(SetRenderAdapterRequest, luid_high) == 4);
assert!(size_of::<InfoReply>() == 8);
assert!(offset_of!(InfoReply, protocol_version) == 0);
assert!(offset_of!(InfoReply, watchdog_timeout_s) == 4);
};
}
/// The IDD-push frame transport: the host-created shared ring header, the publish token, the names, and
/// the driver-status codes. The texture ring itself is host-created D3D11 keyed-mutex textures (opened
/// by name on the driver side); only the *layout/contract* lives here.
pub mod frame {
use alloc::string::String;
use bytemuck::{Pod, Zeroable};
/// Header magic (`"PFVD"` LE). The host stamps it LAST (after the ring textures exist) so the driver
/// only attaches to a fully-published ring.
pub const MAGIC: u32 = 0x4456_4650;
/// Frame-plane version (independent bump of the header layout).
pub const VERSION: u32 = 1;
/// Ring slots. Headroom so the driver's 0 ms-timeout publish always finds a free slot while the host
/// holds one across the convert/copy + the pipelined encode. MUST be identical on both sides — it is,
/// because both read this one constant.
pub const RING_LEN: u32 = 6;
/// `driver_status` values the driver writes into the host header (the host logs them on a timeout).
pub const DRV_STATUS_NONE: u32 = 0;
/// Driver attached to the ring and is publishing.
pub const DRV_STATUS_OPENED: u32 = 1;
/// Driver could not open the host's textures — render-adapter mismatch (it renders on a different GPU
/// than where the host created the ring). `driver_status_detail` carries the HRESULT.
pub const DRV_STATUS_TEX_FAIL: u32 = 2;
/// Driver has no `ID3D11Device1` to open shared resources.
pub const DRV_STATUS_NO_DEVICE1: u32 = 3;
/// The shared metadata header (host-created, mapped by both sides). Atomic fields (`magic`, `latest`,
/// `generation`) are accessed via each side's own atomic view over the mapping; this is the layout.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
pub struct SharedHeader {
pub magic: u32,
pub version: u32,
/// Bumped by the host on a ring recreate (HDR-mode flip → new texture format/names). The driver
/// re-attaches when it changes; a publish carries it so the host rejects a stale-ring publish.
pub generation: u32,
pub ring_len: u32,
pub width: u32,
pub height: u32,
pub dxgi_format: u32,
pub _pad: u32,
/// Driver-written after each copy; host loads `Acquire`. See [`FrameToken`].
pub latest: u64,
pub qpc_pts: u64,
/// Driver-written: the adapter the swap-chain actually renders on (mismatch detection).
pub driver_render_luid_low: u32,
pub driver_render_luid_high: i32,
/// Driver-written status (visibility channel — UMDF hides OutputDebugString + the restricted
/// token blocks file writes, so this header is how the driver reports state).
pub driver_status: u32,
pub driver_status_detail: u32,
}
/// The `SharedHeader.latest` publish token: `(generation << 40) | (seq << 8) | slot`.
/// `generation` is 24-bit, `seq` 32-bit, `slot` 8-bit. The generation tag lets the host REJECT a
/// publish from a stale ring (an old-generation publisher racing a mid-session recreate) so it never
/// consumes an unwritten new-ring slot — eliminating the toggle-time garbage frame.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FrameToken {
pub generation: u32,
pub seq: u32,
pub slot: u8,
}
impl FrameToken {
/// Low 24 bits of `generation` are significant (see the field docs).
pub const GENERATION_MASK: u32 = 0x00FF_FFFF;
pub const fn pack(self) -> u64 {
(((self.generation & Self::GENERATION_MASK) as u64) << 40)
| (((self.seq as u64) & 0xFFFF_FFFF) << 8)
| (self.slot as u64)
}
pub const fn unpack(v: u64) -> Self {
Self {
generation: ((v >> 40) as u32) & Self::GENERATION_MASK,
seq: ((v >> 8) & 0xFFFF_FFFF) as u32,
slot: (v & 0xFF) as u8,
}
}
}
/// `Global\pfvd-hdr-<target>` — the shared metadata header mapping name.
pub fn header_name(target_id: u32) -> String {
alloc::format!("Global\\pfvd-hdr-{target_id}")
}
/// `Global\pfvd-evt-<target>` — the frame-ready auto-reset event name.
pub fn event_name(target_id: u32) -> String {
alloc::format!("Global\\pfvd-evt-{target_id}")
}
/// `Global\pfvd-tex-<target>-<generation>-<slot>` — a ring texture's shared-handle name. The
/// generation in the name means a recreate's new textures never collide with the old ring's
/// not-yet-released handles.
pub fn texture_name(target_id: u32, generation: u32, slot: u32) -> String {
alloc::format!("Global\\pfvd-tex-{target_id}-{generation}-{slot}")
}
// Size + per-field offsets are load-bearing: both sides access these via raw atomic views over the
// mapping, so a same-size field reorder would silently corrupt. Pin every offset. The `_pad` after
// `dxgi_format` is what 8-aligns the `u64 latest` at offset 32 — assert that too.
const _: () = {
use core::mem::{offset_of, size_of};
assert!(size_of::<SharedHeader>() == 64);
assert!(offset_of!(SharedHeader, magic) == 0);
assert!(offset_of!(SharedHeader, version) == 4);
assert!(offset_of!(SharedHeader, generation) == 8);
assert!(offset_of!(SharedHeader, ring_len) == 12);
assert!(offset_of!(SharedHeader, width) == 16);
assert!(offset_of!(SharedHeader, height) == 20);
assert!(offset_of!(SharedHeader, dxgi_format) == 24);
assert!(offset_of!(SharedHeader, _pad) == 28);
assert!(offset_of!(SharedHeader, latest) == 32);
assert!(offset_of!(SharedHeader, qpc_pts) == 40);
assert!(offset_of!(SharedHeader, driver_render_luid_low) == 48);
assert!(offset_of!(SharedHeader, driver_render_luid_high) == 52);
assert!(offset_of!(SharedHeader, driver_status) == 56);
assert!(offset_of!(SharedHeader, driver_status_detail) == 60);
};
}
/// Gamepad shared-memory layouts (host ↔ the UMDF gamepad drivers `pf_xusb` / `pf_dualsense`).
///
/// 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`
/// workspaces, guarded only by "must match" comments — the top ABI-drift hazard the audit flagged
/// (`design/windows-host-rewrite.md` §2.7). Owning them here with `Pod` derives + `offset_of!`
/// asserts makes a one-sided edit a compile error.
///
/// The host creates the section (privileged, permissive DACL so the restricted WUDFHost token can
/// open it) and the driver maps it. Layout only; the section itself is host-created shared memory.
pub mod gamepad {
use alloc::string::String;
use bytemuck::{Pod, Zeroable};
/// XUSB section magic — the exact u32 the shipped host + `pf_xusb` driver compare (loosely "PFXU").
pub const XUSB_MAGIC: u32 = 0x5558_4650;
/// Pad section magic — the exact u32 the shipped host + `pf_dualsense` driver compare (loosely
/// "PFDS"). (Note: the two magics happen to use opposite byte-order mnemonics in the legacy code;
/// only the u32 value is the contract.)
pub const PAD_MAGIC: u32 = 0x5046_4453;
/// `device_type` selector the `pf_dualsense` driver reads to pick its HID identity. The section is
/// zeroed, so `0` = DualSense is the default; one driver serves either identity.
pub const DEVTYPE_DUALSENSE: u8 = 0;
/// `device_type` = DualShock 4 (`VID_054C&PID_09CC` HID identity).
pub const DEVTYPE_DUALSHOCK4: u8 = 1;
/// `Global\pfxusb-shm-<index>` — the virtual Xbox 360 (XInput) shared section.
pub fn xusb_shm_name(index: u8) -> String {
alloc::format!("Global\\pfxusb-shm-{index}")
}
/// `Global\pfds-shm-<index>` — the virtual DualSense / DualShock 4 shared section.
pub fn pad_shm_name(index: u8) -> String {
alloc::format!("Global\\pfds-shm-{index}")
}
/// Virtual Xbox 360 (XInput) shared section (64 B). The host writes the XInput state (a bumped
/// `packet` number + buttons/triggers/sticks in XInput conventions); the driver answers
/// `XInputGetState`. The driver writes force-feedback (`XInputSetState`) into `rumble_*`, bumping
/// `rumble_seq`, which the host relays to the client.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
pub struct XusbShm {
pub magic: u32,
/// XInput `dwPacketNumber` — bumped by the host on every state change.
pub packet: u32,
pub buttons: u16,
pub left_trigger: u8,
pub right_trigger: u8,
pub thumb_lx: i16,
pub thumb_ly: i16,
pub thumb_rx: i16,
pub thumb_ry: i16,
pub _reserved0: u32,
/// Bumped by the driver on a new force-feedback packet.
pub rumble_seq: u32,
pub rumble_large: u8,
pub rumble_small: u8,
pub _reserved1: [u8; 34],
}
/// Virtual DualSense / DualShock 4 shared section (256 B). The host writes the `0x01`-style HID
/// input report into `input`; the driver feeds it to game `READ_REPORT`s and publishes a game's
/// `0x02` output (rumble / lightbar / player-LEDs / adaptive triggers) into `output`, bumping
/// `out_seq`. `device_type` selects the HID identity ([`DEVTYPE_DUALSENSE`] / [`DEVTYPE_DUALSHOCK4`]).
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
pub struct PadShm {
pub magic: u32,
pub _reserved0: u32,
/// Input report region (host-written; the codec's report is <= 64 B — see
/// `inject::dualsense_proto::DS_INPUT_REPORT_LEN`). The region spans `magic`+pad .. `out_seq`.
pub input: [u8; 64],
/// Bumped by the driver when it publishes a new `output` report.
pub out_seq: u32,
/// Output report region (driver-written): rumble / lightbar / player-LEDs / adaptive triggers.
pub output: [u8; 64],
/// HID identity selector — see [`DEVTYPE_DUALSENSE`] / [`DEVTYPE_DUALSHOCK4`].
pub device_type: u8,
pub _reserved1: [u8; 115],
}
// Offsets are the wire contract the shipped drivers already read by hand — pin every one. A failing
// assert here means the struct no longer matches the historical `OFF_*` layout (host) / `view.add(N)`
// literal (driver) and must be fixed before either side switches to the type.
const _: () = {
use core::mem::{offset_of, size_of};
assert!(size_of::<XusbShm>() == 64);
assert!(offset_of!(XusbShm, magic) == 0);
assert!(offset_of!(XusbShm, packet) == 4);
assert!(offset_of!(XusbShm, buttons) == 8);
assert!(offset_of!(XusbShm, left_trigger) == 10);
assert!(offset_of!(XusbShm, right_trigger) == 11);
assert!(offset_of!(XusbShm, thumb_lx) == 12);
assert!(offset_of!(XusbShm, thumb_ly) == 14);
assert!(offset_of!(XusbShm, thumb_rx) == 16);
assert!(offset_of!(XusbShm, thumb_ry) == 18);
assert!(offset_of!(XusbShm, rumble_seq) == 24);
assert!(offset_of!(XusbShm, rumble_large) == 28);
assert!(offset_of!(XusbShm, rumble_small) == 29);
assert!(size_of::<PadShm>() == 256);
assert!(offset_of!(PadShm, magic) == 0);
assert!(offset_of!(PadShm, input) == 8);
assert!(offset_of!(PadShm, out_seq) == 72);
assert!(offset_of!(PadShm, output) == 76);
assert!(offset_of!(PadShm, device_type) == 140);
};
}
#[cfg(test)]
mod tests {
use super::*;
use bytemuck::Zeroable;
#[test]
fn frame_token_roundtrips() {
for (g, s, slot) in [
(1u32, 0u32, 0u8),
(5, 12_345, 3),
(frame::FrameToken::GENERATION_MASK, 0xFFFF_FFFF, 5),
(0, 1, 255),
] {
let t = frame::FrameToken {
generation: g,
seq: s,
slot,
};
assert_eq!(frame::FrameToken::unpack(t.pack()), t);
}
}
#[test]
fn frame_token_packing_matches_legacy_layout() {
// The legacy code packed (gen<<40)|(seq<<8)|slot by hand; lock the bit positions.
let t = frame::FrameToken {
generation: 7,
seq: 42,
slot: 3,
};
assert_eq!(t.pack(), (7u64 << 40) | (42u64 << 8) | 3u64);
}
#[test]
fn shared_header_is_pod_and_64_bytes() {
let mut h = frame::SharedHeader::zeroed();
h.magic = frame::MAGIC;
h.width = 5120;
h.height = 1440;
let bytes = bytemuck::bytes_of(&h);
assert_eq!(bytes.len(), 64);
let back: frame::SharedHeader = *bytemuck::from_bytes(bytes);
assert_eq!(back.magic, frame::MAGIC);
assert_eq!(back.width, 5120);
assert_eq!(back.height, 1440);
}
#[test]
fn control_structs_roundtrip_through_bytes() {
let req = control::AddRequest {
session_id: 0xDEAD_BEEF_CAFE_F00D,
width: 3840,
height: 2160,
refresh_hz: 120,
_reserved: 0,
};
let bytes = bytemuck::bytes_of(&req);
assert_eq!(bytes.len(), 24);
assert_eq!(*bytemuck::from_bytes::<control::AddRequest>(bytes), req);
}
#[test]
fn names_are_stable() {
assert_eq!(frame::header_name(10), "Global\\pfvd-hdr-10");
assert_eq!(frame::event_name(10), "Global\\pfvd-evt-10");
assert_eq!(frame::texture_name(10, 3, 5), "Global\\pfvd-tex-10-3-5");
}
#[test]
fn gamepad_names_and_magics_are_stable() {
assert_eq!(gamepad::xusb_shm_name(0), "Global\\pfxusb-shm-0");
assert_eq!(gamepad::pad_shm_name(2), "Global\\pfds-shm-2");
// Lock the exact u32 magics the shipped host/drivers use (inject/{gamepad,dualsense}_windows.rs).
assert_eq!(gamepad::XUSB_MAGIC, 0x5558_4650);
assert_eq!(gamepad::PAD_MAGIC, 0x5046_4453);
}
#[test]
fn ctl_codes_are_contiguous_and_distinct() {
assert_eq!(control::IOCTL_ADD, ctl_code(0x900));
let all = [
control::IOCTL_ADD,
control::IOCTL_REMOVE,
control::IOCTL_SET_RENDER_ADAPTER,
control::IOCTL_PING,
control::IOCTL_GET_INFO,
control::IOCTL_CLEAR_ALL,
];
for (i, a) in all.iter().enumerate() {
for b in &all[i + 1..] {
assert_ne!(a, b);
}
}
}
#[test]
fn guid_is_not_sudovda() {
const SUDOVDA: u128 = 0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D;
assert_ne!(PF_VDISPLAY_INTERFACE_GUID_U128, SUDOVDA);
}
}
+7 -2
View File
@@ -1454,11 +1454,16 @@ pub mod endpoint {
/// close, while a genuinely dead peer is still detected within `MAX_IDLE`. /// close, while a genuinely dead peer is still detected within `MAX_IDLE`.
fn stream_transport() -> Arc<quinn::TransportConfig> { fn stream_transport() -> Arc<quinn::TransportConfig> {
use std::time::Duration; use std::time::Duration;
const MAX_IDLE: Duration = Duration::from_secs(20); // 8s idle (was 20s): a vanished client is declared dead within 8s instead of 20, so its
// session tears down promptly — which the Windows IDD-push path needs so a RECONNECT recreates
// a fresh virtual monitor (a reused monitor's IddCx swap-chain dies) instead of joining the
// still-lingering old session. Active sessions are unaffected: video keeps the connection live,
// and the 4s keep-alive holds it open through quiet control periods.
const MAX_IDLE: Duration = Duration::from_secs(8);
const KEEP_ALIVE: Duration = Duration::from_secs(4); const KEEP_ALIVE: Duration = Duration::from_secs(4);
let mut t = quinn::TransportConfig::default(); let mut t = quinn::TransportConfig::default();
t.max_idle_timeout(Some( t.max_idle_timeout(Some(
quinn::IdleTimeout::try_from(MAX_IDLE).expect("20s is a valid QUIC idle timeout"), quinn::IdleTimeout::try_from(MAX_IDLE).expect("8s is a valid QUIC idle timeout"),
)); ));
t.keep_alive_interval(Some(KEEP_ALIVE)); t.keep_alive_interval(Some(KEEP_ALIVE));
Arc::new(t) Arc::new(t)
+27 -5
View File
@@ -25,6 +25,14 @@ aes-gcm = "0.10"
cbc = { version = "0.1", features = ["alloc"] } cbc = { version = "0.1", features = ["alloc"] }
rand = "0.8" rand = "0.8"
hex = "0.4" hex = "0.4"
# Cover-art delivery in the game library: encode Lutris's local JPEGs into `data:` URLs and decode
# the Epic launcher's base64 `catcache.bin`. Cross-platform (Linux Lutris art + Windows Epic art).
base64 = "0.22"
# Blocking HTTP for the library cover-art warmer (no-auth GOG api.gog.com + Xbox displaycatalog),
# run on a background thread off the hot path. `ureq` is small + sync (no tokio here) and bundles
# webpki roots (no system cert dependency). Cross-platform so the fetch/parse code is compiled +
# checked everywhere even though only the Windows GOG/Xbox providers need it today.
ureq = "2"
rcgen = { version = "0.13", default-features = false, features = ["aws_lc_rs", "pem"] } rcgen = { version = "0.13", default-features = false, features = ["aws_lc_rs", "pem"] }
x509-parser = "0.16" x509-parser = "0.16"
axum-server = { version = "0.7", features = ["tls-rustls"] } axum-server = { version = "0.7", features = ["tls-rustls"] }
@@ -85,6 +93,10 @@ wayland-scanner = "0.31"
wayland-backend = "0.3" wayland-backend = "0.3"
# Parse `pw-dump` JSON to find gamescope's PipeWire node (gamescope backend). # Parse `pw-dump` JSON to find gamescope's PipeWire node (gamescope backend).
serde_json = "1" serde_json = "1"
# Read the Lutris library DB (`pga.db`) for the Lutris store provider. `bundled` vendors + compiles
# SQLite (cc, already needed for ffmpeg/opus) so there's no system libsqlite3 runtime dependency —
# clean for the deb/rpm/flatpak packaging. Opened read-only/immutable (Lutris may hold it open).
rusqlite = { version = "0.40", features = ["bundled"] }
# Builds/validates the xkb keymap uploaded to the virtual keyboard + tracks modifier state. # Builds/validates the xkb keymap uploaded to the virtual keyboard + tracks modifier state.
xkbcommon = "0.8" xkbcommon = "0.8"
# The safe `opus` crate is stereo-only; surround (5.1/7.1) needs the libopus *multistream* # The safe `opus` crate is stereo-only; surround (5.1/7.1) needs the libopus *multistream*
@@ -155,7 +167,7 @@ windows = { version = "0.62", features = [
"Win32_System_LibraryLoader", "Win32_System_LibraryLoader",
# VirtualProtect — for the inline patch of the win32u GPU-preference shim (Apollo's MinHook port: # VirtualProtect — for the inline patch of the win32u GPU-preference shim (Apollo's MinHook port:
# the hybrid-GPU output-reparenting hook that keeps Desktop Duplication stable on a 4090+iGPU box). # the hybrid-GPU output-reparenting hook that keeps Desktop Duplication stable on a 4090+iGPU box).
# See capture/dxgi.rs `install_gpu_pref_hook`. No trampoline (we fully replace the fn) → no detour # See capture/windows/dxgi.rs `install_gpu_pref_hook`. No trampoline (we fully replace the fn) → no detour
# crate / no C length-disassembler dep; a 12-byte absolute-jmp prologue patch suffices. # crate / no C length-disassembler dep; a 12-byte absolute-jmp prologue patch suffices.
"Win32_System_Memory", "Win32_System_Memory",
# Per-monitor-v2 DPI awareness — IDXGIOutput5::DuplicateOutput1 (the modern capture path Apollo # Per-monitor-v2 DPI awareness — IDXGIOutput5::DuplicateOutput1 (the modern capture path Apollo
@@ -169,15 +181,19 @@ windows = { version = "0.62", features = [
# handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses # handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses
# the `windows` crate above. # the `windows` crate above.
windows-service = "0.7" windows-service = "0.7"
# Read the GOG.com install registry (HKLM\SOFTWARE\WOW6432Node\GOG.com\Games) for the GOG store
# provider — ergonomic + correct-by-construction vs. hand-rolled Reg* FFI for subkey enumeration.
winreg = "0.56"
# Parse each Xbox/Game-Pass game's MicrosoftGame.config (GDK manifest XML) for the Xbox store
# provider — a small read-only DOM is all we need (Identity/Executable/ShellVisuals/StoreId).
roxmltree = "0.21"
# Software H.264 encoder (GPU-less path + NVENC fallback). The default `source` feature statically # Software H.264 encoder (GPU-less path + NVENC fallback). The default `source` feature statically
# compiles OpenH264 (BSD-2) — no system lib, builds on MSVC; nasm on PATH adds the SIMD fast path. # compiles OpenH264 (BSD-2) — no system lib, builds on MSVC; nasm on PATH adds the SIMD fast path.
openh264 = "0.9" openh264 = "0.9"
# WASAPI loopback audio capture (default render endpoint -> 48 kHz stereo f32 for the Opus path). # WASAPI loopback audio capture (default render endpoint -> 48 kHz stereo f32 for the Opus path).
wasapi = "0.23" wasapi = "0.23"
# Virtual Xbox 360 gamepad via ViGEmBus (the uinput-xpad analogue) — driver installed separately. # Virtual Xbox 360 gamepad: the in-tree XUSB companion UMDF driver (packaging/windows/xusb-driver),
# `unstable_xtarget_notification` exposes the rumble/LED back-channel (the game's force-feedback → # driven over shared memory from inject/windows/gamepad_windows.rs — no ViGEmBus dependency.
# `request_notification`), the analogue of the Linux uinput EV_FF read path.
vigem-client = { version = "0.1", features = ["unstable_xtarget_notification"] }
# NVENC hardware encoder (NVENC SDK, D3D11 input). The SDK pins `cudarc` with # NVENC hardware encoder (NVENC SDK, D3D11 input). The SDK pins `cudarc` with
# `cuda-version-from-build-system` (a build-time CUDA-toolkit probe); its `ci-check` feature switches # `cuda-version-from-build-system` (a build-time CUDA-toolkit probe); its `ci-check` feature switches
# cudarc to `dynamic-loading` (loads nvcuda.dll at runtime — nothing needed at build), which is how # cudarc to `dynamic-loading` (loads nvcuda.dll at runtime — nothing needed at build), which is how
@@ -190,6 +206,12 @@ nvidia-video-codec-sdk = { version = "0.4", features = ["ci-check"], optional =
# same BtbN gpl-shared tree the Windows client uses) and pulls the shared `avcodec/avutil/...` DLLs # same BtbN gpl-shared tree the Windows client uses) and pulls the shared `avcodec/avutil/...` DLLs
# at runtime. `ffmpeg-sys-next` auto-detects the FFmpeg version (7.x/avcodec-61 or 8.x/62). # at runtime. `ffmpeg-sys-next` auto-detects the FFmpeg version (7.x/avcodec-61 or 8.x/62).
ffmpeg-next = { version = "8", optional = true } ffmpeg-next = { version = "8", optional = true }
# Shared host<->driver wire contract for the pf-vdisplay IddCx virtual-display backend
# (vdisplay/pf_vdisplay.rs): the control-plane IOCTL codes + `#[repr(C)] Pod` request/reply structs,
# defined ONCE so host<->driver ABI drift is a compile error. `bytemuck` serializes those structs
# to/from the DeviceIoControl byte buffers.
pf-driver-proto = { path = "../pf-driver-proto" }
bytemuck = { version = "1.19", features = ["derive"] }
[features] [features]
# NVENC hardware encode (Windows). OFF by default: it pulls the NVENC SDK, and the host then needs # NVENC hardware encode (Windows). OFF by default: it pulls the NVENC SDK, and the host then needs
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="fake_input">
<copyright>
SPDX-FileCopyrightText: 2015 Martin Gräßlin
SPDX-License-Identifier: LGPL-2.1-or-later
</copyright>
<interface name="org_kde_kwin_fake_input" version="4">
<description summary="Fake input manager">
This interface allows other processes to provide fake input events.
Purpose is on the one hand side to provide testing facilities like XTest
on X11, but also to support use cases like remote control (a remote
desktop server). The compositor gates the interface: it is only exposed
to clients authorized through their .desktop X-KDE-Wayland-Interfaces, so
binding it is the authorization — no per-event confirmation dialog.
</description>
<request name="authenticate">
<description summary="Information about the application requesting fake input">
A FakeInput is required to authenticate itself by providing the
application name and the reason for fake input. The compositor may use
this information to decide whether to allow or deny the request.
</description>
<arg name="application" type="string" summary="user visible name of the application requesting fake input"/>
<arg name="reason" type="string" summary="reason of why fake input is requested"/>
</request>
<request name="pointer_motion">
<description summary="pointer motion event"/>
<arg name="delta_x" type="fixed" summary="X delta of the relative pointer motion"/>
<arg name="delta_y" type="fixed" summary="Y delta of the relative pointer motion"/>
</request>
<request name="button">
<description summary="pointer button event"/>
<arg name="button" type="uint" summary="evdev button code"/>
<arg name="state" type="uint" summary="button state, 0 released, 1 pressed"/>
</request>
<request name="axis">
<description summary="pointer axis (scroll) event"/>
<arg name="axis" type="uint" summary="wl_pointer.axis (0 vertical, 1 horizontal)"/>
<arg name="value" type="fixed" summary="axis value"/>
</request>
<request name="touch_down" since="2">
<description summary="touch down event"/>
<arg name="id" type="uint" summary="unique id of this touch point; must not be reused until up"/>
<arg name="x" type="fixed" summary="x coordinate in global compositor space"/>
<arg name="y" type="fixed" summary="y coordinate in global compositor space"/>
</request>
<request name="touch_motion" since="2">
<description summary="touch motion event"/>
<arg name="id" type="uint" summary="unique id of an existing touch point"/>
<arg name="x" type="fixed" summary="x coordinate in global compositor space"/>
<arg name="y" type="fixed" summary="y coordinate in global compositor space"/>
</request>
<request name="touch_up" since="2">
<description summary="touch up event"/>
<arg name="id" type="uint" summary="unique id of an existing touch point"/>
</request>
<request name="touch_cancel" since="2">
<description summary="cancel all current touch points"/>
</request>
<request name="touch_frame" since="2">
<description summary="end a set of touch events (atomic frame)"/>
</request>
<request name="pointer_motion_absolute" since="3">
<description summary="absolute pointer motion event"/>
<arg name="x" type="fixed" summary="x coordinate in global compositor space"/>
<arg name="y" type="fixed" summary="y coordinate in global compositor space"/>
</request>
<request name="keyboard_key" since="4">
<description summary="keyboard key event"/>
<arg name="button" type="uint" summary="evdev key code"/>
<arg name="state" type="uint" summary="key state, 0 released, 1 pressed"/>
</request>
</interface>
</protocol>
+2
View File
@@ -88,6 +88,8 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod linux; mod linux;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "audio/windows/wasapi_cap.rs"]
mod wasapi_cap; mod wasapi_cap;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "audio/windows/wasapi_mic.rs"]
mod wasapi_mic; mod wasapi_mic;
@@ -320,11 +320,18 @@ fn mic_pw_thread(
.into_inner(); .into_inner();
let mut params = [Pod::from_bytes(&values).context("mic pod from bytes")?]; let mut params = [Pod::from_bytes(&values).context("mic pod from bytes")?];
// RT_PROCESS: run the producer callback on PipeWire's realtime data loop, so the source is a
// *synchronous* graph node that joins its consumer's driver group and is actually driven. Without
// it the node is async/main-loop and, in the host's busy multi-stream graph (desktop-audio +
// video capture + the session), never acquires a driver — it stays suspended and its process()
// never fires, so every recorder hears pure silence (the long-standing "Linux host mic broken").
stream stream
.connect( .connect(
spa::utils::Direction::Output, // we PRODUCE samples (a source) spa::utils::Direction::Output, // we PRODUCE samples (a source)
None, None,
pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS, pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::MAP_BUFFERS
| pw::stream::StreamFlags::RT_PROCESS,
&mut params, &mut params,
) )
.context("pw mic stream connect")?; .context("pw mic stream connect")?;
@@ -106,7 +106,10 @@ fn capture_thread(
} }
let res = (|| -> Result<()> { let res = (|| -> Result<()> {
// Loopback = capture the RENDER endpoint: get the default render device, but open a CAPTURE // Loopback = capture the RENDER endpoint: get the default render device, but open a CAPTURE
// client with loopback=true over it. // client with loopback=true over it. NOTE: the virtual mic (`super::wasapi_mic`) is guarded
// to NEVER target this same endpoint — otherwise the client's injected mic would be captured
// here and streamed back to the client (infinite echo). Keep that guard in sync if this
// device selection ever changes.
let device = DeviceEnumerator::new() let device = DeviceEnumerator::new()
.context("DeviceEnumerator")? .context("DeviceEnumerator")?
.get_default_device(&Direction::Render) .get_default_device(&Direction::Render)
@@ -5,14 +5,27 @@
//! //!
//! Target device, by friendly-name substring (first match wins; override with `PUNKTFUNK_MIC_DEVICE`): //! Target device, by friendly-name substring (first match wins; override with `PUNKTFUNK_MIC_DEVICE`):
//! "Steam Streaming Microphone" (ships with Steam Remote Play — exactly this purpose), VB-Audio //! "Steam Streaming Microphone" (ships with Steam Remote Play — exactly this purpose), VB-Audio
//! "CABLE Input", VoiceMeeter, or anything with "virtual" in the name. If none is present we return an //! "CABLE Input", VoiceMeeter, or anything with "virtual" in the name. If none is present we
//! error with install guidance and the host runs without mic passthrough. //! auto-install the Steam Streaming audio pair (see [`install_steam_audio_pair`]); failing that we
//! return an error with install guidance and the host runs without mic passthrough.
//!
//! **Anti-echo guard (the whole point of this being non-trivial).** The desktop-audio plane
//! ([`super::wasapi_cap`]) loopback-captures the **default render endpoint**. WASAPI loopback
//! captures the *mixed* output of an endpoint — i.e. everything any app renders to it, including
//! what THIS module writes. So if the virtual-mic target is the same device the loopback captures,
//! the client's uplinked mic is captured straight back into the host→client audio stream: an
//! infinite echo. [`find_device`] therefore **excludes the default render endpoint** from the
//! candidates — the mic is guaranteed to land on a different device. (Linux gets this for free: its
//! mic is a dedicated `Audio/Source` node, structurally separate from the monitored sink.)
//! //!
//! `push` enqueues decoded interleaved-f32 PCM into a bounded ring (drop-oldest beyond ~80 ms so mic //! `push` enqueues decoded interleaved-f32 PCM into a bounded ring (drop-oldest beyond ~80 ms so mic
//! latency stays bounded); a dedicated COM-apartment thread renders it event-driven, filling silence //! latency stays bounded); a dedicated COM-apartment thread renders it event-driven, filling silence
//! when the client isn't talking. WASAPI objects are `!Send`, so they live entirely on that thread //! when the client isn't talking. WASAPI objects are `!Send`, so they live entirely on that thread
//! (mirrors `WasapiLoopbackCapturer`). //! (mirrors `WasapiLoopbackCapturer`).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{VirtualMic, SAMPLE_RATE}; use super::{VirtualMic, SAMPLE_RATE};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use std::collections::VecDeque; use std::collections::VecDeque;
@@ -110,8 +123,23 @@ impl VirtualMic for WasapiVirtualMic {
} }
} }
/// Resolve the virtual-mic target among render endpoints by friendly-name. Logs all candidates so a /// The endpoint ID of the device the desktop-audio loopback records (the **default render
/// missing device is diagnosable. /// endpoint**, see [`super::wasapi_cap`]). The virtual mic must never target this device — injecting
/// there echoes the client's mic back into the host→client audio stream. `None` if it can't be
/// resolved (then [`find_device`] can't prove a candidate is safe and falls back to name-only
/// matching — no worse than before the guard existed).
fn default_render_id() -> Option<String> {
wasapi::DeviceEnumerator::new()
.ok()?
.get_default_device(&Direction::Render)
.ok()?
.get_id()
.ok()
}
/// Resolve the virtual-mic target among render endpoints by friendly-name, **excluding the endpoint
/// the loopback captures** (the [`default_render_id`] anti-echo guard). Logs all candidates so a
/// missing/skipped device is diagnosable.
fn find_device() -> Result<wasapi::Device> { fn find_device() -> Result<wasapi::Device> {
let enumerator = wasapi::DeviceEnumerator::new().context("DeviceEnumerator")?; let enumerator = wasapi::DeviceEnumerator::new().context("DeviceEnumerator")?;
let collection = enumerator let collection = enumerator
@@ -121,8 +149,11 @@ fn find_device() -> Result<wasapi::Device> {
let want = std::env::var("PUNKTFUNK_MIC_DEVICE") let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
.ok() .ok()
.map(|s| s.to_lowercase()); .map(|s| s.to_lowercase());
// The device the loopback captures — a name match on it is rejected below (would echo).
let loopback_id = default_render_id();
let mut names = Vec::new(); let mut names = Vec::new();
let mut found = None; let mut found = None;
let mut skipped_loopback = false;
for i in 0..n { for i in 0..n {
let Ok(dev) = collection.get_device_at_index(i) else { let Ok(dev) = collection.get_device_at_index(i) else {
continue; continue;
@@ -134,16 +165,37 @@ fn find_device() -> Result<wasapi::Device> {
None => CANDIDATES.iter().any(|c| lname.contains(c)), None => CANDIDATES.iter().any(|c| lname.contains(c)),
}; };
if hit && found.is_none() { if hit && found.is_none() {
found = Some(dev); // Anti-echo guard: never inject into the endpoint the loopback captures.
let is_loopback = match (dev.get_id().ok(), loopback_id.as_deref()) {
(Some(id), Some(lb)) => id == lb,
_ => false,
};
if is_loopback {
skipped_loopback = true;
tracing::warn!(device = %name,
"virtual-mic candidate is the loopback (default render) endpoint — skipping; \
injecting there would echo the client's mic into the desktop-audio stream");
} else {
found = Some(dev);
}
} }
names.push(name); names.push(name);
} }
found.ok_or_else(|| { found.ok_or_else(|| {
anyhow!( if skipped_loopback {
"no virtual-mic device among render endpoints {names:?}. Install VB-Audio Virtual Cable \ anyhow!(
or enable Steam Remote Play's microphone (Steam Streaming Microphone), or set \ "the only virtual-mic candidate among render endpoints {names:?} is the default \
PUNKTFUNK_MIC_DEVICE=<friendly-name substring>." playback device the host loopback-captures injecting there would echo the mic \
) back to the client. Add a SEPARATE virtual audio device for the mic (e.g. the Steam \
Streaming Microphone) or set a different default playback device, then reconnect."
)
} else {
anyhow!(
"no virtual-mic device among render endpoints {names:?}. Install VB-Audio Virtual \
Cable or enable Steam Remote Play's microphone (Steam Streaming Microphone), or set \
PUNKTFUNK_MIC_DEVICE=<friendly-name substring>."
)
}
}) })
} }
@@ -153,8 +205,15 @@ fn find_or_install_device() -> Result<wasapi::Device> {
match find_device() { match find_device() {
Ok(d) => Ok(d), Ok(d) => Ok(d),
Err(e) => { Err(e) => {
tracing::info!("no virtual mic device present — attempting auto-install"); tracing::info!("no usable virtual mic device present — attempting auto-install");
if unsafe { try_install_virtual_mic() } { // SAFETY: `install_steam_audio_pair` is `unsafe` only because it `LoadLibraryExW`s
// `newdev.dll` and calls `DiInstallDriverW` through a `transmute`d function pointer;
// calling it imposes no extra precondition here (it takes no args and aliases nothing).
// Its internal contract holds: the `DiInstall` type matches the documented
// `BOOL DiInstallDriverW(HWND, PCWSTR, DWORD, PBOOL)` ABI, and it passes a
// NUL-terminated UTF-16 INF path with null/zero optional args. Invoked once on the
// dedicated mic thread.
if unsafe { install_steam_audio_pair() } {
find_device() find_device()
} else { } else {
Err(e) Err(e)
@@ -163,13 +222,26 @@ fn find_or_install_device() -> Result<wasapi::Device> {
} }
} }
/// Best-effort: install a virtual mic device so one exists without the user installing anything. /// Best-effort: install BOTH Steam Streaming audio devices (the "Steam pair") so mic passthrough
/// Mirrors Apollo's Steam Streaming Speakers install — Steam Remote Play ships /// works out of the box and the host has a desktop-audio sink distinct from the mic. Steam Remote
/// `SteamStreamingMicrophone.inf` next to the speakers INF, so install it via `DiInstallDriverW` /// Play ships `SteamStreamingMicrophone.inf` + `SteamStreamingSpeakers.inf`: the microphone gives the
/// (loaded from `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). Needs admin (the /// virtual mic a target whose **capture** endpoint apps record from, and the speakers give a
/// host runs as SYSTEM). Returns true on success; false (no-op) if Steam isn't installed (INF absent), /// **render** endpoint a headless box can loopback-capture that is NOT the mic — so the loopback and
/// the install is denied, or `PUNKTFUNK_NO_MIC_INSTALL` is set. /// the mic land on different devices and never echo (see [`find_device`]). Returns true if either
unsafe fn try_install_virtual_mic() -> bool { /// installed. No-op when Steam isn't installed (INFs absent), the install is denied (needs admin —
/// the host runs as SYSTEM), or `PUNKTFUNK_NO_MIC_INSTALL` is set.
unsafe fn install_steam_audio_pair() -> bool {
// Microphone first (the mic's actual target); speakers second (the distinct desktop-audio sink).
let mic = try_install_steam_audio("SteamStreamingMicrophone.inf");
let spk = try_install_steam_audio("SteamStreamingSpeakers.inf");
mic || spk
}
/// Install one Steam Streaming driver INF by filename via `DiInstallDriverW` (loaded from
/// `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). See
/// [`install_steam_audio_pair`] for the contract; `inf_name` is a bare filename under Steam's
/// per-arch `drivers\Windows10\{arch}\` directory.
unsafe fn try_install_steam_audio(inf_name: &str) -> bool {
use windows::core::{s, w, PCWSTR}; use windows::core::{s, w, PCWSTR};
use windows::Win32::Foundation::HWND; use windows::Win32::Foundation::HWND;
use windows::Win32::System::Environment::ExpandEnvironmentStringsW; use windows::Win32::System::Environment::ExpandEnvironmentStringsW;
@@ -187,12 +259,11 @@ unsafe fn try_install_virtual_mic() -> bool {
let subdir = "arm64"; let subdir = "arm64";
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
let subdir = "x86"; let subdir = "x86";
let template: Vec<u16> = format!( let template: Vec<u16> =
"%CommonProgramFiles(x86)%\\Steam\\drivers\\Windows10\\{subdir}\\SteamStreamingMicrophone.inf" format!("%CommonProgramFiles(x86)%\\Steam\\drivers\\Windows10\\{subdir}\\{inf_name}")
) .encode_utf16()
.encode_utf16() .chain(std::iter::once(0))
.chain(std::iter::once(0)) .collect();
.collect();
let mut path = vec![0u16; 1024]; let mut path = vec![0u16; 1024];
let n = ExpandEnvironmentStringsW(PCWSTR(template.as_ptr()), Some(path.as_mut_slice())); let n = ExpandEnvironmentStringsW(PCWSTR(template.as_ptr()), Some(path.as_mut_slice()));
if n == 0 || n as usize > path.len() { if n == 0 || n as usize > path.len() {
@@ -200,7 +271,7 @@ unsafe fn try_install_virtual_mic() -> bool {
} }
let Ok(newdev) = LoadLibraryExW(w!("newdev.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32) else { let Ok(newdev) = LoadLibraryExW(w!("newdev.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32) else {
tracing::warn!("could not load newdev.dll — virtual-mic auto-install unavailable"); tracing::warn!("could not load newdev.dll — Steam-audio auto-install unavailable");
return false; return false;
}; };
let Some(addr) = GetProcAddress(newdev, s!("DiInstallDriverW")) else { let Some(addr) = GetProcAddress(newdev, s!("DiInstallDriverW")) else {
@@ -216,13 +287,17 @@ unsafe fn try_install_virtual_mic() -> bool {
std::ptr::null_mut(), std::ptr::null_mut(),
) != 0; ) != 0;
if ok { if ok {
tracing::info!("installed the Steam Streaming Microphone virtual device"); tracing::info!(
inf = inf_name,
"installed a Steam Streaming virtual audio device"
);
std::thread::sleep(Duration::from_secs(5)); // let the audio subsystem register the endpoint std::thread::sleep(Duration::from_secs(5)); // let the audio subsystem register the endpoint
} else { } else {
let err = windows::Win32::Foundation::GetLastError(); let err = windows::Win32::Foundation::GetLastError();
tracing::info!( tracing::info!(
inf = inf_name,
?err, ?err,
"no virtual mic auto-installed (Steam absent / not admin) — see manual-install guidance" "Steam-audio device not auto-installed (Steam absent / not admin) — see install guidance"
); );
} }
ok ok
+123 -12
View File
@@ -2,6 +2,10 @@
//! CPU-copy fallback (the portal delivers a CPU buffer; the encoder uploads it to the GPU //! CPU-copy fallback (the portal delivers a CPU buffer; the encoder uploads it to the GPU
//! internally). Zero-copy dmabuf→NVENC import is deferred (plan §9 risk). //! internally). Zero-copy dmabuf→NVENC import is deferred (plan §9 risk).
// Every unsafe block in this module tree carries a `// SAFETY:` proof; enforce it (unsafe-proof
// program). As a parent module this also covers the child modules (capture::windows/linux::*).
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::Result; use anyhow::Result;
/// Packed pixel layout of a [`CapturedFrame`]. The ScreenCast portal negotiates the /// Packed pixel layout of a [`CapturedFrame`]. The ScreenCast portal negotiates the
@@ -44,6 +48,49 @@ impl PixelFormat {
} }
} }
/// What a Windows capturer should produce, resolved **once** per session and passed **into**
/// [`capture_virtual_output`] (Goal-1 stage 5, plan §2.3/§5). Passing the format in is what lets a
/// capturer stop re-deriving the encode backend itself — it kills the
/// `capture/dxgi.rs → encode::windows_resolved_backend()` back-reference (the highest-severity coupling:
/// capture and encode could otherwise disagree on whether frames are GPU-resident). Neutral type; the
/// Linux portal capturer ignores it (it negotiates its own format with PipeWire).
#[derive(Clone, Copy, Debug)]
pub struct OutputFormat {
/// Produce GPU-resident D3D11 frames (zero-copy for a GPU encoder — NVENC/AMF/QSV) rather than CPU
/// staging. `false` **only** for the GPU-less software encoder.
pub gpu: bool,
/// HDR: the capturer converts to 10-bit (IDD-push FP16 → `Rgb10a2`; the DDA secure-desktop HDR hint).
/// `false` = 8-bit SDR.
pub hdr: bool,
}
impl OutputFormat {
/// Resolve the output format for an entry point that doesn't build a full [`SessionPlan`]
/// (`crate::session_plan`) — the GameStream + spike paths: `gpu` from the resolved encode backend,
/// `hdr` as given. The native punktfunk/1 path uses `SessionPlan::output_format()` instead (it already
/// resolved the encoder), so neither path makes a capturer re-derive it.
pub fn resolve(hdr: bool) -> Self {
OutputFormat {
gpu: gpu_encode(),
hdr,
}
}
}
/// True if the resolved encode backend produces GPU frames (anything but the software encoder). The single
/// source for [`OutputFormat::resolve`]'s `gpu`; on Linux always true (the portal/VAAPI/CUDA path is GPU).
#[cfg(target_os = "windows")]
pub(crate) fn gpu_encode() -> bool {
!matches!(
crate::encode::windows_resolved_backend(),
crate::encode::WindowsBackend::Software
)
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn gpu_encode() -> bool {
true
}
/// A captured frame. [`format`](Self::format)/dimensions describe the pixels regardless of /// A captured frame. [`format`](Self::format)/dimensions describe the pixels regardless of
/// where they live — [`payload`](Self::payload) is either a CPU buffer (the spike/fallback path) /// where they live — [`payload`](Self::payload) is either a CPU buffer (the spike/fallback path)
/// or a GPU buffer already on the device (the zero-copy path, plan §9). /// or a GPU buffer already on the device (the zero-copy path, plan §9).
@@ -142,6 +189,16 @@ pub trait Capturer: Send {
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> { fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
None None
} }
/// How many frames the encode loop may keep in flight (submitted but not yet polled) before it
/// blocks. `1` (the default) is the synchronous loop: capture → submit → poll-blocks, so the
/// per-frame wall time is `capture+convert + encode`. A capturer that hands a fresh output texture
/// per frame (so the encode of N reads a different texture than the convert of N+1 writes) can return
/// `>1` to PIPELINE: the loop submits N+1 before polling N, overlapping the convert/copy on the 3D
/// engine with the NVENC-ASIC encode of the prior frame, dropping per-frame wall toward `max(...)`.
fn pipeline_depth(&self) -> usize {
1
}
} }
/// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file → /// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file →
@@ -302,7 +359,14 @@ pub fn open_portal_monitor() -> Result<Box<dyn Capturer>> {
/// [`crate::vdisplay::VirtualDisplay`] backend. The captured size is the size the output was /// [`crate::vdisplay::VirtualDisplay`] backend. The captured size is the size the output was
/// created at — native, no scaling. /// created at — native, no scaling.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Box<dyn Capturer>> { pub fn capture_virtual_output(
vout: crate::vdisplay::VirtualOutput,
_want: OutputFormat,
_capture: crate::session_plan::CaptureBackend,
) -> Result<Box<dyn Capturer>> {
// The Linux host stays 8-bit (HDR is blocked upstream) and the portal negotiates its own format, so
// the `OutputFormat` is unused here; the capture backend is always the portal (the `CaptureBackend`
// arg is a Windows-only dispatch — ignored here).
linux::PortalCapturer::from_virtual_output(vout).map(|c| Box::new(c) as Box<dyn Capturer>) linux::PortalCapturer::from_virtual_output(vout).map(|c| Box::new(c) as Box<dyn Capturer>)
} }
@@ -313,11 +377,16 @@ pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Bo
/// compiled and comes back the moment the flag is unset. /// compiled and comes back the moment the flag is unset.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub(crate) fn wgc_disabled() -> bool { pub(crate) fn wgc_disabled() -> bool {
std::env::var_os("PUNKTFUNK_NO_WGC").is_some() crate::config::config().no_wgc
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Box<dyn Capturer>> { pub fn capture_virtual_output(
vout: crate::vdisplay::VirtualOutput,
want: OutputFormat,
capture: crate::session_plan::CaptureBackend,
) -> Result<Box<dyn Capturer>> {
use crate::session_plan::CaptureBackend;
let target = vout.win_capture.clone().ok_or_else(|| { let target = vout.win_capture.clone().ok_or_else(|| {
anyhow::anyhow!( anyhow::anyhow!(
"SudoVDA target not yet an active display (needs a WDDM GPU to activate it)" "SudoVDA target not yet an active display (needs a WDDM GPU to activate it)"
@@ -325,16 +394,39 @@ pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Bo
})?; })?;
let pref = vout.preferred_mode; let pref = vout.preferred_mode;
let keep = vout.keepalive; let keep = vout.keepalive;
// P2 direct frame push (kill DDA): consume frames straight from the pf-vdisplay driver's shared
// ring — no Desktop Duplication, no win32u reparenting hook. Resolved once in the `SessionPlan`
// (was re-derived from `config().idd_push` here); `IddPush` takes the keepalive (owns the virtual
// display) so there's no fall-through.
if capture == CaptureBackend::IddPush {
// Recreate the monitor + ring per session (fix-teardown): a FRESH monitor reliably gets a
// working IddCx swap-chain, whereas a REUSED monitor's swap-chain dies after ~2 sessions and
// the host can't revive it. The driver's recreate crash (target id resolved to 0) is fixed by
// stamping target_id onto the monitor context. The ring is always FP16 (the driver composes
// the IDD in FP16); `want_hdr` selects the per-frame conversion (FP16 → Rgb10a2 vs Bgra).
// If IDD-push can't open OR the driver doesn't attach to the ring within a few seconds (e.g. a
// hybrid-GPU render mismatch), fall back to DDA so the session is NEVER left black (audit §5.1).
// `open()` hands the keepalive back on failure so DDA can take ownership of the virtual display.
match idd_push::IddPushCapturer::open(target.clone(), pref, want.hdr, keep) {
Ok(c) => return Ok(Box::new(c) as Box<dyn Capturer>),
Err((e, keep)) => {
tracing::warn!(
error = %format!("{e:#}"),
"IDD-push open/attach failed — falling back to DDA"
);
return dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
.map(|c| Box::new(c) as Box<dyn Capturer>);
}
}
}
// WGC (Windows.Graphics.Capture) is the default: it captures the COMPOSED desktop including the // WGC (Windows.Graphics.Capture) is the default: it captures the COMPOSED desktop including the
// overlay/independent-flip planes DXGI Desktop Duplication misses (the frozen-HDR-animation bug), // overlay/independent-flip planes DXGI Desktop Duplication misses (the frozen-HDR-animation bug),
// and has no ACCESS_LOST-on-overlay churn. DDA stays available via PUNKTFUNK_CAPTURE=dda and is // and has no ACCESS_LOST-on-overlay churn. DDA stays available via PUNKTFUNK_CAPTURE=dda and is
// the secure-desktop (lock/UAC) fallback (WGC can't capture those). `keep` is moved into the // the secure-desktop (lock/UAC) fallback (WGC can't capture those). `keep` is moved into the
// chosen backend (it owns the SudoVDA keepalive), so there's no open-time auto-fallback. // chosen backend (it owns the SudoVDA keepalive), so there's no open-time auto-fallback. The
let backend = std::env::var("PUNKTFUNK_CAPTURE") // backend choice (`dda`/`dxgi`/`PUNKTFUNK_NO_WGC` → DDA, else WGC) is now resolved once in the plan.
.unwrap_or_default() if capture == CaptureBackend::Dda {
.to_ascii_lowercase(); return dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
if backend == "dda" || backend == "dxgi" || wgc_disabled() {
return dxgi::DuplCapturer::open(target, pref, keep, false)
.map(|c| Box::new(c) as Box<dyn Capturer>); .map(|c| Box::new(c) as Box<dyn Capturer>);
} }
// WGC default, with a watchdog'd DDA fallback. WGC's Direct3D11CaptureFramePool::CreateFreeThreaded // WGC default, with a watchdog'd DDA fallback. WGC's Direct3D11CaptureFramePool::CreateFreeThreaded
@@ -345,6 +437,11 @@ pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Bo
// DDA is the safety net (+ the secure-desktop path). The encode thread is set MTA so the WGC // DDA is the safety net (+ the secure-desktop path). The encode thread is set MTA so the WGC
// objects built on the watchdog thread (also MTA) are usable here; the keepalive is handed to WGC // objects built on the watchdog thread (also MTA) are usable here; the keepalive is handed to WGC
// only on success, else to DDA. A hung watchdog thread is abandoned (holds no keepalive). // only on success, else to DDA. A hung watchdog thread is abandoned (holds no keepalive).
// SAFETY: `RoInitialize` is a combase FFI call that initializes the WinRT apartment for the calling
// thread. It takes the `RO_INIT_MULTITHREADED` enum by value and borrows no memory, so there is no
// pointer/lifetime/aliasing obligation; it is safe on any thread and idempotent — a second call on a
// thread already in a compatible apartment returns S_FALSE / RPC_E_CHANGED_MODE, which we discard.
// Runs on the encode thread that goes on to use the WGC (WinRT) objects built by the watchdog thread.
unsafe { unsafe {
let _ = windows::Win32::System::WinRT::RoInitialize( let _ = windows::Win32::System::WinRT::RoInitialize(
windows::Win32::System::WinRT::RO_INIT_MULTITHREADED, windows::Win32::System::WinRT::RO_INIT_MULTITHREADED,
@@ -364,31 +461,45 @@ pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Bo
} }
Ok(Err(e)) => { Ok(Err(e)) => {
tracing::warn!(error = %format!("{e:#}"), "WGC open failed — falling back to DDA"); tracing::warn!(error = %format!("{e:#}"), "WGC open failed — falling back to DDA");
dxgi::DuplCapturer::open(target, pref, keep, false) dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
.map(|c| Box::new(c) as Box<dyn Capturer>) .map(|c| Box::new(c) as Box<dyn Capturer>)
} }
Err(_) => { Err(_) => {
tracing::warn!("WGC open timed out (CreateFreeThreaded hang on the virtual display) — falling back to DDA"); tracing::warn!("WGC open timed out (CreateFreeThreaded hang on the virtual display) — falling back to DDA");
dxgi::DuplCapturer::open(target, pref, keep, false) dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
.map(|c| Box::new(c) as Box<dyn Capturer>) .map(|c| Box::new(c) as Box<dyn Capturer>)
} }
} }
} }
#[cfg(not(any(target_os = "linux", target_os = "windows")))] #[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub fn capture_virtual_output(_vout: crate::vdisplay::VirtualOutput) -> Result<Box<dyn Capturer>> { pub fn capture_virtual_output(
_vout: crate::vdisplay::VirtualOutput,
_want: OutputFormat,
_capture: crate::session_plan::CaptureBackend,
) -> Result<Box<dyn Capturer>> {
anyhow::bail!("virtual-output capture requires Linux or Windows") anyhow::bail!("virtual-output capture requires Linux or Windows")
} }
// Goal-1 stage 6: the Windows backends live under `capture/windows/`, the Linux one under `capture/linux/`
// (`#[path]` keeps the module names flat, so every `crate::capture::*` path is unchanged).
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "capture/windows/composed_flip.rs"]
pub mod composed_flip; pub mod composed_flip;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "capture/windows/desktop_watch.rs"]
pub mod desktop_watch; pub mod desktop_watch;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "capture/windows/dxgi.rs"]
pub mod dxgi; pub mod dxgi;
#[cfg(target_os = "windows")]
#[path = "capture/windows/idd_push.rs"]
pub mod idd_push;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod linux; mod linux;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "capture/windows/wgc.rs"]
pub mod wgc; pub mod wgc;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "capture/windows/wgc_relay.rs"]
pub mod wgc_relay; pub mod wgc_relay;
@@ -17,6 +17,9 @@
//! instead of leaking it to process exit. The portal thread (when used) still parks on its zbus //! instead of leaking it to process exit. The portal thread (when used) still parks on its zbus
//! connection until process exit. //! connection until process exit.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat}; use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use std::os::fd::OwnedFd; use std::os::fd::OwnedFd;
@@ -37,6 +40,13 @@ pub struct PortalCapturer {
/// branch to tell "format never negotiated" (modifier/format mismatch) apart from "negotiated /// branch to tell "format never negotiated" (modifier/format mismatch) apart from "negotiated
/// but no buffers arrived" (compositor idle/unmapped) — the two black-screen root causes. /// but no buffers arrived" (compositor idle/unmapped) — the two black-screen root causes.
negotiated: Arc<AtomicBool>, negotiated: Arc<AtomicBool>,
/// True only while the PipeWire stream is `Streaming`. [`try_latest`](Self::try_latest) reads it
/// to distinguish a static desktop (alive, no new buffers) from a dead source (left `Streaming`).
streaming: Arc<AtomicBool>,
/// When the stream first dropped out of `Streaming` with no new frame; used to grace a transient
/// renegotiation before declaring the source lost. Cleared whenever a frame arrives or the stream
/// is `Streaming`.
stall_since: Option<std::time::Instant>,
/// The PipeWire node this capturer consumes — surfaced in error messages for diagnosis. /// The PipeWire node this capturer consumes — surfaced in error messages for diagnosis.
node_id: u32, node_id: u32,
/// Stops the PipeWire loop on teardown (sent in `Drop`). Without it a dropped or failed /// Stops the PipeWire loop on teardown (sent in `Drop`). Without it a dropped or failed
@@ -106,6 +116,7 @@ struct PwHandles {
frames: Receiver<CapturedFrame>, frames: Receiver<CapturedFrame>,
active: Arc<AtomicBool>, active: Arc<AtomicBool>,
negotiated: Arc<AtomicBool>, negotiated: Arc<AtomicBool>,
streaming: Arc<AtomicBool>,
quit: ::pipewire::channel::Sender<()>, quit: ::pipewire::channel::Sender<()>,
join: thread::JoinHandle<()>, join: thread::JoinHandle<()>,
} }
@@ -118,6 +129,8 @@ impl PwHandles {
frames: self.frames, frames: self.frames,
active: self.active, active: self.active,
negotiated: self.negotiated, negotiated: self.negotiated,
streaming: self.streaming,
stall_since: None,
node_id, node_id,
quit: Some(self.quit), quit: Some(self.quit),
join: Some(self.join), join: Some(self.join),
@@ -140,6 +153,8 @@ fn spawn_pipewire(
let active_cb = active.clone(); let active_cb = active.clone();
let negotiated = Arc::new(AtomicBool::new(false)); let negotiated = Arc::new(AtomicBool::new(false));
let negotiated_cb = negotiated.clone(); let negotiated_cb = negotiated.clone();
let streaming = Arc::new(AtomicBool::new(false));
let streaming_cb = streaming.clone();
// pipewire's own cross-thread channel: the receiver attaches to the loop and quits it; the // pipewire's own cross-thread channel: the receiver attaches to the loop and quits it; the
// sender lives on the capturer and fires in its `Drop`. Absolute `::pipewire` path — the // sender lives on the capturer and fires in its `Drop`. Absolute `::pipewire` path — the
// inner `mod pipewire` shadows the crate name at this scope. // inner `mod pipewire` shadows the crate name at this scope.
@@ -154,6 +169,7 @@ fn spawn_pipewire(
frame_tx, frame_tx,
active_cb, active_cb,
negotiated_cb, negotiated_cb,
streaming_cb,
zerocopy, zerocopy,
preferred, preferred,
quit_rx, quit_rx,
@@ -166,6 +182,7 @@ fn spawn_pipewire(
frames: frame_rx, frames: frame_rx,
active, active,
negotiated, negotiated,
streaming,
quit: quit_tx, quit: quit_tx,
join, join,
}) })
@@ -216,6 +233,28 @@ impl Capturer for PortalCapturer {
} }
} }
} }
if latest.is_some() || self.streaming.load(Ordering::Relaxed) {
// A frame arrived, or the source is alive but idle (static desktop) — normal. Clear any
// stall and repeat the last frame on `None`, exactly as before.
self.stall_since = None;
return Ok(latest);
}
// No new frame AND the stream has left `Streaming` (Paused/Unconnected/Error). The source
// went away — a compositor torn down on a Gaming↔Desktop switch, a removed virtual output.
// Grace a brief window (a transient mid-stream renegotiation can blip out of Streaming and
// back) before declaring it lost so the encode loop rebuilds in place rather than freezing
// on the last frame forever.
const STALL_GRACE: Duration = Duration::from_millis(1500);
let since = *self.stall_since.get_or_insert_with(std::time::Instant::now);
if since.elapsed() >= STALL_GRACE {
self.stall_since = None;
return Err(anyhow!(
"PipeWire source stalled (node {}): stream left Streaming for >{}ms with no frames \
the compositor/virtual output went away (session switch?)",
self.node_id,
STALL_GRACE.as_millis()
));
}
Ok(latest) Ok(latest)
} }
@@ -464,6 +503,10 @@ mod pipewire {
/// Set once a video format is agreed (`param_changed`), so a first-frame timeout can tell /// Set once a video format is agreed (`param_changed`), so a first-frame timeout can tell
/// "format never negotiated" apart from "negotiated but no buffers arrived". /// "format never negotiated" apart from "negotiated but no buffers arrived".
negotiated: Arc<AtomicBool>, negotiated: Arc<AtomicBool>,
/// True only while the PipeWire stream is in `Streaming` (the source is alive). Goes false on
/// `Paused`/`Unconnected`/`Error` — the source vanished (compositor torn down on a session
/// switch). Read by [`PortalCapturer::try_latest`] to surface a sustained drop as a loss.
streaming: Arc<AtomicBool>,
/// Present when zero-copy is enabled on NVIDIA: imports a dmabuf → CUDA device buffer. /// Present when zero-copy is enabled on NVIDIA: imports a dmabuf → CUDA device buffer.
importer: Option<crate::zerocopy::EglImporter>, importer: Option<crate::zerocopy::EglImporter>,
/// VAAPI zero-copy: hand the raw dmabuf to the encoder (which imports + GPU-CSCs it) instead /// VAAPI zero-copy: hand the raw dmabuf to the encoder (which imports + GPU-CSCs it) instead
@@ -498,6 +541,12 @@ mod pipewire {
impl DmabufMap { impl DmabufMap {
fn new(fd: i32, len: usize) -> Option<DmabufMap> { fn new(fd: i32, len: usize) -> Option<DmabufMap> {
// SAFETY: a null `addr` lets the kernel choose the mapping address; `fd` is a caller-owned
// dmabuf/MemFd fd, valid for the duration of this call, and `len` is the requested map length.
// `mmap` reads no Rust memory — it installs a fresh PROT_READ/MAP_SHARED page mapping and
// returns its base (or MAP_FAILED, checked below before `DmabufMap` adopts it). The returned
// region is a brand-new VMA, so it aliases no live Rust object, and it keeps the underlying
// object mapped independently of `fd` (which may be closed after this returns).
let ptr = unsafe { let ptr = unsafe {
libc::mmap( libc::mmap(
std::ptr::null_mut(), std::ptr::null_mut(),
@@ -514,6 +563,11 @@ mod pipewire {
impl Drop for DmabufMap { impl Drop for DmabufMap {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `self.ptr`/`self.len` are exactly the base+length of a successful `mmap` in
// `DmabufMap::new` (constructed only when `ptr != MAP_FAILED`). This `DmabufMap` uniquely owns
// that mapping and `drop` runs once, so `munmap` releases a live mapping exactly once — no
// double-unmap. Every `&[u8]` derived from the mapping is bounded by this `DmabufMap`'s
// lifetime, so no borrow outlives the unmap.
unsafe { unsafe {
libc::munmap(self.ptr, self.len); libc::munmap(self.ptr, self.len);
} }
@@ -719,6 +773,14 @@ mod pipewire {
if !ud.active.load(Ordering::Relaxed) { if !ud.active.load(Ordering::Relaxed) {
return; return;
} }
// SAFETY: `spa_buf` is the `*mut spa_buffer` of the PipeWire buffer we dequeued and still hold for
// this `.process` callback (not requeued until after `consume_frame` returns), so it is live. The
// block null-checks `spa_buf`, requires `n_datas != 0`, and null-checks the `datas` array pointer
// before forming any slice. `(*spa_buf).datas` points to `n_datas` libspa `spa_data` structs, and
// `pw::spa::buffer::Data` is `#[repr(transparent)]` over `spa_data` (the same cast
// `Buffer::datas_mut` performs — see the function doc), so the pointer cast + length describe
// exactly that array, in bounds. The PipeWire loop is single-threaded and owns the buffer here, so
// this `&mut` slice is the only reference to it (no aliasing/data race).
let datas: &mut [pw::spa::buffer::Data] = unsafe { let datas: &mut [pw::spa::buffer::Data] = unsafe {
if spa_buf.is_null() || (*spa_buf).n_datas == 0 || (*spa_buf).datas.is_null() { if spa_buf.is_null() || (*spa_buf).n_datas == 0 || (*spa_buf).datas.is_null() {
&mut [] &mut []
@@ -783,6 +845,10 @@ mod pipewire {
// dup the fd so it survives the SPA buffer recycle — the encode thread // dup the fd so it survives the SPA buffer recycle — the encode thread
// imports it. (Content stability across the brief map+CSC window relies on // imports it. (Content stability across the brief map+CSC window relies on
// the compositor's buffer-pool depth, like any zero-copy capture.) // the compositor's buffer-pool depth, like any zero-copy capture.)
// SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid
// for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd,
// touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1).
// The original stays owned by PipeWire; the dup is a new fd we own (checked >= 0).
let dup = let dup =
unsafe { libc::fcntl(datas[0].fd() as i32, libc::F_DUPFD_CLOEXEC, 0) }; unsafe { libc::fcntl(datas[0].fd() as i32, libc::F_DUPFD_CLOEXEC, 0) };
if dup >= 0 { if dup >= 0 {
@@ -796,6 +862,10 @@ mod pipewire {
pts_ns, pts_ns,
format: fmt, format: fmt,
payload: FramePayload::Dmabuf(DmabufFrame { payload: FramePayload::Dmabuf(DmabufFrame {
// SAFETY: `dup` is the fresh fd `fcntl(F_DUPFD_CLOEXEC)` just returned
// (checked `dup >= 0`); nothing else owns it, so `OwnedFd` takes sole
// ownership and closes it exactly once on drop — no alias, no
// double-close.
fd: unsafe { OwnedFd::from_raw_fd(dup) }, fd: unsafe { OwnedFd::from_raw_fd(dup) },
fourcc, fourcc,
modifier: ud.modifier, modifier: ud.modifier,
@@ -930,6 +1000,11 @@ mod pipewire {
// cleanly if the real buffer is genuinely too small. MemPtr buffers (no fd) are same-process — // cleanly if the real buffer is genuinely too small. MemPtr buffers (no fd) are same-process —
// trust `d.data()`. // trust `d.data()`.
let fd_len = if raw_fd > 0 { let fd_len = if raw_fd > 0 {
// SAFETY: `libc::stat` is a C plain-old-data struct for which all-zero is a valid value, so
// `mem::zeroed()` is a sound initializer. `raw_fd` is the buffer's fd (`> 0` checked here) and
// valid for this callback; `fstat` writes metadata into `&mut st`, a live, aligned,
// correctly-sized stack `stat` that outlives the synchronous call. `st.st_size` is read only
// after the return value is confirmed `== 0`. `st` is a fresh local, so nothing aliases it.
unsafe { unsafe {
let mut st: libc::stat = std::mem::zeroed(); let mut st: libc::stat = std::mem::zeroed();
(libc::fstat(raw_fd as i32, &mut st) == 0 && st.st_size > 0) (libc::fstat(raw_fd as i32, &mut st) == 0 && st.st_size > 0)
@@ -946,6 +1021,14 @@ mod pipewire {
match DmabufMap::new(raw_fd as i32, map_len) { match DmabufMap::new(raw_fd as i32, map_len) {
Some(m) => { Some(m) => {
_mapping = m; _mapping = m;
// SAFETY: `_mapping` is the `DmabufMap` just stored; its `ptr`/`len` come from a
// successful `mmap` of `map_len` PROT_READ bytes, so `ptr` is non-null, page-aligned,
// and the VMA is one allocated object of `len` bytes valid for reads. In the common
// path `map_len == fd_len` (the fd's real size from `fstat`), so the mapping spans the
// whole object; the de-pad copy below is further bounded by the `offset <= buf.len()`
// and `needed > avail` guards. The `&[u8]` borrows `_mapping`, which lives to the end
// of `consume_frame`, so the slice never outlives the mapping, and the memory is only
// read here, so there is no aliasing/mutation.
Some(unsafe { Some(unsafe {
std::slice::from_raw_parts(_mapping.ptr as *const u8, _mapping.len) std::slice::from_raw_parts(_mapping.ptr as *const u8, _mapping.len)
}) })
@@ -1013,6 +1096,7 @@ mod pipewire {
tx: SyncSender<CapturedFrame>, tx: SyncSender<CapturedFrame>,
active: Arc<AtomicBool>, active: Arc<AtomicBool>,
negotiated: Arc<AtomicBool>, negotiated: Arc<AtomicBool>,
streaming: Arc<AtomicBool>,
zerocopy: bool, zerocopy: bool,
preferred: Option<(u32, u32, u32)>, preferred: Option<(u32, u32, u32)>,
quit_rx: pw::channel::Receiver<()>, quit_rx: pw::channel::Receiver<()>,
@@ -1107,6 +1191,7 @@ mod pipewire {
tx, tx,
active, active,
negotiated, negotiated,
streaming,
importer, importer,
vaapi_passthrough, vaapi_passthrough,
nv12: crate::zerocopy::nv12_enabled(), nv12: crate::zerocopy::nv12_enabled(),
@@ -1131,8 +1216,17 @@ mod pipewire {
let _listener = stream let _listener = stream
.add_local_listener_with_user_data(data) .add_local_listener_with_user_data(data)
.state_changed(|_stream, _ud, old, new| { .state_changed(|_stream, ud, old, new| {
tracing::info!(?old, ?new, "pipewire stream state"); tracing::info!(?old, ?new, "pipewire stream state");
// Track whether the node is actively producing. A live source sits in `Streaming`
// (a static desktop just sends no buffers); anything else — `Paused`/`Unconnected`/
// `Error` — means the source went away (compositor died, virtual output removed on a
// Gaming↔Desktop switch). `try_latest` turns a sustained non-Streaming state into a
// capture-loss so the encode loop rebuilds instead of freezing on the last frame.
ud.streaming.store(
matches!(new, pw::stream::StreamState::Streaming),
Ordering::Relaxed,
);
}) })
.param_changed(|_stream, ud, id, param| { .param_changed(|_stream, ud, id, param| {
let Some(param) = param else { return }; let Some(param) = param else { return };
@@ -1177,24 +1271,43 @@ mod pipewire {
// Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and // Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and
// recycles its pool; an older queued buffer carries a STALE frame. Drain all // recycles its pool; an older queued buffer carries a STALE frame. Drain all
// queued buffers, requeue the older ones, keep only the newest. // queued buffers, requeue the older ones, keep only the newest.
// SAFETY: `stream` is the live stream PipeWire passes into this `.process` callback on
// the loop thread, where `pw_stream_dequeue_buffer` is the documented call. It returns
// a `*mut pw_buffer` owned by the stream (or null when the queue is drained),
// null-checked before any use. The loop is single-threaded, so no concurrent access.
let mut newest = unsafe { stream.dequeue_raw_buffer() }; let mut newest = unsafe { stream.dequeue_raw_buffer() };
if newest.is_null() { if newest.is_null() {
return; return;
} }
let mut drained = 1u32; let mut drained = 1u32;
loop { loop {
// SAFETY: same stream/loop-thread contract as the dequeue above; each call returns
// the next stream-owned `*mut pw_buffer` or null (null-checked before use).
let next = unsafe { stream.dequeue_raw_buffer() }; let next = unsafe { stream.dequeue_raw_buffer() };
if next.is_null() { if next.is_null() {
break; break;
} }
// SAFETY: `newest` is a non-null `*mut pw_buffer` previously dequeued from this same
// stream and not yet requeued; `pw_stream_queue_buffer` hands ownership back to the
// stream. We immediately overwrite `newest = next`, so the requeued pointer is never
// touched again (no use-after-requeue). Loop thread, single-threaded.
unsafe { stream.queue_raw_buffer(newest) }; unsafe { stream.queue_raw_buffer(newest) };
newest = next; newest = next;
drained += 1; drained += 1;
} }
// SAFETY: `newest` is the non-null buffer we still own (dequeued, not requeued);
// `.buffer` is a `*mut spa_buffer` field libpipewire populated. This is a single field
// load through a valid pointer — no mutation or aliasing.
let spa_buf = unsafe { (*newest).buffer }; let spa_buf = unsafe { (*newest).buffer };
// Inspect the newest buffer's header + first chunk for the diagnostic and the // Inspect the newest buffer's header + first chunk for the diagnostic and the
// CORRUPTED skip. SPA_META_Header is optional — `hdr` may be null. // CORRUPTED skip. SPA_META_Header is optional — `hdr` may be null.
// SAFETY: `spa_buf` is the `*mut spa_buffer` of the buffer we still hold.
// `spa_buffer_find_meta_data` scans that buffer's metadata array for a `SPA_META_Header`
// of at least `size_of::<spa_meta_header>()` bytes and returns a pointer into the held
// buffer's metadata (or null). The size argument matches the struct the result is cast
// to, and the pointer stays valid as long as the buffer is held (until requeue). Null is
// handled below.
let hdr = unsafe { let hdr = unsafe {
spa::sys::spa_buffer_find_meta_data( spa::sys::spa_buffer_find_meta_data(
spa_buf, spa_buf,
@@ -1205,11 +1318,20 @@ mod pipewire {
let hdr_flags = if hdr.is_null() { let hdr_flags = if hdr.is_null() {
0u32 0u32
} else { } else {
// SAFETY: reached only when `hdr` is non-null; it points to a `spa_meta_header`
// inside the live buffer's metadata (returned for a size >=
// `size_of::<spa_meta_header>()`, so `.flags` is in bounds). A single field read
// while the buffer is still held.
unsafe { (*hdr).flags } unsafe { (*hdr).flags }
}; };
// First data chunk's size + flags (used for the diagnostic + CORRUPTED check) // First data chunk's size + flags (used for the diagnostic + CORRUPTED check)
// and its data type (a dmabuf legitimately reports chunk size 0, so the size-0 // and its data type (a dmabuf legitimately reports chunk size 0, so the size-0
// stale skip only applies to mappable SHM buffers). // stale skip only applies to mappable SHM buffers).
// SAFETY: every dereference is guarded in order before any field read — `spa_buf`
// non-null, `n_datas > 0`, the `datas` (`*mut spa_data`) array non-null, and the first
// element's `chunk` (`*mut spa_chunk`) non-null. `d0` is that first `spa_data` and `c`
// its chunk; reading `(*d0).type_`, `(*c).size`, `(*c).flags` are in-bounds field loads
// of libspa structs inside the buffer we still hold. Single-threaded loop, no mutation.
let (chunk_size, chunk_flags, is_dmabuf) = unsafe { let (chunk_size, chunk_flags, is_dmabuf) = unsafe {
if !spa_buf.is_null() if !spa_buf.is_null()
&& (*spa_buf).n_datas > 0 && (*spa_buf).n_datas > 0
@@ -1246,11 +1368,17 @@ mod pipewire {
"capture: skipped a stale CORRUPTED/cursor buffer (GNOME)" "capture: skipped a stale CORRUPTED/cursor buffer (GNOME)"
); );
} }
// SAFETY: `newest` is the non-null buffer we own (dequeued, never requeued on this
// skip path); hand it back to the stream exactly once and return without touching it
// again. Loop thread inside `.process`.
unsafe { stream.queue_raw_buffer(newest) }; unsafe { stream.queue_raw_buffer(newest) };
return; return;
} }
consume_frame(ud, spa_buf); consume_frame(ud, spa_buf);
// SAFETY: `consume_frame` has finished reading `spa_buf` (and the `datas` borrows derived
// from `newest`), so requeuing the owned `newest` exactly once here is sound — no
// use-after-requeue. Loop thread inside `.process`.
unsafe { stream.queue_raw_buffer(newest) }; unsafe { stream.queue_raw_buffer(newest) };
})); }));
if outcome.is_err() { if outcome.is_err() {
@@ -15,6 +15,9 @@
//! composed while a session is live). Effectiveness can be build/driver-dependent; gated by //! composed while a session is live). Effectiveness can be build/driver-dependent; gated by
//! `PUNKTFUNK_FORCE_COMPOSED` (default ON; set =0 to disable). //! `PUNKTFUNK_FORCE_COMPOSED` (default ON; set =0 to disable).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use windows::core::w; use windows::core::w;
@@ -48,6 +51,10 @@ impl ForceComposedFlip {
let st = stop.clone(); let st = stop.clone();
std::thread::Builder::new() std::thread::Builder::new()
.name("composed-flip".into()) .name("composed-flip".into())
// SAFETY: `run` is this module's `unsafe fn` (it owns a desktop+window lifecycle via Win32
// FFI); it takes ownership of `st` (the stop `Arc<AtomicBool>`) and has no caller-side memory
// precondition. It is designed to own its thread for its whole duration — exactly the
// dedicated `composed-flip` thread spawned here.
.spawn(move || unsafe { run(st) }) .spawn(move || unsafe { run(st) })
.ok()?; .ok()?;
tracing::info!("force-composed-flip overlay started (Winlogon-aware)"); tracing::info!("force-composed-flip overlay started (Winlogon-aware)");
@@ -62,6 +69,9 @@ impl Drop for ForceComposedFlip {
} }
extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT { extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT {
// SAFETY: this is the window procedure the OS invokes with the window's own `hwnd` and a real
// message `(msg, wp, lp)`. `DefWindowProcW` performs default processing for exactly those
// parameters (all passed straight through by value); it borrows no Rust memory and is synchronous.
unsafe { DefWindowProcW(hwnd, msg, wp, lp) } unsafe { DefWindowProcW(hwnd, msg, wp, lp) }
} }
@@ -1,5 +1,5 @@
//! Input-desktop watcher (Windows) — the authoritative "normal vs secure desktop" signal for the //! Input-desktop watcher (Windows) — the authoritative "normal vs secure desktop" signal for the
//! two-process secure-desktop design (docs/windows-secure-desktop.md). //! two-process secure-desktop design (design/archive/windows-secure-desktop.md).
//! //!
//! Windows switches the *input desktop* to "Winlogon" (the secure desktop) for UAC elevation, the //! Windows switches the *input desktop* to "Winlogon" (the secure desktop) for UAC elevation, the
//! lock screen and the login screen, and back to "Default" for the normal session. WGC captures only //! lock screen and the login screen, and back to "Default" for the normal session. WGC captures only
@@ -7,6 +7,9 @@
//! desktop's NAME (WTS session notifications miss UAC entirely, so the name is the reliable signal) //! desktop's NAME (WTS session notifications miss UAC entirely, so the name is the reliable signal)
//! and publishes it as an atomic the capture mux + input path read. //! and publishes it as an atomic the capture mux + input path read.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -33,6 +36,10 @@ impl DesktopWatcher {
// mux) sees the real state immediately. Otherwise a session that begins already on the secure // mux) sees the real state immediately. Otherwise a session that begins already on the secure
// desktop (e.g. a reconnect to a locked box) would read DESKTOP_NORMAL for the first poll // desktop (e.g. a reconnect to a locked box) would read DESKTOP_NORMAL for the first poll
// interval and relay one stale normal-desktop frame — the "flash of the login screen" bug. // interval and relay one stale normal-desktop frame — the "flash of the login screen" bug.
// SAFETY: `is_secure_desktop` is this module's `unsafe fn` — unsafe only because it calls Win32
// desktop FFI (`OpenInputDesktop`/`GetUserObjectInformationW`/`CloseDesktop`), with no caller
// precondition; it opens, names, and closes the input-desktop handle internally and is safe to
// call from any thread (here, on the thread running `DesktopWatcher::start`).
let initial = if unsafe { is_secure_desktop() } { let initial = if unsafe { is_secure_desktop() } {
DESKTOP_SECURE DESKTOP_SECURE
} else { } else {
@@ -53,6 +60,9 @@ impl DesktopWatcher {
let mut candidate = initial; let mut candidate = initial;
let mut stable = 0u32; let mut stable = 0u32;
while !st.load(Ordering::Relaxed) { while !st.load(Ordering::Relaxed) {
// SAFETY: same as in `start` — `is_secure_desktop` is self-contained Win32 desktop
// FFI with no caller precondition, called here on the dedicated `desktop-watch`
// polling thread.
let v = if unsafe { is_secure_desktop() } { let v = if unsafe { is_secure_desktop() } {
DESKTOP_SECURE DESKTOP_SECURE
} else { } else {
@@ -7,6 +7,9 @@
//! Validates only with a real GPU + an *activated* SudoVDA monitor (`DuplicateOutput` needs a live //! Validates only with a real GPU + an *activated* SudoVDA monitor (`DuplicateOutput` needs a live
//! WDDM output). Compiles on the GPU-less VM; the pure helpers are unit-tested there. //! WDDM output). Compiles on the GPU-less VM; the pure helpers are unit-tested there.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat}; use super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use std::ffi::c_void; use std::ffi::c_void;
@@ -69,7 +72,12 @@ pub struct D3d11Frame {
pub texture: ID3D11Texture2D, pub texture: ID3D11Texture2D,
pub device: ID3D11Device, pub device: ID3D11Device,
} }
// COM pointers, used only from the single owning thread. // SAFETY: `D3d11Frame` owns an `ID3D11Texture2D` + `ID3D11Device`, which are COM interface pointers.
// D3D11 devices/resources use thread-safe (interlocked) COM reference counting, and the device is
// created free-threaded (`make_device` passes no `D3D11_CREATE_DEVICE_SINGLETHREADED`), so handing
// ownership of the frame to another thread — the capture→encode handoff — and releasing it there is
// sound. The value is moved, never aliased (no `Sync`), so there is no concurrent use of the
// single-threaded immediate context.
unsafe impl Send for D3d11Frame {} unsafe impl Send for D3d11Frame {}
pub fn pack_luid(luid: LUID) -> i64 { pub fn pack_luid(luid: LUID) -> i64 {
@@ -202,6 +210,87 @@ pub(crate) unsafe fn make_device(
Ok((device, context)) Ok((device, context))
} }
/// Resolve the configured GPU scheduling-priority class from `PUNKTFUNK_GPU_PRIORITY_CLASS`
/// (`off|normal|high|realtime`, default high). `None` = leave it at the OS default (the `off` opt-out).
/// D3DKMT_SCHEDULINGPRIORITYCLASS: IDLE 0, BELOW_NORMAL 1, NORMAL 2, ABOVE_NORMAL 3, HIGH 4, REALTIME 5.
fn configured_gpu_priority_class() -> Option<i32> {
match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS")
.ok()
.as_deref()
{
Some("off") => None,
Some("normal") => Some(2),
Some("realtime") => Some(5),
_ => Some(4), // HIGH — safe on NVIDIA+HAGS (realtime can freeze NVENC)
}
}
/// Enable SE_INC_BASE_PRIORITY on the CURRENT process token (best-effort) — the kernel gates the
/// HIGH/REALTIME GPU scheduling-priority bump on it. Held by SYSTEM/Administrators; a UAC-FILTERED
/// token (what `CreateProcessAsUserW` hands the WGC helper) does NOT have it, which is why the helper
/// can't elevate itself and the SYSTEM host stamps the class onto it cross-process instead (see
/// [`set_child_gpu_priority_class`]).
unsafe fn enable_inc_base_priority() {
use windows::core::PCWSTR;
use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID};
use windows::Win32::Security::{
AdjustTokenPrivileges, LookupPrivilegeValueW, LUID_AND_ATTRIBUTES,
SE_INC_BASE_PRIORITY_NAME, SE_PRIVILEGE_ENABLED, TOKEN_ADJUST_PRIVILEGES, TOKEN_PRIVILEGES,
TOKEN_QUERY,
};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
let mut token = HANDLE::default();
if OpenProcessToken(
GetCurrentProcess(),
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
&mut token,
)
.is_ok()
{
let mut luid = LUID::default();
if LookupPrivilegeValueW(PCWSTR::null(), SE_INC_BASE_PRIORITY_NAME, &mut luid).is_ok() {
let tp = TOKEN_PRIVILEGES {
PrivilegeCount: 1,
Privileges: [LUID_AND_ATTRIBUTES {
Luid: luid,
Attributes: SE_PRIVILEGE_ENABLED,
}],
};
if AdjustTokenPrivileges(
token,
false,
Some(&tp as *const TOKEN_PRIVILEGES),
0,
None,
None,
)
.is_err()
{
tracing::warn!("could not enable SE_INC_BASE_PRIORITY for GPU priority");
}
}
let _ = CloseHandle(token);
}
}
/// Call `gdi32!D3DKMTSetProcessSchedulingPriorityClass(process, prio)` (no stable windows-rs binding —
/// loaded by name). Returns the NTSTATUS (0 = success) or `None` if the export can't be resolved. The
/// CALLING process must hold SE_INC_BASE_PRIORITY ([`enable_inc_base_priority`]) for HIGH/REALTIME; the
/// kernel checks the caller's privilege whether the target is self or a child we created.
unsafe fn d3dkmt_set_scheduling_priority_class(
process: windows::Win32::Foundation::HANDLE,
prio: i32,
) -> Option<i32> {
use windows::core::s;
use windows::Win32::Foundation::HANDLE;
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA};
let gdi32 = LoadLibraryA(s!("gdi32.dll")).ok()?;
let p = GetProcAddress(gdi32, s!("D3DKMTSetProcessSchedulingPriorityClass"))?;
type SetPrio = unsafe extern "system" fn(HANDLE, i32) -> i32;
let f: SetPrio = std::mem::transmute(p);
Some(f(process, prio))
}
/// Apollo-style GPU scheduling-priority hardening (Sunshine `display_base.cpp:599-709`). On a /// Apollo-style GPU scheduling-priority hardening (Sunshine `display_base.cpp:599-709`). On a
/// GPU-saturated game our capture+encode process is starved of GPU time slices — NVENC sits ~idle but /// GPU-saturated game our capture+encode process is starved of GPU time slices — NVENC sits ~idle but
/// `lock_bitstream` waits ~20 ms for our context to be scheduled. Elevating the PROCESS GPU scheduling /// `lock_bitstream` waits ~20 ms for our context to be scheduled. Elevating the PROCESS GPU scheduling
@@ -209,89 +298,70 @@ pub(crate) unsafe fn make_device(
/// alone, which we measured as no help) lets our brief encode preempt the game. Uses HIGH, NOT /// alone, which we measured as no help) lets our brief encode preempt the game. Uses HIGH, NOT
/// realtime: realtime on NVIDIA + HAGS can freeze/crash NVENC (Apollo downgrades it for exactly this). /// realtime: realtime on NVIDIA + HAGS can freeze/crash NVENC (Apollo downgrades it for exactly this).
/// Runs once per process; best-effort. `PUNKTFUNK_GPU_PRIORITY_CLASS = off|normal|high|realtime` /// Runs once per process; best-effort. `PUNKTFUNK_GPU_PRIORITY_CLASS = off|normal|high|realtime`
/// (default high). /// (default high). NOTE: in the SYSTEM-host + user-session-helper deployment this self-set NO-OPs in
/// the helper (filtered token), so the host also sets it on the helper via [`set_child_gpu_priority_class`].
fn elevate_process_gpu_priority() { fn elevate_process_gpu_priority() {
use std::sync::Once; use std::sync::Once;
static ONCE: Once = Once::new(); static ONCE: Once = Once::new();
// SAFETY: the closure calls two of this module's `unsafe fn`s — `enable_inc_base_priority`
// (adjusts the current-process token; it has no caller precondition and builds all its FFI args
// locally) and `d3dkmt_set_scheduling_priority_class` (loads gdi32 by name and calls the export).
// The latter requires `process` to be a valid process handle; `GetCurrentProcess()` returns the
// current-process pseudo-handle, which is always valid and needs no close. Runs once via
// `Once::call_once`; no raw pointers are dereferenced here.
ONCE.call_once(|| unsafe { ONCE.call_once(|| unsafe {
use windows::core::{s, PCWSTR}; use windows::Win32::System::Threading::GetCurrentProcess;
use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID}; let Some(prio) = configured_gpu_priority_class() else {
use windows::Win32::Security::{ tracing::info!("GPU process scheduling priority class left at default (off)");
AdjustTokenPrivileges, LookupPrivilegeValueW, LUID_AND_ATTRIBUTES, return;
SE_INC_BASE_PRIORITY_NAME, SE_PRIVILEGE_ENABLED, TOKEN_ADJUST_PRIVILEGES,
TOKEN_PRIVILEGES, TOKEN_QUERY,
}; };
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA}; enable_inc_base_priority();
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; match d3dkmt_set_scheduling_priority_class(GetCurrentProcess(), prio) {
Some(0) => tracing::info!(
// D3DKMT_SCHEDULINGPRIORITYCLASS: IDLE 0, BELOW_NORMAL 1, NORMAL 2, ABOVE_NORMAL 3, HIGH 4, priority_class = prio,
// REALTIME 5. "GPU process scheduling priority class set (2=normal 4=high 5=realtime)"
let prio: i32 = match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS").ok().as_deref() { ),
Some("off") => { Some(st) => tracing::warn!(
tracing::info!("GPU process scheduling priority class left at default (off)"); status = format!("0x{st:08X}"),
return; "D3DKMTSetProcessSchedulingPriorityClass failed (run as admin/SYSTEM for GPU priority)"
} ),
Some("normal") => 2, None => tracing::warn!("D3DKMTSetProcessSchedulingPriorityClass export not found"),
Some("realtime") => 5,
_ => 4, // HIGH — safe on NVIDIA+HAGS (realtime can freeze NVENC)
};
// 1. Enable SE_INC_BASE_PRIORITY so the kernel permits the GPU priority bump.
let mut token = HANDLE::default();
if OpenProcessToken(
GetCurrentProcess(),
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
&mut token,
)
.is_ok()
{
let mut luid = LUID::default();
if LookupPrivilegeValueW(PCWSTR::null(), SE_INC_BASE_PRIORITY_NAME, &mut luid).is_ok() {
let tp = TOKEN_PRIVILEGES {
PrivilegeCount: 1,
Privileges: [LUID_AND_ATTRIBUTES {
Luid: luid,
Attributes: SE_PRIVILEGE_ENABLED,
}],
};
if AdjustTokenPrivileges(
token,
false,
Some(&tp as *const TOKEN_PRIVILEGES),
0,
None,
None,
)
.is_err()
{
tracing::warn!("could not enable SE_INC_BASE_PRIORITY for GPU priority");
}
}
let _ = CloseHandle(token);
}
// 2. D3DKMTSetProcessSchedulingPriorityClass via gdi32 (no stable windows-rs binding).
if let Ok(gdi32) = LoadLibraryA(s!("gdi32.dll")) {
if let Some(p) = GetProcAddress(gdi32, s!("D3DKMTSetProcessSchedulingPriorityClass")) {
type SetPrio = unsafe extern "system" fn(HANDLE, i32) -> i32;
let f: SetPrio = std::mem::transmute(p);
let st = f(GetCurrentProcess(), prio);
if st == 0 {
tracing::info!(
priority_class = prio,
"GPU process scheduling priority class set (2=normal 4=high 5=realtime)"
);
} else {
tracing::warn!(
status = format!("0x{st:08X}"),
"D3DKMTSetProcessSchedulingPriorityClass failed (run as admin/SYSTEM for GPU priority)"
);
}
}
} }
}); });
} }
/// Set the GPU scheduling-priority class of ANOTHER process we created — the WGC capture+encode helper
/// in the interactive user session. The helper is spawned with the user's UAC-FILTERED token, which
/// lacks SE_INC_BASE_PRIORITY, so its own [`elevate_process_gpu_priority`] silently no-ops and NVENC
/// gets starved under a GPU-saturating game (the "240→40 fps in-game collapse"). The SYSTEM host DOES
/// hold the privilege, so it stamps the class onto the child's process handle right after spawn — the
/// process-level class applies to GPU contexts the child creates afterwards. Best-effort; logged.
/// `PUNKTFUNK_GPU_PRIORITY_CLASS=off` disables it (same knob as the self path).
///
/// # Safety
/// `process` must be a valid handle to a process we own with at least PROCESS_SET_INFORMATION access
/// (the just-created helper, `PROCESS_INFORMATION::hProcess`).
pub(crate) unsafe fn set_child_gpu_priority_class(process: windows::Win32::Foundation::HANDLE) {
let Some(prio) = configured_gpu_priority_class() else {
return;
};
enable_inc_base_priority(); // the SYSTEM host holds SE_INC_BASE_PRIORITY; the helper does not
match d3dkmt_set_scheduling_priority_class(process, prio) {
Some(0) => tracing::info!(
priority_class = prio,
"WGC helper GPU scheduling priority class set cross-process from the SYSTEM host \
(2=normal 4=high 5=realtime)"
),
Some(st) => tracing::warn!(
status = format!("0x{st:08X}"),
"cross-process D3DKMTSetProcessSchedulingPriorityClass on the WGC helper failed"
),
None => tracing::warn!(
"D3DKMTSetProcessSchedulingPriorityClass export not found — WGC helper has no GPU priority"
),
}
}
/// Re-find the output, make a fresh device on its adapter, and duplicate it. Used by the ACCESS_LOST /// Re-find the output, make a fresh device on its adapter, and duplicate it. Used by the ACCESS_LOST
/// recovery to rebuild the whole capture on the current (possibly secure) input desktop. /// recovery to rebuild the whole capture on the current (possibly secure) input desktop.
unsafe fn reopen_duplication( unsafe fn reopen_duplication(
@@ -482,6 +552,17 @@ unsafe extern "system" fn hybrid_query_hook(gpu_preference: *mut u32) -> i32 {
pub(crate) fn install_gpu_pref_hook() { pub(crate) fn install_gpu_pref_hook() {
use std::sync::Once; use std::sync::Once;
static HOOK: Once = Once::new(); static HOOK: Once = Once::new();
// SAFETY: this one-time hook install only touches a region it has just validated.
// `LoadLibraryA("win32u.dll")` + `GetProcAddress("NtGdiDdDDIGetCachedHybridQueryValue")` yield the
// live base of the real exported function, so `target` is a valid executable code pointer to at
// least the 12 bytes the patch overwrites (an x64 prologue, per Apollo's verified hook). The two
// `ptr::copy_nonoverlapping`s each move exactly 12 bytes between the 12-byte stack arrays
// (`patch`/`readback`) and `target`, which `VirtualProtect(target, 12, PAGE_EXECUTE_READWRITE, …)`
// has just made writable (and is restored to `old` after) — source and dest never overlap (stack
// vs. loaded module image), so every access stays in mapped, in-bounds memory.
// `FlushInstructionCache` gets the current-process pseudo-handle + that same range. The DPI calls
// take by-value context handles / fill the live local `&mut old`/`&mut restore` for the duration of
// each synchronous call. Runs once via `Once::call_once`, before any DXGI use.
HOOK.call_once(|| unsafe { HOOK.call_once(|| unsafe {
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA}; use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA};
use windows::Win32::System::Memory::{ use windows::Win32::System::Memory::{
@@ -1333,6 +1414,14 @@ pub fn hdr_p010_selftest() -> Result<()> {
} }
} }
// SAFETY: this self-test creates its own D3D11 device + immediate context (`D3D11CreateDevice`,
// both checked non-null) and uses ONLY that device for the rest of the block: every
// `CreateTexture2D`/`CreateShaderResourceView`/`HdrP010Converter::{new,convert}`/`CopyResource`/
// `Map` is invoked on that device or its context, so all resources share one device and run on this
// single thread. The source texture's `D3D11_SUBRESOURCE_DATA` points at `fp16`, a live
// `Vec<u16>` of `W*H*4` samples with `SysMemPitch = W*8`, matching the W×H R16G16B16A16 texture;
// `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are
// proven individually at the `read_u16` closure below.
unsafe { unsafe {
// Hardware D3D11 device (no adapter pin — the default GPU is fine for the self-test). // Hardware D3D11 device (no adapter pin — the default GPU is fine for the self-test).
let mut device: Option<ID3D11Device> = None; let mut device: Option<ID3D11Device> = None;
@@ -1982,7 +2071,11 @@ pub struct DuplCapturer {
dbg_cursor: u64, dbg_cursor: u64,
_keepalive: Box<dyn Send>, _keepalive: Box<dyn Send>,
} }
// COM objects used only from the one thread that owns the capturer (the encode thread). // SAFETY: `DuplCapturer` holds D3D11 device/context/duplication COM pointers plus plain data. The
// device is created free-threaded (`make_device` sets no `D3D11_CREATE_DEVICE_SINGLETHREADED`) and
// COM reference counting is interlocked, so moving ownership of the whole capturer to another thread
// is sound. It is used by exactly one thread (the encode thread) at a time — moved to it once, never
// shared (no `Sync`) — so the single-threaded immediate context is never touched concurrently.
unsafe impl Send for DuplCapturer {} unsafe impl Send for DuplCapturer {}
impl DuplCapturer { impl DuplCapturer {
@@ -1990,8 +2083,18 @@ impl DuplCapturer {
target: WinCaptureTarget, target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>, preferred: Option<(u32, u32, u32)>,
keepalive: Box<dyn Send>, keepalive: Box<dyn Send>,
// Whether the (already-resolved) encode backend wants GPU-resident frames — passed IN (Goal-1
// stage 5) so the capturer never re-derives the encode backend itself.
gpu: bool,
want_hdr: bool, want_hdr: bool,
) -> Result<Self> { ) -> Result<Self> {
// SAFETY: runs on the capture thread that will own this `DuplCapturer`. `install_gpu_pref_hook()`
// and the DPI-context calls take by-value handles / no args and touch only thread/process state;
// `SetThreadExecutionState` takes a flags bitmask by value. `CreateDXGIFactory1` yields a live
// `IDXGIFactory1`, and every subsequent COM method (`EnumAdapters1`/`EnumOutputs`/`GetDesc1`/
// `GetDesc`/`cast`) is called on that factory or on an adapter/output it returned — each obtained
// through a checked `while let Ok(..)`/`?` — all from this one thread. No raw pointers are
// dereferenced; the borrowed strings/locals outlive each synchronous call.
unsafe { unsafe {
// Stop DXGI hybrid-GPU output reparenting BEFORE we create the factory / enumerate outputs // Stop DXGI hybrid-GPU output reparenting BEFORE we create the factory / enumerate outputs
// (the cause of the 0x887A0026 ACCESS_LOST churn on this hybrid box: RTX 4090 + AMD iGPU). // (the cause of the 0x887A0026 ACCESS_LOST churn on this hybrid box: RTX 4090 + AMD iGPU).
@@ -2127,9 +2230,9 @@ impl DuplCapturer {
let context = context.context("null D3D11 context")?; let context = context.context("null D3D11 context")?;
// 3) duplicate the output. Attach to the current input desktop first (as SYSTEM this can // 3) duplicate the output. Attach to the current input desktop first (as SYSTEM this can
// be the Winlogon secure desktop) so a session that starts at the lock/login screen works. // be the Winlogon secure desktop) so a session that starts at the lock/login screen works.
// The SudoVDA is kept the sole desktop via the CCD isolation in sudovda::create_monitor // The virtual display is kept the sole desktop via the CCD isolation the pf-vdisplay backend
// (registry-persisted), so the secure desktop has nowhere to render but the output we // applies at monitor creation (registry-persisted), so the secure desktop has nowhere to render
// capture — no per-open re-isolation needed. // but the output we capture — no per-open re-isolation needed.
attach_input_desktop(); attach_input_desktop();
let dupl = duplicate_output(&output, &device, want_hdr) let dupl = duplicate_output(&output, &device, want_hdr)
.context("DuplicateOutput (already duplicated by another app?)")?; .context("DuplicateOutput (already duplicated by another app?)")?;
@@ -2157,14 +2260,13 @@ impl DuplCapturer {
.ok() .ok()
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.unwrap_or((2000 / refresh_hz.max(1)).max(100)); .unwrap_or((2000 / refresh_hz.max(1)).max(100));
// Produce GPU-resident D3D11 frames (zero-copy NVENC, or the NV12/P010 the AMF/QSV // Produce GPU-resident D3D11 frames (zero-copy NVENC, or the NV12/P010 the AMF/QSV backends
// backends read back / import) whenever the resolved encode backend is a GPU one — so the // read back / import) whenever the encode backend is a GPU one — so the capturer's output
// capturer's output format matches the encoder's input. Only the software (GPU-less) path // format matches the encoder's input. Only the software (GPU-less) path takes CPU staging.
// takes CPU staging. Mirrors `encode::open_video`'s dispatch exactly. // The decision is resolved ONCE per session and passed in (Goal-1 stage 5), instead of this
let gpu_mode = !matches!( // capturer re-calling `encode::windows_resolved_backend()` — the back-reference that let
crate::encode::windows_resolved_backend(), // capture and encode disagree (plan §2.3/§5).
crate::encode::WindowsBackend::Software let gpu_mode = gpu;
);
// Read the source display's HDR mastering metadata while we still hold `output` (it is // Read the source display's HDR mastering metadata while we still hold `output` (it is
// moved into the struct below). Only meaningful for an HDR (FP16) duplication. // moved into the struct below). Only meaningful for an HDR (FP16) duplication.
let is_hdr_init = dd.ModeDesc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT; let is_hdr_init = dd.ModeDesc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT;
@@ -2656,7 +2758,7 @@ impl DuplCapturer {
} }
// The SudoVDA output's GDI name can CHANGE across a secure-desktop topology rebuild — // The SudoVDA output's GDI name can CHANGE across a secure-desktop topology rebuild —
// re-resolve from the STABLE target id so we find it under its current name. // re-resolve from the STABLE target id so we find it under its current name.
if let Some(n) = crate::vdisplay::sudovda::resolve_gdi_name(self.target_id) { if let Some(n) = crate::win_display::resolve_gdi_name(self.target_id) {
self.gdi_name = n; self.gdi_name = n;
} }
// Re-sync the capture thread to the CURRENT input desktop on EVERY rebuild — symmetric for // Re-sync the capture thread to the CURRENT input desktop on EVERY rebuild — symmetric for
@@ -3149,6 +3251,11 @@ impl Capturer for DuplCapturer {
// the duplication up to 12 s). Better a few seconds of frozen-last-frame than dropping the stream. // the duplication up to 12 s). Better a few seconds of frozen-last-frame than dropping the stream.
let mut deadline = Instant::now() + Duration::from_secs(20); let mut deadline = Instant::now() + Duration::from_secs(20);
loop { loop {
// SAFETY: `acquire` is an `unsafe fn` because it drives the D3D11 immediate context + the
// output duplication, which must be touched only from the capturer's owning thread.
// `next_frame` runs on that one thread — `DuplCapturer` is `Send` but not `Sync`, so it is
// owned by a single (encode) thread for its whole life — and `&mut self` gives exclusive
// access for the call, satisfying that contract.
if let Some(f) = unsafe { self.acquire() }? { if let Some(f) = unsafe { self.acquire() }? {
self.ever_got_frame = true; self.ever_got_frame = true;
return Ok(f); return Ok(f);
@@ -3195,6 +3302,8 @@ impl Capturer for DuplCapturer {
} }
fn try_latest(&mut self) -> Result<Option<CapturedFrame>> { fn try_latest(&mut self) -> Result<Option<CapturedFrame>> {
// SAFETY: as in `next_frame` — `acquire` must run on the capturer's single owning thread, and
// `try_latest` is called on it (`DuplCapturer` is `Send`, not `Sync`); `&mut self` is exclusive.
unsafe { self.acquire() } unsafe { self.acquire() }
} }
@@ -3206,11 +3315,19 @@ impl Capturer for DuplCapturer {
impl Drop for DuplCapturer { impl Drop for DuplCapturer {
fn drop(&mut self) { fn drop(&mut self) {
if self.holding_frame { if self.holding_frame {
// SAFETY: `self.dupl` is the live `IDXGIOutputDuplication` this capturer created and owns;
// `ReleaseFrame` is a valid COM method on it, called only when `holding_frame` records that a
// frame was acquired and not yet released (so it is not an unbalanced release). Drop runs on
// whichever thread owns the capturer — its sole owner, since it is `!Sync` — and the `&`
// borrow of the duplication outlives this synchronous call.
unsafe { unsafe {
let _ = self.dupl.as_ref().map(|d| d.ReleaseFrame()); let _ = self.dupl.as_ref().map(|d| d.ReleaseFrame());
} }
} }
// Release the display/system-required execution state we took at open(). // Release the display/system-required execution state we took at open().
// SAFETY: `SetThreadExecutionState` is a Win32 FFI call taking an execution-state flag bitmask
// by value (`ES_CONTINUOUS` clears the display/system-required state taken at open); it borrows
// no Rust memory and is safe to call from any thread.
unsafe { unsafe {
SetThreadExecutionState(ES_CONTINUOUS); SetThreadExecutionState(ES_CONTINUOUS);
} }
File diff suppressed because it is too large Load Diff
@@ -16,6 +16,9 @@
//! Limitation: WGC cannot capture the secure desktop (lock / UAC / login) — the caller falls back to //! Limitation: WGC cannot capture the secure desktop (lock / UAC / login) — the caller falls back to
//! the DDA backend ([`super::dxgi::DuplCapturer`]) for those (see capture.rs). //! the DDA backend ([`super::dxgi::DuplCapturer`]) for those (see capture.rs).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::dxgi::{ use super::dxgi::{
find_output, hdr_shader_p010_enabled, make_device, nudge_cursor_onto, D3d11Frame, HdrConverter, find_output, hdr_shader_p010_enabled, make_device, nudge_cursor_onto, D3d11Frame, HdrConverter,
HdrP010Converter, VideoConverter, WinCaptureTarget, HdrP010Converter, VideoConverter, WinCaptureTarget,
@@ -92,6 +95,10 @@ struct Deimpersonate(Option<HANDLE>);
impl Drop for Deimpersonate { impl Drop for Deimpersonate {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(tok) = self.0.take() { if let Some(tok) = self.0.take() {
// SAFETY: `RevertToSelf` takes no arguments and undoes the thread impersonation set during
// WGC activation; `tok` is the impersonation token `HANDLE` from `impersonate_active_user`,
// owned by this `Deimpersonate` and closed exactly once here (taken out of the `Option`, so
// no double-close). Both are FFI calls borrowing no Rust memory.
unsafe { unsafe {
let _ = RevertToSelf(); let _ = RevertToSelf();
let _ = CloseHandle(tok); let _ = CloseHandle(tok);
@@ -174,7 +181,12 @@ pub struct WgcCapturer {
_keepalive: Option<Box<dyn Send>>, _keepalive: Option<Box<dyn Send>>,
} }
// COM + WinRT pointers; confined to the single owning (encode) thread, like DuplCapturer. // SAFETY: like `DuplCapturer`. `WgcCapturer` holds D3D11 (free-threaded device/context) plus WGC WinRT
// objects (`Direct3D11CaptureFramePool` etc., created free-threaded via `CreateFreeThreaded`). COM/WinRT
// reference counting is interlocked, and the capturer is owned + used by exactly one encode thread,
// moved to it once and never shared (no `Sync`), so transferring ownership across threads is sound. The
// free-threaded `FrameArrived` callback touches only the `Arc<WgcSignal>` (itself `Send + Sync`), not
// the capturer's COM fields.
unsafe impl Send for WgcCapturer {} unsafe impl Send for WgcCapturer {}
impl WgcCapturer { impl WgcCapturer {
@@ -182,6 +194,15 @@ impl WgcCapturer {
/// [`attach_keepalive`](Self::attach_keepalive) only after open succeeds, so a failure leaves the /// [`attach_keepalive`](Self::attach_keepalive) only after open succeeds, so a failure leaves the
/// keepalive with the caller to hand to the DDA fallback. /// keepalive with the caller to hand to the DDA fallback.
pub fn open(target: WinCaptureTarget, preferred: Option<(u32, u32, u32)>) -> Result<Self> { pub fn open(target: WinCaptureTarget, preferred: Option<(u32, u32, u32)>) -> Result<Self> {
// SAFETY: runs on the thread opening the WGC session. `RoInitialize` inits this thread's WinRT
// apartment (idempotent; result ignored). `impersonate_active_user()` and `find_output()` are
// this module's `unsafe fn`s whose contracts (call on the activating thread; pass a GDI name)
// are met, and the impersonation is reverted by `_deimp`'s Drop on every return path. Every
// COM/WinRT call thereafter operates on an object obtained + `?`-checked earlier in this same
// block on this single thread — the `IDXGIOutput1` from `find_output`, the device/context from
// `make_device`, the factory/interop/item/pool/session — and the `TypedEventHandler` closure
// captures an `Arc<WgcSignal>` (Send+Sync) by move. No raw pointers are dereferenced; borrowed
// locals outlive their synchronous calls.
unsafe { unsafe {
// WGC is WinRT — the calling thread needs a COM/WinRT apartment for the GraphicsCaptureItem // WGC is WinRT — the calling thread needs a COM/WinRT apartment for the GraphicsCaptureItem
// activation factory (RoGetActivationFactory). Initialize MTA; ignore "already initialized" // activation factory (RoGetActivationFactory). Initialize MTA; ignore "already initialized"
@@ -196,7 +217,7 @@ impl WgcCapturer {
// The SudoVDA output appears a beat after the display is created — settle-retry like DDA. // The SudoVDA output appears a beat after the display is created — settle-retry like DDA.
let deadline = Instant::now() + Duration::from_millis(2000); let deadline = Instant::now() + Duration::from_millis(2000);
let (adapter, output) = loop { let (adapter, output) = loop {
if let Some(n) = crate::vdisplay::sudovda::resolve_gdi_name(target.target_id) { if let Some(n) = crate::win_display::resolve_gdi_name(target.target_id) {
if let Ok(found) = find_output(&n) { if let Ok(found) = find_output(&n) {
break found; break found;
} }
@@ -585,6 +606,15 @@ impl WgcCapturer {
} }
fn process_frame(&mut self, frame: Direct3D11CaptureFrame) -> Result<CapturedFrame> { fn process_frame(&mut self, frame: Direct3D11CaptureFrame) -> Result<CapturedFrame> {
// SAFETY: runs on the capturer's single owning thread. `frame` is a live
// `Direct3D11CaptureFrame` from `self.pool`; `frame.Surface().cast::<IDirect3DDxgiInterfaceAccess
// >().GetInterface()` yields the frame's backing `ID3D11Texture2D`, which belongs to
// `self.device` (the pool was created on it via `CreateDirect3D11DeviceFromDXGIDevice`). Every
// helper called here — `hdr_to_p010`, `convert_to_yuv`, `ensure_fp16_src`, `ensure_out_ring`,
// `HdrConverter::convert`, `CopyResource`, `CreateRenderTargetView` — operates on
// `self.device`/`self.context` and that same-device texture, so all resources share one device.
// The frame is held in `self.held` until its async GPU read completes for the zero-copy paths.
// Single-threaded immediate-context use; borrowed textures/SRVs/RTVs outlive each synchronous call.
unsafe { unsafe {
let surface = frame.Surface().context("frame Surface")?; let surface = frame.Surface().context("frame Surface")?;
let access: IDirect3DDxgiInterfaceAccess = surface let access: IDirect3DDxgiInterfaceAccess = surface
@@ -1,5 +1,5 @@
//! Host-side WGC helper relay (Windows two-process secure-desktop design, //! Host-side WGC helper relay (Windows two-process secure-desktop design,
//! docs/windows-secure-desktop.md — step 4). //! design/archive/windows-secure-desktop.md — step 4).
//! //!
//! WGC won't activate under the SYSTEM account, so the SYSTEM host can't capture the normal desktop //! WGC won't activate under the SYSTEM account, so the SYSTEM host can't capture the normal desktop
//! itself. Instead it spawns `punktfunk-host wgc-helper` in the **interactive user session** (so WGC works) //! itself. Instead it spawns `punktfunk-host wgc-helper` in the **interactive user session** (so WGC works)
@@ -13,6 +13,9 @@
//! Wire framing (must match `wgc_helper::write_au`): per AU //! Wire framing (must match `wgc_helper::write_au`): per AU
//! `[u32 magic "PFAU" LE][u32 len LE][u64 pts_ns LE][u8 keyframe][len bytes data]`. //! `[u32 magic "PFAU" LE][u32 len LE][u64 pts_ns LE][u8 keyframe][len bytes data]`.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use crate::capture::dxgi::WinCaptureTarget; use crate::capture::dxgi::WinCaptureTarget;
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use std::io::{BufRead, BufReader, Read}; use std::io::{BufRead, BufReader, Read};
@@ -56,9 +59,15 @@ pub struct HelperRelay {
rx: Receiver<RelayAu>, rx: Receiver<RelayAu>,
} }
// HANDLEs are just kernel handle values; we own them for the relay's lifetime and close them on Drop. // SAFETY: every field is itself `Send`: the `proc`/`thread` `HANDLE`s are process-global kernel
// handle values (plain integers valid from any thread, owned for the relay's lifetime and closed once
// on Drop), `stdin_w` is a `Mutex<HANDLE>`, and `rx` is an mpsc `Receiver<RelayAu>` (which is `Send`).
// The relay is moved to one thread and owned there, so transferring it across threads is sound.
unsafe impl Send for HelperRelay {} unsafe impl Send for HelperRelay {}
unsafe impl Sync for HelperRelay {} // NOTE: `HelperRelay` is deliberately NOT `Sync`. Its `rx: Receiver<RelayAu>` is `!Sync` (std mpsc
// is single-consumer), and the relay is only ever a single-owner local in the punktfunk1 two-process
// mux loop — never shared by `&` across threads — so `Sync` is neither sound nor needed. (A prior
// `unsafe impl Sync` here asserted more than the fields support; removed.)
/// Control byte on the helper's stdin: force the next encoded frame to be an IDR (client decode /// Control byte on the helper's stdin: force the next encoded frame to be an IDR (client decode
/// recovery). Mirrors `enc.request_keyframe()` in the single-process path. /// recovery). Mirrors `enc.request_keyframe()` in the single-process path.
@@ -84,6 +93,10 @@ impl HelperRelay {
); );
tracing::info!(cmd = %cmdline, "spawning WGC helper in user session"); tracing::info!(cmd = %cmdline, "spawning WGC helper in user session");
// SAFETY: `spawn_inner` is an `unsafe fn` only because it drives raw Win32 token/pipe/process
// FFI; it imposes no caller-side memory precondition beyond valid arguments. `cmdline` is a live
// `&str` borrowed for the synchronous call and `(w, h, hz)` are plain `u32`s. It validates its
// own runtime requirements (active console session, SYSTEM token) and returns `Err` otherwise.
unsafe { spawn_inner(&cmdline, w, h, hz) } unsafe { spawn_inner(&cmdline, w, h, hz) }
} }
@@ -108,6 +121,11 @@ impl HelperRelay {
pub fn request_keyframe(&self) { pub fn request_keyframe(&self) {
let h = self.stdin_w.lock().unwrap(); let h = self.stdin_w.lock().unwrap();
let mut written = 0u32; let mut written = 0u32;
// SAFETY: `*h` is the host's write end of the helper's stdin pipe — a live `HANDLE` owned by
// this `HelperRelay` (held under the `stdin_w` Mutex, locked here), closed only in Drop.
// `WriteFile` reads the 1-byte `&[CTL_KEYFRAME]` buffer and writes the byte count into
// `written`; both are live locals that outlive the synchronous call. A failure (helper gone) is
// discarded as documented.
unsafe { unsafe {
let _ = windows::Win32::Storage::FileSystem::WriteFile( let _ = windows::Win32::Storage::FileSystem::WriteFile(
*h, *h,
@@ -121,6 +139,10 @@ impl HelperRelay {
impl Drop for HelperRelay { impl Drop for HelperRelay {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `self.proc`/`self.thread` are the child process/thread `HANDLE`s from
// `CreateProcessAsUserW`, and `stdin_w` is the host's pipe write end — all owned by this
// `HelperRelay` and closed exactly once here in Drop (no double-close). `TerminateProcess` and
// the three `CloseHandle`s are FFI calls taking those handles by value, borrowing no Rust memory.
unsafe { unsafe {
// Terminate the child first so its WGC capture + NVENC session tear down, then close our // Terminate the child first so its WGC capture + NVENC session tear down, then close our
// handles (the reader threads end on the resulting broken pipe). // handles (the reader threads end on the resulting broken pipe).
@@ -278,6 +300,13 @@ unsafe fn spawn_inner(cmdline: &str, w: u32, h: u32, hz: u32) -> Result<HelperRe
} }
tracing::info!(pid = pi.dwProcessId, mode = %format!("{w}x{h}@{hz}"), "WGC helper spawned"); tracing::info!(pid = pi.dwProcessId, mode = %format!("{w}x{h}@{hz}"), "WGC helper spawned");
// The helper does the WGC capture + NVENC encode, but it runs under the user's UAC-FILTERED token
// (no SE_INC_BASE_PRIORITY), so it can't raise its OWN GPU scheduling-priority class — under a
// GPU-saturating game NVENC then gets starved (the "240→40 fps in-game collapse"). The SYSTEM host
// holds the privilege, so stamp the HIGH GPU priority class onto the child here, right after spawn
// (the process-level class applies to the GPU contexts the helper creates afterwards).
crate::capture::dxgi::set_child_gpu_priority_class(pi.hProcess);
// stderr → host tracing, line by line. // stderr → host tracing, line by line.
let err_handle = HandleReader(err_r); let err_handle = HandleReader(err_r);
std::thread::Builder::new() std::thread::Builder::new()
@@ -357,10 +386,17 @@ fn au_reader(mut r: HandleReader, tx: SyncSender<RelayAu>) {
/// Minimal `Read` over a Win32 pipe HANDLE (the windows crate doesn't impl `Read` on HANDLE). /// Minimal `Read` over a Win32 pipe HANDLE (the windows crate doesn't impl `Read` on HANDLE).
struct HandleReader(HANDLE); struct HandleReader(HANDLE);
// SAFETY: `HandleReader` owns a single pipe `HANDLE` (a process-global kernel handle value, valid from
// any thread). It is moved into the dedicated reader thread and used only there (and closed once on
// Drop), never shared — so transferring ownership across threads is sound.
unsafe impl Send for HandleReader {} unsafe impl Send for HandleReader {}
impl Read for HandleReader { impl Read for HandleReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let mut read = 0u32; let mut read = 0u32;
// SAFETY: `self.0` is the live read end of an anonymous pipe owned by this `HandleReader`
// (closed only in Drop). `ReadFile` fills the caller-provided `buf` (writing at most `buf.len()`
// bytes) and stores the count in `read`; both outlive the synchronous call. A broken pipe
// surfaces as `Err` and is mapped to EOF below.
let ok = unsafe { let ok = unsafe {
windows::Win32::Storage::FileSystem::ReadFile(self.0, Some(buf), Some(&mut read), None) windows::Win32::Storage::FileSystem::ReadFile(self.0, Some(buf), Some(&mut read), None)
}; };
@@ -373,6 +409,8 @@ impl Read for HandleReader {
} }
impl Drop for HandleReader { impl Drop for HandleReader {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `self.0` is the pipe `HANDLE` this `HandleReader` owns; `CloseHandle` (an FFI call
// taking the handle by value) is invoked exactly once here in Drop, so there is no double-close.
unsafe { unsafe {
let _ = CloseHandle(self.0); let _ = CloseHandle(self.0);
} }
@@ -384,6 +422,13 @@ impl Drop for HandleReader {
pub fn running_as_system() -> bool { pub fn running_as_system() -> bool {
use windows::Win32::Security::{GetTokenInformation, TokenUser, TOKEN_QUERY, TOKEN_USER}; use windows::Win32::Security::{GetTokenInformation, TokenUser, TOKEN_QUERY, TOKEN_USER};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
// SAFETY: `OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token)` opens the current-process
// token (the pseudo-handle is always valid) into `token`, which is closed once before each return.
// The first `GetTokenInformation` (null buffer) queries the required `len`; `buf` is then a
// `Vec<u8>` of exactly `len` bytes and the second call fills it, so `&*(buf.as_ptr() as *const
// TOKEN_USER)` reads a `TOKEN_USER` the kernel just wrote into a sufficiently-sized buffer (the
// variable-length SID it points at also lies within `buf`, which outlives the borrow).
// `is_local_system_sid` is this module's `unsafe fn`, given that in-buffer `PSID`. Safe on any thread.
unsafe { unsafe {
let mut token = HANDLE::default(); let mut token = HANDLE::default();
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).is_err() { if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).is_err() {
+128
View File
@@ -0,0 +1,128 @@
//! `HostConfig` — the host's runtime knobs parsed ONCE from the environment, instead of the ~68 scattered
//! `env::var` reads recomputed at every call site (some up to 8×, which lets capture + encode silently
//! disagree on the resolved backend — plan §2.4). The service / launcher loads `host.env` into the process
//! environment before the host starts, and **for the knobs captured here the environment is constant for the
//! process lifetime**, so a lazily-parsed global is equivalent to "parsed once at startup".
//!
//! **Goal-1 stages 12** (`design/windows-host-rewrite.md` §2.2): stage 1 stood this up; stage 2 migrated the
//! genuinely-constant operator/dispatch knobs onto it (the dispatch-disagreement bug class: `idd_push`,
//! `capture_backend`, `encoder_pref`, `render_adapter`, `no_wgc`, the vdisplay backend select — plus the
//! plan-named `secure_dda`/`idd_depth`/`zerocopy`/`ten_bit` and the multi-site `perf`/`compositor`/
//! `video_source`/`gamepad`). `SessionPlan` (stage 3) consumes it as the single owner of the
//! capture/topology/encoder decision.
//!
//! **What is deliberately NOT here (and must stay a live `env::var` read):**
//! - **Runtime-mutated session vars.** On Linux, [`crate::vdisplay::apply_session_env`] rewrites the process
//! env on *every connect* so one host follows a Bazzite box across Gaming↔Desktop: `WAYLAND_DISPLAY`,
//! `XDG_CURRENT_DESKTOP`, `XDG_RUNTIME_DIR`, `DBUS_SESSION_BUS_ADDRESS`, and the *derived* `PUNKTFUNK_*`
//! vars `INPUT_BACKEND`, `GAMESCOPE_SESSION`/`GAMESCOPE_NODE`, `KWIN_VIRTUAL_PRIMARY`,
//! `MUTTER_VIRTUAL_PRIMARY`, `FORCE_SHM` (+ `GAMESCOPE_APP` on the launch path). Parsing these once would
//! freeze them at startup and silently break session-following — they are NOT constant.
//! - **Single-use local tuning** read exactly where it is used (no resolve-once benefit, and a parse with a
//! call-site-local default/clamp): e.g. `FEC_PCT` (two *different* semantics — GameStream default-20 vs
//! punktfunk/1 `Option`/clamp-90), `VIDEO_DROP`, `VBV_FRAMES`, `SPLIT_ENCODE`, `PACE_BURST_KB`, the
//! `capture/dxgi.rs` timing knobs, the `*_LIVE` test gates.
//! - **Path / genuinely-dynamic reads**: the config-dir resolution, `PATH` executable search, the
//! env-forward-to-child loop, `PUNKTFUNK_MGMT_TOKEN`, `PUNKTFUNK_HOST_CMD`, `PUNKTFUNK_RENDER_NODE`.
//!
//! `PUNKTFUNK_ZEROCOPY` note: this field uses **presence** semantics (`var_os(..).is_some()`) to match the
//! Windows `encode/ffmpeg_win.rs` reader. The Linux `zerocopy` module keeps its own *truthy* parser
//! (`1|true|yes|on`) — the two are independent features that share a name; do NOT conflate them.
use std::sync::OnceLock;
/// Resolved host configuration. Holds the genuinely-constant operator/dispatch knobs (see module docs for
/// what is deliberately excluded). Fields read on only one platform are kept alive cross-platform by the
/// derived `Debug` impl, so the parser can stay a single platform-neutral function.
#[derive(Debug, Clone, Default)]
pub struct HostConfig {
/// `PUNKTFUNK_IDD_PUSH` — capture from the pf-vdisplay driver's shared ring (in-process Session-0
/// capture; no WGC helper). **Value-aware** (`0`/`false`/`no`/`off`/empty ⇒ off, else on); unset ⇒ off.
/// The installer's default `host.env` sets it on, so a fresh install runs the validated IDD-push path
/// (it falls back to DDA if the driver can't attach — see [`crate::capture`]). NOT a bare presence flag
/// (so an operator can turn it OFF in `host.env` with `=0`, which a `var_os` presence check can't).
pub idd_push: bool,
/// `PUNKTFUNK_ENCODER` — explicit encoder-backend override (lowercased; empty = auto-detect by GPU vendor).
pub encoder_pref: String,
/// `PUNKTFUNK_NO_HELPER` — never spawn the user-session WGC helper.
pub no_helper: bool,
/// `PUNKTFUNK_FORCE_HELPER` — force the WGC helper even when not running as SYSTEM.
pub force_helper: bool,
/// `PUNKTFUNK_NO_WGC` — force the pure single-process DDA path (skip WGC and the two-process relay).
pub no_wgc: bool,
/// `PUNKTFUNK_CAPTURE` — explicit Windows capture-backend override (lowercased; `dda`/`dxgi` vs the WGC default).
pub capture_backend: String,
/// `PUNKTFUNK_RENDER_ADAPTER` — discrete render-GPU pin by description substring (`Some` even when empty:
/// the empty string still counts as "set" for the presence checks, and the value reader filters it).
pub render_adapter: Option<String>,
/// `PUNKTFUNK_SECURE_DDA` — enable the experimental DDA-on-secure-desktop (Winlogon/UAC) mux leg.
pub secure_dda: bool,
/// `PUNKTFUNK_IDD_DEPTH` — IDD-push pipeline depth override (default 2; the call site clamps to its `OUT_RING`).
pub idd_depth: usize,
/// `PUNKTFUNK_ZEROCOPY` — opt into the Windows D3D11 zero-copy encode path (presence semantics; see module docs).
pub zerocopy: bool,
/// `PUNKTFUNK_10BIT` — host policy gate for HEVC Main10 (only honored when the client also advertised 10-bit).
pub ten_bit: bool,
/// `PUNKTFUNK_PERF` — per-stage timing instrumentation.
pub perf: bool,
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic).
pub video_source: Option<String>,
/// `PUNKTFUNK_COMPOSITOR` — explicit compositor override (operator/CI/test). NOT the runtime-detected
/// session — this one is a constant operator knob; `apply_session_env` never writes it.
pub compositor: Option<String>,
/// `PUNKTFUNK_GAMEPAD` — client/operator virtual-pad backend preference (fed to `pick_gamepad`).
pub gamepad: Option<String>,
/// `PUNKTFUNK_VDISPLAY` — Windows virtual-display backend. The pf-vdisplay IddCx driver is now the only
/// backend (the legacy SudoVDA backend was removed), so this is currently informational — kept for the
/// shipped `host.env` and as a forward seam if a second backend is ever added.
pub vdisplay: Option<String>,
}
impl HostConfig {
fn from_env() -> Self {
// Presence flag: set ⇒ true. Matches the original `var_os(k).is_some()` reads (and the few
// `var(k).is_ok()` flag reads, which coincide for every real-world value).
let flag = |k: &str| std::env::var_os(k).is_some();
// String value: `var(k).ok()` — `Some` (possibly empty) when set with valid UTF-8, else `None`.
let val = |k: &str| std::env::var(k).ok();
Self {
// Value-aware (not a bare presence flag): the shipped default `host.env` turns it ON, and an
// operator turns it OFF with `PUNKTFUNK_IDD_PUSH=0` (a `var_os` presence check would read `=0`
// as "on"). Unset ⇒ off (the dev / non-pf-driver default).
idd_push: match std::env::var("PUNKTFUNK_IDD_PUSH") {
Ok(v) => !matches!(
v.trim().to_ascii_lowercase().as_str(),
"" | "0" | "false" | "no" | "off"
),
Err(_) => false,
},
encoder_pref: std::env::var("PUNKTFUNK_ENCODER")
.unwrap_or_default()
.to_ascii_lowercase(),
no_helper: flag("PUNKTFUNK_NO_HELPER"),
force_helper: flag("PUNKTFUNK_FORCE_HELPER"),
no_wgc: flag("PUNKTFUNK_NO_WGC"),
capture_backend: std::env::var("PUNKTFUNK_CAPTURE")
.unwrap_or_default()
.to_ascii_lowercase(),
render_adapter: val("PUNKTFUNK_RENDER_ADAPTER"),
secure_dda: flag("PUNKTFUNK_SECURE_DDA"),
idd_depth: val("PUNKTFUNK_IDD_DEPTH")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(2),
zerocopy: flag("PUNKTFUNK_ZEROCOPY"),
ten_bit: flag("PUNKTFUNK_10BIT"),
perf: flag("PUNKTFUNK_PERF"),
video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
compositor: val("PUNKTFUNK_COMPOSITOR"),
gamepad: val("PUNKTFUNK_GAMEPAD"),
vdisplay: val("PUNKTFUNK_VDISPLAY"),
}
}
}
/// The process-wide host configuration, parsed once on first access.
pub fn config() -> &'static HostConfig {
static CFG: OnceLock<HostConfig> = OnceLock::new();
CFG.get_or_init(HostConfig::from_env)
}
+47 -13
View File
@@ -3,6 +3,9 @@
//! RGB→YUV on the GPU, so no host-side CSC) and VAAPI on AMD/Intel (`*_vaapi`; the CPU-input //! RGB→YUV on the GPU, so no host-side CSC) and VAAPI on AMD/Intel (`*_vaapi`; the CPU-input
//! fallback swscales RGB→NV12, the zero-copy path imports the capture dmabuf straight into a //! fallback swscales RGB→NV12, the zero-copy path imports the capture dmabuf straight into a
//! VA surface). One [`Encoder`] trait, selected in [`open_video`]. //! VA surface). One [`Encoder`] trait, selected in [`open_video`].
// Every unsafe block in this module tree carries a `// SAFETY:` proof; enforce it (unsafe-proof
// program). As a parent module this also covers the child modules (encode::windows/linux::*).
#![deny(clippy::undocumented_unsafe_blocks)]
use crate::capture::{CapturedFrame, PixelFormat}; use crate::capture::{CapturedFrame, PixelFormat};
use anyhow::Result; use anyhow::Result;
@@ -71,9 +74,34 @@ impl Codec {
} }
} }
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and HDR
/// plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap `Copy`; fixed
/// for the session (an HDR toggle re-initialises the encoder — re-query if that matters).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct EncoderCaps {
/// The encoder can perform real reference-frame invalidation — i.e.
/// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) can return `true`. When `false`
/// the caller skips that always-`false` call and forces a keyframe directly on loss recovery.
/// Only the Windows direct-NVENC path implements RFI; libavcodec (Linux NVENC), VAAPI and
/// AMF/QSV always keyframe.
pub supports_rfi: bool,
/// The encoder emits in-band HDR mastering/CLL SEI from [`set_hdr_meta`](Encoder::set_hdr_meta).
/// When `false`, `set_hdr_meta` is a no-op and no in-band grade reaches the client. Only the
/// Windows direct-NVENC path attaches it today.
pub supports_hdr_metadata: bool,
}
/// A hardware encoder. One per session; runs on the encode thread. /// A hardware encoder. One per session; runs on the encode thread.
pub trait Encoder: Send { pub trait Encoder: Send {
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>; fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
/// This encoder's static [capabilities](EncoderCaps) (RFI, HDR SEI), so the session glue can
/// route by query rather than rely on the no-op/`false` defaults of
/// [`invalidate_ref_frames`](Self::invalidate_ref_frames) / [`set_hdr_meta`](Self::set_hdr_meta).
/// Default: no optional capabilities (the SDR / libavcodec backends) — only the direct-NVENC
/// path overrides it.
fn caps(&self) -> EncoderCaps {
EncoderCaps::default()
}
/// Force the next submitted frame to be an IDR keyframe (e.g. after a client /// Force the next submitted frame to be an IDR keyframe (e.g. after a client
/// reference-frame-invalidation request). Default: no-op. /// reference-frame-invalidation request). Default: no-op.
fn request_keyframe(&mut self) {} fn request_keyframe(&mut self) {}
@@ -173,14 +201,12 @@ pub fn open_video(
// AMD/Intel → VAAPI (one libavcodec backend for both). Auto-detect by default so a single // AMD/Intel → VAAPI (one libavcodec backend for both). Auto-detect by default so a single
// Linux binary serves any GPU; `PUNKTFUNK_ENCODER` forces a specific backend (and surfaces // Linux binary serves any GPU; `PUNKTFUNK_ENCODER` forces a specific backend (and surfaces
// its errors crisply instead of silently trying the other). // its errors crisply instead of silently trying the other).
let pref = std::env::var("PUNKTFUNK_ENCODER") let pref = crate::config::config().encoder_pref.as_str();
.unwrap_or_default()
.to_ascii_lowercase();
let open_vaapi = || -> Result<Box<dyn Encoder>> { let open_vaapi = || -> Result<Box<dyn Encoder>> {
vaapi::VaapiEncoder::open(codec, format, width, height, fps, bitrate_bps, bit_depth) vaapi::VaapiEncoder::open(codec, format, width, height, fps, bitrate_bps, bit_depth)
.map(|e| Box::new(e) as Box<dyn Encoder>) .map(|e| Box::new(e) as Box<dyn Encoder>)
}; };
match pref.as_str() { match pref {
"nvenc" | "nvidia" | "cuda" => open_nvenc_probed( "nvenc" | "nvidia" | "cuda" => open_nvenc_probed(
codec, codec,
format, format,
@@ -379,11 +405,7 @@ fn nvidia_present() -> bool {
/// passthrough for VAAPI vs the EGL→CUDA import for NVENC). /// passthrough for VAAPI vs the EGL→CUDA import for NVENC).
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn linux_zero_copy_is_vaapi() -> bool { pub fn linux_zero_copy_is_vaapi() -> bool {
match std::env::var("PUNKTFUNK_ENCODER") match crate::config::config().encoder_pref.as_str() {
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"nvenc" | "nvidia" | "cuda" => false, "nvenc" | "nvidia" | "cuda" => false,
"vaapi" | "amd" | "intel" => true, "vaapi" | "amd" | "intel" => true,
_ => !nvidia_present(), _ => !nvidia_present(),
@@ -450,10 +472,8 @@ enum GpuVendor {
/// vendor). Shared by [`open_video`] and the GameStream codec advertisement so both agree. /// vendor). Shared by [`open_video`] and the GameStream codec advertisement so both agree.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub(crate) fn windows_resolved_backend() -> WindowsBackend { pub(crate) fn windows_resolved_backend() -> WindowsBackend {
let pref = std::env::var("PUNKTFUNK_ENCODER") // Resolved ONCE in HostConfig (Goal-1) — was re-read from PUNKTFUNK_ENCODER on every call.
.unwrap_or_default() match crate::config::config().encoder_pref.as_str() {
.to_ascii_lowercase();
match pref.as_str() {
"nvenc" | "hw" | "nvidia" | "cuda" => WindowsBackend::Nvenc, "nvenc" | "hw" | "nvidia" | "cuda" => WindowsBackend::Nvenc,
"amf" | "amd" => WindowsBackend::Amf, "amf" | "amd" => WindowsBackend::Amf,
"qsv" | "intel" => WindowsBackend::Qsv, "qsv" | "intel" => WindowsBackend::Qsv,
@@ -488,6 +508,14 @@ fn windows_gpu_vendor() -> Option<GpuVendor> {
CreateDXGIFactory1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE, CreateDXGIFactory1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE,
}; };
static CACHE: OnceLock<Option<GpuVendor>> = OnceLock::new(); static CACHE: OnceLock<Option<GpuVendor>> = OnceLock::new();
// SAFETY: `CreateDXGIFactory1` returns a fresh owned `IDXGIFactory1` COM object (refcounted by the
// windows-rs wrapper, Released when the local drops); `.ok()?` bails on failure so `factory` is a
// valid interface before any use. `EnumAdapters1(i)` hands back the i-th adapter as an owned
// `IDXGIAdapter1` (or an error past the last adapter, which ends the loop). `GetDesc1()` returns the
// `DXGI_ADAPTER_DESC1` by value (no out-pointer), so reading `desc.Flags`/`desc.VendorId` is plain
// field access. Every call only touches COM objects this closure owns; the `OnceLock` runs the
// closure once (no data race) and all interfaces are Released as the locals drop. No raw pointer is
// dereferenced and nothing is aliased.
*CACHE.get_or_init(|| unsafe { *CACHE.get_or_init(|| unsafe {
let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?; let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?;
let mut i = 0u32; let mut i = 0u32;
@@ -539,15 +567,21 @@ pub fn windows_codec_support() -> CodecSupport {
}) })
} }
// Goal-1 stage 6: GPU/CPU encoders confined to `encode/windows/` (NVENC, AMF/QSV ffmpeg, software) and
// `encode/linux/` (NVENC/CUDA + VAAPI); `#[path]` keeps the `crate::encode::*` module names flat.
#[cfg(all(target_os = "windows", feature = "amf-qsv"))] #[cfg(all(target_os = "windows", feature = "amf-qsv"))]
#[path = "encode/windows/ffmpeg_win.rs"]
mod ffmpeg_win; mod ffmpeg_win;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod linux; mod linux;
#[cfg(all(target_os = "windows", feature = "nvenc"))] #[cfg(all(target_os = "windows", feature = "nvenc"))]
#[path = "encode/windows/nvenc.rs"]
mod nvenc; mod nvenc;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "encode/windows/sw.rs"]
mod sw; mod sw;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "encode/linux/vaapi.rs"]
mod vaapi; mod vaapi;
#[cfg(test)] #[cfg(test)]
@@ -8,6 +8,8 @@
//! does *not* accept — we expand it to `rgb0` (one padding byte/pixel, no colour math). //! does *not* accept — we expand it to `rgb0` (one padding byte/pixel, no colour math).
//! The encoder is opened *without* a global header so VPS/SPS/PPS are emitted in-band on //! The encoder is opened *without* a global header so VPS/SPS/PPS are emitted in-band on
//! every IDR — the output is both a playable raw Annex-B stream and self-contained AUs. //! every IDR — the output is both a playable raw Annex-B stream and self-contained AUs.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{Codec, EncodedFrame, Encoder}; use super::{Codec, EncodedFrame, Encoder};
use crate::capture::{CapturedFrame, FramePayload, PixelFormat}; use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
@@ -79,6 +81,12 @@ impl CudaHw {
impl Drop for CudaHw { impl Drop for CudaHw {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `CudaHw::new` created
// (it bails before returning `Self` if either alloc fails, so a live `CudaHw` always holds
// both). `av_buffer_unref` drops one reference and nulls the pointer through the `&mut`. This
// `Drop` runs exactly once and `CudaHw` owns these refs exclusively → no double-free /
// use-after-free. Frames are unref'd before the device (the frames ctx internally refs the
// device; refcounted, so the order is sound regardless).
unsafe { unsafe {
ffi::av_buffer_unref(&mut self.frames_ref); ffi::av_buffer_unref(&mut self.frames_ref);
ffi::av_buffer_unref(&mut self.device_ref); ffi::av_buffer_unref(&mut self.device_ref);
@@ -136,6 +144,13 @@ pub struct NvencEncoder {
// `CudaHw` holds raw `AVBufferRef`s; the encoder lives on a single thread. The CPU encoder is // `CudaHw` holds raw `AVBufferRef`s; the encoder lives on a single thread. The CPU encoder is
// already `Send` via ffmpeg-next; assert it for the CUDA fields too. // already `Send` via ffmpeg-next; assert it for the CUDA fields too.
// SAFETY: `NvencEncoder` owns an ffmpeg-next `Encoder`/`VideoFrame` (already `Send`) plus a `CudaHw`
// holding raw `AVBufferRef`s, which are not `Send` by default. The encoder is owned and driven by
// exactly ONE thread — the per-session encode thread it is moved to — and is only touched through
// `&mut self` methods, so it is never aliased or accessed concurrently. The wrapped libav contexts
// (and the shared `CUcontext` the `CudaHw` references) have no thread affinity, so transferring
// ownership across threads is sound. This asserts `Send` (transfer) only, extending ffmpeg-next's
// existing `Send` to the raw CUDA fields; `Sync` (shared `&`) is deliberately NOT implemented.
unsafe impl Send for NvencEncoder {} unsafe impl Send for NvencEncoder {}
impl NvencEncoder { impl NvencEncoder {
@@ -162,6 +177,9 @@ impl NvencEncoder {
} }
ffmpeg::init().context("ffmpeg init")?; ffmpeg::init().context("ffmpeg init")?;
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() { if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
// SAFETY: `av_log_set_level` sets libav's global integer log level; `48` (= AV_LOG_DEBUG)
// is a valid level with no pointer args, and libav was just initialized by `ffmpeg::init()`
// above — always sound.
unsafe { ffi::av_log_set_level(48) }; // AV_LOG_DEBUG — surface NVENC hw-frame rejects unsafe { ffi::av_log_set_level(48) }; // AV_LOG_DEBUG — surface NVENC hw-frame rejects
} }
let name = codec.nvenc_name(); let name = codec.nvenc_name();
@@ -195,6 +213,11 @@ impl NvencEncoder {
.unwrap_or(1.0); .unwrap_or(1.0);
let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * vbv_frames as f64) let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * vbv_frames as f64)
.clamp(1.0, i32::MAX as f64); .clamp(1.0, i32::MAX as f64);
// SAFETY: `video` is the ffmpeg-next encoder builder wrapping a freshly-allocated
// `AVCodecContext` that we hold by value and have not opened yet; `video.as_mut_ptr()` returns
// that non-null, properly-aligned, exclusively-owned context. Writing the plain `rc_buffer_size`
// int field before `open_with` is the supported way to set a field ffmpeg-next exposes no
// setter for. Sole owner → no aliasing; synchronous in-bounds scalar write.
unsafe { unsafe {
(*video.as_mut_ptr()).rc_buffer_size = vbv_bits as i32; (*video.as_mut_ptr()).rc_buffer_size = vbv_bits as i32;
} }
@@ -204,6 +227,9 @@ impl NvencEncoder {
// "freeze". NVENC emits one IDR at stream start, then P-frames only; `forced-idr` (below) // "freeze". NVENC emits one IDR at stream start, then P-frames only; `forced-idr` (below)
// turns a client recovery request (RFI, via `request_keyframe`) into an IDR on demand. // turns a client recovery request (RFI, via `request_keyframe`) into an IDR on demand.
// This is the Moonlight/Sunshine low-latency model. // This is the Moonlight/Sunshine low-latency model.
// SAFETY: same `video` builder as above — a non-null, properly-aligned, sole-owned, not-yet-
// opened `AVCodecContext`. We write the plain `gop_size` int field (= -1, infinite GOP) before
// `open_with`, which ffmpeg-next has no setter for. No aliasing; synchronous scalar write.
unsafe { unsafe {
(*video.as_mut_ptr()).gop_size = -1; (*video.as_mut_ptr()).gop_size = -1;
} }
@@ -214,6 +240,10 @@ impl NvencEncoder {
// RGB-input paths leave these unset (NVENC's internal CSC writes its own VUI). Matches the // RGB-input paths leave these unset (NVENC's internal CSC writes its own VUI). Matches the
// Windows NV12 path's BT.709 limited-range signalling. // Windows NV12 path's BT.709 limited-range signalling.
if matches!(format, PixelFormat::Nv12) { if matches!(format, PixelFormat::Nv12) {
// 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
// fields to valid `AVColorSpace`/`AVColorRange`/`AVColorPrimaries`/`AVColorTransfer-
// Characteristic` variants before `open_with`. Sole owner → no aliasing; synchronous writes.
unsafe { unsafe {
let raw = video.as_mut_ptr(); let raw = video.as_mut_ptr();
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709; (*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
@@ -228,7 +258,17 @@ impl NvencEncoder {
// *before* open (NVENC derives the device from `hw_frames_ctx`). // *before* open (NVENC derives the device from `hw_frames_ctx`).
let cuda_hw = if cuda { let cuda_hw = if cuda {
let cu_ctx = crate::zerocopy::cuda::context().context("shared CUDA context")?; let cu_ctx = crate::zerocopy::cuda::context().context("shared CUDA context")?;
// SAFETY: `CudaHw::new` (an `unsafe fn`) requires libav initialized (the `ffmpeg::init()`
// above ran) and a valid `CUcontext`; `cu_ctx` is the shared importer context from
// `zerocopy::cuda::context()?`, non-null on the `Ok` path. `nvenc_pixel` is a valid `Pixel`
// and `width`/`height` are the validated positive dims. It returns a RAII `CudaHw` wrapping
// (not owning) `cu_ctx` and owning two `AVBufferRef`s freed on drop.
let hw = unsafe { CudaHw::new(cu_ctx, nvenc_pixel, width, height)? }; let hw = unsafe { CudaHw::new(cu_ctx, nvenc_pixel, width, height)? };
// SAFETY: `raw = video.as_mut_ptr()` is the non-null, sole-owned, not-yet-opened
// `AVCodecContext`. We set `pix_fmt = CUDA` and attach NEW refs (`av_buffer_ref`) of
// `hw.device_ref`/`hw.frames_ref` — both non-null (`CudaHw::new` guarantees) and from the
// live `hw`, which is moved into `NvencEncoder.cuda` next to `enc` and so outlives the
// encoder. The context owns its own refs (freed when the context closes). No aliasing.
unsafe { unsafe {
let raw = video.as_mut_ptr(); let raw = video.as_mut_ptr();
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_CUDA; (*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_CUDA;
@@ -428,6 +468,19 @@ impl NvencEncoder {
// The device→device copy below uses our shared context directly; make it current on the // The device→device copy below uses our shared context directly; make it current on the
// encode thread (ffmpeg pushes its own around the pool alloc, so order is fine). // encode thread (ffmpeg pushes its own around the pool alloc, so order is fine).
crate::zerocopy::cuda::make_current().context("CUDA context current (encode thread)")?; crate::zerocopy::cuda::make_current().context("CUDA context current (encode thread)")?;
// SAFETY: `frames_ref` is the non-null CUDA frames ctx from `self.cuda` (unwrapped via
// `.context(..)?` above), and the shared CUDA context was just made current on THIS thread
// (`make_current()?`), the precondition for the device-pointer copies below.
// * `av_frame_alloc` → `f` (null-checked). `av_hwframe_get_buffer(frames_ref, f, 0)` fills `f`
// with a pooled CUDA surface (sets `data[]`/`linesize[]`/`buf[0]`/`hw_frames_ctx`); on
// failure we free `f` and bail.
// * For NV12 we read `(*f).data[0..2]` / `linesize[0..2]` (Y + interleaved UV), else
// `data[0]`/`linesize[0]` — in-struct fields of the non-null `f`, valid for the surface dims
// ffmpeg allocated — and pass them to the cuda copy helpers, which device→device copy `buf`
// (the imported `DeviceBuffer`, owned by the caller and live for this call) into the surface.
// * On copy error we free `f` and return. Otherwise we write `pts`/`pict_type` through `f` and
// `avcodec_send_frame` it into the live owned `self.enc` context (which takes its own ref of
// the pooled surface), then free our `f` ref exactly once. Single-threaded encoder → no race.
unsafe { unsafe {
let mut f = ffi::av_frame_alloc(); let mut f = ffi::av_frame_alloc();
if f.is_null() { if f.is_null() {
@@ -19,6 +19,8 @@
//! hwdevice/hwframes/buffersrc/buffersink calls go through `ffmpeg::ffi` (= `ffmpeg_sys_next`), //! hwdevice/hwframes/buffersrc/buffersink calls go through `ffmpeg::ffi` (= `ffmpeg_sys_next`),
//! as the CUDA encode path and the clients' decode paths already do. The encoder is opened //! as the CUDA encode path and the clients' decode paths already do. The encoder is opened
//! *without* a global header, so VPS/SPS/PPS are in-band on every IDR. //! *without* a global header, so VPS/SPS/PPS are in-band on every IDR.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{Codec, EncodedFrame, Encoder}; use super::{Codec, EncodedFrame, Encoder};
use crate::capture::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat}; use crate::capture::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat};
@@ -133,6 +135,14 @@ pub fn probe_can_encode(codec: Codec) -> bool {
if ffmpeg::init().is_err() { if ffmpeg::init().is_err() {
return false; return false;
} }
// SAFETY: `ffmpeg::init()` returned Ok above, so libav is initialized. `av_log_get_level`/
// `av_log_set_level` only read/write libav's global integer log level (no pointer args) and are
// always sound to call post-init. `VaapiHw::new` (an `unsafe fn`) builds a VAAPI device + NV12
// frames pool from the literal NV12/640x480/pool=2 args and hands back a RAII handle that unrefs
// both `AVBufferRef`s on drop. `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/
// `hw.frames_ref` — the two non-null refs `VaapiHw::new` just created — and `av_buffer_ref`s them
// into the encoder; `hw` is a live local for the whole match arm, so the borrows outlive the
// synchronous call, and both `hw` and the probe encoder are dropped (RAII) when the arm ends.
unsafe { unsafe {
// A missing VA device (non-VAAPI host, GPU-less CI) is an expected probe outcome — quiet // A missing VA device (non-VAAPI host, GPU-less CI) is an expected probe outcome — quiet
// ffmpeg's "No VA display found" error for the probe, then restore the level. // ffmpeg's "No VA display found" error for the probe, then restore the level.
@@ -224,6 +234,12 @@ impl VaapiHw {
impl Drop for VaapiHw { impl Drop for VaapiHw {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `VaapiHw::new`
// created (it bails before constructing `Self` if either alloc fails, so a live `VaapiHw`
// always holds both). `av_buffer_unref` drops one reference and nulls the pointer through the
// `&mut`. This `Drop` runs exactly once and `VaapiHw` owns these refs exclusively, so there
// is no double-free / use-after-free. Frames are unref'd before the device because the frames
// ctx internally holds a ref on the device (refcounted, so the order is sound either way).
unsafe { unsafe {
ffi::av_buffer_unref(&mut self.frames_ref); ffi::av_buffer_unref(&mut self.frames_ref);
ffi::av_buffer_unref(&mut self.device_ref); ffi::av_buffer_unref(&mut self.device_ref);
@@ -252,7 +268,16 @@ impl CpuInner {
) -> Result<Self> { ) -> Result<Self> {
let src_pixel = vaapi_sws_src(format)?; let src_pixel = vaapi_sws_src(format)?;
const POOL: c_int = 16; const POOL: c_int = 16;
// SAFETY: `VaapiHw::new` (an `unsafe fn`) requires libav initialized — guaranteed because the
// only path here is `VaapiEncoder::open` → `ensure_inner` → `CpuInner::open`, and `open` ran
// `ffmpeg::init()`. The args are valid: NV12 sw_format, the validated positive `width`/`height`,
// pool=16. It returns a RAII `VaapiHw` that unrefs its two `AVBufferRef`s on drop.
let hw = unsafe { VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_NV12, width, height, POOL)? }; let hw = unsafe { VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_NV12, width, height, POOL)? };
// SAFETY: `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/`hw.frames_ref` — both
// non-null (`VaapiHw::new` guarantees it) and from the `hw` just built above, which is a live
// local that outlives this synchronous call. The fn `av_buffer_ref`s them into the encoder, so
// the encoder holds its own references; `hw` is also moved into the returned `CpuInner` next to
// `enc`, keeping the device/frames alive for the encoder's whole lifetime.
let enc = unsafe { let enc = unsafe {
open_vaapi_encoder( open_vaapi_encoder(
codec, codec,
@@ -266,6 +291,12 @@ impl CpuInner {
}; };
// swscale RGB→NV12, BT.709 limited (matches the VUI), no rescale. // swscale RGB→NV12, BT.709 limited (matches the VUI), no rescale.
let src_av = pixel_to_av(src_pixel); let src_av = pixel_to_av(src_pixel);
// SAFETY: `sws_getContext` allocates a swscale context for the given src/dst dimensions and
// pixel formats. All four dims are the encoder's positive `width`/`height` cast to `c_int`;
// `src_av` is a valid `AVPixelFormat` (from `pixel_to_av` of the `vaapi_sws_src`-validated
// `src_pixel`), the dst is NV12. The three trailing pointers (srcFilter, dstFilter, param) are
// explicitly null = "use defaults", which the API documents as accepted. No Rust memory is
// borrowed — only by-value ints/enums — and the returned pointer is null-checked just below.
let sws = unsafe { let sws = unsafe {
ffi::sws_getContext( ffi::sws_getContext(
width as c_int, width as c_int,
@@ -283,10 +314,23 @@ impl CpuInner {
if sws.is_null() { if sws.is_null() {
bail!("sws_getContext(RGB→NV12) failed"); bail!("sws_getContext(RGB→NV12) failed");
} }
// SAFETY: `sws` is the non-null `SwsContext` from `sws_getContext` above (the `is_null()`
// check immediately preceding returned false). `sws_getCoefficients(SWS_CS_ITU709)` returns a
// pointer into a libswscale static const coefficient table valid for the whole process, reused
// here for both the inverse (src) and forward (dst) matrices. `sws_setColorspaceDetails` only
// reads those tables and writes scalar CSC settings into `sws`; the table pointer outlives the
// synchronous call and no Rust memory is passed.
unsafe { unsafe {
let cs709 = ffi::sws_getCoefficients(SWS_CS_ITU709); let cs709 = ffi::sws_getCoefficients(SWS_CS_ITU709);
ffi::sws_setColorspaceDetails(sws, cs709, 1, cs709, 0, 0, 1 << 16, 1 << 16); ffi::sws_setColorspaceDetails(sws, cs709, 1, cs709, 0, 0, 1 << 16, 1 << 16);
} }
// SAFETY: `av_frame_alloc` returns a fresh, uniquely-owned heap `AVFrame` (null-checked — on
// null we free the already-built `sws` and bail). We then write the plain `format`/`width`/
// `height` fields through the non-null, properly-aligned `f` (sole owner, not yet shared).
// `av_frame_get_buffer(f, 0)` allocates backing storage for those dims/format; on failure we
// free `f` and `sws` (unwinding the half-built state) and bail. On success `f` is a fully-owned
// NV12 frame stored in `CpuInner.nv12` and freed once in `CpuInner::drop`. `f` is a unique
// fresh pointer, so none of these writes alias anything.
let nv12 = unsafe { let nv12 = unsafe {
let f = ffi::av_frame_alloc(); let f = ffi::av_frame_alloc();
if f.is_null() { if f.is_null() {
@@ -329,6 +373,18 @@ impl CpuInner {
let h = self.height as usize; let h = self.height as usize;
let src_row = w * self.src_format.bytes_per_pixel(); let src_row = w * self.src_format.bytes_per_pixel();
anyhow::ensure!(bytes.len() >= src_row * h, "captured buffer too small"); anyhow::ensure!(bytes.len() >= src_row * h, "captured buffer too small");
// SAFETY: The `ensure!`s above guarantee `format == self.src_format` and
// `bytes.len() >= src_row * h`. `sws_scale` reads `h` rows of `src_row` bytes from
// `src_data[0] = bytes.as_ptr()` (the other planes null/0 — packed RGB is single-plane), all
// in bounds; `bytes`, `src_data`, `src_stride` are live locals for this synchronous call.
// `self.sws` is the non-null context built in `open`; it writes into `self.nv12` (a non-null
// owned frame whose `data`/`linesize` in-struct arrays were sized by `av_frame_get_buffer`).
// `av_frame_alloc` (null-checked) yields a fresh `hwf`; `av_hwframe_get_buffer` pulls a pooled
// VAAPI surface from the live non-null `self.hw.frames_ref`; `av_hwframe_transfer_data` uploads
// the staged NV12 into it — both frames live, failures free `hwf` and bail. We then write
// `pts`/`pict_type` through the non-null `hwf` and `avcodec_send_frame` it into the live
// owned `self.enc` context (which takes its own ref), then free our `hwf` ref exactly once.
// The encoder runs only on this thread (see `unsafe impl Send`), so no aliasing/data race.
unsafe { unsafe {
let src_data: [*const u8; 4] = [bytes.as_ptr(), ptr::null(), ptr::null(), ptr::null()]; let src_data: [*const u8; 4] = [bytes.as_ptr(), ptr::null(), ptr::null(), ptr::null()];
let src_stride: [c_int; 4] = [src_row as c_int, 0, 0, 0]; let src_stride: [c_int; 4] = [src_row as c_int, 0, 0, 0];
@@ -374,6 +430,12 @@ impl CpuInner {
impl Drop for CpuInner { impl Drop for CpuInner {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `self.nv12` (an owned `AVFrame`) and `self.sws` (an owned `SwsContext`) are each
// freed exactly once here, guarded by `is_null()` so a never-set pointer is skipped (no double
// free). `CpuInner` owns both exclusively and `Drop` runs once. `av_frame_free` takes `&mut`
// and nulls the pointer. `self.enc`/`self.hw` are freed afterward by their own `Drop` impls;
// the encoder holds its own `av_buffer_ref`'d device/frames copies, so field-drop order is
// irrelevant to soundness.
unsafe { unsafe {
if !self.nv12.is_null() { if !self.nv12.is_null() {
ffi::av_frame_free(&mut self.nv12); ffi::av_frame_free(&mut self.nv12);
@@ -417,6 +479,31 @@ impl DmabufInner {
let drm_fourcc = crate::zerocopy::drm_fourcc(format) let drm_fourcc = crate::zerocopy::drm_fourcc(format)
.ok_or_else(|| anyhow!("no DRM fourcc for {format:?} (VAAPI zero-copy)"))?; .ok_or_else(|| anyhow!("no DRM fourcc for {format:?} (VAAPI zero-copy)"))?;
let node = render_node(); let node = render_node();
// SAFETY: libav is initialized (`VaapiEncoder::open` ran `ffmpeg::init()` before
// `ensure_inner` → `DmabufInner::open`). Every raw pointer dereferenced below is either freshly
// allocated by the immediately-preceding ffmpeg call and null-checked, or an in-struct field of
// such an object:
// * `node` is a `CString` (from `render_node`) live for the whole block; its `.as_ptr()` is a
// NUL-terminated path read only during `av_hwdevice_ctx_create`.
// * `av_hwdevice_ctx_create(&mut drm_device, DRM, …)` / `…_create_derived(&mut vaapi_device,
// VAAPI, drm_device, …)`: on `r < 0` the out-param stays null and we bail (the derive path
// unrefs `drm_device` first); on success each is a non-null owned `AVBufferRef`.
// * `av_hwframe_ctx_alloc(drm_device)` → `drm_frames` (null-checked); `(*drm_frames).data` is
// its `AVHWFramesContext` payload, written before `av_hwframe_ctx_init`.
// * `avfilter_graph_alloc` → `graph` (null-checked); `avfilter_get_by_name` returns a static
// const `AVFilter` (process-lifetime) or null; `avfilter_graph_alloc_filter` allocates each
// filter ctx inside `graph`; the four are null-checked together. `inst`/arg strings are
// 'static C literals.
// * `(*hwmap/scale).hw_device_ctx = av_buffer_ref(vaapi_device)` attaches a NEW ref owned by
// the filter (freed by `avfilter_graph_free`); our `vaapi_device` ref is untouched.
// * `av_buffersink_get_hw_frames_ctx(sink)` → `nv12_ctx` is a borrowed ref owned by the sink,
// valid while `graph` lives (and `graph` is moved into the returned `DmabufInner`).
// * `open_vaapi_encoder` borrows `vaapi_device` (our live owned ref) and `nv12_ctx` (sink's
// live ref) and `av_buffer_ref`s both into the encoder.
// Every early-error path unref's the allocated buffers and frees the graph in the right order
// before bailing; on success the four `AVBufferRef`s + `graph` + `src`/`sink` are moved into
// `DmabufInner` and freed in its `Drop`. (Two non-UB leaks noted below: `av_buffersrc_*` and
// the final `?`.)
unsafe { unsafe {
// DRM device (source dmabuf frames) + a VAAPI device derived from it (same GPU) for // DRM device (source dmabuf frames) + a VAAPI device derived from it (same GPU) for
// hwmap/scale_vaapi/the encoder. // hwmap/scale_vaapi/the encoder.
@@ -509,7 +596,12 @@ impl DmabufInner {
num: 1, num: 1,
den: fps as c_int, den: fps as c_int,
}; };
(*par).hw_frames_ctx = ffi::av_buffer_ref(drm_frames); // Assign `drm_frames` BORROWED (no extra ref): `av_buffersrc_parameters_set` takes its
// own ref of `par->hw_frames_ctx` (via av_buffer_replace), and `av_free(par)` frees only
// the struct, not the ref. Our single owned `drm_frames` ref is retained, lives in
// `DmabufInner`, and is unref'd in `Drop`. Wrapping it in `av_buffer_ref` here would leak
// that extra ref every session (the persistent listener would accumulate them).
(*par).hw_frames_ctx = drm_frames;
let r = ffi::av_buffersrc_parameters_set(src, par); let r = ffi::av_buffersrc_parameters_set(src, par);
ffi::av_free(par as *mut _); ffi::av_free(par as *mut _);
if r < 0 { if r < 0 {
@@ -564,7 +656,12 @@ impl DmabufInner {
ffi::av_buffer_unref(&mut drm_device); ffi::av_buffer_unref(&mut drm_device);
bail!("filter sink has no VAAPI frames context"); bail!("filter sink has no VAAPI frames context");
} }
let enc = open_vaapi_encoder( // On encoder-open failure, free the graph + our owned buffer refs before bailing (matching
// every error path above) so a failed session doesn't leak them. `nv12_ctx` is borrowed
// from the sink (owned by `graph`), so `avfilter_graph_free` reclaims it — don't unref it
// separately. On success the encoder takes its own ref of `vaapi_device`, and `drm_frames`/
// `vaapi_device`/`drm_device`/`graph` move into `DmabufInner` (freed in `Drop`).
let enc = match open_vaapi_encoder(
codec, codec,
width, width,
height, height,
@@ -572,7 +669,16 @@ impl DmabufInner {
bitrate_bps, bitrate_bps,
vaapi_device, vaapi_device,
nv12_ctx, nv12_ctx,
)?; ) {
Ok(enc) => enc,
Err(e) => {
ffi::avfilter_graph_free(&mut graph);
ffi::av_buffer_unref(&mut drm_frames);
ffi::av_buffer_unref(&mut vaapi_device);
ffi::av_buffer_unref(&mut drm_device);
return Err(e);
}
};
tracing::info!( tracing::info!(
encoder = codec.vaapi_name(), encoder = codec.vaapi_name(),
@@ -600,6 +706,23 @@ impl DmabufInner {
dmabuf.fourcc, dmabuf.fourcc,
self.fourcc self.fourcc
); );
// SAFETY: The `ensure!` above checked `dmabuf.fourcc == self.fourcc`.
// * `std::mem::zeroed::<AVDRMFrameDescriptor>()` is sound: it is a `#[repr(C)]` POD of ints and
// nested int-struct arrays (no `NonNull`/refs), for which all-zero is a valid bit pattern;
// `Box` puts it on the heap with a unique owner.
// * `dmabuf.fd.as_raw_fd()` is the fd of the caller's `&DmabufFrame`, which owns it for the
// whole synchronous `submit`; we describe one object/layer/plane from its
// fourcc/modifier/offset/stride and pass `object.size = 0` (ffmpeg queries the real size).
// * `av_frame_alloc` → `drm` (null-checked); we set its scalar fields and
// `hw_frames_ctx = av_buffer_ref(self.drm_frames)` (new ref of the live owned ctx).
// * `data[0] = Box::into_raw(desc)` transfers the box into the frame; `buf[0] =
// av_buffer_create(.., free_desc, ..)` registers a destructor that reclaims it exactly once
// when the buffer's refcount hits zero — matched alloc/free, no leak/double-free.
// * `av_buffersrc_add_frame_flags(self.src, drm, KEEP_REF)` pushes a ref into the live
// buffersrc; KEEP_REF keeps our own `drm` ref, which we then `av_frame_free`. We pull the
// converted surface with `av_buffersink_get_frame(self.sink, nv12)` BEFORE returning, so the
// dmabuf (owned by the caller) is read while still valid. `nv12` is sent into the live owned
// `self.enc` (takes its own ref) and our ref freed once. Single-threaded encoder → no race.
unsafe { unsafe {
// Build a DRM-PRIME AVFrame describing the dmabuf (one object/fd, one layer/plane). // Build a DRM-PRIME AVFrame describing the dmabuf (one object/fd, one layer/plane).
let mut desc: Box<ffi::AVDRMFrameDescriptor> = Box::new(std::mem::zeroed()); let mut desc: Box<ffi::AVDRMFrameDescriptor> = Box::new(std::mem::zeroed());
@@ -626,6 +749,11 @@ impl DmabufInner {
// Own the descriptor so it frees with the frame (the fd is owned by the DmabufFrame, // Own the descriptor so it frees with the frame (the fd is owned by the DmabufFrame,
// which outlives this call — the graph reads the surface before submit returns). // which outlives this call — the graph reads the surface before submit returns).
extern "C" fn free_desc(_opaque: *mut std::ffi::c_void, data: *mut u8) { extern "C" fn free_desc(_opaque: *mut std::ffi::c_void, data: *mut u8) {
// SAFETY: `data` is exactly the pointer produced by `Box::into_raw(desc)` and passed as
// `av_buffer_create`'s first arg, which libav hands back verbatim to this callback. It
// is a valid, uniquely-owned `Box<AVDRMFrameDescriptor>` raw pointer; libav invokes the
// callback exactly once (when the last buffer ref drops), so `from_raw` + `drop`
// reclaims it exactly once — no double-free. `_opaque` is unused (we passed null).
unsafe { drop(Box::from_raw(data as *mut ffi::AVDRMFrameDescriptor)) }; unsafe { drop(Box::from_raw(data as *mut ffi::AVDRMFrameDescriptor)) };
} }
(*drm).buf[0] = ffi::av_buffer_create( (*drm).buf[0] = ffi::av_buffer_create(
@@ -673,6 +801,13 @@ impl DmabufInner {
impl Drop for DmabufInner { impl Drop for DmabufInner {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `graph`/`drm_frames`/`vaapi_device`/`drm_device` are the non-null objects
// `DmabufInner::open` built and moved into `self` (open bails before constructing `Self` if any
// alloc fails). `avfilter_graph_free` frees the graph (and the per-filter device refs it owns);
// each `av_buffer_unref` drops one ref and nulls the pointer via `&mut`. `DmabufInner` owns all
// four exclusively and `Drop` runs once → no double-free/use-after-free. The graph is freed
// first (it holds refs on the devices), then frames, then the derived VAAPI device, then DRM.
// (`self.enc` drops via ffmpeg-next afterward, holding its own refs.)
unsafe { unsafe {
ffi::avfilter_graph_free(&mut self.graph); ffi::avfilter_graph_free(&mut self.graph);
ffi::av_buffer_unref(&mut self.drm_frames); ffi::av_buffer_unref(&mut self.drm_frames);
@@ -703,6 +838,13 @@ pub struct VaapiEncoder {
} }
// Raw FFI pointers; the encoder lives on a single thread (same contract as `NvencEncoder`). // Raw FFI pointers; the encoder lives on a single thread (same contract as `NvencEncoder`).
// SAFETY: `VaapiEncoder`'s `Inner` holds raw FFI pointers (`SwsContext`, `AVFrame`, `AVBufferRef`,
// `AVFilterContext`, `AVCodecContext`) that are not `Send` by default. The encoder is owned and
// driven by exactly ONE thread — the host's per-session encode thread it is moved (transferred) to —
// and is only ever touched through `&mut self` methods, so it is never aliased or accessed
// concurrently from two threads. None of the underlying libav/libswscale objects have thread
// affinity (they are not thread-local), so transferring ownership across threads is sound. This
// asserts `Send` (transfer) only; `Sync` (shared `&`) is deliberately NOT implemented.
unsafe impl Send for VaapiEncoder {} unsafe impl Send for VaapiEncoder {}
impl VaapiEncoder { impl VaapiEncoder {
@@ -720,6 +862,9 @@ impl VaapiEncoder {
} }
ffmpeg::init().context("ffmpeg init")?; ffmpeg::init().context("ffmpeg init")?;
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() { if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
// SAFETY: `av_log_set_level` sets libav's global integer log level; `48` (= AV_LOG_DEBUG)
// is a valid level and there are no pointer args. libav was just initialized by the
// `ffmpeg::init()` above, so the call is always sound.
unsafe { ffi::av_log_set_level(48) }; unsafe { ffi::av_log_set_level(48) };
} }
// Validate the codec/format up front so a bad request fails at open, not on the first frame. // Validate the codec/format up front so a bad request fails at open, not on the first frame.
@@ -28,6 +28,8 @@
//! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The //! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The
//! `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` layouts are mirrored (the bindings don't //! `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` layouts are mirrored (the bindings don't
//! allowlist `hwcontext_d3d11va.h`), as [`super::linux`] mirrors `AVCUDADeviceContext`. //! allowlist `hwcontext_d3d11va.h`), as [`super::linux`] mirrors `AVCUDADeviceContext`.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{Codec, EncodedFrame, Encoder}; use super::{Codec, EncodedFrame, Encoder};
use crate::capture::{dxgi::D3d11Frame, CapturedFrame, FramePayload, PixelFormat}; use crate::capture::{dxgi::D3d11Frame, CapturedFrame, FramePayload, PixelFormat};
@@ -109,7 +111,7 @@ impl WinVendor {
/// Is the zero-copy D3D11 path enabled? Opt-in (`PUNKTFUNK_ZEROCOPY=1`) until on-glass validated; /// Is the zero-copy D3D11 path enabled? Opt-in (`PUNKTFUNK_ZEROCOPY=1`) until on-glass validated;
/// the default is the robust system-memory readback path. /// the default is the robust system-memory readback path.
fn zerocopy_enabled() -> bool { fn zerocopy_enabled() -> bool {
std::env::var_os("PUNKTFUNK_ZEROCOPY").is_some() crate::config::config().zerocopy
} }
/// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only). /// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only).
@@ -243,6 +245,12 @@ pub fn probe_can_encode(vendor: WinVendor, codec: Codec) -> bool {
if ffmpeg::init().is_err() { if ffmpeg::init().is_err() {
return false; return false;
} }
// SAFETY: `ffmpeg::init()` succeeded above, so libav's global state is initialised.
// `av_log_get_level`/`av_log_set_level` are global scalar getters/setters with no pointer args.
// `open_win_encoder` (the `unsafe fn`) is called with null `device_ref`/`frames_ref` (the system
// path), so it touches no D3D11/hwcontext — it only allocates and opens a self-contained
// libavcodec encoder that is dropped at the end of `.is_ok()`. We restore the prior log level and
// no raw pointer escapes the block.
unsafe { unsafe {
// A missing AMF/QSV runtime (wrong-vendor host, GPU-less CI) is an expected probe outcome — // A missing AMF/QSV runtime (wrong-vendor host, GPU-less CI) is an expected probe outcome —
// quiet ffmpeg's open error for the probe, then restore the level. // quiet ffmpeg's open error for the probe, then restore the level.
@@ -337,6 +345,10 @@ impl SystemInner {
} else { } else {
ffi::AVPixelFormat::AV_PIX_FMT_NV12 ffi::AVPixelFormat::AV_PIX_FMT_NV12
}; };
// SAFETY: calls the `unsafe fn open_win_encoder` with null `device_ref`/`frames_ref`, so the
// system path is taken (no hw device/frames context is touched); all other args are scalars.
// The returned `encoder::video::Encoder` owns its `AVCodecContext` and frees it on drop; no raw
// pointer is aliased.
let enc = unsafe { let enc = unsafe {
open_win_encoder( open_win_encoder(
vendor, vendor,
@@ -352,6 +364,11 @@ impl SystemInner {
ptr::null_mut(), ptr::null_mut(),
)? )?
}; };
// SAFETY: `av_frame_alloc` returns a freshly-allocated, uniquely-owned `AVFrame` (null-checked
// before any deref); writing `format`/`width`/`height` through `*f` stays inside that
// allocation. `av_frame_get_buffer(f, 0)` allocates the backing planes — on failure we
// `av_frame_free` the sole owner (no double-free) and bail; on success the raw `f` is moved into
// `self.sw_frame` and freed exactly once in `Drop`.
let sw_frame = unsafe { let sw_frame = unsafe {
let f = ffi::av_frame_alloc(); let f = ffi::av_frame_alloc();
if f.is_null() { if f.is_null() {
@@ -467,6 +484,18 @@ impl SystemInner {
} else { } else {
DXGI_FORMAT_NV12 DXGI_FORMAT_NV12
}; };
// SAFETY: `ensure_staging` builds a STAGING texture (CPU_ACCESS_READ) matching `dxgi_fmt` on
// `frame.device` — the same `ID3D11Device` that owns `frame.texture` — and caches that device's
// immediate context in `self.ctx`. `src`/`dst` are that device's textures of identical NV12/P010
// format and dimensions, so `CopyResource` on the single-threaded immediate context is valid.
// `Map(.., D3D11_MAP_READ)` succeeds on a staging texture and yields `map.pData` valid for the
// whole resource; for NV12/P010 the luma plane is `H` rows at `RowPitch` and the chroma plane
// follows at byte offset `RowPitch*H` (`H/2` rows), so `total = pitch*(H+⌈H/2⌉)` is exactly the
// mapped extent and `from_raw_parts(base, total)` stays in-bounds. Each `copy_nonoverlapping`
// reads a bounds-checked `mapped[..]` sub-slice (`row_bytes ≤ pitch`) and writes `row_bytes ≤
// linesize` into the `av_frame_get_buffer`-allocated plane at row `y < H`, so every destination
// offset is inside the frame's plane allocation; src and dst never alias. `Unmap` pairs `Map`,
// then `send` (the `unsafe fn`) hands `sw_frame` to the encoder.
unsafe { unsafe {
self.ensure_staging(&frame.device, dxgi_fmt)?; self.ensure_staging(&frame.device, dxgi_fmt)?;
let staging = self.staging.clone().context("staging texture")?; let staging = self.staging.clone().context("staging texture")?;
@@ -510,6 +539,14 @@ impl SystemInner {
if self.ten_bit { if self.ten_bit {
bail!("ffmpeg_win: BGRA readback is 8-bit only (HDR needs the P010 capture path)"); bail!("ffmpeg_win: BGRA readback is 8-bit only (HDR needs the P010 capture path)");
} }
// SAFETY: `ensure_staging` builds a B8G8R8A8 STAGING texture on `frame.device` and caches that
// device's immediate context; `src`/`dst` are that device's textures of matching BGRA format,
// so `CopyResource` on the single-threaded context is valid. `Map(READ)` on the staging texture
// yields `base` valid for `pitch` × `h` rows. `ensure_sws` lazily builds the BGRA→NV12 context;
// `sws_scale` reads `h` rows of `pitch` bytes from `base` (in-bounds — the staging surface is
// `≥ pitch*h`) into the `sw_frame` planes addressed by its `data`/`linesize` (allocated for
// `width`×`height` NV12). `Unmap` pairs `Map`; the cached `sws` is freed once in `Drop`. The
// mapped read region never aliases the owned encoder frame.
unsafe { unsafe {
self.ensure_staging(&frame.device, DXGI_FORMAT_B8G8R8A8_UNORM)?; self.ensure_staging(&frame.device, DXGI_FORMAT_B8G8R8A8_UNORM)?;
let staging = self.staging.clone().context("staging texture")?; let staging = self.staging.clone().context("staging texture")?;
@@ -552,6 +589,13 @@ impl SystemInner {
/// R10 shader output instead of P010. DXGI `R10G10B10A2_UNORM` (R in the low 10 bits, X2 alpha in /// R10 shader output instead of P010. DXGI `R10G10B10A2_UNORM` (R in the low 10 bits, X2 alpha in
/// the top 2) == FFmpeg `AV_PIX_FMT_X2BGR10LE`. UNTESTED on glass (no AMD/Intel Windows box). /// the top 2) == FFmpeg `AV_PIX_FMT_X2BGR10LE`. UNTESTED on glass (no AMD/Intel Windows box).
fn readback_rgb10(&mut self, frame: &D3d11Frame, pts: i64, idr: bool) -> Result<()> { fn readback_rgb10(&mut self, frame: &D3d11Frame, pts: i64, idr: bool) -> Result<()> {
// SAFETY: same shape as `readback_yuv`/`readback_bgra` — `ensure_staging` builds an
// R10G10B10A2 STAGING texture on `frame.device` and caches its immediate context; `src`/`dst`
// are that device's matching-format textures, so `CopyResource` on the single-threaded context
// is valid. `Map(READ)` yields `base` valid for `pitch` × `h` rows. `ensure_sws` builds the
// X2BGR10LE→P010 (BT.2020) context; `sws_scale` reads `h` rows of `pitch` bytes from `base`
// (in-bounds) into the `sw_frame` P010 planes (`data`/`linesize`, allocated `width`×`height`).
// `Unmap` pairs `Map`; `sws` is freed once in `Drop`. No aliasing between read and write.
unsafe { unsafe {
self.ensure_staging(&frame.device, DXGI_FORMAT_R10G10B10A2_UNORM)?; self.ensure_staging(&frame.device, DXGI_FORMAT_R10G10B10A2_UNORM)?;
let staging = self.staging.clone().context("staging texture")?; let staging = self.staging.clone().context("staging texture")?;
@@ -605,6 +649,12 @@ impl SystemInner {
let h = self.height as usize; let h = self.height as usize;
let src_row = w * format.bytes_per_pixel(); let src_row = w * format.bytes_per_pixel();
anyhow::ensure!(bytes.len() >= src_row * h, "captured buffer too small"); anyhow::ensure!(bytes.len() >= src_row * h, "captured buffer too small");
// SAFETY: `ensure_sws` lazily builds the (packed RGB/BGR)→NV12 context for this fixed src/dst
// format pair. `src_data[0] = bytes.as_ptr()` with `src_stride[0] = src_row`; the `ensure!`
// above guarantees `bytes` holds at least `src_row*h` bytes, so `sws_scale` reads `h` rows of
// `src_row` bytes in-bounds and writes the `sw_frame` NV12 planes (`data`/`linesize`, allocated
// `width`×`height`). `bytes` is borrowed for the call only and never aliases the owned
// `sw_frame`. `send` then hands `sw_frame` to the encoder.
unsafe { unsafe {
self.ensure_sws( self.ensure_sws(
pixel_to_av(sws_src(format)?), pixel_to_av(sws_src(format)?),
@@ -667,6 +717,10 @@ impl SystemInner {
impl Drop for SystemInner { impl Drop for SystemInner {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `sw_frame` is the `AVFrame` allocated in `open` (or null) — `av_frame_free` drops it
// once and nulls the pointer through the `&mut`; `sws` is the cached `SwsContext` (or null) —
// `sws_freeContext` frees it once. This `Drop` runs exactly once and `SystemInner` owns both
// exclusively, so there is no double-free or use-after-free.
unsafe { unsafe {
if !self.sw_frame.is_null() { if !self.sw_frame.is_null() {
ffi::av_frame_free(&mut self.sw_frame); ffi::av_frame_free(&mut self.sw_frame);
@@ -745,6 +799,12 @@ impl D3d11Hw {
impl Drop for D3d11Hw { impl Drop for D3d11Hw {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `D3d11Hw::new` created
// (it bails before constructing `Self` if either alloc/init fails, so a live `D3d11Hw` always
// holds both). `av_buffer_unref` drops one reference and nulls the pointer through the `&mut`.
// This `Drop` runs exactly once and `D3d11Hw` owns these refs exclusively → no double-free /
// use-after-free. Frames are unref'd before the device because the frames ctx internally holds
// a ref on the device (refcounted, so the order is sound either way).
unsafe { unsafe {
ffi::av_buffer_unref(&mut self.frames_ref); ffi::av_buffer_unref(&mut self.frames_ref);
ffi::av_buffer_unref(&mut self.device_ref); ffi::av_buffer_unref(&mut self.device_ref);
@@ -800,6 +860,18 @@ impl ZeroCopyInner {
WinVendor::Qsv => (D3D11_BIND_DECODER.0 | D3D11_BIND_VIDEO_ENCODER.0) as u32, WinVendor::Qsv => (D3D11_BIND_DECODER.0 | D3D11_BIND_VIDEO_ENCODER.0) as u32,
}; };
const POOL: c_int = 8; const POOL: c_int = 8;
// SAFETY: `D3d11Hw::new` wraps the capturer's `device` as a D3D11VA hwdevice (handing FFmpeg an
// owned AddRef of it, balanced by FFmpeg's teardown Release) and builds an owned
// device_ref/frames_ref pair freed by `D3d11Hw::Drop`; `hw` is a local, so it is dropped (and
// both refs freed) on every early `return Err`. For QSV, `av_hwdevice_ctx_create_derived` and
// `av_hwframe_ctx_create_derived` fill the null-initialised `qsv_device`/`qsv_frames` out-params
// only on success (`r >= 0` checked); on the frames-derive failure we unref the already-created
// `qsv_device` before bailing. `open_win_encoder` internally `av_buffer_ref`s the dev/frames
// refs it is given (so ownership of `hw`'s and the derived refs stays here), and on its failure
// we unref the still-owned derived `qsv_frames`/`qsv_device` (null for AMF → skipped) and return
// — `hw` then drops its D3D11 refs. On success the derived refs are moved into `ZeroCopyInner`
// (freed in its `Drop`) and the encoder holds its own AddRef'd copies. Every `AVBufferRef` is
// unref'd exactly once across all paths — no leak, no double-free.
unsafe { unsafe {
let hw = D3d11Hw::new(device, sw_av, bind_flags, width, height, POOL)?; let hw = D3d11Hw::new(device, sw_av, bind_flags, width, height, POOL)?;
let (pix_fmt, dev_ref, frames_ref, mut qsv_device, mut qsv_frames) = match vendor { let (pix_fmt, dev_ref, frames_ref, mut qsv_device, mut qsv_frames) = match vendor {
@@ -887,6 +959,19 @@ impl ZeroCopyInner {
} }
fn submit(&mut self, frame: &D3d11Frame, pts: i64, idr: bool) -> Result<()> { fn submit(&mut self, frame: &D3d11Frame, pts: i64, idr: bool) -> Result<()> {
// SAFETY: `d3d = av_frame_alloc()` is a fresh owned frame (null-checked) and is `av_frame_free`d
// exactly once on every path below. `av_hwframe_get_buffer` fills it from the pool — on failure
// we free it and bail. `(*d3d).data[0]` is the pool's texture-array and `data[1]` the array
// index; `from_raw_borrowed` borrows that `ID3D11Texture2D` WITHOUT taking ownership (no Release
// — the frame owns it) and is null-checked. `src` (the captured texture) and `dst` (the pooled
// slice) live on the SAME D3D11 device wrapped by `self.hw`, and the caller guarantees
// `captured.format == pool_format` before calling, so `CopySubresourceRegion(dst, dst_index, ..,
// src, 0, ..)` on the single-threaded immediate context `self.ctx` is a valid same-format GPU
// copy. For QSV the mapped `qsv` frame is a fresh owned frame whose `hw_frames_ctx` takes an
// `av_buffer_ref` of `self.qsv_frames`; it is `av_frame_free`d (releasing that ref) on both the
// map-failure and success paths. `avcodec_send_frame` only internally refs the input frame, so
// the `av_frame_free(d3d)`/`av_frame_free(qsv)` afterwards are the sole owning frees — no leak,
// no double-free, no use-after-free.
unsafe { unsafe {
// Pull a pooled D3D11 surface; its data[0] is the pool's texture-ARRAY, data[1] the slice. // Pull a pooled D3D11 surface; its data[0] is the pool's texture-ARRAY, data[1] the slice.
let mut d3d = ffi::av_frame_alloc(); let mut d3d = ffi::av_frame_alloc();
@@ -959,6 +1044,11 @@ impl ZeroCopyInner {
impl Drop for ZeroCopyInner { impl Drop for ZeroCopyInner {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `qsv_frames`/`qsv_device` are the derived QSV `AVBufferRef`s (or null for AMF); each
// is `av_buffer_unref`'d once here (nulling the pointer through the `&mut`) — `ZeroCopyInner`
// owns these handles exclusively and this `Drop` runs once, so no double-free. The `enc` and
// `hw` fields free the encoder's AddRef'd copies and the D3D11 device/frames refs through their
// own `Drop`, so all references stay balanced.
unsafe { unsafe {
if !self.qsv_frames.is_null() { if !self.qsv_frames.is_null() {
ffi::av_buffer_unref(&mut self.qsv_frames); ffi::av_buffer_unref(&mut self.qsv_frames);
@@ -996,6 +1086,13 @@ pub struct FfmpegWinEncoder {
} }
// Raw FFI pointers + COM objects; the encoder lives on a single thread (same contract as NVENC/VAAPI). // Raw FFI pointers + COM objects; the encoder lives on a single thread (same contract as NVENC/VAAPI).
// SAFETY: `FfmpegWinEncoder` owns raw libav pointers (`AVFrame`/`SwsContext`/`AVBufferRef`) and
// windows-rs COM handles (`ID3D11Device`/`ID3D11DeviceContext`/textures) that are not auto-`Send`. The
// session creates the encoder, drives `submit`/`poll`/`flush`, and drops it all on one dedicated encode
// thread; it is never shared by reference across threads, and the D3D11 immediate context is only ever
// touched from that thread. The only cross-thread action is the initial move to the encode thread,
// after which every interior pointer/COM ref is used single-threaded — the same contract the
// NVENC/VAAPI encoders rely on. No interior state is accessed concurrently.
unsafe impl Send for FfmpegWinEncoder {} unsafe impl Send for FfmpegWinEncoder {}
impl FfmpegWinEncoder { impl FfmpegWinEncoder {
@@ -1012,6 +1109,8 @@ impl FfmpegWinEncoder {
) -> Result<Self> { ) -> Result<Self> {
ffmpeg::init().context("ffmpeg init")?; ffmpeg::init().context("ffmpeg init")?;
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() { if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
// SAFETY: `ffmpeg::init()` ran on the line above, so libav is initialised; `av_log_set_level`
// is a global scalar setter with no pointer arguments.
unsafe { ffi::av_log_set_level(48) }; unsafe { ffi::av_log_set_level(48) };
} }
// Make sure the encoder name exists in this libavcodec build up front (clear error vs a // Make sure the encoder name exists in this libavcodec build up front (clear error vs a
@@ -13,7 +13,10 @@
//! Needs a real NVIDIA GPU at runtime (session creation fails otherwise) — compiles GPU-less, but //! Needs a real NVIDIA GPU at runtime (session creation fails otherwise) — compiles GPU-less, but
//! `open`/`submit` only succeed on a GPU box. The software encoder (`super::sw`) is the fallback. //! `open`/`submit` only succeed on a GPU box. The software encoder (`super::sw`) is the fallback.
use super::{Codec, EncodedFrame, Encoder}; // Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{Codec, EncodedFrame, Encoder, EncoderCaps};
use crate::capture::{CapturedFrame, FramePayload, PixelFormat}; use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
@@ -88,7 +91,15 @@ pub struct NvencD3d11Encoder {
init_device: *mut c_void, init_device: *mut c_void,
} }
// Raw NVENC handle + COM ptrs; confined to the single encode thread (like the Linux encoder). // SAFETY: the `!Send` fields are the raw NVENC session/device handles (`encoder`, `init_device`),
// the raw NVENC bitstream/registered/mapped pointers carried in `bitstreams`/`regs`/`pending`, and
// the `ID3D11Texture2D` COM refs — none of which may be touched concurrently from two threads. This
// encoder is owned by exactly one thread: it is moved onto the host encode thread once at
// construction, and every NVENC call and D3D11 access happens only from that thread thereafter
// (`submit`/`poll`/`invalidate_ref_frames`/`Drop` all run there, like the Linux encoder). Moving the
// handles across that single ownership-transfer boundary is sound because no NVENC/D3D11 call is in
// flight during the move and the session and its D3D11 immediate context are never shared (`&`) or
// used concurrently — so `Send` introduces no data race on the non-`Send` fields.
unsafe impl Send for NvencD3d11Encoder {} unsafe impl Send for NvencD3d11Encoder {}
impl NvencD3d11Encoder { impl NvencD3d11Encoder {
@@ -403,6 +414,17 @@ impl NvencD3d11Encoder {
/// Lazily create the session on the first frame's D3D11 device (so capture + encode share it). /// Lazily create the session on the first frame's D3D11 device (so capture + encode share it).
fn init_session(&mut self, device: &ID3D11Device) -> Result<()> { fn init_session(&mut self, device: &ID3D11Device) -> Result<()> {
// SAFETY: every call below goes through a function pointer resolved once from the loaded
// `nvidia_video_codec_sdk::ENCODE_API` (`nvEncodeAPI`) table, or through this type's own
// `unsafe fn`s whose contract is met here. `query_caps`/`try_open_session` receive `device`,
// the live `ID3D11Device` the caller pulled off the first frame; each returns either a valid
// open NVENC session handle or an `Err`. `destroy_encoder` is only ever called on a handle a
// `try_open_session` just returned (and `best` only when `!best.is_null()`), so it never frees
// a dangling or null session. `create_bitstream_buffer` is passed `enc` — the one chosen live
// session — and `&mut cb`, a `#[repr(C)] NV_ENC_CREATE_BITSTREAM_BUFFER` whose `version` is set
// to `NV_ENC_CREATE_BITSTREAM_BUFFER_VER`; `cb` lives across the synchronous call and its
// returned `bitstreamBuffer` is copied into `self.bitstreams` before `cb` drops. No handle
// escapes the encode thread.
unsafe { unsafe {
// Probe real GPU caps first (max dims / 10-bit / custom-VBV / RFI) so the config below is // Probe real GPU caps first (max dims / 10-bit / custom-VBV / RFI) so the config below is
// gated on what this card supports and an out-of-range mode fails with a clear error // gated on what this card supports and an out-of-range mode fails with a clear error
@@ -589,6 +611,11 @@ impl Encoder for NvencD3d11Encoder {
new = format!("{}x{}", captured.width, captured.height), new = format!("{}x{}", captured.width, captured.height),
"NVENC: capture device/size/HDR changed — re-initializing session" "NVENC: capture device/size/HDR changed — re-initializing session"
); );
// SAFETY: `teardown` (an `unsafe fn`) requires the encode thread with no NVENC call in
// flight and a session whose cached regs/bitstreams/pending all belong to `self.encoder`.
// All hold: this is the synchronous encode thread, `self.inited` so `self.encoder` is the
// live session every cached resource was created against, and the previous frame's encode
// has already been polled (synchronous submit→poll), so nothing is mid-encode.
unsafe { self.teardown() }; unsafe { self.teardown() };
} }
if !self.inited { if !self.inited {
@@ -609,7 +636,14 @@ impl Encoder for NvencD3d11Encoder {
self.bit_depth = 10; self.bit_depth = 10;
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10 nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
} }
PixelFormat::Nv12 => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12, PixelFormat::Nv12 => {
// NV12 is 8-bit 4:2:0. Force 8-bit so a transition from a prior P010 (10-bit) session
// — or a 10-bit-negotiated client on an SDR display — re-inits at the matching depth.
// Unlike ARGB (which NVENC upconverts to Main10), NV12 cannot feed a 10-bit session:
// `register_resource` rejects it as InvalidParam (the HDR→SDR-toggle stream drop).
self.bit_depth = 8;
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12
}
_ => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB, _ => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB,
}; };
let device = frame.device.clone(); let device = frame.device.clone();
@@ -618,6 +652,21 @@ impl Encoder for NvencD3d11Encoder {
} }
let slot = self.next % POOL; let slot = self.next % POOL;
self.next += 1; self.next += 1;
// SAFETY: every NVENC call goes through a function pointer from the loaded `ENCODE_API` table
// and takes `self.encoder`, the live session `init_session` just established (non-null on the
// path that reaches here). `NV_ENC_REGISTER_RESOURCE rr` has `version =
// NV_ENC_REGISTER_RESOURCE_VER` and registers `frame.texture` — a D3D11 texture from
// `frame.device`, which is the SAME device the session was opened against (any device change
// tears down and re-inits above, so `init_device == frame.device.as_raw()` here); the cloned
// `ID3D11Texture2D` is kept alive in `regs` so NVENC's registration never outlives the texture.
// `mp` (`NV_ENC_MAP_INPUT_RESOURCE`, version set) maps that registration and the map is recorded
// in `pending` to be unmapped exactly once in `poll`/`teardown`. `pic` (`NV_ENC_PIC_PARAMS`,
// version set) points `inputBuffer` at `mp.mappedResource` and `outputBitstream` at the live
// pool bitstream `bitstreams[slot]`; the optional SEI scratch (`mastering_sei`/`cll_sei` and the
// `sei` Vec whose `as_mut_ptr()` is written into the codec union) are stack locals that outlive
// the synchronous `encode_picture`. Every `#[repr(C)]` param is a live local borrowed `&mut`
// for the duration of its one synchronous call. (In-place encode without `CopyResource` is
// sound because the encode loop is synchronous, as the module docs state.)
unsafe { unsafe {
// Register the capturer's texture with NVENC once (cached by raw pointer), then encode it // Register the capturer's texture with NVENC once (cached by raw pointer), then encode it
// IN PLACE — no `CopyResource` into an encoder-owned pool. This is the zero-copy win: the // IN PLACE — no `CopyResource` into an encoder-owned pool. This is the zero-copy win: the
@@ -732,6 +781,15 @@ impl Encoder for NvencD3d11Encoder {
self.force_kf = true; self.force_kf = true;
} }
fn caps(&self) -> EncoderCaps {
// RFI is probed once at open (`rfi_supported`); HDR SEI rides keyframes whenever the
// session is in HDR mode. Both are the real capabilities the session glue routes on.
EncoderCaps {
supports_rfi: self.rfi_supported,
supports_hdr_metadata: self.hdr,
}
}
fn set_hdr_meta(&mut self, meta: Option<punktfunk_core::quic::HdrMeta>) { fn set_hdr_meta(&mut self, meta: Option<punktfunk_core::quic::HdrMeta>) {
// Stored and emitted as in-band SEI on the next keyframe (see `submit`). Cheap to call every // Stored and emitted as in-band SEI on the next keyframe (see `submit`). Cheap to call every
// frame; only changes when the source is regraded or HDR toggles. // frame; only changes when the source is regraded or HDR toggles.
@@ -765,6 +823,12 @@ impl Encoder for NvencD3d11Encoder {
// We tag each input with `inputTimeStamp = frame_idx` (0,1,2,…), which is also the client's // We tag each input with `inputTimeStamp = frame_idx` (0,1,2,…), which is also the client's
// frame number (the packetizer numbers frames in submit order), so the client's lost-frame // frame number (the packetizer numbers frames in submit order), so the client's lost-frame
// range maps 1:1 onto the timestamps NVENC invalidates here. // range maps 1:1 onto the timestamps NVENC invalidates here.
// SAFETY: `invalidate_ref_frames` is a function pointer from the loaded `ENCODE_API` table.
// `self.encoder` was checked non-null at the top of this fn and is the live session; this runs
// on the encode thread (like submit/poll), so there is no concurrent NVENC use. Each `ts` was
// clamped to `[oldest_in_dpb, frame_idx - 1]` above, so it names a frame still in the session's
// DPB; the call passes only that `u64` timestamp (no struct), so there is no struct-size or
// lifetime concern.
unsafe { unsafe {
for ts in first..=last { for ts in first..=last {
if (API.invalidate_ref_frames)(self.encoder, ts as u64) if (API.invalidate_ref_frames)(self.encoder, ts as u64)
@@ -783,6 +847,16 @@ impl Encoder for NvencD3d11Encoder {
let Some((bs, map, pts_ns)) = self.pending.pop_front() else { let Some((bs, map, pts_ns)) = self.pending.pop_front() else {
return Ok(None); return Ok(None);
}; };
// SAFETY: a non-empty `pending` implies `submit` ran, so `self.encoder` is the live session
// (`teardown` clears `pending` whenever it nulls the handle); all calls below use function
// pointers from the loaded `ENCODE_API` table on the encode thread. `NV_ENC_LOCK_BITSTREAM lock`
// (version = `NV_ENC_LOCK_BITSTREAM_VER`) locks `bs`, a pool bitstream a prior `encode_picture`
// targeted; `lock_bitstream` blocks until that encode finishes, so on success
// `lock.bitstreamBufferPtr` is non-null and points at `lock.bitstreamSizeInBytes` bytes of
// NVENC-owned, CPU-readable output valid until `unlock_bitstream`. The `from_raw_parts` slice is
// only read (copied via `to_vec()`) BEFORE `unlock_bitstream(bs)` — lock and unlock pair on the
// same buffer — so it never outlives the lock. `map` (the input resource paired with `bs` in
// `pending`) is unmapped here, after the encode completed, exactly once.
unsafe { unsafe {
let mut lock = nv::NV_ENC_LOCK_BITSTREAM { let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
version: nv::NV_ENC_LOCK_BITSTREAM_VER, version: nv::NV_ENC_LOCK_BITSTREAM_VER,
@@ -822,6 +896,11 @@ impl Encoder for NvencD3d11Encoder {
impl Drop for NvencD3d11Encoder { impl Drop for NvencD3d11Encoder {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `teardown` (an `unsafe fn`) needs the owning thread with no NVENC call in flight and
// a session whose cached resources all belong to `self.encoder`. At Drop this encoder is owned
// exclusively (no other reference can exist), runs on the encode thread it was confined to, and
// `teardown` early-returns when `self.encoder` is null; otherwise every cached reg/bitstream/
// pending was created against that live session. It runs exactly once (here).
unsafe { self.teardown() }; unsafe { self.teardown() };
} }
} }
@@ -2,6 +2,8 @@
//! fallback when NVENC is unavailable). Low-latency screen-content config: single-reference, //! fallback when NVENC is unavailable). Low-latency screen-content config: single-reference,
//! no B-frames (Baseline), bitrate rate-control, in-band SPS/PPS each IDR, BT.709 limited range. //! no B-frames (Baseline), bitrate rate-control, in-band SPS/PPS each IDR, BT.709 limited range.
//! 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).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{EncodedFrame, Encoder}; use super::{EncodedFrame, Encoder};
use crate::capture::{CapturedFrame, FramePayload, PixelFormat}; use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
@@ -30,6 +32,12 @@ pub struct OpenH264Encoder {
} }
// openh264's Encoder holds a raw C handle (not auto-Send); it lives on the single encode thread. // openh264's Encoder holds a raw C handle (not auto-Send); it lives on the single encode thread.
// SAFETY: `OpenH264Encoder` wraps `Oh264` (openh264's `Encoder`), which holds a raw C handle to the
// openh264 `ISVCEncoder` and is not auto-`Send`; the other fields (`YUVBuffer`, `Vec`, scalars,
// `Option<EncodedFrame>`) are plain owned data. The session creates the encoder, calls
// `submit`/`poll`/`flush`, and drops it all on one dedicated encode thread, never sharing it by
// reference across threads, so the C handle is only ever touched from a single thread. Moving the
// whole value to that thread is therefore sound — there is no concurrent access to the handle.
unsafe impl Send for OpenH264Encoder {} unsafe impl Send for OpenH264Encoder {}
impl OpenH264Encoder { impl OpenH264Encoder {
+48 -1
View File
@@ -17,6 +17,9 @@
//! data packets are consumed immediately and missing parity only costs loss recovery — so //! data packets are consumed immediately and missing parity only costs loss recovery — so
//! the validated stereo path stays byte-identical (data packets only, exactly as before). //! the validated stereo path stays byte-identical (data packets only, exactly as before).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
#[cfg(any(target_os = "linux", target_os = "windows", test))] #[cfg(any(target_os = "linux", target_os = "windows", test))]
use crate::audio::SAMPLE_RATE; use crate::audio::SAMPLE_RATE;
#[cfg(any(target_os = "linux", target_os = "windows"))] #[cfg(any(target_os = "linux", target_os = "windows"))]
@@ -409,7 +412,10 @@ struct MsEncoder {
st: std::ptr::NonNull<audiopus_sys::OpusMSEncoder>, st: std::ptr::NonNull<audiopus_sys::OpusMSEncoder>,
} }
// The raw encoder state has no thread affinity; the session owns it on one thread at a time. // SAFETY: `MsEncoder` owns a unique `OpusMSEncoder` via `NonNull` (it is neither `Clone` nor
// `Sync`, so the pointer is never aliased). libopus's multistream encoder state is a self-contained
// heap allocation with no thread-local or thread-affine state, so moving ownership to another thread
// is sound; every method takes `&mut self`, keeping access single-threaded at any instant.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
unsafe impl Send for MsEncoder {} unsafe impl Send for MsEncoder {}
@@ -418,6 +424,13 @@ impl MsEncoder {
fn new(layout: &OpusLayout) -> Result<MsEncoder> { fn new(layout: &OpusLayout) -> Result<MsEncoder> {
use std::os::raw::c_int; use std::os::raw::c_int;
let mut err: c_int = 0; let mut err: c_int = 0;
// SAFETY: every scalar arg is a valid libopus input (sample rate, channel/stream/coupled
// counts, the RESTRICTED_LOWDELAY application constant). `layout.mapping.as_ptr()` addresses
// a 'static slice of exactly `layout.channels` bytes (every `OpusLayout` constant upholds
// that), which is the element count `opus_multistream_encoder_create` reads through it, and
// `&mut err` is a live local the call writes its status into. libopus copies the mapping into
// its own allocation, so the pointer need only be valid for the call; the returned pointer is
// null/`OPUS_OK`-checked below before any use.
let st = unsafe { let st = unsafe {
audiopus_sys::opus_multistream_encoder_create( audiopus_sys::opus_multistream_encoder_create(
SAMPLE_RATE as i32, SAMPLE_RATE as i32,
@@ -432,6 +445,11 @@ impl MsEncoder {
let st = std::ptr::NonNull::new(st) let st = std::ptr::NonNull::new(st)
.filter(|_| err == audiopus_sys::OPUS_OK) .filter(|_| err == audiopus_sys::OPUS_OK)
.ok_or_else(|| anyhow::anyhow!("opus_multistream_encoder_create failed ({err})"))?; .ok_or_else(|| anyhow::anyhow!("opus_multistream_encoder_create failed ({err})"))?;
// SAFETY: `st` is the non-null encoder `opus_multistream_encoder_create` just returned, owned
// exclusively here. Each `opus_multistream_encoder_ctl` call passes a valid request constant
// with the single by-value `c_int` argument that request's variadic ABI expects
// (`OPUS_SET_BITRATE_REQUEST` → bitrate, `OPUS_SET_VBR_REQUEST` → 0). No pointer escapes the
// call and the encoder outlives it.
unsafe { unsafe {
audiopus_sys::opus_multistream_encoder_ctl( audiopus_sys::opus_multistream_encoder_ctl(
st.as_ptr(), st.as_ptr(),
@@ -453,6 +471,13 @@ impl MsEncoder {
samples_per_channel: usize, samples_per_channel: usize,
out: &mut [u8], out: &mut [u8],
) -> Result<usize> { ) -> Result<usize> {
// SAFETY: `self.st` is the live encoder from `new`. libopus reads `samples_per_channel *
// channels` f32s through `frame.as_ptr()`; every caller passes a `frame` of exactly that
// length together with the matching `samples_per_channel` (`audio_body`'s `frame_len =
// samples_per_channel * layout.channels`; the round-trip tests size identically), so the read
// stays in bounds. `out.as_mut_ptr()` is written for at most `out.len()` bytes, which is
// passed as the capacity bound. Both buffers are live locals outliving this synchronous call;
// the return value is range-checked before being used as a length.
let n = unsafe { let n = unsafe {
audiopus_sys::opus_multistream_encode_float( audiopus_sys::opus_multistream_encode_float(
self.st.as_ptr(), self.st.as_ptr(),
@@ -470,6 +495,9 @@ impl MsEncoder {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
impl Drop for MsEncoder { impl Drop for MsEncoder {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `self.st` is the encoder `opus_multistream_encoder_create` returned; this
// `MsEncoder` owns it uniquely and `drop` runs exactly once, so the destroy frees it once
// with no subsequent use.
unsafe { audiopus_sys::opus_multistream_encoder_destroy(self.st.as_ptr()) } unsafe { audiopus_sys::opus_multistream_encoder_destroy(self.st.as_ptr()) }
} }
} }
@@ -761,6 +789,10 @@ mod tests {
let client_mapping = client_swap(&digits[3..]); let client_mapping = client_swap(&digits[3..]);
let mut err = 0i32; let mut err = 0i32;
// SAFETY: scalar args are valid libopus inputs. `client_mapping.as_ptr()` addresses a
// `Vec<u8>` of exactly `ch` entries (derived from the advertised surround-params), which is
// the element count the decoder reads through it, and `&mut err` is a live local the call
// writes. The returned pointer is `OPUS_OK`/non-null-checked immediately below before use.
let dec = unsafe { let dec = unsafe {
audiopus_sys::opus_multistream_decoder_create( audiopus_sys::opus_multistream_decoder_create(
SAMPLE_RATE as i32, SAMPLE_RATE as i32,
@@ -789,6 +821,11 @@ mod tests {
} }
let n = enc.encode_float(&frame, samples, &mut out).unwrap(); let n = enc.encode_float(&frame, samples, &mut out).unwrap();
assert!(n > 0); assert!(n > 0);
// SAFETY: `dec` is the non-null decoder asserted above. `out.as_ptr()` is read for
// the `n` encoded bytes just produced by `encode_float`; `decoded.as_mut_ptr()` is
// written for up to `samples * ch` f32s and `decoded` is exactly that long; `samples`
// is the per-channel frame size. All buffers are live locals outliving the call; the
// return is checked to equal `samples`.
let got = unsafe { let got = unsafe {
audiopus_sys::opus_multistream_decode_float( audiopus_sys::opus_multistream_decode_float(
dec, dec,
@@ -817,6 +854,8 @@ mod tests {
(energies: {energy:?})" (energies: {energy:?})"
); );
} }
// SAFETY: `dec` is the decoder `opus_multistream_decoder_create` returned; the test owns it
// and destroys it exactly once here, after the final decode — no later use, no double free.
unsafe { audiopus_sys::opus_multistream_decoder_destroy(dec) }; unsafe { audiopus_sys::opus_multistream_decoder_destroy(dec) };
} }
@@ -853,6 +892,9 @@ mod tests {
let digits: Vec<u8> = s.bytes().map(|b| b - b'0').collect(); let digits: Vec<u8> = s.bytes().map(|b| b - b'0').collect();
let client_mapping = client_swap(&digits[3..]); let client_mapping = client_swap(&digits[3..]);
let mut err = 0i32; let mut err = 0i32;
// SAFETY: scalar args are valid; `client_mapping.as_ptr()` addresses a 6-entry `Vec<u8>`
// (matches the 6-channel layout the decoder reads through it), alive past the call, and
// `&mut err` is a live local. The pointer is `OPUS_OK`-checked before use.
let dec = unsafe { let dec = unsafe {
audiopus_sys::opus_multistream_decoder_create( audiopus_sys::opus_multistream_decoder_create(
48000, 48000,
@@ -865,6 +907,10 @@ mod tests {
}; };
assert_eq!(err, audiopus_sys::OPUS_OK); assert_eq!(err, audiopus_sys::OPUS_OK);
let mut pcm = vec![0f32; 240 * 6]; let mut pcm = vec![0f32; 240 * 6];
// SAFETY: `dec` is the non-null decoder from create. `out.as_ptr()` is read for the CBR
// packet length passed in (`*sizes.first()`, a real encoded packet size in `out`);
// `pcm.as_mut_ptr()` is written for up to `240 * 6` f32s and `pcm` is exactly that long;
// `240` is the per-channel frame size. All buffers are live locals outliving the call.
let got = unsafe { let got = unsafe {
audiopus_sys::opus_multistream_decode_float( audiopus_sys::opus_multistream_decode_float(
dec, dec,
@@ -875,6 +921,7 @@ mod tests {
0, 0,
) )
}; };
// SAFETY: `dec` is owned by the test; destroyed exactly once here after the final decode.
unsafe { audiopus_sys::opus_multistream_decoder_destroy(dec) }; unsafe { audiopus_sys::opus_multistream_decoder_destroy(dec) };
assert_eq!(got, 240); assert_eq!(got, 240);
} }
@@ -1,7 +1,7 @@
//! Pairing crypto primitives (control plane only — distinct from `punktfunk_core`'s AES-GCM //! Pairing crypto primitives (control plane only — distinct from `punktfunk_core`'s AES-GCM
//! data-plane sealing). GameStream pairing uses: AES-128-**ECB** with **no padding**, //! data-plane sealing). GameStream pairing uses: AES-128-**ECB** with **no padding**,
//! SHA-256 (host appversion major ≥ 7), and RSA-PKCS1v15-SHA256 signatures. See the //! SHA-256 (host appversion major ≥ 7), and RSA-PKCS1v15-SHA256 signatures. See the
//! `serverinfo + pairing` section of `docs/research/gamestream-protocol-research.json`. //! `serverinfo + pairing` section of `design/research/gamestream-protocol-research.json`.
use aes::cipher::generic_array::GenericArray; use aes::cipher::generic_array::GenericArray;
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit}; use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit};
+21 -8
View File
@@ -1,7 +1,7 @@
//! GameStream (P1) control plane — what a stock Moonlight/Artemis client talks to around //! GameStream (P1) control plane — what a stock Moonlight/Artemis client talks to around
//! the media streams: mDNS discovery, the nvhttp serverinfo + pairing HTTP(S) API, RTSP, //! the media streams: mDNS discovery, the nvhttp serverinfo + pairing HTTP(S) API, RTSP,
//! and the ENet control stream. `tokio`/`axum` live here (control plane, I/O-bound — never //! and the ENet control stream. `tokio`/`axum` live here (control plane, I/O-bound — never
//! the per-frame hot path; that is `punktfunk_core`'s P1 wire codec). See `docs/gamestream-host-plan.md`. //! the per-frame hot path; that is `punktfunk_core`'s P1 wire codec). See `design/gamestream-host-plan.md`.
//! //!
//! Status: P1.1 — mDNS `_nvstream._tcp` advertisement + `/serverinfo`. Pairing, RTSP, and //! Status: P1.1 — mDNS `_nvstream._tcp` advertisement + `/serverinfo`. Pairing, RTSP, and
//! the media streams follow (see the GameStream host task list / plan). //! the media streams follow (see the GameStream host task list / plan).
@@ -125,12 +125,21 @@ pub struct AppState {
/// (avoids a PipeWire stream setup per reconnect); drained on reuse so no stale audio is /// (avoids a PipeWire stream setup per reconnect); drained on reuse so no stale audio is
/// sent, dropped + reopened when a session negotiates a different channel count. /// sent, dropped + reopened when a session negotiates a different channel count.
pub audio_cap: std::sync::Arc<std::sync::Mutex<Option<Box<dyn crate::audio::AudioCapturer>>>>, pub audio_cap: std::sync::Arc<std::sync::Mutex<Option<Box<dyn crate::audio::AudioCapturer>>>>,
/// Shared streaming-stats recorder (web-console capture/graph). The GameStream encode loop
/// reads `is_armed()` per frame and emits samples; the same `Arc` is shared with the mgmt API
/// and the native punktfunk/1 loops so one capture spans whichever path is streaming.
pub stats: Arc<crate::stats_recorder::StatsRecorder>,
} }
impl AppState { impl AppState {
/// Fresh control-plane state: no active session; the pairing allow-list is loaded from /// Fresh control-plane state: no active session; the pairing allow-list is loaded from
/// disk (pairings persist across restarts). /// disk (pairings persist across restarts). `stats` is the shared recorder handed to both the
pub fn new(host: Host, identity: cert::ServerIdentity) -> AppState { /// mgmt API and the streaming loops.
pub fn new(
host: Host,
identity: cert::ServerIdentity,
stats: Arc<crate::stats_recorder::StatsRecorder>,
) -> AppState {
AppState { AppState {
host, host,
identity, identity,
@@ -145,6 +154,7 @@ impl AppState {
rfi_range: std::sync::Arc::new(std::sync::Mutex::new(None)), rfi_range: std::sync::Arc::new(std::sync::Mutex::new(None)),
video_cap: std::sync::Arc::new(std::sync::Mutex::new(None)), video_cap: std::sync::Arc::new(std::sync::Mutex::new(None)),
audio_cap: std::sync::Arc::new(std::sync::Mutex::new(None)), audio_cap: std::sync::Arc::new(std::sync::Mutex::new(None)),
stats,
} }
} }
} }
@@ -166,7 +176,10 @@ pub fn serve(
) -> Result<()> { ) -> Result<()> {
let host = Host::detect()?; let host = Host::detect()?;
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?; let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
let state = Arc::new(AppState::new(host, identity)); // The shared streaming-stats recorder: one handle for the mgmt API, the GameStream encode loop
// (via `AppState`), and the native punktfunk/1 loops (passed to `punktfunk1::serve`).
let stats = crate::stats_recorder::StatsRecorder::new(crate::stats_recorder::default_dir());
let state = Arc::new(AppState::new(host, identity, stats.clone()));
// The native plane always runs, so the shared native-pairing handle (linking the QUIC ceremony // The native plane always runs, so the shared native-pairing handle (linking the QUIC ceremony
// and the management API) always exists. // and the management API) always exists.
let np = Arc::new( let np = Arc::new(
@@ -206,8 +219,8 @@ pub fn serve(
); );
tokio::try_join!( tokio::try_join!(
nvhttp::run(state.clone()), nvhttp::run(state.clone()),
crate::mgmt::run(state.clone(), mgmt, Some(np.clone())), crate::mgmt::run(state.clone(), mgmt, Some(np.clone()), stats.clone()),
crate::punktfunk1::serve(native_opts, np), crate::punktfunk1::serve(native_opts, np, stats.clone()),
)?; )?;
} else { } else {
// Secure default: native punktfunk/1 + management API only (no GameStream surface). // Secure default: native punktfunk/1 + management API only (no GameStream surface).
@@ -217,8 +230,8 @@ pub fn serve(
(GameStream OFF — pass --gamestream for stock-Moonlight compat)" (GameStream OFF — pass --gamestream for stock-Moonlight compat)"
); );
tokio::try_join!( tokio::try_join!(
crate::mgmt::run(state.clone(), mgmt, Some(np.clone())), crate::mgmt::run(state.clone(), mgmt, Some(np.clone()), stats.clone()),
crate::punktfunk1::serve(native_opts, np), crate::punktfunk1::serve(native_opts, np, stats.clone()),
)?; )?;
} }
Ok(()) Ok(())
@@ -291,7 +291,10 @@ mod tests {
https_port: HTTPS_PORT, https_port: HTTPS_PORT,
}; };
let identity = super::super::cert::ServerIdentity::ephemeral().expect("ephemeral identity"); let identity = super::super::cert::ServerIdentity::ephemeral().expect("ephemeral identity");
Arc::new(AppState::new(host, identity)) let stats = crate::stats_recorder::StatsRecorder::new(
std::env::temp_dir().join(format!("pf-nvhttp-stats-{}", std::process::id())),
);
Arc::new(AppState::new(host, identity, stats))
} }
fn fp_of(der: &[u8]) -> String { fn fp_of(der: &[u8]) -> String {
@@ -1,7 +1,7 @@
//! The 4-phase GameStream pairing state machine (over HTTP), keyed by `uniqueid`. Proves //! The 4-phase GameStream pairing state machine (over HTTP), keyed by `uniqueid`. Proves
//! both sides know the PIN (via the SHA-256(salt||pin) AES-ECB key) and own their certs //! both sides know the PIN (via the SHA-256(salt||pin) AES-ECB key) and own their certs
//! (RSA signatures), then pins the client cert. The final `pairchallenge` happens over //! (RSA signatures), then pins the client cert. The final `pairchallenge` happens over
//! HTTPS (handled in `nvhttp`). Byte-exact spec: `docs/research/…-research.json`. //! HTTPS (handled in `nvhttp`). Byte-exact spec: `design/research/…-research.json`.
use super::cert::ServerIdentity; use super::cert::ServerIdentity;
use super::crypto; use super::crypto;
@@ -234,6 +234,7 @@ fn handle_request(req: &Request, state: &AppState) -> String {
state.force_idr.clone(), state.force_idr.clone(),
state.rfi_range.clone(), state.rfi_range.clone(),
state.video_cap.clone(), state.video_cap.clone(),
state.stats.clone(),
); );
} }
Some(_) => tracing::info!("RTSP PLAY — stream already running"), Some(_) => tracing::info!("RTSP PLAY — stream already running"),
+324 -42
View File
@@ -3,6 +3,9 @@
//! either real portal desktop capture (`PUNKTFUNK_VIDEO_SOURCE=portal`, the portal PipeWire path) or //! either real portal desktop capture (`PUNKTFUNK_VIDEO_SOURCE=portal`, the portal PipeWire path) or
//! a synthetic test pattern (default). Runs on its own native thread. //! a synthetic test pattern (default). Runs on its own native thread.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::video::{FrameType, VideoPacketizer}; use super::video::{FrameType, VideoPacketizer};
use super::VIDEO_PORT; use super::VIDEO_PORT;
use crate::capture::{self, Capturer, FastSyntheticCapturer}; use crate::capture::{self, Capturer, FastSyntheticCapturer};
@@ -45,6 +48,7 @@ pub fn start(
force_idr: Arc<AtomicBool>, force_idr: Arc<AtomicBool>,
rfi_range: RfiSlot, rfi_range: RfiSlot,
video_cap: CapturerSlot, video_cap: CapturerSlot,
stats: Arc<crate::stats_recorder::StatsRecorder>,
) { ) {
let _ = std::thread::Builder::new() let _ = std::thread::Builder::new()
.name("punktfunk-video".into()) .name("punktfunk-video".into())
@@ -57,6 +61,7 @@ pub fn start(
&force_idr, &force_idr,
&rfi_range, &rfi_range,
&video_cap, &video_cap,
&stats,
) { ) {
tracing::error!(error = %format!("{e:#}"), "video stream failed"); tracing::error!(error = %format!("{e:#}"), "video stream failed");
} }
@@ -65,6 +70,7 @@ pub fn start(
}); });
} }
#[allow(clippy::too_many_arguments)]
fn run( fn run(
cfg: StreamConfig, cfg: StreamConfig,
app: Option<&super::apps::AppEntry>, app: Option<&super::apps::AppEntry>,
@@ -72,6 +78,9 @@ fn run(
force_idr: &AtomicBool, force_idr: &AtomicBool,
rfi_range: &std::sync::Mutex<Option<(i64, i64)>>, rfi_range: &std::sync::Mutex<Option<(i64, i64)>>,
video_cap: &std::sync::Mutex<Option<Box<dyn Capturer>>>, video_cap: &std::sync::Mutex<Option<Box<dyn Capturer>>>,
// Shared stats recorder for the web-console capture/graph. Threaded into `stream_body` (the
// encode loop); per-frame sample emission is wired by a later pass.
stats: &Arc<crate::stats_recorder::StatsRecorder>,
) -> Result<()> { ) -> Result<()> {
// GameStream capture/encode thread: apply Windows session tuning (no-op off Windows). // GameStream capture/encode thread: apply Windows session tuning (no-op off Windows).
crate::session_tuning::on_hot_thread(); crate::session_tuning::on_hot_thread();
@@ -97,18 +106,20 @@ fn run(
sock.connect(client) sock.connect(client)
.context("connect client video endpoint")?; .context("connect client video endpoint")?;
tracing::info!(%client, "video: client endpoint learned"); tracing::info!(%client, "video: client endpoint learned");
// Short label for web-console stats captures: the client's peer IP.
let client_label = client.ip().to_string();
// Native client-resolution source: create a compositor virtual output sized to the client's // Native client-resolution source: create a compositor virtual output sized to the client's
// request and capture it (no scaling). Self-contained — deliberately NOT pooled in // request and capture it (no scaling). Self-contained — deliberately NOT pooled in
// `video_cap`, since a reconnect at a different resolution needs a freshly-sized output; the // `video_cap`, since a reconnect at a different resolution needs a freshly-sized output; the
// output is released when this capturer drops at stream end (RAII via its keepalive). // output is released when this capturer drops at stream end (RAII via its keepalive).
if std::env::var("PUNKTFUNK_VIDEO_SOURCE").as_deref() == Ok("virtual") { if crate::config::config().video_source.as_deref() == Some("virtual") {
// The launched app picks the compositor (e.g. gamescope for game entries) and the // Open the virtual-display source: pick the live compositor, normalize the session env
// nested command. // (apply_session_env/apply_input_env — gamescope ATTACH/resize + KWin/Mutter retargeting,
let compositor = app // exactly like the native plane), create a virtual output at the client mode, and capture it.
.and_then(|a| a.compositor) // Re-runnable: the encode loop calls it again on a mid-stream capture loss to FOLLOW a
.map(Ok) // Desktop<->Game switch.
.unwrap_or_else(|| crate::vdisplay::detect().context("detect compositor"))?; let (mut capturer, compositor) = open_gs_virtual_source(cfg, app)?;
tracing::info!( tracing::info!(
?compositor, ?compositor,
app = ?app.map(|a| &a.title), app = ?app.map(|a| &a.title),
@@ -116,21 +127,41 @@ fn run(
h = cfg.height, h = cfg.height,
"video source: virtual display (native client resolution)" "video source: virtual display (native client resolution)"
); );
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?; // Launch the app's command now that capture is live, for the backends that DON'T nest it via
// Carry the resolved launch command on the backend instance (per-session) rather than a // set_launch_command above: Windows (no gamescope) and Linux kwin/mutter/wlroots (which stream
// process-global env var, so concurrent sessions can't stomp each other's launch target. // the existing desktop, so the app must be spawned into the session to land on the streamed
vd.set_launch_command(app.and_then(|a| a.cmd.clone())); // output). Linux gamescope already nested it via set_launch_command, so skip it there.
let vout = vd #[cfg(windows)]
.create(punktfunk_core::Mode { let launch_here = true;
width: cfg.width, #[cfg(target_os = "linux")]
height: cfg.height, let launch_here = compositor != crate::vdisplay::Compositor::Gamescope;
refresh_hz: cfg.fps, #[cfg(any(windows, target_os = "linux"))]
}) if launch_here {
.context("create virtual output at client resolution")?; if let Some(cmd) = app
let mut capturer = .and_then(|a| a.cmd.as_deref())
capture::capture_virtual_output(vout).context("capture virtual output")?; .filter(|c| !c.trim().is_empty())
capturer.set_active(true); {
return stream_body(&mut *capturer, &sock, cfg, running, force_idr, rfi_range); if let Err(e) = crate::library::launch_gamestream_command(cmd) {
tracing::warn!(command = %cmd, error = %e, "gamestream: could not launch app");
}
}
}
// Rebuild closure: re-open the source on a mid-stream capture loss, RE-DETECTING the live
// compositor — so a Desktop<->Game switch (at the client's fixed mode) is FOLLOWED in place
// without a Moonlight reconnect. (A resolution change can't be followed mid-stream on
// GameStream — WxH is locked at ANNOUNCE — but a session toggle keeps the negotiated mode.)
let rebuild = || open_gs_virtual_source(cfg, app).map(|(c, _)| c);
return stream_body(
&mut capturer,
Some(&rebuild),
&sock,
cfg,
running,
force_idr,
rfi_range,
stats,
&client_label,
);
} }
// Reuse the persistent capturer (one screencast session → clean reconnect); create it on // Reuse the persistent capturer (one screencast session → clean reconnect); create it on
@@ -140,7 +171,7 @@ fn run(
tracing::info!("video source: reusing capturer"); tracing::info!("video source: reusing capturer");
c c
} }
None if std::env::var("PUNKTFUNK_VIDEO_SOURCE").is_ok_and(|v| v == "portal") => { None if crate::config::config().video_source.as_deref() == Some("portal") => {
tracing::info!("video source: portal desktop capture"); tracing::info!("video source: portal desktop capture");
capture::open_portal_monitor().context("open portal capturer")? capture::open_portal_monitor().context("open portal capturer")?
} }
@@ -150,12 +181,70 @@ fn run(
} }
}; };
capturer.set_active(true); capturer.set_active(true);
let result = stream_body(&mut *capturer, &sock, cfg, running, force_idr, rfi_range); // Portal/synthetic source: no compositor virtual output to re-detect, so no rebuild closure.
let result = stream_body(
&mut capturer,
None,
&sock,
cfg,
running,
force_idr,
rfi_range,
stats,
&client_label,
);
capturer.set_active(false); capturer.set_active(false);
*video_cap.lock().unwrap() = Some(capturer); *video_cap.lock().unwrap() = Some(capturer);
result result
} }
/// Open the virtual-display video source for a GameStream session: pick the LIVE compositor + normalize
/// the session env (apply_session_env/apply_input_env — gamescope ATTACH/resize, KWin/Mutter
/// retargeting) exactly like the native plane (punktfunk1.rs resolve_compositor), create a virtual
/// output at the client's mode, and capture it. Returns the capturer (it owns the output's keepalive;
/// the stateless VirtualDisplay factory is dropped here) plus the resolved compositor. An apps.json
/// entry can PIN a compositor (skips the live detect/retarget). Re-run on a mid-stream capture loss to
/// FOLLOW a Desktop<->Game switch: it re-detects the now-live compositor and re-targets at it. Does NOT
/// launch the app (that happens once at stream start; a rebuild must not re-spawn it).
fn open_gs_virtual_source(
cfg: StreamConfig,
app: Option<&super::apps::AppEntry>,
) -> Result<(Box<dyn Capturer>, crate::vdisplay::Compositor)> {
let compositor = if let Some(c) = app.and_then(|a| a.compositor) {
c
} else {
let active = crate::vdisplay::detect_active_session();
crate::vdisplay::apply_session_env(&active);
let c = crate::vdisplay::compositor_for_kind(active.kind)
.map(Ok)
.unwrap_or_else(crate::vdisplay::detect)
.context("detect compositor")?;
crate::vdisplay::apply_input_env(c);
c
};
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
// Carry the resolved launch command on the backend instance (per-session) rather than a
// process-global env var, so concurrent sessions can't stomp each other's launch target.
vd.set_launch_command(app.and_then(|a| a.cmd.clone()));
let vout = vd
.create(punktfunk_core::Mode {
width: cfg.width,
height: cfg.height,
refresh_hz: cfg.fps,
})
.context("create virtual output at client resolution")?;
// want_hdr=false: GameStream HDR is not negotiated into StreamConfig here (the default WGC backend
// still auto-detects HDR from the output colorspace; only the opt-in IDD-push path streams SDR).
let capturer = capture::capture_virtual_output(
vout,
capture::OutputFormat::resolve(false),
crate::session_plan::CaptureBackend::resolve(),
)
.context("capture virtual output")?;
capturer.set_active(true);
Ok((capturer, compositor))
}
/// One frame's packets, handed from the encode thread to the send thread. /// One frame's packets, handed from the encode thread to the send thread.
type PacketBatch = Vec<Vec<u8>>; type PacketBatch = Vec<Vec<u8>>;
@@ -177,6 +266,10 @@ fn sendmmsg_all(sock: &UdpSocket, pkts: &[Vec<u8>]) -> std::io::Result<()> {
let mut hdrs: Vec<libc::mmsghdr> = iovs let mut hdrs: Vec<libc::mmsghdr> = iovs
.iter_mut() .iter_mut()
.map(|iov| { .map(|iov| {
// SAFETY: `libc::mmsghdr` is a plain `#[repr(C)]` struct of integers and raw
// pointers, for which an all-zero bit pattern is valid (null pointers / zero
// lengths); the fields we rely on (`msg_iov`, `msg_iovlen`) are overwritten on the
// next two lines before the struct is handed to the kernel.
let mut h: libc::mmsghdr = unsafe { std::mem::zeroed() }; let mut h: libc::mmsghdr = unsafe { std::mem::zeroed() };
h.msg_hdr.msg_iov = iov; h.msg_hdr.msg_iov = iov;
h.msg_hdr.msg_iovlen = 1; h.msg_hdr.msg_iovlen = 1;
@@ -185,6 +278,13 @@ fn sendmmsg_all(sock: &UdpSocket, pkts: &[Vec<u8>]) -> std::io::Result<()> {
.collect(); .collect();
let mut off = 0usize; let mut off = 0usize;
while off < hdrs.len() { while off < hdrs.len() {
// SAFETY: `fd` is `sock`'s live raw fd (`sock` outlives the call). `hdrs[off..]
// .as_mut_ptr()` is a live slice of `(hdrs.len() - off)` `mmsghdr`s — exactly the count
// passed — into which the kernel writes each `msg_len`. Each header's `msg_iov` points
// into `iovs` (a local that outlives this call, with `msg_iovlen == 1` matching its one
// entry) and each `iovec.iov_base` points into the `chunk` packet buffers (the caller's
// `pkts`, alive for the call); the kernel only reads those payloads. Flags 0; the return
// is error-/progress-checked before advancing `off`.
let n = unsafe { let n = unsafe {
libc::sendmmsg(fd, hdrs[off..].as_mut_ptr(), (hdrs.len() - off) as u32, 0) libc::sendmmsg(fd, hdrs[off..].as_mut_ptr(), (hdrs.len() - off) as u32, 0)
}; };
@@ -282,15 +382,36 @@ fn spawn_sender(
Ok(()) Ok(())
} }
/// Percentile of a slice (sorts it in place first). `q` in `0.0..=1.0`. Used for the web-console
/// stats sample's per-stage p50/p99.
fn percentile(v: &mut [u32], q: f64) -> u32 {
if v.is_empty() {
return 0;
}
v.sort_unstable();
let i = ((v.len() as f64 * q) as usize).min(v.len() - 1);
v[i]
}
/// The encode → packetize loop, over a borrowed capturer. Sending runs on a dedicated thread /// The encode → packetize loop, over a borrowed capturer. Sending runs on a dedicated thread
/// (see [`spawn_sender`]) so a send spike can never stall capture/encode. /// (see [`spawn_sender`]) so a send spike can never stall capture/encode.
#[allow(clippy::too_many_arguments)]
fn stream_body( fn stream_body(
capturer: &mut dyn Capturer, // `&mut Box` (not `&mut dyn`) so a mid-stream capture-loss rebuild can SWAP the capturer in place.
capturer: &mut Box<dyn Capturer>,
// Re-open the video source on capture loss (virtual-display path → follow a Desktop<->Game switch);
// `None` for the portal/synthetic source, which has nothing to re-detect (propagate the error).
rebuild: Option<&dyn Fn() -> Result<Box<dyn Capturer>>>,
sock: &UdpSocket, sock: &UdpSocket,
cfg: StreamConfig, cfg: StreamConfig,
running: &Arc<AtomicBool>, running: &Arc<AtomicBool>,
force_idr: &AtomicBool, force_idr: &AtomicBool,
rfi_range: &std::sync::Mutex<Option<(i64, i64)>>, rfi_range: &std::sync::Mutex<Option<(i64, i64)>>,
// Shared stats recorder. The encode loop reads `stats.is_armed()` per frame to decide whether
// to accumulate the per-stage split, then emits a `StatsSample` at its 1 s aggregation boundary.
stats: &Arc<crate::stats_recorder::StatsRecorder>,
// Short client label (peer IP) seeded into the capture meta on the first armed registration.
client_label: &str,
) -> Result<()> { ) -> Result<()> {
// The first frame establishes the authoritative size/format for the encoder. // The first frame establishes the authoritative size/format for the encoder.
let mut frame = capturer.next_frame().context("capture first frame")?; let mut frame = capturer.next_frame().context("capture first frame")?;
@@ -351,25 +472,111 @@ fn stream_body(
// Per-stage timing (PUNKTFUNK_PERF=1): max µs/stage per second + unique vs re-encoded frames, // Per-stage timing (PUNKTFUNK_PERF=1): max µs/stage per second + unique vs re-encoded frames,
// to pinpoint stalls. `unique` counts genuinely-new captured frames (vs re-encoded holds). // to pinpoint stalls. `unique` counts genuinely-new captured frames (vs re-encoded holds).
let perf = std::env::var_os("PUNKTFUNK_PERF").is_some(); let perf = crate::config::config().perf;
let (mut mx_cap, mut mx_enc, mut mx_pkt, mut mx_send, mut mx_pkts, mut uniq) = let (mut mx_cap, mut mx_enc, mut mx_pkt, mut mx_send, mut mx_pkts, mut uniq) =
(0u128, 0u128, 0u128, 0u128, 0usize, 0u32); (0u128, 0u128, 0u128, 0u128, 0usize, 0u32);
// Web-console stats accumulation (active when `perf` OR a capture is armed): per-stage vectors
// for p50/p99, the goodput bytes queued to the sender this window, the previous window's
// dropped-frame count for delta computation, and the registration id cached on the first sample.
let codec_name = match cfg.codec {
Codec::H264 => "h264",
Codec::H265 => "hevc",
Codec::Av1 => "av1",
};
let mut sid: Option<u32> = None;
let (mut v_cap, mut v_enc, mut v_pkt, mut v_send): (Vec<u32>, Vec<u32>, Vec<u32>, Vec<u32>) =
(Vec::new(), Vec::new(), Vec::new(), Vec::new());
let mut bytes_win: u64 = 0;
let mut last_dropped_batches: u64 = 0;
// Absolute next-frame deadline — the single pacing clock for the loop. // Absolute next-frame deadline — the single pacing clock for the loop.
let mut next_frame = Instant::now(); let mut next_frame = Instant::now();
// RFI capability is fixed for the session (probed at encoder open). Query it once so the
// recovery path skips the always-`false` invalidate call on encoders without NVENC RFI and
// forces a keyframe directly instead.
let mut supports_rfi = enc.caps().supports_rfi;
// Bound consecutive capture-loss rebuilds (a delivered frame clears the counter) so a permanently
// dead source can't loop forever — it ends the stream after the cap, falling back to a reconnect.
const MAX_REBUILDS: u32 = 5;
let mut rebuilds: u32 = 0;
while running.load(Ordering::SeqCst) { while running.load(Ordering::SeqCst) {
let tick = Instant::now(); let tick = Instant::now();
// Measure per-stage timing when `PUNKTFUNK_PERF` is set OR a web-console stats capture is
// armed (cheap Relaxed atomic, re-read each frame).
let measure = perf || stats.is_armed();
// Advance to the freshest captured frame if one arrived; otherwise reuse the last. // Advance to the freshest captured frame if one arrived; otherwise reuse the last.
if let Some(f) = capturer.try_latest().context("capture frame")? { match capturer.try_latest() {
frame = f; Ok(Some(f)) => {
uniq += 1; frame = f;
uniq += 1;
rebuilds = 0; // a delivered frame clears the consecutive-loss counter
}
Ok(None) => {} // no new frame — reuse the last (static/idle desktop)
Err(e) => {
// The capture source went away — the compositor was torn down on a Desktop<->Game
// switch, or the virtual output was removed. On the virtual-display path, re-detect the
// now-live compositor and re-attach IN PLACE (the send thread + packetizer + socket +
// RTP clock all survive), then force an IDR so Moonlight resyncs — so the stream FOLLOWS
// the switch with no client reconnect. Build the new source BEFORE dropping the old.
// Bounded by a counter + a ~40s budget; on exhaustion, end the stream (Moonlight
// reconnect). The portal/synthetic path has no rebuild closure → propagate as before.
let Some(rebuild) = rebuild else {
return Err(e).context("capture frame");
};
rebuilds += 1;
if rebuilds > MAX_REBUILDS {
return Err(e).context("capture lost — rebuild attempts exhausted");
}
tracing::warn!(error = %format!("{e:#}"), rebuild = rebuilds,
"gamestream: capture lost — rebuilding source in place (following a session switch)");
let rebuild_deadline = Instant::now() + Duration::from_secs(40);
let new_cap = loop {
match rebuild() {
Ok(c) => break c,
Err(e2) => {
if !running.load(Ordering::SeqCst) || Instant::now() >= rebuild_deadline
{
return Err(e2)
.context("capture lost — no source within the rebuild budget");
}
tracing::warn!(error = %format!("{e2:#}"),
"gamestream: source not up yet — retrying");
std::thread::sleep(Duration::from_millis(500));
}
}
};
*capturer = new_cap;
capturer.set_active(true);
frame = capturer.next_frame().context("first frame after rebuild")?;
// Re-open the encoder for the new source (same negotiated WxH → same SPS profile) and
// force an IDR so Moonlight resyncs on the first emitted AU.
enc = encode::open_video(
cfg.codec,
frame.format,
frame.width,
frame.height,
cfg.fps,
cfg.bitrate_kbps as u64 * 1000,
frame.is_cuda(),
8,
)
.context("reopen encoder after rebuild")?;
supports_rfi = enc.caps().supports_rfi;
enc.request_keyframe();
next_frame = Instant::now();
tracing::info!("gamestream: source rebuilt — stream continues");
continue;
}
} }
let t_cap = tick.elapsed(); let t_cap = tick.elapsed();
// Honor a client recovery request. Prefer reference-frame invalidation (the encoder // Honor a client recovery request. Prefer reference-frame invalidation (the encoder
// re-references an older still-valid frame — no costly IDR spike); if the encoder can't // re-references an older still-valid frame — no costly IDR spike); if the encoder can't
// invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe. // invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe.
if let Some((first, last)) = rfi_range.lock().unwrap().take() { if let Some((first, last)) = rfi_range.lock().unwrap().take() {
if !enc.invalidate_ref_frames(first, last) { // Prefer reference-frame invalidation when the encoder supports it (no costly IDR
// spike); otherwise — or if the range is too old to invalidate — force a keyframe.
if !(supports_rfi && enc.invalidate_ref_frames(first, last)) {
enc.request_keyframe(); enc.request_keyframe();
} }
} }
@@ -397,9 +604,19 @@ fn stream_body(
// Hand the frame's packets to the send thread; never block here. A full queue means // Hand the frame's packets to the send thread; never block here. A full queue means
// the sender is behind — drop this batch (FEC/RFI covers the client) and keep encoding. // the sender is behind — drop this batch (FEC/RFI covers the client) and keep encoding.
let n = batch.len(); let n = batch.len();
// Goodput this window = bytes actually queued to the sender (a dropped batch never reaches
// the wire, so it's excluded). Summed only when measuring, to keep the idle path free.
let batch_bytes: u64 = if measure {
batch.iter().map(|p| p.len() as u64).sum()
} else {
0
};
if n > 0 { if n > 0 {
match batch_tx.try_send(batch) { match batch_tx.try_send(batch) {
Ok(()) => sent_batches += 1, Ok(()) => {
sent_batches += 1;
bytes_win += batch_bytes;
}
Err(std::sync::mpsc::TrySendError::Full(_)) => { Err(std::sync::mpsc::TrySendError::Full(_)) => {
dropped_batches += 1; dropped_batches += 1;
if dropped_batches.is_power_of_two() { if dropped_batches.is_power_of_two() {
@@ -411,17 +628,26 @@ fn stream_body(
} }
} }
} }
if perf { if measure {
let t_send = tick.elapsed(); let t_send = tick.elapsed();
mx_cap = mx_cap.max(t_cap.as_micros()); let cap_us = t_cap.as_micros();
mx_enc = mx_enc.max((t_enc - t_cap).as_micros()); let enc_us = (t_enc - t_cap).as_micros();
mx_pkt = mx_pkt.max((t_pkt - t_enc).as_micros()); let pkt_us = (t_pkt - t_enc).as_micros();
mx_send = mx_send.max((t_send - t_pkt).as_micros()); let send_us = (t_send - t_pkt).as_micros();
mx_cap = mx_cap.max(cap_us);
mx_enc = mx_enc.max(enc_us);
mx_pkt = mx_pkt.max(pkt_us);
mx_send = mx_send.max(send_us);
mx_pkts = mx_pkts.max(n); mx_pkts = mx_pkts.max(n);
v_cap.push(cap_us as u32);
v_enc.push(enc_us as u32);
v_pkt.push(pkt_us as u32);
v_send.push(send_us as u32);
} }
fps_count += 1; fps_count += 1;
if fps_t.elapsed() >= Duration::from_secs(1) { if fps_t.elapsed() >= Duration::from_secs(1) {
let secs = fps_t.elapsed().as_secs_f64();
if perf { if perf {
// Max µs/stage this second: cap=drain channel, enc=submit (zero-copy device // Max µs/stage this second: cap=drain channel, enc=submit (zero-copy device
// copy + NVENC), pkt=poll+FEC+packetize, send=paced packet send. `uniq`=new // copy + NVENC), pkt=poll+FEC+packetize, send=paced packet send. `uniq`=new
@@ -436,12 +662,6 @@ fn stream_body(
max_pkts = mx_pkts, max_pkts = mx_pkts,
"video: streaming (perf)" "video: streaming (perf)"
); );
mx_cap = 0;
mx_enc = 0;
mx_pkt = 0;
mx_send = 0;
mx_pkts = 0;
uniq = 0;
} else { } else {
tracing::info!( tracing::info!(
fps = fps_count, fps = fps_count,
@@ -450,6 +670,68 @@ fn stream_body(
"video: streaming" "video: streaming"
); );
} }
// Web-console capture: build the aggregated sample. The host send side exposes no
// receiver-side packet loss / FEC-recovery / send-buffer EAGAIN counters, so those stay
// 0 (not fabricated); `frames_dropped` is the per-frame send-queue overflow delta.
if stats.is_armed() {
let session_id = *sid.get_or_insert_with(|| {
stats.register_session(
"gamestream",
cfg.width,
cfg.height,
cfg.fps,
codec_name,
client_label,
)
});
let sample = crate::stats_recorder::StatsSample {
t_ms: 0, // stamped by push_sample from the capture's monotonic start
session_id,
stages: vec![
crate::stats_recorder::StageTiming {
name: "capture".into(),
p50_us: percentile(&mut v_cap, 0.50) as f32,
p99_us: percentile(&mut v_cap, 0.99) as f32,
},
crate::stats_recorder::StageTiming {
name: "encode".into(),
p50_us: percentile(&mut v_enc, 0.50) as f32,
p99_us: percentile(&mut v_enc, 0.99) as f32,
},
crate::stats_recorder::StageTiming {
name: "packetize".into(),
p50_us: percentile(&mut v_pkt, 0.50) as f32,
p99_us: percentile(&mut v_pkt, 0.99) as f32,
},
crate::stats_recorder::StageTiming {
name: "send".into(),
p50_us: percentile(&mut v_send, 0.50) as f32,
p99_us: percentile(&mut v_send, 0.99) as f32,
},
],
fps: (uniq as f64 / secs) as f32,
repeat_fps: (fps_count.saturating_sub(uniq) as f64 / secs) as f32,
mbps: (bytes_win as f64 * 8.0 / secs / 1_000_000.0) as f32,
bitrate_kbps: cfg.bitrate_kbps,
frames_dropped: dropped_batches.saturating_sub(last_dropped_batches) as u32,
packets_dropped: 0,
send_dropped: 0,
fec_recovered: 0,
};
stats.push_sample(session_id, sample);
}
mx_cap = 0;
mx_enc = 0;
mx_pkt = 0;
mx_send = 0;
mx_pkts = 0;
uniq = 0;
v_cap.clear();
v_enc.clear();
v_pkt.clear();
v_send.clear();
bytes_win = 0;
last_dropped_batches = dropped_batches;
fps_count = 0; fps_count = 0;
fps_t = Instant::now(); fps_t = Instant::now();
} }
@@ -3,7 +3,7 @@
//! `RTP_PACKET(12, big-endian) + reserved[4] + NV_VIDEO_PACKET(16, little-endian) + payload` //! `RTP_PACKET(12, big-endian) + reserved[4] + NV_VIDEO_PACKET(16, little-endian) + payload`
//! and the frame's bitstream is prefixed with an 8-byte `video_short_frame_header_t`, then //! and the frame's bitstream is prefixed with an 8-byte `video_short_frame_header_t`, then
//! striped into ≤4 FEC blocks of ≤255 shards. Byte-exact spec: //! striped into ≤4 FEC blocks of ≤255 shards. Byte-exact spec:
//! `docs/research/gamestream-protocol-research.json` (video plane). //! `design/research/gamestream-protocol-research.json` (video plane).
//! //!
//! FEC (P1.5): each block carries `m = ⌈k·pct/100⌉` ReedSolomon parity shards generated by //! FEC (P1.5): each block carries `m = ⌈k·pct/100⌉` ReedSolomon parity shards generated by
//! `punktfunk_core::fec::Gf8Coder` (the nanors-compatible Cauchy GF(2⁸) coder). Crucially, RS runs //! `punktfunk_core::fec::Gf8Coder` (the nanors-compatible Cauchy GF(2⁸) coder). Crucially, RS runs
+75 -14
View File
@@ -24,6 +24,9 @@ pub trait InputInjector {
pub enum Backend { pub enum Backend {
/// wlroots virtual pointer + keyboard Wayland protocols — the headless-Sway path. /// wlroots virtual pointer + keyboard Wayland protocols — the headless-Sway path.
WlrVirtual, WlrVirtual,
/// KWin `org_kde_kwin_fake_input` — direct injection, no RemoteDesktop portal / approval dialog
/// (authorized by the host's `.desktop`). The headless KDE-Desktop path; what krdpserver uses.
KwinFakeInput,
/// libei via `reis` — Wayland-native (RemoteDesktop portal). Not yet implemented. /// libei via `reis` — Wayland-native (RemoteDesktop portal). Not yet implemented.
Libei, Libei,
/// libei directly against gamescope's own EIS socket (no portal): input lands in the /// libei directly against gamescope's own EIS socket (no portal): input lands in the
@@ -47,6 +50,16 @@ pub fn open(backend: Backend) -> Result<Box<dyn InputInjector>> {
anyhow::bail!("wlroots virtual input requires Linux + a Wayland compositor") anyhow::bail!("wlroots virtual input requires Linux + a Wayland compositor")
} }
} }
Backend::KwinFakeInput => {
#[cfg(target_os = "linux")]
{
Ok(Box::new(kwin_fake_input::KwinFakeInjector::open()?))
}
#[cfg(not(target_os = "linux"))]
{
anyhow::bail!("KWin fake_input requires Linux + a KWin Wayland session")
}
}
Backend::Libei => { Backend::Libei => {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
@@ -90,12 +103,18 @@ pub fn open(backend: Backend) -> Result<Box<dyn InputInjector>> {
/// Pick the injection backend for the current session. gamescope hosts its own EIS server (no /// Pick the injection backend for the current session. gamescope hosts its own EIS server (no
/// portal), so a gamescope session injects directly into it. wlroots/Sway only implements the /// portal), so a gamescope session injects directly into it. wlroots/Sway only implements the
/// ScreenCast portal (no RemoteDesktop), so libei can't run there — use the wlr virtual-input /// ScreenCast portal (no RemoteDesktop), so libei can't run there — use the wlr virtual-input
/// protocols. KWin and GNOME implement RemoteDesktop but not the wlr protocols, so use libei. /// protocols. **KWin** exposes `org_kde_kwin_fake_input` (direct injection, no portal / approval
/// `PUNKTFUNK_INPUT_BACKEND=wlr|libei|gamescope|uinput` overrides the auto-detection. /// dialog — the only headless-capable path; what krdpserver uses), so prefer it there. **GNOME**
/// has neither fake_input nor the wlr protocols, so it uses libei via the RemoteDesktop portal
/// (which needs a user to approve, or a pre-seeded grant — not truly headless).
/// `PUNKTFUNK_INPUT_BACKEND=wlr|kwin|libei|gamescope|uinput` overrides the auto-detection.
pub fn default_backend() -> Backend { pub fn default_backend() -> Backend {
if let Ok(v) = std::env::var("PUNKTFUNK_INPUT_BACKEND") { if let Ok(v) = std::env::var("PUNKTFUNK_INPUT_BACKEND") {
match v.trim().to_ascii_lowercase().as_str() { match v.trim().to_ascii_lowercase().as_str() {
"wlr" | "wlroots" | "wlrvirtual" => return Backend::WlrVirtual, "wlr" | "wlroots" | "wlrvirtual" => return Backend::WlrVirtual,
"kwin" | "fakeinput" | "fake_input" | "kwin-fake-input" => {
return Backend::KwinFakeInput
}
"libei" | "ei" | "portal" => return Backend::Libei, "libei" | "ei" | "portal" => return Backend::Libei,
"gamescope" | "gamescope-ei" => return Backend::GamescopeEi, "gamescope" | "gamescope-ei" => return Backend::GamescopeEi,
"uinput" => return Backend::Uinput, "uinput" => return Backend::Uinput,
@@ -112,14 +131,26 @@ pub fn default_backend() -> Backend {
} }
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
{ {
if std::env::var("PUNKTFUNK_COMPOSITOR") // An explicit compositor pick (set per connect / mid-stream) is the strongest signal.
.is_ok_and(|v| v.trim().eq_ignore_ascii_case("gamescope")) let compositor = crate::config::config().compositor.clone();
{ if let Some(c) = compositor.as_deref() {
return Backend::GamescopeEi; let c = c.trim();
if c.eq_ignore_ascii_case("gamescope") {
return Backend::GamescopeEi;
}
if c.eq_ignore_ascii_case("kwin") {
return Backend::KwinFakeInput;
}
if c.eq_ignore_ascii_case("wlroots") || c.eq_ignore_ascii_case("sway") {
return Backend::WlrVirtual;
}
// mutter (GNOME) falls through to the XDG_CURRENT_DESKTOP check below.
} }
let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(); let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
let d = desktop.to_ascii_uppercase(); let d = desktop.to_ascii_uppercase();
if d.contains("KDE") || d.contains("GNOME") { if d.contains("KDE") {
Backend::KwinFakeInput
} else if d.contains("GNOME") {
Backend::Libei Backend::Libei
} else { } else {
Backend::WlrVirtual Backend::WlrVirtual
@@ -260,8 +291,10 @@ fn coalesce(events: Vec<InputEvent>) -> Vec<InputEvent> {
/// (`org.gnome.Mutter.RemoteDesktop`), the same direct API the Mutter video backend uses. /// (`org.gnome.Mutter.RemoteDesktop`), the same direct API the Mutter video backend uses.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn libei_ei_source() -> libei::EiSource { fn libei_ei_source() -> libei::EiSource {
let gnome = std::env::var("PUNKTFUNK_COMPOSITOR") let gnome = crate::config::config()
.is_ok_and(|v| v.trim().eq_ignore_ascii_case("mutter")) .compositor
.as_deref()
.is_some_and(|v| v.trim().eq_ignore_ascii_case("mutter"))
|| std::env::var("XDG_CURRENT_DESKTOP") || std::env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default() .unwrap_or_default()
.to_ascii_uppercase() .to_ascii_uppercase()
@@ -421,24 +454,46 @@ fn gs_button_to_evdev(b: u32) -> Option<u32> {
}) })
} }
// 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/`;
// `#[path]` keeps every `crate::inject::*` module name flat.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/linux/dualsense.rs"]
pub mod dualsense; pub mod dualsense;
/// Transport-independent DualSense HID contract, shared by the Linux UHID backend ([`dualsense`]) /// Transport-independent DualSense HID contract, shared by the Linux UHID backend ([`dualsense`])
/// and the Windows UMDF-driver backend ([`dualsense_windows`]). /// and the Windows UMDF-driver backend ([`dualsense_windows`]).
#[cfg(any(target_os = "linux", target_os = "windows"))] #[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/proto/dualsense_proto.rs"]
pub mod dualsense_proto; pub mod dualsense_proto;
/// Windows: virtual DualSense via the UMDF minidriver + a shared-memory host channel. /// Windows: virtual DualSense via the UMDF minidriver + a shared-memory host channel.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "inject/windows/dualsense_windows.rs"]
pub mod dualsense_windows; pub mod dualsense_windows;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/linux/dualshock4.rs"]
pub mod dualshock4; pub mod dualshock4;
#[cfg(target_os = "linux")] /// Transport-independent DualShock 4 HID codec used by the Windows UMDF-driver backend
pub mod gamepad; /// ([`dualshock4_windows`]). (The Linux backend still carries its own copy — see the module FIXME.)
/// Windows: virtual Xbox 360 pads via ViGEmBus. #[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/proto/dualshock4_proto.rs"]
pub mod dualshock4_proto;
/// Windows: virtual DualShock 4 via the same UMDF minidriver + shared-memory channel (device-type 1).
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "inject/gamepad_windows.rs"] #[path = "inject/windows/dualshock4_windows.rs"]
pub mod dualshock4_windows;
#[cfg(target_os = "linux")]
#[path = "inject/linux/gamepad.rs"]
pub mod gamepad; pub mod gamepad;
/// Stub — virtual gamepads need Linux uinput or Windows ViGEmBus; events are dropped elsewhere. /// Windows: virtual Xbox 360 pads via the in-tree XUSB companion UMDF driver (classic XInput).
#[cfg(target_os = "windows")]
#[path = "inject/windows/gamepad_windows.rs"]
pub mod gamepad;
/// Windows: small RAII wrappers (`Shm` section+view, `SwDevice` devnode) shared by the three gamepad
/// backends (DualSense / DualShock 4 / XUSB), so each per-pad resource closes deterministically on drop.
#[cfg(target_os = "windows")]
#[path = "inject/windows/gamepad_raii.rs"]
mod gamepad_raii;
/// Stub — virtual gamepads need Linux uinput or the Windows UMDF drivers; events are dropped elsewhere.
#[cfg(not(any(target_os = "linux", target_os = "windows")))] #[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub mod gamepad { pub mod gamepad {
#[derive(Default)] #[derive(Default)]
@@ -452,10 +507,16 @@ pub mod gamepad {
} }
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/linux/kwin_fake_input.rs"]
mod kwin_fake_input;
#[cfg(target_os = "linux")]
#[path = "inject/linux/libei.rs"]
mod libei; mod libei;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "inject/windows/sendinput.rs"]
mod sendinput; mod sendinput;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/linux/wlr.rs"]
mod wlr; mod wlr;
#[cfg(test)] #[cfg(test)]
@@ -1,138 +0,0 @@
//! Windows virtual gamepad via ViGEmBus — the analogue of the Linux uinput Xbox-360 pad.
//! One virtual Xbox 360 controller per client pad index. GameStream/Moonlight already uses the
//! XInput button/stick/trigger conventions (low 16 button bits, sticks 32768..32767 +Y up,
//! triggers 0..255), so the mapping is ~1:1.
//!
//! Needs the ViGEmBus driver installed (like SudoVDA for the display); absent → gamepad is disabled
//! and the session continues without it. Rumble flows back the *other* way: a game on the host writes
//! force-feedback to the virtual pad, ViGEm's notification API delivers it on a background thread,
//! and [`GamepadManager::pump_rumble`] relays level changes to the client (the universal 0xCA plane),
//! mirroring the Linux `EV_FF` read path.
use crate::gamestream::gamepad::GamepadEvent;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::thread::JoinHandle;
use vigem_client::{Client, TargetId, XButtons, XGamepad, Xbox360Wired};
/// A plugged virtual pad plus its rumble back-channel. The notification thread stores the latest
/// motor levels into `rumble` (packed `large << 8 | small`, both 0..255); [`GamepadManager::pump_rumble`]
/// reads it and emits level changes. Dropping `target` aborts the outstanding notification request,
/// so the thread's `poll` returns an error and it exits on its own — we detach it (per ViGEm's docs,
/// dropping the `JoinHandle` does not stop the thread, but the target-drop abort does).
struct PadEntry {
target: Xbox360Wired<Arc<Client>>,
rumble: Arc<AtomicU32>,
last_emitted: u32,
_notif_thread: Option<JoinHandle<()>>,
}
pub struct GamepadManager {
client: Option<Arc<Client>>,
pads: HashMap<u8, PadEntry>,
}
impl GamepadManager {
pub fn new() -> GamepadManager {
let client = match Client::connect() {
Ok(c) => {
tracing::info!("ViGEmBus connected (virtual Xbox 360 gamepads)");
Some(Arc::new(c))
}
Err(e) => {
tracing::warn!(
error = format!("{e:?}"),
"ViGEmBus unavailable — gamepad disabled (install ViGEmBus)"
);
None
}
};
GamepadManager {
client,
pads: HashMap::new(),
}
}
/// Lazily plug pad `index` on its first event, arming the rumble notification thread. Returns
/// `None` if ViGEmBus is unavailable or the pad failed to plug.
fn ensure_pad(&mut self, index: u8) -> Option<&mut PadEntry> {
if !self.pads.contains_key(&index) {
let client = self.client.clone()?;
let mut target = Xbox360Wired::new(client, TargetId::XBOX360_WIRED);
if let Err(e) = target.plugin() {
tracing::warn!(error = format!("{e:?}"), "ViGEm pad plugin failed");
return None;
}
let _ = target.wait_ready();
// Arm the force-feedback back-channel: a background thread writes each notification's
// motor levels into the shared atomic; the input thread drains changes via pump_rumble.
let rumble = Arc::new(AtomicU32::new(0));
let notif_thread = match target.request_notification() {
Ok(req) => {
let sink = rumble.clone();
Some(req.spawn_thread(move |_req, n| {
sink.store(
((n.large_motor as u32) << 8) | n.small_motor as u32,
Ordering::Relaxed,
);
}))
}
Err(e) => {
tracing::warn!(
error = format!("{e:?}"),
"ViGEm rumble notification unavailable — pad runs without force feedback"
);
None
}
};
self.pads.insert(
index,
PadEntry {
target,
rumble,
last_emitted: 0,
_notif_thread: notif_thread,
},
);
}
self.pads.get_mut(&index)
}
pub fn handle(&mut self, ev: &GamepadEvent) {
let GamepadEvent::State(f) = ev else {
return; // Arrival metadata — the pad is created lazily on the first State
};
let Some(entry) = self.ensure_pad(f.index.max(0) as u8) else {
return;
};
let gp = XGamepad {
buttons: XButtons {
raw: (f.buttons & 0xffff) as u16,
},
left_trigger: f.left_trigger,
right_trigger: f.right_trigger,
thumb_lx: f.ls_x,
thumb_ly: f.ls_y,
thumb_rx: f.rs_x,
thumb_ry: f.rs_y,
};
let _ = entry.target.update(&gp);
}
/// Relay any changed rumble level to the client. The notification thread keeps `rumble` current;
/// we emit only on change (the input thread re-sends the steady state every 500 ms to heal drops).
/// ViGEm motors are 0..255; the wire carries 0..65535, so scale by 257 (255 → 65535). `large`
/// (low-frequency) maps to the universal datagram's `low`, `small` (high-frequency) to `high`.
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
for (idx, entry) in self.pads.iter_mut() {
let packed = entry.rumble.load(Ordering::Relaxed);
if packed != entry.last_emitted {
entry.last_emitted = packed;
let large = ((packed >> 8) & 0xff) as u16;
let small = (packed & 0xff) as u16;
send(*idx as u16, large * 257, small * 257);
}
}
}
}
@@ -258,6 +258,7 @@ impl DualShock4Pad {
// union (uhid_create2_req) starts at byte 4. // union (uhid_create2_req) starts at byte 4.
put_cstr(&mut ev, 4, 128, &format!("Punktfunk DualShock 4 {index}")); // name[128] put_cstr(&mut ev, 4, 128, &format!("Punktfunk DualShock 4 {index}")); // name[128]
put_cstr(&mut ev, 132, 64, &format!("punktfunk/dualshock4/{index}")); // phys[64] put_cstr(&mut ev, 132, 64, &format!("punktfunk/dualshock4/{index}")); // phys[64]
// A unique uniq[64] keeps the sysfs nodes tidy when several pads coexist (the kernel's // A unique uniq[64] keeps the sysfs nodes tidy when several pads coexist (the kernel's
// duplicate-device check itself keys off the per-pad MAC in the pairing feature report). // duplicate-device check itself keys off the per-pad MAC in the pairing feature report).
put_cstr(&mut ev, 196, 64, &format!("punktfunk-ds4-{index}")); // uniq[64] put_cstr(&mut ev, 196, 64, &format!("punktfunk-ds4-{index}")); // uniq[64]
@@ -15,6 +15,9 @@
//! `<linux/uinput.h>` on x86_64. `/dev/uinput` needs a udev rule + `input` group membership //! `<linux/uinput.h>` on x86_64. `/dev/uinput` needs a udev rule + `input` group membership
//! (see `scripts/60-punktfunk.rules`); creation fails with a clear error otherwise. //! (see `scripts/60-punktfunk.rules`); creation fails with a clear error otherwise.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use crate::gamestream::gamepad::{self, GamepadFrame, MAX_PADS}; use crate::gamestream::gamepad::{self, GamepadFrame, MAX_PADS};
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use std::collections::HashMap; use std::collections::HashMap;
@@ -215,6 +218,11 @@ const _: () = {
}; };
fn ioctl_int(fd: i32, req: libc::c_ulong, arg: libc::c_int, what: &str) -> Result<()> { fn ioctl_int(fd: i32, req: libc::c_ulong, arg: libc::c_int, what: &str) -> Result<()> {
// SAFETY: every caller passes one of UI_SET_EVBIT/KEYBIT/FFBIT/UI_DEV_CREATE/UI_DEV_DESTROY as
// `req` — all integer-argument ioctls whose third arg the kernel takes BY VALUE, so nothing is
// dereferenced through `arg` and no memory must outlive the call. The only precondition is `fd`
// being a valid open descriptor; callers pass the live `/dev/uinput` fd, and even a stale fd
// would merely return -1/EBADF (reported below), never UB.
if unsafe { libc::ioctl(fd, req, arg) } < 0 { if unsafe { libc::ioctl(fd, req, arg) } < 0 {
bail!("{what}: {}", std::io::Error::last_os_error()); bail!("{what}: {}", std::io::Error::last_os_error());
} }
@@ -222,6 +230,12 @@ fn ioctl_int(fd: i32, req: libc::c_ulong, arg: libc::c_int, what: &str) -> Resul
} }
fn ioctl_ptr<T>(fd: i32, req: libc::c_ulong, arg: *mut T, what: &str) -> Result<()> { fn ioctl_ptr<T>(fd: i32, req: libc::c_ulong, arg: *mut T, what: &str) -> Result<()> {
// SAFETY: `fd` is the caller's live `/dev/uinput` fd. Every call site passes `&mut x` for a live,
// uniquely-borrowed `#[repr(C)]` `x: T` whose size matches the struct the request number encodes
// (UI_DEV_SETUP=0x405c_5503 → 0x5c=92=size_of::<UinputSetup>(); UI_ABS_SETUP → 0x1c=28; the FF
// upload/erase ioctls → 0x68/0x0c — all pinned by the `size_of` asserts above). The kernel copies
// exactly that many bytes in/out through `arg`; the `&mut` keeps the pointee alive and unaliased
// for the whole synchronous call.
if unsafe { libc::ioctl(fd, req, arg) } < 0 { if unsafe { libc::ioctl(fd, req, arg) } < 0 {
bail!("{what}: {}", std::io::Error::last_os_error()); bail!("{what}: {}", std::io::Error::last_os_error());
} }
@@ -251,6 +265,9 @@ pub struct VirtualPad {
impl VirtualPad { impl VirtualPad {
pub fn create(index: usize, identity: PadIdentity) -> Result<VirtualPad> { pub fn create(index: usize, identity: PadIdentity) -> Result<VirtualPad> {
use std::os::fd::FromRawFd; use std::os::fd::FromRawFd;
// SAFETY: `c"/dev/uinput"` is a 'static NUL-terminated C string literal; `as_ptr()` yields a
// valid pointer the kernel only reads as a filesystem path. `open` returns a fresh fd (or -1)
// and retains nothing; no Rust memory is aliased or handed to the kernel beyond that 'static path.
let raw = unsafe { let raw = unsafe {
libc::open( libc::open(
c"/dev/uinput".as_ptr(), c"/dev/uinput".as_ptr(),
@@ -264,6 +281,9 @@ impl VirtualPad {
std::io::Error::last_os_error() std::io::Error::last_os_error()
); );
} }
// SAFETY: `raw >= 0` here (the `< 0` branch above already bailed), so it is a freshly-opened fd
// from `libc::open` that is not stored or owned anywhere else. Transferring it to `OwnedFd` makes
// this the unique owner, which will `close` it exactly once on drop (no double-close, no leak).
let fd = unsafe { OwnedFd::from_raw_fd(raw) }; let fd = unsafe { OwnedFd::from_raw_fd(raw) };
ioctl_int(raw, UI_SET_EVBIT, EV_KEY as i32, "UI_SET_EVBIT(EV_KEY)")?; ioctl_int(raw, UI_SET_EVBIT, EV_KEY as i32, "UI_SET_EVBIT(EV_KEY)")?;
@@ -356,6 +376,11 @@ impl VirtualPad {
code, code,
value, value,
}; };
// SAFETY: `ev` is a live local `#[repr(C)]` struct of all-integer fields with no padding bytes
// (timeval=16 + u16 + u16 + i32 = 24, the size asserted above), so every byte is initialized and
// valid to read as `u8`. The pointer is non-null and `u8`-aligned (align 1), the length is exactly
// `size_of::<InputEventRaw>()` so the slice spans precisely `ev`'s bytes (in bounds), and `ev`
// outlives `bytes` (used immediately below) with no concurrent mutation (single-threaded local).
let bytes = unsafe { let bytes = unsafe {
std::slice::from_raw_parts( std::slice::from_raw_parts(
&ev as *const _ as *const u8, &ev as *const _ as *const u8,
@@ -363,6 +388,10 @@ impl VirtualPad {
) )
}; };
// Best-effort: a full kernel queue drops the event; the next frame re-syncs state. // Best-effort: a full kernel queue drops the event; the next frame re-syncs state.
// SAFETY: `self.fd` is the live uinput `OwnedFd` (borrowed via `as_raw_fd`, so it stays open for
// the call); `bytes` is the slice above backed by the still-live local `ev`. `write` only READS
// exactly `bytes.len()` bytes from `bytes.as_ptr()` (in bounds) and retains nothing past return,
// so the buffer outlives the synchronous call and the read-only access cannot race or alias.
let _ = unsafe { let _ = unsafe {
libc::write( libc::write(
self.fd.as_raw_fd(), self.fd.as_raw_fd(),
@@ -404,6 +433,10 @@ impl VirtualPad {
let raw = self.fd.as_raw_fd(); let raw = self.fd.as_raw_fd();
let mut buf = [0u8; std::mem::size_of::<InputEventRaw>()]; let mut buf = [0u8; std::mem::size_of::<InputEventRaw>()];
loop { loop {
// SAFETY: `raw` is the live raw fd of `self.fd` (the non-blocking uinput device). `buf` is a
// live local `[u8; size_of::<InputEventRaw>()]`; `buf.as_mut_ptr()` is a valid writable pointer
// to its `buf.len()` bytes. `read` writes AT MOST `buf.len()` bytes (in bounds), the buffer
// outlives this synchronous call, and `buf` is borrowed uniquely here (no alias/race).
let n = unsafe { libc::read(raw, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) }; let n = unsafe { libc::read(raw, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
if n != buf.len() as isize { if n != buf.len() as isize {
break; // EAGAIN / short read — queue drained break; // EAGAIN / short read — queue drained
@@ -415,6 +448,10 @@ impl VirtualPad {
unsafe { std::ptr::read_unaligned(buf.as_ptr() as *const InputEventRaw) }; unsafe { std::ptr::read_unaligned(buf.as_ptr() as *const InputEventRaw) };
match (ev.type_, ev.code) { match (ev.type_, ev.code) {
(EV_UINPUT, UI_FF_UPLOAD) => { (EV_UINPUT, UI_FF_UPLOAD) => {
// SAFETY: `UinputFfUpload` is `#[repr(C)]` over integers (`u32`, `i32`) and two
// `FfEffect`s (integers + `[u8; 32]`); all-zero is a valid bit pattern for every field
// (no bool/NonZero/enum/reference niche), so `zeroed` yields a fully-initialized valid
// value — `request_id` is then set below and the rest filled by UI_BEGIN_FF_UPLOAD.
let mut up: UinputFfUpload = unsafe { std::mem::zeroed() }; let mut up: UinputFfUpload = unsafe { std::mem::zeroed() };
up.request_id = ev.value as u32; up.request_id = ev.value as u32;
if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() { if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() {
@@ -442,6 +479,9 @@ impl VirtualPad {
} }
} }
(EV_UINPUT, UI_FF_ERASE) => { (EV_UINPUT, UI_FF_ERASE) => {
// SAFETY: `UinputFfErase` is `#[repr(C)]` over three integer fields (`u32`, `i32`,
// `u32`); all-zero is a valid bit pattern for each, so `zeroed` produces a fully-valid
// initialized value — `request_id` is set below and `effect_id` filled by the ioctl.
let mut er: UinputFfErase = unsafe { std::mem::zeroed() }; let mut er: UinputFfErase = unsafe { std::mem::zeroed() };
er.request_id = ev.value as u32; er.request_id = ev.value as u32;
if ioctl_ptr(raw, UI_BEGIN_FF_ERASE, &mut er, "UI_BEGIN_FF_ERASE").is_ok() { if ioctl_ptr(raw, UI_BEGIN_FF_ERASE, &mut er, "UI_BEGIN_FF_ERASE").is_ok() {
@@ -492,6 +532,9 @@ impl VirtualPad {
impl Drop for VirtualPad { impl Drop for VirtualPad {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `self.fd` is still the live owned uinput fd here (the `OwnedFd` field is closed only
// AFTER this `drop` body returns), borrowed by `as_raw_fd`. UI_DEV_DESTROY takes its argument
// (0) BY VALUE, so nothing is dereferenced or aliased; the ioctl just tears down the device.
let _ = unsafe { libc::ioctl(self.fd.as_raw_fd(), UI_DEV_DESTROY, 0) }; let _ = unsafe { libc::ioctl(self.fd.as_raw_fd(), UI_DEV_DESTROY, 0) };
} }
} }
@@ -0,0 +1,209 @@
//! Headless input injection on KWin via the privileged `org_kde_kwin_fake_input` protocol — the
//! exact path KDE's own headless RDP server (`krdpserver`) uses. KWin advertises this restricted
//! global only to a client authorized through its installed `.desktop` `X-KDE-Wayland-Interfaces`
//! (we ship `io.unom.Punktfunk.Host.desktop`, which lists `org_kde_kwin_fake_input` alongside
//! `zkde_screencast_unstable_v1`). Binding the global IS the authorization, so injection needs **no
//! RemoteDesktop portal and no "Allow remote control?" dialog** — it works with no user present,
//! which the libei/portal path cannot. We connect as an ordinary Wayland client on the KWin session's
//! `$WAYLAND_DISPLAY` and translate events into fake-input requests; keyboard keys are raw Linux
//! evdev codes that KWin resolves through the session's own keymap (no keymap upload, unlike the wlr
//! virtual-keyboard path), and absolute pointer/touch coordinates are global compositor space — which
//! on a headless box (single per-session virtual output at the origin, scale 1) equals the streamed
//! output's pixels.
#![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
use anyhow::{Context, Result};
use punktfunk_core::input::InputKind;
use wayland_client::protocol::wl_registry::{self, WlRegistry};
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle};
// Generate the client bindings for the vendored protocol XML inline (no build.rs), exactly like the
// KWin virtual-output backend. Path is relative to CARGO_MANIFEST_DIR.
#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
pub mod fake {
use wayland_client;
use wayland_client::protocol::*;
pub mod __interfaces {
use wayland_client::protocol::__interfaces::*;
wayland_scanner::generate_interfaces!("protocols/fake-input.xml");
}
use self::__interfaces::*;
wayland_scanner::generate_client_code!("protocols/fake-input.xml");
}
use fake::org_kde_kwin_fake_input::OrgKdeKwinFakeInput as FakeInput;
/// Highest interface version we drive. `keyboard_key` arrived at v4; KWin advertises ≥4.
const MAX_VERSION: u32 = 4;
/// `wl_pointer.axis` values used by `axis`.
const AXIS_VERTICAL: u32 = 0;
const AXIS_HORIZONTAL: u32 = 1;
/// `code` value marking a horizontal scroll event (mirrors `gamestream::input` / the wlr backend).
const SCROLL_HORIZONTAL: u32 = 1;
/// Registry-bound globals (the Wayland dispatch state).
#[derive(Default)]
struct State {
fake: Option<FakeInput>,
}
impl Dispatch<WlRegistry, ()> for State {
fn event(
state: &mut Self,
registry: &WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_registry::Event::Global {
name,
interface,
version,
} = event
{
if interface == "org_kde_kwin_fake_input" {
state.fake = Some(registry.bind(name, version.min(MAX_VERSION), qh, ()));
}
}
}
}
// fake_input emits no events.
impl Dispatch<FakeInput, ()> for State {
fn event(
_: &mut Self,
_: &FakeInput,
_: <FakeInput as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
pub struct KwinFakeInjector {
conn: Connection,
queue: EventQueue<State>,
state: State,
fake: FakeInput,
}
impl KwinFakeInjector {
pub fn open() -> Result<Self> {
let conn = Connection::connect_to_env()
.context("connect to KWin Wayland (is WAYLAND_DISPLAY set to the KWin socket?)")?;
let mut queue = conn.new_event_queue();
let qh = queue.handle();
let _registry = conn.display().get_registry(&qh, ());
let mut state = State::default();
queue
.roundtrip(&mut state)
.context("Wayland registry roundtrip")?;
let fake = state.fake.clone().context(
"KWin does not expose org_kde_kwin_fake_input to this client — install the host's \
.desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \
KWin authorizes it (the grant is cached per-exe on first connect), or this is not a \
KWin session",
)?;
// Authenticate (the legacy handshake; for an interface-authorized client KWin accepts it
// without a dialog — same as krdpserver/krfb headless).
fake.authenticate("punktfunk".into(), "remote streaming input".into());
queue
.roundtrip(&mut state)
.context("fake_input authenticate roundtrip")?;
conn.flush().ok();
tracing::info!("KWin fake_input ready (headless keyboard/mouse/touch — no portal)");
Ok(Self {
conn,
queue,
state,
fake,
})
}
}
impl InputInjector for KwinFakeInjector {
fn inject(&mut self, event: &InputEvent) -> Result<()> {
match event.kind {
InputKind::MouseMove => {
self.fake.pointer_motion(event.x as f64, event.y as f64);
}
InputKind::MouseMoveAbs => {
let w = (event.flags >> 16) & 0xffff;
let h = event.flags & 0xffff;
if w > 0 && h > 0 {
let x = event.x.clamp(0, w as i32) as f64;
let y = event.y.clamp(0, h as i32) as f64;
self.fake.pointer_motion_absolute(x, y);
}
}
InputKind::MouseButtonDown | InputKind::MouseButtonUp => {
if let Some(btn) = gs_button_to_evdev(event.code) {
let st = u32::from(event.kind == InputKind::MouseButtonDown);
self.fake.button(btn, st);
}
}
InputKind::MouseScroll => {
// GameStream sends WHEEL_DELTA(120)-scaled units; a notch ≈ 15px. Vertical flips
// sign on the Wayland axis, horizontal passes through — same as the wlr backend.
let horizontal = event.code == SCROLL_HORIZONTAL;
let axis = if horizontal {
AXIS_HORIZONTAL
} else {
AXIS_VERTICAL
};
let notches = event.x as f64 / 120.0;
let sign = if horizontal { 1.0 } else { -1.0 };
self.fake.axis(axis, sign * notches * 15.0);
}
InputKind::KeyDown | InputKind::KeyUp => {
// Raw evdev keycode; KWin resolves it through the session's own keymap (and tracks
// modifier state itself, so no separate modifiers request is needed).
if let Some(evdev) = vk_to_evdev(event.code as u8) {
let st = u32::from(event.kind == InputKind::KeyDown);
self.fake.keyboard_key(evdev as u32, st);
} else {
tracing::debug!(vk = event.code, "unmapped VK keycode — dropped");
}
}
// Touch: id = event.code, coords in the client surface w×h packed into flags (same
// absolute mapping as MouseMoveAbs). Each event is its own frame.
InputKind::TouchDown | InputKind::TouchMove => {
let w = (event.flags >> 16) & 0xffff;
let h = event.flags & 0xffff;
if w > 0 && h > 0 {
let x = event.x.clamp(0, w as i32) as f64;
let y = event.y.clamp(0, h as i32) as f64;
if event.kind == InputKind::TouchDown {
self.fake.touch_down(event.code, x, y);
} else {
self.fake.touch_motion(event.code, x, y);
}
self.fake.touch_frame();
}
}
InputKind::TouchUp => {
self.fake.touch_up(event.code);
self.fake.touch_frame();
}
// Gamepads are injected through uinput, not the compositor.
InputKind::GamepadButton | InputKind::GamepadAxis => {}
}
// Surface protocol errors / disconnects, then push the batch to the compositor.
self.queue
.dispatch_pending(&mut self.state)
.context("wayland dispatch")?;
self.conn.flush().context("wayland flush")?;
Ok(())
}
}
@@ -5,6 +5,9 @@
//! keymap, and translate events into virtual pointer/keyboard requests, tracking modifier state //! keymap, and translate events into virtual pointer/keyboard requests, tracking modifier state
//! so the compositor resolves shifted keysyms correctly. //! so the compositor resolves shifted keysyms correctly.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector}; use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use punktfunk_core::input::InputKind; use punktfunk_core::input::InputKind;
@@ -264,10 +267,17 @@ impl InputInjector for WlrootsInjector {
/// Create an anonymous in-memory file holding `s` + a trailing NUL (for the keymap fd). /// Create an anonymous in-memory file holding `s` + a trailing NUL (for the keymap fd).
fn memfd_with(s: &str) -> Result<std::fs::File> { fn memfd_with(s: &str) -> Result<std::fs::File> {
let name = b"punktfunk-keymap\0"; let name = b"punktfunk-keymap\0";
// SAFETY: `name` is a byte-string literal with an explicit trailing NUL, so `name.as_ptr()` is a
// valid NUL-terminated C string; `memfd_create` only reads that name (copying it) and creates an
// anonymous file, returning a fresh fd (or -1). `MFD_CLOEXEC` is a valid flag. The 'static literal
// outlives the synchronous call and nothing aliases it. The result is checked `< 0` below.
let fd = unsafe { libc::memfd_create(name.as_ptr() as *const libc::c_char, libc::MFD_CLOEXEC) }; let fd = unsafe { libc::memfd_create(name.as_ptr() as *const libc::c_char, libc::MFD_CLOEXEC) };
if fd < 0 { if fd < 0 {
bail!("memfd_create failed: {}", std::io::Error::last_os_error()); bail!("memfd_create failed: {}", std::io::Error::last_os_error());
} }
// SAFETY: `fd` is the fresh memfd `memfd_create` just returned and checked `>= 0`; it is a unique
// open fd nothing else owns, so `File` takes sole ownership and closes it exactly once on drop —
// no alias, no double-close.
let mut f = unsafe { std::fs::File::from_raw_fd(fd) }; let mut f = unsafe { std::fs::File::from_raw_fd(fd) };
f.write_all(s.as_bytes()).context("write keymap")?; f.write_all(s.as_bytes()).context("write keymap")?;
f.write_all(&[0]).context("write keymap NUL")?; f.write_all(&[0]).context("write keymap NUL")?;
@@ -0,0 +1,180 @@
//! Transport-independent DualShock 4 HID contract — the pure report codec used by the Windows
//! UMDF-driver backend ([`super::dualshock4_windows`]).
//!
//! FIXME(ds4-dedup): the Linux UHID backend ([`super::dualshock4`]) still carries its own byte-
//! identical copy of this codec (`serialize_state` / `parse_ds4_output` / `Ds4Feedback` / the touch
//! dims). Fold it onto this module once the Linux build can be re-validated (it is `cfg(linux)`, so
//! it can't be compile-checked from a Windows host). Keep the two in sync until then.
//!
//! The PS4 sibling of [`super::dualsense_proto`]: the pure report codec with no transport. The DS4
//! reuses the DualSense [`DsState`] controller model + its `GameStream`/XInput mapper
//! ([`DsState::from_gamepad`]) — only the report *byte layout*, the touchpad resolution, and the
//! feedback report differ. The Linux backend writes report `0x01` to `/dev/uhid` and reads `0x05` via
//! `UHID_OUTPUT`; the Windows backend pushes `0x01` to the UMDF driver and pulls `0x05` back over its
//! shared-memory channel — both build/parse the exact same bytes here.
//!
//! Field offsets are the canonical real-DS4-USB layout the kernel `struct
//! dualshock4_input_report_usb` / `_output_report_common` parse.
use super::dualsense_proto::{DsState, Touch};
use punktfunk_core::quic::HidOutput;
/// DualShock 4 v2 USB identity (Sony Interactive Entertainment / CUH-ZCT2).
pub const DS4_VENDOR: u16 = 0x054C;
pub const DS4_PRODUCT: u16 = 0x09CC;
/// USB input report `0x01` is 64 bytes total (report id + 63-byte body).
pub const DS4_INPUT_REPORT_LEN: usize = 64;
/// The DualShock 4 touchpad resolution the kernel advertises (ABS_MT 0..1919 / 0..941). Narrower
/// than the DualSense's 1920×1080.
pub const DS4_TOUCH_W: u16 = 1920;
pub const DS4_TOUCH_H: u16 = 942;
/// Pack one touchpad contact into the DS4's 4-byte point (same bit layout as the DualSense's:
/// byte0 bit7 = NOT-active, bits0-6 = id; 12-bit X then 12-bit Y).
fn pack_touch(dst: &mut [u8], t: &Touch) {
dst[0] = (t.id & 0x7F) | if t.active { 0 } else { 0x80 };
// Never emit the extent itself — the kernel advertises 0..=W-1 / 0..=H-1.
let (x, y) = (t.x.min(DS4_TOUCH_W - 1), t.y.min(DS4_TOUCH_H - 1));
dst[1] = (x & 0xFF) as u8;
dst[2] = (((x >> 8) & 0x0F) as u8) | (((y & 0x0F) as u8) << 4);
dst[3] = ((y >> 4) & 0xFF) as u8;
}
/// Serialize a full DS4 input report `0x01` (pure — unit-testable without a transport). Field offsets
/// per the kernel's `struct dualshock4_input_report_usb` { report_id; common; num_touch; touch[3];
/// rsvd[3] } where `common` = { x,y,rx,ry; buttons[3]; z,rz; sensor_ts le16; temp; gyro[3] le16;
/// accel[3] le16; rsvd[5]; status[2]; rsvd }. The report id is byte 0, so a `common` field at struct
/// offset N sits at report byte N+1.
pub fn serialize_state(r: &mut [u8; DS4_INPUT_REPORT_LEN], st: &DsState, counter: u8, ts: u16) {
r[0] = 0x01; // report id
r[1] = st.lx;
r[2] = st.ly;
r[3] = st.rx;
r[4] = st.ry;
r[5] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // dpad hat (low) + face buttons (high)
r[6] = st.buttons[1]; // L1/R1, L2/R2 digital, Share/Options, L3/R3
r[7] = (st.buttons[2] & 0x03) | ((counter & 0x3F) << 2); // PS + touchpad-click + report counter
r[8] = st.l2; // L2 analog (z)
r[9] = st.r2; // R2 analog (rz)
r[10..12].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 9)
// r[12] temperature stays 0
for (i, v) in st.gyro.iter().enumerate() {
r[13 + i * 2..15 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 12
}
for (i, v) in st.accel.iter().enumerate() {
r[19 + i * 2..21 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 18
}
// r[25..30] reserved2.
// status[0] (struct off 29 → r[30]): bit4 = cable/wired, low nibble = battery capacity. Report
// wired + full (0x1B) so SteamOS / the kernel never warn "low battery" on a virtual pad.
r[30] = 0x10 | 0x0B;
// r[31] status[1] = 0 (no headphone/mic), r[32] reserved3 = 0.
r[33] = 1; // num_touch_reports: one frame carrying the two contacts (a real DS4 always sends one)
r[34] = ts as u8; // touch_reports[0].timestamp
pack_touch(&mut r[35..39], &st.touch[0]); // touch point 0
pack_touch(&mut r[39..43], &st.touch[1]); // touch point 1
// remaining touch frames (r[43..61]) + reserved (r[61..64]) stay zero
}
/// What one feedback pass extracted from the device's HID output reports. Rumble rides the universal
/// 0xCA plane; the lightbar rides the HID-output 0xCD plane (DS4 has no player LEDs or adaptive
/// triggers, so those never appear).
#[derive(Default)]
pub struct Ds4Feedback {
pub hidout: Vec<HidOutput>,
/// `(low, high)` motor levels (0..=0xFF00), if a report carried them.
pub rumble: Option<(u16, u16)>,
/// Lightbar RGB, if the report carried it (deduped by the manager).
pub led: Option<(u8, u8, u8)>,
}
/// Parse a DualShock 4 USB output report (`0x05`) into a [`Ds4Feedback`]. Layout per the kernel
/// `struct dualshock4_output_report_common`: valid_flag0 (bit0 motor, bit1 LED, bit2 blink) at [1],
/// valid_flag1 [2], reserved [3], motor_right (weak/small) [4], motor_left (strong/large) [5],
/// lightbar R/G/B [6..9], blink on/off [9..11]. Gated on the valid-flags so a rumble-only write
/// doesn't masquerade as a lightbar change.
pub fn parse_ds4_output(data: &[u8], fb: &mut Ds4Feedback) {
if data.first() != Some(&0x05) || data.len() < 11 {
return; // not the USB output report (BT 0x11 is shifted) / too short
}
let flag0 = data[1];
if flag0 & 0x01 != 0 {
// motor_left (strong/large/low-freq) at [5], motor_right (weak/small/high-freq) at [4];
// scale 0..255 → 0..0xFF00, same (low, high) convention as the other backends.
let low = (data[5] as u16) << 8;
let high = (data[4] as u16) << 8;
fb.rumble = Some((low, high));
}
if flag0 & 0x02 != 0 {
fb.led = Some((data[6], data[7], data[8]));
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Report 0x01 places sticks/buttons/triggers/motion/touch at the kernel's DS4 offsets.
#[test]
fn serialize_offsets() {
use punktfunk_core::input::gamepad as gs;
let mut st = DsState::from_gamepad(
gs::BTN_A | gs::BTN_DPAD_UP | gs::BTN_LB,
16384, // lx (right)
0,
0,
-32768, // ry (down) — inverted to 0xFF
200, // L2
0,
);
st.gyro = [0x0102, 0x0304, 0x0506];
st.accel = [0x1112, 0x1314, 0x1516];
st.touch[0] = Touch {
active: true,
id: 0,
x: 100,
y: 200,
};
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
serialize_state(&mut r, &st, 0, 0);
assert_eq!(r[0], 0x01); // report id
assert_eq!(r[8], 200); // L2 analog at byte 8 (not the DualSense's byte 5)
assert_eq!(r[5] & 0x0F, 0); // dpad hat = N (up)
assert_eq!(r[5] & 0x20, 0x20); // Cross (A) face bit
assert_eq!(r[6] & 0x01, 0x01); // L1
// gyro le16 at 13..19, accel le16 at 19..25.
assert_eq!(&r[13..19], &[0x02, 0x01, 0x04, 0x03, 0x06, 0x05]);
assert_eq!(&r[19..25], &[0x12, 0x11, 0x14, 0x13, 0x16, 0x15]);
assert_eq!(r[33], 1); // one touch frame
assert_eq!(r[35] & 0x80, 0); // contact 0 active (bit7 clear)
assert_eq!(r[35] & 0x7F, 0); // contact id 0
assert_eq!(r[30] & 0x10, 0x10); // cable/wired bit set
}
/// A DS4 USB output report (`0x05`) with motor + LED flags parses into rumble (0xCA) and a
/// lightbar `Led` (0xCD); a rumble-only report (no LED flag) leaves the lightbar untouched.
#[test]
fn parse_output_rumble_and_lightbar() {
let mut report = [0u8; 32];
report[0] = 0x05;
report[1] = 0x01 | 0x02; // MOTOR | LED
report[4] = 0x40; // motor_right (weak/high)
report[5] = 0x80; // motor_left (strong/low)
report[6] = 0x11; // R
report[7] = 0x22; // G
report[8] = 0x33; // B
let mut fb = Ds4Feedback::default();
parse_ds4_output(&report, &mut fb);
assert_eq!(fb.rumble, Some((0x8000, 0x4000))); // (low=strong, high=weak)
assert_eq!(fb.led, Some((0x11, 0x22, 0x33)));
let mut motor_only = [0u8; 32];
motor_only[0] = 0x05;
motor_only[1] = 0x01; // MOTOR only
motor_only[5] = 0x10;
let mut fb2 = Ds4Feedback::default();
parse_ds4_output(&motor_only, &mut fb2);
assert!(fb2.rumble.is_some());
assert_eq!(fb2.led, None); // lightbar not asserted → no spurious change
}
}
@@ -11,9 +11,10 @@
//! bytes. `hidclass` gates the device stack, so this user-mode IPC is the only viable channel (a //! bytes. `hidclass` gates the device stack, so this user-mode IPC is the only viable channel (a
//! UMDF driver has no control device); see `windows-dualsense-scoping.md`. //! UMDF driver has no control device); see `windows-dualsense-scoping.md`.
//! //!
//! Device lifecycle: each pad `SwDeviceCreate`s the `root\pf_dualsense` devnode on open and //! Device lifecycle: each pad `SwDeviceCreate`s a `pf_pad_<index>` software devnode (hardware id
//! `SwDeviceClose`s it on drop, so the virtual DualSense appears/disappears with the session — //! `pf_dualsense`, enumerator `punktfunk`) on open and `SwDeviceClose`s it on drop, so the virtual
//! matching the Linux UHID pad. (The driver itself must already be installed; the installer stages it.) //! DualSense appears/disappears with the session — matching the Linux UHID pad. (The driver itself
//! must already be installed; the installer stages it.)
use super::dualsense_proto::{ use super::dualsense_proto::{
parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H, parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H,
@@ -24,52 +25,53 @@ use anyhow::{anyhow, Result};
use punktfunk_core::quic::{HidOutput, RichInput}; use punktfunk_core::quic::{HidOutput, RichInput};
use std::ffi::c_void; use std::ffi::c_void;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use windows::core::{w, HRESULT, HSTRING, PCWSTR}; use windows::core::{w, GUID, HRESULT, HSTRING, PCWSTR};
use windows::Win32::Devices::Enumeration::Pnp::{ use windows::Win32::Devices::Enumeration::Pnp::{
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO, SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
}; };
use windows::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}; use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
};
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
use windows::Win32::System::Memory::{
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
};
use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject}; use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject};
/// Shared-section layout — must match `packaging/windows/dualsense-driver/src/lib.rs`. /// Shared-section layout — the single source of truth is [`pf_driver_proto::gamepad::PadShm`] (offset
const SHM_SIZE: usize = 256; /// asserts pin every field; the `pf_dualsense` driver maps the same struct). Derive the size/offsets/magic
const SHM_MAGIC: u32 = 0x5046_4453; // "PFDS" /// from it so a layout change is a compile error, not a hand-synced literal (audit §6.1). `pub(super)` so
const OFF_INPUT: usize = 8; /// the sibling DualShock 4 backend ([`super::dualshock4_windows`]) reuses the exact offsets.
const OFF_OUT_SEQ: usize = 72; pub(super) const SHM_SIZE: usize = core::mem::size_of::<pf_driver_proto::gamepad::PadShm>();
const OFF_OUTPUT: usize = 76; pub(super) const SHM_MAGIC: u32 = pf_driver_proto::gamepad::PAD_MAGIC; // "PFDS"
pub(super) const OFF_INPUT: usize = core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, input);
pub(super) const OFF_OUT_SEQ: usize =
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, out_seq);
pub(super) const OFF_OUTPUT: usize =
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, output);
/// Device-type selector the driver reads to choose which HID identity/descriptor it serves: 0 =
/// DualSense (the default — the section is zeroed), 1 = DualShock 4.
pub(super) const OFF_DEVTYPE: usize =
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, device_type);
pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4;
/// A single virtual DualSense: the `root\pf_dualsense` software devnode (the driver loads on it and /// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_<index>` software devnode (the driver
/// the HID DualSense appears to games) plus the shared-memory section the driver maps. Dropping it /// loads on it and the HID DualSense appears to games) plus the shared-memory section the driver maps.
/// removes the devnode (`SwDeviceClose`) and unmaps + closes the section. /// Dropping it removes the devnode (`SwDeviceClose`) and unmaps + closes the section.
struct DsWinPad { struct DsWinPad {
/// Per-session devnode from SwDeviceCreate, when it succeeds. `None` falls back to an out-of-band /// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
/// `pf_dualsense` devnode (installer/devgen). /// `None` falls back to an out-of-band `pf_dualsense` devnode (installer/devgen).
hsw: Option<HSWDEVICE>, _sw: Option<super::gamepad_raii::SwDevice>,
map: HANDLE, /// The named shared section the driver maps (RAII — unmapped + closed on drop).
view: *mut u8, shm: super::gamepad_raii::Shm,
seq: u8, seq: u8,
ts: u32, ts: u32,
last_out_seq: u32, last_out_seq: u32,
} }
/// Context for the async `SwDeviceCreate` completion callback: the event to signal + the result. /// Context for the `SwDeviceCreate` completion callback: an event to signal + the HRESULT it reports.
#[repr(C)] #[repr(C)]
struct SwCreateCtx { struct SwCreateCtx {
event: HANDLE, event: HANDLE,
result: HRESULT, result: HRESULT,
} }
/// `SwDeviceCreate` fires this on a worker thread once the device is created. We stash the result and /// `SwDeviceCreate` fires this once PnP has enumerated the device; stash the result and wake the
/// wake the waiting [`create_swdevice`]; the creator blocks on the event, so there's no concurrent /// creator, which blocks on the event (so there's no concurrent access to `*ctx`).
/// access to `*ctx`.
unsafe extern "system" fn sw_create_cb( unsafe extern "system" fn sw_create_cb(
_dev: HSWDEVICE, _dev: HSWDEVICE,
result: HRESULT, result: HRESULT,
@@ -77,38 +79,113 @@ unsafe extern "system" fn sw_create_cb(
_id: PCWSTR, _id: PCWSTR,
) { ) {
if !ctx.is_null() { if !ctx.is_null() {
let c = ctx as *mut SwCreateCtx; // SAFETY: ctx is the &mut SwCreateCtx the creator passed; it outlives this callback.
// SAFETY: c is the &mut SwCreateCtx the creator passed; it outlives this callback (the
// creator waits on the event before dropping it).
unsafe { unsafe {
let c = ctx as *mut SwCreateCtx;
(*c).result = result; (*c).result = result;
let _ = SetEvent((*c).event); let _ = SetEvent((*c).event);
} }
} }
} }
/// Spawn the virtual DualSense software device under enumerator `punktfunk` (hardware id /// The PnP identity for a virtual controller devnode — varies by controller type so the same
/// `pf_dualsense`, which the INF matches). The returned `HSWDEVICE` owns the devnode for the session /// [`create_swdevice`] builds a DualSense (`VID_054C&PID_0CE6`) or a DualShock 4
/// — `SwDeviceClose` removes it. /// (`VID_054C&PID_09CC`). The fields map onto the `SW_DEVICE_CREATE_INFO` identity discussed below.
pub(super) struct SwDeviceProfile<'a> {
/// PnP instance id — distinct namespaces per type (`pf_pad_<idx>` vs `pf_ds4_<idx>`) so the two
/// never reuse the same devnode shell.
pub instance: &'a str,
/// Index for the deterministic per-pad ContainerId.
pub container_index: u8,
/// The INF-matched hardware id (`pf_dualsense` / `pf_dualshock4`), listed FIRST so the INF binds.
pub hwid: &'a str,
/// The USB VID&PID token (`VID_054C&PID_0CE6`) used to synthesize the USB hardware/compatible ids.
pub usb_vid_pid: &'a str,
/// Device description shown in Device Manager.
pub description: &'a str,
}
/// Spawn the per-session virtual controller devnode under enumerator `punktfunk` (instance
/// `profile.instance`). The returned `HSWDEVICE` owns it — `SwDeviceClose` removes it on drop, so the
/// pad appears/disappears with the session and nothing persists.
/// ///
/// NB: enumerator names with an underscore (`pf_dualsense`) get E_INVALIDARG — hence `punktfunk`. /// **Game-detection identity** (see `design/windows-dualsense-game-detection.md`). `HIDD_ATTRIBUTES`
/// TODO: a SECOND E_INVALIDARG remains — passing the completion callback is rejected (callback-absent /// alone (VID/PID via the IOCTL) satisfies SDL/HIDAPI/RawInput, but a native PS5 path (libScePad-
/// is accepted but then the devnode doesn't materialize). Until that's resolved [`DsWinPad::open`] /// style raw HID) classifies the *connection type* by walking from the HID child to its parent
/// treats a failure as non-fatal and relies on an out-of-band `pf_dualsense` devnode (installer / /// (`CM_Get_Parent`) and string-matching `"USB"`/`"BTHENUM"` in that parent's
/// dev-box `devgen`); see `docs/windows-dualsense-scoping.md`. /// `DEVPKEY_Device_CompatibleIds`; with no bus identity the pad reads as `UNKNOWN` and the native
fn create_swdevice() -> Result<HSWDEVICE> { /// path rejects it. So we set, via `SW_DEVICE_CREATE_INFO` (NOT `pProperties` — bus/identity info is
let hwids: Vec<u16> = "pf_dualsense".encode_utf16().chain([0u16, 0u16]).collect(); /// create-time-only and a `DEVPROPERTY` write of these keys is ignored):
let desc: Vec<u16> = "punktfunk Virtual DualSense" /// - `pszzCompatibleIds` starting with a `USB\` token → the parent walk resolves `bus_type = USB`.
/// - `pszzHardwareIds` = `pf_dualsense` **first** (so the INF still binds our UMDF driver) followed
/// by `USB\VID_054C&PID_0CE6[&REV_0100]`, which makes hidclass derive the real-DualSense child
/// hardware ids `HID\VID_054C&PID_0CE6[&REV_0100]` (the set a genuine USB DS5 exposes).
/// - a deterministic, non-sentinel per-pad `pContainerId` (groups the pad's devnodes; avoids the
/// null-sentinel ContainerId that trips an `xinput1_4` slot-skip bug).
///
/// (Validated live on `.173`: the INF still binds, the child gains the `HID\VID&PID` ids, and the
/// parent walk reports USB. Remaining gap: GameInput parses VID/PID from the child *instance path*
/// `HID\punktfunk\…`, which only a real USB-bus instance path — a bus driver — would change.)
///
/// Two requirements each yield E_INVALIDARG if violated: the enumerator name must not contain `_`
/// (hence `punktfunk`, not `pf_dualsense`), and the completion callback is mandatory (the docs mark
/// `pCallback` as `[in]`, not optional — a NULL callback is rejected). The caller must be
/// Administrator (the host service runs as LocalSystem).
pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<HSWDEVICE> {
// Build a double-NUL-terminated UTF-16 multi-sz from a list of ids.
let multi_sz = |ids: &[&str]| -> Vec<u16> {
ids.iter()
.flat_map(|s| s.encode_utf16().chain(std::iter::once(0)))
.chain(std::iter::once(0))
.collect()
};
let usb_rev = format!("USB\\{}&REV_0100", p.usb_vid_pid);
let usb = format!("USB\\{}", p.usb_vid_pid);
let hwids = multi_sz(&[
p.hwid, // FIRST → the INF binds our UMDF driver on this id
usb_rev.as_str(),
usb.as_str(),
]);
let compat = multi_sz(&[
usb.as_str(), // a `USB\` token → native bus-type detection resolves USB
"USB\\Class_03&SubClass_00&Prot_00",
"USB\\Class_03",
]);
let instid: Vec<u16> = p
.instance
.encode_utf16() .encode_utf16()
.chain(std::iter::once(0)) .chain(std::iter::once(0))
.collect(); .collect();
// SAFETY: zeroed then the fields we use are set; cbSize identifies the struct version. let desc: Vec<u16> = p
.description
.encode_utf16()
.chain(std::iter::once(0))
.collect();
// The pad index, stamped into the device Location — the driver reads it to map `pfds-shm-<index>`
// (multi-pad). The buffer outlives the SwDeviceCreate call (we wait on the event before return).
let loc: Vec<u16> = format!("{}", p.container_index)
.encode_utf16()
.chain(std::iter::once(0))
.collect();
// Deterministic per-pad ContainerId {50464453-0000-0000-0000-0000000000<idx>} ("PFDS").
let container = GUID::from_values(
0x5046_4453,
0x0000,
0x0000,
[0, 0, 0, 0, 0, 0, 0, p.container_index],
);
// SAFETY: zeroed then the fields we use are set; cbSize identifies the struct version. The id
// buffers and `container` outlive the SwDeviceCreate call (we wait on the event before return).
let mut info: SW_DEVICE_CREATE_INFO = unsafe { std::mem::zeroed() }; let mut info: SW_DEVICE_CREATE_INFO = unsafe { std::mem::zeroed() };
info.cbSize = std::mem::size_of::<SW_DEVICE_CREATE_INFO>() as u32; info.cbSize = std::mem::size_of::<SW_DEVICE_CREATE_INFO>() as u32;
info.pszInstanceId = PCWSTR(instid.as_ptr());
info.pszzHardwareIds = PCWSTR(hwids.as_ptr()); info.pszzHardwareIds = PCWSTR(hwids.as_ptr());
info.pszzCompatibleIds = PCWSTR(compat.as_ptr());
info.pContainerId = &container;
info.pszDeviceDescription = PCWSTR(desc.as_ptr()); info.pszDeviceDescription = PCWSTR(desc.as_ptr());
// SWDeviceCapabilities: DriverRequired (8) | SilentInstall (2) | Removable (1). info.pszDeviceLocation = PCWSTR(loc.as_ptr());
info.CapabilityFlags = 0x0000_000B; info.CapabilityFlags = 0x0000_000B; // DriverRequired | SilentInstall | Removable
// SAFETY: a manual-reset, initially-unsignaled, unnamed event. // SAFETY: a manual-reset, initially-unsignaled, unnamed event.
let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? }; let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? };
@@ -116,7 +193,7 @@ fn create_swdevice() -> Result<HSWDEVICE> {
event, event,
result: HRESULT(0), result: HRESULT(0),
}; };
// SAFETY: info + hwids/desc outlive the call; ctx outlives the callback (we wait below). // SAFETY: info + the buffers + ctx outlive the call (we wait on the event before returning);
// windows-rs returns the HSWDEVICE (the C out-param) as the Result value. // windows-rs returns the HSWDEVICE (the C out-param) as the Result value.
let hsw = match unsafe { let hsw = match unsafe {
SwDeviceCreate( SwDeviceCreate(
@@ -137,7 +214,8 @@ fn create_swdevice() -> Result<HSWDEVICE> {
return Err(anyhow!("SwDeviceCreate failed: {e}")); return Err(anyhow!("SwDeviceCreate failed: {e}"));
} }
}; };
// SAFETY: event is valid; block up to 10s for the creation callback. // Block until PnP finishes enumerating (the callback signals), then check its result.
// SAFETY: event is valid.
unsafe { unsafe {
WaitForSingleObject(event, 10_000); WaitForSingleObject(event, 10_000);
let _ = CloseHandle(event); let _ = CloseHandle(event);
@@ -145,7 +223,10 @@ fn create_swdevice() -> Result<HSWDEVICE> {
if ctx.result.is_err() { if ctx.result.is_err() {
// SAFETY: hsw is the handle SwDeviceCreate returned. // SAFETY: hsw is the handle SwDeviceCreate returned.
unsafe { SwDeviceClose(hsw) }; unsafe { SwDeviceClose(hsw) };
return Err(anyhow!("SwDeviceCreate callback reported {:?}", ctx.result)); return Err(anyhow!(
"SwDeviceCreate enumeration failed: {:?}",
ctx.result
));
} }
Ok(hsw) Ok(hsw)
} }
@@ -155,51 +236,15 @@ impl DsWinPad {
/// `root\pf_dualsense` devnode (the driver loads on it and maps the section). The devnode lives /// `root\pf_dualsense` devnode (the driver loads on it and maps the section). The devnode lives
/// for the pad's lifetime — dropping the pad removes it (`SwDeviceClose`). /// for the pad's lifetime — dropping the pad removes it (`SwDeviceClose`).
fn open(index: u8) -> Result<DsWinPad> { fn open(index: u8) -> Result<DsWinPad> {
let name = HSTRING::from(format!("Global\\pfds-shm-{index}")); let shm = super::gamepad_raii::Shm::create(
&HSTRING::from(pf_driver_proto::gamepad::pad_shm_name(index)),
// A permissive DACL so the WUDFHost (whatever account it runs as) can open the section. SHM_SIZE,
let mut psd = PSECURITY_DESCRIPTOR::default(); )?;
// SAFETY: the SDDL literal is valid; psd receives an allocated descriptor (freed by the OS let base = shm.base();
// when the process exits — acceptable for a host-lifetime object). // Stamp the neutral input report, then the magic LAST (the driver only accepts the section
unsafe { // once magic is set). The device-type stays 0 (DualSense — the section is already zeroed).
ConvertStringSecurityDescriptorToSecurityDescriptorW(
w!("D:(A;;GA;;;WD)"),
SDDL_REVISION_1,
&mut psd,
None,
)?;
}
let sa = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: psd.0,
bInheritHandle: false.into(),
};
// SAFETY: anonymous (pagefile-backed) section of SHM_SIZE bytes with the SDDL above.
let map = unsafe {
CreateFileMappingW(
INVALID_HANDLE_VALUE,
Some(&sa),
PAGE_READWRITE,
0,
SHM_SIZE as u32,
PCWSTR(name.as_ptr()),
)?
};
// SAFETY: map is a valid section handle; map the whole thing.
let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, SHM_SIZE) };
if view.Value.is_null() {
// SAFETY: map is valid.
unsafe {
let _ = CloseHandle(map);
}
return Err(anyhow!("MapViewOfFile failed for {name}"));
}
let base = view.Value as *mut u8;
// Zero the section then stamp the magic LAST (the driver only accepts it once magic is set).
// SAFETY: base points at SHM_SIZE writable bytes. // SAFETY: base points at SHM_SIZE writable bytes.
unsafe { unsafe {
std::ptr::write_bytes(base, 0, SHM_SIZE);
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], { std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], {
let mut r = [0u8; DS_INPUT_REPORT_LEN]; let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, &DsState::neutral(), 0, 0); serialize_state(&mut r, &DsState::neutral(), 0, 0);
@@ -207,20 +252,27 @@ impl DsWinPad {
}); });
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
} }
// Best-effort: spawn a per-session devnode via SwDeviceCreate. It currently fails with a // Spawn the per-session devnode via SwDeviceCreate; `SwDeviceClose` removes it on drop. On the
// SwDevice quirk (see create_swdevice), so on failure we keep the section + data plane and // rare failure we keep the section + data plane and fall back to an out-of-band `pf_dualsense`
// rely on an out-of-band `pf_dualsense` devnode (installer / dev-box devgen). // devnode (installer / dev-box devgen).
let hsw = match create_swdevice() { let inst = format!("pf_pad_{index}");
let hsw = match create_swdevice(&SwDeviceProfile {
instance: &inst,
container_index: index,
hwid: "pf_dualsense",
usb_vid_pid: "VID_054C&PID_0CE6",
description: "punktfunk Virtual DualSense",
}) {
Ok(h) => Some(h), Ok(h) => Some(h),
Err(e) => { Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; using an out-of-band pf_dualsense devnode"); tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; falling back to an out-of-band pf_dualsense devnode");
None None
} }
}; };
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
Ok(DsWinPad { Ok(DsWinPad {
hsw, _sw,
map, shm,
view: base,
seq: 0, seq: 0,
ts: 0, ts: 0,
last_out_seq: 0, last_out_seq: 0,
@@ -233,22 +285,25 @@ impl DsWinPad {
self.ts = self.ts.wrapping_add(1); self.ts = self.ts.wrapping_add(1);
let mut r = [0u8; DS_INPUT_REPORT_LEN]; let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, st, self.seq, self.ts); serialize_state(&mut r, st, self.seq, self.ts);
// SAFETY: view points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. // SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
unsafe { std::ptr::copy_nonoverlapping(r.as_ptr(), self.view.add(OFF_INPUT), r.len()) }; unsafe {
std::ptr::copy_nonoverlapping(r.as_ptr(), self.shm.base().add(OFF_INPUT), r.len())
};
} }
/// Poll the section's output slot; parse a new `0x02` report (rumble / LEDs / triggers) into a /// Poll the section's output slot; parse a new `0x02` report (rumble / LEDs / triggers) into a
/// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything new. /// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything new.
fn service(&mut self, pad: u8) -> DsFeedback { fn service(&mut self, pad: u8) -> DsFeedback {
let mut fb = DsFeedback::default(); let mut fb = DsFeedback::default();
// SAFETY: view points at SHM_SIZE bytes. // SAFETY: base points at SHM_SIZE bytes.
let seq = unsafe { std::ptr::read_unaligned(self.view.add(OFF_OUT_SEQ) as *const u32) }; let seq =
unsafe { std::ptr::read_unaligned(self.shm.base().add(OFF_OUT_SEQ) as *const u32) };
if seq != self.last_out_seq { if seq != self.last_out_seq {
self.last_out_seq = seq; self.last_out_seq = seq;
let mut out = [0u8; 64]; let mut out = [0u8; 64];
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section. // SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
unsafe { unsafe {
std::ptr::copy_nonoverlapping(self.view.add(OFF_OUTPUT), out.as_mut_ptr(), 64) std::ptr::copy_nonoverlapping(self.shm.base().add(OFF_OUTPUT), out.as_mut_ptr(), 64)
}; };
parse_ds_output(pad, &out, &mut fb); parse_ds_output(pad, &out, &mut fb);
} }
@@ -256,21 +311,6 @@ impl DsWinPad {
} }
} }
impl Drop for DsWinPad {
fn drop(&mut self) {
// SAFETY: hsw (if any) owns the devnode; view/map from MapViewOfFile/CreateFileMappingW.
unsafe {
if let Some(h) = self.hsw {
SwDeviceClose(h);
}
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
Value: self.view as *mut c_void,
});
let _ = CloseHandle(self.map);
}
}
}
/// All virtual DualSense pads of a session — the Windows analogue of /// All virtual DualSense pads of a session — the Windows analogue of
/// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface so the session input /// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface so the session input
/// thread drives either backend identically. /// thread drives either backend identically.
@@ -0,0 +1,290 @@
//! Virtual Sony DualShock 4 on Windows via the UMDF minidriver — the PS4 sibling of
//! [`super::dualsense_windows`]. Same transport (a per-session `SwDeviceCreate` devnode + the
//! `Global\pfds-shm-<idx>` shared section the driver maps), same controller model ([`DsState`]); only
//! the PnP identity (`VID_054C&PID_09CC`, hardware id `pf_dualshock4`) and the report codec
//! ([`super::dualshock4_proto`]) differ. The host stamps `device_type = 1` (DualShock 4) into the
//! section so the one UMDF driver serves the DS4 descriptor / attributes / features instead of the
//! DualSense ones. Feedback is motor rumble (universal 0xCA plane) + the lightbar (0xCD `Led`); a DS4
//! has no adaptive triggers / player LEDs.
use super::dualsense_proto::DsState;
use super::dualsense_windows::{
create_swdevice, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, OFF_INPUT, OFF_OUTPUT,
OFF_OUT_SEQ, SHM_MAGIC, SHM_SIZE,
};
use super::dualshock4_proto::{
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W,
};
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use anyhow::Result;
use punktfunk_core::quic::{HidOutput, RichInput};
use std::time::{Duration, Instant};
use windows::core::HSTRING;
/// A single virtual DualShock 4: the `SwDeviceCreate`'d `pf_ds4_<index>` devnode plus the mapped
/// shared section. Dropping it removes the devnode and unmaps + closes the section.
struct Ds4WinPad {
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
_sw: Option<super::gamepad_raii::SwDevice>,
/// The named shared section the driver maps (RAII — unmapped + closed on drop).
shm: super::gamepad_raii::Shm,
counter: u8,
ts: u16,
last_out_seq: u32,
}
impl Ds4WinPad {
/// Create + map the section, stamp `device_type = DualShock 4` + a neutral report + the magic,
/// then spawn the `pf_ds4_<index>` devnode (the driver loads on it and maps the section).
fn open(index: u8) -> Result<Ds4WinPad> {
let shm = super::gamepad_raii::Shm::create(
&HSTRING::from(pf_driver_proto::gamepad::pad_shm_name(index)),
SHM_SIZE,
)?;
let base = shm.base();
// device-type FIRST (so it's visible the moment magic is), neutral report, magic LAST.
// SAFETY: base points at SHM_SIZE writable bytes; OFF_DEVTYPE/OFF_INPUT are in range.
unsafe {
*base.add(OFF_DEVTYPE) = DEVTYPE_DUALSHOCK4;
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS4_INPUT_REPORT_LEN], {
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
serialize_state(&mut r, &DsState::neutral(), 0, 0);
r
});
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
}
let inst = format!("pf_ds4_{index}");
let hsw = match create_swdevice(&SwDeviceProfile {
instance: &inst,
container_index: index,
hwid: "pf_dualshock4",
usb_vid_pid: "VID_054C&PID_09CC",
description: "punktfunk Virtual DualShock 4",
}) {
Ok(h) => Some(h),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; DualShock 4 devnode unavailable");
None
}
};
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
Ok(Ds4WinPad {
_sw,
shm,
counter: 0,
ts: 0,
last_out_seq: 0,
})
}
/// Serialize `st` into report `0x01` and publish it to the section's input slot.
fn write_state(&mut self, st: &DsState) {
self.counter = self.counter.wrapping_add(1);
self.ts = self.ts.wrapping_add(188); // ~1ms in the DS4's 5.33µs sensor-clock units
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
serialize_state(&mut r, st, self.counter, self.ts);
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
unsafe {
std::ptr::copy_nonoverlapping(r.as_ptr(), self.shm.base().add(OFF_INPUT), r.len())
};
}
/// Poll the section's output slot; parse a new `0x05` report (rumble / lightbar) into a
/// [`Ds4Feedback`]. Returns empty feedback if the driver hasn't published anything new.
fn service(&mut self) -> Ds4Feedback {
let mut fb = Ds4Feedback::default();
// SAFETY: base points at SHM_SIZE bytes.
let seq =
unsafe { std::ptr::read_unaligned(self.shm.base().add(OFF_OUT_SEQ) as *const u32) };
if seq != self.last_out_seq {
self.last_out_seq = seq;
let mut out = [0u8; 64];
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
unsafe {
std::ptr::copy_nonoverlapping(self.shm.base().add(OFF_OUTPUT), out.as_mut_ptr(), 64)
};
parse_ds4_output(&out, &mut fb);
}
fb
}
}
/// All virtual DualShock 4 pads of a session — the Windows analogue of
/// [`DualShock4Manager`](super::dualshock4::DualShock4Manager), with the same method surface as the
/// Windows DualSense manager so the session input thread drives either backend identically.
pub struct DualShock4WindowsManager {
pads: Vec<Option<Ds4WinPad>>,
state: Vec<DsState>,
last_rumble: Vec<(u16, u16)>,
last_led: Vec<Option<(u8, u8, u8)>>,
last_write: Vec<Instant>,
broken: bool,
}
impl Default for DualShock4WindowsManager {
fn default() -> DualShock4WindowsManager {
DualShock4WindowsManager::new()
}
}
impl DualShock4WindowsManager {
pub fn new() -> DualShock4WindowsManager {
DualShock4WindowsManager {
pads: (0..MAX_PADS).map(|_| None).collect(),
state: vec![DsState::neutral(); MAX_PADS],
last_rumble: vec![(0, 0); MAX_PADS],
last_led: vec![None; MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
broken: false,
}
}
/// Handle one decoded controller event (create/destroy by mask, then merge button/stick state).
pub fn handle(&mut self, ev: &GamepadEvent) {
match ev {
GamepadEvent::Arrival { index, kind, .. } => {
tracing::info!(index, kind, "controller arrival (DualShock 4/Windows)");
self.ensure(*index as usize);
}
GamepadEvent::State(f) => {
let idx = f.index as usize;
if idx >= MAX_PADS {
return;
}
for (i, slot) in self.pads.iter_mut().enumerate() {
if slot.is_some() && f.active_mask & (1 << i) == 0 {
tracing::info!(index = i, "controller unplugged (DualShock 4/Windows)");
*slot = None;
self.state[i] = DsState::neutral();
self.last_rumble[i] = (0, 0);
self.last_led[i] = None;
}
}
if f.active_mask & (1 << idx) == 0 {
return;
}
self.ensure(idx);
let prev = self.state[idx];
let mut s = DsState::from_gamepad(
f.buttons,
f.ls_x,
f.ls_y,
f.rs_x,
f.rs_y,
f.left_trigger,
f.right_trigger,
);
s.touch = prev.touch;
s.gyro = prev.gyro;
s.accel = prev.accel;
self.state[idx] = s;
self.write(idx);
}
}
}
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad.
pub fn apply_rich(&mut self, rich: RichInput) {
let idx = match rich {
RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize,
};
if idx >= MAX_PADS || self.pads[idx].is_none() {
return;
}
match rich {
RichInput::Touchpad {
finger,
active,
x,
y,
..
} => {
let slot = (finger as usize).min(1);
let t = &mut self.state[idx].touch[slot];
t.active = active;
t.id = slot as u8;
t.x = ((x as u32 * (DS4_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16;
t.y = ((y as u32 * (DS4_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16;
}
RichInput::Motion { gyro, accel, .. } => {
self.state[idx].gyro = gyro;
self.state[idx].accel = accel;
}
}
self.write(idx);
}
fn write(&mut self, idx: usize) {
let st = self.state[idx];
if let Some(pad) = self.pads[idx].as_mut() {
pad.write_state(&st);
}
self.last_write[idx] = Instant::now();
}
/// Re-emit each live pad's current report if it's been silent for `max_gap` (parity with the
/// other backends' heartbeat — keeps the section fresh).
pub fn heartbeat(&mut self, max_gap: Duration) {
let now = Instant::now();
for i in 0..self.pads.len() {
if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap {
self.write(i);
}
}
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
return;
}
match Ds4WinPad::open(idx as u8) {
Ok(p) => {
tracing::info!(
index = idx,
"virtual DualShock 4 created (Windows UMDF shm channel)"
);
self.pads[idx] = Some(p);
self.state[idx] = DsState::neutral();
self.last_rumble[idx] = (0, 0);
self.last_led[idx] = None;
self.last_write[idx] = Instant::now();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — controller input disabled");
self.broken = true;
}
}
}
/// Service every pad: poll the section for a game's feedback. `rumble` fires `(index, low, high)`
/// only on change (universal 0xCA plane); `hidout` fires the lightbar (0xCD `Led`), deduped.
pub fn pump(
&mut self,
mut rumble: impl FnMut(u16, u16, u16),
mut hidout: impl FnMut(HidOutput),
) {
for i in 0..self.pads.len() {
let Some(pad) = self.pads[i].as_mut() else {
continue;
};
let fb = pad.service();
if let Some(r) = fb.rumble {
if self.last_rumble[i] != r {
self.last_rumble[i] = r;
rumble(i as u16, r.0, r.1);
}
}
if let Some(rgb) = fb.led {
if self.last_led[i] != Some(rgb) {
self.last_led[i] = Some(rgb);
hidout(HidOutput::Led {
pad: i as u8,
r: rgb.0,
g: rgb.1,
b: rgb.2,
});
}
}
}
}
}
@@ -0,0 +1,115 @@
//! Per-pad Windows resource RAII for the gamepad backends (DualSense / DualShock 4 / XUSB).
//!
//! Each virtual pad owns two OS resources: the named shared-memory section (+ its mapped view) the
//! `pf_dualsense`/`pf_xusb` driver reads, and the `SwDeviceCreate`'d software devnode the driver loads
//! on. Before this module, all three backends hand-rolled the same `CreateFileMappingW` +
//! `MapViewOfFile` and an identical `Drop` doing `SwDeviceClose` + `UnmapViewOfFile` + `CloseHandle` —
//! easy to drift or leak on an error path. [`Shm`] and [`SwDevice`] own those resources with RAII, so a
//! backend just holds them and the cleanup (and ordering) happens by construction.
use anyhow::{anyhow, Result};
use std::os::windows::io::{FromRawHandle, OwnedHandle};
use windows::core::{w, HSTRING, PCWSTR};
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE};
use windows::Win32::Foundation::INVALID_HANDLE_VALUE;
use windows::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
};
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
use windows::Win32::System::Memory::{
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
};
/// A named, anonymous (pagefile-backed) shared section + its mapped read/write view, created with the
/// permissive `D:(A;;GA;;;WD)` SDDL the restricted-token driver needs to open it. RAII: drop unmaps the
/// view, then the [`OwnedHandle`] closes the section handle (in that order). Replaces the three backends'
/// hand-duplicated `CreateFileMappingW` + `MapViewOfFile` + manual `Drop`.
pub(super) struct Shm {
/// Owns the section handle (closed on drop). Held only for ownership — never read after construction.
_handle: OwnedHandle,
view: MEMORY_MAPPED_VIEW_ADDRESS,
}
impl Shm {
/// Create + zero a `size`-byte section named `name`, mapped read/write. The section handle is owned
/// immediately, so any failure below (or the returned `Shm`'s drop) closes it.
pub(super) fn create(name: &HSTRING, size: usize) -> Result<Shm> {
let mut psd = PSECURITY_DESCRIPTOR::default();
// SAFETY: the SDDL literal is valid; `psd` receives an OS-allocated descriptor (freed at process
// exit — acceptable for a host-lifetime object).
unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
w!("D:(A;;GA;;;WD)"),
SDDL_REVISION_1,
&mut psd,
None,
)?;
}
let sa = SECURITY_ATTRIBUTES {
nLength: core::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: psd.0,
bInheritHandle: false.into(),
};
// SAFETY: an anonymous (pagefile-backed) section of `size` bytes with the SDDL above.
let map = unsafe {
CreateFileMappingW(
INVALID_HANDLE_VALUE,
Some(&sa),
PAGE_READWRITE,
0,
size as u32,
PCWSTR(name.as_ptr()),
)?
};
// SAFETY: `map` is a fresh section handle we own; take ownership immediately so that the early
// return below (and the eventual drop) closes it. `map` (a `Copy` `HANDLE`) stays usable for the
// `MapViewOfFile` borrow that follows — `from_raw_handle` only copies the inner pointer.
let handle = unsafe { OwnedHandle::from_raw_handle(map.0) };
// SAFETY: `map` is a valid section handle; map the whole thing read/write.
let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, size) };
if view.Value.is_null() {
// `handle` drops here → closes the section. No view to unmap.
return Err(anyhow!("MapViewOfFile failed for {name}"));
}
// SAFETY: `view` points at `size` writable bytes (just mapped).
unsafe { core::ptr::write_bytes(view.Value as *mut u8, 0, size) };
Ok(Shm {
_handle: handle,
view,
})
}
/// The mapped section's base pointer. Stable for the `Shm`'s lifetime (moving the `Shm` does not
/// relocate the OS mapping — the view address is fixed by `MapViewOfFile`).
pub(super) fn base(&self) -> *mut u8 {
self.view.Value as *mut u8
}
}
impl Drop for Shm {
fn drop(&mut self) {
// SAFETY: `view` came from `MapViewOfFile`; unmap it BEFORE the `_handle` field closes the
// section (struct fields drop only after this `Drop::drop` returns).
unsafe {
let _ = UnmapViewOfFile(self.view);
}
}
}
/// A `SwDeviceCreate`'d software devnode; drop removes it via `SwDeviceClose`. Replaces the manual
/// `SwDeviceClose` each backend used to call in its `Drop`.
pub(super) struct SwDevice(HSWDEVICE);
impl SwDevice {
pub(super) fn new(hsw: HSWDEVICE) -> Self {
SwDevice(hsw)
}
}
impl Drop for SwDevice {
fn drop(&mut self) {
// SAFETY: `self.0` is the handle `SwDeviceCreate` returned; `SwDeviceClose` removes the devnode.
unsafe { SwDeviceClose(self.0) };
}
}
@@ -0,0 +1,315 @@
//! Windows virtual Xbox 360 gamepad via the punktfunk **XUSB companion** UMDF driver
//! (`packaging/windows/xusb-driver`) — the in-tree replacement for ViGEmBus. One virtual Xbox 360
//! controller per client pad index, visible to classic **XInput** (`XInputGetState`) with no kernel
//! bus driver: each pad `SwDeviceCreate`s a `pf_xusb_<index>` devnode (the driver loads on it and
//! registers `GUID_DEVINTERFACE_XUSB`) and the host pushes the XInput state into the shared section
//! `Global\pfxusb-shm-<index>`. GameStream/Moonlight already speak the XInput conventions (low-16
//! button bits, sticks 32768..32767 +Y up, triggers 0..255), so the state copy is ~1:1.
//!
//! Rumble flows back the other way: a game writes force-feedback via `XInputSetState`, the driver
//! parses the `SET_STATE` packet into the shared section, and [`GamepadManager::pump_rumble`] relays
//! level changes to the client (the universal 0xCA plane), mirroring the Linux `EV_FF` read path.
//!
//! NB: the driver currently maps `Global\pfxusb-shm-0` (hardcoded), so a single pad (index 0) is
//! fully correct; mixed multi-pad needs the driver to read its own index first (same limitation as
//! the DualSense backend).
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use anyhow::{anyhow, Result};
use std::ffi::c_void;
use windows::core::{w, GUID, HRESULT, HSTRING, PCWSTR};
use windows::Win32::Devices::Enumeration::Pnp::{
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
};
use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject};
// Shared-section layout — the single source of truth is `pf_driver_proto::gamepad::XusbShm` (offset
// asserts pin every field; the `pf_xusb` 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).
use pf_driver_proto::gamepad::XusbShm;
const SHM_SIZE: usize = core::mem::size_of::<XusbShm>();
const SHM_MAGIC: u32 = pf_driver_proto::gamepad::XUSB_MAGIC; // "PFXU"
const OFF_PACKET: usize = core::mem::offset_of!(XusbShm, packet);
const OFF_BUTTONS: usize = core::mem::offset_of!(XusbShm, buttons);
const OFF_LT: usize = core::mem::offset_of!(XusbShm, left_trigger);
const OFF_RT: usize = core::mem::offset_of!(XusbShm, right_trigger);
const OFF_LX: usize = core::mem::offset_of!(XusbShm, thumb_lx);
const OFF_LY: usize = core::mem::offset_of!(XusbShm, thumb_ly);
const OFF_RX: usize = core::mem::offset_of!(XusbShm, thumb_rx);
const OFF_RY: usize = core::mem::offset_of!(XusbShm, thumb_ry);
const OFF_RUMBLE_SEQ: usize = core::mem::offset_of!(XusbShm, rumble_seq);
const OFF_RUMBLE: usize = core::mem::offset_of!(XusbShm, rumble_large); // large @28, small @29
/// Context for the `SwDeviceCreate` completion callback: an event to signal + the HRESULT it reports.
#[repr(C)]
struct SwCreateCtx {
event: HANDLE,
result: HRESULT,
}
/// `SwDeviceCreate` fires this once PnP has enumerated the device; stash the result + wake the creator.
unsafe extern "system" fn sw_create_cb(
_dev: HSWDEVICE,
result: HRESULT,
ctx: *const c_void,
_id: PCWSTR,
) {
if !ctx.is_null() {
// SAFETY: ctx is the &mut SwCreateCtx the creator passed; it outlives this callback.
unsafe {
let c = ctx as *mut SwCreateCtx;
(*c).result = result;
let _ = SetEvent((*c).event);
}
}
}
/// Spawn the `pf_xusb_<index>` companion devnode (hardware id `pf_xusb`, enumerator `punktfunk`). The
/// INF (System class) binds our UMDF driver, which registers the XUSB interface. Unlike the HID pads,
/// no USB compatible-ids are needed — XInput finds the device by the interface GUID, not VID/PID — but
/// we still pass a deterministic non-null `pContainerId` (the null-sentinel trips an `xinput1_4`
/// slot-skip bug). `SwDeviceClose` removes it on drop.
fn create_swdevice(index: u8) -> Result<HSWDEVICE> {
let hwids: Vec<u16> = "pf_xusb".encode_utf16().chain([0u16, 0u16]).collect();
let instid: Vec<u16> = format!("pf_xusb_{index}")
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let desc: Vec<u16> = "punktfunk Virtual Xbox 360 (XUSB)"
.encode_utf16()
.chain(std::iter::once(0))
.collect();
// The pad index, stamped into the device Location — the driver reads it to map `pfxusb-shm-<index>`
// (multi-pad). The buffer must outlive the SwDeviceCreate call (it does; we wait on the event).
let loc: Vec<u16> = format!("{index}")
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let container = GUID::from_values(0x5046_5855, 0x0000, 0x0000, [0, 0, 0, 0, 0, 0, 0, index]);
// SAFETY: zeroed then the fields we use are set; the buffers + container outlive the call.
let mut info: SW_DEVICE_CREATE_INFO = unsafe { std::mem::zeroed() };
info.cbSize = std::mem::size_of::<SW_DEVICE_CREATE_INFO>() as u32;
info.pszInstanceId = PCWSTR(instid.as_ptr());
info.pszzHardwareIds = PCWSTR(hwids.as_ptr());
info.pContainerId = &container;
info.pszDeviceDescription = PCWSTR(desc.as_ptr());
info.pszDeviceLocation = PCWSTR(loc.as_ptr());
info.CapabilityFlags = 0x0000_000B; // DriverRequired | SilentInstall | Removable
// SAFETY: a manual-reset, initially-unsignaled, unnamed event.
let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? };
let mut ctx = SwCreateCtx {
event,
result: HRESULT(0),
};
// SAFETY: info + buffers + ctx outlive the call (we wait on the event before returning).
let hsw = match unsafe {
SwDeviceCreate(
w!("punktfunk"),
w!("HTREE\\ROOT\\0"),
&info,
None,
Some(sw_create_cb),
Some(&mut ctx as *mut SwCreateCtx as *const c_void),
)
} {
Ok(h) => h,
Err(e) => {
// SAFETY: event is valid.
unsafe {
let _ = CloseHandle(event);
}
return Err(anyhow!("SwDeviceCreate(pf_xusb) failed: {e}"));
}
};
// SAFETY: event valid; block until PnP finishes enumerating, then check the callback result.
unsafe {
WaitForSingleObject(event, 10_000);
let _ = CloseHandle(event);
}
if ctx.result.is_err() {
// SAFETY: hsw is the handle SwDeviceCreate returned.
unsafe { SwDeviceClose(hsw) };
return Err(anyhow!(
"SwDeviceCreate(pf_xusb) enumeration failed: {:?}",
ctx.result
));
}
Ok(hsw)
}
/// A single virtual Xbox 360 pad: the `pf_xusb_<index>` devnode plus the mapped shared section.
struct XusbWinPad {
/// Owns the `pf_xusb_<index>` devnode (dropped → `SwDeviceClose`). `None` if `SwDeviceCreate` failed.
_sw: Option<super::gamepad_raii::SwDevice>,
/// Owns `Global\pfxusb-shm-<index>` (the section + its mapped view; drop unmaps + closes).
shm: super::gamepad_raii::Shm,
packet: u32,
last_rumble_seq: u32,
}
impl XusbWinPad {
/// Create + map `Global\pfxusb-shm-<index>`, stamp the magic, then spawn the devnode.
fn open(index: u8) -> Result<XusbWinPad> {
// Permissive-DACL named section the WUDFHost (whatever account) can open; `Shm` owns the
// section handle + its mapped view (zero-filled) and unmaps/closes on drop.
let shm = super::gamepad_raii::Shm::create(
&HSTRING::from(pf_driver_proto::gamepad::xusb_shm_name(index)),
SHM_SIZE,
)?;
let base = shm.base();
// Zero the section then stamp the magic LAST (the driver only accepts it once magic is set).
// SAFETY: base points at SHM_SIZE writable bytes.
unsafe {
std::ptr::write_bytes(base, 0, SHM_SIZE);
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
}
let hsw = match create_swdevice(index) {
Ok(h) => Some(h),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; XUSB devnode unavailable");
None
}
};
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
Ok(XusbWinPad {
_sw,
shm,
packet: 0,
last_rumble_seq: 0,
})
}
/// Publish the XInput state to the section and bump the packet number (XInput uses it to detect
/// change). `buttons` is the XINPUT_GAMEPAD_* bitmap; sticks are i16, triggers u8.
#[allow(clippy::too_many_arguments)]
fn write_state(&mut self, buttons: u16, lt: u8, rt: u8, lx: i16, ly: i16, rx: i16, ry: i16) {
self.packet = self.packet.wrapping_add(1);
let base = self.shm.base();
// SAFETY: `base` is the start of the mapped section (`SHM_SIZE` bytes, owned by `Shm`); every
// `OFF_*` is a fixed in-range offset into it and `write_unaligned` handles the unaligned field
// writes. Single owner (`&mut self`), so no concurrent writer races these stores.
unsafe {
std::ptr::write_unaligned(base.add(OFF_BUTTONS) as *mut u16, buttons);
*base.add(OFF_LT) = lt;
*base.add(OFF_RT) = rt;
std::ptr::write_unaligned(base.add(OFF_LX) as *mut i16, lx);
std::ptr::write_unaligned(base.add(OFF_LY) as *mut i16, ly);
std::ptr::write_unaligned(base.add(OFF_RX) as *mut i16, rx);
std::ptr::write_unaligned(base.add(OFF_RY) as *mut i16, ry);
std::ptr::write_unaligned(base.add(OFF_PACKET) as *mut u32, self.packet);
}
}
/// Poll the section for a game's rumble (the driver bumps `rumble_seq` on each SET_STATE). Returns
/// `(large, small)` motor levels (0..=255) when a new one arrived.
fn service(&mut self) -> Option<(u8, u8)> {
let base = self.shm.base();
// SAFETY: base points at SHM_SIZE bytes.
let seq = unsafe { std::ptr::read_unaligned(base.add(OFF_RUMBLE_SEQ) as *const u32) };
if seq == self.last_rumble_seq {
return None;
}
self.last_rumble_seq = seq;
// SAFETY: rumble bytes at OFF_RUMBLE / OFF_RUMBLE+1.
let (large, small) = unsafe { (*base.add(OFF_RUMBLE), *base.add(OFF_RUMBLE + 1)) };
Some((large, small))
}
}
/// All virtual Xbox 360 pads of a session — the Windows analogue of the Linux uinput-xpad manager,
/// now backed by the XUSB companion driver. Same method surface (`new`/`handle`/`pump_rumble`) the
/// session input thread already drives.
pub struct GamepadManager {
pads: Vec<Option<XusbWinPad>>,
last_rumble: Vec<(u8, u8)>,
broken: bool,
}
impl Default for GamepadManager {
fn default() -> GamepadManager {
GamepadManager::new()
}
}
impl GamepadManager {
pub fn new() -> GamepadManager {
GamepadManager {
pads: (0..MAX_PADS).map(|_| None).collect(),
last_rumble: vec![(0, 0); MAX_PADS],
broken: false,
}
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
return;
}
match XusbWinPad::open(idx as u8) {
Ok(p) => {
tracing::info!(
index = idx,
"virtual Xbox 360 created (Windows XUSB companion)"
);
self.pads[idx] = Some(p);
self.last_rumble[idx] = (0, 0);
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual Xbox 360 creation failed — controller input disabled (is the pf_xusb driver installed?)");
self.broken = true;
}
}
}
pub fn handle(&mut self, ev: &GamepadEvent) {
let GamepadEvent::State(f) = ev else {
return; // Arrival metadata — the pad is created lazily on the first State
};
let idx = f.index.max(0) as usize;
if idx >= MAX_PADS {
return;
}
// Unplugs: drop any allocated pad whose mask bit cleared.
for (i, slot) in self.pads.iter_mut().enumerate() {
if slot.is_some() && f.active_mask & (1 << i) == 0 {
tracing::info!(index = i, "controller unplugged (Xbox 360/Windows)");
*slot = None;
self.last_rumble[i] = (0, 0);
}
}
if f.active_mask & (1 << idx) == 0 {
return;
}
self.ensure(idx);
if let Some(pad) = self.pads[idx].as_mut() {
pad.write_state(
(f.buttons & 0xffff) as u16,
f.left_trigger,
f.right_trigger,
f.ls_x,
f.ls_y,
f.rs_x,
f.rs_y,
);
}
}
/// Relay any changed rumble level to the client. XUSB motors are 0..255; the wire carries
/// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small`
/// (high-frequency) → `high` — matching the other backends.
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
for i in 0..self.pads.len() {
let Some(pad) = self.pads[i].as_mut() else {
continue;
};
if let Some((large, small)) = pad.service() {
if self.last_rumble[i] != (large, small) {
self.last_rumble[i] = (large, small);
send(i as u16, large as u16 * 257, small as u16 * 257);
}
}
}
}
}
@@ -5,6 +5,9 @@
//! thread stays bound to its desktop and only reattaches (`OpenInputDesktop`/`SetThreadDesktop`) when //! thread stays bound to its desktop and only reattaches (`OpenInputDesktop`/`SetThreadDesktop`) when
//! `SendInput` reports a short write (the input desktop switched) — no per-event reattach overhead. //! `SendInput` reports a short write (the input desktop switched) — no per-event reattach overhead.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::Result; use anyhow::Result;
use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::input::{InputEvent, InputKind};
use std::mem::size_of; use std::mem::size_of;
@@ -35,7 +38,12 @@ pub struct SendInputInjector {
desktop: Option<HDESK>, desktop: Option<HDESK>,
} }
// Only ever used from the host's single injector thread (like SudoVdaDisplay). // SAFETY: `SendInputInjector` holds only an `Option<HDESK>` (a desktop handle). The host creates
// and drives it from a single dedicated injector thread; the handle is opened, rebound, and closed
// on whichever thread owns the value, and the type is not `Sync`, so there is never concurrent
// access. A desktop `HDESK` is not thread-affine for ownership (`CloseDesktop` works from any
// thread; `SetThreadDesktop` rebinds the current thread), so transferring ownership via `Send` is
// sound.
unsafe impl Send for SendInputInjector {} unsafe impl Send for SendInputInjector {}
impl SendInputInjector { impl SendInputInjector {
@@ -49,6 +57,12 @@ impl SendInputInjector {
/// Bind this thread to the desktop currently receiving input. UAC / lock screen / Ctrl-Alt-Del /// Bind this thread to the desktop currently receiving input. UAC / lock screen / Ctrl-Alt-Del
/// swap the input desktop; `SendInput` silently no-ops unless our thread is on it. /// swap the input desktop; `SendInput` silently no-ops unless our thread is on it.
fn reattach_input_desktop(&mut self) { fn reattach_input_desktop(&mut self) {
// SAFETY: `OpenInputDesktop`/`SetThreadDesktop`/`CloseDesktop` are FFI calls passed only
// by-value args (constant desktop flags, a `bool`, an access mask). `OpenInputDesktop`
// yields an owned `HDESK` only on `Ok`; we then either install it with `SetThreadDesktop`
// (closing the previously-owned handle exactly once) or close the fresh handle on failure —
// so every handle is closed exactly once and none is used after close. `SetThreadDesktop`
// only rebinds this calling thread, which is where the injector runs.
unsafe { unsafe {
match OpenInputDesktop( match OpenInputDesktop(
DESKTOP_CONTROL_FLAGS(0), DESKTOP_CONTROL_FLAGS(0),
@@ -75,12 +89,17 @@ impl SendInputInjector {
/// switched out from under us, e.g. into UAC/lock) do we reattach to the now-current input desktop /// switched out from under us, e.g. into UAC/lock) do we reattach to the now-current input desktop
/// and retry once. This serves both the normal and secure desktops with no steady-state overhead. /// and retry once. This serves both the normal and secure desktops with no steady-state overhead.
fn send(&mut self, inputs: &[INPUT]) -> Result<()> { fn send(&mut self, inputs: &[INPUT]) -> Result<()> {
// SAFETY: `inputs` is a live `&[INPUT]` slice that outlives this synchronous `SendInput`
// call; `size_of::<INPUT>()` is the exact per-element stride Win32 requires as `cbSize`. The
// call only reads the array (one event per element) and returns the count injected.
let n = unsafe { SendInput(inputs, size_of::<INPUT>() as i32) }; let n = unsafe { SendInput(inputs, size_of::<INPUT>() as i32) };
if n as usize == inputs.len() { if n as usize == inputs.len() {
return Ok(()); return Ok(());
} }
// Short write → the input desktop likely changed. Reattach + retry once. // Short write → the input desktop likely changed. Reattach + retry once.
self.reattach_input_desktop(); self.reattach_input_desktop();
// SAFETY: same as the first `SendInput` — `inputs` is the identical live slice outliving the
// call and `cbSize == size_of::<INPUT>()`; only re-issued after reattaching the input desktop.
let n = unsafe { SendInput(inputs, size_of::<INPUT>() as i32) }; let n = unsafe { SendInput(inputs, size_of::<INPUT>() as i32) };
if n as usize != inputs.len() { if n as usize != inputs.len() {
anyhow::bail!( anyhow::bail!(
@@ -95,6 +114,9 @@ impl SendInputInjector {
impl Drop for SendInputInjector { impl Drop for SendInputInjector {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(h) = self.desktop.take() { if let Some(h) = self.desktop.take() {
// SAFETY: `h` is the `HDESK` this injector owned (moved out of `self.desktop`);
// `CloseDesktop` runs once here in `Drop` on that still-valid handle, with no later use —
// no double close.
unsafe { unsafe {
let _ = CloseDesktop(h); let _ = CloseDesktop(h);
} }
@@ -216,7 +238,11 @@ impl InputInjector for SendInputInjector {
} }
InputKind::KeyDown | InputKind::KeyUp => { InputKind::KeyDown | InputKind::KeyUp => {
let down = event.kind == InputKind::KeyDown; let down = event.kind == InputKind::KeyDown;
let vk = (event.code & 0xff) as u16; // client sends Windows VK // client sends Windows VK
let vk = (event.code & 0xff) as u16;
// SAFETY: `MapVirtualKeyExW` is a pure value translation (VK → scancode); all three
// args are by-value (`u32`, the `MAPVK_VK_TO_VSC_EX` map-type constant, a `None`
// HKL). It dereferences no pointer and returns a `u32` — FFI-`unsafe` only.
let sc_ex = unsafe { MapVirtualKeyExW(vk as u32, MAPVK_VK_TO_VSC_EX, None) }; let sc_ex = unsafe { MapVirtualKeyExW(vk as u32, MAPVK_VK_TO_VSC_EX, None) };
if sc_ex == 0 { if sc_ex == 0 {
return Ok(()); // unmappable -> drop return Ok(()); // unmappable -> drop
@@ -264,6 +290,8 @@ fn key(ki: KEYBDINPUT) -> INPUT {
} }
fn virtual_desktop_rect() -> (i32, i32, i32, i32) { fn virtual_desktop_rect() -> (i32, i32, i32, i32) {
// SAFETY: each `GetSystemMetrics` takes a single by-value `SYSTEM_METRICS_INDEX` constant and
// returns an `i32`; it dereferences no pointer and has no side effects — FFI-`unsafe` only.
unsafe { unsafe {
( (
GetSystemMetrics(SM_XVIRTUALSCREEN), GetSystemMetrics(SM_XVIRTUALSCREEN),
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,9 @@
//! 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 `waited=false` 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).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::os::fd::RawFd; use std::os::fd::RawFd;
// 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.
@@ -40,6 +43,11 @@ pub fn wait_read_ready(dmabuf_fd: RawFd, timeout_ms: i32) -> std::io::Result<boo
flags: DMA_BUF_SYNC_READ, flags: DMA_BUF_SYNC_READ,
fd: -1, fd: -1,
}; };
// SAFETY: `dmabuf_fd` is a live dmabuf fd supplied by the caller (borrowed for this call; we
// never close it). `DMA_BUF_IOCTL_EXPORT_SYNC_FILE` encodes `size_of::<DmaBufExportSyncFile>()`
// — the exact byte count the kernel copies — and `&mut req` is a live, correctly-sized
// `#[repr(C)]` struct the EXPORT_SYNC_FILE ioctl reads (`flags`) and writes (`fd`). `req`
// outlives this synchronous call and is not aliased elsewhere.
let r = unsafe { libc::ioctl(dmabuf_fd, DMA_BUF_IOCTL_EXPORT_SYNC_FILE, &mut req) }; let r = unsafe { libc::ioctl(dmabuf_fd, DMA_BUF_IOCTL_EXPORT_SYNC_FILE, &mut req) };
if r < 0 { if r < 0 {
return Err(std::io::Error::last_os_error()); return Err(std::io::Error::last_os_error());
@@ -54,11 +62,21 @@ pub fn wait_read_ready(dmabuf_fd: RawFd, timeout_ms: i32) -> std::io::Result<boo
revents: 0, revents: 0,
}; };
// Non-blocking probe: not-yet-signaled (poll==0) means the producer is still rendering. // 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; let pending = unsafe { libc::poll(&mut pfd, 1, 0) } == 0;
if pending { if pending {
pfd.revents = 0; 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 unsafe { libc::poll(&mut pfd, 1, timeout_ms) }; // block until the render fence signals
} }
// 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
// never used afterward — no double-close or use-after-close.
unsafe { libc::close(sync_fd) }; unsafe { libc::close(sync_fd) };
Ok(pending) Ok(pending)
} }
@@ -8,6 +8,8 @@
//! verified (ioctl numbers + a live signal→wait round trip), ready to wire in the moment a producer //! verified (ioctl numbers + a live signal→wait round trip), ready to wire in the moment a producer
//! gains working `SPA_META_SyncTimeline`. //! gains working `SPA_META_SyncTimeline`.
#![allow(dead_code)] #![allow(dead_code)]
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
//! //!
//! Compositors that render directly into the PipeWire buffer pool (Mutter's virtual //! Compositors that render directly into the PipeWire buffer pool (Mutter's virtual
//! monitors) hand buffers over at GPU-submit time; on drivers without implicit dmabuf //! monitors) hand buffers over at GPU-submit time; on drivers without implicit dmabuf
@@ -81,6 +83,8 @@ pub struct DrmSync {
impl DrmSync { impl DrmSync {
pub fn open() -> Result<DrmSync> { pub fn open() -> Result<DrmSync> {
let path = c"/dev/dri/renderD128"; let path = c"/dev/dri/renderD128";
// SAFETY: `path` is a 'static NUL-terminated C string literal; `open` only reads it as a
// filesystem path and returns an fd (or -1). No Rust memory is aliased or handed to the kernel.
let fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) }; let fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
if fd < 0 { if fd < 0 {
bail!("open /dev/dri/renderD128 for syncobj ops: {}", errno()); bail!("open /dev/dri/renderD128 for syncobj ops: {}", errno());
@@ -94,6 +98,9 @@ impl DrmSync {
fd: syncobj_fd, fd: syncobj_fd,
..Default::default() ..Default::default()
}; };
// SAFETY: `self.fd` is the live render-node fd from `open`; the request number encodes
// `size_of::<DrmSyncobjHandle>()` (the bytes the kernel copies), and `&mut req` is a live,
// correctly-sized `#[repr(C)]` struct the FD_TO_HANDLE ioctl reads (`fd`) and writes (`handle`).
let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_FD_TO_HANDLE, &mut req) }; let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_FD_TO_HANDLE, &mut req) };
if r < 0 { if r < 0 {
bail!("SYNCOBJ_FD_TO_HANDLE: {}", errno()); bail!("SYNCOBJ_FD_TO_HANDLE: {}", errno());
@@ -106,6 +113,8 @@ impl DrmSync {
handle, handle,
..Default::default() ..Default::default()
}; };
// SAFETY: `self.fd` is the live render-node fd; `DRM_IOCTL_SYNCOBJ_DESTROY` encodes
// `size_of::<DrmSyncobjDestroy>()`, and `&mut req` is a live correctly-sized struct the kernel reads.
unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_DESTROY, &mut req) }; unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_DESTROY, &mut req) };
} }
@@ -117,6 +126,8 @@ impl DrmSync {
tv_sec: 0, tv_sec: 0,
tv_nsec: 0, tv_nsec: 0,
}; };
// SAFETY: `CLOCK_MONOTONIC` is a valid clock id and `&mut now` is a live `libc::timespec` the
// kernel fills in; the call returns before `now` is read, so there is no aliasing/lifetime issue.
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut now) }; unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut now) };
let deadline = now.tv_sec * 1_000_000_000 + now.tv_nsec + timeout_ms as i64 * 1_000_000; let deadline = now.tv_sec * 1_000_000_000 + now.tv_nsec + timeout_ms as i64 * 1_000_000;
let handles = [handle]; let handles = [handle];
@@ -129,6 +140,11 @@ impl DrmSync {
flags: DRM_SYNCOBJ_WAIT_FLAGS_WAIT_FOR_SUBMIT, flags: DRM_SYNCOBJ_WAIT_FLAGS_WAIT_FOR_SUBMIT,
..Default::default() ..Default::default()
}; };
// SAFETY: `self.fd` is the live render-node fd; the request number encodes
// `size_of::<DrmSyncobjTimelineWait>()`; `&mut req` is a live correctly-sized struct. Its
// `handles`/`points` u64 fields hold the addresses of the local `handles`/`points` arrays, which
// outlive this synchronous call, and `count_handles == 1` matches their length — so every kernel
// read through those addresses stays in bounds.
let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_TIMELINE_WAIT, &mut req) }; let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_TIMELINE_WAIT, &mut req) };
let saved = errno(); let saved = errno();
self.destroy(handle); self.destroy(handle);
@@ -151,6 +167,10 @@ impl DrmSync {
count_handles: 1, count_handles: 1,
flags: 0, flags: 0,
}; };
// SAFETY: `self.fd` is the live render-node fd; the request number encodes
// `size_of::<DrmSyncobjTimelineArray>()`; `&mut req` is a live correctly-sized struct whose
// `handles`/`points` u64 fields address the local `handles`/`points` arrays (alive for this
// synchronous call, `count_handles == 1` matching their length).
let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_TIMELINE_SIGNAL, &mut req) }; let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_TIMELINE_SIGNAL, &mut req) };
let saved = errno(); let saved = errno();
self.destroy(handle); self.destroy(handle);
@@ -163,6 +183,8 @@ impl DrmSync {
impl Drop for DrmSync { impl Drop for DrmSync {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `self.fd` is the fd `open` returned; this `DrmSync` owns it exclusively and `close`
// runs exactly once (here, in `Drop`), so there is no double-close or use-after-close.
unsafe { libc::close(self.fd) }; unsafe { libc::close(self.fd) };
} }
} }
@@ -203,14 +225,19 @@ mod tests {
const CREATE: u64 = iowr(0xBF, std::mem::size_of::<Create>()); const CREATE: u64 = iowr(0xBF, std::mem::size_of::<Create>());
const HANDLE_TO_FD: u64 = iowr(0xC1, std::mem::size_of::<DrmSyncobjHandle>()); const HANDLE_TO_FD: u64 = iowr(0xC1, std::mem::size_of::<DrmSyncobjHandle>());
let mut c = Create::default(); let mut c = Create::default();
// SAFETY: `sync.fd` is the live render-node fd; `CREATE` encodes `size_of::<Create>()`, and
// `&mut c` is a live correctly-sized struct the kernel fills (`handle`).
assert!(unsafe { libc::ioctl(sync.fd, CREATE, &mut c) } >= 0); assert!(unsafe { libc::ioctl(sync.fd, CREATE, &mut c) } >= 0);
let mut h = DrmSyncobjHandle { let mut h = DrmSyncobjHandle {
handle: c.handle, handle: c.handle,
..Default::default() ..Default::default()
}; };
// SAFETY: `sync.fd` is live; `HANDLE_TO_FD` encodes `size_of::<DrmSyncobjHandle>()`; `&mut h`
// is a live correctly-sized struct (the kernel reads `handle`, writes `fd`).
assert!(unsafe { libc::ioctl(sync.fd, HANDLE_TO_FD, &mut h) } >= 0); assert!(unsafe { libc::ioctl(sync.fd, HANDLE_TO_FD, &mut h) } >= 0);
sync.signal_point(h.fd, 1).expect("signal"); sync.signal_point(h.fd, 1).expect("signal");
sync.wait_point(h.fd, 1, 100).expect("wait after signal"); sync.wait_point(h.fd, 1, 100).expect("wait after signal");
// SAFETY: `h.fd` is the fd HANDLE_TO_FD just exported; we own it and close it exactly once here.
unsafe { libc::close(h.fd) }; unsafe { libc::close(h.fd) };
sync.destroy(c.handle); sync.destroy(c.handle);
} }
@@ -11,6 +11,8 @@
//! thread) and ffmpeg's `hevc_nvenc` (encode thread); each thread makes it current before use. //! thread) and ffmpeg's `hevc_nvenc` (encode thread); each thread makes it current before use.
#![allow(non_camel_case_types, non_snake_case)] #![allow(non_camel_case_types, non_snake_case)]
// Every `unsafe` block/impl below carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use std::os::raw::{c_int, c_uint, c_void}; use std::os::raw::{c_int, c_uint, c_void};
@@ -128,8 +130,14 @@ struct CudaApi {
) -> CUresult, ) -> CUresult,
cuDestroyExternalMemory: unsafe extern "C" fn(CUexternalMemory) -> CUresult, cuDestroyExternalMemory: unsafe extern "C" fn(CUexternalMemory) -> CUresult,
} }
// The resolved fn pointers are plain addresses into a process-lifetime mapping; safe to share. // SAFETY: every field is a bare `extern "C" fn` address into the leaked, process-lifetime
// `libcuda` mapping (`cuda_api` `forget`s the `Library`, so it is never unloaded) — an immutable
// value with no interior mutability and no thread affinity. Moving the table to another thread
// cannot dangle (the code it points at stays mapped) or race (the fields are read-only).
unsafe impl Send for CudaApi {} unsafe impl Send for CudaApi {}
// SAFETY: as above — the table is a set of immutable fn-pointer addresses with no interior
// mutability, so concurrent shared reads from multiple threads cannot race; the driver entry
// points they address are themselves thread-safe.
unsafe impl Sync for CudaApi {} unsafe impl Sync for CudaApi {}
/// `CUresult` returned by the wrappers when `libcuda` isn't loaded (no NVIDIA driver). Non-zero so /// `CUresult` returned by the wrappers when `libcuda` isn't loaded (no NVIDIA driver). Non-zero so
@@ -143,6 +151,14 @@ static CUDA_API: OnceLock<Option<CudaApi>> = OnceLock::new();
/// (the expected case on AMD/Intel hosts) — logged at debug, not an error. /// (the expected case on AMD/Intel hosts) — logged at debug, not an error.
fn cuda_api() -> Option<&'static CudaApi> { fn cuda_api() -> Option<&'static CudaApi> {
CUDA_API CUDA_API
// SAFETY: `Library::new` runs `libcuda.so.1`'s initializers — it is the trusted NVIDIA
// driver library, so loading has no unexpected effects; `?`/`None` handle its absence.
// Each `lib.get::<T>(name)` asserts the symbol's real ABI equals `T`: every NUL-terminated
// name is a documented CUDA Driver API entry point and `T` is the exact
// `unsafe extern "C" fn(..)` signature from cuda.h/cudaGL.h (`_v2` for ctx/mem ops). Each
// `Symbol` only borrows `lib` until the end of the struct-literal statement; we deref-copy
// the raw fn-pointer out first, then `forget(lib)` leaks the mapping so those addresses
// stay valid for the whole process. Runs once under the `OnceLock` init — no aliasing.
.get_or_init(|| unsafe { .get_or_init(|| unsafe {
let lib = libloading::Library::new("libcuda.so.1") let lib = libloading::Library::new("libcuda.so.1")
.or_else(|_| libloading::Library::new("libcuda.so")) .or_else(|_| libloading::Library::new("libcuda.so"))
@@ -361,6 +377,12 @@ pub fn read_plane_to_host(
Height: height, Height: height,
..Default::default() ..Default::default()
}; };
// SAFETY: `copy_blocking` is unsafe because it issues a CUDA copy; its contract is a valid
// descriptor with the shared context current (the caller's responsibility — self-test path).
// `&copy` is a live local `#[repr(C)] CUDA_MEMCPY2D` that outlives the synchronous call:
// `srcDevice`/`srcPitch` are the caller's live pitched device plane, `dstHost` addresses the
// freshly-allocated `host` `Vec` of exactly `width_bytes*height` bytes, and `WidthInBytes`×
// `Height` fit both. The copy is synchronous, so `host` is fully written before we return it.
unsafe { copy_blocking(&copy, "cuMemcpy2DAsync_v2(dev->host)")? }; unsafe { copy_blocking(&copy, "cuMemcpy2DAsync_v2(dev->host)")? };
Ok(host) Ok(host)
} }
@@ -369,7 +391,13 @@ pub fn read_plane_to_host(
/// in a `OnceLock`; the raw `CUcontext` is thread-safe to make current from any thread. /// in a `OnceLock`; the raw `CUcontext` is thread-safe to make current from any thread.
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct Context(pub CUcontext); pub struct Context(pub CUcontext);
// SAFETY: `CUcontext` is an opaque CUDA driver handle, not a dereferenceable Rust pointer. It is
// created once and never destroyed (process lifetime), and the only thing done with it is
// `cuCtxSetCurrent`, which the Driver API explicitly allows from any thread — so transferring the
// handle to another thread cannot dangle or race (the driver owns the synchronization).
unsafe impl Send for Context {} unsafe impl Send for Context {}
// SAFETY: as above — the wrapped handle is an immutable opaque address and the driver does all the
// synchronization, so sharing `&Context` across threads is sound.
unsafe impl Sync for Context {} unsafe impl Sync for Context {}
static CONTEXT: OnceLock<Context> = OnceLock::new(); static CONTEXT: OnceLock<Context> = OnceLock::new();
@@ -382,6 +410,12 @@ pub fn context() -> Result<CUcontext> {
if cuda_api().is_none() { if cuda_api().is_none() {
bail!("libcuda.so.1 not available — no NVIDIA driver (CUDA zero-copy disabled)"); bail!("libcuda.so.1 not available — no NVIDIA driver (CUDA zero-copy disabled)");
} }
// SAFETY: we returned above unless `cuda_api()` is `Some`, so every wrapper here forwards into
// the live, leaked `libcuda` table rather than the not-loaded stub. `cuInit(0)` passes the
// API-required flags value 0. `&mut dev`/`&mut ctx` are live, zero/null-initialized stack
// out-params the driver writes the device handle / new context into; each outlives its
// synchronous call and they are distinct locals (no aliasing). `cuCtxCreate_v2` yields a valid
// `CUcontext` on success (`ck` bails otherwise), which becomes the block's value.
let ctx = unsafe { let ctx = unsafe {
ck(cuInit(0), "cuInit")?; ck(cuInit(0), "cuInit")?;
let mut dev: CUdevice = 0; let mut dev: CUdevice = 0;
@@ -401,6 +435,10 @@ pub fn context() -> Result<CUcontext> {
/// Make the shared context current on the calling thread (required before any CUDA op here). /// Make the shared context current on the calling thread (required before any CUDA op here).
pub fn make_current() -> Result<()> { pub fn make_current() -> Result<()> {
let ctx = context()?; let ctx = context()?;
// SAFETY: `ctx` came from `context()?`, so it is the live shared `CUcontext` and the driver
// table is present. `cuCtxSetCurrent` binds that opaque handle to the calling thread; it takes
// no Rust-memory pointer and is thread-safe (affects only this thread's current context), so
// there is no aliasing or lifetime hazard.
unsafe { ck(cuCtxSetCurrent(ctx), "cuCtxSetCurrent") } unsafe { ck(cuCtxSetCurrent(ctx), "cuCtxSetCurrent") }
} }
@@ -423,6 +461,12 @@ fn copy_stream() -> CUstream {
if let Some(s) = cell.get() { if let Some(s) = cell.get() {
return s; return s;
} }
// SAFETY: `copy_stream` runs with the shared context current (its doc contract), so the
// wrappers forward into the live `libcuda` table. `&mut least`/`&mut greatest` are live
// stack `i32`s the driver fills with the priority range; `&mut s` is a live null-init
// `CUstream` the driver writes the new stream into. All out-params outlive their
// synchronous calls and are distinct locals. On any non-zero result we fall back to a null
// (NULL-stream) value and never read an uninitialized handle.
let stream = unsafe { let stream = unsafe {
let (mut least, mut greatest) = (0i32, 0i32); let (mut least, mut greatest) = (0i32, 0i32);
if cuCtxGetStreamPriorityRange(&mut least, &mut greatest) != 0 { if cuCtxGetStreamPriorityRange(&mut least, &mut greatest) != 0 {
@@ -459,6 +503,11 @@ unsafe fn copy_blocking(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> {
fn alloc_pitched(width: u32, height: u32) -> Result<(CUdeviceptr, usize)> { fn alloc_pitched(width: u32, height: u32) -> Result<(CUdeviceptr, usize)> {
let mut ptr: CUdeviceptr = 0; let mut ptr: CUdeviceptr = 0;
let mut pitch: usize = 0; let mut pitch: usize = 0;
// SAFETY: `cuMemAllocPitch_v2` allocates a pitched device buffer (the wrapper forwards to the
// live table on any path that reached allocation). `&mut ptr` (`CUdeviceptr`) and `&mut pitch`
// (`usize`) are live, distinct stack out-params the driver writes the allocation pointer and
// its pitch into; both outlive the synchronous call. Width/height/element-size are by-value
// ints. No aliasing — two separate locals.
unsafe { unsafe {
ck( ck(
cuMemAllocPitch_v2( cuMemAllocPitch_v2(
@@ -486,6 +535,10 @@ fn alloc_pitched_nv12(
let mut y_pitch: usize = 0; let mut y_pitch: usize = 0;
let mut uv_ptr: CUdeviceptr = 0; let mut uv_ptr: CUdeviceptr = 0;
let mut uv_pitch: usize = 0; let mut uv_pitch: usize = 0;
// 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
// 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.
unsafe { unsafe {
ck( ck(
cuMemAllocPitch_v2( cuMemAllocPitch_v2(
@@ -524,6 +577,13 @@ struct PoolInner {
impl Drop for PoolInner { impl Drop for PoolInner {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: the pool only exists because allocation succeeded, so the driver table is live.
// `PoolInner` drops only once every `DeviceBuffer` that referenced it (each holds an `Arc`
// clone) has been recycled, so `free`/`free_uv` hold every outstanding allocation exactly
// once and nothing else still uses them — no double-free or use-after-free. We make the
// shared context current first (drop may run off the allocating thread) so `cuMemFree_v2`
// targets the right context. Each `p` is a `CUdeviceptr` previously returned by
// `cuMemAllocPitch_v2`; results are ignored (best-effort teardown).
unsafe { unsafe {
if let Some(c) = CONTEXT.get() { if let Some(c) = CONTEXT.get() {
let _ = cuCtxSetCurrent(c.0); let _ = cuCtxSetCurrent(c.0);
@@ -697,6 +757,12 @@ impl Drop for DeviceBuffer {
} }
} else { } else {
// The buffer may be freed on the encode thread; cuMemFree needs a current context. // The buffer may be freed on the encode thread; cuMemFree needs a current context.
// SAFETY: this is the un-pooled branch (`pool` is `None`), so this `DeviceBuffer`
// exclusively owns `self.ptr` (and `self.uv`'s `uv_ptr`), each returned by
// `cuMemAllocPitch_v2` and freed exactly once here — `drop` runs once and the
// `self.ptr == 0` guard above skips the sentinel/empty case, so no double-free. We set
// the shared context current first because drop may run on a thread where it isn't, and
// `cuMemFree_v2` needs it. Wrapper → live table; results ignored (teardown).
unsafe { unsafe {
if let Some(c) = CONTEXT.get() { if let Some(c) = CONTEXT.get() {
let _ = cuCtxSetCurrent(c.0); let _ = cuCtxSetCurrent(c.0);
@@ -745,6 +811,16 @@ impl RegisteredTexture {
/// 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
/// before the source dmabuf is recycled. Always unmaps, even if the copy errors. /// before the source dmabuf is recycled. Always unmaps, even if the copy errors.
pub fn copy_mapped_to(&mut self, dst: &DeviceBuffer) -> Result<()> { pub fn copy_mapped_to(&mut self, dst: &DeviceBuffer) -> Result<()> {
// SAFETY: `self.resource` is the valid `CUgraphicsResource` from a successful `register_gl`
// (its only constructor), so the wrappers forward to the live table; the caller holds the
// GL+CUDA contexts current (the registration's contract). `cuGraphicsMapResources` maps
// `count == 1` resource via `&mut self.resource` (a live field) on the default stream;
// `cuGraphicsSubResourceGetMappedArray` writes the mapped `CUarray` into the live local
// `array` (index 0, mip 0). On failure we unmap and bail (balanced). `&copy` is a live
// local `CUDA_MEMCPY2D` outliving the synchronous `copy_blocking`: `srcArray` is valid
// while mapped, `dstDevice`/`dstPitch` are `dst`'s live allocation, `width*4`×`height` fit
// both. `copy_blocking` syncs before we unmap, so the array stays valid through the copy;
// we always unmap afterward (even on error), keeping the map/unmap pair balanced.
unsafe { unsafe {
ck( ck(
cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()), cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()),
@@ -783,6 +859,14 @@ impl RegisteredTexture {
width_bytes: usize, width_bytes: usize,
height: usize, height: usize,
) -> Result<()> { ) -> Result<()> {
// SAFETY: identical contract to `copy_mapped_to` — `self.resource` is the valid
// `CUgraphicsResource` from `register_gl` (wrappers → live table; caller holds GL+CUDA
// contexts current). Map `count == 1` resource via the live `&mut self.resource`; the
// mapped `CUarray` is written into the live local `array` (index 0, mip 0); on failure we
// unmap and bail (balanced). `&copy` is a live local outliving the synchronous
// `copy_blocking`: `srcArray` valid while mapped, `dstDevice`/`dstPitch` are the caller's
// live plane, `width_bytes`×`height` fit it. We always unmap afterward, even on copy error,
// so the map/unmap pair stays balanced and the array outlives the copy.
unsafe { unsafe {
ck( ck(
cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()), cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()),
@@ -847,6 +931,10 @@ pub fn copy_device_to_device(
Height: src.height as usize, Height: src.height as usize,
..Default::default() ..Default::default()
}; };
// SAFETY: `copy_blocking` is unsafe (issues a CUDA copy); the caller must have the shared
// context current (documented). `&copy` is a live local device→device `CUDA_MEMCPY2D` outliving
// the synchronous call: `srcDevice`/`srcPitch` are `src`'s live allocation, `dstDevice`/
// `dstPitch` the caller's live region, `width*4`×`height` within both. Wrapper → live table.
unsafe { copy_blocking(&copy, "cuMemcpy2DAsync_v2(dev->dev)") } unsafe { copy_blocking(&copy, "cuMemcpy2DAsync_v2(dev->dev)") }
} }
@@ -888,6 +976,12 @@ pub fn copy_nv12_to_device(
Height: h / 2, Height: h / 2,
..Default::default() ..Default::default()
}; };
// SAFETY: two unsafe `copy_blocking` device→device copies; the caller must have the shared
// context current (documented). `&y`/`&uv` are live local `CUDA_MEMCPY2D`s outliving each
// synchronous call. All four device pointers are valid: `src.ptr`/`src_uv_ptr` come from a live
// NV12 `DeviceBuffer` (its `.uv` presence was checked via `ok_or_else`), `y_dst`/`uv_dst` are
// the caller's live NVENC surface planes; the luma copy is `w`×`h`, the chroma copy
// `(w/2)*2`×`h/2`, each within its planes. Wrappers → live table.
unsafe { unsafe {
copy_blocking(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)")?; copy_blocking(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)")?;
copy_blocking(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)") copy_blocking(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)")
@@ -897,6 +991,12 @@ pub fn copy_nv12_to_device(
impl Drop for RegisteredTexture { impl Drop for RegisteredTexture {
fn drop(&mut self) { fn drop(&mut self) {
if !self.resource.is_null() { if !self.resource.is_null() {
// SAFETY: `self.resource` is non-null (just checked) and is the valid
// `CUgraphicsResource` from `register_gl`, owned exclusively by this `RegisteredTexture`
// and unregistered exactly once here (drop runs once) — no use-after-free or
// double-unregister. `cuGraphicsUnregisterResource` releases the GL↔CUDA registration;
// wrapper → live table (the resource exists ⇒ the driver was present). Result ignored
// (best-effort teardown).
unsafe { unsafe {
let _ = cuGraphicsUnregisterResource(self.resource); let _ = cuGraphicsUnregisterResource(self.resource);
} }
@@ -913,7 +1013,11 @@ pub struct ExternalDmabuf {
pub size: u64, pub size: u64,
} }
// Raw driver handles; used from the single capture thread but moved with the importer. // SAFETY: the fields are opaque CUDA driver handles — an external-memory handle and a device
// pointer — not dereferenceable Rust memory, and the value is uniquely owned (no `Clone`). It is
// used from a single capture thread but constructed on / moved between threads with the importer;
// transferring these handles is sound because uniqueness rules out aliasing and they are destroyed
// exactly once in `Drop`. Only `Send` (not `Sync`) is asserted, matching the single-thread use.
unsafe impl Send for ExternalDmabuf {} unsafe impl Send for ExternalDmabuf {}
impl ExternalDmabuf { impl ExternalDmabuf {
@@ -921,6 +1025,9 @@ impl ExternalDmabuf {
/// from then on) and map its full `size` bytes to a device pointer. The shared context /// from then on) and map its full `size` bytes to a device pointer. The shared context
/// must be current. /// must be current.
pub fn import(fd: i32, size: u64) -> Result<ExternalDmabuf> { pub fn import(fd: i32, size: u64) -> Result<ExternalDmabuf> {
// SAFETY: `libc::dup` only reads the integer `fd` and returns a new descriptor (or -1); it
// touches no Rust memory and `fd` is the caller's still-owned dmabuf fd (not consumed
// here). No aliasing or lifetime concern — a pure syscall on an integer.
let dup = unsafe { libc::dup(fd) }; let dup = unsafe { libc::dup(fd) };
if dup < 0 { if dup < 0 {
bail!("dup(dmabuf fd) failed"); bail!("dup(dmabuf fd) failed");
@@ -938,8 +1045,17 @@ impl ExternalDmabuf {
}; };
desc.handle[0] = dup as u32 as u64; // union member `int fd` (little-endian low bytes) desc.handle[0] = dup as u32 as u64; // union member `int fd` (little-endian low bytes)
let mut ext: CUexternalMemory = std::ptr::null_mut(); let mut ext: CUexternalMemory = std::ptr::null_mut();
// SAFETY: `cuImportExternalMemory` imports the memory described by `&desc`, a live local
// `#[repr(C)] CUDA_EXTERNAL_MEMORY_HANDLE_DESC` (cuda.h 64-bit layout) that outlives this
// synchronous call: `type_` is OPAQUE_FD, `handle[0]` holds the dup'd fd in the union's
// `int fd` low bytes, `size` is set. `&mut ext` is a live null-init out-param the driver
// writes the imported handle into. The driver takes ownership of the fd only on success.
// Distinct locals → no aliasing. Wrapper → live table (caller holds the context current).
let r = unsafe { cuImportExternalMemory(&mut ext, &desc) }; let r = unsafe { cuImportExternalMemory(&mut ext, &desc) };
if r != 0 { if r != 0 {
// SAFETY: import failed (`r != 0`), so the driver did NOT take ownership of `dup`; we
// still own it and close it exactly once here on the error path (the success path never
// closes it — the driver does). `libc::close` acts on the integer fd alone.
unsafe { libc::close(dup) }; // import failed → the driver did not take the fd unsafe { libc::close(dup) }; // import failed → the driver did not take the fd
bail!("cuImportExternalMemory failed ({r}) — LINEAR dmabuf import unsupported?"); bail!("cuImportExternalMemory failed ({r}) — LINEAR dmabuf import unsupported?");
} }
@@ -949,8 +1065,17 @@ impl ExternalDmabuf {
..Default::default() ..Default::default()
}; };
let mut ptr: CUdeviceptr = 0; let mut ptr: CUdeviceptr = 0;
// SAFETY: maps a device pointer from `ext` (the valid `CUexternalMemory` just imported) per
// `&buf`, a live local `CUDA_EXTERNAL_MEMORY_BUFFER_DESC` (offset 0, full `size`) that
// outlives this synchronous call. `&mut ptr` is a live zero-init out-param the driver writes
// the mapped device address into; distinct locals → no aliasing. Wrapper → live table
// (context current).
let r = unsafe { cuExternalMemoryGetMappedBuffer(&mut ptr, ext, &buf) }; let r = unsafe { cuExternalMemoryGetMappedBuffer(&mut ptr, ext, &buf) };
if r != 0 { if r != 0 {
// SAFETY: mapping failed; `ext` is the valid `CUexternalMemory` we imported and
// exclusively own. We destroy it exactly once here on the error path (the success path
// instead moves it into the returned `ExternalDmabuf`, whose `Drop` destroys it),
// releasing the fd the driver took — no double-destroy or use-after-free.
unsafe { unsafe {
let _ = cuDestroyExternalMemory(ext); let _ = cuDestroyExternalMemory(ext);
} }
@@ -962,6 +1087,12 @@ impl ExternalDmabuf {
impl Drop for ExternalDmabuf { impl Drop for ExternalDmabuf {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: this `ExternalDmabuf` only exists after a successful import, so the driver table
// is live. It exclusively owns `self.ptr` (the mapped buffer) and `self.ext` (the external
// memory), each torn down exactly once here (drop runs once; guarded by `!= 0` / `!null`) —
// no double-free or use-after-free. We make the shared context current first because drop
// may run off the import thread, and we free the mapped buffer before destroying its
// backing external memory. Results ignored (best-effort teardown).
unsafe { unsafe {
if let Some(c) = CONTEXT.get() { if let Some(c) = CONTEXT.get() {
let _ = cuCtxSetCurrent(c.0); let _ = cuCtxSetCurrent(c.0);
@@ -996,5 +1127,10 @@ pub fn copy_pitched_to_buffer(
}; };
// copy_blocking syncs our priority stream before returning, so the copy is complete before the // copy_blocking syncs our priority stream before returning, so the copy is complete before the
// dmabuf is requeued to the producer. // dmabuf is requeued to the producer.
// SAFETY: `copy_blocking` is unsafe (issues a CUDA copy); the caller must have the shared
// context current (documented). `&copy` is a live local device→device `CUDA_MEMCPY2D` outliving
// the synchronous call: `srcDevice`/`srcPitch` are the caller's live mapped span (e.g. an
// `ExternalDmabuf`), `dstDevice`/`dstPitch` are `dst`'s live allocation, `width*4`×`height`
// within both. Wrapper → live table.
unsafe { copy_blocking(&copy, "cuMemcpy2DAsync_v2(ext->dev)") } unsafe { copy_blocking(&copy, "cuMemcpy2DAsync_v2(ext->dev)") }
} }
@@ -12,6 +12,8 @@
//! 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)]
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::cuda::{self, DeviceBuffer}; use super::cuda::{self, DeviceBuffer};
use anyhow::{bail, ensure, Context as _, Result}; use anyhow::{bail, ensure, Context as _, Result};
@@ -415,6 +417,14 @@ impl Nv12Blit {
impl Drop for Nv12Blit { impl Drop for Nv12Blit {
fn drop(&mut self) { fn drop(&mut self) {
// 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
// `EglImporter` is dropped on its single capture thread (fields drop before
// `EglImporter::drop`, which never releases the context). `glDelete*` takes a count + a
// 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
// whole `glDeleteFramebuffers` call (n=2 matches). The symbols dispatch through libGL
// (libglvnd) to the driver for the current context. Each name is deleted exactly once.
unsafe { unsafe {
glDeleteTextures(1, &self.y_tex); glDeleteTextures(1, &self.y_tex);
glDeleteTextures(1, &self.uv_tex); glDeleteTextures(1, &self.uv_tex);
@@ -459,7 +469,14 @@ pub struct EglImporter {
render_fd: c_int, render_fd: c_int,
} }
// The EGL handles are confined to the capture thread; the struct is moved there once. // SAFETY: `EglImporter` owns thread-affine handles — an EGLDisplay/contexts made current on one
// thread, a loaded GL proc pointer, a `gbm_device*`, a raw fd, and CUDA-registered GL textures —
// none safe to touch concurrently. It is constructed inside `pipewire_thread` on the dedicated
// `punktfunk-pipewire` thread, and every method (`import*`, `supported_modifiers`, `Drop`) runs on
// that same thread; it is never accessed through a shared `&` from another thread. `Send` asserts
// only that transferring *ownership* is sound (needed so the importer can live in the PipeWire
// stream's user-data, whose API imposes a `Send` bound) — the live handles are never used
// off-thread. `Sync` is deliberately NOT implied.
unsafe impl Send for EglImporter {} unsafe impl Send for EglImporter {}
impl EglImporter { impl EglImporter {
@@ -470,16 +487,38 @@ impl EglImporter {
// to the same DRM device CUDA-GL interop associates with, which the EGL device platform // to the same DRM device CUDA-GL interop associates with, which the EGL device platform
// did not (cuGraphicsGLRegisterImage rejected device-platform GL textures). // did not (cuGraphicsGLRegisterImage rejected device-platform GL textures).
let path = std::ffi::CString::new("/dev/dri/renderD128").unwrap(); let path = std::ffi::CString::new("/dev/dri/renderD128").unwrap();
// SAFETY: `path` is a live local `CString` (built from a string with no interior NUL, so it
// is NUL-terminated); `path.as_ptr()` is a valid pointer to that buffer which outlives this
// synchronous `open`. `open` only reads the path and returns a new fd (or -1); it neither
// 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 /dev/dri/renderD128 for GBM"); ensure!(render_fd >= 0, "open /dev/dri/renderD128 for GBM");
// SAFETY: `render_fd` is the live DRM render-node fd just returned by `open` and checked
// `>= 0`. `gbm_create_device` (libgbm, linked above) builds a `gbm_device` over that fd and
// returns a `*mut gbm_device` (or null); it borrows but does not take ownership of the fd,
// which `EglImporter` keeps open and closes only in `Drop` after `gbm_device_destroy`. No
// Rust-owned memory is passed, so there is nothing to alias.
let gbm = unsafe { gbm_create_device(render_fd) }; let gbm = unsafe { gbm_create_device(render_fd) };
if gbm.is_null() { if gbm.is_null() {
// SAFETY: reached only when `gbm_create_device` failed (null) — the fd was not consumed
// and no `EglImporter` exists yet to close it again, so this `close` runs exactly once on
// the live `render_fd`, releasing it before the error return. No double-close.
unsafe { libc::close(render_fd) }; unsafe { libc::close(render_fd) };
anyhow::bail!("gbm_create_device failed"); anyhow::bail!("gbm_create_device failed");
} }
// 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
// match the ABI the `khronos_egl` `EGL1_5` bindings declare. No Rust memory is passed; the
// 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
// `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
// 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
// `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,
@@ -533,6 +572,13 @@ impl EglImporter {
.context("eglCreateContext(OpenGL)")?; .context("eglCreateContext(OpenGL)")?;
egl.make_current(display, None, None, Some(gl_ctx)) egl.make_current(display, None, None, Some(gl_ctx))
.context("eglMakeCurrent surfaceless (needs EGL_KHR_surfaceless_context)")?; .context("eglMakeCurrent surfaceless (needs EGL_KHR_surfaceless_context)")?;
// SAFETY: the GL context was made current on this thread just above, which `eglGetProcAddress`
// requires to return a usable pointer. The non-null (`?`-checked) pointer it returns for
// "glEGLImageTargetTexture2DOES" is the driver's implementation of that GL-OES entry point,
// whose real ABI is `void(GLenum, GLeglImageOES)` = `(u32, *mut c_void)` `extern "system"`.
// `EglImageTargetFn` is declared with exactly that signature, so the transmute only retypes a
// same-size, same-ABI thin function pointer (no value/representation change). The function is
// present because `EGL_EXT_image_dma_buf_import` was asserted on this display above.
let egl_image_target: EglImageTargetFn = unsafe { let egl_image_target: EglImageTargetFn = unsafe {
std::mem::transmute( std::mem::transmute(
egl.get_proc_address("glEGLImageTargetTexture2DOES") egl.get_proc_address("glEGLImageTargetTexture2DOES")
@@ -543,6 +589,10 @@ impl EglImporter {
// Create the shared CUDA context up front so import() is pure hot path. // Create the shared CUDA context up front so import() is pure hot path.
cuda::context().context("create CUDA context")?; cuda::context().context("create CUDA context")?;
// SAFETY: `egl::NO_CONTEXT` is EGL's defined sentinel (a null handle) for "no context";
// `Context::from_ptr` only stores the handle (it never dereferences it), so wrapping the
// null sentinel is sound and yields exactly the `EGL_NO_CONTEXT` value that
// `eglCreateImage(EGL_LINUX_DMA_BUF_EXT)` requires as its context argument later.
let no_ctx = unsafe { egl::Context::from_ptr(egl::NO_CONTEXT) }; let no_ctx = unsafe { egl::Context::from_ptr(egl::NO_CONTEXT) };
tracing::info!( tracing::info!(
"zero-copy EGL importer ready (GBM platform + GL texture interop, dma_buf_import + modifiers)" "zero-copy EGL importer ready (GBM platform + GL texture interop, dma_buf_import + modifiers)"
@@ -602,8 +652,21 @@ impl EglImporter {
let Some(sym) = self.egl.get_proc_address("eglQueryDmaBufModifiersEXT") else { let Some(sym) = self.egl.get_proc_address("eglQueryDmaBufModifiersEXT") else {
return Vec::new(); return Vec::new();
}; };
// SAFETY: `sym` is the non-null pointer `eglGetProcAddress("eglQueryDmaBufModifiersEXT")`
// returned (the `let-else` already bailed on `None`) — the driver's implementation of that
// EGL extension entry point. `QueryFn` is declared with that function's exact documented ABI
// (`EGLDisplay, EGLint, EGLint, EGLuint64* , EGLBoolean*, EGLint* -> EGLBoolean`), all
// `extern "system"`, so the transmute only retypes a same-size, same-ABI thin fn pointer.
let query: QueryFn = unsafe { std::mem::transmute(sym) }; let query: QueryFn = unsafe { std::mem::transmute(sym) };
let dpy = self.display.as_ptr(); let dpy = self.display.as_ptr();
// SAFETY: `dpy` is this importer's live, initialized `EGLDisplay`; `query` is the proc loaded
// just above. The first call passes null out-arrays with `max_modifiers == 0`, which the
// extension defines as "write only the count" — it writes solely through `&mut count` (a live
// local `i32`). For the second call, `mods`/`ext` are freshly allocated `Vec`s of exactly
// `count` elements and `max_modifiers == count`, so the driver writes at most `count`
// `u64`/`u32` entries (in bounds) plus the actual count through `&mut n` (a live local). All
// four Rust addresses outlive these synchronous calls and alias nothing else. `truncate` only
// shrinks, so even a misbehaving `n > count` cannot read out of bounds.
unsafe { unsafe {
let mut count: i32 = 0; let mut count: i32 = 0;
if query( if query(
@@ -699,6 +762,10 @@ impl EglImporter {
]); ]);
} }
attrs.push(egl::ATTRIB_NONE); attrs.push(egl::ATTRIB_NONE);
// SAFETY: `eglCreateImage(EGL_LINUX_DMA_BUF_EXT, ...)` mandates a NULL `EGLClientBuffer`
// (the source is described entirely by the attribute list built above), so wrapping
// `null_mut()` is the required value. `from_ptr` only stores the pointer without
// dereferencing it, so constructing it from null is sound.
let client = unsafe { egl::ClientBuffer::from_ptr(std::ptr::null_mut()) }; let client = unsafe { egl::ClientBuffer::from_ptr(std::ptr::null_mut()) };
let image = self let image = self
.egl .egl
@@ -733,11 +800,21 @@ impl EglImporter {
) -> Result<DeviceBuffer> { ) -> Result<DeviceBuffer> {
cuda::make_current()?; cuda::make_current()?;
if self.blit.as_ref().map(|b| (b.width, b.height)) != Some((width, height)) { if self.blit.as_ref().map(|b| (b.width, b.height)) != Some((width, height)) {
// SAFETY: `GlBlit::new` requires the GL context current on the calling thread and a
// current CUDA context. Both hold: this runs on the capture thread where
// `EglImporter::new` made the GL context current and never released it, and
// `cuda::make_current()?` ran at the top of this function. `width`/`height` are plain
// `Copy` frame dimensions.
self.blit = Some(unsafe { GlBlit::new(width, height)? }); self.blit = Some(unsafe { GlBlit::new(width, height)? });
} }
let egl_image_target = self.egl_image_target; let egl_image_target = self.egl_image_target;
let blit = self.blit.as_mut().unwrap(); let blit = self.blit.as_mut().unwrap();
// SAFETY: GL + CUDA contexts current on this thread; `image` is a valid EGLImage. // SAFETY: `GlBlit::run` requires a current GL context and a valid `EGLImage`. The GL context
// is current on this capture thread (made current in `EglImporter::new`, never released) and
// `cuda::make_current()` ran above; `egl_image_target` is the `glEGLImageTargetTexture2DOES`
// pointer loaded in `new`; `image` is the raw handle of the live `EGLImage` that
// `import_inner` created with `eglCreateImage` and destroys only AFTER this call returns, so
// it stays valid for the whole synchronous `run`.
unsafe { blit.run(egl_image_target, image)? }; unsafe { blit.run(egl_image_target, image)? };
// Persistent registration (mapped per frame) + a pooled buffer — no per-frame // Persistent registration (mapped per frame) + a pooled buffer — no per-frame
// cuGraphicsGLRegisterImage / cuMemAllocPitch. // cuGraphicsGLRegisterImage / cuMemAllocPitch.
@@ -757,11 +834,21 @@ impl EglImporter {
) -> Result<DeviceBuffer> { ) -> Result<DeviceBuffer> {
cuda::make_current()?; cuda::make_current()?;
if self.nv12_blit.as_ref().map(|b| (b.width, b.height)) != Some((width, height)) { if self.nv12_blit.as_ref().map(|b| (b.width, b.height)) != Some((width, height)) {
// SAFETY: `Nv12Blit::new` requires the GL context current on the calling thread and a
// current CUDA context. Both hold: this runs on the capture thread where
// `EglImporter::new` made the GL context current and never released it, and
// `cuda::make_current()?` ran at the top of this function. `width`/`height` are plain
// `Copy` frame dimensions.
self.nv12_blit = Some(unsafe { Nv12Blit::new(width, height)? }); self.nv12_blit = Some(unsafe { Nv12Blit::new(width, height)? });
} }
let egl_image_target = self.egl_image_target; let egl_image_target = self.egl_image_target;
let blit = self.nv12_blit.as_mut().unwrap(); let blit = self.nv12_blit.as_mut().unwrap();
// SAFETY: GL + CUDA contexts current on this thread; `image` is a valid EGLImage. // SAFETY: `Nv12Blit::run` requires a current GL context and a valid `EGLImage`. The GL
// context is current on this capture thread (made current in `EglImporter::new`, never
// released) and `cuda::make_current()` ran above; `egl_image_target` is the
// `glEGLImageTargetTexture2DOES` pointer loaded in `new`; `image` is the raw handle of the
// live `EGLImage` that `import_inner` created with `eglCreateImage` and destroys only AFTER
// this call returns, so it stays valid for the whole synchronous `run`.
unsafe { blit.run(egl_image_target, image)? }; unsafe { blit.run(egl_image_target, image)? };
let dst = blit.pool.get()?; let dst = blit.pool.get()?;
cuda::copy_mapped_nv12(&mut blit.y_registered, &mut blit.uv_registered, &dst)?; cuda::copy_mapped_nv12(&mut blit.y_registered, &mut blit.uv_registered, &dst)?;
@@ -787,9 +874,22 @@ impl EglImporter {
); );
cuda::make_current()?; cuda::make_current()?;
if self.nv12_blit.as_ref().map(|b| (b.width, b.height)) != Some((width, height)) { if self.nv12_blit.as_ref().map(|b| (b.width, b.height)) != Some((width, height)) {
// SAFETY: `Nv12Blit::new` requires the GL context current on the calling thread and a
// current CUDA context. Both hold: this self-test path runs on the thread that owns this
// `EglImporter` with its GL context current, and `cuda::make_current()?` ran just above.
// `width`/`height` are plain `Copy` scalars.
self.nv12_blit = Some(unsafe { Nv12Blit::new(width, height)? }); self.nv12_blit = Some(unsafe { Nv12Blit::new(width, height)? });
} }
let blit = self.nv12_blit.as_mut().unwrap(); let blit = self.nv12_blit.as_mut().unwrap();
// SAFETY: runs on the thread that owns this `EglImporter` with its GL context current.
// `blit.src_tex` is a texture this `Nv12Blit` owns; `glTexStorage2D` allocates immutable
// RGBA8 storage exactly once (guarded by `test_src_storage`) sized `width×height`.
// `glTexSubImage2D` then uploads exactly `width×height` RGBA8 texels, reading `width*height*4`
// bytes from `rgba.as_ptr()`; the caller already asserted `rgba.len() == width*height*4`, rows
// are `width*4` bytes (a multiple of the default 4-byte unpack alignment, so no row-padding
// over-read), and `rgba` is a live borrow that outlives this synchronous upload. `run_passes`
// then needs only the current GL context (no further Rust pointers). All GL names are this
// blit's own, alias no other live object, and nothing is retained past the calls.
unsafe { unsafe {
// Upload the host RGBA into `src_tex` (an immutable GL_RGBA8 backing must exist first; // Upload the host RGBA into `src_tex` (an immutable GL_RGBA8 backing must exist first;
// the live path never allocates it — it retargets `src_tex` via EGLImage instead). // the live path never allocates it — it retargets `src_tex` via EGLImage instead).
@@ -824,9 +924,16 @@ impl EglImporter {
impl Drop for EglImporter { impl Drop for EglImporter {
fn drop(&mut self) { fn drop(&mut self) {
if !self.gbm.is_null() { if !self.gbm.is_null() {
// SAFETY: `self.gbm` is the non-null `gbm_device*` from `gbm_create_device` in `new`
// (checked non-null here), owned exclusively by this `EglImporter` and destroyed exactly
// 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) }; unsafe { gbm_device_destroy(self.gbm) };
} }
if self.render_fd >= 0 { 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) }; unsafe { libc::close(self.render_fd) };
} }
} }
@@ -16,6 +16,9 @@
//! a stream's life). Falls back cleanly: any init/import error disables the importer and the //! a stream's life). Falls back cleanly: any init/import error disables the importer and the
//! CPU mmap path takes over. //! CPU mmap path takes over.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::cuda::{self, DeviceBuffer}; use super::cuda::{self, DeviceBuffer};
use anyhow::{anyhow, bail, Context as _, Result}; use anyhow::{anyhow, bail, Context as _, Result};
use ash::vk; use ash::vk;
@@ -51,12 +54,27 @@ pub struct VkBridge {
dst: Option<DstBuf>, dst: Option<DstBuf>,
} }
// Confined to the capture thread; moved there once. // SAFETY: `VkBridge` owns ash Vulkan handles (instance/device/queue/command pool+buffer/fence), a
// CUDA external-memory mapping, and an fd→buffer cache — none `Sync`, and a single queue +
// command buffer must be externally synchronized. It is created inside `EglImporter::import_linear`
// on the dedicated `punktfunk-pipewire` capture thread and every method (`import_linear`, `Drop`)
// runs on that thread; it is never shared via `&` across threads. `Send` asserts only that
// transferring ownership is sound (so the bridge can live inside the `Send` `EglImporter`); the live
// handles are never touched off-thread, and `Sync` is deliberately NOT implied.
unsafe impl Send for VkBridge {} unsafe impl Send for VkBridge {}
impl VkBridge { impl VkBridge {
/// Bring up Vulkan on the NVIDIA GPU with the external-memory extensions. /// Bring up Vulkan on the NVIDIA GPU with the external-memory extensions.
pub fn new() -> Result<VkBridge> { pub fn new() -> Result<VkBridge> {
// SAFETY: standard ash bring-up — every call is `unsafe` only because ash cannot statically
// verify Vulkan handle/CreateInfo validity. `ash::Entry::load` dlopens a real system
// libvulkan. Each `*CreateInfo`/`AllocateInfo` is built by ash's builders from locals (`app`,
// `exts`, `prio`, `qci`, and the inline infos) that all live for the duration of the
// synchronous `create_*`/`enumerate_*` call that reads them — in particular the
// `enabled_extension_names(&exts)` and `queue_priorities(&prio)` borrows outlive their calls.
// Every handle passed (`instance`, `phys`, `device`, `qf`, `cmd_pool`) was just created and
// checked via `?`/`ok_or_else` in this same function, so no invalid handle is ever used. This
// constructor shares nothing across threads.
unsafe { unsafe {
let entry = ash::Entry::load().context("load libvulkan")?; let entry = ash::Entry::load().context("load libvulkan")?;
let app = vk::ApplicationInfo::default().api_version(vk::API_VERSION_1_1); let app = vk::ApplicationInfo::default().api_version(vk::API_VERSION_1_1);
@@ -294,6 +312,19 @@ impl VkBridge {
height: u32, height: u32,
pool: &cuda::BufferPool, pool: &cuda::BufferPool,
) -> Result<DeviceBuffer> { ) -> Result<DeviceBuffer> {
// SAFETY: `fd` is the live dmabuf fd handed in by the caller (borrowed; `import_src` dup's it
// internally and Vulkan owns the dup). `libc::lseek` only queries the fd's size. The unsafe
// `import_src`/`ensure_dst` are called with a valid fd and a checked size. The bounds are
// proven: `import_src` asserts `size >= span` (so the cached `src_size >= span`),
// `copy_size = src_size.min(span)`, and `ensure_dst(copy_size)` makes `dst` at least
// `copy_size` — so the GPU `cmd_copy_buffer` of `copy_size` bytes reads/writes within both
// buffers, and the later CUDA pitched copy reading `[offset, span)` from `dst.cuda.ptr` (=
// `offset + stride*height = span <= copy_size`) stays inside the freshly-copied region. The
// `*Info`/`region`/`cmds`/`submit` are locals that outlive the synchronous calls reading them.
// `cmd`/`queue`/`fence` are this bridge's own handles, used on this single thread only. The
// host-side `wait_for_fences` fully retires the Vulkan copy BEFORE CUDA reads the shared
// memory, so there is no GPU write/read data race. `dst` is an `&self.dst` shared borrow that
// does not alias the `&self.device` calls.
unsafe { unsafe {
let span = offset as u64 + stride as u64 * height as u64; let span = offset as u64 + stride as u64 * height as u64;
if !self.src_cache.contains_key(&fd) { if !self.src_cache.contains_key(&fd) {
@@ -347,6 +378,15 @@ impl VkBridge {
impl Drop for VkBridge { impl Drop for VkBridge {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: runs once when the bridge is dropped on its owning capture thread.
// `device_wait_idle` first drains all in-flight GPU work, so no queued command still
// references these objects. Every handle freed (the `src_cache` buffers+memories, the `dst`
// buffer+memory, `fence`, `cmd_pool`, `device`, `instance`) was created by this `VkBridge`
// and owned exclusively by it, so each `destroy_*`/`free_*` runs exactly once with no
// double-free, in dependency order (child objects before `device`, `device` before
// `instance`). `dst.cuda` is dropped after `free_memory`, which is safe because CUDA holds
// its own dup'd OPAQUE_FD reference to the underlying allocation. No other thread touches
// these handles.
unsafe { unsafe {
let _ = self.device.device_wait_idle(); let _ = self.device.device_wait_idle();
for (_, s) in self.src_cache.drain() { for (_, s) in self.src_cache.drain() {
+143 -30
View File
@@ -13,18 +13,33 @@
// Scaffold: trait methods and config paths are defined ahead of their backends. // Scaffold: trait methods and config paths are defined ahead of their backends.
#![allow(dead_code)] #![allow(dead_code)]
// Unsafe-proof program: every `unsafe {}` / `unsafe impl` in the crate must carry a `// SAFETY:`
// proof of why it is sound. This crate-root deny is the permanent, catch-all gate (it also covers
// any future module); individual files keep their own `#![deny(...)]` as belt-and-suspenders.
#![deny(clippy::undocumented_unsafe_blocks)]
mod audio; mod audio;
mod capture; mod capture;
mod config;
mod discovery; mod discovery;
// Goal-1 stage 6: top-level platform-only modules live under `src/linux/` and `src/windows/`; `#[path]`
// keeps the `crate::*` module names flat (every existing path is unchanged).
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "linux/dmabuf_fence.rs"]
mod dmabuf_fence; mod dmabuf_fence;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "linux/drm_sync.rs"]
mod drm_sync; mod drm_sync;
mod encode; mod encode;
mod gamestream; mod gamestream;
mod hdr; mod hdr;
mod inject; mod inject;
#[cfg(target_os = "windows")]
#[path = "windows/install.rs"]
mod install;
#[cfg(target_os = "windows")]
#[path = "windows/interactive.rs"]
mod interactive;
mod library; mod library;
mod mgmt; mod mgmt;
mod mgmt_token; mod mgmt_token;
@@ -33,13 +48,24 @@ mod pipeline;
mod punktfunk1; mod punktfunk1;
mod pwinit; mod pwinit;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "windows/service.rs"]
mod service; mod service;
mod session_plan;
mod session_tuning; mod session_tuning;
mod spike; mod spike;
mod stats_recorder;
mod vdisplay; mod vdisplay;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "windows/wgc_helper.rs"]
mod wgc_helper; mod wgc_helper;
#[cfg(target_os = "windows")]
#[path = "windows/win_adapter.rs"]
mod win_adapter;
#[cfg(target_os = "windows")]
#[path = "windows/win_display.rs"]
mod win_display;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "linux/zerocopy/mod.rs"]
mod zerocopy; mod zerocopy;
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
@@ -209,7 +235,6 @@ fn real_main() -> Result<()> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
Some("dualsense-windows-test") => { Some("dualsense-windows-test") => {
use crate::gamestream::gamepad::{GamepadEvent, GamepadFrame}; use crate::gamestream::gamepad::{GamepadEvent, GamepadFrame};
use inject::dualsense_windows::DualSenseWindowsManager;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
let secs: u64 = args let secs: u64 = args
.iter() .iter()
@@ -217,33 +242,115 @@ fn real_main() -> Result<()> {
.nth(1) .nth(1)
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.unwrap_or(20); .unwrap_or(20);
let mut mgr = DualSenseWindowsManager::new(); // `--index N` creates pad `pf_pad_N` (default 0) — use a spare index (e.g. 1) to test
// Arrival creates the pad (SwDeviceCreate + section); State pushes the report. // alongside a running host that already holds pad 0. `--ds4` drives the DualShock 4
mgr.handle(&GamepadEvent::Arrival { // backend instead of the DualSense one.
index: 0, let idx: u8 = args
kind: 2, .iter()
capabilities: 0, .skip_while(|a| *a != "--index")
}); .nth(1)
// ls_x 16384 → report byte1 0xC0; BTN_A (Cross) → report byte8 0x28. .and_then(|s| s.parse().ok())
mgr.handle(&GamepadEvent::State(GamepadFrame { .unwrap_or(0);
index: 0, let ds4 = args.iter().any(|a| a == "--ds4");
active_mask: 1, let xbox = args.iter().any(|a| a == "--xbox");
buttons: punktfunk_core::input::gamepad::BTN_A, // Same drive loop for either backend (identical method surface): Arrival creates the pad,
left_trigger: 0, // State pushes a cycling report, pump surfaces a game's rumble/lightbar feedback.
right_trigger: 0, macro_rules! drive {
ls_x: 16384, ($mgr:expr, $label:expr) => {{
ls_y: 0, let mut mgr = $mgr;
rs_x: 0, mgr.handle(&GamepadEvent::Arrival {
rs_y: 0, index: idx,
})); kind: 2,
println!( capabilities: 0,
"virtual DualSense created via SwDeviceCreate (VID 054C/PID 0CE6). Holding {secs}s — \ });
verify Get-PnpDevice VID_054C + a HID read (expect byte1=0xC0, byte8=0x28)." println!(
); "virtual {} up — cycling Cross + sweeping the left stick for {secs}s. Watch \
let deadline = Instant::now() + Duration::from_secs(secs); it in joy.cpl / Steam / a game; any feedback the game sends prints below.",
while Instant::now() < deadline { $label
mgr.pump(|_, _, _| {}, |_| {}); );
std::thread::sleep(Duration::from_millis(50)); let deadline = Instant::now() + Duration::from_secs(secs);
let (mut i, mut last) = (0i32, Instant::now());
while Instant::now() < deadline {
mgr.pump(
|pad, lo, hi| {
println!(" rumble from game: pad={pad} low={lo} high={hi}")
},
|o| println!(" hid output from game: {o:?}"),
);
if last.elapsed() >= Duration::from_millis(400) {
last = Instant::now();
i += 1;
let buttons = if i % 2 == 0 {
punktfunk_core::input::gamepad::BTN_A // Cross
} else {
0
};
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
mgr.handle(&GamepadEvent::State(GamepadFrame {
index: idx as i16,
active_mask: 1 << idx,
buttons,
left_trigger: 0,
right_trigger: 0,
ls_x: lx,
ls_y: 0,
rs_x: 0,
rs_y: 0,
}));
}
std::thread::sleep(Duration::from_millis(15));
}
}};
}
if xbox {
// Xbox 360 via the XUSB companion: a different surface (handle + pump_rumble, no
// HID-output plane), so drive it inline rather than via the macro.
let mut mgr = inject::gamepad::GamepadManager::new();
mgr.handle(&GamepadEvent::Arrival {
index: idx,
kind: 1,
capabilities: 0,
});
println!(
"virtual Xbox 360 (XUSB) up — sweeping LS + toggling A for {secs}s. Check with \
an XInput game or xinputtest.exe."
);
let deadline = Instant::now() + Duration::from_secs(secs);
let mut t = 0i32;
while Instant::now() < deadline {
mgr.pump_rumble(|pad, lo, hi| {
println!(" rumble from game: pad={pad} low={lo} high={hi}")
});
t += 1;
let lx = (((t % 200) - 100) * 327).clamp(-32768, 32767) as i16; // sweep ±32700
let buttons = if (t / 67) % 2 == 0 {
punktfunk_core::input::gamepad::BTN_A
} else {
0
};
mgr.handle(&GamepadEvent::State(GamepadFrame {
index: idx as i16,
active_mask: 1 << idx,
buttons,
left_trigger: 0,
right_trigger: 0,
ls_x: lx,
ls_y: 0,
rs_x: 0,
rs_y: 0,
}));
std::thread::sleep(Duration::from_millis(15));
}
} else if ds4 {
drive!(
inject::dualshock4_windows::DualShock4WindowsManager::new(),
"DualShock 4"
);
} else {
drive!(
inject::dualsense_windows::DualSenseWindowsManager::new(),
"DualSense"
);
} }
println!("dualsense-windows-test: done (devnode removed)"); println!("dualsense-windows-test: done (devnode removed)");
Ok(()) Ok(())
@@ -286,7 +393,7 @@ fn real_main() -> Result<()> {
} }
// USER-session WGC helper (Windows two-process secure-desktop design): capture the EXISTING // USER-session WGC helper (Windows two-process secure-desktop design): capture the EXISTING
// SudoVDA via WGC + NVENC, stream AUs on stdout to the SYSTEM host. Spawned by the host // SudoVDA via WGC + NVENC, stream AUs on stdout to the SYSTEM host. Spawned by the host
// (CreateProcessAsUser), not run by hand. See docs/windows-secure-desktop.md. // (CreateProcessAsUser), not run by hand. See design/archive/windows-secure-desktop.md.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
Some("wgc-helper") => { Some("wgc-helper") => {
let get = |flag: &str| { let get = |flag: &str| {
@@ -318,6 +425,12 @@ fn real_main() -> Result<()> {
// that launches the host into the active interactive session. // that launches the host into the active interactive session.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
Some("service") => service::main(&args[1..]), Some("service") => service::main(&args[1..]),
// Install-time work the Windows installer delegates to the exe instead of locale-parsed
// PowerShell *files* (the ANSI-codepage parse-break root fix; see windows/install.rs).
#[cfg(target_os = "windows")]
Some("driver") => install::driver_main(&args[1..]),
#[cfg(target_os = "windows")]
Some("web") => install::web_main(&args[1..]),
Some("-h") | Some("--help") | Some("help") | None => { Some("-h") | Some("--help") | Some("help") | None => {
print_usage(); print_usage();
Ok(()) Ok(())
@@ -601,7 +714,7 @@ SPIKE OPTIONS:
NOTES: NOTES:
'portal' needs headless Sway + xdg-desktop-portal-wlr running in this session 'portal' needs headless Sway + xdg-desktop-portal-wlr running in this session
(see docs/linux-setup.md). 'synthetic' needs no capture session and always runs. (see design/linux-setup.md). 'synthetic' needs no capture session and always runs.
Encoded AUs are written to a playable file AND (unless --no-loopback) fed through a Encoded AUs are written to a playable file AND (unless --no-loopback) fed through a
punktfunk_core host→client loopback that reassembles and byte-verifies each one. punktfunk_core host→client loopback that reassembles and byte-verifies each one.
Both 'serve' and 'punktfunk1-host' advertise the native service over mDNS Both 'serve' and 'punktfunk1-host' advertise the native service over mDNS
+217 -9
View File
@@ -6,7 +6,7 @@
//! The API is versioned under `/api/v1` and described by an OpenAPI 3.1 document generated //! The API is versioned under `/api/v1` and described by an OpenAPI 3.1 document generated
//! at compile time with `utoipa` — `punktfunk-host openapi` prints it for client codegen, the //! at compile time with `utoipa` — `punktfunk-host openapi` prints it for client codegen, the
//! running server serves it at `/api/v1/openapi.json` plus interactive docs at `/api/docs`, //! running server serves it at `/api/v1/openapi.json` plus interactive docs at `/api/docs`,
//! and a copy is checked in at `docs/api/openapi.json` (a test fails if it drifts, like the //! and a copy is checked in at `api/openapi.json` (a test fails if it drifts, like the
//! cbindgen header). //! cbindgen header).
//! //!
//! Security: binds loopback by default, serves HTTPS with the host's identity cert, and requires //! Security: binds loopback by default, serves HTTPS with the host's identity cert, and requires
@@ -20,6 +20,7 @@ use crate::gamestream::{
tls::{serve_https, PeerCertFingerprint}, tls::{serve_https, PeerCertFingerprint},
AppState, APP_VERSION, AUDIO_PORT, CONTROL_PORT, GFE_VERSION, RTSP_PORT, VIDEO_PORT, AppState, APP_VERSION, AUDIO_PORT, CONTROL_PORT, GFE_VERSION, RTSP_PORT, VIDEO_PORT,
}; };
use crate::stats_recorder::{Capture, CaptureMeta, StatsStatus};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use axum::{ use axum::{
extract::{Path, Request, State}, extract::{Path, Request, State},
@@ -66,6 +67,9 @@ struct MgmtState {
/// Native (punktfunk/1) pairing — shared with the QUIC host when the unified `serve --native` /// Native (punktfunk/1) pairing — shared with the QUIC host when the unified `serve --native`
/// runs it. `None` ⇒ GameStream-only host (the native endpoints report `enabled: false`). /// runs it. `None` ⇒ GameStream-only host (the native endpoints report `enabled: false`).
native: Option<Arc<crate::native_pairing::NativePairing>>, native: Option<Arc<crate::native_pairing::NativePairing>>,
/// Shared streaming-stats recorder — the same handle the streaming loops emit into, so an
/// operator can arm/stop a capture here and review/list/delete saved recordings.
stats: Arc<crate::stats_recorder::StatsRecorder>,
token: Option<String>, token: Option<String>,
/// The port we serve on, echoed in [`PortMap`] so a client can persist a full endpoint map. /// The port we serve on, echoed in [`PortMap`] so a client can persist a full endpoint map.
port: u16, port: u16,
@@ -77,6 +81,7 @@ pub async fn run(
state: Arc<AppState>, state: Arc<AppState>,
opts: Options, opts: Options,
native: Option<Arc<crate::native_pairing::NativePairing>>, native: Option<Arc<crate::native_pairing::NativePairing>>,
stats: Arc<crate::stats_recorder::StatsRecorder>,
) -> Result<()> { ) -> Result<()> {
// The mgmt API is HTTPS + token-authenticated ALWAYS (even on loopback): `parse_serve` // The mgmt API is HTTPS + token-authenticated ALWAYS (even on loopback): `parse_serve`
// guarantees a token (CLI flag / env / persisted ~/.config/punktfunk/mgmt-token / generated). // guarantees a token (CLI flag / env / persisted ~/.config/punktfunk/mgmt-token / generated).
@@ -100,7 +105,7 @@ pub async fn run(
auth = "mTLS (paired cert) or bearer (required)", auth = "mTLS (paired cert) or bearer (required)",
"management API listening over HTTPS (docs at /api/docs, spec at /api/v1/openapi.json)" "management API listening over HTTPS (docs at /api/docs, spec at /api/v1/openapi.json)"
); );
let app = app(state, Some(token), opts.bind.port(), native); let app = app(state, Some(token), opts.bind.port(), native, stats);
serve_https(opts.bind, app, tls).await serve_https(opts.bind, app, tls).await
} }
@@ -110,10 +115,12 @@ fn app(
token: Option<String>, token: Option<String>,
port: u16, port: u16,
native: Option<Arc<crate::native_pairing::NativePairing>>, native: Option<Arc<crate::native_pairing::NativePairing>>,
stats: Arc<crate::stats_recorder::StatsRecorder>,
) -> Router { ) -> Router {
let shared = Arc::new(MgmtState { let shared = Arc::new(MgmtState {
app: state, app: state,
native, native,
stats,
token, token,
port, port,
}); });
@@ -158,13 +165,19 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(request_idr)) .routes(routes!(request_idr))
.routes(routes!(get_library)) .routes(routes!(get_library))
.routes(routes!(create_custom_game)) .routes(routes!(create_custom_game))
.routes(routes!(update_custom_game, delete_custom_game)), .routes(routes!(update_custom_game, delete_custom_game))
.routes(routes!(stats_capture_start))
.routes(routes!(stats_capture_stop))
.routes(routes!(stats_capture_status))
.routes(routes!(stats_capture_live))
.routes(routes!(stats_recordings_list))
.routes(routes!(stats_recording_get, stats_recording_delete)),
) )
.split_for_parts() .split_for_parts()
} }
/// The OpenAPI document as pretty JSON — what `punktfunk-host openapi` prints and what is /// The OpenAPI document as pretty JSON — what `punktfunk-host openapi` prints and what is
/// checked in at `docs/api/openapi.json` for client codegen. /// checked in at `api/openapi.json` for client codegen.
pub fn openapi_json() -> String { pub fn openapi_json() -> String {
let (_, api) = api_router_parts(); let (_, api) = api_router_parts();
let mut json = api.to_pretty_json().expect("serialize OpenAPI document"); let mut json = api.to_pretty_json().expect("serialize OpenAPI document");
@@ -190,6 +203,7 @@ pub fn openapi_json() -> String {
(name = "native", description = "Native punktfunk/1 pairing: arm a window, display the host PIN, manage paired devices"), (name = "native", description = "Native punktfunk/1 pairing: arm a window, display the host PIN, manage paired devices"),
(name = "session", description = "Active streaming session control"), (name = "session", description = "Active streaming session control"),
(name = "library", description = "Game library: installed-store titles (Steam) plus user-curated custom entries"), (name = "library", description = "Game library: installed-store titles (Steam) plus user-curated custom entries"),
(name = "stats", description = "Streaming performance-stats capture: arm/stop a recording, read the live + saved time-series for graphing"),
) )
)] )]
struct ApiDoc; struct ApiDoc;
@@ -1218,6 +1232,185 @@ async fn delete_custom_game(Path(id): Path<String>) -> Response {
} }
} }
// ---------------------------------------------------------------------------------------
// Streaming stats capture (design/stats-capture-plan.md §2)
// ---------------------------------------------------------------------------------------
/// Start a stats capture
///
/// Arms a new performance-stats capture. Idempotent: if a capture is already running this returns
/// the current status unchanged. While armed, the streaming loops emit aggregated samples (~ every
/// 12 s) into the in-progress capture, readable live via `GET /stats/capture/live`.
#[utoipa::path(
post,
path = "/stats/capture/start",
tag = "stats",
operation_id = "statsCaptureStart",
responses(
(status = OK, description = "Capture armed (or already running)", body = StatsStatus),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn stats_capture_start(State(st): State<Arc<MgmtState>>) -> Json<StatsStatus> {
let status = st.stats.start();
tracing::info!(
started_unix_ms = status.started_unix_ms,
"management API: stats capture armed"
);
Json(status)
}
/// Stop the stats capture
///
/// Disarms the in-progress capture and writes it to disk atomically, returning its summary. If
/// nothing was recording, returns `204 No Content`.
#[utoipa::path(
post,
path = "/stats/capture/stop",
tag = "stats",
operation_id = "statsCaptureStop",
responses(
(status = OK, description = "Capture stopped and saved", body = CaptureMeta),
(status = NO_CONTENT, description = "Nothing was recording"),
(status = INTERNAL_SERVER_ERROR, description = "Could not write the recording to disk", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn stats_capture_stop(State(st): State<Arc<MgmtState>>) -> Response {
match st.stats.stop() {
Ok(Some(meta)) => {
tracing::info!(id = %meta.id, samples = meta.sample_count, "management API: stats capture saved");
(StatusCode::OK, Json(meta)).into_response()
}
Ok(None) => StatusCode::NO_CONTENT.into_response(),
Err(e) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("could not save capture: {e}"),
),
}
}
/// Stats capture status
///
/// Whether a capture is armed, its sample count, and start time. Poll this (e.g. every 2 s) to
/// drive the capture-control UI.
#[utoipa::path(
get,
path = "/stats/capture/status",
tag = "stats",
operation_id = "statsCaptureStatus",
responses(
(status = OK, description = "In-progress capture status (idle when not armed)", body = StatsStatus),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn stats_capture_status(State(st): State<Arc<MgmtState>>) -> Json<StatsStatus> {
Json(st.stats.status())
}
/// Live in-progress capture
///
/// The full sample time-series of the capture currently recording, for live graphing. `404` when
/// nothing is armed.
#[utoipa::path(
get,
path = "/stats/capture/live",
tag = "stats",
operation_id = "statsCaptureLive",
responses(
(status = OK, description = "The in-progress capture (meta + samples so far)", body = Capture),
(status = NOT_FOUND, description = "No capture is currently recording", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn stats_capture_live(State(st): State<Arc<MgmtState>>) -> Response {
match st.stats.live_snapshot() {
Some(capture) => Json(capture).into_response(),
None => api_error(StatusCode::NOT_FOUND, "no capture is currently recording"),
}
}
/// List saved recordings
///
/// Every saved capture's summary (the `meta` head only — not the sample body), newest first.
#[utoipa::path(
get,
path = "/stats/recordings",
tag = "stats",
operation_id = "statsRecordingsList",
responses(
(status = OK, description = "Saved capture summaries, newest first", body = [CaptureMeta]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn stats_recordings_list(State(st): State<Arc<MgmtState>>) -> Json<Vec<CaptureMeta>> {
Json(st.stats.list())
}
/// Get a saved recording
///
/// The full capture (meta + samples) for `id`, for graphing or download.
#[utoipa::path(
get,
path = "/stats/recordings/{id}",
tag = "stats",
operation_id = "statsRecordingGet",
params(("id" = String, Path, description = "The recording id (its filename stem)")),
responses(
(status = OK, description = "The full capture", body = Capture),
(status = NOT_FOUND, description = "No recording with that id", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "The recording file is unreadable", body = ApiError),
)
)]
async fn stats_recording_get(State(st): State<Arc<MgmtState>>, Path(id): Path<String>) -> Response {
match st.stats.load(&id) {
Ok(capture) => Json(capture).into_response(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
api_error(StatusCode::NOT_FOUND, "no recording with that id")
}
Err(e) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("could not read recording: {e}"),
),
}
}
/// Delete a saved recording
///
/// Removes the recording `id` from disk. `404` if there is no such recording.
#[utoipa::path(
delete,
path = "/stats/recordings/{id}",
tag = "stats",
operation_id = "statsRecordingDelete",
params(("id" = String, Path, description = "The recording id (its filename stem)")),
responses(
(status = NO_CONTENT, description = "Recording deleted"),
(status = NOT_FOUND, description = "No recording with that id", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not delete the recording", body = ApiError),
)
)]
async fn stats_recording_delete(
State(st): State<Arc<MgmtState>>,
Path(id): Path<String>,
) -> Response {
match st.stats.delete(&id) {
Ok(()) => {
tracing::info!(id, "management API: recording deleted");
StatusCode::NO_CONTENT.into_response()
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
api_error(StatusCode::NOT_FOUND, "no recording with that id")
}
Err(e) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("could not delete recording: {e}"),
),
}
}
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------
// Tests // Tests
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------
@@ -1231,6 +1424,15 @@ mod tests {
use std::net::{IpAddr, Ipv4Addr}; use std::net::{IpAddr, Ipv4Addr};
use tower::ServiceExt; use tower::ServiceExt;
/// A throwaway stats recorder rooted in a unique temp dir (never touches the real config dir).
fn test_stats() -> Arc<crate::stats_recorder::StatsRecorder> {
crate::stats_recorder::StatsRecorder::new(std::env::temp_dir().join(format!(
"pf-mgmt-stats-{}-{:p}",
std::process::id(),
&0u8 as *const u8
)))
}
fn test_state() -> Arc<AppState> { fn test_state() -> Arc<AppState> {
let host = Host { let host = Host {
hostname: "test-host".into(), hostname: "test-host".into(),
@@ -1240,18 +1442,20 @@ mod tests {
https_port: HTTPS_PORT, https_port: HTTPS_PORT,
}; };
let identity = ServerIdentity::ephemeral().expect("ephemeral identity"); let identity = ServerIdentity::ephemeral().expect("ephemeral identity");
Arc::new(AppState::new(host, identity)) Arc::new(AppState::new(host, identity, test_stats()))
} }
// The mgmt API now always requires auth, so the router always has a token. A test that passes // The mgmt API now always requires auth, so the router always has a token. A test that passes
// `None` gets the default "test-secret" (and `send` auto-attaches the matching bearer); a test // `None` gets the default "test-secret" (and `send` auto-attaches the matching bearer); a test
// that passes an explicit token exercises a mismatch (e.g. `bearer_token_is_enforced`). // that passes an explicit token exercises a mismatch (e.g. `bearer_token_is_enforced`).
fn test_app(state: Arc<AppState>, token: Option<&str>) -> Router { fn test_app(state: Arc<AppState>, token: Option<&str>) -> Router {
let stats = state.stats.clone();
app( app(
state, state,
Some(token.unwrap_or("test-secret").to_string()), Some(token.unwrap_or("test-secret").to_string()),
DEFAULT_PORT, DEFAULT_PORT,
None, None,
stats,
) )
} }
@@ -1261,11 +1465,13 @@ mod tests {
) -> Router { ) -> Router {
// Auth required always; the paired-cert tests inject a fingerprint (cert branch wins), the // Auth required always; the paired-cert tests inject a fingerprint (cert branch wins), the
// rest authenticate via the `send`-attached default bearer. // rest authenticate via the `send`-attached default bearer.
let stats = state.stats.clone();
app( app(
state, state,
Some("test-secret".to_string()), Some("test-secret".to_string()),
DEFAULT_PORT, DEFAULT_PORT,
Some(np), Some(np),
stats,
) )
} }
@@ -1580,7 +1786,9 @@ mod tests {
bind: "127.0.0.1:0".parse().unwrap(), bind: "127.0.0.1:0".parse().unwrap(),
token: Some(" ".into()), token: Some(" ".into()),
}; };
let err = run(test_state(), opts, None).await.unwrap_err(); let err = run(test_state(), opts, None, test_stats())
.await
.unwrap_err();
assert!(err.to_string().contains("no token"), "{err}"); assert!(err.to_string().contains("no token"), "{err}");
} }
@@ -1663,14 +1871,14 @@ mod tests {
serde_json::json!([{}]) serde_json::json!([{}])
); );
let checked_in = include_str!("../../../docs/api/openapi.json"); let checked_in = include_str!("../../../api/openapi.json");
// Compare content, not line-ending style: the generated `json` is LF (serde_json), but git // Compare content, not line-ending style: the generated `json` is LF (serde_json), but git
// may check the file out CRLF on Windows. // may check the file out CRLF on Windows.
assert_eq!( assert_eq!(
json.trim().replace('\r', ""), json.trim().replace('\r', ""),
checked_in.trim().replace('\r', ""), checked_in.trim().replace('\r', ""),
"docs/api/openapi.json is stale — regenerate with: \ "api/openapi.json is stale — regenerate with: \
cargo run -p punktfunk-host -- openapi > docs/api/openapi.json" cargo run -p punktfunk-host -- openapi > api/openapi.json"
); );
} }
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
//! `SessionPlan` — the per-session capture / topology / encoder decision, resolved **once** from
//! [`HostConfig`](crate::config) (+ the handshake-negotiated bit depth) into a typed, logged value.
//!
//! **Goal-1 stage 3** (`design/windows-host-rewrite.md` §2.2): before this, the Windows session decision was
//! re-derived at three call sites — the capture backend inside `capture::capture_virtual_output`, the
//! process topology in `punktfunk1::should_use_helper`, and the encode backend in
//! `encode::windows_resolved_backend` — each reading [`config`](crate::config) independently, with no
//! single owner (the latent "capture and encode disagree on the backend" hazard, plan §2.4). `SessionPlan`
//! resolves them together, once, so the deployed path reads one typed artifact.
//!
//! Stage 3 routes the **capture** and **topology** decisions through the plan (see
//! `capture::capture_virtual_output` taking [`CaptureBackend`] in, and `virtual_stream` reading
//! [`SessionTopology`]). The **encoder** is resolved by `encode::windows_resolved_backend` (config-backed
//! and GPU-vendor cached since stage 2, so already a single source) and *recorded* here as
//! [`EncoderBackend`]. Threading `encoder`/`input_format` into the encoder + capturer opens — which
//! removes the `capture → encode::windows_resolved_backend()` back-reference recomputed in `dxgi.rs` —
//! is **stage 5**.
//!
//! The type is platform-neutral so it threads through the shared `virtual_stream`/`build_pipeline`
//! signatures; on Linux it resolves to the single portal/single-process path (the 3-way dispatch is a
//! Windows-only concern).
/// Where a session's frames come from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CaptureBackend {
/// Linux: the xdg ScreenCast portal → PipeWire (the only Linux capture path).
Portal,
/// Windows: IDD direct-push — frames pulled straight from the pf-vdisplay driver's shared ring
/// (in-process, Session 0; no Desktop Duplication, no WGC helper).
IddPush,
/// Windows: DXGI Desktop Duplication (`PUNKTFUNK_CAPTURE=dda|dxgi` or `PUNKTFUNK_NO_WGC`).
Dda,
/// Windows: Windows.Graphics.Capture (the composed-desktop default), with a DDA watchdog fallback.
Wgc,
}
impl CaptureBackend {
/// Resolve the capture backend from [`config`](crate::config). This is the single resolver shared by
/// [`SessionPlan::resolve`] and the standalone callers (GameStream / spike), so they can't drift.
#[cfg(target_os = "linux")]
pub fn resolve() -> Self {
CaptureBackend::Portal
}
/// Windows precedence (identical to the pre-stage-3 `capture_virtual_output` branch order):
/// IDD-push wins; else an explicit `dda`/`dxgi` request or `PUNKTFUNK_NO_WGC` selects DDA; else WGC.
#[cfg(target_os = "windows")]
pub fn resolve() -> Self {
let cfg = crate::config::config();
if cfg.idd_push {
CaptureBackend::IddPush
} else if matches!(cfg.capture_backend.as_str(), "dda" | "dxgi")
|| crate::capture::wgc_disabled()
{
CaptureBackend::Dda
} else {
CaptureBackend::Wgc
}
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub fn resolve() -> Self {
CaptureBackend::Portal
}
}
/// How a session is structured across processes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SessionTopology {
/// One process captures + encodes (Linux; Windows non-SYSTEM / IDD-push / `NO_WGC`).
SingleProcess,
/// SYSTEM host + a user-session WGC helper relay (the Windows normal-desktop path under SYSTEM,
/// where in-process WGC can't activate). See `virtual_stream_relay`.
TwoProcessRelay,
}
/// The resolved encode backend (recorded for logging / stages 45; the per-session encoder open still
/// resolves via `encode::windows_resolved_backend`, which is config-backed + GPU-vendor cached).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EncoderBackend {
/// Linux: NVENC vs VAAPI is auto-detected inside `encode::open_video` (not modeled here).
PlatformAuto,
Nvenc,
Amf,
Qsv,
Software,
}
impl EncoderBackend {
/// True if this backend encodes on the GPU (so the capturer should produce GPU-resident frames). Only
/// the software encoder takes CPU staging; `PlatformAuto` (Linux NVENC/VAAPI) is always GPU.
pub fn is_gpu(self) -> bool {
!matches!(self, EncoderBackend::Software)
}
}
/// The per-session decision, resolved once. `Copy` so it threads through the capture/encode chain
/// without ceremony (stage 4 folds it, with the rest of the arg soup, into a `SessionContext`).
#[derive(Clone, Copy, Debug)]
pub struct SessionPlan {
pub capture: CaptureBackend,
pub topology: SessionTopology,
pub encoder: EncoderBackend,
/// Handshake-negotiated encode bit depth (8, or 10 = HEVC Main10).
pub bit_depth: u8,
/// The IDD-push HDR hint (`bit_depth >= 10`) — the want-HDR flag the capturer was passed before.
/// Non-IDD-push Windows backends ignore it and auto-detect HDR from the monitor; Linux is 8-bit.
pub hdr: bool,
}
impl SessionPlan {
/// Resolve the whole plan once from [`config`](crate::config) + the negotiated `bit_depth`.
pub fn resolve(bit_depth: u8) -> Self {
SessionPlan {
capture: CaptureBackend::resolve(),
topology: resolve_topology(),
encoder: resolve_encoder(),
bit_depth,
hdr: bit_depth >= 10,
}
}
/// The capturer's target output format (Goal-1 stage 5): `gpu` from the already-resolved `encoder`
/// (no second backend probe), `hdr` from the plan. Handed into `capture::capture_virtual_output` so the
/// capturer never re-derives the encode backend.
pub fn output_format(&self) -> crate::capture::OutputFormat {
crate::capture::OutputFormat {
gpu: self.encoder.is_gpu(),
hdr: self.hdr,
}
}
}
/// Process topology. On Windows this is the former `punktfunk1::should_use_helper` logic verbatim; on
/// every other platform the session is always single-process.
#[cfg(target_os = "windows")]
fn resolve_topology() -> SessionTopology {
let cfg = crate::config::config();
// `NO_HELPER`/`NO_WGC` force single-process; IDD-push captures in-process in Session 0 (no helper);
// otherwise the helper runs when forced or when we're SYSTEM (in-process WGC can't activate there).
let helper = if cfg.no_helper || crate::capture::wgc_disabled() || cfg.idd_push {
false
} else {
cfg.force_helper || crate::capture::wgc_relay::running_as_system()
};
if helper {
SessionTopology::TwoProcessRelay
} else {
SessionTopology::SingleProcess
}
}
#[cfg(not(target_os = "windows"))]
fn resolve_topology() -> SessionTopology {
SessionTopology::SingleProcess
}
#[cfg(target_os = "windows")]
fn resolve_encoder() -> EncoderBackend {
match crate::encode::windows_resolved_backend() {
crate::encode::WindowsBackend::Nvenc => EncoderBackend::Nvenc,
crate::encode::WindowsBackend::Amf => EncoderBackend::Amf,
crate::encode::WindowsBackend::Qsv => EncoderBackend::Qsv,
crate::encode::WindowsBackend::Software => EncoderBackend::Software,
}
}
#[cfg(not(target_os = "windows"))]
fn resolve_encoder() -> EncoderBackend {
EncoderBackend::PlatformAuto
}

Some files were not shown because too many files have changed in this diff Show More