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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
§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>
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>
§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>
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>
§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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
cargo audit fails on the rsa "Marvin Attack" advisory, which has NO fixed release
(the constant-time rewrite is still unreleased upstream) and rsa is required for
GameStream/Moonlight pairing. The attack targets RSA *decryption* (PKCS#1 v1.5
padding oracle); the host uses rsa ONLY for PKCS#1 v1.5 signing/verifying
(gamestream/cert.rs + pairing.rs), never for decryption, so the vulnerable path is
not exercised. Add the documented .cargo/audit.toml ignore with the justification.
The 3 unmaintained warnings (audiopus_sys / paste / rustls-pemfile) are left visible
on purpose — `cargo audit` does not fail on them, and they carry a maintenance signal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Windows host was NVIDIA-only (NVENC) with an openh264 software fallback. Add
AMD AMF and Intel QSV via libavcodec — the Windows analogue of the Linux VAAPI
backend — so one installer serves all three GPU vendors.
- encode/ffmpeg_win.rs: new WinVendor{Amf,Qsv} encoder. System-memory NV12/P010
readback (default, robust) + opt-in zero-copy D3D11 (PUNKTFUNK_ZEROCOPY: shares
the capturer's ID3D11Device; AMF takes AV_PIX_FMT_D3D11, QSV derives a QSV frames
ctx and maps) with a system fallback for the format-group mismatch the capturer's
video-processor fallback can produce. HDR Main10 (P010 + BT.2020/PQ VUI; an
Rgb10a2->P010 swscale covers the shader fallback).
- encode.rs: Codec::amf_name/qsv_name; open_video + windows_resolved_backend()
resolve PUNKTFUNK_ENCODER=auto|nvenc|amf|qsv|sw via a DXGI adapter VendorId probe.
- capture/dxgi.rs: gpu_mode mirrors the resolved backend (D3D11 NV12/P010 for AMF/QSV).
- gamestream/serverinfo.rs: GPU-aware codec advertisement (windows_codec_support;
AV1 gated to RDNA3+/Arc, like the VAAPI path).
- Cargo.toml: amf-qsv feature (optional ffmpeg-next in the windows target block).
- CI/installer: windows-host.yml sets FFMPEG_DIR + builds --features nvenc,amf-qsv;
the Inno installer bundles the FFmpeg DLLs; host.env default nvenc -> auto.
CI-green target; AMF/QSV not yet on-glass validated (no AMD/Intel Windows box in the
lab) — NVENC stays live-validated. An adversarial-review pass caught + fixed real
FFI bugs (AV_PIX_FMT_P010 is a macro -> P010LE; windows-rs 0.62 GetImmediateContext/
GetDesc1 return Result; AV_HWFRAME_MAP_* is a bindgen enum with no BitOr).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DualSenseWindowsManager now SwDeviceCreate's the pf_dualsense devnode per session
(SwDeviceClose on drop), matching the Linux UHID pad's lifecycle. It's best-effort:
SwDeviceCreate currently hits an unresolved E_INVALIDARG when a completion callback is
passed (an underscore in the enumerator name was a second cause, fixed by using
"punktfunk"), so on failure the host keeps the section + data plane and falls back to
an out-of-band devnode (installer/devgen) — see docs/windows-dualsense-scoping.md.
Add a `dualsense-windows-test` host CLI that drives the manager (create devnode + push
a frame + hold), used to validate the path. Live on the RTX box: the manager creates
the section + pushes report 0x01 and a devnode serves it to a HID read (b1=0xC0,
b8=0x28) — the host-side data plane works end to end.
cargo check + clippy -D warnings clean on x86_64-pc-windows-msvc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The host<->driver channel is the shared-memory section (hidclass blocks the device
stack and UMDF has no control device), so the first-attempt in-driver IOCTL channel
never fired. Remove it: the custom device interface, IOCTL_PFDS_SET_INPUT/GET_OUTPUT,
the output queue, and the on_set_input/complete_one_read/deliver_output helpers. The
driver keeps the HID handshake, the 8ms read timer fed from the shared section, and
on_output_report publishing the game's 0x02 to the section. Rebuilt + reloaded + the
channel still verifies both directions live on the RTX box.
Also list `pf_dualsense` as a second hardware id (alongside `root\pf_dualsense`) so the
host's SwDeviceCreate'd software device binds the same driver as a devgen one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire the Windows UMDF DualSense driver into the host as a real pad backend, so a
client that requests a DualSense gets a genuine one on a Windows host (instead of
folding to Xbox 360).
- Extract the transport-independent DualSense contract (DsState + from_gamepad,
serialize_state, parse_ds_output, DUALSENSE_RDESC, feature blobs, DS_* consts)
out of the Linux-only UHID backend into inject/dualsense_proto.rs, shared by both
platforms; dualsense.rs is now just the /dev/uhid plumbing.
- Add inject/dualsense_windows.rs: DualSenseWindowsManager mirroring the Linux
DualSenseManager (same new/handle/apply_rich/pump/heartbeat surface) over a
DsWinPad that creates the Global\pfds-shm-<idx> section (CreateFileMappingW +
SDDL D:(A;;GA;;;WD) so WUDFHost can open it), writes serialize_state -> input
slot, polls output_seq -> parse_ds_output -> rumble/hidout callbacks.
- Un-gate the seam: PadBackend::DualSenseWindows arm; pick_gamepad gains a
windows flag (DualSense honored on linux||windows; DS4/Xbox One stay Linux-only).
Verified: Linux cargo test gamepad_resolution_precedence + clippy clean; Windows
cargo check + clippy -D warnings clean (on the RTX box). Device lifecycle still
uses an out-of-band devnode (devgen/installer); SwDeviceCreate per session is next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A self-authored UMDF2 HID minidriver (packaging/windows/dualsense-driver) that
presents a virtual Sony DualSense (VID 054C/PID 0CE6) on Windows — adaptive
triggers / lightbar / rumble that ViGEm structurally cannot deliver.
Validated live on an RTX box (Win11 25H2, Secure Boot ON): the self-signed driver
loads, Steam recognizes it as a genuine DualSense, and a game's 0x02 output report
reaches the driver. The host<->driver channel is a named shared-memory section
(Global\pfds-shm-<idx>) the host creates and the driver maps from its timer: input
report 0x01 host->driver, output report 0x02 driver->host — input and output proven
both directions live. This bypasses hidclass, which gates both a custom device
interface and custom IOCTLs on the HID node, and UMDF has no control device.
Built in Rust on microsoft/windows-drivers-rs. The load wall was the PE
FORCE_INTEGRITY bit that wdk-build sets via /INTEGRITYCHECK (forces a CI-trusted
page-hash signature a self-signed cert cannot satisfy) — cleared post-build. See
packaging/windows/dualsense-driver/README.md for the build/sign/install recipe.
Deferred: SwDeviceCreate per-session device lifecycle; removing the inert in-driver
IOCTL-channel code; full on-glass session test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Scalar's /api reference injects a *global* `body { background-color:
var(--scalar-background-1) }` (via its linked stylesheet + a runtime
<style id=scalar-style>) that TanStack doesn't remove on a client-side route
change. After navigating /api -> /docs without a reload, that rule kept
painting the docs body: Scalar's stock gray (#0f0f0f) while .dark-mode lingered
on <body>, or transparent once the class was gone. A hard reload was fine
because the stylesheet was never loaded there.
Fix: give --scalar-background-1 a global fallback = --color-fd-background so any
non-API page paints its own surface while Scalar's sheet lingers; /api itself
overrides it via the higher-specificity body.{dark,light}-mode rule. Also strip
the leftover #scalar-style/#scalar-refs nodes and body mode-class when /api
unmounts so the DOM matches a fresh load. Verified light + dark via headless
CDP: post-nav docs body now equals a fresh reload (#141019 / #f0ebff).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Scalar puts .light-mode/.dark-mode on document.body and renders customCss
*before* its built-in theme preset in the same <style> tag, so a bare
.dark-mode override loses at equal specificity and the stock #0f0f0f gray
showed through. Scope the palette to body.{dark,light}-mode (0,1,1) so it beats
both the linked base sheet and the in-component preset, and add a full
light-lavender palette to match the docs light surface.
Drive Scalar's darkMode from the resolved Fumadocs theme (next-themes) instead
of hard-locking it on, so toggling the docs theme switch flips the API
reference too; the React wrapper's updateConfiguration effect live-swaps the
body mode class.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A push to main publishes canary builds to canary channels (fast iteration,
unchanged); a single vX.Y.Z tag releases every platform at one version to the
stable channels and attaches all artifacts (.deb/.rpm/.msix/.apk/.aab/.dmg +
flatpak/decky/host-installer) to one Gitea Release. Collapses the
host-v*/win-v*/host-win-v* tag namespaces into v* — the channel split makes the
version-shadow bug structurally impossible (canary and stable are separate repos,
never a shared version line).
- scripts/ci/gitea-release.{sh,ps1}: one idempotent release helper
(create-or-fetch + delete-before-upload), replacing 3 copy-pasted inline blocks
and fixing their latent 409-on-reupload bug; prerelease flag auto-derived from
the tag (an -rc tag won't shadow "Latest")
- channels: apt canary/stable distributions; rpm *-canary/base groups; flatpak
canary/stable OSTree branches + a 2nd .Canary.flatpakref; generic-registry
canary/ vs latest/ aliases; Play internal/alpha; Apple TestFlight vs notarized DMG
- android versionName threaded through gradle (versionCode stays run_number);
Apple canary = TestFlight-only (no DMG/tvOS); canary base bumped to 0.3.0
- docs: new docs-site channels.md (subscribe table + cut-a-release runbook +
box migration), refreshed ci.md workflow table + packaging READMEs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends virtual-controller support beyond Xbox 360 + DualSense. Goal: a
physical Xbox One or PS4 pad on the client gets a near-native matching virtual
pad on the host, auto-resolved from the controller type.
Protocol/core:
- GamepadPref gains XboxOne (wire 3) + DualShock4 (wire 4); to_u8/from_u8/
from_name/as_str + C ABI PUNKTFUNK_GAMEPAD_XBOXONE/_DUALSHOCK4 constants
(compile-time guard ties them to the enum). Single-byte wire form is
unchanged, so it's forward-compatible (older peers degrade to Auto).
Host (Linux):
- New UHID DualShock 4 backend (inject/dualshock4.rs) bound by hid-playstation:
lightbar, touchpad, motion, rumble — DualSense minus adaptive triggers /
player LEDs / mute. Reuses the DualSense pure state + button mapping; only the
report byte layout, the real-DS4 HID descriptor, the GET_REPORT handshake
(0x12 MAC mandatory; 0x02 calibration; 0xa3 firmware) and the touchpad
resolution (1920x942) differ. Touchpad/motion ride the existing 0xCC plane,
lightbar the 0xCD Led plane (deduped); rumble the universal 0xCA plane.
- Xbox One/Series is the uinput Xbox-360 backend parameterized with the One S
USB identity (045e:02ea) for matching glyphs — XInput-identical otherwise.
- PadBackend dispatch + resolver handle both; off Linux the UHID pads and
One/Series fold into Xbox 360. Windows-host DS4 (ViGEm) deferred.
Clients (auto-resolve physical pad -> virtual type, plus manual settings):
- Linux/Windows (SDL3): SDL_GAMEPAD_TYPE_PS4 -> DualShock 4, _XBOXONE ->
Xbox One; PadInfo carries the resolved pref; DS4 touchpad/motion capture +
lightbar already type-agnostic. Linux settings combo + label updated.
- Apple (GameController): GCDualShockGamepad/GCXboxGamepad detection, DS4
touchpad capture, settings picker entries.
- Android (Kotlin): InputDevice VID/PID auto-detect (matching the other
clients) + settings entries.
- probe: --gamepad help/aliases.
Also hardens the Android JNI boundary: wrap the teardown + poll-thread shims in
catch_unwind so a panic degrades to a logged no-op instead of aborting the app.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Dashboard session card: the header stacks the title above the action buttons
on narrow screens (flex-col -> sm:flex-row) and the button group wraps
(flex-wrap), so "Request IDR" / "Stop session" no longer overflow the card.
- Mobile bottom nav: give each label a fixed two-line-tall centered box so a
1- or 2-line label (labels vary by locale) keeps every tab icon at the same
height.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@scalar/api-reference-react@0.9.47's entry imports createApiReference but does
NOT import its own style.css (nor inject it at runtime), so /api rendered with
no Scalar CSS at all. Import the sheet as a route-scoped <link> (?url +
head.links, same pattern as the root app.css) so it loads for SSR + the
client-side Vue mount. The brand customCss still themes on top.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- console: remove @unom/ui's specular "material" gloss (drop UnomProviders +
the material.css import) so components render flat like the marketing site;
the violet brand + Geist stay.
- mobile bottom tab bar: center the labels (w-full text-center, leading-tight)
and even out the per-tab layout.
- docs /api: roll the punktfunk dark-violet palette across the whole Scalar
reference (surfaces/text/sidebar/links/buttons/method colours via the full
--scalar-* token set), locked to dark (hideDarkModeToggle).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the management console (web/) off shadcn/ui to the shared @unom/ui
design system the marketing site + docs are built on, on the punktfunk
violet brand over dark chrome:
- Add @unom/ui/@unom/style/motion/radix-ui/zod + Geist; web/.npmrc maps the
@unom scope (packages are public-read, so CI needs no npm auth).
- styles.css: one dark-violet palette (#141019/#1c1530, brand #6c5bf3 ->
#a79ff8) exposed under BOTH the shadcn token names the routes use and
@unom/ui's contract, so routes + components both resolve; pulls in
@unom/ui's material gloss + easings.
- components/ui/* now back onto @unom/ui (AnimatedButton/InputText/Label/
AnimatedCard); brand-mark/wordmark/logo replace the generic Radio icon in
the shell + login.
- MaterialProvider (specular gloss) at the root. No UI sounds, like the site.
docs-site: new /api route renders the host management REST API as an
interactive Scalar reference (reads public/openapi.json, a snapshot of
docs/api/openapi.json), branded violet and linked from the top nav, the
docs sidebar, the landing page, and host-cli.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Rename steam-deck-host.md → steamos-host.md (nav + install table updated).
- Lead with the rationale: SteamOS host support targets the upcoming Steam
Machine; the Steam Deck is the SteamOS device validated against today.
- Soften the WiFi note: ~250 Mbps was our testing on one device/network,
not a universal ceiling — other SteamOS hardware/drivers/bands may do more.
- Generalize Deck-specific language to SteamOS devices throughout.
- Document --no-gamestream (secure native-only) + GameStream-compat caveat.
- decky README: drop stale `serve --native` (now just `serve`).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the GameStream-opt-in posture (54b75c9) on the SteamOS path: the installer keeps
Moonlight compat on by default (`serve --gamestream`, the Deck commonly streams to Moonlight),
but `--no-gamestream` now installs a secure native-only host with no GameStream on-path surface
(plain-HTTP pairing / legacy GCM nonce reuse — security-review #5/#9; native clients only).
Documented in the installer --help; the SteamOS host doc references it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follows the security audit (#5/#9): the GameStream-compat plane carries inherent on-path weaknesses
that can't be fixed on the wire without breaking stock Moonlight — its pairing runs over plain HTTP
(#9, MITM-able during the pairing window) and its legacy control encryption can reuse GCM nonces (#5,
a passive eavesdropper can recover/forge input). The native punktfunk/1 plane (SPAKE2 PIN pairing +
per-direction AEAD nonces) has neither. So flip the default to secure-by-default:
- `serve` → native punktfunk/1 plane + management API ONLY (no GameStream surface).
- `serve --gamestream` → ALSO the GameStream/Moonlight-compat planes (nvhttp pairing, RTSP, ENet
control, _nvstream mDNS). Opt-in, logged with a trusted-LAN caveat. `--moonlight` is an alias.
- The native plane is now ALWAYS on in `serve` (`--native` is a kept-for-compat no-op); the unified
GameStream+native host is `serve --gamestream`.
`gamestream::serve` gates the GameStream spawns (nvhttp/rtsp/control/mdns) on the flag; the native
plane + mgmt + native-pairing handle always run.
To avoid silently regressing validated Moonlight deployments, the explicit deployment configs PRESERVE
Moonlight via `--gamestream` (each documents dropping it for a secure native-only host): the Linux
systemd unit, the Steam Deck installer, and the Windows service default (DEFAULT_HOST_CMD). The bare
`serve` default (new/manual use) is secure.
Docs swept to match (host-cli, moonlight, quickstart, install, packaging READMEs, CLAUDE.md, README,
…): Moonlight setup now instructs `--gamestream`; native/console refs use bare `serve`. OpenAPI
regenerated (a stale "run `serve --native`" string). fmt + clippy clean; 94 host tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the lower-severity findings from docs/security-review.md (#4-#12). Each fix was
adversarially re-reviewed (5-agent pass); two review catches folded in (the Apple client's
GET /library cert path; an RTSP header-cap bypass + a spawn-panic counter leak).
- #4 [low] mgmt mTLS-paired-cert no longer grants full admin. A paired STREAMING cert authorizes
only a read-only allowlist (GET /host,/compositors,/status,/clients,/native/clients,/library);
every state-changing route and every PIN-exposing route (/pair, /native/pair) requires the
operator's bearer token. New cert_auth_is_a_read_only_allowlist test. (/library kept on the
allowlist — the native clients browse it cert-only; its mutations stay token-only.)
- #6 [low] RTSP pre-auth DoS bounds: a concurrent-connection cap (RAII slot guard), a per-read
timeout (slow-loris), and Content-Length/header/message size caps — closing an unauthenticated
slow-loris / memory-growth / thread-exhaustion vector on TCP 48010.
- #11 [info] A FEC reconstruction failure is now a counted drop (discard the block, keep the
session) instead of being stream-fatal — a lossy link can't be torn down by one bad block.
- #10 [info] Fixed ALPN ("pkf1") on both native QUIC endpoints (defense-in-depth; a deliberate
coordinated client+host upgrade — a new host rejects an ALPN-less old client).
- #8 [info] Constant-time GameStream pairing phase-4 hash compare (crypto::ct_eq).
- #7 [low] New VirtualDisplay::set_launch_command carries the launch command per-session on the
GameStream path (no process-global env stomp under concurrent sessions); native path keeps the
env under today's single-session model (documented; plumb per-session with concurrent sessions).
- #5 [low] Legacy GameStream GCM nonce reuse: documented as inherent to Nvidia's old-style control
encryption (Apollo/Moonlight identical; key is client-known) — unfixable on the legacy wire; the
real fix is V2 control-encryption negotiation. Code comment at control.rs.
- #9 [info] GameStream plain-HTTP pairing: documented (inherent to GFE compat; use punktfunk/1).
- #12 [low] Web global NODE_TLS_REJECT_UNAUTHORIZED: fix designed (undici dispatcher scoped to the
loopback mgmt fetch) but DEFERRED — needs `bun add undici` in the web build env; reverted to keep
the web working. Latent-only (the loopback mgmt fetch is the console's only outbound TLS).
fmt + clippy -D warnings clean; 94 host + core tests green; no C-ABI/OpenAPI drift. (The HDR
Steps 1-2 client work in the tree is the user's parallel WIP — deliberately NOT included here.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Continues docs/hdr-pipeline-plan.md. Steps 0/1 + Step 2 (Windows/Android) already
landed in 3526517; this is Step 2 (Apple) + Step 3 (all clients). Client-only — no
core/host/ABI change (the 0xCE/next_hdr_meta/color_info surfaces shipped in Step 0).
Step 2 — clients APPLY the host's HDR metadata (each remaps from the wire form: ST.2086
G,B,R order, mastering luminance in 0.0001 cd/m2):
- Apple: connect via punktfunk_connect_ex5 (resurrects the previously-dead HDR pipeline);
nextHdrMeta/colorInfo wrappers + HdrMeta SEI-blob builders; the pump drains nextHdrMeta
-> VideoDecoder.setHdrMeta -> CVBufferSetAttachment of MasteringDisplayColorVolume (24B
BE) + ContentLightLevelInfo (4B BE) on each HDR pixel buffer (correct for the
itur_2100_PQ layer; CAEDRMetadata avoided as ambiguous there).
Step 3 — capability-gate: advertise HDR caps ONLY when the display can present it, so an
SDR display gets a proper BT.709 stream instead of PQ it would mis-tone-map; an HDR
display self-tone-maps from the Step-1/2 mastering metadata.
- Windows: present::display_supports_hdr() (DXGI any IDXGIOutput6 colour space == G2084),
ANDed with the user HDR setting in session.rs; logs the SDR drop.
- Apple: NSScreen.maximumExtendedDynamicRangeColorComponentValue>1 (macOS) /
UIScreen.main.potentialEDRHeadroom>1 (iOS) in SessionModel.
- Android: Settings.displaySupportsHdr (Display.getHdrCapabilities HDR10/HDR10+) passed
through a new hdr_enabled jboolean on nativeConnect; session.rs gates the caps.
Validation: Android native (incl. the jboolean gate) builds + clippy clean via cargo-ndk;
fmt clean. Windows (MSVC), Apple (Swift) and the Kotlin side are CI/on-glass validated —
not compilable on the Linux dev box. Deferred to the RTX box: mid-session Reconfigure
SDR-downgrade on monitor move, and confirming the host emits SDR for an SDR client off an
HDR desktop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two strands, entangled in punktfunk1.rs, committed together (one builds-green tree).
HDR pipeline Step 0 — glass-to-glass colour-metadata transport (docs/hdr-pipeline-plan.md):
- Protocol/ABI: ColorInfo on the Welcome + a 0xCE HdrMeta datagram carry the source colour
space + HDR10 static mastering metadata (quic.rs, abi.rs connect_ex5 fixing caps=0).
- New platform-independent, unit-tested HDR static-metadata helpers (hdr.rs): chromaticities
(1/50000), mastering luminance (0.0001 cd/m2), MaxCLL/MaxFALL in HDR10/ST.2086 units.
- Capture/encode hooks (capture.rs, encode.rs set_hdr_meta) + Linux client / probe plumbing.
Security-audit hardening — top 3 from docs/security-review.md, each adversarially verified:
- #1 [HIGH] Secret file permissions. The host key.pem/cert.pem and both trust stores are now
written owner-only: 0600 + dir 0700 on Unix (mirrors mgmt_token), best-effort
SYSTEM/Administrators/OWNER-only icacls DACL on Windows (%ProgramData% is Users-readable).
Closes a local key-disclosure -> host-impersonation gap. New gamestream::{create_private_dir,
write_secret_file} + a 0600 regression test.
- #2 [HIGH] Native SPAKE2 PIN is single-use. The PIN is consumed the moment the host sends its
key-confirmation (which lets the client test its one guess), before reading the proof, so any
completed attempt -- right OR wrong -- disarms the window. A wrong PIN isn't observable
host-side (the client aborts before sending its proof), so consuming on first attempt is what
delivers the documented "one online guess" instead of an unbounded brute-force of the static
4-digit PIN. Test verifies single-use.
- #3 [MEDIUM] RTSP packetSize is bounded ([64,2048] in stream_config) and VideoPacketizer::new
uses saturating .max(1), killing a PRE-AUTH div-by-zero/underflow panic of the video thread.
Tests for {0,15,16,17} + out-of-range rejection.
fmt + clippy -D warnings clean; full workspace test suite green (93 host tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A pass over the apollo-comparison backlog (re-verified against current code).
Lands four items end-to-end plus a Windows-DualSense scoping doc.
- #5/#92/#26 — GameStream paired-cert allow-list. tls.rs surfaces the verified
peer cert to handlers (serve_https + PeerCertFingerprint, now shared with the
mgmt API instead of duplicated); nvhttp gates /launch /resume /applist /cancel
on AppState.paired and reports a real PairStatus; save_paired writes atomically
(temp+rename). Closes the "mTLS accepts any client cert" hole. + regression test.
- #6/#51/#19/#22 — NVENC caps query -> reference-frame invalidation. nvenc.rs
query_caps probes nvEncGetEncodeCaps (max dims / 10-bit / custom-VBV / RFI),
rejecting over-range modes and degrading 10-bit->8-bit instead of an opaque
InvalidParam. New Encoder::invalidate_ref_frames (default false -> caller
keyframes); the Windows NVENC path implements real RFI (multi-ref DPB +
nvEncInvalidateRefFrames, dedup + IDR-on-overflow). control.rs decodes the
0x0301 lost-frame range (Apollo's IDX_INVALIDATE_REF_FRAMES) -> AppState.rfi_range
-> encode loop, falling back to a keyframe. NOTE: the Windows NVENC impl is
RTX-box/CI-pending (can't compile on Linux); adversarially reviewed vs the SDK.
- #43/#72 — media socket QoS + buffer growth. New punktfunk_core::transport::qos:
grow_socket_buffers (factored out the native plane's 32MB SO_SNDBUF growth so the
GameStream sockets reuse it) + set_media_qos (opt-in PUNKTFUNK_DSCP=1: DSCP CS5
video / CS6 audio + Linux SO_PRIORITY, Apollo's scheme). Wired into UdpTransport
and the GameStream video/audio sockets. Windows IP_TOS needs qWAVE (follow-up).
- #8/#45 — GameStream input injection off the ENet service thread. on_receive no
longer injects inline (a slow inject head-blocked ENet keepalive/retransmit); it
forwards to a dedicated injector thread. The hardened InjectorService moved from
punktfunk1 into crate::inject (shared by both planes) + a coalesce step that sums
adjacent relative-mouse/scroll deltas while preserving button/key/abs ordering.
Docs: re-verified apollo-comparison.md status (22 items already done/obsolete since
the snapshot) + windows-dualsense-scoping.md (ViGEm can't emulate a DualSense; real
DS5 on Windows needs a VHF virtual-HID driver — web-research pass pending).
fmt + clippy -D warnings clean; full workspace test suite green; no C-ABI/OpenAPI drift.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The management console is a Nitro `node-server` build (per web/vite.config.ts) — it must be
run with `node`, not `bun`. Run under bun it 500s on every page render with
"Cannot find package 'srvx'": bun mis-resolves Nitro's externalized server deps from the
nested SSR chunk at request time. (This was pre-existing — the old manual pfweb.sh ran it
with bun too.)
- Provision `nodejs` in the pf2 distrobox; run the web service with `node .output/server/index.mjs`.
- Use `enable` + `restart` (not `enable --now`) so re-running the installer actually applies
unit-file changes instead of no-opping against the running service.
Verified on the Deck: web `/login` now returns 200 (was 500), "Listening on http://0.0.0.0:3000",
no srvx error.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SteamOS is immutable read-only Arch, and the Deck is AMD (VAAPI) — so none of the
checked-in packaging (arch/sysext is NVENC-first + client-oriented, deb/rpm are
soname-mismatched) actually installs a working host on a Steam Deck. The proven path
(distrobox-built native binary + systemd-run units) was 100% manual. Make it one command.
- scripts/steamdeck/install.sh — idempotent installer: ensure the pf2 Debian-trixie
distrobox + toolchain → build host (+web console) → write config (generated web login
password) → raise UDP buffers to 32 MB + udev + input group (sudo, skipped gracefully
if unavailable) → install + start punktfunk-host / punktfunk-web systemd USER services
with linger. Flags: --open (accept unpaired clients), --no-web, --src=DIR. Builds
on-device so a rebuild always matches the running SteamOS (no prebuilt-binary fragility
across OS updates); VAAPI on the Deck's AMD GPU.
- scripts/steamdeck/update.sh — rebuild from current source + restart (config/pairings persist).
- scripts/steamdeck/README.md — deep reference (why on-device, what's installed, gotchas).
- docs-site: new "Steam Deck (Host)" guide + sidebar entry; install.md splits Arch from the
Steam Deck host path; packaging/arch/README points Deck-host users here and corrects the
stale "NVENC-only" note (VAAPI host encode landed).
Live-validated on the Deck: installer runs clean, both services come up, host listens
(QUIC :9777 + mgmt :47990), web serves (302→login); on a client connect it takes over the
Game-Mode gamescope session at the client's mode, captures via PipeWire, and VAAPI-encodes
(hevc_vaapi) — full pipeline confirmed in the host journal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On a clean link the flat 20% FEC is pure waste: extra wire bytes AND extra
packets. On a packet-rate-bound uplink (the Steam Deck's WiFi tx caps ~22k pps
regardless of bitrate) those extra packets directly cost goodput — measured at
200 Mbps goodput, 20% FEC drove ~10% loss vs ~2.6% at 0% (it saturated the link).
Adaptive FEC closes the loop:
- Client measures the loss FEC is absorbing each ~750 ms window from session stats
(recovered shards / received, + a bump when a frame went unrecoverable) and sends
a periodic `LossReport { loss_ppm }` on the control stream (new message;
`window_loss_ppm` helper, shared + unit-tested). Connector (Apple/Linux/Windows)
and probe both report; suppressed during a speed test so its filler can't skew it.
- Host maps loss → recovery % (`adapt_fec`: ≈ loss×1.4 + 1pt, clamped 1..50) and
applies it live via `Session::set_fec_percent` (the wire is self-describing — each
packet carries its block's data/recovery counts, so the receiver needs no notice).
A clean link decays to ~1%; loss ramps it up and converges.
- `PUNKTFUNK_FEC_PCT`, when set, now PINS FEC static (disables adaptation) so
speed-test / measurement runs keep a fixed, known overhead. Unset ⇒ adaptive,
starting at 10%.
An older host ignores LossReport (unknown control message) and keeps static FEC;
an older client simply never reports and the host holds its start value. Builds +
clippy + fmt + tests green (adapt_fec / window_loss_ppm / loss_report unit tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
HDR (10-bit BT.2020 PQ) works end-to-end with the Windows host — it captures
an HDR desktop (WGC FP16 / Desktop-Duplication FP16 for the secure desktop)
and encodes HEVC Main10 to HDR-capable clients (Windows, Android). Only the
Linux host is blocked upstream (no 10-bit compositor capture). Corrected the
roadmap (grid + shipped/blocked), Windows Host page, status, and CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- install (host): add a Windows (NVIDIA) section with signed-installer and
certificate-trust steps; note the .cer is the same across releases.
- install-client: clarify the Windows MSIX certificate is the same every
release (trust once, updates need nothing).
- Move "Project & Internals" out of the public docs site: relocate
implementation-plan, apple-stage2-presenter, gamescope-multiuser,
dualsense-haptics, ci, and gamestream-host-plan to docs/; drop them from
the nav. Move windows-host into Host Setup.
- Rewrite roadmap as a lean public page with an at-a-glance grid and
current statuses (Windows host shipped/beta, Apple incl. tvOS shipped,
Android shipped, concurrent sessions + delegated pairing done).
- Fix status.md link to the now-internal implementation plan.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The punktfunk/1 speed test was unusable across every client/host: at the start of
a burst a little data got through, then everything read as dropped (~10 MB total).
Two compounding bugs:
1. Receive side measured throughput from fully-reassembled FLAG_PROBE *access
units* only. The instant loss crossed the 20% FEC budget no AU completed, so the
figure cliffed to 0 / 100% loss even though most bytes still arrived — a binary
cliff, not a graded measurement.
2. Send side blasted each filler AU (up to 256 KB ≈ 200 packets) into the socket
buffer in one unpaced batch, unlike the real video path which paces. On a small
buffer (e.g. the Steam Deck's 416 KB) a single AU overflowed it, so the test
measured self-inflicted buffer overflow instead of the link.
Fixes:
- Host `run_probe_burst` keeps each AU a small (~16 KB) burst and paces by the byte
budget, mirroring `paced_submit`; reports the WIRE packets the kernel accepted and
the ones the send buffer dropped (stat deltas), separating host-side drops from
link loss.
- `ProbeResult` gains `wire_packets_sent` + `send_dropped` (back-compat decode: a
21-byte pre-wire-stats result still decodes, new fields 0).
- Clients (probe + connector) count delivered traffic at the packet level via
`session.stats()` deltas over the burst window, so throughput/loss degrade
gracefully. Connector freezes the delivered figure when the host report lands so
resumed video can't inflate it. New `ProbeOutcome`/`PunktfunkProbeResult` fields:
`host_drop_pct`, `wire_packets_sent`, `send_dropped`.
Validated on loopback (graded 142→1391 Mbps, host_drop/link_loss split correctly,
no cliff) and live against the Deck: clean to ~200 Mbps goodput / 273 Mbps wire at
0% link loss, host send buffer the wall above that (the lever-#1 target).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add the Windows host (implemented & shipping: DXGI capture, SudoVDA
virtual display, NVENC, ViGEm, WASAPI, LocalSystem service installer;
NVIDIA-only, x64-only) — it was absent entirely.
- Add the Android client (full client: AMediaCodec/HDR10 decode, Oboe
audio + mic, gamepad feedback, discovery, pairing, Compose phone+TV UI;
Google Play internal testing) and drop the stale "scaffolds" item.
- macOS stage-2 presenter: built + live-validated behind the opt-in flag,
not "next".
- Concurrent sessions + delegated pairing approval marked done.
- Layout/CI: note Windows host backends and per-client release workflows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refresh the README and documentation for public visitors:
- README: public-facing rewrite with accurate status for all four native
clients (macOS, Linux, Windows, Android) and the Windows host.
- docs site: fix stale client status (Android is a full client, not a
scaffold; Windows client is stage-1 complete + signed MSIX), add the
missing Android client section, correct "which client" guidance.
- Windows host: corrected from "deferred/scoped" to implemented & shipping
(NVIDIA-only, x64-only) across windows-host, roadmap, status,
requirements, running-as-a-service, and the README.
- Remove internal infrastructure from public docs (box names, private IPs,
SSH/token commands, deploy topology); rewrite status.md as a public
project-status page; sanitize ci.md and implementation-plan.md.
- Update clients/android and clients/apple READMEs to current state.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Steam Deck (SteamOS) ships its OWN gaming session — `gamescope-session.target`
driven by `/usr/lib/steamos/gamescope-session`, not Bazzite's `gamescope-session-plus`.
That script `exec gamescope`s with HARDCODED physical-panel args (`-w 1280 -h 800 -O
'*',eDP-1`) and launches Steam via a SEPARATE `steam-launcher.service`, so the existing
managed-session path (which assumes session-plus) couldn't honor the client's mode — an
attach captured the panel's native 1280x800 instead.
Add a SteamOS branch to the managed-session path: detect it, write a `gamescope` PATH-shim
that rewrites the hardcoded args to `--backend headless -W <client> -H <client> -r <hz>`,
drop a transient user `gamescope-session.service.d` override pointing PATH at the shim +
the mode, then RESTART the whole target so `steam-launcher.service` brings Steam up IN the
headless gamescope at the client's resolution. Attach to the one fresh node (the restart
kills any prior gamescope, so no stale-node attach). Restore-on-disconnect removes the
override + restarts the target back to the physical panel (debounced; skipped if the user
switched to a desktop session). All user-level (`systemctl --user`) — no root.
Also widen `build_pipeline_with_retry` to 8 attempts (~90s): a host-managed gamescope
session cold-starting Steam Big Picture takes 30-60s to first frame, and a first-connect
timeout would tear down the warm session (forcing another cold start on reconnect).
Permanent failures still fail fast via `is_permanent_build_error`.
Validated live on a Steam Deck: Game Mode auto-detected, host takes over headless at the
client's mode (720p / 1080p), Steam Big Picture streamed glass-to-glass to the Mac at the
requested resolution. Single-tenant (concurrent clients at different modes still thrash —
a follow-up).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shared unom CMS is now multi-tenant; the footer global became a per-tenant
collection. Query footers scoped to tenant.slug = punktfunk instead of the
removed /globals/footer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WiFi drivers (e.g. ath11k on the Steam Deck) return ENOBUFS — not
EAGAIN/EWOULDBLOCK — when the tx queue is momentarily full. Rust maps
ENOBUFS to ErrorKind::Uncategorized, so `is_transient_io` (which only
matched WouldBlock/ConnRefused/ConnReset) treated it as a real error and
tore the whole stream down on a single transient burst.
This presented as a vicious Heisenbug on the Deck: the native host
streamed flawlessly on loopback and under a debugger (anything slow
enough not to fill the small ~416 KB wlan0 buffer), but died at full rate
cross-machine over WiFi — flaky hang-or-SIGKILL because tx-queue-full is
probabilistic. Diagnosed live via a forced core dump (gdb on the hung
core): the data-plane thread had bailed on a fatal send error.
Treat ENOBUFS (and asynchronous network-path blips ENETUNREACH /
EHOSTUNREACH / ENETDOWN / EHOSTDOWN) as a lossy drop like WouldBlock —
FEC + the next frame recover. Validated: 6/6 back-to-back cross-machine
streams over the Deck's WiFi, host stable, p50 ~4.4 ms (one run dropped
4/300 frames *gracefully*, 0 mismatched — the fix working as intended).
Also surface a data-plane bind/hole-punch failure directly in punktfunk1
(it was previously only reported after teardown, which a stall could
swallow entirely).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pull the same footer from the shared unom CMS global (cms.unom.io) and render
it globally under both the home and docs layouts. Read-only typed fetch in a
server-side root loader (falls back to null on a CMS hiccup). Root-relative
links target the marketing site, so they're resolved against its origin (the
docs don't host /legal/* etc.); themed with Fumadocs tokens for light/dark.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the CSS-mask/webp wordmark with the inline vector from
Export/Punktfunk_Logo-Text_No-Border_Dark.svg (white export background
dropped), painted via currentColor — deep-violet on light, light-violet on
dark. Crisp at any size; drops the now-unused funk-wordmark.{webp,png}.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Swap the plain "punktfunk" text in the nav and landing hero for the real
brand wordmark from the marketing site. The source asset is a single
light-violet variant (made for dark surfaces), so it's painted as a CSS
mask and coloured per theme — deep-violet on light, light-violet on dark —
to stay legible with the docs' light/dark toggle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Theme the Fumadocs docs site with the punktfunk identity, mirroring the
marketing site:
- Swap the stock `neutral` preset for `purple`, then override --color-fd-*
with the violet lens-mark palette (#6c5bf3 / #a79ff8). The brand is the
violet, not the site's blue marketing background, so the blue is not used
as a reading surface; dark mode tints the chrome toward the app-icon
violet-dark (#1c1530).
- Adopt @unom/ui's token contract (--brand/--primary/--accent + bg-brand
etc.) as the shared token source, and @source its dist.
- Load Geist (the brand typeface) via @fontsource-variable/geist.
- Add the BrandMark lens to the nav + landing hero, wire the brand
favicon.svg, and add Docs/Website nav links.
- Keep the Fumadocs light/dark toggle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Phase 3 GPU-aware codec mask (6922e1c) probes VAAPI on any non-NVIDIA host.
On a GPU-less box (CI container: no /dev/nvidia* -> `auto` picks VAAPI, but there's
no VA display) the probe returns all-false, so the mask was 0 -- the host
advertised NO codecs, and the serverinfo unit test failed.
Fall back to the static superset when the probe yields nothing (VAAPI wasn't
usable, not "the GPU encodes nothing"); quiet ffmpeg's expected "No VA display"
error during the probe; and assert the test against codec_mode_support() rather
than a hardcoded 65793 so it's deterministic regardless of the build host's GPU.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Polish for AMD/Intel support:
- GameStream serverinfo advertises only codecs the GPU can ACTUALLY encode on
the VAAPI backend (probed once by opening a tiny encoder per codec). AV1
encode is narrow (Intel Arc/Xe2+, AMD RDNA3+/RDNA4) and an old iGPU may lack
HEVC, so a Moonlight client never negotiates a codec the encoder can't open.
NVENC/Windows keep the Moonlight-validated static mask. Validated on a Radeon
780M: h264/h265/av1 all probe true -> mask unchanged (65793).
- Packaging: Recommends mesa-va-drivers + intel-media-va-driver (deb) /
mesa-va-drivers + intel-media-driver (rpm) so the auto-selected VAAPI backend
works out of the box on AMD/Intel; NVIDIA boxes can --no-install-recommends.
(Fedora note: stock mesa-va-drivers disables HEVC/AV1 -- needs the freeworld
variant from RPM Fusion.)
- De-NVIDIA-fy the user-facing encoder log/context strings ("open NVENC" ->
"open video encoder") now that VAAPI is a first-class backend.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2 of AMD/Intel support: the VAAPI encoder now takes the capture dmabuf
directly and does the RGB->NV12 colour conversion on the GPU's video engine,
eliminating the host-side de-pad + swscale CSC + upload the CPU path pays.
- capture: a vendor-neutral FramePayload::Dmabuf (dup'd fd + fourcc/modifier/
layout). When zero-copy is on, the EGL->CUDA importer is unavailable (any
non-NVIDIA host), and the backend is VAAPI, the capturer advertises LINEAR
dmabuf and hands the raw buffer to the encoder instead of CPU-copying it.
- encode/vaapi: the encoder self-configures from the first frame's payload (no
open_video signature change). The dmabuf arm wraps the buffer as an
AV_PIX_FMT_DRM_PRIME frame and pushes it through a filter graph
buffer(drm_prime) -> hwmap(vaapi) -> scale_vaapi=nv12 -> buffersink; the
encoder takes NV12 surfaces straight from the sink. The Phase 1 CPU-upload
path is kept as the other arm (used when capture produces CPU frames).
Live-validated on a Radeon 780M (real Sway/xdpw desktop capture): correct,
pixel-perfect HEVC, and ~10x less host CPU at 1440p (4.2s -> 0.4s of CPU for
300 frames) -- the de-pad/CSC/upload moves to the GPU. NVIDIA unchanged
(zero-copy still imports to CUDA; the passthrough path only engages on
non-NVIDIA hosts).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The CPU de-pad path trusted PipeWire's MAP_BUFFERS slice (`d.data()`, length =
`data.maxsize`). xdg-desktop-portal-wlr hands MemFd ScreenCast buffers whose
maxsize exceeds the bytes PipeWire actually maps into our process, so reading to
maxsize ran off the end of the mapping and SIGSEGV'd the capture thread —
crashing every CPU-path capture on Sway/wlroots (and thus any non-NVIDIA host,
which has no CUDA zero-copy importer and always falls back to this path).
mmap the fd ourselves, sized to its real length (fstat), for any fd-backed
buffer (MemFd SHM or DmaBuf); fall back to `d.data()` then drop. The existing
`needed > avail` guard now drops cleanly instead of over-reading. This also
subsumes the original "MAP_BUFFERS didn't map a Vulkan dmabuf" fallback.
Verified: fixes real Sway-desktop portal capture -> VAAPI HEVC on a Radeon 780M
(correct image + colours); the NVIDIA zero-copy path (returns before this code)
and the NVIDIA/KWin CPU path (self-mmap, fd_len == maxsize) both still work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The host hard-linked libcuda.so.1 on Linux (`#[link(name="cuda")]` in
`zerocopy::cuda`), so the binary wouldn't even *start* on a non-NVIDIA box —
the dynamic loader can't resolve the NEEDED libcuda. That blocked running the
new VAAPI (AMD/Intel) path on a machine without the NVIDIA driver.
Resolve the 18 CUDA Driver API symbols at runtime via `libloading` instead.
Same-named wrapper fns forward to the dlopen'd table (call sites unchanged);
when libcuda is absent they return a non-zero CUresult so `context()` fails
cleanly and the capturer falls back to the CPU path. The library handle is
leaked (process-lifetime, like the shared context).
One Linux binary now runs on NVIDIA (CUDA zero-copy -> NVENC) and on AMD/Intel
(VAAPI, no NVIDIA driver). Verified: the NVIDIA dev box still does dmabuf->CUDA
zero-copy; on a Radeon 780M box the host builds with no libcuda present, the
binary has no NEEDED libcuda entry, and VAAPI encode runs with no stub.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Linux host was NVENC/CUDA-only. Add a VAAPI encoder — one libavcodec
backend (h264/hevc/av1_vaapi) covering both AMD (Mesa radeonsi) and Intel
(iHD) — behind the existing `Encoder` trait, and turn `open_video`'s Linux
arm into a vendor dispatcher: `PUNKTFUNK_ENCODER=auto|nvenc|vaapi` (default
auto: NVENC when a CUDA frame or /dev/nvidia* is present, else VAAPI). The
NVIDIA path is unchanged — auto resolves to NVENC on an NVIDIA box and the
bitrate-probe loop moved verbatim into `open_nvenc_probed`.
`VaapiEncoder` mirrors the NVENC hwframes pattern with AV_HWDEVICE_TYPE_VAAPI.
The CPU-input path swscales packed RGB -> NV12 (BT.709 limited, VUI signalled)
and uploads into a pooled VA surface (av_hwframe_transfer_data), preserving the
low-latency model (infinite GOP, on-demand forced IDR, async_depth=1, CBR when
the driver supports it). It works on a non-NVIDIA box with no capture changes:
the capturer already falls back to CPU frames when its EGL->CUDA importer can't
initialise (no libcuda).
Live-validated on a Radeon 780M (RDNA3): hevc/h264/av1_vaapi all encode,
HEVC/H264 decode cleanly with correct BT.709-limited colours, infinite GOP
preserved. Zero-copy dmabuf import (the high-res perf lever) is next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The host is NVIDIA/NVENC + SudoVDA coupled; Windows ARM64 has neither an NVIDIA
driver nor an ARM64 SudoVDA, so an ARM64 host would install but couldn't encode
or make a virtual display. Document the deliberate x64-only scope so it doesn't
get re-litigated. ARM64 stays client-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause of the persistent ISCC "path not found": ISCC.exe is 32-bit, and the
self-hosted runner runs as SYSTEM, so the checkout lives under
C:\Windows\System32\config\systemprofile\.cache\... . WOW64 file-system
redirection rewrites a 32-bit process's System32 reads to SysWOW64 (where nothing
exists), so ISCC died opening the .iss before it even printed its version line.
(The smoke-test diagnostic compiled fine precisely because it lived at C:\t\out.)
Fix: copy every file ISCC reads (the .iss + host.env.example + README.md) into
the non-redirected build dir C:\t\out and compile from there; BinDir, StageDir,
and OutputDir already live under C:\t. Removed the now-spent smoke diagnostic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The smoke-test diagnostic proved Inno itself is healthy (a trivial ASCII script
compiled), while the real .iss died before the "Compiler engine version" line —
i.e. at script open, not during compile. The difference: the real .iss was UTF-8
with non-ASCII chars (→, —) in comments, which ISCC 6.4+ rejects without a UTF-8
BOM (and the German-locale runner misreads). Replace them with ASCII (->, -).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All [Files] sources are validated-present yet ISCC still errors before any
"Compiling" output (no line number) — so it's startup/[Setup]-internal, not a
source path. Add an explicit [Languages] (compiler:Default.isl) to rule out the
auto-added default language, and on ISCC failure dump the Inno install dir +
run a trivial [Setup]-only smoke script to tell "Inno broken" from "my script".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The {#SourcePath} relative-traversal for host.env.example/README kept tripping
ISCC ("path not found", error 2) regardless of the separator, so drop it: compute
the two paths absolutely in pack-host-installer.ps1, Test-Path them (clear PS error
if missing), and pass /DHostEnv + /DReadme. The .iss [Files] now reference the
absolute defines — no {#SourcePath}, no ..\.. traversal. Also prints "source ok"
for each so a future failure is unambiguous.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
windows.yml + windows-msix.yml gain an x86_64/aarch64 target matrix. ARM64 is
cross-compiled on the one x64 Windows runner — the x64 MSVC toolset ships the
ARM64 cross compiler, aarch64-pc-windows-msvc is tier-2 with host tools, and
SDL3/libopus (build-from-source) cross-compile cleanly. The only arch-specific
external dep is FFmpeg's import libs: the matrix points FFMPEG_DIR at a per-arch
tree (x64 C:\Users\Public\ffmpeg, arm64 C:\Users\Public\ffmpeg-arm64, both
FFmpeg 7.x / avcodec-61). Per-arch short CARGO_TARGET_DIR avoids a shared target
dir; fmt + test run only for x64 (aarch64 can't execute on the x64 host).
pack-msix.ps1 gains -Arch x64|arm64 (stamps the manifest ProcessorArchitecture,
arch-suffixes the .msix/.cer); windows-msix.yml matrixes both arches and
publishes ..._x64.msix / ..._arm64.msix. setup-windows-runner.ps1 provisions the
rustup target + the ARM64 FFmpeg tree (idempotent).
Verified live on the runner (home-windows-1): debug+release cross-build green,
clippy -D warnings green, and MSIX pack produces a valid arm64 package (manifest
arch=arm64; bundled exe/SDL3/avcodec/reactor-bootstrap all PE machine 0xAA64).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On the Windows WGC HDR path the FP16 scRGB capture was fed to NVENC as
R10G10B10A2 (BT.2020 PQ), and NVENC did the RGB→YUV CSC internally on the
contended SM — adding to the encode_ms wall under a GPU-saturating game.
(NVIDIA's D3D11 VideoProcessor can't do RGB→P010 for HDR; that path renders
green, confirmed live — so the convert must be ours.)
New `HdrP010Converter` fuses the tone-map with the BT.2020 RGB→YUV matrix and
emits P010 (10-bit limited range) directly: a luma pass → an R16_UNORM plane
RTV (full-res) and a chroma pass → an R16G16_UNORM plane RTV (half-res, 2x2
box average) of a DXGI_FORMAT_P010 texture. NVENC then takes native P010 and
skips its SM-side convert.
Gated behind PUNKTFUNK_HDR_SHADER_P010 (default OFF → the existing
R10→NVENC path is byte-for-byte unchanged). Colour validated by a new
`hdr-p010-selftest` subcommand: a synthetic scRGB pattern → P010 → readback,
compared to a BT.2020 PQ 10-bit reference — max abs error Y=0.99 / Cb=0.82 /
Cr=0.75 codes on an RTX 4090. Live-validated HDR colours correct (no green).
Build + clippy (--features nvenc -D warnings) green on x86_64-pc-windows-msvc.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gradle's Exec resolves command[0] via the JVM/daemon's inherited PATH, not
the environment("PATH", …) set on the task (that only reaches the spawned
child). A GUI Android Studio launch — and any daemon it starts — has no
~/.cargo/bin on its PATH, so a bare "cargo" fails with "A problem occurred
starting process 'command 'cargo''". Use the already-computed cargoBin
absolute path; the env PATH still lets cargo/cargo-ndk find their subtools.
Also refresh the README prereqs: add the missing cmake;3.22.1 SDK package
(the cmake crate builds libopus with it) and drop the broken
`brew --prefix openjdk@21` JAVA_HOME hint in favour of `java_home -v 21`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Fedora RPM build linked punktfunk-host against a synthesized libcuda stub
with a FROZEN symbol list baked into ci/fedora-rpm.Dockerfile. The priority-
stream work added cuCtxGetStreamPriorityRange / cuStreamCreateWithPriority /
cuStreamSynchronize / cuMemcpy2DAsync_v2, which weren't in that list, so the
link failed with "undefined symbol".
build-rpm.sh now regenerates /usr/lib64/libcuda.so.1 from every cu* symbol the
host source references (grep of crates/punktfunk-host/src), before rpmbuild — so
a new cu* call can never silently break the link again. Self-maintaining and
needs no builder-image rebuild (it supersedes the Dockerfile's frozen stub).
Verified the 23 extracted symbols compile and cover the 4 that were undefined.
Also fix the bogus %changelog weekday (Sun -> Mon, Jun 15 2026 is a Monday) that
rpmbuild warned on.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ExposedDropdownMenuBox anchors on a read-only OutlinedTextField, and a text field
captures D-pad focus -- directional keys never escape it, so on a TV/controller you
got stuck on the first select. Replace SettingDropdown with a clickable Surface +
DropdownMenu (no text field): D-pad moves between settings, A opens the menu, A
selects an item. Adds a primary-colour focus border so the focused setting reads
across a room.
Verified locally: ./gradlew :app:assembleDebug BUILD SUCCESSFUL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hourly docker-prune could never reclaim the real disk filler: the act_runner
cache server's blob store (cache.dir:"" -> /root/.cache/actcache/cache) lives in
the long-running runner container's WRITABLE LAYER, which docker prune can't see.
It grew to ~66 GB and filled the 125 GB disk on its own.
- New docker-prune.sh holds the logic (inline ExecStart= broke under systemd's
own $-expansion, which emptied $SZ/$(...) before sh ran them — silently no-oping
the burst guard). The unit now just calls the script.
- Caps the actcache: clears the blobs once they exceed ~20 GB (act_runner
repopulates; keys are content-hashed, so only stale entries drop).
- Burst guard lowered 85%->80% and now also clears the actcache.
- Timer hourly -> every 30 min; image/cache `until` 12h -> 6h.
Live: cleared 66 GB on home-runner-1 (93% -> 20%), deployed + verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ISPP's {#SourcePath} has no trailing backslash, so {#SourcePath}..\..\scripts
resolved to ...\packaging\windows..\..\scripts (invalid component "windows..")
-> ISCC error 2 "path not found". Add the explicit separator (a double backslash
is harmless on Windows if a future ISPP ever adds the trailing one).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first CI run failed only on the SudoVDA download: SudoMaker/SudoVDA has no
releases (source-only repo; Apollo embeds the driver in its installer), so there
was nothing to fetch. Vendor the prebuilt SIGNED driver in-repo instead.
- packaging/windows/sudovda/: SudoVDA.inf/.cat/.dll + sudovda.cer (derived from
the .cat signer CN=sudovda@su.mk), pulled from the dev-box driver store.
v1.10.9.289, Class=Display, HWID Root\SudoMaker\SudoVDA, MIT/CC0.
- fetch-sudovda.ps1 -> stage-sudovda.ps1: stage the vendored driver + fetch
nefcon from its real pinned release (v1.17.40, sha256 812bae7e…, x64/nefconc.exe).
- pack-host-installer.ps1: call stage-sudovda.ps1; README updated with the
driver-refresh recipe.
The rest of the pipeline already passed on the first run (host built --features
nvenc via the llvm-dlltool import lib; ISCC + signtool found; signed with the
real CN=unom cert).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Linux zero-copy tiled-GL path can now produce NV12 (BT.709 limited range)
on the GPU and feed NVENC native YUV, deleting NVENC's internal RGB->YUV CSC —
which runs on the SM/3D-compute engine a saturating game pins at 100% (the
game-vs-encode contention headache). Windows already does this via the D3D11
video processor; this closes the Linux gap. See docs/host-latency-plan.md §2A.
Gated behind PUNKTFUNK_NV12 (default OFF → the RGB/BGRx path is byte-for-byte
unchanged; zero regression). Only the tiled EGL/GL path converts; the
LINEAR/Vulkan-bridge (gamescope) path stays RGB.
- zerocopy/egl.rs: Nv12Blit — BT.709 limited Y pass (R8, full-res) + UV pass
(RG8, half-res, GL_LINEAR 2x2 average); both CUDA-registered; import_nv12.
- zerocopy/cuda.rs: two-plane DeviceBuffer (Y W*H@1B + interleaved UV
(W/2)*2 x H/2), paired Y+UV pool, copy_mapped_nv12 + copy_nv12_to_device,
on the per-thread priority stream (dmabuf-recycle sync preserved).
- encode/linux.rs: nvenc_input(Nv12)->NV12; submit_cuda copies two planes into
NVENC's surface; VUI signalled BT.709 limited (colorspace/range/primaries/trc).
- capture/linux.rs: gate (PUNKTFUNK_NV12 && tiled), report format Nv12.
- main.rs + zerocopy/mod.rs: `nv12-selftest` subcommand.
Validated on RTX 5070 Ti two ways: (1) nv12-selftest — synthetic RGBA->NV12
round-trip vs a BT.709 reference, max abs error Y=0.56/U=0.33/V=0.26 LSB;
(2) live capture->NV12->NVENC->decode of animated red content matches the RGB
path's colour (avg RGB 230,18,18 vs 231,18,20). build/clippy/fmt green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cfg(windows) code can't be lint-checked on the Linux dev box, so three
-D warnings slipped through (caught by windows.yml; the FFI + shaders compiled
fine):
- gpu.rs: SetMultithreadProtected returns a must-use BOOL -> `let _ =`.
- video.rs: drop the unused GpuFrame::ten_bit field (present keys off `hdr`;
the value is still computed locally for the first-frame log).
- present.rs: GpuView::frame is an RAII keep-alive (its Drop returns the decoder
surface to the pool), never read -> #[allow(dead_code)].
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The client was pure software HEVC decode + CPU swscale->RGBA + a full-frame
dynamic-texture upload every frame -- the reason performance was poor on a GPU
box (the GPU sat idle while the CPU churned). This adds a hardware path, HDR,
and a GUI pass.
Performance -- D3D11VA zero-copy:
- gpu.rs (new): one D3D11 device (hardware + VIDEO_SUPPORT, WARP fallback,
multithread-protected) shared by decoder and presenter via a Send/Sync
OnceLock. Sharing is mandatory -- a decoded texture is only bindable on the
device that created it. windows-rs COM interfaces are !Send/!Sync, so the
unsafe impl is sound only under the multithread protection + disjoint
decode(video ctx)/present(immediate ctx) split.
- video.rs: D3d11vaDecoder (raw FFI mirroring the Linux VAAPI module). The
COM-typed AVD3D11VA{Device,Frames}Context are declared here (stable FFmpeg
ABI) to avoid ffmpeg-sys binding the d3d11 headers; get_format builds a frames
ctx with BindFlags=SHADER_RESOURCE so the NV12/P010 array slices are
sampleable. av_frame_clone guard keeps each surface out of the reuse pool
until the presenter drops it. Software decode stays as the fallback
(DecoderPref Auto/Hardware/Software; auto falls back on init/decode error).
- present.rs: shared device; per-plane SRVs over the array slice
(NV12->R8/R8G8, P010->R16/R16G16) + three pixel shaders (RGBA passthrough,
NV12/BT.709, P010/BT.2020-PQ). present() now takes the frame by value so the
GPU surface survives re-presents.
HDR:
- Detected in-band (transfer == SMPTE2084), same signal as the other clients.
Swapchain flips to R10G10B10A2 + ST.2084 + HDR10 metadata. New Settings toggle
gates advertising VIDEO_CAP_10BIT|HDR; host still gates 10-bit behind its own
PUNKTFUNK_10BIT + actual-HDR-content checks.
GUI (windows-reactor):
- Host cards with accent-monogram avatars + colored status pills, InfoBar for
errors/pairing hints, ToggleSwitch settings (+ HDR, decoder, bitrate), button
icons, a richer connecting screen, and a stream HUD with GPU/CPU-decode + HDR
status chips.
Not yet on-glass validated: the Linux dev box can't compile the cfg(windows)
code (ffmpeg/windows crates unfetched; WARP has no hw decode) -- only
cargo fmt checks it here. API shapes verified against the windows-rs/reactor
source and the YUV->RGB coefficients checked by hand, but D3D11VA + shaders +
the GUI need a real build (Windows CI / build VM) and on-glass test on the RTX
box. The host-side HDR encode path is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make a controller drive the Compose UI when not streaming, so the menus work on a TV
remote AND on a controller paired to a phone:
- MainActivity maps gamepad face buttons to the keys Compose's focus system
understands (A -> DPAD_CENTER to activate, B -> BACK); D-pad *keys* already move
focus and pass through untouched.
- For controllers whose D-pad reports as HAT axes (or to navigate with the left
stick), dispatchGenericMotionEvent converts AXIS_HAT_X/Y / AXIS_X/Y into discrete
D-pad key events, edge-detected so a held direction moves focus exactly once.
- HostCard draws a clear primary-colour focus border (the default state layer is too
subtle across a room on TV).
All gated on "not streaming" -- during a stream the controller still forwards to the
host unchanged. Compile-verified (./gradlew :app:assembleDebug); the focus behaviour
itself needs on-device validation (no KVM here for a TV emulator).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verified, prioritized analysis in docs/host-latency-plan.md (multi-agent
investigation + adversarial verification). Lands the two low-risk tiers:
Tier 2B — Linux scheduling hygiene:
- boost_thread_priority now nices the capture/encode (-10) and send (-5)
threads on Linux (setpriority, best-effort; no-op without CAP_SYS_NICE),
and the wrong "gamescope caps the game" doc-comment is corrected.
- CUDA context created with CU_CTX_SCHED_BLOCKING_SYNC (frees a core on the
shared box instead of busy-spinning on completion).
- Copies moved off the default stream onto a per-thread highest-priority
CUDA stream (cuStreamCreateWithPriority, graceful NULL-stream fallback)
with a per-stream sync that no longer blocks on the other worker thread's
in-flight copies. Stream priority is measure-then-keep (NVIDIA Linux may
ignore it); never regresses.
Tier 3A — Windows session tuning (new session_tuning.rs, raw C-ABI FFI,
no-op off Windows): once-per-process 1ms timer + DwmEnableMMCSS + HIGH
priority class; per-thread MMCSS "Games" + keep-display-awake. Wired into
both the native (boost_thread_priority) and GameStream (stream.rs) paths.
We had zero session tuning before (Apollo streaming_will_start parity).
Tier 2A (Linux NV12 convert) is specified but intentionally not landed:
it is colour-correctness-critical and needs A/B validation on a GPU box
with a display (green-screen risk). Builds + clippy + fmt green on Linux.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MSIX (the client's format) can't install the host's LocalSystem secure-desktop
service or the SudoVDA kernel driver, so the host ships as a signed Inno Setup
setup.exe that runs elevated and delegates to the existing idempotent
`punktfunk-host service install`.
- packaging/windows/punktfunk-host.iss: lay exe into Program Files, optional
SudoVDA driver task, run service install/start; [Code] stops+waits the service
before file copy on upgrade; uninstall runs service uninstall.
- pack-host-installer.ps1: cert (reuses MSIX_CERT_PFX_B64 / self-signed CN=unom),
sign inner exe + setup.exe, fetch/stage SudoVDA, run ISCC, export public .cer.
- fetch-sudovda.ps1 / install-sudovda.ps1: pinned SudoVDA + nefcon download, cert
import, gated device-node create (no phantom dup), pnputil install (warn-not-abort).
- nvenc/: synthesize nvencodeapi.lib via llvm-dlltool from a 2-export .def so
--features nvenc links with no GPU/SDK at build time.
- .gitea/workflows/windows-host.yml: build (nvenc) -> clippy -> ISCC -> sign ->
publish setup.exe + .cer to the generic registry pkg punktfunk-host-windows.
Tag host-win-v* -> X.Y.Z (+ latest/ alias); main push -> rolling 0.2.<run>.
- setup-windows-runner.ps1: provision Inno Setup; docs: installer instructions.
SudoVDA/nefcon release URLs+SHA-256s in fetch-sudovda.ps1 are placeholders
(baseline v0.2.1) — fetch warns + prints the computed hash until pinned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Decky Loader is a PyInstaller binary; it puts its bundled (older) libssl/libcrypto
on LD_LIBRARY_PATH via its /tmp/_MEI* unpack dir, and that env leaked into the
backend's `flatpak run`/`flatpak kill` subprocess. The SYSTEM flatpak's libcurl
+ libostree need newer OPENSSL symbols (3.2/3.3/3.4), so pairing failed with
"libssl.so.3: version OPENSSL_3.3.0 not found". _flatpak_env() now restores
each LD_*_ORIG PyInstaller saved, or drops the var, so the system loader uses
system libs. Reproduced + verified on the Deck (SteamOS 3.8.10, Flatpak 1.16.6).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Settings: flat list -> Display / Host / Audio / Overlay sections in outlined
cards (SettingsGroup + ToggleRow helpers) with section headers.
- ConnectScreen: connection errors now show in a filled errorContainer banner
(was plain red text lost in the layout), and a "Searching the local network..."
spinner appears while discovery is active but nothing's turned up yet.
Verified locally: ./gradlew :app:assembleDebug BUILD SUCCESSFUL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Crash: DisposableEffect.onDispose called nativeClose(handle) (Box::from_raw frees
the SessionHandle) while the SurfaceView's surfaceDestroyed independently called
nativeStopVideo/Audio/Mic on the same handle -- whichever ran after the close
dereferenced freed memory (SIGSEGV: the consistent back-navigation crash). Add a
one-shot `closed` guard: onDispose marks it before freeing; surfaceDestroyed skips
the native calls once closed (backgrounding still stops the threads when it wins).
Polish:
- Branded Material You theme (Theme.kt): dynamic colour on Android 12+, punktfunk
brand violets as the pre-12 fallback, replacing the generic darkColorScheme().
- ConnectScreen: "Connecting..." was rendered in error-red with no spinner; now a
neutral spinner while connecting, red reserved for actual errors.
Verified locally: ./gradlew :app:assembleDebug BUILD SUCCESSFUL (both ABIs + the
Compose changes), debug APK assembles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ndk's DataSpace derives Copy/PartialEq/Eq and impls Display (no Debug), so the
{ds:?} in the HDR dataspace log statements wouldn't compile under cargo-ndk.
Host clippy can't catch it — decode.rs is android-gated. Switch to {ds}.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Install failed with "GPG verification enabled, but no signatures found" on the
commit: the deploy step only ran build-update-repo (signs the summary). Add
`flatpak build-sign` to sign the commit objects too — clients with
gpg-verify=true verify the commit, so summary-only signing isn't enough.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors the Apple client's HDR path so the Android client can display HDR from a
Windows HDR host:
- nativeConnect now advertises VIDEO_CAP_10BIT | VIDEO_CAP_HDR (was 0), so the
host upgrades to a Main10 / BT.2020 PQ encode.
- decode.rs detects HDR reactively from the decoder's reported output colour
(color-transfer ST2084=6 / HLG=7, color-range) -- the AMediaCodec analogue of
VideoToolbox's format description on Apple -- and signals the Surface dataspace
(Bt2020[Itu]Pq / Bt2020[Itu]Hlg) so the compositor/display switch to HDR.
AMediaCodec decodes Main10 from the in-band SPS; no profile override needed.
Also fixes the Android build: set_frame_rate (added in 5262e28) is gated on the
ndk `nativewindow` + `api-level-30` features, which weren't enabled -- so that
commit could not compile under cargo-ndk. Enable
features = ["media","audio","nativewindow","api-level-31"] (minSdk 31): covers
set_frame_rate (api-30), set_buffers_data_space + the DataSpace module (api-28),
and ANativeWindow (nativewindow).
Verified host-side: fmt --all + clippy --workspace (the caps advertise + JNI
surface). The android-gated decode + NDK gating verified against the ndk 0.9
sources; android.yml (cargo-ndk) is the compile gate, and real HDR display needs
an HDR device + Windows HDR host.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Apple is TestFlight-only (no App Store) — link the join URL; drop the App Store
placeholder. Add the live Google Play listing for io.unom.punktfunk.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per-device install steps in one place: Linux (Flatpak via flatpak.unom.io +
native apt/rpm/Arch), Steam Deck, Windows (signed MSIX from the registry),
macOS (notarized DMG from releases), and iOS/Android (store/beta links). Adds
it to the Connecting nav and cross-links clients.md, whose Linux/Flatpak bullet
now points at the hosted flatpak.unom.io repo instead of the bundle README.
Mobile store/TestFlight URLs are placeholders pending the public listings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stats HUD (mirrors the Apple client): the decode thread accumulates FPS, receive
throughput, and capture->client latency (p50/p95, skew-corrected) in Rust
(clients/android/native/src/stats.rs); nativeVideoStats drains a snapshot ~1 Hz
over JNI as a DoubleArray. StreamScreen renders a Compose overlay
(W*H@Hz / fps / Mb/s / latency, + dropped-under-loss), toggled by a Settings
switch (persisted, default on) or a 3-finger tap.
Performance (decode.rs):
- ANativeWindow_setFrameRate(refresh_hz): align display vsync to the stream rate
(no 60-in-120 judder); safe since minSdk 31 >= API 30.
- Raise the decode thread toward URGENT_DISPLAY (best-effort setpriority) so
background work can't preempt it under load.
- Codec low-latency hints KEY_PRIORITY=0 (realtime) + KEY_OPERATING_RATE.
Verified host-side: cargo build/clippy/fmt --workspace (the ungated stats + JNI
accessor). The android-gated decode.rs (NDK) and the Kotlin build only in CI
(android.yml: gradle + cargo-ndk) -- APIs verified against crate sources.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CI added --default-branch=stable, so the repo ref is
app/io.unom.Punktfunk/x86_64/stable. build-bundle defaults to `master` when no
branch is given → "Refspec app/io.unom.Punktfunk/x86_64/master not found". Pass
`stable` explicitly in both flatpak.yml and the local build-flatpak.sh.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
stoppedHandler/resetHandler are non-optional closures on the CI SDK
((StoppedReason)->() and ()->()), so assigning nil fails to compile
(apple.yml). Assign no-op closures to disarm them before engine.stop()
-- same re-entrancy guard intent, type-correct.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CI only shipped a single-file .flatpak bundle, which has no remote — users
couldn't `flatpak update`. Keep the bundle (Decky fallback) but also sign the
OSTree repo flatpak-builder already produces and publish it to a shared,
reusable unom-wide remote.
- flatpak.yml: pin --default-branch=stable; import the signing key and
build-update-repo --gpg-sign; generate unom.flatpakrepo + the app .flatpakref
+ index.html; rsync the repo to unom-1 and bring up a static Caddy container.
The step no-ops until FLATPAK_GPG_PRIVATE_KEY/DEPLOY_* exist (build stays green).
- packaging/flatpak/server/: compose.production.yml + Caddyfile (static file
server on :3230, mirrors docker.yml deploy-docs).
- unom-flatpak.gpg: committed public signing key (base64 -> GPGKey= in the descriptors).
- README: hosted repo is now the recommended install; documents the one-time
infra (edge Caddy vhost, infra port 3230, DNS, the GPG secret).
Edge Caddy vhost + infra port allowlist + the secret are applied out-of-band.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two bodies of work in one commit (the rename moved files the fixes also touched).
Naming/structure cleanup (pre-launch):
- Host modules m3.rs->punktfunk1.rs, m0.rs->spike.rs; CLI m3-host->punktfunk1-host,
m0->spike; bare `punktfunk-host` now prints help. Types M3Options/M3Source->
Punktfunk1Options/Punktfunk1Source.
- Clients consolidated out of crates/ into clients/: punktfunk-client-rs->
clients/probe (crate punktfunk-probe), client-linux->clients/linux,
client-windows->clients/windows, punktfunk-android->clients/android/native
(crate punktfunk-client-android; kept [lib] name=punktfunk_android so the JNI
contract is unchanged). crates/ now holds only core + host.
- Milestone codes M0-M4 purged from code/CLI/CLAUDE.md/README/docs/docs-site,
kept only in docs/implementation-plan.md. docs/m2-plan.md->
docs/gamestream-host-plan.md. CI/gradle/flatpak paths updated.
Client loss-recovery (video froze and never recovered after a brief drop):
- Export punktfunk_connection_frames_dropped through the C ABI (the core already
tracked it for the client keyframe-recovery loop; it was never reachable from
the ABI clients). Regenerated punktfunk_core.h.
- Apple (StreamPump + Stage2Pipeline) and Android (decode.rs) now poll
frames_dropped and request a keyframe when it climbs -- the same loss-driven
recovery Linux/Windows already had. Under infinite GOP the decoder silently
conceals reference-missing frames, so the decode-error trigger rarely fires.
Apple rumble robustness (worked then went spotty -- DualSense + Xbox):
- Add CHHapticEngine stopped/reset handlers (rebuild on app background / audio
interruption / server reset) and drop the permanent `broken` latch on a
transient drive failure; latch only when the controller truly has no haptics.
- Surface swallowed SDL set_rumble errors on Linux/Windows + diagnostic logging.
Verified: cargo build/clippy/fmt --workspace, C-ABI harness, header drift.
Not runnable on this box (verify in CI): Gitea workflows, gradle/Android,
flatpak, Swift/decky.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
r0adkll/upload-google-play hides real API errors behind "Unknown error
occurred." Proved the full upload sequence (insert edit -> upload bundle ->
track update -> validate) succeeds with the service account, so the failure was
r0adkll's opaque error handling and/or a base64-encoded SERVICE_ACCOUNT_JSON
secret.
clients/android/ci/play-upload.py does the same sequence with stdlib + openssl
(no pip), reuses the SERVICE_ACCOUNT_JSON secret, tolerates it being raw JSON or
base64, auto-retries commit with changesNotSentForReview, and prints Google's
actual error. Locally dry-run-validated against the live app (both secret forms).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build a universal release APK alongside the AAB and push both to the public
generic registry (punktfunk-android/<run_number>/) before the Play upload, so
artifacts are downloadable even while the Play step is still failing. Matches
windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
So Windows mic passthrough works without the user installing anything: when no virtual-mic
device is present, install Steam Remote Play's SteamStreamingMicrophone.inf (ships under
Steam\drivers\Windows10\{arch}\ next to the speakers INF Apollo uses) via DiInstallDriverW
loaded from newdev.dll — the same mechanism Apollo uses for Steam Streaming Speakers — then
re-find the device. Needs admin (the host runs as SYSTEM); best-effort and safe (no-op if
Steam absent / INF not found / PUNKTFUNK_NO_MIC_INSTALL), falling back to the manual-install
guidance (VB-Audio Cable) otherwise.
Not yet built/validated on the box (down); FFI cross-checked against windows-0.62. Whether
Steam ships SteamStreamingMicrophone.inf at that path is to be confirmed on the box.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The host received the client's mic uplink (0xCB Opus) but dropped it on Windows ("requires
Linux"). Windows has no user-mode way to CREATE a capture endpoint, so target an existing
virtual audio device and write the decoded mic PCM into its RENDER endpoint — the device's
CAPTURE endpoint then surfaces as a microphone host apps record from (the inverse of a
virtual cable). New audio::wasapi_mic::WasapiVirtualMic: finds the device by friendly-name
(Steam Streaming Microphone / VB-Audio CABLE Input / VoiceMeeter / "virtual", override with
PUNKTFUNK_MIC_DEVICE), opens a WASAPI shared event-driven RENDER client (48 kHz stereo f32,
autoconvert), and a dedicated COM thread writes a bounded (~80 ms drop-oldest) inject queue
with silence-fill. open_virtual_mic() gets a Windows arm; mic_service_thread (Opus decode →
push) now compiles for windows too (opus is already a windows dep). Clear error + install
guidance when no virtual device is present.
Linux/cross-platform side cargo-checks; the Windows path is built/validated when the box is
back (the wasapi render API was cross-checked against the docs + the existing capture path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Apollo runs its capture thread at CRITICAL and its encoder thread at ABOVE_NORMAL; we set
none. Our GPU work is already HIGH priority, but the GPU scheduler can only favour commands
we've SUBMITTED — a normal-priority thread descheduled by a CPU-heavy game submits the
convert/encode late, so the HIGH GPU priority never bites (consistent with the measured
"NVENC engine idle yet the encode waits ~15 ms"). Raise the WGC helper's capture+encode
loop and the single-process capture+encode loop to THREAD_PRIORITY_HIGHEST, and the
transmit thread to ABOVE_NORMAL, via a cross-platform boost_thread_priority() (Windows-only
effect — the Linux host caps the game via gamescope so its threads aren't starved).
Not yet built/validated on the GPU box (it's down); the cross-platform side compiles
(cargo check) and the Windows calls are cross-checked against the windows-0.62 API.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a client requests a bitrate above the GPU's HEVC/AV1 level ceiling, NVENC rejects
initialize_encoder. The old probe stepped the rate down by ×¾ each retry, undershooting
the real ceiling badly (a 1 Gbps request landed ~300 Mbps even with the level cap near
800). Replace it with a binary search over [floor, requested] that converges (±20 Mbps)
on the HIGHEST rate NVENC accepts and clamps to that — so the stream uses the full
codec-level bitrate. Factored the session open/config/init into try_open_session() for
the probe; split-encode rejection is disambiguated from a bitrate-cap rejection (retry
once with split disabled) and the floor fallback also tries split-disabled.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
NVENC defaulted to Main tier, whose per-level bitrate ceiling at 5K (HEVC Level 6.2
Main ≈ 240 Mbps) made initialize_encoder reject a high client bitrate; the existing
probe-and-step-down then silently dropped a ~1 Gbps request by ×¾ to ~240-320 Mbps —
visible color/motion compression on fast scenes. Set HIGH tier (≈800 Mbps for HEVC,
higher for AV1) + autoselect level so the requested bitrate goes through. `tier`/`level`
are u32 (HIGH=1, AUTOSELECT=0) shared across the HEVC/AV1 union offset; the step-down
remains as a safety net. Not yet built/validated on-box (box offline).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Windows host capped at ~60 fps with 35-40 ms latency on a GPU-heavy game:
the per-frame capture→encode path shared the 3D engine with the game and got
scheduled behind it. Rework to minimize 3D-engine work per frame:
- VideoConverter (D3D11 video processor): capture → NVENC-native NV12/P010 so
NVENC skips its internal RGB→YUV (a 3D/compute step). Wired into both DDA
(dxgi.rs) and WGC (wgc.rs). New PixelFormat::Nv12/P010 + NVENC YUV input.
- GPU scheduling hardening (Apollo-style): D3DKMTSetProcessSchedulingPriorityClass
HIGH, absolute SetGPUThreadPriority, SetMaximumFrameLatency(1).
- WGC SDR zero-copy (hold pool frames; no CopyResource). DDA keeps a fast
CopyResource to decouple its single-frame acquire/release from the async convert.
- Pipelined helper encode loop (PUNKTFUNK_ENCODE_DEPTH, default 1) + perf split
(cap_wait / encode / write).
Live on the RTX 4090: hard 60 fps ceiling removed (now scene-scaling 40-200+),
latency much reduced. Residual cap in GPU-pinned scenes is the irreducible RGB→YUV
convert (no fixed-function unit on NVIDIA — VideoProcessing engine reads 0%) waiting
behind an uncapped game under WDDM context time-slicing; Linux avoids it via
gamescope capping the game to the display refresh.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The plugin was a QAM launcher whose stream never appeared, with no
pairing. Three fixes, plus a headless --pair mode on the GTK client:
- Stream actually starts (MoonDeck's proven mechanism): gamescope only
focuses the process tree Steam launched via reaper, so a flatpak
spawned from the (root) backend is invisible. The frontend now
registers ONE hidden non-Steam shortcut pointing at bin/punktfunkrun.sh,
passes the host as the shortcut's Steam launch options, and starts it
with SteamClient.Apps.RunGame — gamescope then fullscreen-focuses it.
The wrapper execs `flatpak run io.unom.Punktfunk --connect <host>`.
- Fullscreen page: routerHook.addRoute("/punktfunk") — host list,
per-host Pair/Stream, and a settings section (resolution/refresh/
bitrate/gamepad/mic, written to client-gtk-settings.json).
- Pairing: a gamepad-navigable PIN keypad. The host shows the PIN; the
backend runs the SPAKE2 ceremony headlessly via the client's new
`--pair <PIN> --connect host` CLI mode (app.rs), persisting the host
as paired so the stream then connects silently. Same flatpak =>
shared identity store, verified live (ceremony against a real host).
- Backend (main.py): discover / pair / runner_info / get_settings /
set_settings / kill_stream; uses DECKY_USER_HOME so paths resolve to
the deck user's flatpak install regardless of the plugin's root flag.
CI (decky.yml) and the sideload packager now ship bin/punktfunkrun.sh.
The Steam-shortcut launch and headless-pairing env follow MoonDeck
exactly but need a Deck in Gaming Mode to fully confirm.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On the Steam Deck there was no way out of fullscreen — no F11 key, and the
header bar (with the fullscreen button) is hidden while fullscreen.
- Controller: a Moonlight-style escape chord (L1+R1+Start+Select) held
together leaves fullscreen and releases input capture. The gamepad
service latches the chord (fires once per press) and signals the stream
page over an async channel; four simultaneous buttons no game uses as a
deliberate combo, so it can't trigger during play.
- Keyboard: F11 already toggled fullscreen (checked before input
forwarding, so it works while captured) — now surfaced.
- Discoverability: entering fullscreen flashes a 4s hint listing both
exits (F11 · L1+R1+Start+Select).
The escape future is aborted on page-hidden so a stale session can't act
on the shared window.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The lock prune (a5b99b2) stopped flatpak-builder full-cloning windows-rs
(disk-fill), but exposed the next layer: `cargo --offline --locked -p
punktfunk-client-linux` resolves the WHOLE workspace, so it still tried
to load the now-un-vendored windows-rs source for the
punktfunk-client-windows member (its windows-rs git deps are
cfg(windows)-gated, but cargo resolves all targets regardless) and
failed: "can't checkout ... you are in the offline mode".
Drop the Windows client from the workspace members inside the sandbox
build (sed on the copied Cargo.toml — the flatpak never compiles it) and
remove --locked (the lock no longer matches the reduced member set;
--offline still pins every crate to the vendored cargo-sources.json, so
the build stays reproducible). android stays — it has no git deps.
Verified locally: removing the member, `cargo build -p
punktfunk-client-linux --offline` Finishes with zero windows-rs
involvement; manifest YAML still valid.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cargo fmt --all over the merged connect() call-sites (the video_caps/
launch args landed without a fmt pass). Comment-alignment only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The flatpak CI was failing at "Downloading sources" with "No space left
on device": flatpak-cargo-generator walks the whole workspace Cargo.lock
and emits a `type: git` source for the windows-rs crates (windows +
windows-reactor + ~12 sub-crates, pinned by punktfunk-client-windows),
and flatpak-builder then FULL-clones that multi-GB repo — for a bundle
that only ever compiles `-p punktfunk-client-linux` and never touches a
windows-* crate.
New packaging/flatpak/prune-windows-lock.py writes a copy of Cargo.lock
with the windows-rs git packages stripped (matches on the `source =`
line, so a crate that merely lists a windows dependency is kept;
dependency-free so it also runs on the Deck's stock python). Both the CI
and build-flatpak.sh feed that pruned lock to the generator. The
committed Cargo.lock is untouched — cargo --offline only needs vendored
sources for the crates it actually builds, and the windows-rs crates are
not in the Linux client's dependency closure.
Verified locally: 14 crates pruned (507 -> 493 packages), zero windows-rs
`source =` lines remain, output parses as TOML, all Linux-client deps
(gtk4/ffmpeg-sys-next/sdl3/pipewire) intact.
This unblocks the flatpak build carrying the VAAPI green-screen fix
(64b1679) for the Steam Deck.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First AMD test (Steam Deck, Mesa radeonsi) showed a mostly-green image
with red whites — the classic fingerprint of NV12 chroma read as 0.
Root cause (confirmed against FFmpeg/GTK/mpv source): FFmpeg's VAAPI
export uses VA_EXPORT_SURFACE_SEPARATE_LAYERS unconditionally, so an
NV12 surface comes back as TWO single-plane layers — layers[0]=R8
(luma), layers[1]=GR88 (chroma) — sharing one object/fd, the UV plane
reached via offset. map_dmabuf took layers[0] only and used its format
(R8) as the GTK fourcc, so GdkDmabufTexture got a luma-only texture
with the chroma plane dropped → chroma defaults to 0 → green field,
red highlights.
Fix (matches mpv's dmabuf_interop_gl flatten pattern):
- Derive the combined fourcc from the decoder's sw_format
(AVHWFramesContext.sw_format → NV12 → DRM_FORMAT_NV12), NOT from the
per-plane component formats. The frame format is absent from the
separate-layer descriptor and must be deduced from sw_format.
- Flatten every plane across every layer in declared order (Y then UV),
each with its own fd (objects[plane.object_index].fd), offset, pitch.
- One-time descriptor dump (objects/layers/formats/modifier) so a new
driver's real layout is visible in the logs.
- Unit test locks the DRM FourCC magic numbers (NV12=0x3231564e).
Software decode (swscale, reads colorspace from the VUI) was always
correct, which isolated the bug to this path. PUNKTFUNK_DECODER=software
is the immediate workaround on an un-rebuilt binary. Awaiting Steam Deck
reconfirm (no AMD VAAPI on the NVIDIA dev box to live-verify).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Light up the dormant 10-bit/HDR path end to end on the Windows client.
- core: NativeClient::connect gains a video_caps param threaded into the Hello. The Windows
client advertises VIDEO_CAP_10BIT | VIDEO_CAP_HDR; every other caller (the C ABI shim,
Linux, Android, host test connects) passes 0, so the 8-bit BT.709 path is unchanged. The
host already gates a Main10/PQ encode on these bits + PUNKTFUNK_10BIT.
- video.rs: a PQ frame (color_trc == SMPTE2084) converts 10-bit YUV → X2BGR10 (== DXGI
R10G10B10A2) with the BT.2020 matrix via sws_setColorspaceDetails; swscale applies only
the matrix + range, so the PQ-encoded samples pass through untouched.
- present.rs: on an HDR frame the swapchain flips in place (ResizeBuffers) to R10G10B10A2 +
DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 + HDR10 metadata; the passthrough shader is
unchanged and the compositor maps PQ→display. Switched to ALPHA_MODE_IGNORE so the 10-bit
padding bits don't render transparent. SDR stays 8-bit B8G8R8A8.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first real run on a display surfaced three issues the headless/dev-VM build never hit:
- Route each hook-using screen (hosts/pair/stream) as its own component() instead of
calling it with the shared cx. Calling hooks on the parent cx changed the hook order
when the screen flipped, tripping reactor's Rules-of-Hooks guard and aborting the moment
you navigated to the stream page.
- Mouse: replace the absolute path (which swallowed WM_MOUSEMOVE and so froze the OS cursor,
snapping the host pointer back to one point) with proper pointer lock — hide + ClipCursor
+ recentre, shipping relative MouseMove scaled by the Contain-fit factor. Ctrl+Alt+Shift+Q
now actually toggles capture: track modifier state from the hook's own event stream
(GetAsyncKeyState doesn't see keys we suppress in our own LL hook), and flush held
keys/buttons on release so nothing sticks on the host.
- Add the stats HUD overlay (mode · fps · Mb/s · capture→client/decode latency), mirroring
the Apple client. Stats live in root state and reach the stream page as a prop (a child's
own async-state update is pruned when props are unchanged), fed by a small poll thread.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 00:18:29 +02:00
514 changed files with 69961 additions and 12451 deletions
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.