Commit Graph

509 Commits

Author SHA1 Message Date
enricobuehler 56f724815e feat(web): unify console + docs on @unom/ui; host OpenAPI via Scalar
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>
2026-06-21 12:00:46 +00:00
enricobuehler 43b480ada3 docs(steamos): reframe Steam Deck host page to SteamOS
- 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>
2026-06-21 10:33:49 +00:00
enricobuehler 5dbe3b00cc feat(steamdeck): --no-gamestream installer flag for a secure native-only SteamOS host
Completes the GameStream-opt-in posture (0de648f) 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>
2026-06-21 10:29:40 +00:00
enricobuehler 0de648f106 feat(host): GameStream/Moonlight compat is now opt-in (--gamestream) — secure native-only by default
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>
2026-06-21 10:19:40 +00:00
enricobuehler ed7dc3ed24 fix(security): remaining audit findings — mgmt admin gate, RTSP DoS bounds, FEC drop, ALPN, ct-compare
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>
2026-06-21 09:50:24 +00:00
enricobuehler 4ddd65d732 feat(clients): HDR Steps 2-3 — apply mastering metadata + display capability-gate
Continues docs/hdr-pipeline-plan.md. Steps 0/1 + Step 2 (Windows/Android) already
landed in ffae2a3; 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>
2026-06-21 09:46:58 +00:00
enricobuehler ffae2a31e4 feat: HDR Step-0 colour-metadata transport + security-audit hardening
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>
2026-06-21 09:07:59 +00:00
enricobuehler 4ef4ddbf37 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	docs-site/content/docs/meta.json
2026-06-21 00:07:36 +00:00
enricobuehler 1e0d2e661f feat(host): Apollo-backlog hardening — cert gate, NVENC RFI, media QoS, async injector
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>
2026-06-21 00:06:30 +00:00
enricobuehler 9675022618 fix(steamdeck): run the web console with node, not bun (Nitro node-server preset)
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>
2026-06-20 23:32:23 +00:00
enricobuehler e235177bf8 feat(steamdeck): one-command host install + docs (build-on-device)
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>
2026-06-20 22:20:00 +00:00
enricobuehler eefadf1a69 feat(core/fec): adaptive FEC — size recovery to measured loss, not a flat 20%
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>
2026-06-20 21:31:07 +00:00
enricobuehler 750f3676bb docs: clarify HDR is supported on the Windows host (Linux still blocked)
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>
2026-06-20 22:13:09 +02:00
enricobuehler 30179ccbab docs(site): add Windows host install, restructure nav, new public roadmap
- 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>
2026-06-20 19:52:23 +02:00
enricobuehler 53bbda7e68 fix(core/speed-test): packet-level throughput + paced burst (kill the 0/100% cliff)
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>
2026-06-20 17:46:17 +00:00
enricobuehler c1a091ec3f docs(CLAUDE.md): refresh stale status
- 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>
2026-06-20 19:10:51 +02:00
enricobuehler 0be3183cf2 docs: update README + docs site for public readiness
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>
2026-06-20 18:59:23 +02:00
enricobuehler 24dc8ee60b Merge remote-tracking branch 'origin/main' 2026-06-20 16:30:32 +00:00
enricobuehler 6e83895fe4 feat(host/gamescope): custom-resolution Game-Mode streaming on the Steam Deck
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>
2026-06-20 16:30:24 +00:00
enricobuehler a4099ca450 feat(docs-site): read footer from the per-tenant CMS collection
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>
2026-06-20 17:11:42 +02:00
enricobuehler 44093ce1c2 Merge remote-tracking branch 'origin/main' 2026-06-20 14:50:09 +00:00
enricobuehler 1e71bfea12 fix(core/transport): treat ENOBUFS as a transient drop, not a fatal error
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>
2026-06-20 14:49:59 +00:00
enricobuehler 9b83b6a235 feat(docs-site): add a Gitea repo link to the nav
Link to https://git.unom.io/unom/punktfunk in the top bar, alongside the
Docs and Website links.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 16:08:44 +02:00
enricobuehler 15697467ee feat(docs-site): add the site-wide footer, shared with the marketing site
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>
2026-06-20 15:42:39 +02:00
enricobuehler 5d0abdd63c refactor(docs-site): use the vector SVG wordmark instead of the raster
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>
2026-06-20 15:34:19 +02:00
enricobuehler bb0bd25929 feat(docs-site): use the funk wordmark logo instead of "punktfunk" text
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>
2026-06-20 15:05:23 +02:00
enricobuehler dc183ac898 feat(docs-site): brand the docs to match the punktfunk website
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>
2026-06-20 14:40:52 +02:00
enricobuehler 68e772b662 fix(host/serverinfo): don't advertise an empty codec mask when the VAAPI probe finds nothing
The Phase 3 GPU-aware codec mask (e2cef91) 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>
2026-06-20 11:52:17 +00:00
enricobuehler e2cef91d3a feat(host): VAAPI codec probe + AMD/Intel packaging + neutral logs (Phase 3)
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>
2026-06-20 10:41:37 +00:00
enricobuehler 07c9244eaf feat(host/encode): VAAPI zero-copy dmabuf import (AMD/Intel GPU CSC)
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>
2026-06-20 09:57:00 +00:00
enricobuehler 330574e058 fix(host/capture): mmap the buffer fd ourselves — xdpw MemFd over-reads MAP_BUFFERS
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>
2026-06-19 21:48:49 +00:00
enricobuehler 405eb7731d refactor(host/zerocopy): dlopen libcuda instead of a link-time #[link]
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>
2026-06-19 15:44:57 +00:00
enricobuehler 2823785bb6 feat(host/encode): VAAPI encode backend for AMD/Intel GPUs (Linux)
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>
2026-06-19 15:35:49 +00:00
enricobuehler f48c739a9c fix build
improve iOS & iPadOS UI
2026-06-19 15:49:48 +02:00
enricobuehler 9d6ff77a02 docs(packaging/windows): note the host is x64-only (no ARM64)
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>
2026-06-19 13:23:30 +00:00
enricobuehler 6e0507a9ee fix(packaging/windows): dodge WOW64 redirection — run ISCC on copies under C:\t
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>
2026-06-19 12:27:19 +00:00
enricobuehler ca6ee4dd7f fix(packaging/windows): make the .iss pure ASCII (ISCC encoding failure)
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>
2026-06-19 12:22:00 +00:00
enricobuehler 0d94944c41 diag(packaging/windows): isolate the ISCC "path not found" failure
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>
2026-06-19 12:13:29 +00:00
enricobuehler 23ac174a50 fix(packaging/windows): pass installer source files as validated absolute defines
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>
2026-06-19 11:44:30 +00:00
enricobuehler 67d58bfde3 feat(windows-client): cross-compile + ship ARM64 (aarch64) off the x64 runner
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>
2026-06-19 11:44:24 +00:00
enricobuehler 60531a423c feat(host/windows): HDR scRGB→P010 in a shader — NVENC native P010, off the SM
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>
2026-06-19 09:54:00 +00:00
enricobuehler 088c60cae1 fix(android): invoke cargo by absolute path in cargoNdk task
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>
2026-06-19 11:09:44 +02:00
enricobuehler 1c67ce7a93 fix(rpm): derive the libcuda link stub from source (fixes undefined cu* symbols)
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>
2026-06-19 08:29:16 +00:00
enricobuehler 778a11a76a fix(android): settings dropdowns trapped D-pad/controller focus
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>
2026-06-19 08:20:55 +00:00
enricobuehler 693eedd314 ci(runner): cap the act_runner cache + 30-min prune (fix recurring disk-full)
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>
2026-06-19 07:53:08 +00:00
enricobuehler def681c54c fix(packaging/windows): add '\' after {#SourcePath} in the .iss [Files]
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>
2026-06-19 07:36:01 +00:00
enricobuehler 5e6b271410 fix(packaging/windows): vendor SudoVDA driver (no upstream release) + real nefcon URL
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>
2026-06-19 07:22:52 +00:00
enricobuehler d186d88b34 perf(host/linux): NV12 GPU convert — feed NVENC native YUV, off the contended SM (Tier 2A)
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>
2026-06-18 23:39:11 +00:00
enricobuehler d364a4ee7c fix(windows-client): clear clippy -D warnings on MSVC
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>
2026-06-18 23:21:18 +00:00
enricobuehler ead3f8a306 feat(windows-client): D3D11VA zero-copy hw decode + HDR10 present + GUI polish
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>
2026-06-18 23:16:07 +00:00