Compare commits

..

30 Commits

Author SHA1 Message Date
enricobuehler ecfa71212d chore: consolidate all in-progress parallel-session WIP
audit / bun-audit (push) Successful in 27s
windows-drivers / probe-and-proto (push) Successful in 49s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m10s
apple / swift (push) Successful in 1m18s
decky / build-publish (push) Successful in 34s
windows-drivers / driver-build (push) Successful in 1m37s
audit / cargo-audit (push) Successful in 3m1s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
ci / bench (push) Successful in 5m56s
windows-host / package (push) Failing after 5m17s
apple / screenshots (push) Successful in 4m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m2s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8m6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m6s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
arch / build-publish (push) Successful in 16m50s
android / android (push) Successful in 17m7s
docker / deploy-docs (push) Successful in 28s
deb / build-publish (push) Successful in 17m6s
ci / rust (push) Failing after 19m1s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m3s
flatpak / build-publish (push) Successful in 8m0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m14s
release / apple (push) Successful in 5m44s
Wholesale commit of every uncommitted change across the tree, at the user's
explicit request — host refactor-campaign W1 (native.rs facade + native/ dir,
library/ + mgmt/ splits), Android, core. These streams were mid-flight and not
individually built/tested together; this supersedes the per-session HOLD
markers. Consolidating so everything lands on main in one pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 20:08:29 +02:00
enricobuehler 07e2836601 feat(apple/M1): PunktfunkWidgetsExtension target — wired, signed, building
The Xcode widget-extension target that hosts the launcher widget + Live Activity
UI. Bundle id io.unom.punktfunk.widgets, iOS 17, App Group group.io.unom.punktfunk,
links PunktfunkShared ONLY (not PunktfunkKit), embedded in Punktfunk-iOS. Sources
come from the PunktfunkWidgets/ synchronized folder. Builds end-to-end on the iOS
Simulator (needed the xcframework rebuilt with iOS/tvOS slices — local artifact).

- project.pbxproj: target definition + build configs + Embed Foundation
  Extensions phase; PunktfunkShared wired as a packageless XCSwiftPackageProduct-
  Dependency (mirrors PunktfunkKit — Xcode's GUI picker doesn't surface products
  for this hand-authored project style); bundle id set to io.unom.punktfunk.widgets.
- PunktfunkWidgetsExtension.entitlements: App Group only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 19:54:12 +02:00
enricobuehler 6ac7134e7c fix(apple/M4): IntentError message must be a string literal
LocalizedStringResource is ExpressibleByStringLiteral, so a single literal
converts implicitly, but the "…" + "…" concatenation is a runtime String it
can't convert. Collapsed to one literal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 19:52:34 +02:00
enricobuehler 6d2e738070 fix(apple/M3): import AppIntents for Button(intent:) + drop deprecated Text+
The Live Activity's End button uses Button(intent:), whose initializer lives in
_AppIntents_SwiftUI — reached via `import AppIntents` (was missing, so the
widget target failed to build). Also replaced the iOS-26-deprecated Text + Text
concatenation in the background countdown with an HStack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 19:49:52 +02:00
enricobuehler 09e2043ce0 chore(apple/M1): move widget sources into the Xcode target's synced folder
Xcode created the PunktfunkWidgetsExtension target with a file-system-
synchronized root group at clients/apple/PunktfunkWidgets/, so the target
compiles whatever lives there. Deleted the three generated stubs
(PunktfunkWidgets.swift / PunktfunkWidgetsBundle.swift /
PunktfunkWidgetsControl.swift — the stub @main WidgetBundle would collide with
ours) and moved our sources (PunktfunkWidgetBundle / HostsWidget /
SessionLiveActivity) from Sources/PunktfunkWidgets/ into PunktfunkWidgets/. Kept
the generated Info.plist (build-excluded via the sync exception set) and
Assets.xcassets. Still outside Sources/, so SwiftPM ignores it; swift build green.

project.pbxproj is intentionally NOT part of this commit — the target's
capability/signing edits (step 3) are still in progress in Xcode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 19:18:26 +02:00
enricobuehler a513186424 fix(apple/M3): reach shared Activity types via PunktfunkKit re-export
SessionActivityController is in the app target, which links the PunktfunkKit
product (not PunktfunkShared directly). Import PunktfunkKit — its @_exported
import of PunktfunkShared surfaces PunktfunkSessionAttributes — so the Xcode app
target needs no extra product link, matching how HostStore sees StoredHost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 18:53:39 +02:00
enricobuehler df6a5325d8 feat(apple/M1+M3+M4): widgets, Live Activity, and Siri/Shortcuts intents
The extension-side + App Intents surface for design/apple-live-activities-and-
widgets.md. The iOS-framework code (WidgetKit/ActivityKit/AppIntents) can't be
compiled by the macOS `swift build` CI target and needs the Xcode widget-
extension target that only exists once created in the GUI — see the checklist in
the memory note. What macOS DID verify: HostEntity (AppIntents is available on
macOS), the shared attribute/notification plumbing, and that nothing regressed
(142 tests green).

Shared (PunktfunkShared):
- PunktfunkSessionAttributes (ActivityAttributes) — the one type app + extension
  share; gated os(iOS) (ActivityKit imports on macOS but its types are
  unavailable, so canImport would wrongly admit it).
- EndStreamIntent (LiveActivityIntent) — posts .punktfunkEndActiveSession.
- HostEntity + HostEntityQuery (AppEntity over the shared store) — the intent /
  widget-config parameter type; canImport(AppIntents), so macOS type-checks it.
- New notifications: end-active-session, open-deep-link.

M1 widget extension sources (Sources/PunktfunkWidgets/, NOT a SwiftPM target —
`swift build` ignores the dir):
- PunktfunkWidgetBundle (@main): HostsWidget + PunktfunkSessionLiveActivity.
- HostsWidget (kind "PunktfunkHosts"): reads the shared-suite store, sorts by
  recency, deep-links each host; small/medium/accessory families; empty state.
- SessionLiveActivity: Lock-Screen banner + Dynamic Island (elapsed timer,
  mode line, background countdown, End button).

M3 controller (app, iOS): SessionActivityController owns the Activity lifecycle
(request/update/end + launch orphan-sweep + staleDate); ContentView drives it
from the model's phase/isBackgrounded/backgroundDeadline (which SessionModel now
publishes), keeping ActivityKit out of the cross-platform model.

M4 (app, iOS): ConnectToHost/WakeHost intents + AppShortcutsProvider; Connect
routes via .punktfunkOpenDeepLink into the same onOpenURL router (one set of
guards); Wake reuses the WoL path; End surfaced to Shortcuts too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 18:51:41 +02:00
enricobuehler 14c5e7c11c feat(apple/M2): opt-in background keep-alive (audio + video-drop + timeout)
Backgrounding a live session no longer freezes it when the user opts in: audio
keeps playing (UIBackgroundModes audio), the QUIC connection + pump stay live,
video decode is DROPPED, and a bounded timer auto-disconnects. Off by default.

- PunktfunkConnection.setVideoDropped/isVideoDropped: a tiny lock-guarded flag
  both pumps read every iteration. StreamPump (stage-1), Stage2Pipeline (VT +
  PyroWave) drain nextAU() for flow control but DISCARD the AU before any
  VideoToolbox/Metal work — the crash/jetsam-safe seam (no GPU off-screen).
- SessionModel.enterBackground(timeoutMinutes:) / exitBackground(): set the drop
  flag, mute the mic (privacy — SessionAudio.setMicMuted pauses the capture
  engine), arm a DispatchSourceTimer that disconnect(deliberate:false)s on fire
  (keeps host linger → fast late reconnect). exitBackground clears the flag and
  requestKeyframe()s; the pump's freeze gate auto-arms on the resumed
  frame-index gap so concealed frames are withheld until the IDR re-anchors.
  disconnect() cancels the timer + clears isBackgrounded.
- ContentView scenePhase driver (iOS): .background+streaming+setting →
  enterBackground; .active → exitBackground. scenePhase (not willResignActive)
  so Control-Center/app-switcher peeks don't start the timer.
- Settings → General (iOS-only keepAliveSection): toggle + 1/5/10/30 timeout;
  new keys backgroundKeepAlive (def off) / backgroundTimeoutMinutes (def 10).
- Info.plist: UIBackgroundModes [audio] + NSSupportsLiveActivities (for M3).

macOS swift build + swift test green (142 tests). The iOS-gated scenePhase
handler + settings section are not exercised by the macOS CI target (known §9
gap) — need on-glass verification (audio never gaps, video re-anchors <1s LAN,
timeout ends the session, phone-call audio-steal degrades gracefully).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 18:41:46 +02:00
enricobuehler 4cae1b8bb8 feat(apple/M0): App Group + PunktfunkShared + punktfunk:// deep links
Foundation milestone for Live Activities & Widgets (design/apple-live-
activities-and-widgets.md). No user-visible change beyond the URL scheme.

- New dependency-free PunktfunkShared SwiftPM target (+ library product) so a
  future widget extension can link it WITHOUT PunktfunkKit (Rust staticlib +
  presentation layer). Moves StoredHost (model + JSON codec), DefaultsKeys, and
  punktfunkDefaultMgmtPort there; adds AppGroup.suiteName and the punktfunk://
  DeepLink builder/parser. PunktfunkKit @_exported-imports it (no call-site
  churn for consumers; intra-Kit files import it explicitly since imports are
  file-scoped).
- HostStore reads/writes the shared App-Group suite (group.io.unom.punktfunk)
  with a one-time migration from UserDefaults.standard (old value left in place
  for staged rollout); reloads the "PunktfunkHosts" widget timeline on change.
- App Group entitlement on iOS/tvOS + macOS.
- CFBundleURLTypes scheme `punktfunk`; ContentView.onOpenURL routes
  connect/<uuid>[?launch=<GameEntry.id>] into the existing connect() path
  (unknown host / already-streaming guards; never tears down a live session).
- Round-trip tests: StoredHost JSON codec (+ legacy missing-optional decode),
  DeepLink grammar. `swift build` + `swift test` green (142 tests, 0 failures).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 18:16:39 +02:00
enricobuehler b45323c0be fix(host/windows): force off a game-abandoned rumble on the UMDF virtual pads
The DualSense / DualSense Edge / DualShock 4 / Switch Pro / Steam Deck backends
all run through UhidManager, whose pump() forwarded rumble only on a level
CHANGE and had no idle watchdog. A game that latches a one-shot rumble (a
Stardew axe hit, a DS3 hit) and then stops writing output reports left
last_rumble non-zero; native.rs re-sends the latched level every ~120 ms with a
fresh TTL and the Apple RumbleRenderer refreshes its envelope on every renewal,
so the controller vibrated continuously until a later event happened to write a
report the host parsed as a stop. The XUSB path already guards against this
(RUMBLE_IDLE_TIMEOUT force-off, 19e9828e); that guard was never ported here, so
every UMDF pad regressed for game-abandoned rumble once clients began
negotiating first-class virtual DualSense/DS4/etc. on Windows.

Port the guard into UhidManager::pump, keyed on game ACTIVITY (a fresh output
report, even at an unchanged level) so a rumble the game keeps asserting is
never cut — only an abandoned residual. The activity signal rides a new
PadFeedback.game_drove: Option<bool>; the Windows backends set it from a fresh
out_seq (via a `fresh` flag on DsFeedback/Ds4Feedback; the Deck uses is_some()).
Linux backends leave it None (untracked → always-active → the force-off never
fires there), so their behaviour is unchanged. +2 deterministic unit tests.

Verified: cargo check -p punktfunk-host --tests green on both Windows (.173) and
Linux (home-worker-5); the 10 inject::uhid_manager tests pass on Linux.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 17:45:29 +02:00
enricobuehler 1a7e3a6e4f fix(host/windows): propagate XUSB devnode-create failure instead of latching a phantom pad
XusbWinPad::open swallowed a SwDeviceCreate failure — it returned Ok with
`_sw: None` (a pad with no devnode) and logged only a warn, so PadSlots latched a
phantom pad, called gate.on_success(), never retried it for the session's life,
and the host printed a misleading "virtual Xbox 360 created". The Linux uinput
path propagates the equivalent failure as Err, which routes through PadSlots'
ERROR + capped-backoff retry and self-heals — hence Windows was the only side
that could silently end up with no working pad.

Propagate the create failure with `?` so Windows gets the same ERROR + backoff
retry as Linux. Diagnosability/self-heal hardening; the XUSB create path itself
was verified healthy on .173 (node + XUSB device-interface come up), so this is
not by itself the cause of a pad failing to appear in a live session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 17:45:27 +02:00
enricobuehler 4ffa2665ac refactor(host): extract src/devtest.rs — the standalone dev/test subcommands
Per plan §W5 (main-cli, 'devtest.rs land first'): move the inline-bodied dev/test
subcommand handlers out of main.rs's match into src/devtest.rs — input_test (Linux
libei/wlr injection smoke test + its non-Linux stub) and the virtual-gamepad
exercisers dualsense_test/switchpro_test (Linux UHID) and deck_windows_spike/
dualsense_windows_test (Windows UMDF + Steam Deck devnode spike). main.rs's arms
become one-line forwards; main.rs drops 1004→667 lines. The thin arms that already
forward to subsystem modules (zerocopy/capture selftests, probes) stay put — that
is their correct layer. Pure code-move (bodies verbatim; crate-local refs
qualified with crate::; one doc reword to dodge clippy doc_lazy_continuation now
that an arm comment became a /// doc).

Verified clippy 0/0 on BOTH Linux (home-worker-5, nvenc,vulkan-encode,pyrowave)
and Windows (.173, nvenc,amf-qsv — covers the cfg(windows) handlers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 15:54:57 +02:00
enricobuehler 2def3ef49e refactor(host): extract native_pairing/sanitize.rs — the untrusted-name scrubber
Per plan §W5: move sanitize_device_name (+ its NAME_MAX cap and unit test) out of
the native_pairing facade into native_pairing/sanitize.rs. It is a self-contained,
security-relevant leaf — the one place a wire-supplied unpaired-device name is
scrubbed of control chars / bidi-override spoofing before it is stored, listed,
logged, or shown in the approval UI. Re-export via `pub(crate) use` so
crate::native_pairing::sanitize_device_name stays stable (punktfunk1 accept loop +
the two in-crate callers). Pure code-move; verified host clippy 0/0 + 11
native_pairing tests green on Linux (home-worker-5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 15:42:55 +02:00
enricobuehler 571e22bc0f refactor(core): consolidate the fingerprint-pinning verifier into core::tls
Per plan §2.5: the security-critical rustls fingerprint-pinning ServerCertVerifier
was hand-rolled three times — quic/endpoint.rs (PinVerify), pf-client-core
library.rs, punktfunk-tray status.rs — drifting copies on a trust boundary. Add
one canonical punktfunk_core::tls::PinVerify (+ cert_fingerprint) behind a light
`tls` feature (rustls + sha2 only, no QUIC runtime); `quic` now depends on it, and
quic::endpoint re-exports cert_fingerprint so that path stays byte-stable
(gamestream + pf-client-core reach it there).

- core::tls::PinVerify: new(pin) for the HTTP clients, with_observed(pin, slot)
  for the QUIC TOFU connect. Behavior-identical to all three originals (pin-check
  + real CertificateVerify signature verification; only hashes the leaf when a pin
  or observed slot needs it). Two focused unit tests anchor the boundary.
- quic/endpoint.rs: drop the private PinVerify, wire client_pinned through
  tls::PinVerify::with_observed.
- pf-client-core library.rs + tray status.rs: use the shared verifier; tray also
  routes load_pin through core cert_fingerprint and drops its direct sha2 dep,
  gaining only the light core `tls` feature (still no host dep, no QUIC runtime).

Verified on Linux (home-worker-5): clippy 0/0 for core(quic), core(tls),
pf-client-core, tray, host(nvenc,vulkan-encode,pyrowave); core 153 lib tests +
loopback 7/7 (pinned handshake) + c_abi round-trip green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 15:36:29 +02:00
enricobuehler ce085b8e3b style(vdisplay): dedupe the attach-block comment (first-frame stash follow-up)
Comment-only: the lazy-attach comment carried the delivery-consumption
sentence twice after the stash rework.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 15:24:36 +02:00
enricobuehler 405b005a0d refactor(host): extract audio/mic_pump.rs — the host-lifetime virtual-mic pump
Per plan §2.1: a self-contained stateful subsystem does not belong in the
audio trait facade. Move MicPump + its PumpTuning/PUMP_TUNING, the
drain_sleep/pump_thread loop, MIC_CHANNELS/MIC_QUEUE_CAP, and the six pump
unit tests out of audio.rs into audio/mic_pump.rs. audio.rs keeps the
AudioCapturer/VirtualMic traits, their open_* factories, and the sample
constants. Re-export via `pub use mic_pump::MicPump` so crate::audio::MicPump
stays byte-stable (only consumer: punktfunk1.rs). Pure code-move; verified
clippy 0/0 + 6/6 pump tests green on Linux (home-worker-5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 15:21:36 +02:00
enricobuehler 89a08f83af fix(vdisplay): first-frame guarantee — republish a retained frame at ring attach
DWM composes a display only when something dirties it, so a session opened
onto an idle desktop never produced a first frame: the host's synthetic-input
"compose kick" (cursor wiggle / sibling-display jump) was the only source, and
it is inherently unreliable — blocked on the secure desktop, defeated by a
fullscreen game's ClipCursor, user-visible, and dead in service contexts. The
field symptom: connect → black stream until something repaints the desktop.

Reconstruct DDA's first-frame semantics at the driver instead (DDA seeds a new
duplication with the current desktop image; IDD-push never had an equivalent):

* frame_transport.rs: new FrameStash — the retained last composed frame, a
  driver-private copy-only texture. publish() now reports Published /
  DescMismatch / Dropped, and harvest_into() pulls the last-published ring
  slot into the stash (keyed-mutex guarded, freshness-checked) before a
  superseded publisher is dropped — between sessions the driver keeps writing
  the host-side-dead old ring, so that slot IS the current desktop image.
* swap_chain_processor.rs: the worker stashes every frame the ring can NOT
  take (unattached, or descriptor-mismatched during a mode/HDR-flip race),
  harvests before a supersede, and REPUBLISHES the stash into every freshly
  attached ring — the host sees a normal seq=1 publish milliseconds after
  channel delivery, no compose needed. Zero steady-state cost: matched
  publishes touch only the ring. The frame-channel stash is now polled every
  iteration (attach latency = first-frame latency; it was 1-in-30).
* monitor.rs: preserved_stash (LUID-tagged) so the retained frame survives
  swap-chain unassign→reassign flaps, alongside the preserved publisher.
* host idd_push.rs: kick_dwm_compose demoted to documented last-resort
  fallback for pre-stash drivers; a debug log now fires when a kick actually
  runs so field logs show whether the stash path is working.

No proto change: the republish is an ordinary publish, so old host + new
driver and new host + old driver both keep working (the latter via the kick).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 15:16:36 +02:00
enricobuehler 5748706631 refactor(host): hoist the direct-NVENC init-params authoring into nvenc_core
Both backends' build_init_params authored an identical NV_ENC_INITIALIZE_PARAMS
(P1/ULL preset, PTD, session dimensions/rate, split-encode mode) — the only
difference was the Windows-only enableEncodeAsync flag (Linux is sync-only).
Hoist it to nvenc_core::build_init_params(codec_guid, w, h, fps, cfg, split_mode,
enable_async); Linux's two call sites pass enable_async=false (the field stays 0
as before), Windows passes its session_async through. Keeps open and in-place
reconfigure presenting the SAME init params, now guaranteed identical across
platforms too.

Verified on BOTH platforms: Linux clippy 0/0 (nvenc,vulkan-encode,pyrowave, RTX
5070 Ti) and Windows clippy 0/0 (nvenc,amf-qsv, RTX 4090 / .173).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:58:03 +02:00
enricobuehler 2ae5cf98ee refactor(host): hoist the direct-NVENC low-latency config into nvenc_core
Both direct-SDK NVENC backends authored a near-identical NV_ENC_CONFIG in
build_config: CBR + infinite GOP + P-only + ~1-frame VBV, per-codec tier/level,
chroma + bit depth, unconditional colour VUI, and the RFI DPB — ~125 lines each,
differing only in comments plus two genuinely per-platform bits (which surface
formats carry full chroma / 10-bit input). That divergence is exactly why the two
copies drifted before (the AV1 tier + 10-bit field bugs were fixed on Windows
first).

Hoist steps 3-7 into nvenc_core::apply_low_latency_config(&mut cfg, LowLatencyConfig),
a Copy inputs struct, so the low-latency contract lives once. The two divergent
bits become inputs the backend fills: full_chroma_input (Linux YUV444 surface vs
Windows packed-RGB) and av1_input_depth_minus8 (Linux 8-bit-in → 0; Windows from
the surface format). Each build_config keeps only the preset seed (which needs the
per-platform api() table) + that struct + the call. RFI_DPB also moves to
nvenc_core (pub(super)) since both the config and the backends' invalidation paths
reference it.

Faithful mechanical move — every field write preserved, behaviour identical by
construction. Verified on BOTH platforms: Linux clippy 0/0 (nvenc,vulkan-encode,
pyrowave, RTX 5070 Ti) and Windows clippy 0/0 (nvenc,amf-qsv, RTX 4090 / .173).
Net -83 lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:53:18 +02:00
enricobuehler e61d655b1e refactor(host): extract encode/nvenc_core.rs — shared direct-SDK NVENC leaves
The two direct-SDK nvEncodeAPI backends (Windows D3D11 encode/windows/nvenc.rs,
Linux CUDA encode/linux/nvenc_cuda.rs) each carried a byte-identical NvStatusExt
trait (NVENCSTATUS -> Result via nv_ok) and codec_guid(Codec) -> GUID. Hoist
both into a new encode/nvenc_core.rs, the platform-agnostic sibling of the
existing encode/nvenc_status.rs (same cfg gate: any(linux,windows) + nvenc).
Each backend now imports them via super::nvenc_core; call sites (.nv_ok() ×16/20,
the one codec_guid() struct-init) are unchanged.

The per-platform machinery — entry-table load (nvEncodeAPI64.dll/LoadLibrary vs
libnvidia-encode.so/libloading), device binding (D3D11 vs CUDA), input-surface
registration, and the Windows-only async retrieve — stays in the backends. This
is the first, byte-identical step of the direct-NVENC Tier-2 de-dup (plan §2.2);
the larger build_config authoring is a later, carefully-diffed step.

Verified on BOTH platforms: Linux clippy 0/0 (nvenc,vulkan-encode,pyrowave, RTX
5070 Ti) and Windows clippy 0/0 (nvenc,amf-qsv, RTX 4090 / .173).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:42:08 +02:00
enricobuehler 7099266594 refactor(host): hoist the shared low-latency RC contract into encode/libav.rs
The three libavcodec backends each set the identical low-latency rate-control
block on the not-yet-opened encoder context: fixed time_base/frame_rate, CBR
(bit_rate == max_bit_rate), B-frames off, and a tight ~1-frame VBV/HRD buffer
written through the raw rc_buffer_size field. Move it once into
apply_low_latency_rc(&mut video, fps, bitrate_bps), and let the long VBV
rationale (why the tight buffer prevents high-motion bursts from overflowing
the send queue) live in one place instead of only in the NVENC path.

Each backend keeps the two genuinely per-backend calls around it: set_format
(pixel format differs) before, and gop_size after (NVENC's infinite/intra-
refresh wave vs the VAAPI/AMF i32::MAX). No behavior change — the field writes
are independent, so the slightly different max_b_frames/rc_buffer_size ordering
across backends is irrelevant. Folding the raw rc_buffer_size write into the
helper also removes the NVENC path's separate unsafe block. Drops the now-unused
ffmpeg::Rational import from all three.

Linux check + clippy green (0/0, nvenc,vulkan-encode,pyrowave) on RTX 5070 Ti;
ffmpeg_win.rs is Windows-cfg, pending .173 compile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:23:28 +02:00
enricobuehler fd8a062e2c refactor(host): hoist the libav poll_encoder drain + PollOutcome into encode/libav.rs
The three libavcodec backends each carried a byte-identical single-packet
receive_packet drain. Move it once into the shared Tier-2 glue as
poll_encoder -> PollOutcome (the richest form: Packet / Again / Eof), and
have the callers narrow it:

- Linux NVENC (encode/linux/mod.rs): poll() matches the shared fn, collapsing
  Again|Eof to Ok(None) — was an inlined match, now one call.
- VAAPI (encode/linux/vaapi.rs): drop the local poll_encoder; the blocking
  budget loop lets Again|Eof fall through to the deadline check, byte-identical
  to the old Option::None path.
- Windows AMF/QSV (encode/windows/ffmpeg_win.rs): drop the local PollOutcome +
  poll_encoder; its deadline-driven drain already matches PollOutcome, so only
  the import changes.

No behavior change on any backend. Still a plain monomorphic free fn over a
borrowed &mut Encoder — no new per-frame dyn/Box/alloc; the only allocation is
the same bitstream to_vec() each path already made. Drops the now-unused
ffmpeg::Packet import from all three.

Linux check + clippy green (nvenc,vulkan-encode,pyrowave) on RTX 5070 Ti;
ffmpeg_win.rs is Windows-cfg, pending .173 compile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:16:45 +02:00
enricobuehler 3c38a5f0e8 refactor(host): hoist shared libav glue into encode/libav.rs (pixel_to_av + swscale consts)
First step of the W2 libav de-dup (plan §2.2, the missing Tier-2 mid-layer). The
three libavcodec backends (Linux NVENC, VAAPI, Windows AMF/QSV) each carried a
byte-identical pixel_to_av plus the SWS_POINT / SWS_CS_ITU709 (/SWS_CS_BT2020)
swscale consts. Hoist them into a new encode/libav.rs and import from super::libav.

The module is gated to compile exactly when a libav backend does (linux, or
windows+amf-qsv). Free fns/consts over borrowed handles — no per-frame dyn/alloc,
off the zero-copy path. Verified: Linux cargo check green (linux/mod.rs + vaapi.rs
compile against it); ffmpeg_win.rs is Windows-cfg — same mechanical swap, covered
by Windows CI on push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:04:52 +02:00
enricobuehler 1f519d44f9 docs(core): backfill //! module docs on quic/{io,endpoint,pake}
The last three //!-less modules in the tree (plan §2.5 / §3.2):
  - io:       length-prefixed control-message framing (read_msg/write_msg)
  - endpoint: QUIC endpoint construction + transport tuning + the TOFU
              cert-pinning verifier (PinVerify)
  - pake:     SPAKE2 pairing key exchange

Docs only — no code, type, or wire-format change (cbindgen header byte-identical).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:54:46 +02:00
enricobuehler abecb5226c refactor(host): route the three libav backends' VBV re-parse through vbv_frames_env
The libavcodec paths (Linux NVENC, VAAPI, Windows QSV) each re-parsed
PUNKTFUNK_VBV_FRAMES locally in f32, duplicating and diverging in precision from
the f64 vbv_frames_env() helper the direct-NVENC/AMF paths already use. Now that
the helper lives in encode/codec.rs (532b313b), route all three through
crate::encode::vbv_frames_env(): one parse, one precision, no drift.

Behaviour-identical (same filter finite && > 0, same 1.0 default), f64 not f32.
Verified: Linux cargo check green (linux/mod.rs + vaapi.rs compile); ffmpeg_win.rs
is Windows-cfg and mirrors the amf.rs/nvenc.rs sites already using the helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:48:26 +02:00
enricobuehler 11045a0f70 chore: consolidate parallel-session WIP (HOLD — do not push)
Local snapshot of intermingled in-flight work, committed to unblock the encode
refactor (a clean ffmpeg_win.rs for the vbv-dedup follow-on). These hunks span
the same files and can't be cleanly split here; the commit bundles three
distinct workstreams that each belong in their own PR:

  - logging rework (~43 files: level re-tiering, structured fields, `?e`,
    hot-path flood latches)
  - conflicting-host detection (detect.rs + detect/{linux,windows}.rs + wiring
    in main.rs/mgmt.rs/Cargo.toml/docs/packaging)
  - standby-sink DWM-stall attribution (windows/display_events.rs + capture/
    vdisplay wiring)

NOT verified as a combination. NOT to be pushed until the refactor is done and
these are re-verified and reorganized into their proper per-workstream PRs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:42:53 +02:00
enricobuehler d466e3e2b2 chore(nix): NixOS flake snapshot — host+client packages, module, devShell (WIP)
Local snapshot of the in-flight NixOS support: flake.nix + flake.lock + nix/
(crane host and client packages, services.punktfunk module, devShell).
Standalone — nothing in the Rust/Cargo tree references it. Held from push
pending its owning session's finalization (Skia-under-Nix follow-up + intended
per-workstream PR).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:42:34 +02:00
enricobuehler 532b313b8c refactor(host): extract encode/codec.rs — the encoder contract
Move the Tier-1 encoder contract out of the stuffed encode.rs facade into a
new encode/codec.rs submodule (plan §7 / W2): EncodedFrame, Codec (all methods
except host_wire_caps), ChromaFormat, EncoderCaps, the Encoder trait,
validate_dimensions, vbv_frames_env, and the dimension + wire-roundtrip contract
tests. host_wire_caps stays in encode.rs alongside the backend-selection probes
it depends on; CodecSupport and its wire-mask test stay too.

encode.rs gains `mod codec;` + `pub(crate) use codec::*;` so every existing
crate::encode::X path — crate::encode::vbv_frames_env, ::Codec, ::Encoder, … —
stays byte-stable. Pure relocation: no call sites touched.

Verified: dev-Mac type-check of both files clean; Linux `cargo check -p
punktfunk-host --features nvenc,vulkan-encode,pyrowave` green (all encode
backends compile against the relocated contract); contract unit tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:21:49 +02:00
enricobuehler d381cdf7f4 fix(host): NVENC open-failure resilience — backoff, failed-open hygiene, self-diagnosis
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 51s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
apple / swift (push) Successful in 1m9s
decky / build-publish (push) Successful in 18s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
ci / bench (push) Successful in 5m48s
docker / deploy-docs (push) Successful in 24s
apple / screenshots (push) Successful in 5m18s
windows-host / package (push) Successful in 9m16s
arch / build-publish (push) Successful in 10m53s
android / android (push) Successful in 11m58s
deb / build-publish (push) Successful in 18m7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m19s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m28s
ci / rust (push) Successful in 24m56s
Field report (Linux direct NVENC): after a codec switch, every session open
failed with NV_ENC_ERR_INVALID_VERSION until the host process was restarted —
so the poisoned state is per-process, not a driver install issue. On-hardware
investigation (RTX 5070 Ti, 610.43.03) could not reproduce it with clean codec
cycles, dirty teardowns, or open/destroy storms, but established the failure
class: the driver enforces a per-process concurrent-session cap (12 there,
status INCOMPATIBLE_CLIENT_KEY; other branches report differently) whose
exhaustion is exactly this signature — persistent open failures healed only by
a process restart. Harden every path that can feed or mask that state:

* Rebuild backoff: the in-place encoder-rebuild retries slept one frame
  interval, so all 5 attempts burned within ~40 ms at 120 Hz — no driver-side
  transient (deferred teardown of the previous session, engine reset) can
  clear that fast. Exponential backoff 100 ms → 1.6 s (~3 s total) so
  transients heal instead of killing the session.
* Destroy-on-failed-open (Linux + Windows, all four open sites): the NVENC
  docs require NvEncDestroyEncoder even when OpenEncodeSessionEx FAILS — the
  driver may have allocated the session slot before erroring. Without it a
  retry burst against a transient leaks slots toward the cap, converting the
  transient into permanent exhaustion.
* Teardown: a destroy_encoder failure (a session slot the driver may keep) is
  now logged with its status instead of silently discarded.
* One-shot self-diagnosis on a failed session open (Linux): retry the raw open
  on a fresh dedicated CUDA context and log which of the three causes applies
  — shared-context poisoned (fresh works), driver-level skew/exhaustion/GPU
  loss (fresh fails the same way), or CUDA itself unhealthy (no fresh context)
  — so the next field report pinpoints the root cause with zero reporter
  effort.

On-hardware regression tests (RTX box .21, all green): codec-switch reopen
cycle (H265→AV1→H265→H264→H265), dirty teardown with in-flight encodes, and
the full open-failure→diagnosis→in-place-recovery path via real session-cap
exhaustion. Existing RFI/reconfigure/4:4:4 smokes still pass; clippy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 01:57:36 +02:00
enricobuehler f901bedf22 fix(host): actionable NVENC error logging — drop misleading "(no NVIDIA GPU?)"
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 1m2s
apple / swift (push) Successful in 1m10s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 56s
apple / screenshots (push) Successful in 5m24s
ci / bench (push) Successful in 7m32s
docker / deploy-docs (push) Successful in 28s
arch / build-publish (push) Successful in 11m51s
windows-host / package (push) Successful in 14m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m54s
android / android (push) Successful in 17m12s
deb / build-publish (push) Successful in 18m12s
ci / rust (push) Successful in 24m52s
Every NVENC entry-point failure was annotated "(no NVIDIA GPU?)", which
misled triage: the direct-NVENC path only loads on a machine that HAS an
NVIDIA GPU. A Linux user hit NV_ENC_ERR_INVALID_VERSION at
open_encode_session_ex (past the NvEncodeAPIGetMaxSupportedVersion pre-flight
gate) — the signature of a userspace/kernel driver version skew that a host
reboot fixes — and the log pointed at a missing GPU instead. A restart did
fix it.

Add encode/nvenc_status.rs: a shared NVENCSTATUS -> cause mapper that folds
the real cause into the anyhow::Error at construction, so every downstream
{e:#} log (the encode-recovery loop, session teardown) improves for free.
INVALID_VERSION now reads "update the NVIDIA driver, or reboot if you just
updated it (a host restart is the usual fix)"; NO_ENCODE_DEVICE /
DEVICE_NOT_EXIST / INCOMPATIBLE_CLIENT_KEY (session-count limit) / OOM /
UNSUPPORTED_PARAM get their own glosses. The required API version comes from
the SDK consts so it stays correct across crate bumps.

Wire it into all NVENC entry-point failures in both backends
(encode/linux/nvenc_cuda.rs, encode/windows/nvenc.rs) — every open, init,
preset/resource/bitstream call.

Also: when the encode-recovery loop exhausts its in-place rebuilds it now
logs a clear terminal line with the underlying cause instead of the session
silently vanishing after the last identical "rebuilt in place" line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 01:13:53 +02:00
196 changed files with 16442 additions and 10425 deletions
+5
View File
@@ -38,3 +38,8 @@ CLAUDE.md
# Local flatpak-builder output (build-flatpak.sh) — ostree repo + build dir at the repo root.
.flatpak-repo/
.flatpak-build/
# Nix build outputs (flake.nix) — `nix build` result symlinks + direnv cache. flake.lock IS tracked.
/result
/result-*
.direnv/
Generated
+1 -1
View File
@@ -3184,10 +3184,10 @@ dependencies = [
"anyhow",
"ksni",
"libc",
"punktfunk-core",
"rustls",
"serde",
"serde_json",
"sha2",
"ureq",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
"windows-service",
@@ -1,6 +1,8 @@
package io.unom.punktfunk
import android.content.Context
import android.os.Build
import android.util.Log
import android.view.Display
/**
@@ -249,11 +251,25 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
*/
fun displaySupportsHdr(context: Context): Boolean {
val display = runCatching { context.display }.getOrNull() ?: return false
@Suppress("DEPRECATION") // hdrCapabilities is the supported query on minSdk 31
val caps = display.hdrCapabilities ?: return false
return caps.supportedHdrTypes.any {
val types = buildSet {
// API 34+: the sanctioned per-mode query (Display.Mode.getSupportedHdrTypes). The
// deprecated Display-level hdrCapabilities can return EMPTY on Android 14+ devices
// (Pixel-class panels included), which would make a genuinely HDR display advertise
// no-HDR and pin the whole session to 8-bit SDR.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
display.mode.supportedHdrTypes.forEach { add(it) }
}
// Union the legacy query defensively — the supported one on minSdk 31, and some vendors
// populate only this on newer APIs.
@Suppress("DEPRECATION")
display.hdrCapabilities?.supportedHdrTypes?.forEach { add(it) }
}
// HDR10/HDR10+ only: the stream is BT.2020 PQ — a Dolby-Vision/HLG-only panel can't present it.
val supported = types.any {
it == Display.HdrCapabilities.HDR_TYPE_HDR10 || it == Display.HdrCapabilities.HDR_TYPE_HDR10_PLUS
}
Log.i("punktfunk", "display HDR types=$types → advertise HDR10=$supported")
return supported
}
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
@@ -39,6 +39,7 @@ internal fun StatsOverlay(
s: DoubleArray,
verbosity: StatsVerbosity,
decoderLabel: String = "",
codecLabel: String = "",
modifier: Modifier = Modifier,
) {
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
@@ -66,7 +67,7 @@ internal fun StatsOverlay(
statLine(decoderLabel, Color(0xFFB0D0FF))
}
if (detailed) {
videoFeedLine(s)?.let { statLine(it, Color.White) }
videoFeedLine(s, codecLabel)?.let { statLine(it, Color.White) }
}
if (latValid) {
// Display stage (s[22]s[25], from OnFrameRendered): when a render timestamp landed
@@ -151,14 +152,15 @@ private fun counterLine(s: DoubleArray, lostTotal: Long): String? {
}
/**
* Format the negotiated video-feed descriptor from the trailing four stats doubles
* `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
* `HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles)
* Format the negotiated video-feed descriptor from [codecLabel] plus the trailing four stats
* doubles `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
* `AV1 · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles)
* so the overlay simply omits the line. The codes are CICP / H.273: transfer 16 = PQ, 18 = HLG (else
* SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4. The
* Android decoder is always HEVC (`video/hevc`).
* SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4.
* [codecLabel] is the host-resolved codec (`nativeVideoCodecLabel`); a blank one falls back to
* `HEVC` (the pre-negotiation default) for the brief window before it's resolved.
*/
private fun videoFeedLine(s: DoubleArray): String? {
private fun videoFeedLine(s: DoubleArray, codecLabel: String): String? {
if (s.size < 14) return null
val bitDepth = s[10].toInt()
val primaries = s[11].toInt()
@@ -175,5 +177,6 @@ private fun videoFeedLine(s: DoubleArray): String? {
2 -> "4:2:2"
else -> "4:2:0"
}
return "HEVC · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel"
val codec = codecLabel.ifEmpty { "HEVC" }
return "$codec · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel"
}
@@ -84,6 +84,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
val initialSettings = remember { SettingsStore(context).load() }
var stats by remember { mutableStateOf<DoubleArray?>(null) }
var decoderLabel by remember { mutableStateOf("") }
var codecLabel by remember { mutableStateOf("") }
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
val statsOn = statsVerbosity != StatsVerbosity.OFF
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
@@ -99,6 +100,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
LaunchedEffect(handle, statsOn) {
NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn)
if (statsOn) {
// Codec is resolved at the handshake (Welcome) — fixed for the session, so read its
// label once up front (before the first snapshot renders the video-feed line).
if (codecLabel.isEmpty()) codecLabel = NativeBridge.nativeVideoCodecLabel(handle)
while (true) {
delay(1000)
stats = NativeBridge.nativeVideoStats(handle)
@@ -366,7 +370,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
if (statsOn) {
stats?.let {
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
StatsOverlay(it, statsVerbosity, decoderLabel, codecLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
}
}
// "Hold to quit" hint while the gamepad exit chord is armed — the exit debounces on a ~1 s
@@ -214,6 +214,7 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
),
verbosity = verbosity,
decoderLabel = "c2.qti.hevc.decoder · low-latency",
codecLabel = "HEVC",
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
)
}
@@ -161,6 +161,14 @@ object NativeBridge {
*/
external fun nativeVideoMime(handle: Long): String
/**
* A short human label for the codec the host resolved (`"H.264"` / `"HEVC"` / `"AV1"` /
* `"PyroWave"`), for the stats HUD's video-feed line, or `""` on a `0` handle. Distinct from
* [nativeVideoMime] because the MIME collapses PyroWave onto `video/hevc` and can't name it.
* Fixed for the session (resolved at the handshake); read once. Cheap; UI-safe.
*/
external fun nativeVideoCodecLabel(handle: Long): String
/**
* Start the decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. [decoderName] is the
+19 -2
View File
@@ -161,7 +161,7 @@ fn run_sync(
return;
}
log::info!(
"decode: HEVC decoder started at {}x{}",
"decode: {mime} decoder started at {}x{}",
mode.width,
mode.height
);
@@ -617,6 +617,19 @@ pub(crate) fn codec_mime(codec: u8) -> &'static str {
}
}
/// A short human label for the codec the host resolved, for the stats HUD's video-feed line
/// (`"H.264"` / `"HEVC"` / `"AV1"` / `"PyroWave"`). Mirrors [`codec_mime`]'s fallback: anything
/// not H.264/AV1/PyroWave is reported as HEVC (every pre-negotiation host emitted HEVC). Kept
/// beside [`codec_mime`] because the MIME collapses PyroWave onto `video/hevc` and so can't name it.
pub(crate) fn codec_label(codec: u8) -> &'static str {
match codec {
punktfunk_core::quic::CODEC_H264 => "H.264",
punktfunk_core::quic::CODEC_AV1 => "AV1",
punktfunk_core::quic::CODEC_PYROWAVE => "PyroWave",
_ => "HEVC",
}
}
/// Create the decoder: prefer the specific codec Kotlin ranked from `MediaCodecList`
/// (`from_codec_name`), falling back to the platform's default decoder for the MIME
/// (`from_decoder_type`) if that name can't be created (codec busy / renamed across an OS update).
@@ -815,7 +828,11 @@ fn run_async(
})),
on_error: Some(Box::new(move |e, code, _detail| {
let fatal = !code.is_recoverable() && !code.is_transient();
log::warn!("decode: codec error {e:?} (fatal={fatal})");
if fatal {
log::error!("decode: fatal codec error — stream will stop: {e:?}");
} else {
log::warn!("decode: codec error {e:?} (recoverable)");
}
let _ = err_tx.send(DecodeEvent::Error { fatal });
})),
};
@@ -102,6 +102,31 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'
})
}
/// `NativeBridge.nativeVideoCodecLabel(handle): String` — a short human label for the codec the
/// host resolved (`"H.264"` / `"HEVC"` / `"AV1"` / `"PyroWave"`), for the stats HUD's video-feed
/// line. Distinct from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime`] because the MIME
/// collapses PyroWave onto `video/hevc` and can't name it. Empty string on a `0` handle. Cheap;
/// safe on the UI thread. Android-gated (reads `crate::decode`), matching `nativeVideoMime`.
#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecLabel<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jstring {
jni_guard(std::ptr::null_mut(), || {
if handle == 0 {
return std::ptr::null_mut();
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
match env.new_string(crate::decode::codec_label(h.client.codec)) {
Ok(s) => s.into_raw(),
Err(_) => std::ptr::null_mut(),
}
})
}
/// `NativeBridge.nativeVideoDecoderLabel(handle): String` — the resolved decoder identity for the
/// HUD, e.g. `c2.qti.avc.decoder · low-latency`, or `""` before the decode thread has resolved one.
/// One-shot (the decoder is fixed for the session); poll once after the HUD appears. Not
+27
View File
@@ -19,5 +19,32 @@
<array>
<string>_punktfunk._udp</string>
</array>
<!-- Background keep-alive (opt-in, iOS/iPadOS): the ONLY sanctioned way to keep the long-lived
QUIC socket + pump-thread set alive while backgrounded is the audio background mode, backed
by the session's real, audible remote audio (AVAudioEngine keeps rendering). Video decode is
dropped; a bounded timer auto-disconnects. Never silence-as-keepalive (App Review 2.5.4).
tvOS ignores/tolerates the key; macOS is not gated by it. -->
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<!-- Live Activities (iOS/iPadOS): the Lock-Screen / Dynamic-Island session surface. Updated
locally (pushType nil) from the alive app process — no aps-environment. tvOS/macOS ignore it. -->
<key>NSSupportsLiveActivities</key>
<true/>
<!-- Deep links: punktfunk://connect/<host-uuid>[?launch=<GameEntry.id>]. Emitted by the
launcher widget and Siri/Shortcuts; routed by ContentView.onOpenURL into the existing
connect path. Shared across all three targets (tvOS/macOS accept it harmlessly). -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>io.unom.punktfunk.deeplink</string>
<key>CFBundleURLSchemes</key>
<array>
<string>punktfunk</string>
</array>
</dict>
</array>
</dict>
</plist>
@@ -73,5 +73,15 @@
<array>
<string>$(AppIdentifierPrefix)io.unom.punktfunk</string>
</array>
<!-- App Group: same shared UserDefaults suite as iOS (Config/Punktfunk.entitlements). Shared
here so a single HostStore code path (UserDefaults(suiteName:)) works on every platform;
macOS widgets that read it arrive with M5. macOS App Groups use the plain group id under
the App Store profile; a Developer-ID-signed build wants the team-prefixed form — the
Dev-ID codesign step in release.yml must verify this value against the Dev-ID profile. -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.io.unom.punktfunk</string>
</array>
</dict>
</plist>
@@ -20,5 +20,14 @@
is true on iOS/tvOS too. -->
<key>com.apple.developer.networking.multicast</key>
<true/>
<!-- App Group: the shared UserDefaults suite (group.io.unom.punktfunk) that both the app and
the Widget/Live-Activity extension read — the saved-host store moved there so a launcher
widget can see it (HostStore reads UserDefaults(suiteName:)). Must be registered on the
developer portal and enabled in the provisioning profile for BOTH app ids
(io.unom.punktfunk + io.unom.punktfunk.widgets). tvOS carries the key harmlessly. -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.io.unom.punktfunk</string>
</array>
</dict>
</plist>
+10 -2
View File
@@ -9,13 +9,20 @@ let package = Package(
platforms: [.macOS(.v14), .iOS(.v17), .tvOS(.v17)],
products: [
.library(name: "PunktfunkKit", targets: ["PunktfunkKit"]),
// Dependency-free foundation (stored-host model + JSON codec, settings keys, App-Group
// constant, deep-link grammar, Live Activity attributes). A separate PRODUCT so the widget
// extension which must never link PunktfunkKit (Rust staticlib + presentation layer)
// can link this and nothing else. PunktfunkKit re-exports it (see SharedReexport.swift).
.library(name: "PunktfunkShared", targets: ["PunktfunkShared"]),
.executable(name: "PunktfunkClient", targets: ["PunktfunkClient"]),
],
targets: [
.binaryTarget(name: "PunktfunkCore", path: "PunktfunkCore.xcframework"),
// No dependencies by design an extension process links this alone.
.target(name: "PunktfunkShared"),
.target(
name: "PunktfunkKit",
dependencies: ["PunktfunkCore"],
dependencies: ["PunktfunkCore", "PunktfunkShared"],
// OSS attribution shown by the app's Acknowledgements screen. Bundled here (not in the
// app target) so it rides along via Bundle.module in both `swift build` and the Xcode
// app, which links the PunktfunkKit product. Refresh with
@@ -43,7 +50,8 @@ let package = Package(
// PunktfunkCore is a direct dep too so the wire tests can name the C ABI's
// `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout.
.testTarget(
name: "PunktfunkKitTests", dependencies: ["PunktfunkKit", "PunktfunkCore"],
name: "PunktfunkKitTests",
dependencies: ["PunktfunkKit", "PunktfunkShared", "PunktfunkCore"],
resources: [
// PyroWave golden fixtures: host-encoded AUs + upstream-decoded reference
// planes (regenerate with punktfunk-host's `pyrowave_dump_golden` on a
@@ -11,14 +11,56 @@
BB0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000006 /* PunktfunkKit */; };
CC0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0000000000000000000006 /* PunktfunkKit */; };
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2955699300948B9009F939C /* WidgetKit.framework */; };
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E295569B300948B9009F939C /* SwiftUI.framework */; };
E2CAFE000000000000000001 /* PunktfunkShared in Frameworks */ = {isa = PBXBuildFile; productRef = E2CAFE000000000000000002 /* PunktfunkShared */; };
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
E29556A7300948BA009F939C /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA000000000000000000000D /* Project object */;
proxyType = 1;
remoteGlobalIDString = E2955696300948B9009F939C;
remoteInfo = PunktfunkWidgetsExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
E29556AA300948BA009F939C /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
AA0000000000000000000001 /* Punktfunk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Punktfunk.app; sourceTree = BUILT_PRODUCTS_DIR; };
BB0000000000000000000001 /* Punktfunk-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Punktfunk-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
CC0000000000000000000001 /* Punktfunk-tvOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Punktfunk-tvOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PunktfunkWidgetsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
E2955699300948B9009F939C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
E295569B300948B9009F939C /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
E295577B30094CE5009F939C /* PunktfunkWidgetsExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PunktfunkWidgetsExtension.entitlements; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
E29556AD300948BA009F939C /* Exceptions for "PunktfunkWidgets" folder in "PunktfunkWidgetsExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = E2955696300948B9009F939C /* PunktfunkWidgetsExtension */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
AA0000000000000000000002 /* App */ = {
isa = PBXFileSystemSynchronizedRootGroup;
@@ -30,6 +72,14 @@
path = Sources/PunktfunkClient;
sourceTree = "<group>";
};
E295569D300948B9009F939C /* PunktfunkWidgets */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
E29556AD300948BA009F939C /* Exceptions for "PunktfunkWidgets" folder in "PunktfunkWidgetsExtension" target */,
);
path = PunktfunkWidgets;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -58,14 +108,27 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
E2955694300948B9009F939C /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
E2CAFE000000000000000001 /* PunktfunkShared in Frameworks */,
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */,
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
AA0000000000000000000007 = {
isa = PBXGroup;
children = (
E295577B30094CE5009F939C /* PunktfunkWidgetsExtension.entitlements */,
AA0000000000000000000002 /* App */,
AA0000000000000000000003 /* Sources/PunktfunkClient */,
E295569D300948B9009F939C /* PunktfunkWidgets */,
E2955698300948B9009F939C /* Frameworks */,
AA0000000000000000000008 /* Products */,
);
sourceTree = "<group>";
@@ -76,10 +139,20 @@
AA0000000000000000000001 /* Punktfunk.app */,
BB0000000000000000000001 /* Punktfunk-iOS.app */,
CC0000000000000000000001 /* Punktfunk-tvOS.app */,
E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */,
);
name = Products;
sourceTree = "<group>";
};
E2955698300948B9009F939C /* Frameworks */ = {
isa = PBXGroup;
children = (
E2955699300948B9009F939C /* WidgetKit.framework */,
E295569B300948B9009F939C /* SwiftUI.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -114,10 +187,12 @@
BB000000000000000000000B /* Sources */,
BB0000000000000000000004 /* Frameworks */,
BB000000000000000000000C /* Resources */,
E29556AA300948BA009F939C /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
E29556A8300948BA009F939C /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
AA0000000000000000000002 /* App */,
@@ -156,6 +231,29 @@
productReference = CC0000000000000000000001 /* Punktfunk-tvOS.app */;
productType = "com.apple.product-type.application";
};
E2955696300948B9009F939C /* PunktfunkWidgetsExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = E29556AE300948BA009F939C /* Build configuration list for PBXNativeTarget "PunktfunkWidgetsExtension" */;
buildPhases = (
E2955693300948B9009F939C /* Sources */,
E2955694300948B9009F939C /* Frameworks */,
E2955695300948B9009F939C /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
E295569D300948B9009F939C /* PunktfunkWidgets */,
);
name = PunktfunkWidgetsExtension;
packageProductDependencies = (
E2CAFE000000000000000002 /* PunktfunkShared */,
);
productName = PunktfunkWidgetsExtension;
productReference = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -163,11 +261,15 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2700;
LastUpgradeCheck = 2700;
TargetAttributes = {
AA0000000000000000000009 = {
CreatedOnToolsVersion = 26.0;
};
E2955696300948B9009F939C = {
CreatedOnToolsVersion = 27.0;
};
};
};
buildConfigurationList = AA000000000000000000000E /* Build configuration list for PBXProject "Punktfunk" */;
@@ -190,6 +292,7 @@
AA0000000000000000000009 /* Punktfunk */,
BB0000000000000000000009 /* Punktfunk-iOS */,
CC0000000000000000000009 /* Punktfunk-tvOS */,
E2955696300948B9009F939C /* PunktfunkWidgetsExtension */,
);
};
/* End PBXProject section */
@@ -216,6 +319,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
E2955695300948B9009F939C /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -240,8 +350,23 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
E2955693300948B9009F939C /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
E29556A8300948BA009F939C /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = E2955696300948B9009F939C /* PunktfunkWidgetsExtension */;
targetProxy = E29556A7300948BA009F939C /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
AA0000000000000000000010 /* Debug */ = {
isa = XCBuildConfiguration;
@@ -564,6 +689,97 @@
};
name = Release;
};
E29556AB300948BA009F939C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = PunktfunkWidgetsExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F4H37KF6WC;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = PunktfunkWidgets/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = PunktfunkWidgets;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
E29556AC300948BA009F939C /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = PunktfunkWidgetsExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F4H37KF6WC;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = PunktfunkWidgets/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = PunktfunkWidgets;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -603,6 +819,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
E29556AE300948BA009F939C /* Build configuration list for PBXNativeTarget "PunktfunkWidgetsExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
E29556AB300948BA009F939C /* Debug */,
E29556AC300948BA009F939C /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
@@ -636,6 +861,10 @@
isa = XCSwiftPackageProductDependency;
productName = PunktfunkKit;
};
E2CAFE000000000000000002 /* PunktfunkShared */ = {
isa = XCSwiftPackageProductDependency;
productName = PunktfunkShared;
};
DD0000000000000000000002 /* SwiftUINavigationTransitions */ = {
isa = XCSwiftPackageProductDependency;
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,186 @@
// Home-Screen / Lock-Screen quick-launch widget (kind "PunktfunkHosts"). Reads the saved-host
// store from the shared App-Group suite, sorts most-recent-first, and deep-links each host into a
// session via `punktfunk://connect/<uuid>` the app's onOpenURL routes it through the normal
// connect path (trust policy / WoL / approval all apply).
//
// No reachability probing in v1 (a UDP check has no place in a timeline build; WoL handles offline
// hosts on tap). Timeline is a single `.never` entry the app pushes reloads on store changes
// (HostStore WidgetCenter.reloadTimelines).
import SwiftUI
import WidgetKit
import PunktfunkShared
// MARK: - Timeline
struct HostsEntry: TimelineEntry {
let date: Date
let hosts: [StoredHost]
}
struct HostsProvider: TimelineProvider {
func placeholder(in context: Context) -> HostsEntry {
HostsEntry(date: .now, hosts: [])
}
func getSnapshot(in context: Context, completion: @escaping (HostsEntry) -> Void) {
completion(HostsEntry(date: .now, hosts: Self.loadHosts()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<HostsEntry>) -> Void) {
// Single entry, never auto-refresh: the app reloads this timeline whenever the store
// changes (a new host, a fresh connect reordering by recency).
let entry = HostsEntry(date: .now, hosts: Self.loadHosts())
completion(Timeline(entries: [entry], policy: .never))
}
/// Decode the shared-suite host JSON (same wire format the app writes), most-recent first.
static func loadHosts() -> [StoredHost] {
guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts),
let hosts = try? JSONDecoder().decode([StoredHost].self, from: data)
else { return [] }
return hosts.sorted {
($0.lastConnected ?? .distantPast) > ($1.lastConnected ?? .distantPast)
}
}
}
// MARK: - Widget
struct HostsWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "PunktfunkHosts", provider: HostsProvider()) { entry in
HostsWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Punktfunk Hosts")
.description("Quick-launch your recent streaming hosts.")
.supportedFamilies([
.systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular,
])
}
}
// MARK: - Views
struct HostsWidgetView: View {
@Environment(\.widgetFamily) private var family
let entry: HostsEntry
var body: some View {
switch family {
case .systemMedium:
MediumHostsView(hosts: entry.hosts)
case .accessoryCircular:
CircularHostView(host: entry.hosts.first)
case .accessoryRectangular:
RectangularHostView(host: entry.hosts.first)
default: // systemSmall + fallback
SmallHostView(host: entry.hosts.first)
}
}
}
/// Deep link that connects to a stored host.
private func connectURL(_ host: StoredHost) -> URL {
DeepLink.connect(host: host.id, launchID: nil).url
}
private struct SmallHostView: View {
let host: StoredHost?
var body: some View {
if let host {
VStack(alignment: .leading, spacing: 6) {
Image(systemName: "play.tv.fill")
.font(.title2)
.foregroundStyle(.tint)
Spacer(minLength: 0)
Text(host.displayName)
.font(.headline)
.lineLimit(2)
if let last = host.lastConnected {
Text(last, format: .relative(presentation: .named))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.widgetURL(connectURL(host))
} else {
EmptyHostView()
}
}
}
private struct MediumHostsView: View {
let hosts: [StoredHost]
var body: some View {
if hosts.isEmpty {
EmptyHostView()
} else {
VStack(alignment: .leading, spacing: 8) {
Text("Punktfunk")
.font(.caption).bold()
.foregroundStyle(.tint)
ForEach(hosts.prefix(4)) { host in
Link(destination: connectURL(host)) {
HStack {
Image(systemName: "play.tv.fill")
.foregroundStyle(.tint)
Text(host.displayName)
.font(.subheadline)
.lineLimit(1)
Spacer()
if let last = host.lastConnected {
Text(last, format: .relative(presentation: .named))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
}
private struct CircularHostView: View {
let host: StoredHost?
var body: some View {
ZStack {
AccessoryWidgetBackground()
Image(systemName: "play.tv.fill")
}
.widgetURL(host.map(connectURL))
}
}
private struct RectangularHostView: View {
let host: StoredHost?
var body: some View {
HStack {
Image(systemName: "play.tv.fill")
Text(host?.displayName ?? "Punktfunk")
.lineLimit(1)
}
.widgetURL(host.map(connectURL))
}
}
private struct EmptyHostView: View {
var body: some View {
VStack(spacing: 6) {
Image(systemName: "play.tv")
.font(.title2)
.foregroundStyle(.secondary)
Text("Open Punktfunk to add a host.")
.font(.caption)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,20 @@
// The widget extension's entry point. ONE extension target (bundle id io.unom.punktfunk.widgets,
// iOS only) hosts both the launcher widgets and the Live Activity UI. It links PunktfunkShared and
// NOTHING else never PunktfunkKit (Rust staticlib + presentation layer would blow the widget
// process's ~30 MB budget).
//
// These files are NOT part of the SwiftPM package (Package.swift doesn't declare a PunktfunkWidgets
// target, so `swift build` ignores the directory). They compile only in the Xcode widget-extension
// target you add pointing at this folder see design/apple-live-activities-and-widgets.md §M1 and
// the GUI checklist.
import SwiftUI
import WidgetKit
@main
struct PunktfunkWidgetBundle: WidgetBundle {
var body: some Widget {
HostsWidget()
PunktfunkSessionLiveActivity()
}
}
@@ -0,0 +1,140 @@
// The Live Activity UI (Lock Screen banner + Dynamic Island) for a running session. The app owns
// the Activity's lifecycle (SessionActivityController); this is only its presentation, rendered in
// the widget-extension process from the shared `PunktfunkSessionAttributes`.
//
// The End button runs `EndStreamIntent` (a LiveActivityIntent) IN THE APP's process, which posts
// .punktfunkEndActiveSession the app disconnects. Elapsed time ticks client-side via
// Text(timerInterval:) no per-second push.
import ActivityKit
import AppIntents
import SwiftUI
import WidgetKit
import PunktfunkShared
struct PunktfunkSessionLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: PunktfunkSessionAttributes.self) { context in
LockScreenView(context: context)
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Label {
Text(context.attributes.hostName).font(.caption).lineLimit(1)
} icon: {
Image(systemName: "play.tv.fill")
}
.foregroundStyle(.tint)
}
DynamicIslandExpandedRegion(.trailing) {
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
.font(.caption).monospacedDigit()
.frame(maxWidth: 56)
.foregroundStyle(.secondary)
}
DynamicIslandExpandedRegion(.center) {
if let title = context.attributes.launchTitle {
Text(title).font(.caption2).lineLimit(1).foregroundStyle(.secondary)
}
}
DynamicIslandExpandedRegion(.bottom) {
VStack(spacing: 6) {
Text(context.state.modeLine)
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
StageLine(state: context.state)
EndButton()
}
}
} compactLeading: {
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
} compactTrailing: {
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
.monospacedDigit()
.frame(maxWidth: 44)
} minimal: {
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
}
}
}
}
// MARK: - Lock Screen banner
private struct LockScreenView: View {
let context: ActivityViewContext<PunktfunkSessionAttributes>
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "play.tv.fill")
.font(.title2)
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(context.attributes.hostName).font(.headline).lineLimit(1)
Spacer()
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
.font(.subheadline).monospacedDigit()
.foregroundStyle(.secondary)
}
if let title = context.attributes.launchTitle {
Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Text(context.state.modeLine)
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
StageLine(state: context.state)
}
if context.state.stage == .background {
EndButton()
}
}
.padding()
}
}
// MARK: - Shared pieces
/// The stage badge + (while backgrounded) the auto-disconnect countdown.
private struct StageLine: View {
let state: PunktfunkSessionAttributes.ContentState
var body: some View {
switch state.stage {
case .streaming:
EmptyView()
case .background:
if let deadline = state.backgroundDeadline {
HStack(spacing: 3) {
Text("Keeps running for")
Text(timerInterval: Date()...deadline, countsDown: true)
.monospacedDigit()
}
.font(.caption2)
.foregroundStyle(.secondary)
} else {
badge("Running in background", .orange)
}
case .reconnecting:
badge("Reconnecting…", .yellow)
case .ending:
badge("Session ended", .secondary)
}
}
private func badge(_ text: String, _ color: Color) -> some View {
Text(text).font(.caption2).foregroundStyle(color)
}
}
/// End-stream button runs EndStreamIntent in the app process (LiveActivityIntent).
private struct EndButton: View {
var body: some View {
Button(intent: EndStreamIntent()) {
Label("End", systemImage: "stop.fill")
.font(.caption).bold()
}
.tint(.red)
.buttonStyle(.bordered)
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.io.unom.punktfunk</string>
</array>
</dict>
</plist>
@@ -51,6 +51,15 @@ struct ContentView: View {
}
}
@State private var showAddHost = false
/// A `punktfunk://` deep link (widget / Siri / Shortcuts) couldn't be honored unknown host, or
/// a live session is already up. Surfaced as an informational alert (distinct from the
/// "Connection failed" one, which is for actual connect errors).
@State private var deepLinkNotice: String?
#if os(iOS)
/// Owns the Live Activity for the running session (Lock Screen / Dynamic Island). Driven from
/// the session model's published state below; iPhone/iPad only.
@State private var liveActivity = SessionActivityController()
#endif
@State private var pairingTarget: StoredHost?
/// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN
/// delegated approval ("Request Access") and the SPAKE2 PIN ceremony (rule 3b).
@@ -92,6 +101,14 @@ struct ContentView: View {
/// fires Wake-on-LAN up front and falls into the "Waking" wait if the dial fails. Off: connects
/// go straight through with no wake. The explicit "Wake Host" action is unaffected either way.
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
/// Background keep-alive (Settings General, iOS-only). Default OFF (today's freeze-on-background
/// is the default). When on, backgrounding a live session keeps audio + the connection alive and
/// drops video, auto-disconnecting after `backgroundTimeoutMinutes`.
@AppStorage(DefaultsKey.backgroundKeepAlive) private var backgroundKeepAlive = false
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) private var backgroundTimeoutMinutes = 10
/// scenePhase drives the keep-alive: use THIS, not the willResignActive observers resign-active
/// also fires for Control Center / app-switcher peeks, where the disconnect timer must not start.
@Environment(\.scenePhase) private var scenePhase
private var gamepadUIActive: Bool {
GamepadUIEnvironment.isActive(
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
@@ -113,7 +130,62 @@ struct ContentView: View {
.onAppear {
seedDefaultModeIfNeeded()
autoConnectIfAsked()
#if os(iOS)
SessionActivityController.sweepOrphans() // end any Activity a prior killed launch left
#endif
}
// Deep links (widget quick-launch, Siri/Shortcuts): route into the SAME connect path a card
// tap uses, so trust policy / WoL / the approval sheet all come along. Never starts a
// parallel session this drives the one `model` ContentView owns.
.onOpenURL { handleDeepLink($0) }
#if os(iOS)
// Background keep-alive driver (opt-in). Only .background/.active matter; .inactive (a
// transient peek) is ignored so the disconnect timer never starts for a Control-Center pull.
.onChange(of: scenePhase) { _, phase in
switch phase {
case .background:
if backgroundKeepAlive, model.phase == .streaming {
model.enterBackground(timeoutMinutes: backgroundTimeoutMinutes)
}
case .active:
model.exitBackground()
default:
break
}
}
// Live Activity lifecycle, driven from the model's published state.
.onChange(of: model.phase) { _, phase in
switch phase {
case .streaming:
if let host = model.activeHost {
liveActivity.begin(
hostID: host.id, hostName: host.displayName,
launchTitle: nil, // no live foreground-app title mid-session (v1)
modeLine: currentModeLine(), startedAt: Date())
}
case .idle:
liveActivity.end()
default:
break
}
}
.onChange(of: model.isBackgrounded) { _, backgrounded in
liveActivity.update {
$0.stage = backgrounded ? .background : .streaming
$0.backgroundDeadline = model.backgroundDeadline
}
}
// The Live Activity's / Shortcuts' End button runs EndStreamIntent in-process, which posts
// this tear the session down deliberately (quit-close the host).
.onReceive(NotificationCenter.default.publisher(for: .punktfunkEndActiveSession)) { _ in
model.disconnect(deliberate: true)
}
// Connect App Intent (Siri/Shortcuts): route its punktfunk:// URL through the same handler
// as a widget tap.
.onReceive(NotificationCenter.default.publisher(for: .punktfunkOpenDeepLink)) { note in
if let url = note.object as? URL { handleDeepLink(url) }
}
#endif
.onChange(of: model.phase) { _, phase in
switch phase {
case .streaming:
@@ -262,6 +334,57 @@ struct ContentView: View {
+ "console (port 3000 → Pairing). This device connects automatically once you "
+ "approve it — no need to reconnect.")
}
// Informational deep-link outcome (unknown host / already streaming). Not an error.
.alert(
"Can't open",
isPresented: Binding(
get: { deepLinkNotice != nil },
set: { if !$0 { deepLinkNotice = nil } })
) {
Button("OK", role: .cancel) {}
} message: {
Text(deepLinkNotice ?? "")
}
}
#if os(iOS)
/// The Live Activity mode line, e.g. "2560×1440 @120 · HEVC · HDR", from the live connection.
private func currentModeLine() -> String {
guard let c = model.connection else { return "" }
let codec: String
switch c.videoCodec {
case .h264: codec = "H.264"
case .hevc: codec = "HEVC"
case .av1: codec = "AV1"
case .pyrowave: codec = "PyroWave"
}
var line = "\(c.width)×\(c.height)"
if c.refreshHz > 0 { line += " @\(c.refreshHz)" }
line += " · \(codec)"
if c.isHDR { line += " · HDR" }
return line
}
#endif
/// Route a `punktfunk://` deep link into the existing connect path. Rules (per design):
/// unknown host notice + no-op; a live session is up ignore if it's the same host, else
/// tell the user to end the current one first (NEVER tear down a live session on a background
/// tap); otherwise the normal `connect` trust policy, WoL and the approval sheet all apply.
private func handleDeepLink(_ url: URL) {
guard case let .connect(hostID, launchID)? = DeepLink(url) else { return }
guard let host = store.hosts.first(where: { $0.id == hostID }) else {
deepLinkNotice = "That host isn't saved on this device."
return
}
if model.phase != .idle {
guard model.activeHost?.id == hostID else {
let current = model.activeHost?.displayName ?? "a host"
deepLinkNotice = "Already streaming \(current). End that session first."
return
}
return // deep-linked to the host we're already on nothing to do
}
connect(host, launchID: launchID)
}
private var home: some View {
@@ -0,0 +1,102 @@
// Siri / Shortcuts / Spotlight surface (design §M4). Deliberately thin: every action already has an
// internal entry point M0's deep-link router (connect / connect-and-launch), M3's in-process
// end-session hook, and the existing Wake-on-LAN path so these intents only wrap them.
//
// Gated os(iOS): the AppShortcutsProvider bundles `EndStreamIntent`, which is a LiveActivityIntent
// (iPhone/iPad only). Connect/Wake themselves are plain AppIntents; they live here with the
// provider rather than being split across platforms. `HostEntity` (the parameter type) is in
// PunktfunkShared so the widget's configuration intent can share it.
#if os(iOS)
import AppIntents
import Foundation
import PunktfunkKit
/// Load a full saved host (MACs, address) from the shared App-Group store by id HostEntity only
/// carries id + name.
private func loadStoredHost(_ id: UUID) -> StoredHost? {
guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts),
let hosts = try? JSONDecoder().decode([StoredHost].self, from: data)
else { return nil }
return hosts.first { $0.id == id }
}
/// Start a session with a stored host (optionally launching a title). Foregrounds the app and
/// routes through the SAME `.onOpenURL` path a widget tap uses trust policy, WoL and the approval
/// sheet all apply, and its guards (unknown host, already-streaming) hold.
struct ConnectToHostIntent: AppIntent {
static let title: LocalizedStringResource = "Connect to Host"
static let description = IntentDescription("Start a Punktfunk streaming session with a host.")
static let openAppWhenRun = true
@Parameter(title: "Host") var host: HostEntity
@Parameter(title: "Game ID", description: "Optional store id like steam:570")
var launchID: String?
func perform() async throws -> some IntentResult {
let url = DeepLink.connect(host: host.id, launchID: launchID).url
await MainActor.run {
NotificationCenter.default.post(name: .punktfunkOpenDeepLink, object: url)
}
return .result()
}
}
/// Wake a sleeping host (magic packet). No `openAppWhenRun` usable in automations ("when I get
/// home, wake the tower") without foregrounding the app.
struct WakeHostIntent: AppIntent {
static let title: LocalizedStringResource = "Wake Host"
static let description = IntentDescription("Send a Wake-on-LAN magic packet to a host.")
@Parameter(title: "Host") var host: HostEntity
func perform() async throws -> some IntentResult {
guard let stored = loadStoredHost(host.id), !stored.wakeMacs.isEmpty else {
throw IntentError.noWakeAddress
}
PunktfunkConnection.wakeOnLAN(macs: stored.wakeMacs, lastKnownIP: stored.address)
return .result()
}
}
/// Errors surfaced to Siri/Shortcuts. `CustomLocalizedStringResourceConvertible` makes the message
/// show as the intent's failure text.
enum IntentError: Error, CustomLocalizedStringResourceConvertible {
case noWakeAddress
var localizedStringResource: LocalizedStringResource {
switch self {
case .noWakeAddress:
// One string LITERAL LocalizedStringResource is ExpressibleByStringLiteral, but a
// `"" + ""` concatenation is a runtime String it can't convert.
return "That host has no saved Wake-on-LAN address yet. Connect to it once so Punktfunk can learn it."
}
}
}
/// Zero-setup Siri / Spotlight phrases. Parameterized phrases resolve a `HostEntity` by name; stays
/// well under the 10-shortcut cap.
struct PunktfunkShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: ConnectToHostIntent(),
phrases: [
"Connect to \(\.$host) in \(.applicationName)",
"Stream \(\.$host) with \(.applicationName)",
],
shortTitle: "Connect", systemImageName: "play.tv.fill")
AppShortcut(
intent: WakeHostIntent(),
phrases: [
"Wake \(\.$host) with \(.applicationName)",
],
shortTitle: "Wake Host", systemImageName: "power")
AppShortcut(
intent: EndStreamIntent(),
phrases: [
"End the \(.applicationName) stream",
],
shortTitle: "End Stream", systemImageName: "stop.fill")
}
}
#endif
@@ -0,0 +1,89 @@
// Owns the ActivityKit Live Activity lifecycle for a streaming session (iPhone/iPad only). Driven
// by ContentView from the session model's published state (phase / isBackgrounded / deadline) so
// none of this leaks into the cross-platform SessionModel. Local updates only (`pushType: nil`)
// the app process is alive whenever there's a session to report, so there's no push token plumbing.
//
// Gated os(iOS): ActivityKit is iPhone/iPad only. Minimum deployment is iOS 17, so no @available
// guards are needed (Activity has existed since 16.1).
#if os(iOS)
import ActivityKit
import Foundation
// PunktfunkKit re-exports PunktfunkShared (@_exported), so the app target sees PunktfunkSessionAttributes
// without linking the Shared product directly same pattern as StoredHost in HostStore.
import PunktfunkKit
@MainActor
final class SessionActivityController {
private var activity: Activity<PunktfunkSessionAttributes>?
/// The last pushed state, so an update can mutate one field and keep the rest (notably
/// `startedAt`, which the Lock-Screen timer ticks from).
private var state: PunktfunkSessionAttributes.ContentState?
/// How far past the next expected update to mark the content stale a frozen opt-out session
/// then greys out instead of showing a lying clock.
private static let staleWindow: TimeInterval = 90
var isActive: Bool { activity != nil }
/// End any Activity left over from a previous launch that was killed mid-session. Call once at
/// app start (ContentView.onAppear).
static func sweepOrphans() {
Task {
for activity in Activity<PunktfunkSessionAttributes>.activities {
await activity.end(nil, dismissalPolicy: .immediate)
}
}
}
/// Start the Live Activity for a freshly-streaming session. No-op if the user disabled Live
/// Activities for the app, or one is already up.
func begin(hostID: UUID, hostName: String, launchTitle: String?, modeLine: String, startedAt: Date) {
guard ActivityAuthorizationInfo().areActivitiesEnabled, activity == nil else { return }
let attributes = PunktfunkSessionAttributes(
hostID: hostID, hostName: hostName, launchTitle: launchTitle)
let initial = PunktfunkSessionAttributes.ContentState(
stage: .streaming, startedAt: startedAt, modeLine: modeLine)
state = initial
do {
activity = try Activity.request(
attributes: attributes,
content: content(initial),
pushType: nil)
} catch {
activity = nil
state = nil
}
}
/// Coalesced update: mutate the running state in place (keeps `startedAt` etc.) and push once.
/// No-op when there's no live Activity.
func update(_ mutate: (inout PunktfunkSessionAttributes.ContentState) -> Void) {
guard let activity, var next = state else { return }
mutate(&next)
state = next
Task { await activity.update(content(next)) }
}
/// End with a final "ended" state, dismissed a few seconds later.
func end() {
guard let activity, var final = state else {
self.activity = nil
state = nil
return
}
self.activity = nil
state = nil
final.stage = .ending
final.backgroundDeadline = nil
Task {
await activity.end(content(final), dismissalPolicy: .after(.now + 4))
}
}
private func content(_ s: PunktfunkSessionAttributes.ContentState)
-> ActivityContent<PunktfunkSessionAttributes.ContentState> {
ActivityContent(state: s, staleDate: Date().addingTimeInterval(Self.staleWindow))
}
}
#endif
@@ -148,6 +148,16 @@ final class SessionModel: ObservableObject {
var isBusy: Bool { phase != .idle }
/// True while a streaming session is running in the background under the opt-in keep-alive
/// (audio plays, video dropped, timeout armed). Drives the Live Activity's stage/countdown (M3)
/// and is cleared on foreground or teardown. iOS/iPadOS only in practice.
@Published private(set) var isBackgrounded = false
/// When the backgrounded keep-alive will auto-disconnect (nil unless backgrounded) drives the
/// Live Activity countdown. Set alongside `backgroundTimer`.
@Published private(set) var backgroundDeadline: Date?
/// Bounded auto-disconnect for a backgrounded keep-alive session. Fires on `.main`.
private var backgroundTimer: DispatchSourceTimer?
/// `allowTofu` gates the trust-on-first-use prompt for an unpinned host: it is only true
/// when the host EXPLICITLY advertised `pair=optional` (rule 3a). For any other unpinned host
/// `pair=required`, a manually-typed host, or a discovered host with no/unknown `pair`
@@ -332,6 +342,48 @@ final class SessionModel: ObservableObject {
}
}
// MARK: - Background keep-alive (opt-in, iOS)
/// Enter the backgrounded keep-alive state: keep audio playing, DROP video decode (no GPU work
/// off-screen), mute the mic (privacy), and arm a bounded auto-disconnect. The caller
/// (ContentView's scenePhase driver) gates this on the setting + `.streaming`; a no-op otherwise.
/// The video-drop seam is read by both pumps every iteration (`connection.isVideoDropped`).
func enterBackground(timeoutMinutes: Int) {
guard phase == .streaming, let conn = connection, !isBackgrounded else { return }
isBackgrounded = true
conn.setVideoDropped(true)
audio?.setMicMuted(true)
// Non-deliberate on fire (keep the host linger) so a user who returns late reconnects fast,
// exactly like today's network-drop path. min 1 minute guards a nonsense setting.
let minutes = max(1, timeoutMinutes)
backgroundDeadline = Date().addingTimeInterval(TimeInterval(minutes * 60))
let timer = DispatchSource.makeTimerSource(queue: .main)
timer.schedule(deadline: .now() + .seconds(minutes * 60))
timer.setEventHandler { [weak self] in
// The timer fires on `.main`, so the actor's executor is the main thread here.
MainActor.assumeIsolated { self?.disconnect(deliberate: false) }
}
backgroundTimer?.cancel()
backgroundTimer = timer
timer.resume()
}
/// Return to foreground: cancel the timeout, resume mic + video, and force a clean re-anchor
/// request a fresh IDR (infinite GOP: it won't come on its own) and let the pump's freeze gate
/// withhold the concealed frames until it lands (it auto-arms on the resumed frame-index gap).
func exitBackground() {
guard isBackgrounded else { return }
isBackgrounded = false
backgroundDeadline = nil
backgroundTimer?.cancel()
backgroundTimer = nil
audio?.setMicMuted(false)
if let conn = connection {
conn.setVideoDropped(false)
conn.requestKeyframe()
}
}
/// The user confirmed the fingerprint: returns it for pinning and enters streaming.
func confirmTrust() -> Data? {
guard case .awaitingTrust(let fingerprint) = phase else { return nil }
@@ -349,6 +401,11 @@ final class SessionModel: ObservableObject {
func disconnect(deliberate: Bool = true) {
statsTimer?.invalidate()
statsTimer = nil
// Drop any armed background keep-alive (incl. the timeout that just fired us).
backgroundTimer?.cancel()
backgroundTimer = nil
isBackgrounded = false
backgroundDeadline = nil
let audio = self.audio
self.audio = nil
// Gamepad capture is main-actor (releases held buttons on the wire while the
@@ -440,6 +440,34 @@ extension SettingsView {
}
}
/// iOS/iPadOS only: keep a backgrounded session alive (audio background mode). Empty elsewhere
/// (tvOS backgrounding semantics differ; macOS isn't gated by the mode) so the shared `.general`
/// detail can reference it unconditionally.
@ViewBuilder var keepAliveSection: some View {
#if os(iOS)
Section {
Toggle("Keep streaming in background", isOn: $backgroundKeepAlive)
if backgroundKeepAlive {
Picker("Disconnect after", selection: $backgroundTimeoutMinutes) {
Text("1 minute").tag(1)
Text("5 minutes").tag(5)
Text("10 minutes").tag(10)
Text("30 minutes").tag(30)
}
}
} header: {
Text("Background")
} footer: {
Text("Off by default: backgrounding the app freezes the session. When on, audio keeps "
+ "playing and the connection stays live (video is dropped to save power) after you "
+ "switch away — and the session auto-disconnects after the time above so it can't "
+ "run down your battery. Returning to the app resumes video instantly.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
#endif
}
@ViewBuilder var experimentalSection: some View {
Section {
Toggle("Show game library", isOn: $libraryEnabled)
@@ -49,6 +49,8 @@ struct SettingsView: View {
@ObservedObject var gamepads = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10
#if DEBUG && !os(tvOS)
@State var showControllerTest = false
#endif
@@ -242,6 +244,7 @@ struct SettingsView: View {
pointerSection
compositorSection
wakeSection
keepAliveSection // iOS-only content; empty on tvOS
}
.formStyle(.grouped)
.navigationTitle("General")
@@ -11,32 +11,13 @@
import Foundation
import PunktfunkKit
import SwiftUI
#if canImport(WidgetKit)
import WidgetKit
#endif
struct StoredHost: Identifiable, Codable, Hashable {
var id = UUID()
var name: String
var address: String
var port: UInt16 = 9777
/// SHA-256 of the host's certificate, set after the user explicitly trusted it.
var pinnedSHA256: Data?
/// Last time a streaming session actually started (nil until the first one).
var lastConnected: Date?
/// Management-API port for the library browser (distinct from the data-plane `port`). Optional
/// (NOT a defaulted non-optional) so older saved hosts whose JSON lacks this key still
/// decode: synthesized Decodable ignores property defaults but treats a missing Optional as
/// nil. Resolve via `effectiveMgmtPort`. (Auth is mTLS by the pinned identity no token.)
var mgmtPort: UInt16?
/// Wake-on-LAN MAC address(es) of the host's wake-capable NIC(s), each `aa:bb:cc:dd:ee:ff`.
/// Learned from the host's mDNS `mac` TXT record while it's awake and persisted here, so the
/// client can send a magic packet to wake the host later (when it's asleep and no longer
/// advertising). Optional (same forward-compat reason as `mgmtPort`); nil until first learned.
var macAddresses: [String]?
var displayName: String { name.isEmpty ? address : name }
var effectiveMgmtPort: UInt16 { mgmtPort ?? punktfunkDefaultMgmtPort }
/// Wake-capable, in a form the wake helper accepts (empty when none learned yet).
var wakeMacs: [String] { macAddresses ?? [] }
}
// `StoredHost` (the model + its JSON codec) now lives in PunktfunkShared so the widget extension
// can read the same store; PunktfunkKit re-exports it. The discovery-join helpers below stay here
// because they reference PunktfunkKit's `DiscoveredHost`/`HostDiscovery`.
extension StoredHost {
/// True when a live mDNS advert (`DiscoveredHost`) describes THIS saved host drives the
@@ -86,8 +67,14 @@ final class HostStore: ObservableObject {
/// never advertises still reads Online. Not persisted (it's live reachability, not config).
@Published var probedOnline: Set<StoredHost.ID> = []
/// The App-Group suite shared with the Widget/Live-Activity extension so a launcher widget
/// sees the same saved hosts. Falls back to `.standard` in an un-entitled process (see
/// `AppGroup.defaults`).
private let defaults = AppGroup.defaults
init() {
if let data = UserDefaults.standard.data(forKey: Self.key),
Self.migrateToAppGroupIfNeeded()
if let data = defaults.data(forKey: Self.key),
let decoded = try? JSONDecoder().decode([StoredHost].self, from: data) {
hosts = decoded
} else {
@@ -95,6 +82,20 @@ final class HostStore: ObservableObject {
}
}
/// One-time move of the saved-host JSON from `UserDefaults.standard` (where every build before
/// the App Group wrote it) into the shared suite. Idempotent: only fires when the suite has no
/// hosts yet but standard does. The old value is LEFT in place during a staged TestFlight
/// rollout an older build still reads `.standard`, so tombstoning it now would hide hosts from
/// the not-yet-updated app. Remove the standard copy a release later.
private static func migrateToAppGroupIfNeeded() {
let suite = AppGroup.defaults
let standard = UserDefaults.standard
guard suite !== standard else { return } // un-entitled fallback: nothing to migrate
guard suite.data(forKey: key) == nil,
let legacy = standard.data(forKey: key) else { return }
suite.set(legacy, forKey: key)
}
func add(_ host: StoredHost) {
hosts.append(host)
}
@@ -112,7 +113,7 @@ final class HostStore: ObservableObject {
func markConnected(_ hostID: UUID) {
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
hosts[i].lastConnected = Date()
hosts[i].lastConnected = Date() // didSet persist() writes the shared suite + reloads widget
}
/// One reachability sweep, driving `probedOnline`: probe every saved host NOT currently
@@ -158,7 +159,17 @@ final class HostStore: ObservableObject {
private func persist() {
if let data = try? JSONEncoder().encode(hosts) {
UserDefaults.standard.set(data, forKey: Self.key)
defaults.set(data, forKey: Self.key)
}
reloadHostsWidget() // the widget reads this store; any change refreshes its timeline
}
/// Ask WidgetKit to rebuild the hosts widget's timeline after any store change (add/remove/pin/
/// last-connected). iOS-only and a no-op where WidgetKit is absent; the widget uses
/// `.never`-refresh entries and relies on this push.
private func reloadHostsWidget() {
#if canImport(WidgetKit) && os(iOS)
WidgetCenter.shared.reloadTimelines(ofKind: "PunktfunkHosts")
#endif
}
}
@@ -180,6 +180,23 @@ public final class SessionAudio {
}
}
/// Background keep-alive: silence the mic uplink while backgrounded (privacy no room audio
/// leaves the device) and restore it on return. Pauses/resumes the capture engine; a no-op when
/// there's no uplink (playback-only / tvOS / mic disabled). The audio SESSION stays active for
/// background playback, so iOS may keep showing the recording indicator until a full reconfigure
/// this stops the actual capture, which is the privacy-relevant part. Main thread.
public func setMicMuted(_ muted: Bool) {
stateLock.lock()
let capture = captureEngine
stateLock.unlock()
guard let capture else { return }
if muted {
capture.pause()
} else if !flag.isStopped {
try? capture.start()
}
}
// MARK: - Playback (host speaker)
private func startPlayback(speakerUID: String) {
@@ -11,6 +11,9 @@
// LaunchSpec schema in `crates/punktfunk-host/src/library.rs`.
import Foundation
// `punktfunkDefaultMgmtPort` (and StoredHost/DefaultsKey) now live in PunktfunkShared so the
// dependency-free widget extension can share them; PunktfunkKit re-exports the module.
import PunktfunkShared
/// Cover art URLs (the public Steam CDN for Steam titles, user-supplied for custom entries).
public struct Artwork: Codable, Hashable, Sendable {
@@ -64,10 +67,6 @@ public enum LibraryError: LocalizedError {
}
}
/// The management API's default port adjacent to the GameStream block; matches
/// `mgmt::DEFAULT_PORT` on the host.
public let punktfunkDefaultMgmtPort: UInt16 = 47990
/// Stateless fetcher for a host's library.
public enum LibraryClient {
/// `GET https://<address>:<port>/api/v1/library`, authenticated by **mTLS**: the client
@@ -534,6 +534,23 @@ public final class PunktfunkConnection {
_ = punktfunk_connection_request_keyframe(h)
}
/// Background-keep-alive video drop (opt-in). While true, both video pumps keep DRAINING
/// `nextAU()` (so QUIC flow control and host pacing stay healthy) but DISCARD each AU before any
/// VideoToolbox/Metal decode or render the crash/jetsam-safe way to hold a backgrounded
/// session (audio keeps rendering; no GPU work off-screen). Set on `SessionModel.enterBackground`,
/// cleared on `exitBackground` (which then requests a fresh IDR; the pump's re-anchor gate
/// auto-arms on the resumed frame-index gap). Its own tiny lock read on the pump thread every
/// iteration, written on the main actor; never contends the ABI/plane locks.
private let videoDropLock = NSLock()
private var videoDropped = false
public var isVideoDropped: Bool {
videoDropLock.lock(); defer { videoDropLock.unlock() }
return videoDropped
}
public func setVideoDropped(_ dropped: Bool) {
videoDropLock.lock(); videoDropped = dropped; videoDropLock.unlock()
}
/// Feed each received AU's `frameIndex` (in receive order) so the client recovers from loss with a
/// cheap reference-frame invalidation instead of always paying for a full IDR. On a forward gap
/// a `frameIndex` jump means the intervening frames were lost and the following AUs reference a
@@ -23,6 +23,7 @@ import Combine
import CoreHaptics
import Foundation
import GameController
import PunktfunkShared
public final class GamepadFeedback {
private let connection: PunktfunkConnection
@@ -20,6 +20,7 @@
import Combine
import Foundation
import GameController
import PunktfunkShared
@MainActor
public final class GamepadManager: ObservableObject {
@@ -6,6 +6,7 @@
// the two combine without adding a second ObservableObject or an environment key nobody else needs.
import Foundation
import PunktfunkShared
public enum GamepadUIEnvironment {
/// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`);
@@ -19,6 +19,7 @@
#if os(iOS)
import Foundation
import PunktfunkCore
import PunktfunkShared
import UIKit
/// How touchscreen fingers drive the host persisted under `DefaultsKey.touchMode`, latched
@@ -0,0 +1,9 @@
// PunktfunkShared holds what the app AND the widget extension both need the stored-host model,
// the settings-key names, the App-Group constant, the deep-link grammar, and the Live Activity
// attributes in a module that links neither the Rust core nor the presentation layer.
//
// Re-export it so every existing consumer of PunktfunkKit (`import PunktfunkKit`) keeps seeing
// `StoredHost`, `DefaultsKey`, `punktfunkDefaultMgmtPort`, `DeepLink`, etc. with no call-site churn.
// (Files INSIDE PunktfunkKit still `import PunktfunkShared` explicitly Swift imports are
// file-scoped; the re-export only reaches downstream modules.)
@_exported import PunktfunkShared
@@ -8,6 +8,7 @@
// tap, InputCapture's captured-state S) cycle it directly.
import Foundation
import PunktfunkShared
/// How much of the streaming statistics overlay to show. The raw values are stable on disk
/// rename the cases freely, never the strings.
@@ -9,6 +9,7 @@
#if canImport(Metal) && canImport(QuartzCore)
import AVFoundation
import Foundation
import PunktfunkShared
import QuartzCore
#if os(tvOS)
import UIKit
@@ -38,6 +38,7 @@
import AVFoundation
import Foundation
import Metal
import PunktfunkShared
import QuartzCore
/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode
@@ -431,6 +432,15 @@ public final class Stage2Pipeline {
while alive, !token.isStopped {
alive = autoreleasepool { () -> Bool in
do {
// Background keep-alive: drain one AU (flow control + host pacing) and discard it
// BEFORE any VideoToolbox decode or Metal render no GPU work off-screen. The
// decoder session is left intact; exitBackground requests a fresh IDR and the
// re-anchor gate arms on the resumed frame-index gap so concealed frames are
// withheld until it lands.
if connection.isVideoDropped {
_ = try connection.nextAU(timeoutMs: 100)
return true
}
// Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the
// decoder conceals the reference-missing deltas often WITHOUT an error callback
// so key off the drop count climbing, then keep asking (awaitingIDR) until a fresh
@@ -686,6 +696,13 @@ public final class Stage2Pipeline {
while alive, !token.isStopped {
alive = autoreleasepool { () -> Bool in
do {
// Background keep-alive: drain + discard before the Metal wavelet decode
// (PyroWave is all-intra, so the resumed frame heals on its own no IDR
// request needed, just no GPU work off-screen).
if connection.isVideoDropped {
_ = try connection.nextAU(timeoutMs: 100)
return true
}
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
onFrame?(au)
if let newest = newestIndex,
@@ -63,6 +63,14 @@ final class StreamPump {
while alive, !token.isStopped {
alive = autoreleasepool { () -> Bool in
do {
// Background keep-alive: drain one AU to keep QUIC flow control + host pacing
// healthy, then discard it BEFORE any decode/enqueue no VideoToolbox/Metal work
// off-screen. Skips all recovery/gate bookkeeping too; exitBackground requests a
// fresh IDR and the re-anchor gate re-arms on the resumed frame-index gap.
if connection.isVideoDropped {
_ = try connection.nextAU(timeoutMs: 100)
return true
}
// Loss recovery (the primary path). Under the host's infinite GOP the only
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs
// (framesDropped); the decoder then *conceals* the reference-missing deltas a
@@ -19,6 +19,7 @@
#if os(macOS)
import AppKit
import AVFoundation
import PunktfunkShared
import SwiftUI
import os
@@ -35,6 +35,7 @@
import AVFoundation
import GameController
import PunktfunkCore
import PunktfunkShared
import SwiftUI
import UIKit
import os
@@ -0,0 +1,23 @@
// The App-Group foundation shared by the app and its extensions (Widgets / Live Activity).
//
// PunktfunkShared is deliberately dependency-free: it links NEITHER PunktfunkKit (which drags in
// the Rust staticlib + presentation layer) NOR any Apple UI framework. A widget process gets ~30 MB,
// so everything an extension needs the stored-host model + its JSON codec, the settings-key names,
// the deep-link grammar, and (later) the Live Activity attributes lives here and here only.
import Foundation
/// The one App-Group identifier, matched by `Config/*.entitlements`
/// (`com.apple.security.application-groups`). Registered on the developer portal for both the app
/// id (`io.unom.punktfunk`) and the widget extension id (`io.unom.punktfunk.widgets`).
public enum AppGroup {
public static let suiteName = "group.io.unom.punktfunk"
/// The shared defaults suite. Non-nil in a correctly-entitled process; falls back to
/// `.standard` if the group is somehow unavailable (unsigned `swift run`, a misprovisioned
/// build) so the app still functions single-process rather than crashing the widget just
/// won't see the same store there.
public static var defaults: UserDefaults {
UserDefaults(suiteName: suiteName) ?? .standard
}
}
@@ -0,0 +1,57 @@
// The `punktfunk://` deep-link grammar the single builder/parser shared by the widget (which
// emits links via `widgetURL`/`Link`) and the app (`ContentView.onOpenURL`, which routes them into
// the existing connect path). Keeping both sides on one type means the wire format can't drift.
//
// Grammar (v1):
// punktfunk://connect/<host-uuid> connect to a stored host
// punktfunk://connect/<host-uuid>?launch=<GameEntry.id> connect and ask the host to launch it
//
// `launch` carries a `GameEntry.id` (e.g. "steam:570"); it is percent-encoded on build and decoded
// on parse, so ids with reserved characters survive the round trip.
import Foundation
public enum DeepLink: Equatable {
/// Connect to a saved host; `launchID` is a `GameEntry.id` to launch on arrival, if any.
case connect(host: UUID, launchID: String?)
public static let scheme = "punktfunk"
/// Build the canonical URL for a route. Non-optional: every route is representable.
public var url: URL {
switch self {
case let .connect(host, launchID):
var comps = URLComponents()
comps.scheme = Self.scheme
comps.host = "connect"
comps.path = "/\(host.uuidString)"
if let launchID, !launchID.isEmpty {
comps.queryItems = [URLQueryItem(name: "launch", value: launchID)]
}
// URLComponents percent-encodes the query value; force-unwrap is safe for a URL we
// fully control (scheme/host/path are all valid).
return comps.url!
}
}
/// Parse an incoming URL, or nil if it isn't a recognized `punktfunk://` route. Tolerant of
/// case in the scheme and of a trailing slash on the path.
public init?(_ url: URL) {
guard url.scheme?.lowercased() == Self.scheme else { return nil }
guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
switch comps.host?.lowercased() {
case "connect":
// Path is "/<uuid>"; strip the leading slash and any trailing one.
let raw = comps.path
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let host = UUID(uuidString: raw) else { return nil }
let launch = comps.queryItems?
.first(where: { $0.name == "launch" })?.value
.flatMap { $0.isEmpty ? nil : $0 }
self = .connect(host: host, launchID: launch)
default:
return nil
}
}
}
@@ -1,7 +1,8 @@
// One source of truth for the client's UserDefaults / @AppStorage keys. A magic-string key
// duplicated across a setting's writer (a Settings @AppStorage) and reader (e.g. a stream view
// reading UserDefaults) splits silently on a typo the setting just stops taking effect. These
// live in PunktfunkKit because both the app and the kit's views read them.
// live in the dependency-free PunktfunkShared module (re-exported by PunktfunkKit) because the app,
// the kit's views, AND the widget extension all read them the widget needs `DefaultsKey.hosts`.
import Foundation
@@ -111,6 +112,16 @@ public enum DefaultsKey {
/// routed/VPN host), so connects go straight through instead of waiting out the wake timeout.
/// The explicit "Wake Host" action stays available regardless. Read by ContentView.startSession.
public static let autoWake = "punktfunk.autoWake"
/// iOS/iPadOS: keep a streaming session ALIVE when the app is backgrounded (audio background
/// mode). Off by default (today's freeze-on-background is the default). When on, backgrounding a
/// live session keeps audio playing and the QUIC/pump live while DROPPING video decode, and a
/// bounded timer (`backgroundTimeoutMinutes`) auto-disconnects if the user doesn't return. Read
/// by ContentView's scenePhase driver. Hidden on tvOS/macOS.
public static let backgroundKeepAlive = "punktfunk.backgroundKeepAlive"
/// iOS/iPadOS: minutes a backgrounded keep-alive session runs before auto-disconnecting (a
/// battery/thermal/bandwidth backstop). Default 10; the UI offers 1/5/10/30. The auto-disconnect
/// is non-deliberate (host linger kept), so a late return reconnects fast. Read on enterBackground.
public static let backgroundTimeoutMinutes = "punktfunk.backgroundTimeoutMinutes"
}
extension Notification.Name {
@@ -120,4 +131,15 @@ extension Notification.Name {
/// menus) it exists so the menu item is honest whenever it CAN fire, and as the shortcut's
/// discoverable menu-bar surface.
public static let punktfunkReleaseCapture = Notification.Name("io.unom.punktfunk.release-capture")
/// Posted by the Live Activity's / Shortcuts' End-stream intent (`EndStreamIntent.perform`,
/// which runs in the app's process): the app tears the active session down deliberately
/// (quit-close the host). Same cross-process-signal pattern as `punktfunkReleaseCapture`
/// the intent lives in PunktfunkShared and can't reach the app's `SessionModel` directly.
public static let punktfunkEndActiveSession = Notification.Name("io.unom.punktfunk.end-active-session")
/// Posted by the Connect App Intent (Siri/Shortcuts) with a `punktfunk://` URL as `object`:
/// the app routes it through the SAME `.onOpenURL` handler a widget tap uses (one router, one
/// set of guards). The intent uses `openAppWhenRun`, so the app is foregrounded to receive it.
public static let punktfunkOpenDeepLink = Notification.Name("io.unom.punktfunk.open-deep-link")
}
@@ -0,0 +1,56 @@
// The saved-host as an App Intents entity the parameter type for the Connect/Wake intents and
// the configurable single-host widget. Lives in the shared module (not the app) because widget
// *configuration* intents execute in the EXTENSION process, so the entity can't be app-only.
//
// AppIntents is genuinely available on macOS (13+), so this is gated on `canImport(AppIntents)`
// (unlike ActivityKit, whose macOS types are unavailable) it compiles on every platform and the
// entity query reads the same shared App-Group store the widget does.
#if canImport(AppIntents)
import AppIntents
import Foundation
public struct HostEntity: AppEntity, Identifiable {
public static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Host")
public static let defaultQuery = HostEntityQuery()
public let id: UUID
public let name: String
public init(id: UUID, name: String) {
self.id = id
self.name = name
}
public init(_ host: StoredHost) {
self.id = host.id
self.name = host.displayName
}
public var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(name)")
}
}
public struct HostEntityQuery: EntityQuery {
public init() {}
public func entities(for identifiers: [UUID]) async throws -> [HostEntity] {
Self.loadHosts().filter { identifiers.contains($0.id) }.map(HostEntity.init)
}
/// Sorted most-recent first Siri/Shortcuts and the widget config picker suggest recent hosts.
public func suggestedEntities() async throws -> [HostEntity] {
Self.loadHosts().map(HostEntity.init)
}
static func loadHosts() -> [StoredHost] {
guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts),
let hosts = try? JSONDecoder().decode([StoredHost].self, from: data)
else { return [] }
return hosts.sorted {
($0.lastConnected ?? .distantPast) > ($1.lastConnected ?? .distantPast)
}
}
}
#endif
@@ -0,0 +1,68 @@
// The Live Activity's attributes the ONE type that must be identical in the app (which starts
// and updates the Activity) and the widget extension (which renders it). Hence it lives in the
// dependency-free shared module.
//
// Gated on `os(iOS)`, NOT `canImport(ActivityKit)`: ActivityKit *imports* on macOS but its types
// are `@available(macOS, unavailable)`, so canImport would wrongly admit this on the macOS build.
// Live Activities are iPhone/iPad only (iPadOS reports os(iOS)).
//
// Naming/shape is a runtime contract: an Activity started by one build is decoded by the extension
// of the same build, so keep `ContentState` Codable-stable across releases the way `StoredHost` is.
#if os(iOS)
import ActivityKit
import Foundation
public struct PunktfunkSessionAttributes: ActivityAttributes {
// Static for the Activity's whole life (set at request time).
public let hostID: UUID
public let hostName: String
/// The title of the launched game, if the session started from the library; nil for a plain
/// host connect (nothing tracks the live foreground app mid-session).
public let launchTitle: String?
public init(hostID: UUID, hostName: String, launchTitle: String?) {
self.hostID = hostID
self.hostName = hostName
self.launchTitle = launchTitle
}
public struct ContentState: Codable, Hashable {
public enum Stage: String, Codable, Hashable {
case streaming // foreground, live
case background // backgrounded keep-alive (countdown running)
case reconnecting // post-loss re-anchor hold
case ending // torn down final state before dismissal
}
public var stage: Stage
/// Session start drives `Text(timerInterval:)` for a free client-side ticking clock (no
/// per-second push needed).
public var startedAt: Date
/// e.g. "2560×1440 @120 · HEVC · HDR". Updated only when it actually changes.
public var modeLine: String
/// Coarse, updated sparsely (every ~30 s) never the 1 Hz stats firehose.
public var latencyMs: Int?
public var mbps: Double?
/// While backgrounded: when the keep-alive auto-disconnect fires drives the countdown.
public var backgroundDeadline: Date?
public init(
stage: Stage, startedAt: Date, modeLine: String,
latencyMs: Int? = nil, mbps: Double? = nil, backgroundDeadline: Date? = nil
) {
self.stage = stage
self.startedAt = startedAt
self.modeLine = modeLine
self.latencyMs = latencyMs
self.mbps = mbps
self.backgroundDeadline = backgroundDeadline
}
}
}
/// Kind string for the Live Activity kept next to the attributes so app + extension agree.
public enum PunktfunkActivity {
public static let kind = "PunktfunkSession"
}
#endif
@@ -0,0 +1,30 @@
// App Intents that must compile into BOTH the app and the widget extension live here in the shared
// module. Today that's `EndStreamIntent` the Live Activity's "End stream" button (a
// LiveActivityIntent runs in the APP's process) which M4 also surfaces to Siri/Shortcuts.
//
// Gated on os(iOS): LiveActivityIntent is part of ActivityKit's world (iPhone/iPad only). The M4
// Connect/Wake intents that need the app's router live in the app target, not here.
#if os(iOS)
import AppIntents
import Foundation
/// Ends the active streaming session. Backs the Live Activity's End button and the Shortcuts /
/// Siri "End the Punktfunk stream" phrase. `perform()` runs in the app's process (LiveActivityIntent)
/// it posts `.punktfunkEndActiveSession`, which the app's SessionModel owner observes and turns
/// into `disconnect(deliberate: true)` (the user explicitly ended it quit-close the host).
@available(iOS 17.0, *)
public struct EndStreamIntent: LiveActivityIntent {
public static let title: LocalizedStringResource = "End Punktfunk Stream"
public static let description = IntentDescription("Ends the active Punktfunk streaming session.")
public init() {}
public func perform() async throws -> some IntentResult {
await MainActor.run {
NotificationCenter.default.post(name: .punktfunkEndActiveSession, object: nil)
}
return .result()
}
}
#endif
@@ -0,0 +1,57 @@
// The saved-host model + its on-disk JSON wire format the widget/extension depends on BOTH, so
// they live in the dependency-free shared module. The `ObservableObject` store that wraps them
// (`HostStore`, with add/remove/pin/reachability) stays in the app target; discovery-join helpers
// (`matches`, `advertises`) stay there too because they reference PunktfunkKit's `DiscoveredHost`.
//
// Wire-format stability: the JSON encoding of `StoredHost` is now a shared contract between the app
// (writer) and the widget (reader). The `PunktfunkSharedTests` codec round-trip pins it do not
// rename the coding keys or make a stored `Optional` non-optional (older saved JSON must still
// decode; synthesized Decodable treats a missing Optional as nil).
import Foundation
/// The management-API port default (distinct from the data-plane `port`). Lives here (not in
/// PunktfunkKit's LibraryClient, which re-exports it) so `StoredHost.effectiveMgmtPort` can resolve
/// it without the shared module taking a dependency on the kit.
public let punktfunkDefaultMgmtPort: UInt16 = 47990
public struct StoredHost: Identifiable, Codable, Hashable {
public var id = UUID()
public var name: String
public var address: String
public var port: UInt16 = 9777
/// SHA-256 of the host's certificate, set after the user explicitly trusted it.
public var pinnedSHA256: Data?
/// Last time a streaming session actually started (nil until the first one).
public var lastConnected: Date?
/// Management-API port for the library browser (distinct from the data-plane `port`). Optional
/// (NOT a defaulted non-optional) so older saved hosts whose JSON lacks this key still
/// decode: synthesized Decodable ignores property defaults but treats a missing Optional as
/// nil. Resolve via `effectiveMgmtPort`. (Auth is mTLS by the pinned identity no token.)
public var mgmtPort: UInt16?
/// Wake-on-LAN MAC address(es) of the host's wake-capable NIC(s), each `aa:bb:cc:dd:ee:ff`.
/// Learned from the host's mDNS `mac` TXT record while it's awake and persisted here, so the
/// client can send a magic packet to wake the host later (when it's asleep and no longer
/// advertising). Optional (same forward-compat reason as `mgmtPort`); nil until first learned.
public var macAddresses: [String]?
public init(
id: UUID = UUID(), name: String, address: String, port: UInt16 = 9777,
pinnedSHA256: Data? = nil, lastConnected: Date? = nil, mgmtPort: UInt16? = nil,
macAddresses: [String]? = nil
) {
self.id = id
self.name = name
self.address = address
self.port = port
self.pinnedSHA256 = pinnedSHA256
self.lastConnected = lastConnected
self.mgmtPort = mgmtPort
self.macAddresses = macAddresses
}
public var displayName: String { name.isEmpty ? address : name }
public var effectiveMgmtPort: UInt16 { mgmtPort ?? punktfunkDefaultMgmtPort }
/// Wake-capable, in a form the wake helper accepts (empty when none learned yet).
public var wakeMacs: [String] { macAddresses ?? [] }
}
@@ -0,0 +1,89 @@
// PunktfunkShared is now a wire contract between the app (writer) and the widget extension +
// deep-link senders (readers). These pin the two formats that cross that boundary:
// the `StoredHost` JSON codec the widget decodes the exact bytes the app persisted, and
// older saved JSON (missing `mgmtPort` / `macAddresses`) must still decode;
// the `punktfunk://` deep-link grammar the widget builds URLs the app parses.
import XCTest
@testable import PunktfunkKit
import PunktfunkShared
final class SharedFoundationTests: XCTestCase {
// MARK: - StoredHost JSON codec
func testStoredHostRoundTrips() throws {
let host = StoredHost(
id: UUID(uuidString: "11111111-2222-3333-4444-555555555555")!,
name: "Tower", address: "192.168.1.173", port: 9777,
pinnedSHA256: Data([0xDE, 0xAD, 0xBE, 0xEF]),
lastConnected: Date(timeIntervalSince1970: 1_700_000_000),
mgmtPort: 47990, macAddresses: ["aa:bb:cc:dd:ee:ff"])
let data = try JSONEncoder().encode(host)
let decoded = try JSONDecoder().decode(StoredHost.self, from: data)
XCTAssertEqual(decoded, host)
}
/// Older saved hosts predate `mgmtPort`/`macAddresses` a missing key must decode to nil, not
/// throw (synthesized Decodable treats a missing Optional as nil). This is the forward-compat
/// guarantee the widget depends on when reading a store written by any prior build.
func testStoredHostDecodesLegacyJSONWithoutOptionalKeys() throws {
let json = """
{"id":"11111111-2222-3333-4444-555555555555","name":"Old","address":"10.0.0.5","port":9777}
""".data(using: .utf8)!
let decoded = try JSONDecoder().decode(StoredHost.self, from: json)
XCTAssertEqual(decoded.name, "Old")
XCTAssertNil(decoded.mgmtPort)
XCTAssertNil(decoded.macAddresses)
XCTAssertNil(decoded.pinnedSHA256)
XCTAssertNil(decoded.lastConnected)
// Resolvers fall back cleanly.
XCTAssertEqual(decoded.effectiveMgmtPort, punktfunkDefaultMgmtPort)
XCTAssertEqual(decoded.wakeMacs, [])
XCTAssertEqual(decoded.displayName, "Old")
}
func testStoredHostDisplayNameFallsBackToAddress() {
let host = StoredHost(name: "", address: "10.0.0.9")
XCTAssertEqual(host.displayName, "10.0.0.9")
}
// MARK: - DeepLink grammar
func testDeepLinkConnectRoundTrips() {
let id = UUID()
let link = DeepLink.connect(host: id, launchID: nil)
let parsed = DeepLink(link.url)
XCTAssertEqual(parsed, link)
XCTAssertEqual(parsed, .connect(host: id, launchID: nil))
}
func testDeepLinkConnectWithLaunchRoundTrips() {
let id = UUID()
// A store-qualified GameEntry.id with a reserved char must survive percent-encoding.
let launch = "steam:570"
let link = DeepLink.connect(host: id, launchID: launch)
XCTAssertEqual(DeepLink(link.url), .connect(host: id, launchID: launch))
}
func testDeepLinkParsesCanonicalString() throws {
let id = UUID()
let url = try XCTUnwrap(URL(string: "punktfunk://connect/\(id.uuidString)"))
XCTAssertEqual(DeepLink(url), .connect(host: id, launchID: nil))
}
func testDeepLinkRejectsForeignSchemeAndBadHost() throws {
let id = UUID()
XCTAssertNil(DeepLink(try XCTUnwrap(URL(string: "https://connect/\(id.uuidString)"))))
XCTAssertNil(DeepLink(try XCTUnwrap(URL(string: "punktfunk://connect/not-a-uuid"))))
XCTAssertNil(DeepLink(try XCTUnwrap(URL(string: "punktfunk://bogus/\(id.uuidString)"))))
}
func testDeepLinkSchemeIsCaseInsensitive() throws {
let id = UUID()
let url = try XCTUnwrap(URL(string: "PUNKTFUNK://connect/\(id.uuidString)"))
XCTAssertEqual(DeepLink(url), .connect(host: id, launchID: nil))
}
}
+2 -2
View File
@@ -60,7 +60,7 @@ impl AudioPlayer {
.spawn(move || {
if let Err(e) = render_thread(pcm_rx, recycle_tx, stop_t, ready_tx, channels as u8)
{
tracing::warn!(error = format!("{e:#}"), "audio playback thread ended");
tracing::warn!(error = %format!("{e:#}"), "audio playback thread ended");
}
})
.context("spawn audio thread")?;
@@ -232,7 +232,7 @@ impl MicStreamer {
.name("punktfunk-mic".into())
.spawn(move || {
if let Err(e) = mic_thread(&connector, stop_t) {
tracing::warn!(error = format!("{e:#}"), "mic uplink thread ended");
tracing::warn!(error = %format!("{e:#}"), "mic uplink thread ended");
}
})
.context("spawn mic thread")?;
+1 -1
View File
@@ -1493,7 +1493,7 @@ impl Worker {
Err(e) => {
tracing::warn!(pad = slot.index, low, high, error = %e, "rumble: SDL set_rumble failed")
}
Ok(()) => tracing::debug!(pad = slot.index, low, high, "rumble: rendered"),
Ok(()) => tracing::trace!(pad = slot.index, low, high, "rumble: rendered"),
}
slot.rumble.last = (low, high);
slot.rumble.last_at = Some(Instant::now());
+1 -66
View File
@@ -125,7 +125,7 @@ pub fn agent(
.with_safe_default_protocol_versions()
.map_err(|e| bad("tls config", &e))?
.dangerous()
.with_custom_certificate_verifier(Arc::new(PinVerify { pin }));
.with_custom_certificate_verifier(Arc::new(punktfunk_core::tls::PinVerify::new(pin)));
let cert = rustls::pki_types::CertificateDer::from_pem_slice(identity.0.as_bytes())
.map_err(|e| bad("client cert pem", &e))?;
let key = rustls::pki_types::PrivateKeyDer::from_pem_slice(identity.1.as_bytes())
@@ -251,71 +251,6 @@ fn classify(e: ureq::Error) -> LibraryError {
}
}
/// Fingerprint-pinning verifier — the client-HTTP twin of core's (private) QUIC
/// `PinVerify`: trust is the SHA-256 of the host's self-signed leaf cert. The handshake
/// signatures MUST still be verified for real: CertificateVerify is what proves the peer
/// *holds the pinned cert's private key* — skip it and an active MITM can replay the
/// host's (public) certificate, match the pin, and complete the handshake with its own key.
#[derive(Debug)]
struct PinVerify {
pin: Option<[u8; 32]>,
}
impl rustls::client::danger::ServerCertVerifier for PinVerify {
fn verify_server_cert(
&self,
end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
if let Some(expected) = self.pin {
let fp = punktfunk_core::quic::endpoint::cert_fingerprint(end_entity.as_ref());
if fp != expected {
return Err(rustls::Error::InvalidCertificate(
rustls::CertificateError::ApplicationVerificationFailure,
));
}
}
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
#[cfg(test)]
mod tests {
use super::*;
+2 -2
View File
@@ -222,7 +222,7 @@ fn pump(
preferred = punktfunk_core::quic::CODEC_PYROWAVE;
} else {
tracing::warn!(
"PUNKTFUNK_PREFER_PYROWAVE=1 but the presenter device failed the pyrowave probe — keeping the normal codec preference"
"PUNKTFUNK_PREFER_PYROWAVE=1 but the presenter device failed the pyrowave probe — keeping the normal codec preference"
);
}
}
@@ -791,7 +791,7 @@ fn spawn_audio(
buf.extend_from_slice(&pcm[..n]);
player.push(buf);
}
Err(e) => tracing::debug!(error = %e, "opus decode"),
Err(e) => tracing::debug!(error = %e, "opus decode failed"),
}
}
Err(PunktfunkError::NoFrame) => {}
+5 -5
View File
@@ -510,7 +510,7 @@ impl Decoder {
if choice == "vaapi" {
return Err(e.context("PUNKTFUNK_DECODER=vaapi but VAAPI failed"));
}
tracing::info!(reason = %e, "VAAPI unavailable — software decode");
tracing::warn!(error = %e, "VAAPI unavailable — falling back to software decode");
}
}
}
@@ -695,8 +695,8 @@ impl Decoder {
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
self.vaapi_fails = 0;
} else {
tracing::warn!(error = %e,
"{which} decode error — requesting keyframe, keeping hardware decode");
tracing::debug!(backend = which, error = %e,
"decode error — requesting keyframe, keeping hardware decode");
}
Ok(None)
}
@@ -1653,7 +1653,7 @@ unsafe extern "C" fn pick_vulkan(
&mut fr,
);
if r < 0 || fr.is_null() {
tracing::warn!("avcodec_get_hw_frames_parameters(VULKAN) failed ({r})");
tracing::warn!(code = r, "avcodec_get_hw_frames_parameters(VULKAN) failed");
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
}
let fc = (*fr).data as *mut ffi::AVHWFramesContext;
@@ -1665,7 +1665,7 @@ unsafe extern "C" fn pick_vulkan(
as _;
let r = ffi::av_hwframe_ctx_init(fr);
if r < 0 {
tracing::warn!("av_hwframe_ctx_init(VULKAN) failed ({r})");
tracing::warn!(code = r, "av_hwframe_ctx_init(VULKAN) failed");
let mut fr = fr;
ffi::av_buffer_unref(&mut fr);
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
+4 -3
View File
@@ -537,7 +537,8 @@ impl PyroWaveDecoder {
let fence = device.create_fence(&vk::FenceCreateInfo::default(), None)?;
tracing::info!(
mode = %format!("{width}x{height}"),
width,
height,
"PyroWave decoder open on the presenter's device (compute iDWT, BT.709 limited)"
);
Ok(PyroWaveDecoder {
@@ -602,8 +603,8 @@ impl PyroWaveDecoder {
});
self.next = 0;
tracing::info!(
from = %format!("{}x{}", self.width, self.height),
to = %format!("{width}x{height}"),
from = %format_args!("{}x{}", self.width, self.height),
to = %format_args!("{width}x{height}"),
"PyroWave decoder rebuilt for mid-stream resize"
);
self.width = width;
+1 -1
View File
@@ -101,7 +101,7 @@ pub fn import(
// EGL could leave an INVALID modifier to the driver's implied choice; explicit-
// modifier images can't — LINEAR is the only honest guess (debug-visible if wrong).
let modifier = if frame.modifier == DRM_FORMAT_MOD_INVALID {
tracing::debug!("dmabuf carried no explicit modifier — importing as LINEAR");
tracing::trace!("dmabuf carried no explicit modifier — importing as LINEAR");
DRM_FORMAT_MOD_LINEAR
} else {
frame.modifier
+19 -3
View File
@@ -184,6 +184,11 @@ struct StreamState {
// Hardware-path health: a failure streak (or a device with no import support at
// all) demotes the decoder to software via the shared flag — once per session.
dmabuf_demoted: bool,
/// PyroWave present has no demote rung (nothing else decodes the codec), so a
/// persistent non-device-lost present failure would warn on every frame. Latch it:
/// warn on the first failure of a streak, then stay quiet until a present succeeds.
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
pyro_present_warned: bool,
hw_fails: u32,
/// The OSD's text (multi-line; rebuilt each Stats window and on a live tier cycle).
osd_text: String,
@@ -255,6 +260,8 @@ impl StreamState {
win_start: Instant::now(),
presented: PresentedWindow::default(),
dmabuf_demoted: false,
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
pyro_present_warned: false,
hw_fails: 0,
osd_text: String::new(),
last_stats: None,
@@ -542,7 +549,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
fullscreen = !fullscreen;
tracing::debug!(fullscreen, "fullscreen toggle");
if let Err(e) = window.set_fullscreen(fullscreen) {
tracing::warn!(error = %e, "fullscreen toggle");
tracing::warn!(error = %e, fullscreen, "failed to toggle fullscreen");
}
continue;
}
@@ -985,13 +992,22 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
FrameInput::PyroWave(f),
overlay_frame.as_ref(),
) {
Ok(p) => p,
Ok(p) => {
st.pyro_present_warned = false;
p
}
Err(e) => {
if device_lost(&e) {
return Err(e)
.context("GPU device lost — the session cannot continue");
}
tracing::warn!(error = %format!("{e:#}"), "pyrowave present failed");
if !st.pyro_present_warned {
st.pyro_present_warned = true;
tracing::warn!(
error = %format!("{e:#}"),
"pyrowave present failed — suppressing repeats until it recovers"
);
}
false
}
}
+5 -1
View File
@@ -17,9 +17,13 @@ crate-type = ["lib", "cdylib", "staticlib"]
[features]
default = []
# Cert-fingerprint pinning shared across the clients (the one `PinVerify` in `tls.rs`). Light:
# rustls + sha2 only, no QUIC runtime — so a lean consumer (the tray's loopback status poll) can
# pin the host cert without pulling tokio/quinn. The heavier `quic` feature builds on top of it.
tls = ["dep:rustls", "dep:sha2", "dep:rustls-pki-types"]
# Control-plane QUIC (pairing, config, reverse audio). tokio is permitted ONLY here,
# never on the per-frame hot path. Off by default so the core stays runtime-free.
quic = ["dep:quinn", "dep:tokio", "dep:rustls", "dep:rcgen", "dep:rustls-pki-types", "dep:sha2", "dep:hmac", "dep:spake2", "dep:opus"]
quic = ["tls", "dep:quinn", "dep:tokio", "dep:rcgen", "dep:hmac", "dep:spake2", "dep:opus"]
[dependencies]
reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the wall-breaker (P2)
+5 -1
View File
@@ -1941,7 +1941,11 @@ async fn worker_main(args: WorkerArgs) {
ResyncStep::Idle => {}
}
} else {
tracing::warn!("unknown control message — ignoring");
tracing::warn!(
tag = ?msg.first(),
len = msg.len(),
"unknown control message — ignoring"
);
}
}
}
+2
View File
@@ -42,6 +42,8 @@ pub mod reanchor;
pub mod reject;
pub mod session;
pub mod stats;
#[cfg(feature = "tls")]
pub mod tls;
pub mod transport;
pub mod wol;
+10 -77
View File
@@ -1,3 +1,6 @@
//! Shared QUIC endpoint construction (host + client) and transport tuning — keep-alive and idle
//! timeout, plus the certificate-fingerprint helpers. The cert-pinning verifier itself is the
//! one shared [`crate::tls::PinVerify`]; this module only wires it into the client endpoint.
use std::sync::{Arc, Mutex};
/// Shared QUIC transport tuning for BOTH the host and client endpoints. Keep-alive is the
@@ -130,11 +133,10 @@ pub fn peer_fingerprint(conn: &quinn::Connection) -> Option<[u8; 32]> {
certs.first().map(|c| cert_fingerprint(c.as_ref()))
}
/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin.
pub fn cert_fingerprint(cert_der: &[u8]) -> [u8; 32] {
use sha2::Digest;
sha2::Sha256::digest(cert_der).into()
}
/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin. The implementation is
/// [`crate::tls::cert_fingerprint`] (shared with the HTTP clients); re-exported here so callers
/// reaching it as `quic::endpoint::cert_fingerprint` keep working.
pub use crate::tls::cert_fingerprint;
/// Fingerprint of a PEM-encoded certificate (what a host logs/shows for pairing UX —
/// must match what the client's verifier computes from the DER on the wire).
@@ -178,10 +180,10 @@ pub fn client_pinned_with_identity(
let _ = rustls::crypto::ring::default_provider().install_default();
let builder = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(PinVerify {
.with_custom_certificate_verifier(Arc::new(crate::tls::PinVerify::with_observed(
pin,
observed: observed.clone(),
}));
observed.clone(),
)));
let mut rustls_cfg = match identity {
None => builder.with_no_client_auth(),
Some((cert_pem, key_pem)) => {
@@ -232,9 +234,6 @@ pub mod anyhow_result {
}
}
/// Fingerprint-pinning verifier: trust is the SHA-256 of the host's (self-signed) leaf
/// cert, not a CA chain. With no pin it accepts any cert (TOFU) but still records what
/// it saw, so the embedder can persist the fingerprint and pin it from then on.
/// Server-side client-cert verifier: accept any (self-signed) client certificate but
/// verify the handshake signature for real — possession of the presented cert's key is
/// what makes the post-handshake fingerprint ([`peer_fingerprint`]) meaningful.
@@ -294,69 +293,3 @@ impl rustls::server::danger::ClientCertVerifier for AcceptAnyClientCert {
.supported_schemes()
}
}
#[derive(Debug)]
struct PinVerify {
pin: Option<[u8; 32]>,
observed: Arc<Mutex<Option<[u8; 32]>>>,
}
impl rustls::client::danger::ServerCertVerifier for PinVerify {
fn verify_server_cert(
&self,
end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp: &[u8],
_now: rustls::pki_types::UnixTime,
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
let fp = cert_fingerprint(end_entity.as_ref());
*self.observed.lock().unwrap() = Some(fp);
if let Some(expected) = self.pin {
if fp != expected {
return Err(rustls::Error::InvalidCertificate(
rustls::CertificateError::ApplicationVerificationFailure,
));
}
}
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
// The handshake signatures MUST be verified for real even though we pin the cert:
// CertificateVerify is what proves the peer *holds the pinned cert's private key* —
// skip it and an active MITM can replay the host's (public) certificate, match the
// pin, and complete the handshake with its own key.
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
+2
View File
@@ -1,3 +1,5 @@
//! Length-prefixed framing for QUIC control-stream messages: a `u16` length header followed by the
//! payload, bounded at 64 KiB (control messages are tiny).
/// Read one framed message (bounded at 64 KiB — control messages are tiny).
pub async fn read_msg(recv: &mut quinn::RecvStream) -> std::io::Result<Vec<u8>> {
let mut len = [0u8; 2];
+2
View File
@@ -1,3 +1,5 @@
//! SPAKE2 password-authenticated key exchange for pairing: derive a shared key from the PIN and
//! confirm it against both certificate fingerprints.
use crate::error::{PunktfunkError, Result};
use hmac::{Hmac, Mac};
use spake2::{Ed25519Group, Identity, Password, Spake2};
+164
View File
@@ -0,0 +1,164 @@
//! Shared TLS trust primitives for the punktfunk clients: the certificate-fingerprint hash and
//! the one canonical fingerprint-pinning [`ServerCertVerifier`](rustls::client::danger::ServerCertVerifier)
//! (`PinVerify`). Trust across the whole system is the SHA-256 of the host's self-signed leaf cert
//! (TOFU-pinned), not a CA chain — and this verifier is what the QUIC connect, the game-library
//! HTTP client, and the tray status poll all share, instead of hand-rolling it three times on a
//! trust boundary. Behind the light `tls` feature (rustls + sha2 only — no QUIC runtime), which
//! the heavier `quic` feature pulls in.
use std::sync::{Arc, Mutex};
/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin. Re-exported as
/// `crate::quic::endpoint::cert_fingerprint` for callers that already reach it there.
pub fn cert_fingerprint(cert_der: &[u8]) -> [u8; 32] {
use sha2::Digest;
sha2::Sha256::digest(cert_der).into()
}
/// Fingerprint-pinning verifier: trust is the SHA-256 of the host's (self-signed) leaf cert,
/// not a CA chain.
///
/// - `pin = Some(sha256)` rejects any host whose leaf doesn't hash to `sha256`.
/// - `pin = None` accepts any leaf (trust-on-first-use) — pair with [`with_observed`](Self::with_observed)
/// to record what was seen so the embedder can persist it and pin it from then on.
///
/// The handshake signatures are ALWAYS verified for real even though the cert is pinned:
/// `CertificateVerify` is what proves the peer *holds the pinned cert's private key* — skip it and
/// an active MITM can replay the host's (public) certificate, match the pin, and complete the
/// handshake with its own key.
#[derive(Debug)]
pub struct PinVerify {
pin: Option<[u8; 32]>,
observed: Option<Arc<Mutex<Option<[u8; 32]>>>>,
}
impl PinVerify {
/// A verifier that pins `pin` (or accepts any when `None`) without recording what it saw —
/// the HTTP clients, which connect with a known pin or accept-any and never need to persist
/// the observed fingerprint.
pub fn new(pin: Option<[u8; 32]>) -> Self {
Self {
pin,
observed: None,
}
}
/// Like [`new`](Self::new) but also writes the observed leaf fingerprint into `slot` during
/// the handshake, so a trust-on-first-use caller (the QUIC connect) can read it off the slot
/// and pin it on the next connect.
pub fn with_observed(pin: Option<[u8; 32]>, slot: Arc<Mutex<Option<[u8; 32]>>>) -> Self {
Self {
pin,
observed: Some(slot),
}
}
}
impl rustls::client::danger::ServerCertVerifier for PinVerify {
fn verify_server_cert(
&self,
end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp: &[u8],
_now: rustls::pki_types::UnixTime,
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
// Only hash the leaf when the result depends on it: a pin to check and/or a slot to
// record into. Accept-any-without-recording (an un-pinned HTTP agent) skips it.
if self.pin.is_some() || self.observed.is_some() {
let fp = cert_fingerprint(end_entity.as_ref());
if let Some(slot) = &self.observed {
*slot.lock().unwrap() = Some(fp);
}
if let Some(expected) = self.pin {
if fp != expected {
return Err(rustls::Error::InvalidCertificate(
rustls::CertificateError::ApplicationVerificationFailure,
));
}
}
}
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rustls::client::danger::ServerCertVerifier;
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
/// Drive the pin check against `cert_bytes`. `verify_server_cert` only hashes the leaf, so
/// arbitrary bytes stand in for a DER certificate here.
fn verify(v: &PinVerify, cert_bytes: &[u8]) -> std::result::Result<(), rustls::Error> {
let der = CertificateDer::from(cert_bytes.to_vec());
let name = ServerName::try_from("punktfunk").unwrap();
v.verify_server_cert(
&der,
&[],
&name,
&[],
UnixTime::since_unix_epoch(std::time::Duration::ZERO),
)
.map(|_| ())
}
#[test]
fn matching_pin_accepts_and_mismatch_is_rejected() {
let cert = b"the-host-leaf-cert";
let good = cert_fingerprint(cert);
assert!(verify(&PinVerify::new(Some(good)), cert).is_ok());
let mut wrong = good;
wrong[0] ^= 0xff;
match verify(&PinVerify::new(Some(wrong)), cert) {
Err(rustls::Error::InvalidCertificate(
rustls::CertificateError::ApplicationVerificationFailure,
)) => {}
other => panic!("a pin mismatch must be rejected, got {other:?}"),
}
}
#[test]
fn no_pin_accepts_any_and_records_the_observed_fingerprint() {
let cert = b"whatever-the-host-presents";
let slot = Arc::new(Mutex::new(None));
let v = PinVerify::with_observed(None, slot.clone());
assert!(verify(&v, cert).is_ok());
assert_eq!(*slot.lock().unwrap(), Some(cert_fingerprint(cert)));
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
/// Sized for 1 Gbps+: at ~1.2 Gbps on the wire an 8 MB buffer is only ~49 ms of steady state, and a
/// single multi-MB IDR keyframe (~4 MB ≈ 3300 packets) instantly fills most of it. 32 MB gives ~200 ms
/// of headroom and absorbs a keyframe burst without EAGAIN/ENOBUFS drops. (Paced sending —
/// `punktfunk1.rs::paced_submit` — spreads a big frame's overflow, so this buffer mostly absorbs the
/// `native.rs::paced_submit` — spreads a big frame's overflow, so this buffer mostly absorbs the
/// immediate microburst rather than a whole unpaced frame.)
pub(crate) const TARGET_SOCKBUF: usize = 32 * 1024 * 1024;
+2 -3
View File
@@ -4,7 +4,7 @@
//! ([`Transport::recv_batch`], ≤128/syscall into a reused ring) on Linux AND Android (which is
//! `target_os = "android"`, not `"linux"` — it needs its own bionic binding, see [`android_mmsg`])
//! — the 1 Gbps+ syscall lever (~125k → a few-k syscalls/sec at line rate). The host additionally
//! paces each frame's send across the frame interval (see `punktfunk1.rs::paced_submit`) so a real
//! paces each frame's send across the frame interval (see `native.rs::paced_submit`) so a real
//! NIC doesn't drop a line-rate burst. All three layer on this same [`Transport`] seam (scalar
//! fallbacks for loopback and the remaining targets).
@@ -230,8 +230,7 @@ mod uso {
STATE.store(if off { 2 } else { 1 }, Ordering::Relaxed);
tracing::info!(
enabled = !off,
"Windows UDP Send Offload (USO): {} (the 1 Gbps+ send lever; PUNKTFUNK_GSO=0 disables)",
if off { "off" } else { "on" }
"Windows UDP Send Offload (USO) resolved (the 1 Gbps+ send lever; PUNKTFUNK_GSO=0 disables)"
);
!off
}
+3
View File
@@ -217,6 +217,9 @@ windows = { version = "0.62", features = [
# (src/windows/crash.rs); Kernel gates the CONTEXT type EXCEPTION_POINTERS embeds.
"Win32_System_Diagnostics_Debug",
"Win32_System_Kernel",
# CreateToolhelp32Snapshot/Process32*W — the conflicting-streaming-host process scan
# (src/detect/windows.rs): is Sunshine/Apollo/... running alongside us?
"Win32_System_Diagnostics_ToolHelp",
] }
# The SCM plumbing for the `service` subcommand (define_windows_service! / dispatcher / control
# handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses
+1 -1
View File
@@ -77,7 +77,7 @@ src/
inject/ · inject.rs input backends (libei · wlr · uinput gamepads · UHID DualSense/DS4)
audio/ · audio.rs Opus out + virtual mic (PipeWire / WASAPI)
gamestream/ Moonlight compat: nvhttp · pairing · rtsp · control · stream · gamepad · apps
punktfunk1.rs the native punktfunk/1 host (QUIC control + native-thread UDP data plane)
native.rs the native punktfunk/1 host (QUIC control + native-thread UDP data plane)
mgmt.rs · native_pairing.rs · stats_recorder.rs management API, pairing, perf capture
hdr.rs · library.rs HDR metadata; multi-store game library
linux/ · windows/ platform-confined backends
+2 -422
View File
@@ -106,220 +106,6 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
anyhow::bail!("virtual mic requires Linux + PipeWire or Windows + a virtual audio device")
}
/// Mic is 48 kHz stereo — matches the Opus stereo decoder and the host→client audio layout.
pub const MIC_CHANNELS: u32 = 2;
/// Bound for the shared mic frame queue (drop-newest when full): the host-lifetime queue is
/// shared across all concurrent sessions and must not grow without limit under a near-line-rate
/// flood (security-review 2026-06-28 S6). 64 × 520 ms frames ≈ 0.31.3 s of slack.
const MIC_QUEUE_CAP: usize = 64;
/// Tuning for [`MicPump`]'s open/reopen/flush behaviour — parameterized so the tests can run the
/// real pump loop in milliseconds instead of seconds.
#[derive(Clone, Copy)]
struct PumpTuning {
/// First-retry delay after a failed backend open; doubles per failure up to `backoff_cap`
/// (a persistently-absent PipeWire session / audio endpoint isn't hammered), resets on
/// success.
backoff_start: std::time::Duration,
backoff_cap: std::time::Duration,
/// Idle liveness-probe interval: with no frames flowing, the pump still notices a dead
/// backend this often and reopens — so the mic is healthy BEFORE the next session starts.
heartbeat: std::time::Duration,
/// An uplink gap longer than this discards the backend's buffered audio before pushing the
/// next frame (a recorder must never hear a stale burst from before a mute/session end).
stale_gap: std::time::Duration,
/// A backend that dies before living this long counts as a FAILED open for backoff purposes
/// (an open that succeeds but dies instantly — e.g. a flapping daemon — must not churn at
/// heartbeat rate); one that lived longer resets the backoff.
stable_after: std::time::Duration,
}
const PUMP_TUNING: PumpTuning = PumpTuning {
backoff_start: std::time::Duration::from_secs(2),
backoff_cap: std::time::Duration::from_secs(60),
heartbeat: std::time::Duration::from_secs(1),
stale_gap: std::time::Duration::from_millis(600),
stable_after: std::time::Duration::from_secs(5),
};
/// Host-lifetime virtual-microphone pump: one thread owns the [`VirtualMic`] backend + an Opus
/// decoder; sessions forward the client's Opus mic frames (0xCB) over a clonable `Send` sender,
/// the thread decodes and feeds the backend.
///
/// The rock-solid properties live HERE, not in the backends:
/// - **Eager**: the backend opens at host start (retrying with backoff), NOT on the first mic
/// frame — so the virtual mic device already exists when host apps/games launch and bind
/// their capture device (most games never re-follow a default-device change mid-run).
/// - **Self-healing**: a dead backend (PipeWire restart, Windows endpoint churn) is detected on
/// every push and on an idle heartbeat, and reopened with backoff. Sessions keep their
/// senders; nothing upstream notices.
/// - **Stale-flush**: buffered audio is discarded after an uplink gap (see [`PumpTuning`]).
///
/// Per-frame Opus DECODE errors stay non-fatal (dropped frame): the mic is shared across every
/// concurrent session, so one paired client's junk frames must not deny everyone's mic
/// (security-review 2026-06-28 S2). The thread exits when every sender is dropped (host
/// shutdown), tearing the backend down.
pub struct MicPump {
tx: std::sync::mpsc::SyncSender<Vec<u8>>,
}
impl MicPump {
/// Start the host-lifetime pump (Linux/Windows). On platforms without a virtual-mic backend
/// the thread just drains and drops frames (sessions still count the datagrams).
pub fn start() -> MicPump {
let (tx, rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(MIC_QUEUE_CAP);
let spawned = std::thread::Builder::new()
.name("punktfunk-mic-pump".into())
.spawn(move || {
#[cfg(any(target_os = "linux", target_os = "windows"))]
pump_thread(rx, || open_virtual_mic(MIC_CHANNELS), PUMP_TUNING);
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
tracing::warn!("mic passthrough unsupported on this platform — frames dropped");
for _ in rx {}
}
});
if let Err(e) = spawned {
tracing::error!(error = %e, "mic pump thread spawn failed — mic passthrough disabled");
}
MicPump { tx }
}
/// A sender a session forwards the client's Opus mic frames to (`try_send` — never block a
/// datagram loop). Cloned per session; dropping a clone does NOT stop the pump (it holds
/// the original sender for the host life).
pub fn sender(&self) -> std::sync::mpsc::SyncSender<Vec<u8>> {
self.tx.clone()
}
}
/// Sleep for `dur` while draining (and dropping) queued frames, so a closed/reopening backend
/// never accumulates a stale backlog and senders never see a wedged queue. Returns `false` when
/// every sender is gone (host shutdown).
#[cfg_attr(not(any(target_os = "linux", target_os = "windows")), allow(dead_code))]
fn drain_sleep(rx: &std::sync::mpsc::Receiver<Vec<u8>>, dur: std::time::Duration) -> bool {
use std::sync::mpsc::RecvTimeoutError;
let deadline = std::time::Instant::now() + dur;
loop {
let left = deadline.saturating_duration_since(std::time::Instant::now());
if left.is_zero() {
return true;
}
match rx.recv_timeout(left.min(std::time::Duration::from_millis(250))) {
Ok(_) => {} // drop frames while closed
Err(RecvTimeoutError::Timeout) => {} // keep waiting
Err(RecvTimeoutError::Disconnected) => return false, // host shutdown
}
}
}
/// The pump loop. `opener` is injected so the tests can run the REAL loop against a mock
/// backend; production passes [`open_virtual_mic`].
#[cfg_attr(not(any(target_os = "linux", target_os = "windows")), allow(dead_code))]
fn pump_thread<O>(rx: std::sync::mpsc::Receiver<Vec<u8>>, opener: O, tuning: PumpTuning)
where
O: Fn() -> Result<Box<dyn VirtualMic>>,
{
use std::sync::mpsc::RecvTimeoutError;
use std::time::Instant;
let mut backoff = tuning.backoff_start;
let mut open_fails: u64 = 0;
loop {
// Open phase — eager, from thread start.
let (mic, mut decoder) = loop {
let opened = opener().and_then(|m| {
let d = opus::Decoder::new(SAMPLE_RATE, opus::Channels::Stereo)
.map_err(|e| anyhow::anyhow!("opus decoder: {e}"))?;
Ok((m, d))
});
match opened {
Ok(pair) => break pair,
Err(e) => {
// Throttle (1st, 2nd, 4th, 8th … failure): a box without a PipeWire session
// or virtual audio device would otherwise log every backoff forever.
open_fails += 1;
if open_fails.is_power_of_two() {
tracing::warn!(error = %format!("{e:#}"), attempts = open_fails,
"virtual mic unavailable — retrying with backoff");
}
if !drain_sleep(&rx, backoff) {
return;
}
backoff = (backoff * 2).min(tuning.backoff_cap);
}
}
};
tracing::info!("virtual mic ready (host-lifetime)");
// Drop anything queued while (re)opening — it predates the backend. (The backoff does
// NOT reset here: only an instance that proves stable resets it — see the death triage.)
while rx.try_recv().is_ok() {}
let opened_at = Instant::now();
// Pump phase — runs until the backend dies (break) or the host shuts down (return).
let mut decode_fails: u64 = 0;
let mut pcm = vec![0f32; 5760 * MIC_CHANNELS as usize]; // up to 120 ms scratch
let mut last_push = Instant::now();
loop {
match rx.recv_timeout(tuning.heartbeat) {
Ok(frame) => {
if frame.is_empty() {
continue; // DTX silence — the source underruns to silence on its own
}
if last_push.elapsed() > tuning.stale_gap {
mic.discard();
}
match decoder.decode_float(&frame, &mut pcm, false) {
Ok(samples_per_ch) => {
let total = (samples_per_ch * MIC_CHANNELS as usize).min(pcm.len());
if !mic.push(&pcm[..total]) {
tracing::warn!("virtual mic backend died — reopening");
break;
}
last_push = Instant::now();
decode_fails = 0;
}
Err(e) => {
// Malformed/garbage frame: drop it, keep the shared mic + decoder
// (see the struct docs). Throttled log (1, 2, 4, … fails).
decode_fails += 1;
if decode_fails.is_power_of_two() {
tracing::warn!(error = %e, fails = decode_fails,
"mic opus decode failed — dropping frame");
}
}
}
}
Err(RecvTimeoutError::Timeout) => {
if !mic.alive() {
tracing::warn!("virtual mic backend died while idle — reopening");
break;
}
}
Err(RecvTimeoutError::Disconnected) => {
tracing::debug!("mic pump stopped (host shutting down)");
return;
}
}
}
// Death triage: an instance that lived is a one-off (PipeWire/audio-engine restart) —
// reopen immediately with the backoff reset. One that died right after opening is a
// failed open in disguise (flapping daemon, endpoint racing away): back off like the
// open loop, or the pump would churn open→die→reopen at heartbeat rate.
if opened_at.elapsed() >= tuning.stable_after {
backoff = tuning.backoff_start;
open_fails = 0;
} else {
open_fails += 1;
if !drain_sleep(&rx, backoff) {
return;
}
backoff = (backoff * 2).min(tuning.backoff_cap);
}
}
}
#[cfg(target_os = "windows")]
#[path = "audio/windows/audio_control.rs"]
mod audio_control;
@@ -335,211 +121,5 @@ mod wasapi_mic;
#[path = "audio/wiring_plan.rs"]
pub(crate) mod wiring_plan;
#[cfg(test)]
mod pump_tests {
use super::*;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// Mock backend: records pushes/discards, dies on command.
struct MockMic {
alive: Arc<AtomicBool>,
pushed: Arc<AtomicUsize>,
discards: Arc<AtomicUsize>,
}
impl VirtualMic for MockMic {
fn push(&self, pcm: &[f32]) -> bool {
if !self.alive.load(Ordering::Acquire) {
return false;
}
self.pushed.fetch_add(pcm.len(), Ordering::Relaxed);
true
}
fn alive(&self) -> bool {
self.alive.load(Ordering::Acquire)
}
fn discard(&self) {
self.discards.fetch_add(1, Ordering::Relaxed);
}
}
struct Harness {
tx: std::sync::mpsc::SyncSender<Vec<u8>>,
opens: Arc<AtomicUsize>,
alive: Arc<Mutex<Option<Arc<AtomicBool>>>>, // latest instance's kill switch
pushed: Arc<AtomicUsize>,
discards: Arc<AtomicUsize>,
join: std::thread::JoinHandle<()>,
}
/// Run the REAL pump loop against mock backends; `fail_first` opens fail before the first
/// success (exercises the eager retry/backoff path). `dead_on_arrival` opens every instance
/// pre-killed (exercises the rapid-death churn guard). `stable_after` mirrors the tuning
/// field (ZERO = every death counts as stable → immediate reopen, keeping tests fast).
fn start_tuned(fail_first: usize, dead_on_arrival: bool, stable_after: Duration) -> Harness {
let (tx, rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(MIC_QUEUE_CAP);
let opens = Arc::new(AtomicUsize::new(0));
let alive = Arc::new(Mutex::new(None::<Arc<AtomicBool>>));
let pushed = Arc::new(AtomicUsize::new(0));
let discards = Arc::new(AtomicUsize::new(0));
let (opens2, alive2, pushed2, discards2) = (
opens.clone(),
alive.clone(),
pushed.clone(),
discards.clone(),
);
let tuning = PumpTuning {
backoff_start: Duration::from_millis(10),
backoff_cap: Duration::from_millis(40),
heartbeat: Duration::from_millis(20),
stale_gap: Duration::from_millis(80),
stable_after,
};
let join = std::thread::spawn(move || {
pump_thread(
rx,
move || {
let n = opens2.fetch_add(1, Ordering::SeqCst);
if n < fail_first {
anyhow::bail!("backend not up yet (simulated)");
}
let a = Arc::new(AtomicBool::new(!dead_on_arrival));
*alive2.lock().unwrap() = Some(a.clone());
Ok(Box::new(MockMic {
alive: a,
pushed: pushed2.clone(),
discards: discards2.clone(),
}) as Box<dyn VirtualMic>)
},
tuning,
)
});
Harness {
tx,
opens,
alive,
pushed,
discards,
join,
}
}
fn start(fail_first: usize) -> Harness {
start_tuned(fail_first, false, Duration::ZERO)
}
fn wait_until(what: &str, mut cond: impl FnMut() -> bool) {
for _ in 0..200 {
if cond() {
return;
}
std::thread::sleep(Duration::from_millis(10));
}
panic!("timed out waiting for: {what}");
}
fn opus_frame() -> Vec<u8> {
let mut enc = opus::Encoder::new(48_000, opus::Channels::Stereo, opus::Application::Voip)
.expect("opus encoder");
let pcm = [0.1f32; 960 * 2]; // 20 ms stereo
let mut out = vec![0u8; 4000];
let n = enc.encode_float(&pcm, &mut out).expect("encode");
out.truncate(n);
out
}
/// Eager: the backend opens (after transient failures) with NO frame ever sent.
#[test]
fn opens_eagerly_with_backoff() {
let h = start(3);
wait_until("eager open after 3 failures", || {
h.opens.load(Ordering::SeqCst) >= 4 && h.alive.lock().unwrap().is_some()
});
drop(h.tx);
h.join.join().unwrap();
}
/// Frames flow: opus in → PCM pushed to the backend.
#[test]
fn decodes_and_pushes() {
let h = start(0);
wait_until("open", || h.alive.lock().unwrap().is_some());
h.tx.send(opus_frame()).unwrap();
wait_until("pcm pushed", || h.pushed.load(Ordering::SeqCst) > 0);
drop(h.tx);
h.join.join().unwrap();
}
/// A dead backend is noticed WHILE IDLE (heartbeat) and reopened without any traffic.
#[test]
fn reopens_after_idle_death() {
let h = start(0);
wait_until("first open", || h.opens.load(Ordering::SeqCst) >= 1);
wait_until("instance", || h.alive.lock().unwrap().is_some());
h.alive
.lock()
.unwrap()
.as_ref()
.unwrap()
.store(false, Ordering::Release); // kill it
wait_until("reopen after idle death", || {
h.opens.load(Ordering::SeqCst) >= 2
});
drop(h.tx);
h.join.join().unwrap();
}
/// A death detected on push (frame flowing) also reopens, and the frame after reopen flows.
#[test]
fn reopens_after_push_death() {
let h = start(0);
wait_until("instance", || h.alive.lock().unwrap().is_some());
h.alive
.lock()
.unwrap()
.as_ref()
.unwrap()
.store(false, Ordering::Release);
h.tx.send(opus_frame()).unwrap(); // push sees death → reopen
wait_until("reopen", || h.opens.load(Ordering::SeqCst) >= 2);
h.tx.send(opus_frame()).unwrap();
wait_until("pcm after reopen", || h.pushed.load(Ordering::SeqCst) > 0);
drop(h.tx);
h.join.join().unwrap();
}
/// Instances that die immediately after opening must be retried with BACKOFF, not at
/// heartbeat rate — a flapping backend (daemon up but dropping us instantly) would
/// otherwise churn open→die→reopen every heartbeat forever.
#[test]
fn rapid_death_backs_off() {
// Every instance is dead on arrival; stability threshold high so each death counts
// as a failed open. Without the guard: ~1 reopen per heartbeat (20 ms) ≈ 25 opens in
// 500 ms. With backoff 10→20→40 (cap): ≈ 7.
let h = start_tuned(0, true, Duration::from_secs(10));
std::thread::sleep(Duration::from_millis(500));
let opens = h.opens.load(Ordering::SeqCst);
assert!(opens >= 2, "must keep retrying (got {opens})");
assert!(
opens <= 15,
"must back off, not churn per heartbeat (got {opens})"
);
drop(h.tx);
h.join.join().unwrap();
}
/// An uplink gap discards buffered-stale audio before the next frame plays.
#[test]
fn discards_after_gap() {
let h = start(0);
wait_until("instance", || h.alive.lock().unwrap().is_some());
h.tx.send(opus_frame()).unwrap();
wait_until("first push", || h.pushed.load(Ordering::SeqCst) > 0);
std::thread::sleep(Duration::from_millis(150)); // > stale_gap
h.tx.send(opus_frame()).unwrap();
wait_until("discard on gap", || h.discards.load(Ordering::SeqCst) >= 1);
drop(h.tx);
h.join.join().unwrap();
}
}
mod mic_pump;
pub use mic_pump::MicPump;
+6 -3
View File
@@ -156,7 +156,10 @@ impl PwMicSource {
.name("punktfunk-pw-mic".into())
.spawn(move || {
if let Err(e) = mic_pw_thread(pcm_rx, quit_rx, channels, flush_t, ready_tx) {
tracing::error!(error = %format!("{e:#}"), "pipewire virtual-mic thread failed");
// Reaching here is always a setup/open failure (once the mainloop runs it exits
// Ok) — and it was already reported to the pump via the ready handshake, which
// owns the throttled operator-facing warn. Keep only a debug breadcrumb.
tracing::debug!(error = %format!("{e:#}"), "pipewire virtual-mic setup failed — pump will back off and retry");
}
// Whether a clean quit or a daemon death: this instance is done — the pump reopens.
alive_t.store(false, Ordering::Release);
@@ -322,7 +325,7 @@ fn mic_pw_thread(
.state_changed({
let mainloop = mainloop.clone();
move |_s, _ud, old, new| {
tracing::info!(?old, ?new, "pipewire virtual-mic stream state");
tracing::debug!(?old, ?new, "pipewire virtual-mic stream state");
// A stream error is unrecoverable for this instance — exit so the pump reopens.
if matches!(new, pw::stream::StreamState::Error(_)) {
mainloop.quit();
@@ -522,7 +525,7 @@ fn pw_thread(
let _listener = stream
.add_local_listener_with_user_data(tx)
.state_changed(|_s, _ud, old, new| {
tracing::info!(?old, ?new, "pipewire audio stream state");
tracing::debug!(?old, ?new, "pipewire audio stream state");
})
.param_changed(|_stream, _tx, id, param| {
let Some(param) = param else { return };
+432
View File
@@ -0,0 +1,432 @@
//! Host-lifetime virtual-microphone pump: one thread owns the [`VirtualMic`] backend plus an Opus
//! decoder, self-heals across backend deaths, and feeds decoded client-mic PCM into the source so
//! the client's microphone reaches host apps. Split out of the `audio` facade (§2.1 — a
//! self-contained stateful subsystem does not belong in the trait facade); the [`VirtualMic`]
//! trait, its factory ([`open_virtual_mic`](super::open_virtual_mic)) and the audio-plane sample
//! rate stay in `super`.
use super::{VirtualMic, SAMPLE_RATE};
use anyhow::Result;
/// Mic is 48 kHz stereo — matches the Opus stereo decoder and the host→client audio layout.
pub const MIC_CHANNELS: u32 = 2;
/// Bound for the shared mic frame queue (drop-newest when full): the host-lifetime queue is
/// shared across all concurrent sessions and must not grow without limit under a near-line-rate
/// flood (security-review 2026-06-28 S6). 64 × 520 ms frames ≈ 0.31.3 s of slack.
const MIC_QUEUE_CAP: usize = 64;
/// Tuning for [`MicPump`]'s open/reopen/flush behaviour — parameterized so the tests can run the
/// real pump loop in milliseconds instead of seconds.
#[derive(Clone, Copy)]
struct PumpTuning {
/// First-retry delay after a failed backend open; doubles per failure up to `backoff_cap`
/// (a persistently-absent PipeWire session / audio endpoint isn't hammered), resets on
/// success.
backoff_start: std::time::Duration,
backoff_cap: std::time::Duration,
/// Idle liveness-probe interval: with no frames flowing, the pump still notices a dead
/// backend this often and reopens — so the mic is healthy BEFORE the next session starts.
heartbeat: std::time::Duration,
/// An uplink gap longer than this discards the backend's buffered audio before pushing the
/// next frame (a recorder must never hear a stale burst from before a mute/session end).
stale_gap: std::time::Duration,
/// A backend that dies before living this long counts as a FAILED open for backoff purposes
/// (an open that succeeds but dies instantly — e.g. a flapping daemon — must not churn at
/// heartbeat rate); one that lived longer resets the backoff.
stable_after: std::time::Duration,
}
const PUMP_TUNING: PumpTuning = PumpTuning {
backoff_start: std::time::Duration::from_secs(2),
backoff_cap: std::time::Duration::from_secs(60),
heartbeat: std::time::Duration::from_secs(1),
stale_gap: std::time::Duration::from_millis(600),
stable_after: std::time::Duration::from_secs(5),
};
/// Host-lifetime virtual-microphone pump: one thread owns the [`VirtualMic`] backend + an Opus
/// decoder; sessions forward the client's Opus mic frames (0xCB) over a clonable `Send` sender,
/// the thread decodes and feeds the backend.
///
/// The rock-solid properties live HERE, not in the backends:
/// - **Eager**: the backend opens at host start (retrying with backoff), NOT on the first mic
/// frame — so the virtual mic device already exists when host apps/games launch and bind
/// their capture device (most games never re-follow a default-device change mid-run).
/// - **Self-healing**: a dead backend (PipeWire restart, Windows endpoint churn) is detected on
/// every push and on an idle heartbeat, and reopened with backoff. Sessions keep their
/// senders; nothing upstream notices.
/// - **Stale-flush**: buffered audio is discarded after an uplink gap (see [`PumpTuning`]).
///
/// Per-frame Opus DECODE errors stay non-fatal (dropped frame): the mic is shared across every
/// concurrent session, so one paired client's junk frames must not deny everyone's mic
/// (security-review 2026-06-28 S2). The thread exits when every sender is dropped (host
/// shutdown), tearing the backend down.
pub struct MicPump {
tx: std::sync::mpsc::SyncSender<Vec<u8>>,
}
impl MicPump {
/// Start the host-lifetime pump (Linux/Windows). On platforms without a virtual-mic backend
/// the thread just drains and drops frames (sessions still count the datagrams).
pub fn start() -> MicPump {
let (tx, rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(MIC_QUEUE_CAP);
let spawned = std::thread::Builder::new()
.name("punktfunk-mic-pump".into())
.spawn(move || {
#[cfg(any(target_os = "linux", target_os = "windows"))]
pump_thread(rx, || super::open_virtual_mic(MIC_CHANNELS), PUMP_TUNING);
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
tracing::warn!("mic passthrough unsupported on this platform — frames dropped");
for _ in rx {}
}
});
if let Err(e) = spawned {
tracing::error!(error = %e, "mic pump thread spawn failed — mic passthrough disabled");
}
MicPump { tx }
}
/// A sender a session forwards the client's Opus mic frames to (`try_send` — never block a
/// datagram loop). Cloned per session; dropping a clone does NOT stop the pump (it holds
/// the original sender for the host life).
pub fn sender(&self) -> std::sync::mpsc::SyncSender<Vec<u8>> {
self.tx.clone()
}
}
/// Sleep for `dur` while draining (and dropping) queued frames, so a closed/reopening backend
/// never accumulates a stale backlog and senders never see a wedged queue. Returns `false` when
/// every sender is gone (host shutdown).
#[cfg_attr(not(any(target_os = "linux", target_os = "windows")), allow(dead_code))]
fn drain_sleep(rx: &std::sync::mpsc::Receiver<Vec<u8>>, dur: std::time::Duration) -> bool {
use std::sync::mpsc::RecvTimeoutError;
let deadline = std::time::Instant::now() + dur;
loop {
let left = deadline.saturating_duration_since(std::time::Instant::now());
if left.is_zero() {
return true;
}
match rx.recv_timeout(left.min(std::time::Duration::from_millis(250))) {
Ok(_) => {} // drop frames while closed
Err(RecvTimeoutError::Timeout) => {} // keep waiting
Err(RecvTimeoutError::Disconnected) => return false, // host shutdown
}
}
}
/// The pump loop. `opener` is injected so the tests can run the REAL loop against a mock
/// backend; production passes [`open_virtual_mic`](super::open_virtual_mic).
#[cfg_attr(not(any(target_os = "linux", target_os = "windows")), allow(dead_code))]
fn pump_thread<O>(rx: std::sync::mpsc::Receiver<Vec<u8>>, opener: O, tuning: PumpTuning)
where
O: Fn() -> Result<Box<dyn VirtualMic>>,
{
use std::sync::mpsc::RecvTimeoutError;
use std::time::Instant;
let mut backoff = tuning.backoff_start;
let mut open_fails: u64 = 0;
loop {
// Open phase — eager, from thread start.
let (mic, mut decoder) = loop {
let opened = opener().and_then(|m| {
let d = opus::Decoder::new(SAMPLE_RATE, opus::Channels::Stereo)
.map_err(|e| anyhow::anyhow!("opus decoder: {e}"))?;
Ok((m, d))
});
match opened {
Ok(pair) => break pair,
Err(e) => {
// Throttle (1st, 2nd, 4th, 8th … failure): a box without a PipeWire session
// or virtual audio device would otherwise log every backoff forever.
open_fails += 1;
if open_fails.is_power_of_two() {
tracing::warn!(error = %format!("{e:#}"), attempts = open_fails,
"virtual mic unavailable — retrying with backoff");
}
if !drain_sleep(&rx, backoff) {
return;
}
backoff = (backoff * 2).min(tuning.backoff_cap);
}
}
};
tracing::info!("virtual mic ready (host-lifetime)");
// Drop anything queued while (re)opening — it predates the backend. (The backoff does
// NOT reset here: only an instance that proves stable resets it — see the death triage.)
while rx.try_recv().is_ok() {}
let opened_at = Instant::now();
// Pump phase — runs until the backend dies (break) or the host shuts down (return).
let mut decode_fails: u64 = 0;
let mut pcm = vec![0f32; 5760 * MIC_CHANNELS as usize]; // up to 120 ms scratch
let mut last_push = Instant::now();
loop {
match rx.recv_timeout(tuning.heartbeat) {
Ok(frame) => {
if frame.is_empty() {
continue; // DTX silence — the source underruns to silence on its own
}
if last_push.elapsed() > tuning.stale_gap {
mic.discard();
}
match decoder.decode_float(&frame, &mut pcm, false) {
Ok(samples_per_ch) => {
let total = (samples_per_ch * MIC_CHANNELS as usize).min(pcm.len());
if !mic.push(&pcm[..total]) {
tracing::warn!("virtual mic backend died — reopening");
break;
}
last_push = Instant::now();
decode_fails = 0;
}
Err(e) => {
// Malformed/garbage frame: drop it, keep the shared mic + decoder
// (see the struct docs). Throttled log (1, 2, 4, … fails).
decode_fails += 1;
if decode_fails.is_power_of_two() {
tracing::warn!(error = %e, fails = decode_fails,
"mic opus decode failed — dropping frame");
}
}
}
}
Err(RecvTimeoutError::Timeout) => {
if !mic.alive() {
tracing::warn!("virtual mic backend died while idle — reopening");
break;
}
}
Err(RecvTimeoutError::Disconnected) => {
tracing::debug!("mic pump stopped (host shutting down)");
return;
}
}
}
// Death triage: an instance that lived is a one-off (PipeWire/audio-engine restart) —
// reopen immediately with the backoff reset. One that died right after opening is a
// failed open in disguise (flapping daemon, endpoint racing away): back off like the
// open loop, or the pump would churn open→die→reopen at heartbeat rate.
if opened_at.elapsed() >= tuning.stable_after {
backoff = tuning.backoff_start;
open_fails = 0;
} else {
open_fails += 1;
if !drain_sleep(&rx, backoff) {
return;
}
backoff = (backoff * 2).min(tuning.backoff_cap);
}
}
}
#[cfg(test)]
mod pump_tests {
use super::*;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// Mock backend: records pushes/discards, dies on command.
struct MockMic {
alive: Arc<AtomicBool>,
pushed: Arc<AtomicUsize>,
discards: Arc<AtomicUsize>,
}
impl VirtualMic for MockMic {
fn push(&self, pcm: &[f32]) -> bool {
if !self.alive.load(Ordering::Acquire) {
return false;
}
self.pushed.fetch_add(pcm.len(), Ordering::Relaxed);
true
}
fn alive(&self) -> bool {
self.alive.load(Ordering::Acquire)
}
fn discard(&self) {
self.discards.fetch_add(1, Ordering::Relaxed);
}
}
struct Harness {
tx: std::sync::mpsc::SyncSender<Vec<u8>>,
opens: Arc<AtomicUsize>,
alive: Arc<Mutex<Option<Arc<AtomicBool>>>>, // latest instance's kill switch
pushed: Arc<AtomicUsize>,
discards: Arc<AtomicUsize>,
join: std::thread::JoinHandle<()>,
}
/// Run the REAL pump loop against mock backends; `fail_first` opens fail before the first
/// success (exercises the eager retry/backoff path). `dead_on_arrival` opens every instance
/// pre-killed (exercises the rapid-death churn guard). `stable_after` mirrors the tuning
/// field (ZERO = every death counts as stable → immediate reopen, keeping tests fast).
fn start_tuned(fail_first: usize, dead_on_arrival: bool, stable_after: Duration) -> Harness {
let (tx, rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(MIC_QUEUE_CAP);
let opens = Arc::new(AtomicUsize::new(0));
let alive = Arc::new(Mutex::new(None::<Arc<AtomicBool>>));
let pushed = Arc::new(AtomicUsize::new(0));
let discards = Arc::new(AtomicUsize::new(0));
let (opens2, alive2, pushed2, discards2) = (
opens.clone(),
alive.clone(),
pushed.clone(),
discards.clone(),
);
let tuning = PumpTuning {
backoff_start: Duration::from_millis(10),
backoff_cap: Duration::from_millis(40),
heartbeat: Duration::from_millis(20),
stale_gap: Duration::from_millis(80),
stable_after,
};
let join = std::thread::spawn(move || {
pump_thread(
rx,
move || {
let n = opens2.fetch_add(1, Ordering::SeqCst);
if n < fail_first {
anyhow::bail!("backend not up yet (simulated)");
}
let a = Arc::new(AtomicBool::new(!dead_on_arrival));
*alive2.lock().unwrap() = Some(a.clone());
Ok(Box::new(MockMic {
alive: a,
pushed: pushed2.clone(),
discards: discards2.clone(),
}) as Box<dyn VirtualMic>)
},
tuning,
)
});
Harness {
tx,
opens,
alive,
pushed,
discards,
join,
}
}
fn start(fail_first: usize) -> Harness {
start_tuned(fail_first, false, Duration::ZERO)
}
fn wait_until(what: &str, mut cond: impl FnMut() -> bool) {
for _ in 0..200 {
if cond() {
return;
}
std::thread::sleep(Duration::from_millis(10));
}
panic!("timed out waiting for: {what}");
}
fn opus_frame() -> Vec<u8> {
let mut enc = opus::Encoder::new(48_000, opus::Channels::Stereo, opus::Application::Voip)
.expect("opus encoder");
let pcm = [0.1f32; 960 * 2]; // 20 ms stereo
let mut out = vec![0u8; 4000];
let n = enc.encode_float(&pcm, &mut out).expect("encode");
out.truncate(n);
out
}
/// Eager: the backend opens (after transient failures) with NO frame ever sent.
#[test]
fn opens_eagerly_with_backoff() {
let h = start(3);
wait_until("eager open after 3 failures", || {
h.opens.load(Ordering::SeqCst) >= 4 && h.alive.lock().unwrap().is_some()
});
drop(h.tx);
h.join.join().unwrap();
}
/// Frames flow: opus in → PCM pushed to the backend.
#[test]
fn decodes_and_pushes() {
let h = start(0);
wait_until("open", || h.alive.lock().unwrap().is_some());
h.tx.send(opus_frame()).unwrap();
wait_until("pcm pushed", || h.pushed.load(Ordering::SeqCst) > 0);
drop(h.tx);
h.join.join().unwrap();
}
/// A dead backend is noticed WHILE IDLE (heartbeat) and reopened without any traffic.
#[test]
fn reopens_after_idle_death() {
let h = start(0);
wait_until("first open", || h.opens.load(Ordering::SeqCst) >= 1);
wait_until("instance", || h.alive.lock().unwrap().is_some());
h.alive
.lock()
.unwrap()
.as_ref()
.unwrap()
.store(false, Ordering::Release); // kill it
wait_until("reopen after idle death", || {
h.opens.load(Ordering::SeqCst) >= 2
});
drop(h.tx);
h.join.join().unwrap();
}
/// A death detected on push (frame flowing) also reopens, and the frame after reopen flows.
#[test]
fn reopens_after_push_death() {
let h = start(0);
wait_until("instance", || h.alive.lock().unwrap().is_some());
h.alive
.lock()
.unwrap()
.as_ref()
.unwrap()
.store(false, Ordering::Release);
h.tx.send(opus_frame()).unwrap(); // push sees death → reopen
wait_until("reopen", || h.opens.load(Ordering::SeqCst) >= 2);
h.tx.send(opus_frame()).unwrap();
wait_until("pcm after reopen", || h.pushed.load(Ordering::SeqCst) > 0);
drop(h.tx);
h.join.join().unwrap();
}
/// Instances that die immediately after opening must be retried with BACKOFF, not at
/// heartbeat rate — a flapping backend (daemon up but dropping us instantly) would
/// otherwise churn open→die→reopen every heartbeat forever.
#[test]
fn rapid_death_backs_off() {
// Every instance is dead on arrival; stability threshold high so each death counts
// as a failed open. Without the guard: ~1 reopen per heartbeat (20 ms) ≈ 25 opens in
// 500 ms. With backoff 10→20→40 (cap): ≈ 7.
let h = start_tuned(0, true, Duration::from_secs(10));
std::thread::sleep(Duration::from_millis(500));
let opens = h.opens.load(Ordering::SeqCst);
assert!(opens >= 2, "must keep retrying (got {opens})");
assert!(
opens <= 15,
"must back off, not churn per heartbeat (got {opens})"
);
drop(h.tx);
h.join.join().unwrap();
}
/// An uplink gap discards buffered-stale audio before the next frame plays.
#[test]
fn discards_after_gap() {
let h = start(0);
wait_until("instance", || h.alive.lock().unwrap().is_some());
h.tx.send(opus_frame()).unwrap();
wait_until("first push", || h.pushed.load(Ordering::SeqCst) > 0);
std::thread::sleep(Duration::from_millis(150)); // > stale_gap
h.tx.send(opus_frame()).unwrap();
wait_until("discard on gap", || h.discards.load(Ordering::SeqCst) >= 1);
drop(h.tx);
h.join.join().unwrap();
}
}
@@ -39,7 +39,7 @@ impl WasapiLoopbackCapturer {
.name("punktfunk-wasapi-audio".into())
.spawn(move || {
if let Err(e) = capture_thread(tx, stop_t, ready_tx, channels) {
tracing::error!(error = format!("{e:#}"), "wasapi loopback thread failed");
tracing::error!(error = %format!("{e:#}"), "wasapi loopback thread failed");
}
})
.context("spawn wasapi audio thread")?;
+15 -21
View File
@@ -650,8 +650,6 @@ mod pipewire {
/// Bumps whenever the bitmap (`rgba`/`bw`/`bh`) changes — stable across position-only moves,
/// so the GPU encoder re-uploads its cursor texture only on change.
serial: u64,
/// One-shot guard for the "cursor present but this frame is zero-copy" notice.
warned_zerocopy: bool,
}
impl CursorState {
@@ -1174,22 +1172,6 @@ mod pipewire {
if ud.broken.load(Ordering::Relaxed) {
return;
}
// Cursor-as-metadata only reaches the frame on the CPU de-pad path below (a small
// straight-alpha blit). The zero-copy paths hand a GPU-resident buffer straight to the
// encoder, so the cached cursor can't be composited here — that needs a GPU blit in the
// encoder (follow-up). Note it once, so a gamescope host (zero-copy by default) shows in
// the logs that the metadata IS arriving even while the overlay isn't drawn yet.
if ud.cursor.visible
&& !ud.cursor.warned_zerocopy
&& (ud.importer.is_some() || ud.vaapi_passthrough)
{
ud.cursor.warned_zerocopy = true;
tracing::warn!(
"cursor metadata received, but frames are delivered zero-copy (GPU-resident) — \
the cursor overlay is composited only on the CPU capture path today; GPU-path \
compositing (Vulkan/CUDA/VAAPI encode) is a follow-up"
);
}
// SAFETY: `spa_buf` is the `*mut spa_buffer` of the PipeWire buffer we dequeued and still hold for
// this `.process` callback (not requeued until after `consume_frame` returns), so it is live. The
// block null-checks `spa_buf`, requires `n_datas != 0`, and null-checks the `datas` array pointer
@@ -1241,7 +1223,7 @@ mod pipewire {
std::sync::atomic::AtomicBool::new(true);
if F2.swap(false, Ordering::Relaxed) {
tracing::warn!(
error = %format!("{e}"),
error = %e,
"dmabuf EXPORT_SYNC_FILE failed — no implicit-fence sync; NVIDIA \
zero-copy may show stale frames (no producer explicit sync)"
);
@@ -1915,7 +1897,15 @@ mod pipewire {
unsafe { stream.queue_raw_buffer(newest) };
}));
if outcome.is_err() {
tracing::error!("panic in pipewire process callback — frame dropped");
// In the per-frame `.process` callback: a deterministic panic (e.g. a bad
// format) would fire this every frame, so power-of-two throttle it — enough to
// surface the fault without evicting the whole log ring.
static PANICS: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
let n = PANICS.fetch_add(1, Ordering::Relaxed) + 1;
if n.is_power_of_two() {
tracing::error!(count = n, "panic in pipewire process callback — frame dropped");
}
}
})
.register()
@@ -1930,7 +1920,11 @@ mod pipewire {
// Request raw video in any encoder-mappable layout, any size/framerate.
let obj = if let Some((fw, fh)) = fixed_pod {
tracing::info!(fw, fh, "PW DEBUG: offering fixed BGRx pod");
tracing::info!(
fw,
fh,
"pipewire: offering a fixed BGRx format pod (PUNKTFUNK_PW_FIXED_POD)"
);
pw::spa::pod::object!(
pw::spa::utils::SpaTypes::ObjectParamFormat,
pw::spa::param::ParamType::EnumFormat,
@@ -299,7 +299,7 @@ pub(crate) fn install_gpu_pref_hook() {
// 100% DuplicateOutput1 E_ACCESSDENIED is diagnosable instead of silent.
match SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) {
Ok(()) => tracing::info!("DPI awareness set: PER_MONITOR_AWARE_V2"),
Err(e) => tracing::warn!(error = %format!("{e:?}"),
Err(e) => tracing::warn!(error = ?e,
"SetProcessDpiAwarenessContext failed (already set?) — DuplicateOutput1 may E_ACCESSDENIED"),
}
// 0=UNAWARE 1=SYSTEM 2=PER_MONITOR(_V2). DuplicateOutput1 needs 2.
@@ -193,10 +193,20 @@ impl Drop for KeyedMutexGuard<'_> {
}
}
/// Nudge DWM into composing THE TARGET virtual display. DWM presents a display only when something
/// DIRTIES it — an idle desktop never does, so a freshly-attached ring (session open, or a
/// mid-session ring recreate) can sit at E_PENDING with no first frame even though everything is
/// healthy. pf-vdisplay implements no hardware-cursor plane, so a cursor move is composited into
/// LAST-RESORT fallback: nudge DWM into composing THE TARGET virtual display. DWM presents a
/// display only when something DIRTIES it — an idle desktop never does, so a freshly-attached ring
/// (session open, or a mid-session ring recreate) can sit at E_PENDING with no first frame even
/// though everything is healthy.
///
/// The PRIMARY first-frame mechanism is the driver's `FrameStash` (frame_transport.rs): the driver
/// retains the last composed frame and republishes it into every freshly-attached ring, so with a
/// stash-capable driver the first frame lands milliseconds after the channel delivery and this kick
/// never fires. It remains for pre-stash drivers and for the empty-stash cold start (a monitor that
/// has NEVER composed — normally the activation compose covers that). Synthetic input is inherently
/// unreliable — blocked on the secure desktop, defeated by a fullscreen game's ClipCursor, and
/// user-visible in the sibling-display case — which is exactly why it was demoted to fallback.
///
/// pf-vdisplay implements no hardware-cursor plane, so a cursor move is composited into
/// the frame — a guaranteed real present onto the IDD swap-chain (empirically what
/// `punktfunk-probe --input-test` always relied on).
///
@@ -595,7 +605,7 @@ impl DescriptorPoller {
.map_err(|e| {
// Degraded, not fatal: the session streams, it just never follows a mid-session
// HDR flip / mode-set (seq stays 0 → the consumer sees no changes).
tracing::error!(error = %e, "IDD push: descriptor-poller thread failed to spawn");
tracing::warn!(error = %e, "IDD push: descriptor-poller thread failed to spawn — mid-session HDR/mode changes won't be followed");
})
.ok();
Self { snap, stop, thread }
@@ -753,6 +763,13 @@ pub struct IddPushCapturer {
/// during active flow and warns when they turn metronomic — the sole-virtual-display
/// periodic-stutter diagnostic.
stall_watch: StallWatch,
/// Stall↔OS-event correlation counters for the metronomic warn: how many stalls this session,
/// and how many had a coinciding [`crate::display_events`] event in their gap window — the
/// discriminator between "Windows re-enumerates a monitor each cycle" (devnode churn the
/// `pnp_disable_monitors` axis suppresses) and "the disturbance is below the OS" (GPU driver
/// servicing a standby sink / display-poller software).
stalls_seen: u32,
stalls_with_os_events: u32,
/// Host-owned ROTATING output ring NVENC encodes (one YUV texture per slot). Rotating it per frame
/// is the precondition for pipelining the encode loop: while NVENC encodes frame N's texture on the
/// ASIC, frame N+1's convert writes a DIFFERENT texture — the two overlap. Format = `out_format()`:
@@ -886,6 +903,9 @@ impl IddPushCapturer {
want_444: bool,
keepalive: Box<dyn Send>,
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
// The stall-attribution listener (idempotent): started with the first IDD-push capturer so
// the stall log can correlate DWM holes with OS display events for the session's lifetime.
crate::display_events::spawn_once();
match Self::open_inner(target, preferred, client_10bit, want_444) {
Ok(mut me) => {
me._keepalive = keepalive;
@@ -1015,6 +1035,19 @@ impl IddPushCapturer {
// there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
let display_hdr = enabled_hdr
|| crate::win_display::advanced_color_enabled(target.target_id).unwrap_or(false);
// Downgrade point D (design/hdr-10bit-default-and-av1.md item 2d): the session was
// NEGOTIATED 10-bit (the client was told HDR in the Welcome), but the virtual display
// could not enable advanced color — the ring sizes SDR and the encoder will emit 8-bit
// BT.709, so the client's label overstates the stream until the descriptor poller sees
// HDR come on. Loud, because every frame of this session is affected.
if client_10bit && !display_hdr {
tracing::error!(
target = target.target_id,
"IDD push: 10-bit HDR was negotiated but enabling advanced color on the \
virtual display FAILED encoding 8-bit SDR while the client was told HDR \
(check the display driver / Windows HDR support on this box)"
);
}
let ring_fmt = if display_hdr {
DXGI_FORMAT_R16G16B16A16_FLOAT
} else {
@@ -1146,6 +1179,8 @@ impl IddPushCapturer {
last_liveness: Instant::now(),
last_kick: Instant::now(),
stall_watch: StallWatch::new(),
stalls_seen: 0,
stalls_with_os_events: 0,
out_ring: Vec::new(),
out_idx: 0,
video_conv: None,
@@ -1173,9 +1208,12 @@ impl IddPushCapturer {
/// Requiring the first frame — not just the attach — catches the *reconnect-into-a-broken-state* case:
/// a fullscreen game can leave the virtual display in a format/size that the driver's `publish()` guard
/// rejects, so the driver ATTACHES but silently drops every frame; without this the host sails past
/// `open()` and only dies on `next_frame`'s 20 s deadline (the "reconnect = black + audio" symptom). At
/// session open the OS activates the virtual display → DWM composites it → a frame arrives within ~1 s,
/// so this does not false-fail a normal (even idle) open; no frame within the window = genuinely broken.
/// `open()` and only dies on `next_frame`'s 20 s deadline (the "reconnect = black + audio" symptom).
/// A stash-capable driver republishes its retained desktop frame the moment it attaches (the
/// first-frame guarantee — `FrameStash`, driver frame_transport.rs), so the normal case clears this
/// gate in milliseconds even on an idle desktop; failing that, at session open the OS activates the
/// virtual display → DWM composites it → a frame arrives within ~1 s, plus the compose-kick fallback
/// below — no frame within the window = genuinely broken.
fn wait_for_attach(&self) -> Result<()> {
// Symmetric host-side binding sanity (proto v3 §3.2): OUR header must still name OUR
// monitor. The stamp is ours and nothing legitimate rewrites it, so a mismatch means a
@@ -1192,11 +1230,15 @@ impl IddPushCapturer {
);
}
let deadline = Instant::now() + Duration::from_secs(4);
// Compose-kick schedule: DWM only presents a display something DIRTIED, so on an idle
// desktop a perfectly healthy attach sees no first frame (E_PENDING forever) and this gate
// used to fail the session — the "idle desktop → no frames" gotcha (a real client escaped
// it only because its own input soon dirtied the desktop; a headless probe never did).
// Give the natural post-activate compose a moment, then nudge.
// First-frame expectation: a stash-capable driver republishes its retained desktop frame
// the moment it attaches (`FrameStash`, frame_transport.rs), so on a healthy pairing the
// gate below clears in milliseconds even on a perfectly idle desktop. The compose-kick
// schedule is the FALLBACK for pre-stash drivers / an empty stash (a display that has
// never composed): DWM only presents a display something DIRTIED, so on an idle desktop
// an attach would otherwise sit at E_PENDING forever and fail this gate — the
// "idle desktop → no frames" gotcha. Give the natural post-activate compose (and the
// stash republish) a moment, then nudge; log when we do, so field logs show whether the
// stash path is working.
let mut next_kick = Instant::now() + Duration::from_millis(600);
loop {
// SAFETY: `self.header` points into the live shared-header mapping this capturer owns (sized
@@ -1249,6 +1291,14 @@ impl IddPushCapturer {
return Ok(());
}
if Instant::now() >= next_kick {
// Reaching a kick at all means the driver did NOT republish a retained frame
// (pre-stash driver, or a never-composed display) — worth a line in the field log.
tracing::debug!(
target_id = self.target_id,
driver_status = st,
"IDD push: no first frame after attach delivery — falling back to a synthetic \
compose kick (stash-capable drivers republish instantly; old driver?)"
);
kick_dwm_compose(self.target_id);
next_kick = Instant::now() + Duration::from_millis(800);
}
@@ -1546,12 +1596,19 @@ impl IddPushCapturer {
}
// Same idle-desktop stall as the open-time attach gate: after a mid-session ring
// recreate (HDR flip / mode change) an idle desktop composes nothing, so the fresh ring
// never sees a frame and the 3 s recover-or-drop above kills a healthy session. Nudge
// DWM (rate-limited) once the natural post-recreate compose has had its chance.
// never sees a frame and the 3 s recover-or-drop above kills a healthy session. A
// stash-capable driver republishes its retained frame at the re-attach, so this kick
// is the legacy-driver fallback here too. Nudge DWM (rate-limited) once the natural
// post-recreate compose (and the stash republish) has had its chance.
if since.elapsed() > Duration::from_millis(600)
&& self.last_kick.elapsed() > Duration::from_millis(800)
{
self.last_kick = Instant::now();
tracing::debug!(
target_id = self.target_id,
"IDD push: no frame after ring recreate — falling back to a synthetic compose \
kick (stash-capable drivers republish at re-attach; old driver?)"
);
kick_dwm_compose(self.target_id);
}
}
@@ -1646,24 +1703,70 @@ impl IddPushCapturer {
// doesn't read as a DWM stall.
self.stall_watch.reset();
} else if let Some(stall) = self.stall_watch.note_fresh(now) {
// OS display events inside the gap (plus a lead-in margin: the event that CAUSED the
// hole lands just before DWM stops delivering) — the attribution that turns "DWM
// stopped composing" into "…because Windows re-enumerated SAMSUNG on HDMI".
let window = stall.gap + Duration::from_millis(300);
let events = now
.checked_sub(window)
.map(|from| crate::display_events::events_between(from, now))
.unwrap_or_default();
self.stalls_seen = self.stalls_seen.saturating_add(1);
if !events.is_empty() {
self.stalls_with_os_events = self.stalls_with_os_events.saturating_add(1);
}
// debug (not warn): a single hole also happens when content legitimately pauses;
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
// at debug level, and the web-console debug ring captures these.
tracing::debug!(
gap_ms = stall.gap.as_millis() as u64,
os_display_events = %crate::display_events::summarize(&events),
"IDD-push capture stall — the desktop was composing at speed, then DWM \
delivered no frame for the gap; the present path stalled below capture"
);
if let Some(period) = stall.metronomic {
tracing::warn!(
period_s = format!("{:.2}", period.as_secs_f64()),
"capture stalls are METRONOMIC — DWM stops composing the virtual display \
on a stable period, i.e. a periodic display-path disturbance BELOW \
capture (DWM present clock / GPU driver / display-poller software). \
Correlate with 'slow display-descriptor poll'; if that never fires, the \
disturbance is outside punktfunk try display topology=primary or \
extend (keep a physical output active), or a different refresh rate"
);
let suspects = crate::display_events::connected_inactive_externals();
let suspects = if suspects.is_empty() {
"none".to_string()
} else {
suspects.join(", ")
};
let correlated = format!("{}/{}", self.stalls_with_os_events, self.stalls_seen);
// Half-or-more of the stalls carrying a coinciding OS event = the reaction
// cascade is OS-visible; otherwise the disturbance never surfaces above the
// driver. Different classes, different cures — say which one this box has.
if self.stalls_with_os_events * 2 >= self.stalls_seen {
tracing::warn!(
period_s = format!("{:.2}", period.as_secs_f64()),
os_correlated = correlated,
connected_inactive = %suspects,
"capture stalls are METRONOMIC and coincide with Windows monitor \
hot-plug/re-enumeration events a connected display (or its \
cable/switch/AVR) re-probes the link on a timer and Windows re-reacts \
each time. Cures, best-first: that display's OSD 'auto input \
scan/detect' OFF (and on TVs: instant-on/quick-start + CEC off), \
unplug its cable at the GPU, an HPD-holding adapter/dummy plug, or \
keep it active while streaming; the pnp_disable_monitors policy axis \
suppresses the Windows-side reaction (see connected_inactive for the \
suspects)"
);
} else {
tracing::warn!(
period_s = format!("{:.2}", period.as_secs_f64()),
os_correlated = correlated,
connected_inactive = %suspects,
"capture stalls are METRONOMIC with NO coinciding OS display event — \
the disturbance is BELOW Windows: the GPU driver servicing a \
connected-but-asleep sink (standby HPD/DDC/link probing), \
display-poller software (the SteelSeries-GG/SignalRGB class \
correlate 'slow display-descriptor poll' lines), or the DWM present \
clock (try a different refresh rate). If connected_inactive lists a \
display, its standby probing is the prime suspect: unplug it at the \
GPU, disable its OSD auto input scan (TVs: instant-on/quick-start + \
CEC off), use an HPD-holding adapter/dummy, or keep it active while \
streaming"
);
}
}
}
self.last_fresh = now; // feeds the driver-death watch
+16 -2
View File
@@ -49,7 +49,12 @@ pub struct HostConfig {
/// `PUNKTFUNK_ZEROCOPY` — Windows D3D11 zero-copy encode input override. `None` (unset) defers to
/// the per-vendor default (AMF on, QSV off — see module docs and `encode/ffmpeg_win.rs`).
pub zerocopy: Option<bool>,
/// `PUNKTFUNK_10BIT` — host policy gate for HEVC Main10 (only honored when the client also advertised 10-bit).
/// `PUNKTFUNK_10BIT` — host policy gate for 10-bit encode (HEVC Main10 / AV1 10-bit).
/// **Default ON** (since 10-bit went probe-gated end-to-end, 2026-07-16): the host merely
/// *allows* 10-bit — a session only becomes 10-bit when the client advertised `VIDEO_CAP_10BIT`
/// (behind its HDR setting + display-capability gate), the codec supports it (HEVC/AV1), and
/// the GPU/backend passed the encode probe (`can_encode_10bit`) — otherwise 8-bit SDR.
/// `PUNKTFUNK_10BIT=0`/`false`/`off`/`no` disables. Independent of `four_four_four` (depth vs chroma).
pub ten_bit: bool,
/// `PUNKTFUNK_444` — host policy gate for full-chroma HEVC 4:4:4 (Range Extensions).
/// **Default ON** (since the pipeline went zero-copy + honest end-to-end, 2026-07-10): the
@@ -108,7 +113,16 @@ impl HostConfig {
"0" | "false" | "off" | "no"
)
}),
ten_bit: flag("PUNKTFUNK_10BIT"),
// Default ON, explicit-off grammar (mirrors `four_four_four`: the client's HDR setting
// is the real per-session switch; the encode probe keeps incapable GPUs honest at 8-bit).
ten_bit: val("PUNKTFUNK_10BIT")
.map(|s| {
!matches!(
s.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
)
})
.unwrap_or(true),
// Default ON, explicit-off grammar (the client's own 4:4:4 setting — default OFF —
// is the real switch; see the field doc).
four_four_four: val("PUNKTFUNK_444")
+342
View File
@@ -0,0 +1,342 @@
//! Conflicting game-streaming host detection.
//!
//! Punktfunk is one of a family of Moonlight-compatible desktop-streaming hosts. The others —
//! Sunshine and its many forks (Apollo, Vibeshine, Vibepollo, LuminalShine, …) — all impersonate
//! NVIDIA GameStream: they bind the **same** ports (47984/47989 nvhttp, 47998-48010 stream,
//! 47990 web UI — which is also our management API), advertise the **same** `_nvstream._tcp`
//! mDNS service, and frequently install a **conflicting virtual-display driver**. Running one of
//! them alongside Punktfunk is unsupported — the symptoms are `address already in use` bind
//! failures, pairing that silently fails, and capture/virtual-display glitches.
//!
//! This module proactively detects such a host (installed and/or running) so we can surface it as
//! early as possible: at host startup (a `warn!` into the log ring + tray/console summary) and via
//! the `detect-conflicts` subcommand the installers/support run.
//!
//! Detection is **fingerprint-first by name**: a small table of the known products (extend
//! [`KNOWN`] as new forks appear) matched against running processes, registered OS services/units,
//! and on-disk install markers. The platform back-ends (`detect/windows.rs`, `detect/linux.rs`)
//! provide the raw facts; the matching + rendering here is portable and unit-tested.
use std::sync::OnceLock;
#[cfg(target_os = "windows")]
#[path = "detect/windows.rs"]
mod platform;
#[cfg(target_os = "linux")]
#[path = "detect/linux.rs"]
mod platform;
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
mod platform {
//! The host only runs on Windows/Linux; the crate still compiles on macOS (dev) — nothing to
//! scan there.
pub fn running_processes() -> Vec<String> {
Vec::new()
}
pub fn static_evidence(_known: &super::Known) -> Vec<super::Evidence> {
Vec::new()
}
}
/// A known competing GameStream/Moonlight host.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Product {
Sunshine,
Apollo,
Vibeshine,
Vibepollo,
Luminalshine,
}
impl Product {
/// The name shown to the user.
pub fn label(self) -> &'static str {
match self {
Product::Sunshine => "Sunshine",
Product::Apollo => "Apollo",
Product::Vibeshine => "Vibeshine",
Product::Vibepollo => "Vibepollo",
Product::Luminalshine => "LuminalShine",
}
}
}
/// How a conflicting host was observed on this machine.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Evidence {
/// A matching process is running **right now** (process/executable basename).
Running { process: String },
/// An OS service / systemd unit for the product is registered (installed; may be stopped).
Service { name: String },
/// Installed on disk — a Program Files directory, a flatpak app id, or a binary on `PATH`.
Installed { at: String },
}
impl Evidence {
fn render(&self) -> String {
match self {
Evidence::Running { process } => format!("running now ({process})"),
Evidence::Service { name } => format!("service {name}"),
Evidence::Installed { at } => format!("installed at {at}"),
}
}
}
/// A detected conflicting host, with every piece of corroborating evidence found.
#[derive(Clone, Debug)]
pub struct Detection {
pub product: Product,
pub evidence: Vec<Evidence>,
}
impl Detection {
/// True when a matching process is live — the acute case (a guaranteed resource clash the
/// moment Punktfunk tries to bind its ports).
pub fn is_running(&self) -> bool {
self.evidence
.iter()
.any(|e| matches!(e, Evidence::Running { .. }))
}
/// A compact one-line label for the tray/console summary, e.g. `Sunshine (running)`.
pub fn label(&self) -> String {
if self.is_running() {
format!("{} (running)", self.product.label())
} else {
self.product.label().to_string()
}
}
}
/// One row of the known-conflicting-host table. Names are matched case-insensitively; process /
/// binary basenames are given **without** an extension (the platform code lowercases + strips
/// `.exe`). Extend this as new Sunshine forks appear — the runtime, the subcommand, and the
/// tray/console summary all key off this one list.
pub struct Known {
pub product: Product,
/// Process / executable basenames (lowercase, no extension) that identify this host.
pub processes: &'static [&'static str],
/// Windows service names (SCM keys under `HKLM\SYSTEM\CurrentControlSet\Services`).
pub win_services: &'static [&'static str],
/// Windows install-dir basenames under `%ProgramFiles%` / `%ProgramFiles(x86)%`.
pub win_dirs: &'static [&'static str],
/// Linux systemd unit basenames (without `.service`), checked in the standard unit dirs.
pub linux_units: &'static [&'static str],
/// Linux flatpak application ids.
pub flatpaks: &'static [&'static str],
}
/// The known Moonlight-compatible hosts that clash with Punktfunk. All are Sunshine or Sunshine
/// forks; add new forks here (one row) and every surface picks them up.
pub const KNOWN: &[Known] = &[
Known {
product: Product::Sunshine,
processes: &["sunshine"],
win_services: &["SunshineService"],
win_dirs: &["Sunshine"],
linux_units: &["sunshine"],
flatpaks: &["dev.lizardbyte.app.Sunshine"],
},
Known {
product: Product::Apollo,
processes: &["apollo"],
win_services: &["ApolloService"],
win_dirs: &["Apollo"],
linux_units: &["apollo"],
flatpaks: &["dev.lizardbyte.app.Apollo"],
},
Known {
product: Product::Vibeshine,
processes: &["vibeshine"],
win_services: &["VibeshineService"],
win_dirs: &["Vibeshine"],
linux_units: &["vibeshine"],
flatpaks: &[],
},
Known {
product: Product::Vibepollo,
processes: &["vibepollo"],
win_services: &["VibepolloService"],
win_dirs: &["Vibepollo"],
linux_units: &["vibepollo"],
flatpaks: &[],
},
Known {
product: Product::Luminalshine,
processes: &["luminalshine"],
win_services: &["LuminalShineService"],
win_dirs: &["LuminalShine"],
linux_units: &["luminalshine"],
flatpaks: &[],
},
];
/// Why running side-by-side breaks — shared by every surface (log, subcommand, installers).
pub const UNSUPPORTED_BLURB: &str =
"Running Punktfunk alongside another Moonlight-compatible host \
(Sunshine and its forks) is UNSUPPORTED: they bind the same GameStream ports (47984/47989, \
47998-48010), advertise the same _nvstream mDNS name, and often install a conflicting \
virtual-display driver. Expect \"address already in use\" errors, failed pairing, and capture \
glitches. Stop and uninstall the other host, or don't run them at the same time.";
/// Scan the machine for conflicting hosts. Portable; dispatches into the platform back-end. Does
/// real OS work (process enumeration, service/registry queries, filesystem stats) — cheap, but not
/// free, so prefer the cached [`init`]/[`snapshot`] for hot paths.
pub fn scan() -> Vec<Detection> {
let procs = platform::running_processes();
let mut out = Vec::new();
for known in KNOWN {
let mut evidence: Vec<Evidence> = Vec::new();
for p in &procs {
if known.processes.iter().any(|n| p == n) {
evidence.push(Evidence::Running { process: p.clone() });
}
}
evidence.extend(platform::static_evidence(known));
if !evidence.is_empty() {
out.push(Detection {
product: known.product,
evidence,
});
}
}
out
}
static SNAPSHOT: OnceLock<Vec<Detection>> = OnceLock::new();
/// Scan once and cache the result for the life of the process (the conflict set doesn't change at
/// streaming granularity — a snapshot taken at host bring-up is the right resolution and keeps the
/// per-poll `/local/summary` free). Returns the cached detections.
pub fn init() -> &'static [Detection] {
SNAPSHOT.get_or_init(scan)
}
/// The cached snapshot, or empty if [`init`] hasn't run. Non-scanning: safe to call from hot paths
/// and from tests without touching the OS.
pub fn snapshot() -> &'static [Detection] {
SNAPSHOT.get().map(Vec::as_slice).unwrap_or(&[])
}
/// Compact labels for the tray / web-console summary (e.g. `["Sunshine (running)", "Apollo"]`).
pub fn summary_labels(detections: &[Detection]) -> Vec<String> {
detections.iter().map(Detection::label).collect()
}
/// A full human-readable report: the blurb + one bullet per detected host with its evidence.
/// Empty string when nothing was detected (callers gate on `is_empty()`).
pub fn render_report(detections: &[Detection]) -> String {
if detections.is_empty() {
return String::new();
}
let mut s = String::from("Detected another game-streaming host on this machine.\n");
s.push_str(UNSUPPORTED_BLURB);
s.push_str("\n\nDetected:\n");
for d in detections {
let ev = d
.evidence
.iter()
.map(Evidence::render)
.collect::<Vec<_>>()
.join("; ");
s.push_str(&format!(" \u{2022} {} \u{2014} {ev}\n", d.product.label()));
}
s
}
#[cfg(test)]
mod tests {
use super::*;
fn det(product: Product, evidence: Vec<Evidence>) -> Detection {
Detection { product, evidence }
}
#[test]
fn empty_report_and_labels() {
assert!(render_report(&[]).is_empty());
assert!(summary_labels(&[]).is_empty());
}
#[test]
fn running_detection_is_flagged_and_labelled() {
let d = det(
Product::Sunshine,
vec![
Evidence::Running {
process: "sunshine".into(),
},
Evidence::Service {
name: "SunshineService".into(),
},
],
);
assert!(d.is_running());
assert_eq!(d.label(), "Sunshine (running)");
}
#[test]
fn installed_only_is_not_running() {
let d = det(
Product::Apollo,
vec![Evidence::Installed {
at: "C:\\Program Files\\Apollo".into(),
}],
);
assert!(!d.is_running());
assert_eq!(d.label(), "Apollo");
}
#[test]
fn report_lists_every_product_and_the_blurb() {
let report = render_report(&[
det(
Product::Sunshine,
vec![Evidence::Running {
process: "sunshine".into(),
}],
),
det(
Product::Apollo,
vec![Evidence::Installed {
at: "/usr/bin/apollo".into(),
}],
),
]);
assert!(report.contains("UNSUPPORTED"));
assert!(report.contains("Sunshine \u{2014} running now (sunshine)"));
assert!(report.contains("Apollo \u{2014} installed at /usr/bin/apollo"));
assert_eq!(
summary_labels(&[
det(
Product::Sunshine,
vec![Evidence::Running {
process: "sunshine".into()
}]
),
det(
Product::Apollo,
vec![Evidence::Installed { at: "x".into() }]
),
]),
vec!["Sunshine (running)".to_string(), "Apollo".to_string()]
);
}
#[test]
fn known_table_rows_are_well_formed() {
// Every known product carries at least a process name and a Windows service so the runtime
// scan and the installer's registry check stay in agreement.
for k in KNOWN {
assert!(
!k.processes.is_empty(),
"{:?} has no process name",
k.product
);
assert!(
!k.win_services.is_empty(),
"{:?} has no Windows service name",
k.product
);
}
}
}
+94
View File
@@ -0,0 +1,94 @@
//! Linux conflicting-host facts: `/proc` for running processes, the standard systemd unit dirs +
//! flatpak app dirs + `PATH` for install markers. All best-effort and dependency-free (no
//! subprocess spawns) — a missing `/proc` or an unreadable dir simply yields no evidence.
use super::{Evidence, Known};
use std::path::Path;
/// Lowercased basenames of every process whose `/proc/<pid>/comm` we can read. `comm` is the
/// kernel's 15-char command name — every host we match on (sunshine, apollo, vibeshine, vibepollo,
/// luminalshine) fits within that, so no `/proc/<pid>/exe` readlink is needed.
pub fn running_processes() -> Vec<String> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir("/proc") else {
return out;
};
for entry in entries.flatten() {
// Only numeric (pid) directories.
if !entry
.file_name()
.to_str()
.map(|n| n.bytes().all(|b| b.is_ascii_digit()))
.unwrap_or(false)
{
continue;
}
if let Ok(comm) = std::fs::read_to_string(entry.path().join("comm")) {
out.push(comm.trim().to_ascii_lowercase());
}
}
out
}
/// systemd unit registration + flatpak + `PATH` binary presence — the "installed" evidence.
pub fn static_evidence(known: &Known) -> Vec<Evidence> {
let mut ev = Vec::new();
// systemd units, system + per-user, in the dirs systemd actually reads.
let home = std::env::var_os("HOME");
let mut unit_dirs: Vec<String> = vec![
"/etc/systemd/system".into(),
"/run/systemd/system".into(),
"/usr/lib/systemd/system".into(),
"/lib/systemd/system".into(),
"/etc/systemd/user".into(),
"/usr/lib/systemd/user".into(),
];
if let Some(h) = &home {
unit_dirs.push(format!("{}/.config/systemd/user", h.to_string_lossy()));
}
for unit in known.linux_units {
let file = format!("{unit}.service");
if unit_dirs.iter().any(|d| Path::new(d).join(&file).exists()) {
ev.push(Evidence::Service { name: file });
}
}
// flatpak app installs (system + per-user).
let mut flatpak_roots: Vec<String> = vec!["/var/lib/flatpak/app".into()];
if let Some(h) = &home {
flatpak_roots.push(format!("{}/.local/share/flatpak/app", h.to_string_lossy()));
}
for id in known.flatpaks {
if flatpak_roots.iter().any(|r| Path::new(r).join(id).exists()) {
ev.push(Evidence::Installed {
at: format!("flatpak {id}"),
});
}
}
// A matching binary on PATH (covers manual / package installs the unit/flatpak checks miss).
let path = std::env::var_os("PATH");
for bin in known.processes {
if let Some(found) = find_on_path(bin, path.as_deref()) {
ev.push(Evidence::Installed { at: found });
}
}
ev
}
fn find_on_path(bin: &str, path: Option<&std::ffi::OsStr>) -> Option<String> {
let dirs = path.map(std::env::split_paths).into_iter().flatten();
// Always also probe the common bindirs, even if PATH is unset/narrow (e.g. a service context).
let extra = ["/usr/bin", "/usr/local/bin", "/bin", "/usr/games"]
.into_iter()
.map(std::path::PathBuf::from);
for dir in dirs.chain(extra) {
let cand = dir.join(bin);
if cand.is_file() {
return Some(cand.to_string_lossy().into_owned());
}
}
None
}
@@ -0,0 +1,87 @@
//! Windows conflicting-host facts: a Toolhelp process snapshot for what's running, the SCM for
//! registered services, and `%ProgramFiles%` for on-disk installs. All best-effort — any failing
//! query (no privilege, API error) yields no evidence rather than aborting startup.
use super::{Evidence, Known};
use windows::Win32::Foundation::CloseHandle;
use windows::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,
};
use windows_service::service::ServiceAccess;
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
/// Lowercased executable basenames (without `.exe`) of every running process, via a Toolhelp
/// snapshot. `szExeFile` is the module base name (e.g. `sunshine.exe`), not a full path.
pub fn running_processes() -> Vec<String> {
let mut out = Vec::new();
// SAFETY: standard Toolhelp snapshot walk. The snapshot handle is closed on every exit path;
// `entry` is fully initialized (dwSize set) before Process32FirstW reads it.
unsafe {
let Ok(snap) = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) else {
return out;
};
// Zeroed then dwSize set — the canonical Toolhelp init (no reliance on a Default impl for
// the 260-wide szExeFile array).
let mut entry = PROCESSENTRY32W {
dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
..std::mem::zeroed()
};
if Process32FirstW(snap, &mut entry).is_ok() {
loop {
let end = entry
.szExeFile
.iter()
.position(|&c| c == 0)
.unwrap_or(entry.szExeFile.len());
let name = String::from_utf16_lossy(&entry.szExeFile[..end]).to_ascii_lowercase();
out.push(name.strip_suffix(".exe").unwrap_or(&name).to_string());
if Process32NextW(snap, &mut entry).is_err() {
break;
}
}
}
let _ = CloseHandle(snap);
}
out
}
/// SCM service registration + `%ProgramFiles%` install dirs — the "installed" evidence.
pub fn static_evidence(known: &Known) -> Vec<Evidence> {
let mut ev = Vec::new();
for svc in known.win_services {
if service_exists(svc) {
ev.push(Evidence::Service {
name: (*svc).to_string(),
});
}
}
for dir in known.win_dirs {
if let Some(at) = program_files_dir(dir) {
ev.push(Evidence::Installed { at });
}
}
ev
}
/// True if a service by this name is registered with the SCM (running or stopped). Opening it with
/// `QUERY_STATUS` fails cleanly when it doesn't exist.
fn service_exists(name: &str) -> bool {
let Ok(mgr) = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
else {
return false;
};
mgr.open_service(name, ServiceAccess::QUERY_STATUS).is_ok()
}
/// The install directory under any of the Program Files roots, if it exists.
fn program_files_dir(dir: &str) -> Option<String> {
for var in ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] {
if let Some(base) = std::env::var_os(var) {
let p = std::path::Path::new(&base).join(dir);
if p.is_dir() {
return Some(p.to_string_lossy().into_owned());
}
}
}
None
}
+362
View File
@@ -0,0 +1,362 @@
//! Standalone dev/test subcommands that validate a subsystem without a streaming client: the
//! input-injection smoke test and the virtual-gamepad exercisers (Linux UHID DualSense /
//! Switch Pro; Windows UMDF DualSense-family + the Steam Deck devnode spike). Split out of the
//! `main` CLI dispatch (plan §W5 "devtest.rs, land first") so `main.rs`'s match keeps only thin
//! arms that forward here. Each fn owns the full behaviour (and doc) of its former inline arm.
#[cfg(target_os = "linux")]
use anyhow::Context;
use anyhow::Result;
/// Inject a scripted mouse + keyboard pattern through the session's input backend (libei on
/// KWin/GNOME, wlr on Sway). Lets us validate input injection without a Moonlight client.
#[cfg(target_os = "linux")]
pub fn input_test() -> Result<()> {
use punktfunk_core::input::{InputEvent, InputKind};
use std::time::Duration;
let backend = crate::inject::default_backend();
tracing::info!(?backend, "input-test: opening injector");
let mut inj = crate::inject::open(backend)?;
// An async backend (libei) needs a moment to establish its portal/EIS session + device
// resume; events injected before then are dropped.
std::thread::sleep(Duration::from_secs(4));
let ev = |kind, code, x, y| InputEvent {
kind,
_pad: [0; 3],
code,
x,
y,
flags: 0,
};
tracing::info!(
"input-test: injecting a mouse square + 'A'/click taps for ~8s (watch wev / focused app)"
);
for i in 0..160u32 {
let (dx, dy) = match (i / 10) % 4 {
0 => (12, 0),
1 => (0, 12),
2 => (-12, 0),
_ => (0, -12),
};
if let Err(e) = inj.inject(&ev(InputKind::MouseMove, 0, dx, dy)) {
tracing::warn!(error = %format!("{e:#}"), "input-test: inject failed");
}
if i % 20 == 0 {
let _ = inj.inject(&ev(InputKind::KeyDown, 0x41, 0, 0)); // 'A'
let _ = inj.inject(&ev(InputKind::KeyUp, 0x41, 0, 0));
let _ = inj.inject(&ev(InputKind::MouseButtonDown, 1, 0, 0)); // left click
let _ = inj.inject(&ev(InputKind::MouseButtonUp, 1, 0, 0));
}
std::thread::sleep(Duration::from_millis(50));
}
tracing::info!("input-test: done");
Ok(())
}
#[cfg(not(target_os = "linux"))]
pub fn input_test() -> Result<()> {
anyhow::bail!("input-test requires Linux")
}
/// Create a virtual DualSense via UHID and exercise it (validation, no streaming session):
/// toggles the Cross button, sweeps the left stick, and prints any HID output the kernel
/// sends back. Verify with `evtest` / `ls /dev/input/by-id/*Punktfunk*` / `wpctl status`.
/// `--edge` creates a DualSense **Edge** (054C:0DF2) instead and additionally cycles the
/// four back/Fn buttons (kernel ≥ 7.2 exposes them as BTN_TRIGGER_HAPPY1..4; on older
/// kernels verify the bind + `hidraw` byte 10 instead).
#[cfg(target_os = "linux")]
pub fn dualsense_test(args: &[String]) -> Result<()> {
use crate::inject::dualsense::{DsUhidIdentity, DualSensePad};
use crate::inject::dualsense_proto::{edge_paddle_bits, DsState};
let secs: u64 = args
.iter()
.skip_while(|a| *a != "--seconds")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(20);
let edge = args.iter().any(|a| a == "--edge");
let (identity, label) = if edge {
(DsUhidIdentity::dualsense_edge(), "DualSense Edge")
} else {
(DsUhidIdentity::dualsense(), "DualSense")
};
use std::time::{Duration, Instant};
let mut pad = DualSensePad::open(0, &identity)
.with_context(|| format!("create virtual {label} via /dev/uhid"))?;
// Answer the kernel's init GET_REPORTs promptly so hid-playstation creates the input
// devices before we start streaming state.
let init = Instant::now() + Duration::from_millis(800);
while Instant::now() < init {
pad.service(0);
std::thread::sleep(Duration::from_millis(10));
}
println!(
"virtual {label} created — check `evtest`, `ls /dev/input/by-id/*Punktfunk*`, \
`ls /sys/class/leds/`. Cycling Cross + sweeping LS for {secs}s."
);
let deadline = Instant::now() + Duration::from_secs(secs);
let (mut i, mut last_write) = (0i32, Instant::now());
while Instant::now() < deadline {
let fb = pad.service(0);
if let Some((low, high)) = fb.rumble {
println!(" rumble from kernel/game: low={low} high={high}");
}
for o in fb.hidout {
println!(" hid output from kernel/game: {o:?}");
}
if last_write.elapsed() >= Duration::from_millis(300) {
last_write = Instant::now();
i += 1;
let mut buttons = if i % 2 == 0 {
punktfunk_core::input::gamepad::BTN_A
} else {
0
};
if edge {
// Cycle one paddle per beat (R4 → L4 → R5 → L5) so all four Edge slots
// are visible in evtest / hidraw.
buttons |= punktfunk_core::input::gamepad::BTN_PADDLE1 << (i % 4);
}
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
let mut st = DsState::from_gamepad(buttons, lx, 0, 0, 0, 0, 0);
if edge {
st.buttons[2] |= edge_paddle_bits(buttons);
}
pad.write_state(&st).context("write report")?;
}
std::thread::sleep(Duration::from_millis(15));
}
println!("dualsense-test: done");
Ok(())
}
/// Create a virtual Switch Pro Controller via UHID and exercise it (validation, no
/// streaming session): answers the full hid-nintendo probe conversation, then cycles the
/// A/B buttons (positionally swapped) + sweeps the left stick, printing rumble / player-
/// light feedback. Verify with `evtest` (hid-nintendo input devices), `dmesg | grep
/// nintendo`, SDL identifying a "Nintendo Switch Pro Controller".
#[cfg(target_os = "linux")]
pub fn switchpro_test(args: &[String]) -> Result<()> {
use crate::inject::switch_pro::SwitchProPad;
use crate::inject::switch_proto::SwitchState;
let secs: u64 = args
.iter()
.skip_while(|a| *a != "--seconds")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(20);
use std::time::{Duration, Instant};
let mut pad =
SwitchProPad::open(0).context("create virtual Switch Pro Controller via /dev/uhid")?;
// Answer the driver's probe conversation promptly — every step blocks hid-nintendo
// init until its reply lands; also stream neutral 0x30 reports like real hardware.
println!("virtual Switch Pro created — servicing the hid-nintendo probe…");
let init = Instant::now() + Duration::from_millis(2500);
let mut hb = Instant::now();
while Instant::now() < init {
let fb = pad.service(0);
for o in fb.hidout {
println!(" probe feedback: {o:?}");
}
if hb.elapsed() >= Duration::from_millis(15) {
hb = Instant::now();
let _ = pad.write_state(&SwitchState::neutral());
}
std::thread::sleep(Duration::from_millis(2));
}
println!("probe window over — cycling buttons + stick for {secs}s (check evtest)");
let deadline = Instant::now() + Duration::from_secs(secs);
let (mut i, mut last_write) = (0i32, Instant::now());
while Instant::now() < deadline {
let fb = pad.service(0);
if let Some((low, high)) = fb.rumble {
println!(" rumble from kernel/game: low={low} high={high}");
}
for o in fb.hidout {
println!(" hid output from kernel/game: {o:?}");
}
// ~15 ms cadence = the real controller's report rate (also keeps the driver's
// post-probe subcommand rate limiter fed).
if last_write.elapsed() >= Duration::from_millis(15) {
last_write = Instant::now();
i += 1;
let step = i / 20; // change the pressed button every ~300 ms
let buttons = if step % 2 == 0 {
punktfunk_core::input::gamepad::BTN_A
} else {
punktfunk_core::input::gamepad::BTN_B
};
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
let st = SwitchState::from_gamepad(buttons, lx, 0, 0, 0, 0, 0);
pad.write_state(&st).context("write Switch Pro report")?;
}
std::thread::sleep(Duration::from_millis(2));
}
println!("switchpro-test: done");
Ok(())
}
/// Windows N4 SPIKE (gamepad-new-types §6): hold a software-devnode HID Steam Deck
/// (28DE:1205 via device_type 3) and watch whether Steam Input promotes it. Needs the
/// updated signed driver installed + Steam running. `--seconds N` (default 120).
#[cfg(target_os = "windows")]
pub fn deck_windows_spike(args: &[String]) -> Result<()> {
let secs: u64 = args
.iter()
.skip_while(|a| *a != "--seconds")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(120);
crate::inject::dualsense_windows::deck_spike_hold(0, secs)
}
/// Windows: create a virtual DualSense via the UMDF driver (a SwDeviceCreate per-session
/// devnode plus the shared-memory channel) and hold it, pushing one fixed frame (Cross +
/// LS-right). Drives the real DualSenseWindowsManager, so it validates the device lifecycle
/// end to end. Verify while it holds: `Get-PnpDevice` shows a VID_054C device, and a HID read
/// returns the pushed report (byte1=0xC0, byte8=0x28). On exit the pad drops → SwDeviceClose
/// removes the devnode.
#[cfg(target_os = "windows")]
pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
use crate::gamestream::gamepad::{GamepadEvent, GamepadFrame};
use std::time::{Duration, Instant};
let secs: u64 = args
.iter()
.skip_while(|a| *a != "--seconds")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(20);
// `--index N` creates pad `pf_pad_N` (default 0) — use a spare index (e.g. 1) to test
// alongside a running host that already holds pad 0. `--ds4` drives the DualShock 4
// backend instead of the DualSense one.
let idx: u8 = args
.iter()
.skip_while(|a| *a != "--index")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let ds4 = args.iter().any(|a| a == "--ds4");
let xbox = args.iter().any(|a| a == "--xbox");
// `--edge` drives the DualSense Edge backend (device_type 2) and additionally holds
// the R4/L4 paddles on the pressed beats, so a HID read shows the Edge bits in
// report byte 10 (0x80|0x40) next to Cross. `--deck` drives the Steam Deck backend
// (device_type 3, the MI_02-promoted identity) — watch Steam claim it live.
let edge = args.iter().any(|a| a == "--edge");
let deck = args.iter().any(|a| a == "--deck");
let extra_buttons: u32 = if edge || deck {
punktfunk_core::input::gamepad::BTN_PADDLE1 | punktfunk_core::input::gamepad::BTN_PADDLE2
} else {
0
};
// Same drive loop for either backend (identical method surface): Arrival creates the pad,
// State pushes a cycling report, pump surfaces a game's rumble/lightbar feedback.
macro_rules! drive {
($mgr:expr, $label:expr) => {{
let mut mgr = $mgr;
mgr.handle(&GamepadEvent::Arrival {
index: idx,
kind: 2,
capabilities: 0,
});
println!(
"virtual {} up — cycling Cross + sweeping the left stick for {secs}s. Watch \
it in joy.cpl / Steam / a game; any feedback the game sends prints below.",
$label
);
let deadline = Instant::now() + Duration::from_secs(secs);
let (mut i, mut last) = (0i32, Instant::now());
while Instant::now() < deadline {
mgr.pump(
|pad, lo, hi| println!(" rumble from game: pad={pad} low={lo} high={hi}"),
|o| println!(" hid output from game: {o:?}"),
);
if last.elapsed() >= Duration::from_millis(400) {
last = Instant::now();
i += 1;
let buttons = if i % 2 == 0 {
punktfunk_core::input::gamepad::BTN_A | extra_buttons // Cross (+ Edge paddles)
} else {
0
};
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
mgr.handle(&GamepadEvent::State(GamepadFrame {
index: idx as i16,
active_mask: 1 << idx,
buttons,
left_trigger: 0,
right_trigger: 0,
ls_x: lx,
ls_y: 0,
rs_x: 0,
rs_y: 0,
}));
}
std::thread::sleep(Duration::from_millis(15));
}
}};
}
if xbox {
// Xbox 360 via the XUSB companion: a different surface (handle + pump_rumble, no
// HID-output plane), so drive it inline rather than via the macro.
let mut mgr = crate::inject::gamepad::GamepadManager::new();
mgr.handle(&GamepadEvent::Arrival {
index: idx,
kind: 1,
capabilities: 0,
});
println!(
"virtual Xbox 360 (XUSB) up — sweeping LS + toggling A for {secs}s. Check with \
an XInput game or xinputtest.exe."
);
let deadline = Instant::now() + Duration::from_secs(secs);
let mut t = 0i32;
while Instant::now() < deadline {
mgr.pump_rumble(|pad, lo, hi| {
println!(" rumble from game: pad={pad} low={lo} high={hi}")
});
t += 1;
let lx = (((t % 200) - 100) * 327).clamp(-32768, 32767) as i16; // sweep ±32700
let buttons = if (t / 67) % 2 == 0 {
punktfunk_core::input::gamepad::BTN_A
} else {
0
};
mgr.handle(&GamepadEvent::State(GamepadFrame {
index: idx as i16,
active_mask: 1 << idx,
buttons,
left_trigger: 0,
right_trigger: 0,
ls_x: lx,
ls_y: 0,
rs_x: 0,
rs_y: 0,
}));
std::thread::sleep(Duration::from_millis(15));
}
} else if ds4 {
drive!(
crate::inject::dualshock4_windows::DualShock4WindowsManager::new(),
"DualShock 4"
);
} else if edge {
drive!(
crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new(),
"DualSense Edge"
);
} else if deck {
drive!(
crate::inject::steam_deck_windows::SteamDeckWindowsManager::new(),
"Steam Deck"
);
} else {
drive!(
crate::inject::dualsense_windows::DualSenseWindowsManager::new(),
"DualSense"
);
}
println!("dualsense-windows-test: done (devnode removed)");
Ok(())
}
+86 -399
View File
@@ -10,92 +10,10 @@
use crate::capture::{CapturedFrame, PixelFormat};
use anyhow::Result;
/// An encoded access unit (one NAL/AU) to hand to `punktfunk_core` for FEC + packetization.
/// `data` is in-band Annex-B (the encoder is opened without a global header), so each
/// keyframe carries its own VPS/SPS/PPS — the bytes are both a playable elementary
/// stream and a self-contained AU for the wire.
pub struct EncodedFrame {
pub data: Vec<u8>,
pub pts_ns: u64,
/// True for IDR/keyframes (sets the SOF/keyframe wire flags).
pub keyframe: bool,
/// True when this AU is a **reference-frame-invalidation recovery frame** — a clean P-frame the
/// encoder coded against a known-good reference in response to
/// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames). The pump tags it
/// [`punktfunk_core::packet::USER_FLAG_RECOVERY_ANCHOR`] so the client lifts its post-loss
/// freeze on it without an IDR. Set by BOTH RFI backends: native AMF (the LTR force-reference
/// frame) and Windows direct-NVENC (the first frame encoded after `nvEncInvalidateRefFrames` —
/// the invalidation applies at the next `encode_picture`, so that AU is by construction the
/// clean re-anchor). Without it the client's freeze can only lift on an IDR — which the host
/// suppresses after a successful RFI (the cooldown), a ~1 s frozen stall per loss event.
pub recovery_anchor: bool,
/// The AU is shard-aligned self-delimiting chunks (see [`Encoder::set_wire_chunking`]);
/// the session stamps [`punktfunk_core::packet::USER_FLAG_CHUNK_ALIGNED`] so the client
/// windows its parse and may opt into partial delivery. Only the PyroWave backend sets it.
pub chunk_aligned: bool,
}
/// Codec selection negotiated with the client.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Codec {
H264,
H265,
Av1,
/// PyroWave — the opt-in wired-LAN intra-only wavelet codec (design/pyrowave-codec-plan.md).
/// Only ever negotiated via the client's explicit `preferred_codec` (never the precedence
/// ladder) and only emitted by the `pyrowave`-feature backend; every AU is a keyframe.
PyroWave,
}
/// Chroma subsampling the encoder emits, negotiated with the client (the `PUNKTFUNK_444` gate + the
/// client's `VIDEO_CAP_444` + a GPU probe). `Yuv420` is the universal default; `Yuv444` is HEVC-only,
/// native-protocol-only (GameStream stays 4:2:0), and the host only ever passes it after
/// [`can_encode_444`] confirmed the active backend supports it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ChromaFormat {
#[default]
Yuv420,
Yuv444,
}
impl ChromaFormat {
/// The HEVC `chroma_format_idc` this maps to: `1` (4:2:0) or `3` (4:4:4). Also the wire value
/// echoed in [`punktfunk_core::quic::Welcome::chroma_format`].
pub fn idc(self) -> u8 {
match self {
ChromaFormat::Yuv420 => punktfunk_core::quic::CHROMA_IDC_420,
ChromaFormat::Yuv444 => punktfunk_core::quic::CHROMA_IDC_444,
}
}
/// True for full-chroma 4:4:4.
pub fn is_444(self) -> bool {
matches!(self, ChromaFormat::Yuv444)
}
}
mod codec;
pub(crate) use codec::*;
impl Codec {
/// Map a negotiated `quic` codec bit ([`punktfunk_core::quic::CODEC_H264`] etc.) to the encoder
/// [`Codec`]. Unknown / `0` → HEVC (the pre-negotiation default). Inverse of [`Codec::to_wire`].
pub fn from_wire(bit: u8) -> Codec {
match bit {
punktfunk_core::quic::CODEC_H264 => Codec::H264,
punktfunk_core::quic::CODEC_AV1 => Codec::Av1,
punktfunk_core::quic::CODEC_PYROWAVE => Codec::PyroWave,
_ => Codec::H265,
}
}
/// The single `quic` codec bit for this codec (echoed in [`punktfunk_core::quic::Welcome::codec`]).
pub fn to_wire(self) -> u8 {
match self {
Codec::H264 => punktfunk_core::quic::CODEC_H264,
Codec::H265 => punktfunk_core::quic::CODEC_HEVC,
Codec::Av1 => punktfunk_core::quic::CODEC_AV1,
Codec::PyroWave => punktfunk_core::quic::CODEC_PYROWAVE,
}
}
/// The `quic` codec bitfield the host can currently **emit** on the punktfunk/1 native path,
/// given the resolved encode backend — the same GPU-aware advertisement GameStream builds for
/// Moonlight ([`crate::gamestream::serverinfo`]), in `quic::CODEC_*` bits. The GPU-less software
@@ -173,275 +91,6 @@ impl Codec {
})();
base | pyro
}
/// Lowercase stats/console label (`"h264"` / `"hevc"` / `"av1"`) — the codec string seeded into
/// the web console's session meta ([`crate::stats_recorder::StatsRecorder::register_session`]).
pub fn label(self) -> &'static str {
match self {
Codec::H264 => "h264",
Codec::H265 => "hevc",
Codec::Av1 => "av1",
Codec::PyroWave => "pyrowave",
}
}
/// The FFmpeg NVENC encoder name (selected by name, not codec id — the latter would
/// pick the software encoder).
pub fn nvenc_name(self) -> &'static str {
match self {
Codec::H264 => "h264_nvenc",
Codec::H265 => "hevc_nvenc",
Codec::Av1 => "av1_nvenc",
// Guarded by the open_video dispatch: a PyroWave session never reaches a
// libavcodec backend.
Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"),
}
}
/// The FFmpeg VAAPI encoder name (AMD via Mesa `radeonsi`, Intel via `iHD`/`i965`). One
/// libavcodec encoder per codec covers both vendors — the kernel driver differs, the libva
/// userspace API is identical. Selected by name (the codec id would pick the SW encoder).
/// AV1 VAAPI encode is narrow (Intel Arc/Xe2+, AMD RDNA3+/RDNA4) — gate it on a capability
/// probe, never assume it (see [`open_video`]).
pub fn vaapi_name(self) -> &'static str {
match self {
Codec::H264 => "h264_vaapi",
Codec::H265 => "hevc_vaapi",
Codec::Av1 => "av1_vaapi",
// Guarded by the open_video dispatch: a PyroWave session never reaches a
// libavcodec backend.
Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"),
}
}
/// The FFmpeg AMD **AMF** encoder name (the Windows AMD backend). Selected by name (the codec id
/// would pick the software encoder). AV1 (`av1_amf`) is RDNA3+/RX 7000+ — probe, never assume.
pub fn amf_name(self) -> &'static str {
match self {
Codec::H264 => "h264_amf",
Codec::H265 => "hevc_amf",
Codec::Av1 => "av1_amf",
// Guarded by the open_video dispatch: a PyroWave session never reaches a
// libavcodec backend.
Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"),
}
}
/// The FFmpeg Intel **QSV** encoder name (the Windows Intel backend). Selected by name. AV1
/// (`av1_qsv`) is Arc/Xe2+; HEVC Main10 is Gen9.5+ — probe, never assume.
pub fn qsv_name(self) -> &'static str {
match self {
Codec::H264 => "h264_qsv",
Codec::H265 => "hevc_qsv",
Codec::Av1 => "av1_qsv",
// Guarded by the open_video dispatch: a PyroWave session never reaches a
// libavcodec backend.
Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"),
}
}
}
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and HDR
/// plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap `Copy`; fixed
/// for the session (an HDR toggle re-initialises the encoder — re-query if that matters).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct EncoderCaps {
/// The encoder can perform real reference-frame invalidation — i.e.
/// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) can return `true`. When `false`
/// the caller skips that always-`false` call and forces a keyframe directly on loss recovery.
/// Two backends implement RFI: Windows direct-NVENC (`nvEncInvalidateRefFrames`) and native
/// AMF (user-LTR force-reference, when the driver accepted the LTR slots at open). The
/// libavcodec paths (Linux NVENC, VAAPI, QSV) can't express it and always keyframe.
pub supports_rfi: bool,
/// The encoder emits in-band HDR mastering/CLL SEI from [`set_hdr_meta`](Encoder::set_hdr_meta).
/// When `false`, `set_hdr_meta` is a no-op and no in-band grade reaches the client. Only the
/// Windows direct-NVENC path attaches it today.
pub supports_hdr_metadata: bool,
/// The opened encoder is actually producing a full-chroma 4:4:4 (`chroma_format_idc = 3`) stream.
/// `false` on every 4:2:0 session (the default) and on a backend that declined 4:4:4. Set by the
/// NVENC backends (Linux + Windows). The chroma is committed to the wire (`Welcome::chroma_format`)
/// from the pre-open probe, so this is a *post-open cross-check*: the session glue logs loudly if
/// the encoder's real chroma disagrees with what was negotiated (the in-band SPS is authoritative
/// for the decoder either way).
pub chroma_444: bool,
/// The encoder runs a periodic **intra-refresh wave** — a moving band of intra blocks that
/// re-codes the whole picture over ~0.5 s, no periodic IDR. FEC-unrecoverable loss self-heals as
/// the band sweeps, so the session glue rate-limits client keyframe requests instead of answering
/// each with a full IDR (the 20-40× frame-size spike that cascades under loss). Linux NVENC / AMF
/// set it when `PUNKTFUNK_INTRA_REFRESH` opened the encoder in that mode; VAAPI/QSV/software never
/// do. NOTE — the wave carries NO decoder-visible clean-point: FFmpeg never sets `AV_FRAME_FLAG_KEY`
/// at a recovery point (H.264 flags key only when `recovery_frame_cnt == 0`; HEVC only on IRAP),
/// and AMF emits no recovery-point SEI at all. So this cap ALONE does not let the client lift its
/// post-loss freeze without an IDR — that needs [`intra_refresh_recovery`](Self::intra_refresh_recovery).
pub intra_refresh: bool,
/// The intra-refresh wave is a *validated constrained GDR* — verified on real hardware to fully
/// heal a lost picture within one wave period with no residual artifacts. Only then does the host
/// tag each wave-boundary AU with [`USER_FLAG_RECOVERY_POINT`](punktfunk_core::packet::USER_FLAG_RECOVERY_POINT),
/// so the client can lift its freeze on the second mark (a proven clean re-anchor) instead of
/// waiting out its backstop and forcing a full IDR. Default `false` on every backend until on-glass
/// validation flips it — an un-validated encoder keeps the IDR recovery path, so this is inert and
/// cannot regress. Meaningless unless [`intra_refresh`](Self::intra_refresh) is also set.
pub intra_refresh_recovery: bool,
/// Length of the intra-refresh wave in frames — the boundary period the host marks on (it sets
/// `USER_FLAG_RECOVERY_POINT` on every Nth emitted AU, re-phased at each IDR). 0 when intra-refresh
/// is off. Only consulted when [`intra_refresh_recovery`](Self::intra_refresh_recovery) is set.
pub intra_refresh_period: u32,
}
/// A hardware encoder. One per session; runs on the encode thread.
pub trait Encoder: Send {
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
/// [`submit`](Self::submit) with the **wire frame index** this frame's AU will carry — the
/// number the packetizer stamps on it and the client's loss reports/RFI requests name. The
/// session glue predicts it exactly as `AUs sent so far + frames in flight` (AUs are emitted
/// FIFO, one per submission; anything that would break the prediction — an in-place reset, a
/// device-change teardown, an encoder rebuild — forfeits the in-flight frames on BOTH sides
/// and clears the encoder's reference state, so stale predictions die with it). The RFI
/// backends pin their frame numbering (LTR marks, DPB timestamps) to this so
/// [`invalidate_ref_frames`](Self::invalidate_ref_frames) compares client frame numbers
/// against the same domain — an encoder-internal counter desyncs from the wire on the first
/// mid-stream rebuild (adaptive bitrate steps do this under congestion, exactly when losses
/// happen). Default: ignore the index and delegate to `submit` (backends without per-frame
/// reference bookkeeping don't care).
fn submit_indexed(&mut self, frame: &CapturedFrame, wire_index: u32) -> Result<()> {
let _ = wire_index;
self.submit(frame)
}
/// This encoder's static [capabilities](EncoderCaps) (RFI, HDR SEI), so the session glue can
/// route by query rather than rely on the no-op/`false` defaults of
/// [`invalidate_ref_frames`](Self::invalidate_ref_frames) / [`set_hdr_meta`](Self::set_hdr_meta).
/// Default: no optional capabilities (the SDR / libavcodec backends) — only the direct-NVENC
/// path overrides it.
fn caps(&self) -> EncoderCaps {
EncoderCaps::default()
}
/// Force the next submitted frame to be an IDR keyframe (e.g. after a client
/// reference-frame-invalidation request). Default: no-op.
fn request_keyframe(&mut self) {}
/// Set the source's static HDR mastering metadata (from the capturer). An HDR encoder emits it
/// as in-band SEI (`mastering_display_colour_volume` + `content_light_level_info`) on each
/// keyframe so any decoder — including stock Moonlight — tone-maps from the source's real grade.
/// Default: no-op (SDR encoders / libavcodec paths that don't attach it yet). Cheap to call
/// every frame; only the direct-NVENC path consumes it.
fn set_hdr_meta(&mut self, _meta: Option<punktfunk_core::quic::HdrMeta>) {}
/// Invalidate a contiguous range of previously-encoded reference frames (client frame numbers
/// — WIRE frame indexes, the domain [`submit_indexed`](Self::submit_indexed) pins the encoder's
/// bookkeeping to) so the encoder re-references an older still-valid frame instead of emitting
/// a full IDR. Returns `true` if a real reference invalidation was performed; `false` means the
/// encoder couldn't (range older than the DPB/LTR history, or the backend has no RFI) and the
/// caller should fall back to [`request_keyframe`](Self::request_keyframe). Default: `false` —
/// the Windows direct-NVENC path (`nvEncInvalidateRefFrames`) and native AMF (LTR
/// force-reference) implement true RFI; the libavcodec paths can't express it, so they keyframe.
fn invalidate_ref_frames(&mut self, _first_frame: i64, _last_frame: i64) -> bool {
false
}
/// Pull the next encoded AU if one is ready.
fn poll(&mut self) -> Result<Option<EncodedFrame>>;
/// Tear the underlying hardware encoder down and rebuild it in place, keeping the session's
/// negotiated parameters — the encode-stall watchdog's recovery lever (a wedged AMF/QSV
/// driver stops emitting AUs or accepting frames without ever returning an error). Returns
/// `true` when the encoder was rebuilt: every submitted-but-unpolled frame is forfeited and
/// the next submitted frame starts a fresh stream (IDR). Default `false`: the backend has no
/// in-place rebuild and the caller must treat the stall as fatal instead.
fn reset(&mut self) -> bool {
false
}
/// Retarget the encoder's rate control to `bps` (average == max, CBR) **in place** — same
/// codec/resolution/fps, only the bitrate and its derived VBV move. Returns `true` when the
/// live encoder accepted the change: the reference chain, the in-flight frames and the
/// caller's wire-index prediction all survive, so an adaptive-bitrate step costs *nothing* on
/// the wire (no IDR, no in-flight forfeit — the whole point vs. a rebuild). `false` = the
/// backend can't (or the driver rejected the new rate, e.g. above the codec-level ceiling) —
/// the caller falls back to its full rebuild path, which also owns the bitrate clamping.
/// Default: no in-place retarget (the libavcodec/software paths).
fn reconfigure_bitrate(&mut self, _bps: u64) -> bool {
false
}
/// Wire-chunk the encoder's AUs at the session's shard payload size (the PyroWave
/// datagram-aligned mode, plan §4.4): every `shard_payload` window of the emitted AU
/// starts a fresh self-delimiting codec packet, zero-padded to the window — so a lost
/// datagram costs a few coefficient blocks, not the frame. AUs produced this way are
/// flagged [`EncodedFrame::chunk_aligned`] and the session marks them on the wire.
/// Default: no-op (the H.26x backends' bitstreams cannot be cut losslessly).
fn set_wire_chunking(&mut self, _shard_payload: usize) {}
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
fn flush(&mut self) -> Result<()>;
}
impl Codec {
/// Maximum encodable dimension (px) per side for this codec on NVENC. H.264 tops out at
/// 4096 (level constraint); HEVC and AV1 allow 8192. Used to reject out-of-range client
/// modes up front (see [`validate_dimensions`]).
pub fn max_dimension(self) -> u32 {
match self {
Codec::H264 => 4096,
// PyroWave has no codec-level dimension cap (arbitrary even sizes); 8192 matches the
// buffer-math guard the other codecs get.
Codec::H265 | Codec::Av1 | Codec::PyroWave => 8192,
}
}
/// The codec's *spec* top level/tier bitrate (bits/s) — the usual boundary at which NVENC
/// starts rejecting `avcodec_open2` with EINVAL. NOT a hard cap: [`open_video`](crate::encode::
/// open_video) probes the actual GPU ceiling by stepping DOWN from the requested bitrate only on
/// EINVAL, and uses this purely as the first step-down candidate (so a card that accepts more —
/// an RTX 5070 Ti does >1 Gbps HEVC where a 4090 caps at ~800 Mbps — is never clamped to it).
/// HEVC Level 6.2 High tier = 800 Mbps; H.264 High level 6.2 ≈ 480 Mbps; AV1's levels allow more.
pub fn max_bitrate_bps(self) -> u64 {
match self {
Codec::H264 => 480_000_000,
Codec::H265 => 800_000_000,
Codec::Av1 => 1_200_000_000,
// No spec level/tier: the rate is a plain per-frame byte budget. Use the protocol's
// own bitrate clamp so the step-down probe logic never binds below it.
Codec::PyroWave => 8_000_000_000,
}
}
}
/// `PUNKTFUNK_VBV_FRAMES` — HRD/VBV size in frame intervals (default 1.0, the strict low-latency
/// shape every backend ships: each frame must fit its rate share, keeping frame sizes uniform for
/// the pacer). The AMF/VAAPI/QSV paths parse the same variable locally; this helper brings the
/// direct-NVENC paths (which used to hardwire 1 frame) to parity. Larger values let complex
/// frames borrow bits — better rate utilization at the cost of per-frame size variance.
pub(crate) fn vbv_frames_env() -> f64 {
std::env::var("PUNKTFUNK_VBV_FRAMES")
.ok()
.and_then(|s| s.parse::<f64>().ok())
.filter(|v| v.is_finite() && *v > 0.0)
.unwrap_or(1.0)
}
/// Validate a requested encode resolution before we allocate buffers or open NVENC. Rejects
/// zero/odd-sized and out-of-range modes with a clear error instead of letting buffer math
/// overflow or the encoder open fail with an opaque NVENC code. A client can request any
/// `mode=WxHxFPS`, so this is the gate on attacker/typo-controlled dimensions.
pub fn validate_dimensions(codec: Codec, width: u32, height: u32) -> Result<()> {
if width == 0 || height == 0 {
anyhow::bail!("invalid encode resolution {width}x{height}: dimensions must be non-zero");
}
// NVENC requires even dimensions for the chroma subsampling it does internally.
if width % 2 != 0 || height % 2 != 0 {
anyhow::bail!("invalid encode resolution {width}x{height}: dimensions must be even");
}
// PyroWave's 5-level wavelet decomposition needs ≥ 4·2⁵ px per axis (upstream
// `MinimumImageSize` — the band mirroring breaks below it); reject a tiny mode here
// (e.g. a match-window resize dragged to a sliver) instead of failing the encoder
// rebuild after the switch was acked.
if codec == Codec::PyroWave && (width < 128 || height < 128) {
anyhow::bail!(
"invalid PyroWave resolution {width}x{height}: the wavelet needs at least 128px per axis"
);
}
let max = codec.max_dimension();
if width > max || height > max {
anyhow::bail!(
"{codec:?} max dimension is {max}px; requested {width}x{height} \
(use HEVC/AV1 above 4096, or lower the client resolution)"
);
}
Ok(())
}
/// Open a hardware video encoder for frames of the given `format` and mode, selecting the GPU
@@ -1196,6 +845,76 @@ pub fn can_encode_444(_codec: Codec) -> bool {
false
}
/// Whether the active GPU encode backend can actually produce a **10-bit** stream for `codec`
/// (HEVC Main10 / AV1 10-bit). Resolved (and cached per selected GPU) *before* the Welcome so the
/// negotiated bit depth — and the HDR/SDR colour label derived from it — matches what the encoder
/// will really emit: the honest-downgrade channel, exactly like [`can_encode_444`]. Without this
/// gate a default-on `PUNKTFUNK_10BIT` would negotiate 10-bit on a GPU/backend that then silently
/// falls back to 8-bit post-Welcome (label HDR / stream SDR).
///
/// Backend truth: Windows **NVENC** queries the per-codec `NV_ENC_CAPS_SUPPORT_10BIT_ENCODE` cap;
/// native **AMF** `Init`s a tiny P010 encoder with the 10-bit profile props (the driver rejects
/// what the VCN can't do). **QSV** stays `false` until validated on Intel glass — the libavcodec
/// Main10 incantation can silently encode 8-bit, the same stance as its 4:4:4 probe. Every
/// **Linux** backend is `false` today: direct-NVENC/CUDA pins 8-bit until a P010 capture path
/// exists (Phase 5.1), libav `hevc_nvenc` needs a 10-bit input format the capturer never feeds,
/// VAAPI 10-bit isn't wired, and Vulkan-video hardcodes 8-bit — so Linux hosts honestly negotiate
/// 8-bit SDR.
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub fn can_encode_10bit(codec: Codec) -> bool {
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
if !codec.supports_10bit() {
return false;
}
// Cached per (selected GPU, codec) — a web-console preference change re-probes on the newly
// selected adapter before the next Welcome, mirroring `can_encode_444`.
static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), bool>>> = OnceLock::new();
let key = (crate::gpu::selection_key(), codec.label());
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(v) = cache.lock().unwrap().get(&key) {
return *v;
}
let supported = {
#[cfg(target_os = "linux")]
{
// No Linux backend encodes 10-bit yet (see the fn doc) — never negotiate it.
false
}
#[cfg(target_os = "windows")]
{
match windows_resolved_backend() {
WindowsBackend::Nvenc => {
#[cfg(feature = "nvenc")]
{
nvenc::probe_can_encode_10bit(codec)
}
#[cfg(not(feature = "nvenc"))]
{
false
}
}
WindowsBackend::Amf => amf::probe_can_encode_10bit(codec),
// QSV: deferred like its 4:4:4 probe (`ffmpeg_win::probe_can_encode_444`) — no
// Intel Windows box in the lab to validate that the libavcodec profile really
// emits Main10 rather than silently 8-bit.
WindowsBackend::Qsv => false,
WindowsBackend::Software => false,
}
}
};
tracing::info!(codec = ?codec, supported, "10-bit encode capability probed");
cache.lock().unwrap().insert(key, supported);
supported
}
/// Non-Linux/Windows (the macOS dev/test build of the host — synthetic-source loopback only):
/// no GPU encode backend exists here, so 10-bit is never negotiated.
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub fn can_encode_10bit(_codec: Codec) -> bool {
false
}
// ---------------------------------------------------------------------------------------------
// Windows backend selection (the analogue of the Linux nvidia_present / linux_zero_copy_is_vaapi
// logic). NVIDIA → NVENC, AMD → AMF, Intel → QSV; `auto` (default) reads the vendor of the
@@ -1379,6 +1098,20 @@ mod nvenc;
#[cfg(all(target_os = "linux", feature = "nvenc"))]
#[path = "encode/linux/nvenc_cuda.rs"]
mod nvenc_cuda;
// Actionable `NVENCSTATUS` → cause mapping shared by both direct-NVENC backends, so a failed
// session open logs "update/reboot the driver" instead of the old misleading "(no NVIDIA GPU?)".
#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "nvenc"))]
#[path = "encode/nvenc_status.rs"]
mod nvenc_status;
// Platform-agnostic direct-SDK NVENC glue (`NvStatusExt`/`nv_ok`, `codec_guid`) shared by both
// `nvEncodeAPI` backends — the byte-identical Tier-2 leaves (plan §2.2). Sibling of `nvenc_status`.
#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "nvenc"))]
#[path = "encode/nvenc_core.rs"]
mod nvenc_core;
// Shared libavcodec glue (`pixel_to_av`, swscale consts) for the three libav backends — Linux
// NVENC + VAAPI and Windows AMF/QSV — so the byte-identical pieces live once (plan §2.2, Tier 2).
#[cfg(any(target_os = "linux", all(target_os = "windows", feature = "amf-qsv")))]
mod libav;
// Software (openh264) H.264 encoder — the GPU-less path on BOTH Windows and Linux (a headless /
// GPU-less test box, or a fallback when no hardware encoder is available). Platform-agnostic: it
// consumes CPU RGB `CapturedFrame`s and the statically-bundled openh264 build.
@@ -1420,41 +1153,6 @@ mod pyrowave;
mod tests {
use super::*;
#[test]
fn rejects_zero_and_odd_dimensions() {
assert!(validate_dimensions(Codec::H265, 0, 1080).is_err());
assert!(validate_dimensions(Codec::H265, 1920, 0).is_err());
assert!(validate_dimensions(Codec::H265, 1921, 1080).is_err()); // odd width
assert!(validate_dimensions(Codec::H265, 1920, 1081).is_err()); // odd height
}
#[test]
fn h264_capped_at_4096() {
assert!(validate_dimensions(Codec::H264, 3840, 2160).is_ok()); // 4K fits (width < 4096)
assert!(validate_dimensions(Codec::H264, 4096, 4096).is_ok()); // exactly at the limit
assert!(validate_dimensions(Codec::H264, 4098, 2160).is_err());
assert!(validate_dimensions(Codec::H264, 3840, 4098).is_err());
}
#[test]
fn hevc_and_av1_allow_up_to_8192() {
for c in [Codec::H265, Codec::Av1] {
assert!(validate_dimensions(c, 3840, 2160).is_ok());
assert!(validate_dimensions(c, 7680, 4320).is_ok()); // 8K fits
assert!(validate_dimensions(c, 8192, 8192).is_ok());
assert!(validate_dimensions(c, 8194, 4320).is_err());
}
}
#[test]
fn common_modes_accepted() {
for c in [Codec::H264, Codec::H265, Codec::Av1] {
for (w, h) in [(1280, 720), (1920, 1080), (2560, 1440)] {
assert!(validate_dimensions(c, w, h).is_ok(), "{c:?} {w}x{h}");
}
}
}
/// The probed-capability → wire-bitfield mapping the native codec advertisement is built from.
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[test]
@@ -1481,15 +1179,4 @@ mod tests {
};
assert_eq!(none.wire_mask(), None);
}
/// Wire round-trip and the stats label stay in lockstep with the `quic::CODEC_*` bits.
#[test]
fn codec_wire_roundtrip_and_label() {
for c in [Codec::H264, Codec::H265, Codec::Av1] {
assert_eq!(Codec::from_wire(c.to_wire()), c);
}
assert_eq!(Codec::H264.label(), "h264");
assert_eq!(Codec::H265.label(), "hevc");
assert_eq!(Codec::Av1.label(), "av1");
}
}
+424
View File
@@ -0,0 +1,424 @@
//! The encoder contract (plan §7, Tier 1): the [`Encoder`] trait plus the plain-data value types its
//! signatures use — [`EncodedFrame`], [`Codec`], [`ChromaFormat`], [`EncoderCaps`] — and the
//! dimension/VBV helpers [`validate_dimensions`] and [`vbv_frames_env`]. Backend selection, the
//! capability probes that mirror it, and `Codec::host_wire_caps` stay in the parent [`crate::encode`]
//! facade, which re-exports this module (`pub(crate) use codec::*;`) so every `crate::encode::*` path
//! is unchanged.
use crate::capture::CapturedFrame;
use anyhow::Result;
/// An encoded access unit (one NAL/AU) to hand to `punktfunk_core` for FEC + packetization.
/// `data` is in-band Annex-B (the encoder is opened without a global header), so each
/// keyframe carries its own VPS/SPS/PPS — the bytes are both a playable elementary
/// stream and a self-contained AU for the wire.
pub struct EncodedFrame {
pub data: Vec<u8>,
pub pts_ns: u64,
/// True for IDR/keyframes (sets the SOF/keyframe wire flags).
pub keyframe: bool,
/// True when this AU is a **reference-frame-invalidation recovery frame** — a clean P-frame the
/// encoder coded against a known-good reference in response to
/// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames). The pump tags it
/// [`punktfunk_core::packet::USER_FLAG_RECOVERY_ANCHOR`] so the client lifts its post-loss
/// freeze on it without an IDR. Set by BOTH RFI backends: native AMF (the LTR force-reference
/// frame) and Windows direct-NVENC (the first frame encoded after `nvEncInvalidateRefFrames` —
/// the invalidation applies at the next `encode_picture`, so that AU is by construction the
/// clean re-anchor). Without it the client's freeze can only lift on an IDR — which the host
/// suppresses after a successful RFI (the cooldown), a ~1 s frozen stall per loss event.
pub recovery_anchor: bool,
/// The AU is shard-aligned self-delimiting chunks (see [`Encoder::set_wire_chunking`]);
/// the session stamps [`punktfunk_core::packet::USER_FLAG_CHUNK_ALIGNED`] so the client
/// windows its parse and may opt into partial delivery. Only the PyroWave backend sets it.
pub chunk_aligned: bool,
}
/// Codec selection negotiated with the client.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Codec {
H264,
H265,
Av1,
/// PyroWave — the opt-in wired-LAN intra-only wavelet codec (design/pyrowave-codec-plan.md).
/// Only ever negotiated via the client's explicit `preferred_codec` (never the precedence
/// ladder) and only emitted by the `pyrowave`-feature backend; every AU is a keyframe.
PyroWave,
}
/// Chroma subsampling the encoder emits, negotiated with the client (the `PUNKTFUNK_444` gate + the
/// client's `VIDEO_CAP_444` + a GPU probe). `Yuv420` is the universal default; `Yuv444` is HEVC-only,
/// native-protocol-only (GameStream stays 4:2:0), and the host only ever passes it after
/// [`can_encode_444`] confirmed the active backend supports it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ChromaFormat {
#[default]
Yuv420,
Yuv444,
}
impl ChromaFormat {
/// The HEVC `chroma_format_idc` this maps to: `1` (4:2:0) or `3` (4:4:4). Also the wire value
/// echoed in [`punktfunk_core::quic::Welcome::chroma_format`].
pub fn idc(self) -> u8 {
match self {
ChromaFormat::Yuv420 => punktfunk_core::quic::CHROMA_IDC_420,
ChromaFormat::Yuv444 => punktfunk_core::quic::CHROMA_IDC_444,
}
}
/// True for full-chroma 4:4:4.
pub fn is_444(self) -> bool {
matches!(self, ChromaFormat::Yuv444)
}
}
impl Codec {
/// Map a negotiated `quic` codec bit ([`punktfunk_core::quic::CODEC_H264`] etc.) to the encoder
/// [`Codec`]. Unknown / `0` → HEVC (the pre-negotiation default). Inverse of [`Codec::to_wire`].
pub fn from_wire(bit: u8) -> Codec {
match bit {
punktfunk_core::quic::CODEC_H264 => Codec::H264,
punktfunk_core::quic::CODEC_AV1 => Codec::Av1,
punktfunk_core::quic::CODEC_PYROWAVE => Codec::PyroWave,
_ => Codec::H265,
}
}
/// The single `quic` codec bit for this codec (echoed in [`punktfunk_core::quic::Welcome::codec`]).
pub fn to_wire(self) -> u8 {
match self {
Codec::H264 => punktfunk_core::quic::CODEC_H264,
Codec::H265 => punktfunk_core::quic::CODEC_HEVC,
Codec::Av1 => punktfunk_core::quic::CODEC_AV1,
Codec::PyroWave => punktfunk_core::quic::CODEC_PYROWAVE,
}
}
/// Lowercase stats/console label (`"h264"` / `"hevc"` / `"av1"`) — the codec string seeded into
/// the web console's session meta ([`crate::stats_recorder::StatsRecorder::register_session`]).
pub fn label(self) -> &'static str {
match self {
Codec::H264 => "h264",
Codec::H265 => "hevc",
Codec::Av1 => "av1",
Codec::PyroWave => "pyrowave",
}
}
/// Whether this codec has a negotiable **10-bit** encode path (HEVC Main10 / AV1 10-bit).
/// H.264 is always 8-bit (High10 is neither an NVENC nor a VCN encode mode — negotiation
/// never asks), and PyroWave's wavelet path ingests 8-bit. `true` here is only the
/// *codec-level* gate: the active GPU/backend must still pass
/// [`can_encode_10bit`](crate::encode::can_encode_10bit) before the host negotiates 10-bit.
pub fn supports_10bit(self) -> bool {
matches!(self, Codec::H265 | Codec::Av1)
}
/// The FFmpeg NVENC encoder name (selected by name, not codec id — the latter would
/// pick the software encoder).
pub fn nvenc_name(self) -> &'static str {
match self {
Codec::H264 => "h264_nvenc",
Codec::H265 => "hevc_nvenc",
Codec::Av1 => "av1_nvenc",
// Guarded by the open_video dispatch: a PyroWave session never reaches a
// libavcodec backend.
Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"),
}
}
/// The FFmpeg VAAPI encoder name (AMD via Mesa `radeonsi`, Intel via `iHD`/`i965`). One
/// libavcodec encoder per codec covers both vendors — the kernel driver differs, the libva
/// userspace API is identical. Selected by name (the codec id would pick the SW encoder).
/// AV1 VAAPI encode is narrow (Intel Arc/Xe2+, AMD RDNA3+/RDNA4) — gate it on a capability
/// probe, never assume it (see [`open_video`]).
pub fn vaapi_name(self) -> &'static str {
match self {
Codec::H264 => "h264_vaapi",
Codec::H265 => "hevc_vaapi",
Codec::Av1 => "av1_vaapi",
// Guarded by the open_video dispatch: a PyroWave session never reaches a
// libavcodec backend.
Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"),
}
}
/// The FFmpeg AMD **AMF** encoder name (the Windows AMD backend). Selected by name (the codec id
/// would pick the software encoder). AV1 (`av1_amf`) is RDNA3+/RX 7000+ — probe, never assume.
pub fn amf_name(self) -> &'static str {
match self {
Codec::H264 => "h264_amf",
Codec::H265 => "hevc_amf",
Codec::Av1 => "av1_amf",
// Guarded by the open_video dispatch: a PyroWave session never reaches a
// libavcodec backend.
Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"),
}
}
/// The FFmpeg Intel **QSV** encoder name (the Windows Intel backend). Selected by name. AV1
/// (`av1_qsv`) is Arc/Xe2+; HEVC Main10 is Gen9.5+ — probe, never assume.
pub fn qsv_name(self) -> &'static str {
match self {
Codec::H264 => "h264_qsv",
Codec::H265 => "hevc_qsv",
Codec::Av1 => "av1_qsv",
// Guarded by the open_video dispatch: a PyroWave session never reaches a
// libavcodec backend.
Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"),
}
}
}
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and HDR
/// plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap `Copy`; fixed
/// for the session (an HDR toggle re-initialises the encoder — re-query if that matters).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct EncoderCaps {
/// The encoder can perform real reference-frame invalidation — i.e.
/// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) can return `true`. When `false`
/// the caller skips that always-`false` call and forces a keyframe directly on loss recovery.
/// Two backends implement RFI: Windows direct-NVENC (`nvEncInvalidateRefFrames`) and native
/// AMF (user-LTR force-reference, when the driver accepted the LTR slots at open). The
/// libavcodec paths (Linux NVENC, VAAPI, QSV) can't express it and always keyframe.
pub supports_rfi: bool,
/// The encoder emits in-band HDR mastering/CLL SEI from [`set_hdr_meta`](Encoder::set_hdr_meta).
/// When `false`, `set_hdr_meta` is a no-op and no in-band grade reaches the client. Only the
/// Windows direct-NVENC path attaches it today.
pub supports_hdr_metadata: bool,
/// The opened encoder is actually producing a full-chroma 4:4:4 (`chroma_format_idc = 3`) stream.
/// `false` on every 4:2:0 session (the default) and on a backend that declined 4:4:4. Set by the
/// NVENC backends (Linux + Windows). The chroma is committed to the wire (`Welcome::chroma_format`)
/// from the pre-open probe, so this is a *post-open cross-check*: the session glue logs loudly if
/// the encoder's real chroma disagrees with what was negotiated (the in-band SPS is authoritative
/// for the decoder either way).
pub chroma_444: bool,
/// The encoder runs a periodic **intra-refresh wave** — a moving band of intra blocks that
/// re-codes the whole picture over ~0.5 s, no periodic IDR. FEC-unrecoverable loss self-heals as
/// the band sweeps, so the session glue rate-limits client keyframe requests instead of answering
/// each with a full IDR (the 20-40× frame-size spike that cascades under loss). Linux NVENC / AMF
/// set it when `PUNKTFUNK_INTRA_REFRESH` opened the encoder in that mode; VAAPI/QSV/software never
/// do. NOTE — the wave carries NO decoder-visible clean-point: FFmpeg never sets `AV_FRAME_FLAG_KEY`
/// at a recovery point (H.264 flags key only when `recovery_frame_cnt == 0`; HEVC only on IRAP),
/// and AMF emits no recovery-point SEI at all. So this cap ALONE does not let the client lift its
/// post-loss freeze without an IDR — that needs [`intra_refresh_recovery`](Self::intra_refresh_recovery).
pub intra_refresh: bool,
/// The intra-refresh wave is a *validated constrained GDR* — verified on real hardware to fully
/// heal a lost picture within one wave period with no residual artifacts. Only then does the host
/// tag each wave-boundary AU with [`USER_FLAG_RECOVERY_POINT`](punktfunk_core::packet::USER_FLAG_RECOVERY_POINT),
/// so the client can lift its freeze on the second mark (a proven clean re-anchor) instead of
/// waiting out its backstop and forcing a full IDR. Default `false` on every backend until on-glass
/// validation flips it — an un-validated encoder keeps the IDR recovery path, so this is inert and
/// cannot regress. Meaningless unless [`intra_refresh`](Self::intra_refresh) is also set.
pub intra_refresh_recovery: bool,
/// Length of the intra-refresh wave in frames — the boundary period the host marks on (it sets
/// `USER_FLAG_RECOVERY_POINT` on every Nth emitted AU, re-phased at each IDR). 0 when intra-refresh
/// is off. Only consulted when [`intra_refresh_recovery`](Self::intra_refresh_recovery) is set.
pub intra_refresh_period: u32,
}
/// A hardware encoder. One per session; runs on the encode thread.
pub trait Encoder: Send {
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
/// [`submit`](Self::submit) with the **wire frame index** this frame's AU will carry — the
/// number the packetizer stamps on it and the client's loss reports/RFI requests name. The
/// session glue predicts it exactly as `AUs sent so far + frames in flight` (AUs are emitted
/// FIFO, one per submission; anything that would break the prediction — an in-place reset, a
/// device-change teardown, an encoder rebuild — forfeits the in-flight frames on BOTH sides
/// and clears the encoder's reference state, so stale predictions die with it). The RFI
/// backends pin their frame numbering (LTR marks, DPB timestamps) to this so
/// [`invalidate_ref_frames`](Self::invalidate_ref_frames) compares client frame numbers
/// against the same domain — an encoder-internal counter desyncs from the wire on the first
/// mid-stream rebuild (adaptive bitrate steps do this under congestion, exactly when losses
/// happen). Default: ignore the index and delegate to `submit` (backends without per-frame
/// reference bookkeeping don't care).
fn submit_indexed(&mut self, frame: &CapturedFrame, wire_index: u32) -> Result<()> {
let _ = wire_index;
self.submit(frame)
}
/// This encoder's static [capabilities](EncoderCaps) (RFI, HDR SEI), so the session glue can
/// route by query rather than rely on the no-op/`false` defaults of
/// [`invalidate_ref_frames`](Self::invalidate_ref_frames) / [`set_hdr_meta`](Self::set_hdr_meta).
/// Default: no optional capabilities (the SDR / libavcodec backends) — only the direct-NVENC
/// path overrides it.
fn caps(&self) -> EncoderCaps {
EncoderCaps::default()
}
/// Force the next submitted frame to be an IDR keyframe (e.g. after a client
/// reference-frame-invalidation request). Default: no-op.
fn request_keyframe(&mut self) {}
/// Set the source's static HDR mastering metadata (from the capturer). An HDR encoder emits it
/// as in-band SEI (`mastering_display_colour_volume` + `content_light_level_info`) on each
/// keyframe so any decoder — including stock Moonlight — tone-maps from the source's real grade.
/// Default: no-op (SDR encoders / libavcodec paths that don't attach it yet). Cheap to call
/// every frame; only the direct-NVENC path consumes it.
fn set_hdr_meta(&mut self, _meta: Option<punktfunk_core::quic::HdrMeta>) {}
/// Invalidate a contiguous range of previously-encoded reference frames (client frame numbers
/// — WIRE frame indexes, the domain [`submit_indexed`](Self::submit_indexed) pins the encoder's
/// bookkeeping to) so the encoder re-references an older still-valid frame instead of emitting
/// a full IDR. Returns `true` if a real reference invalidation was performed; `false` means the
/// encoder couldn't (range older than the DPB/LTR history, or the backend has no RFI) and the
/// caller should fall back to [`request_keyframe`](Self::request_keyframe). Default: `false` —
/// the Windows direct-NVENC path (`nvEncInvalidateRefFrames`) and native AMF (LTR
/// force-reference) implement true RFI; the libavcodec paths can't express it, so they keyframe.
fn invalidate_ref_frames(&mut self, _first_frame: i64, _last_frame: i64) -> bool {
false
}
/// Pull the next encoded AU if one is ready.
fn poll(&mut self) -> Result<Option<EncodedFrame>>;
/// Tear the underlying hardware encoder down and rebuild it in place, keeping the session's
/// negotiated parameters — the encode-stall watchdog's recovery lever (a wedged AMF/QSV
/// driver stops emitting AUs or accepting frames without ever returning an error). Returns
/// `true` when the encoder was rebuilt: every submitted-but-unpolled frame is forfeited and
/// the next submitted frame starts a fresh stream (IDR). Default `false`: the backend has no
/// in-place rebuild and the caller must treat the stall as fatal instead.
fn reset(&mut self) -> bool {
false
}
/// Retarget the encoder's rate control to `bps` (average == max, CBR) **in place** — same
/// codec/resolution/fps, only the bitrate and its derived VBV move. Returns `true` when the
/// live encoder accepted the change: the reference chain, the in-flight frames and the
/// caller's wire-index prediction all survive, so an adaptive-bitrate step costs *nothing* on
/// the wire (no IDR, no in-flight forfeit — the whole point vs. a rebuild). `false` = the
/// backend can't (or the driver rejected the new rate, e.g. above the codec-level ceiling) —
/// the caller falls back to its full rebuild path, which also owns the bitrate clamping.
/// Default: no in-place retarget (the libavcodec/software paths).
fn reconfigure_bitrate(&mut self, _bps: u64) -> bool {
false
}
/// Wire-chunk the encoder's AUs at the session's shard payload size (the PyroWave
/// datagram-aligned mode, plan §4.4): every `shard_payload` window of the emitted AU
/// starts a fresh self-delimiting codec packet, zero-padded to the window — so a lost
/// datagram costs a few coefficient blocks, not the frame. AUs produced this way are
/// flagged [`EncodedFrame::chunk_aligned`] and the session marks them on the wire.
/// Default: no-op (the H.26x backends' bitstreams cannot be cut losslessly).
fn set_wire_chunking(&mut self, _shard_payload: usize) {}
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
fn flush(&mut self) -> Result<()>;
}
impl Codec {
/// Maximum encodable dimension (px) per side for this codec on NVENC. H.264 tops out at
/// 4096 (level constraint); HEVC and AV1 allow 8192. Used to reject out-of-range client
/// modes up front (see [`validate_dimensions`]).
pub fn max_dimension(self) -> u32 {
match self {
Codec::H264 => 4096,
// PyroWave has no codec-level dimension cap (arbitrary even sizes); 8192 matches the
// buffer-math guard the other codecs get.
Codec::H265 | Codec::Av1 | Codec::PyroWave => 8192,
}
}
/// The codec's *spec* top level/tier bitrate (bits/s) — the usual boundary at which NVENC
/// starts rejecting `avcodec_open2` with EINVAL. NOT a hard cap: [`open_video`](crate::encode::
/// open_video) probes the actual GPU ceiling by stepping DOWN from the requested bitrate only on
/// EINVAL, and uses this purely as the first step-down candidate (so a card that accepts more —
/// an RTX 5070 Ti does >1 Gbps HEVC where a 4090 caps at ~800 Mbps — is never clamped to it).
/// HEVC Level 6.2 High tier = 800 Mbps; H.264 High level 6.2 ≈ 480 Mbps; AV1's levels allow more.
pub fn max_bitrate_bps(self) -> u64 {
match self {
Codec::H264 => 480_000_000,
Codec::H265 => 800_000_000,
Codec::Av1 => 1_200_000_000,
// No spec level/tier: the rate is a plain per-frame byte budget. Use the protocol's
// own bitrate clamp so the step-down probe logic never binds below it.
Codec::PyroWave => 8_000_000_000,
}
}
}
/// `PUNKTFUNK_VBV_FRAMES` — HRD/VBV size in frame intervals (default 1.0, the strict low-latency
/// shape every backend ships: each frame must fit its rate share, keeping frame sizes uniform for
/// the pacer). The AMF/VAAPI/QSV paths parse the same variable locally; this helper brings the
/// direct-NVENC paths (which used to hardwire 1 frame) to parity. Larger values let complex
/// frames borrow bits — better rate utilization at the cost of per-frame size variance.
pub(crate) fn vbv_frames_env() -> f64 {
std::env::var("PUNKTFUNK_VBV_FRAMES")
.ok()
.and_then(|s| s.parse::<f64>().ok())
.filter(|v| v.is_finite() && *v > 0.0)
.unwrap_or(1.0)
}
/// Validate a requested encode resolution before we allocate buffers or open NVENC. Rejects
/// zero/odd-sized and out-of-range modes with a clear error instead of letting buffer math
/// overflow or the encoder open fail with an opaque NVENC code. A client can request any
/// `mode=WxHxFPS`, so this is the gate on attacker/typo-controlled dimensions.
pub fn validate_dimensions(codec: Codec, width: u32, height: u32) -> Result<()> {
if width == 0 || height == 0 {
anyhow::bail!("invalid encode resolution {width}x{height}: dimensions must be non-zero");
}
// NVENC requires even dimensions for the chroma subsampling it does internally.
if width % 2 != 0 || height % 2 != 0 {
anyhow::bail!("invalid encode resolution {width}x{height}: dimensions must be even");
}
// PyroWave's 5-level wavelet decomposition needs ≥ 4·2⁵ px per axis (upstream
// `MinimumImageSize` — the band mirroring breaks below it); reject a tiny mode here
// (e.g. a match-window resize dragged to a sliver) instead of failing the encoder
// rebuild after the switch was acked.
if codec == Codec::PyroWave && (width < 128 || height < 128) {
anyhow::bail!(
"invalid PyroWave resolution {width}x{height}: the wavelet needs at least 128px per axis"
);
}
let max = codec.max_dimension();
if width > max || height > max {
anyhow::bail!(
"{codec:?} max dimension is {max}px; requested {width}x{height} \
(use HEVC/AV1 above 4096, or lower the client resolution)"
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_zero_and_odd_dimensions() {
assert!(validate_dimensions(Codec::H265, 0, 1080).is_err());
assert!(validate_dimensions(Codec::H265, 1920, 0).is_err());
assert!(validate_dimensions(Codec::H265, 1921, 1080).is_err()); // odd width
assert!(validate_dimensions(Codec::H265, 1920, 1081).is_err()); // odd height
}
#[test]
fn h264_capped_at_4096() {
assert!(validate_dimensions(Codec::H264, 3840, 2160).is_ok()); // 4K fits (width < 4096)
assert!(validate_dimensions(Codec::H264, 4096, 4096).is_ok()); // exactly at the limit
assert!(validate_dimensions(Codec::H264, 4098, 2160).is_err());
assert!(validate_dimensions(Codec::H264, 3840, 4098).is_err());
}
#[test]
fn hevc_and_av1_allow_up_to_8192() {
for c in [Codec::H265, Codec::Av1] {
assert!(validate_dimensions(c, 3840, 2160).is_ok());
assert!(validate_dimensions(c, 7680, 4320).is_ok()); // 8K fits
assert!(validate_dimensions(c, 8192, 8192).is_ok());
assert!(validate_dimensions(c, 8194, 4320).is_err());
}
}
#[test]
fn common_modes_accepted() {
for c in [Codec::H264, Codec::H265, Codec::Av1] {
for (w, h) in [(1280, 720), (1920, 1080), (2560, 1440)] {
assert!(validate_dimensions(c, w, h).is_ok(), "{c:?} {w}x{h}");
}
}
}
/// Wire round-trip and the stats label stay in lockstep with the `quic::CODEC_*` bits.
#[test]
fn codec_wire_roundtrip_and_label() {
for c in [Codec::H264, Codec::H265, Codec::Av1] {
assert_eq!(Codec::from_wire(c.to_wire()), c);
}
assert_eq!(Codec::H264.label(), "h264");
assert_eq!(Codec::H265.label(), "hevc");
assert_eq!(Codec::Av1.label(), "av1");
}
}
+96
View File
@@ -0,0 +1,96 @@
//! Shared libavcodec (FFmpeg) glue for the three libav encode backends — Linux NVENC
//! (`encode/linux/mod.rs`), VAAPI (`encode/linux/vaapi.rs`), and Windows AMF/QSV
//! (`encode/windows/ffmpeg_win.rs`) — so the byte-identical pieces live once (plan §2.2, the Tier-2
//! gap). Free functions and consts over borrowed handles; nothing here is per-frame `dyn`,
//! allocating, or on the zero-copy ingest path.
use crate::encode::EncodedFrame;
use anyhow::{Context, Result};
use ffmpeg_next as ffmpeg;
use ffmpeg_next::ffi; // = ffmpeg_sys_next
use ffmpeg_next::format::Pixel;
use ffmpeg_next::{encoder, Packet, Rational};
use std::os::raw::c_int;
/// swscale: nearest-neighbour scaler flag (`SWS_POINT`). We never rescale (src dims == dst dims), so
/// the resampler choice only governs the colour-conversion path; POINT is the cheapest.
pub(crate) const SWS_POINT: c_int = 0x10;
/// swscale colorspace id for ITU-R BT.709 (`SWS_CS_ITU709`) — the CSC coefficients for our RGB→YUV.
pub(crate) const SWS_CS_ITU709: c_int = 1;
/// swscale colorspace id for ITU-R BT.2020 non-constant-luminance (`SWS_CS_BT2020`) — the CSC
/// coefficients for the HDR X2BGR10→P010 path (Windows only today).
pub(crate) const SWS_CS_BT2020: c_int = 9;
/// `Pixel` → `AVPixelFormat`. `Pixel` is `#[repr(i32)]`-compatible with `AVPixelFormat` (the bindgen
/// enum) via this documented conversion in ffmpeg-next.
pub(crate) fn pixel_to_av(p: Pixel) -> ffi::AVPixelFormat {
ffi::AVPixelFormat::from(p)
}
/// One `receive_packet` attempt, with the not-ready states kept distinct so a blocking drain can
/// tell "still encoding" (retry) from "stream over" (stop). The Linux NVENC/VAAPI polls collapse
/// `Again`/`Eof` to `None`; the Windows AMF/QSV path keeps them apart for its deadline-driven loop.
pub(crate) enum PollOutcome {
Packet(EncodedFrame),
Again,
Eof,
}
/// Apply the shared low-latency rate-control contract to a **not-yet-opened** encoder context: a
/// fixed frame rate, CBR (target == max bitrate), B-frames off, and a tight ~1-frame VBV/HRD buffer.
///
/// The VBV size bounds any single frame. Under CBR with no buffer set, libav's encoders use a loose
/// default VBV, so a high-motion P-frame can balloon to many times the average; those extra packets
/// overflow the bounded send queue + kernel socket buffer and get dropped, which the client sees as
/// framedrops/jitter (and, on the infinite-GOP path, as old/stale frames flashing until the next
/// RFI). A tight ~1-frame buffer makes the encoder hold frame size roughly constant and absorb motion
/// as a momentary QP (quality) dip instead — the trade we want. Default = 1 frame of bits
/// (bitrate/fps); `PUNKTFUNK_VBV_FRAMES` tunes it (larger = better motion quality, bigger bursts).
///
/// The caller still owns `set_format` (pixel format) and `gop_size` (GOP policy differs: NVENC's
/// infinite/intra-refresh wave vs the VAAPI/AMF `i32::MAX`), since those are backend-specific.
pub(crate) fn apply_low_latency_rc(video: &mut encoder::video::Video, fps: u32, bitrate_bps: u64) {
video.set_time_base(Rational(1, fps as i32));
video.set_frame_rate(Some(Rational(fps as i32, 1)));
video.set_bit_rate(bitrate_bps as usize);
video.set_max_bit_rate(bitrate_bps as usize);
video.set_max_b_frames(0);
let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * crate::encode::vbv_frames_env())
.clamp(1.0, i32::MAX as f64);
// SAFETY: `video` wraps a freshly-allocated `AVCodecContext` we hold by value and have not opened
// yet; `as_mut_ptr()` returns that non-null, aligned, exclusively-owned context. Writing the plain
// `rc_buffer_size` int before `open_with` is the supported way to set a field ffmpeg-next exposes
// no setter for. Sole owner → no aliasing; synchronous in-bounds scalar write.
unsafe {
(*video.as_mut_ptr()).rc_buffer_size = vbv_bits as i32;
}
}
/// Drain the encoder for one packet (shared across the NVENC/VAAPI/AMF/QSV libav backends). The
/// `EncodedFrame`'s only allocation is the `to_vec()` of the bitstream — the same copy each backend
/// already made — so this stays off any per-frame `dyn`/`Box`/channel path.
pub(crate) fn poll_encoder(enc: &mut encoder::video::Encoder, fps: u32) -> Result<PollOutcome> {
let mut pkt = Packet::empty();
match enc.receive_packet(&mut pkt) {
Ok(()) => {
let data = pkt.data().map(|d| d.to_vec()).unwrap_or_default();
let pts = pkt.pts().unwrap_or(0).max(0) as u64;
Ok(PollOutcome::Packet(EncodedFrame {
data,
pts_ns: pts * 1_000_000_000 / fps as u64,
keyframe: pkt.is_key(),
recovery_anchor: false,
chunk_aligned: false,
}))
}
// No packet ready yet (need another input frame).
Err(ffmpeg::Error::Other { errno })
if errno == ffmpeg::util::error::EAGAIN
|| errno == ffmpeg::util::error::EWOULDBLOCK =>
{
Ok(PollOutcome::Again)
}
// Fully drained after flush().
Err(ffmpeg::Error::Eof) => Ok(PollOutcome::Eof),
Err(e) => Err(e).context("receive_packet"),
}
}
+11 -66
View File
@@ -16,19 +16,16 @@ use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
use anyhow::{anyhow, bail, Context, Result};
use ffmpeg::format::Pixel;
use ffmpeg::util::frame::Video as VideoFrame;
use ffmpeg::{codec, encoder, Dictionary, Packet, Rational};
use ffmpeg::{codec, encoder, Dictionary};
use ffmpeg_next as ffmpeg;
use std::os::raw::c_int;
use std::ptr;
use super::libav::{
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_ITU709, SWS_POINT,
};
use ffmpeg::ffi; // = ffmpeg_sys_next
/// swscale: nearest-neighbour scaler flag (`SWS_POINT`). We never rescale (src dims == dst dims), so
/// the resampler choice only governs the colour-conversion path; POINT is the cheapest.
const SWS_POINT: c_int = 0x10;
/// swscale colorspace id for ITU-R BT.709 (`SWS_CS_ITU709`) — the CSC coefficients for our RGB→YUV.
const SWS_CS_ITU709: c_int = 1;
/// The swscale *source* pixel format for a captured packed RGB/BGR layout (the real byte order, not
/// the NVENC-padded `*0` form). Used by the 4:4:4 RGB→YUV444P conversion path. Mirrors the VAAPI
/// CPU-input mapping; YUV/10-bit inputs can't feed this path (the 4:4:4 session forces packed RGB).
@@ -118,13 +115,6 @@ impl Drop for CudaHw {
}
}
/// `ffmpeg::format::Pixel` → raw `AVPixelFormat`.
fn pixel_to_av(p: Pixel) -> ffi::AVPixelFormat {
// `Pixel` is `#[repr(i32)]`-compatible with `AVPixelFormat` (the bindgen enum) via this
// documented conversion in ffmpeg-next.
ffi::AVPixelFormat::from(p)
}
/// Map a captured layout to the NVENC input pixel format, and whether a 3→4 byte expand is
/// needed (packed RGB/BGR have no padding byte; the NVENC `*0` formats do).
fn nvenc_input(format: PixelFormat) -> (Pixel, bool) {
@@ -299,34 +289,8 @@ impl NvencEncoder {
video.set_width(width);
video.set_height(height);
video.set_format(nvenc_pixel); // NVENC converts RGB→YUV internally
video.set_time_base(Rational(1, fps as i32));
video.set_frame_rate(Some(Rational(fps as i32, 1)));
video.set_bit_rate(bitrate_bps as usize);
video.set_max_bit_rate(bitrate_bps as usize);
// VBV/HRD buffer — bound the SIZE of any single frame. Under CBR with no buffer set, NVENC
// uses a loose default VBV, so a high-motion P-frame is allowed to balloon to many times the
// average; those extra packets overflow the bounded send queue + kernel socket buffer and
// get dropped, which the client sees as framedrops/jitter (and, on the infinite-GOP path, as
// old/stale frames flashing until the next RFI). A tight ~1-frame buffer makes the encoder
// hold frame size roughly constant and absorb motion as a momentary QP (quality) dip instead
// — the trade we want. Default = 1 frame of bits (bitrate/fps); PUNKTFUNK_VBV_FRAMES tunes it
// (larger = better motion quality but bigger per-frame bursts).
let vbv_frames = std::env::var("PUNKTFUNK_VBV_FRAMES")
.ok()
.and_then(|s| s.parse::<f32>().ok())
.filter(|v| v.is_finite() && *v > 0.0)
.unwrap_or(1.0);
let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * vbv_frames as f64)
.clamp(1.0, i32::MAX as f64);
// SAFETY: `video` is the ffmpeg-next encoder builder wrapping a freshly-allocated
// `AVCodecContext` that we hold by value and have not opened yet; `video.as_mut_ptr()` returns
// that non-null, properly-aligned, exclusively-owned context. Writing the plain `rc_buffer_size`
// int field before `open_with` is the supported way to set a field ffmpeg-next exposes no
// setter for. Sole owner → no aliasing; synchronous in-bounds scalar write.
unsafe {
(*video.as_mut_ptr()).rc_buffer_size = vbv_bits as i32;
}
video.set_max_b_frames(0);
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
apply_low_latency_rc(&mut video, fps, bitrate_bps);
// Infinite GOP — NO periodic IDR. A keyframe at 5120x1440 is ~20-40x a P-frame, so a
// periodic IDR is a recurring multi-millisecond encode+packetize+send spike — the ~2s
// "freeze". NVENC emits one IDR at stream start, then P-frames only; `forced-idr` (below)
@@ -641,30 +605,11 @@ impl Encoder for NvencEncoder {
}
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
let mut pkt = Packet::empty();
match self.enc.receive_packet(&mut pkt) {
Ok(()) => {
let data = pkt.data().map(|d| d.to_vec()).unwrap_or_default();
let pts = pkt.pts().unwrap_or(0).max(0) as u64;
let pts_ns = pts * 1_000_000_000 / self.fps as u64;
Ok(Some(EncodedFrame {
data,
pts_ns,
keyframe: pkt.is_key(),
recovery_anchor: false,
chunk_aligned: false,
}))
}
// No packet ready yet (need another input frame).
Err(ffmpeg::Error::Other { errno })
if errno == ffmpeg::util::error::EAGAIN
|| errno == ffmpeg::util::error::EWOULDBLOCK =>
{
Ok(None)
}
// Fully drained after flush().
Err(ffmpeg::Error::Eof) => Ok(None),
Err(e) => Err(e).context("receive_packet"),
// Non-blocking single drain: a packet ships, EAGAIN (need another input frame) and EOF
// (drained after flush) both mean "nothing this tick".
match poll_encoder(&mut self.enc, self.fps)? {
PollOutcome::Packet(au) => Ok(Some(au)),
PollOutcome::Again | PollOutcome::Eof => Ok(None),
}
}
@@ -31,6 +31,10 @@
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::nvenc_core::{
apply_low_latency_config, build_init_params, codec_guid, LowLatencyConfig, NvStatusExt, RFI_DPB,
};
use super::nvenc_status;
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
use crate::capture::{CapturedFrame, FramePayload};
use crate::zerocopy::cuda::{self, InputSurface};
@@ -105,20 +109,6 @@ struct EncodeApi {
invalidate_ref_frames: unsafe extern "C" fn(*mut c_void, u64) -> nv::NVENCSTATUS,
}
/// Local `NVENCSTATUS` → `Result` (the sdk's own helper lives in the `safe` module this file must
/// not pull in). The raw status's Debug repr is the error payload.
trait NvStatusExt {
fn nv_ok(self) -> std::result::Result<(), nv::NVENCSTATUS>;
}
impl NvStatusExt for nv::NVENCSTATUS {
fn nv_ok(self) -> std::result::Result<(), nv::NVENCSTATUS> {
match self {
nv::NVENCSTATUS::NV_ENC_SUCCESS => Ok(()),
err => Err(err),
}
}
}
/// Resolve the table once per process. `Err` = NVENC genuinely unavailable (no NVIDIA driver/.so,
/// or a driver older than our headers) — [`NvencCudaEncoder::open`] gates on it and the VAAPI/
/// software backends carry on.
@@ -129,7 +119,7 @@ fn try_api() -> std::result::Result<&'static EncodeApi, &'static str> {
.get_or_init(|| {
let table = load_api();
if let Err(e) = &table {
tracing::warn!("NVENC (Linux direct) API unavailable: {e}");
tracing::warn!(error = %e, "NVENC (Linux direct) API unavailable");
}
table
})
@@ -220,21 +210,6 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
/// helper's `PUNKTFUNK_ENCODE_DEPTH` (default 4, clamped ≤ 6).
const POOL: usize = 8;
/// Reference-frame DPB depth when RFI is supported (Apollo uses 5). A deeper DPB lets an invalidated
/// reference fall back to an older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each
/// P-frame single-reference for low latency.
const RFI_DPB: u32 = 5;
fn codec_guid(codec: Codec) -> nv::GUID {
match codec {
Codec::H264 => nv::NV_ENC_CODEC_H264_GUID,
Codec::H265 => nv::NV_ENC_CODEC_HEVC_GUID,
Codec::Av1 => nv::NV_ENC_CODEC_AV1_GUID,
// Guarded by the open_video dispatch: a PyroWave session never reaches NVENC.
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
}
}
/// The NVENC input buffer format for a captured `DeviceBuffer`'s layout. NV12/YUV444 are the zero-
/// copy worker's convert outputs; packed RGB (`ABGR`) is the fallback where NVENC does the internal
/// CSC. 10-bit is never produced on Linux today (Phase 5.1), so everything is 8-bit.
@@ -317,6 +292,14 @@ pub struct NvencCudaEncoder {
cursor: Option<cuda::CursorBlend>,
cursor_tried: bool,
cursor_serial: u64,
/// Suppress-until-success latches for the per-frame cursor upload/blend warns: a persistent
/// failure sits in the submit() hot path, so warn once per failure streak (reset on success)
/// rather than on every cursor-bearing frame, which would evict the log ring.
cursor_upload_warned: bool,
cursor_blend_warned: bool,
/// One-shot latch for [`diagnose_failed_open`](Self::diagnose_failed_open) so a rebuild-retry
/// burst (the session loop's bounded encoder resets) logs the diagnosis once, not per attempt.
diagnosed: bool,
}
// SAFETY: the `!Send` fields are the raw NVENC session handle (`encoder`), the shared `CUcontext`
@@ -382,6 +365,9 @@ impl NvencCudaEncoder {
cursor: None,
cursor_tried: false,
cursor_serial: u64::MAX,
cursor_upload_warned: false,
cursor_blend_warned: false,
diagnosed: false,
inited: false,
rfi_supported: false,
custom_vbv: false,
@@ -407,7 +393,16 @@ impl NvencCudaEncoder {
for &bs in &self.bitstreams {
let _ = (api().destroy_bitstream_buffer)(self.encoder, bs);
}
let _ = (api().destroy_encoder)(self.encoder);
// A destroy failure means the driver may still hold this session's slot (the concurrent-
// session cap is per process and only a restart clears a leak) — make it visible instead
// of silently discarding the status.
if let Err(e) = (api().destroy_encoder)(self.encoder).nv_ok() {
tracing::warn!(
status = ?e,
"NVENC destroy_encoder failed at teardown — the driver may have leaked this \
session's slot toward the concurrent-session cap"
);
}
self.ring.clear(); // drops the InputSurfaces, freeing their CUDA allocations
self.bitstreams.clear();
self.pending.clear();
@@ -446,11 +441,19 @@ impl NvencCudaEncoder {
..Default::default()
};
let mut enc: *mut c_void = ptr::null_mut();
(api().open_encode_session_ex)(&mut params, &mut enc)
.nv_ok()
.map_err(|e| {
anyhow!("NVENC open_encode_session_ex (caps probe): {e:?} (no NVIDIA GPU?)")
})?;
if let Err(e) = (api().open_encode_session_ex)(&mut params, &mut enc).nv_ok() {
// The NVENC docs require NvEncDestroyEncoder even after a FAILED open (the driver may
// have allocated the session slot before erroring) — without it, every failed open in
// a retry loop leaks a slot toward the concurrent-session cap, turning a transient
// failure into permanent exhaustion that only a host restart clears.
if !enc.is_null() {
let _ = (api().destroy_encoder)(enc);
}
return Err(nvenc_status::call_err(
"open_encode_session_ex (caps probe)",
e,
));
}
let wmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_WIDTH_MAX);
let hmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_HEIGHT_MAX);
let yuv444 = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE);
@@ -490,6 +493,62 @@ impl NvencCudaEncoder {
Ok(())
}
/// One-shot self-diagnosis for a failed session open (2026-07 field report: after a codec
/// switch every open returned `NV_ENC_ERR_INVALID_VERSION` until the HOST PROCESS was
/// restarted — so the poisoned state is per-process, not the driver install). Retries the raw
/// open on a FRESH dedicated CUDA context to split the candidate causes apart in the log:
/// * fresh context WORKS → the shared process context (or its NVENC association) is in a
/// bad state — a host bug to report;
/// * fresh context fails the SAME way → driver-level: userspace/kernel version skew,
/// concurrent-session-cap exhaustion (leaked sessions), or a lost/reset GPU;
/// * no fresh context AT ALL → CUDA itself is unhealthy in this process.
///
/// Log-only (the caller still fails the open); latched per encoder so a reset burst logs once.
fn diagnose_failed_open(&mut self) {
if self.diagnosed {
return;
}
self.diagnosed = true;
let fresh = cuda::with_fresh_context(|ctx| {
let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
deviceType: nv::NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_CUDA,
device: ctx,
apiVersion: nv::NVENCAPI_VERSION,
..Default::default()
};
let mut enc: *mut c_void = ptr::null_mut();
// SAFETY: `params`/`enc` are live stack locals across the synchronous call; `ctx` is
// the live diagnostic context `with_fresh_context` just created. Any session the probe
// opened (even on a failed status, per the NVENC docs) is destroyed exactly once here.
unsafe {
let st = (api().open_encode_session_ex)(&mut params, &mut enc);
if !enc.is_null() {
let _ = (api().destroy_encoder)(enc);
}
st
}
});
match fresh {
Ok(nv::NVENCSTATUS::NV_ENC_SUCCESS) => tracing::error!(
"NVENC self-diagnosis: the session opens FINE on a fresh CUDA context — the \
host's shared CUDA context is in a bad state (host bug; please report this log)"
),
Ok(st) => tracing::error!(
fresh_ctx_status = ?st,
"NVENC self-diagnosis: the open fails on a fresh CUDA context too — driver-level \
cause: {}",
nvenc_status::explain(st)
),
Err(e) => tracing::error!(
error = %format!("{e:#}"),
"NVENC self-diagnosis: could not create a fresh CUDA context — CUDA itself is \
unhealthy in this process (GPU reset/fell off the bus, or a poisoned driver \
state); a host restart should clear it"
),
}
}
/// Author the session's `NV_ENC_CONFIG` at `bitrate` (bps): the P1/ULL preset (queried on
/// `enc`) seeded with the RC/tier/chroma/VUI/DPB shape this backend always runs. ONE builder
/// shared by [`try_open_session`] and [`Encoder::reconfigure_bitrate`], so an in-place rate
@@ -512,164 +571,34 @@ impl NvencCudaEncoder {
&mut preset,
)
.nv_ok()
.map_err(|e| anyhow!("get_encode_preset_config_ex: {e:?}"))?;
.map_err(|e| nvenc_status::call_err("get_encode_preset_config_ex", e))?;
let mut cfg = preset.presetCfg;
// CBR, infinite GOP, P-only, ~1-frame VBV (mirror the Windows/Linux-libav RC config).
cfg.gopLength = nv::NVENC_INFINITE_GOPLENGTH;
cfg.frameIntervalP = 1;
cfg.rcParams.rateControlMode = nv::NV_ENC_PARAMS_RC_MODE::NV_ENC_PARAMS_RC_CBR;
let bps = bitrate.min(u32::MAX as u64) as u32;
cfg.rcParams.averageBitRate = bps;
cfg.rcParams.maxBitRate = bps;
if self.custom_vbv {
// ~1-frame VBV by default; PUNKTFUNK_VBV_FRAMES scales it (parity with AMF/VAAPI/QSV).
let vbv = ((bitrate as f64 / self.fps.max(1) as f64) * crate::encode::vbv_frames_env())
.clamp(1.0, u32::MAX as f64) as u32;
cfg.rcParams.vbvBufferSize = vbv;
cfg.rcParams.vbvInitialDelay = vbv;
}
// Tier + autoselect level, PER CODEC (HEVC HIGH tier for the higher bitrate ceiling; AV1
// Main tier only — tier=1 fails AV1 init; H.264 has no tier). Level 0 = autoselect.
match self.codec {
Codec::H265 => {
cfg.encodeCodecConfig.hevcConfig.tier = 1;
cfg.encodeCodecConfig.hevcConfig.level = 0;
}
Codec::Av1 => {}
Codec::H264 => {}
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
}
// Chroma + bit depth. 4:4:4 (HEVC Range Extensions, chromaFormatIDC=3) engages on a YUV444
// input; 10-bit Main10 is ported but never engages on Linux yet (input is 8-bit).
// Steps 3-7 (RC/VBV, tier+level, chroma+bit-depth, colour VUI, RFI DPB) are the shared
// low-latency contract. On Linux the full-chroma input is a YUV444 surface and the input is
// 8-bit today, so AV1's input-depth is 0.
let yuv444_input = matches!(
self.buffer_fmt,
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444
);
if self.chroma_444 && yuv444_input {
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_FREXT_GUID;
cfg.encodeCodecConfig.hevcConfig.set_chromaFormatIDC(3);
if self.bit_depth == 10 {
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2);
}
} else if self.bit_depth == 10 {
match self.codec {
Codec::H265 => {
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_MAIN10_GUID;
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2);
}
Codec::Av1 => {
cfg.encodeCodecConfig.av1Config.set_pixelBitDepthMinus8(2);
cfg.encodeCodecConfig
.av1Config
.set_inputPixelBitDepthMinus8(0);
}
Codec::H264 => {}
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
}
}
// Colour signaling, written UNCONDITIONALLY: the CUDA input is already CSC'd to a specific
// matrix (NV12/YUV444 8-bit BT.709 limited from the convert worker, or the packed-RGB path
// where NVENC's internal CSC follows this VUI), so the stream must say BT.709 or a decoder
// whose "unspecified" default is 601 will mis-render.
{
let (prim, trc, mat) = if self.hdr {
(
nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT2020,
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084,
nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL,
)
} else {
(
nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT709,
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_BT709,
nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT709,
)
};
match self.codec {
Codec::H265 => {
let vui = &mut cfg.encodeCodecConfig.hevcConfig.hevcVUIParameters;
vui.videoSignalTypePresentFlag = 1;
vui.videoFullRangeFlag = 0;
vui.colourDescriptionPresentFlag = 1;
vui.colourPrimaries = prim;
vui.transferCharacteristics = trc;
vui.colourMatrix = mat;
}
Codec::H264 => {
let vui = &mut cfg.encodeCodecConfig.h264Config.h264VUIParameters;
vui.videoSignalTypePresentFlag = 1;
vui.videoFullRangeFlag = 0;
vui.colourDescriptionPresentFlag = 1;
vui.colourPrimaries = prim;
vui.transferCharacteristics = trc;
vui.colourMatrix = mat;
}
Codec::Av1 => {
let av1 = &mut cfg.encodeCodecConfig.av1Config;
av1.colorPrimaries = prim;
av1.transferCharacteristics = trc;
av1.matrixCoefficients = mat;
av1.colorRange = 0;
}
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
}
}
// Reference-frame invalidation: a deeper DPB so an invalidated reference can fall back to an
// older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each P-frame
// single-reference for low latency. Only when this GPU supports RFI.
if self.rfi_supported {
let one = nv::NV_ENC_NUM_REF_FRAMES::NV_ENC_NUM_REF_FRAMES_1;
match self.codec {
Codec::H264 => {
cfg.encodeCodecConfig.h264Config.maxNumRefFrames = RFI_DPB;
cfg.encodeCodecConfig.h264Config.numRefL0 = one;
}
Codec::H265 => {
cfg.encodeCodecConfig.hevcConfig.maxNumRefFramesInDPB = RFI_DPB;
cfg.encodeCodecConfig.hevcConfig.numRefL0 = one;
}
Codec::Av1 => {
cfg.encodeCodecConfig.av1Config.maxNumRefFramesInDPB = RFI_DPB;
}
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
}
}
apply_low_latency_config(
&mut cfg,
LowLatencyConfig {
codec: self.codec,
bitrate,
fps: self.fps,
custom_vbv: self.custom_vbv,
chroma_444: self.chroma_444,
full_chroma_input: yuv444_input,
bit_depth: self.bit_depth,
av1_input_depth_minus8: 0,
hdr: self.hdr,
rfi_supported: self.rfi_supported,
},
);
Ok(cfg)
}
/// Author the `NV_ENC_INITIALIZE_PARAMS` pointing at `cfg`. Shared by [`try_open_session`]
/// and [`Encoder::reconfigure_bitrate`] — a reconfigure must present the SAME init params as
/// the open. The returned struct borrows `cfg` raw; the caller keeps `cfg` alive across the
/// NVENC call it feeds this into.
fn build_init_params(
&self,
cfg: &mut nv::NV_ENC_CONFIG,
split_mode: u32,
) -> nv::NV_ENC_INITIALIZE_PARAMS {
let mut init = nv::NV_ENC_INITIALIZE_PARAMS {
version: nv::NV_ENC_INITIALIZE_PARAMS_VER,
encodeGUID: self.codec_guid,
presetGUID: nv::NV_ENC_PRESET_P1_GUID,
tuningInfo: nv::NV_ENC_TUNING_INFO::NV_ENC_TUNING_INFO_ULTRA_LOW_LATENCY,
encodeWidth: self.width,
encodeHeight: self.height,
darWidth: self.width,
darHeight: self.height,
frameRateNum: self.fps,
frameRateDen: 1,
enablePTD: 1,
encodeConfig: cfg,
..Default::default()
};
init.set_splitEncodeMode(split_mode);
init
}
/// Open + configure + initialize ONE NVENC CUDA session at `bitrate` (bps) and `split_mode`.
/// Returns the session handle, or destroys it and returns the error.
unsafe fn try_open_session(&self, bitrate: u64, split_mode: u32) -> Result<*mut c_void> {
@@ -681,9 +610,14 @@ impl NvencCudaEncoder {
..Default::default()
};
let mut enc: *mut c_void = ptr::null_mut();
(api().open_encode_session_ex)(&mut params, &mut enc)
.nv_ok()
.map_err(|e| anyhow!("NVENC open_encode_session_ex: {e:?} (no NVIDIA GPU?)"))?;
if let Err(e) = (api().open_encode_session_ex)(&mut params, &mut enc).nv_ok() {
// Destroy-on-failed-open, as in `query_caps`: a failed open may still hold a session
// slot that must be released.
if !enc.is_null() {
let _ = (api().destroy_encoder)(enc);
}
return Err(nvenc_status::call_err("open_encode_session_ex", e));
}
let mut cfg = match self.build_config(enc, bitrate) {
Ok(cfg) => cfg,
@@ -692,13 +626,21 @@ impl NvencCudaEncoder {
return Err(e);
}
};
let mut init = self.build_init_params(&mut cfg, split_mode);
let mut init = build_init_params(
self.codec_guid,
self.width,
self.height,
self.fps,
&mut cfg,
split_mode,
false,
);
match (api().initialize_encoder)(enc, &mut init).nv_ok() {
Ok(()) => Ok(enc),
Err(e) => {
let _ = (api().destroy_encoder)(enc);
Err(anyhow!("initialize_encoder: {e:?}"))
Err(nvenc_status::call_err("initialize_encoder", e))
}
}
}
@@ -718,7 +660,12 @@ impl NvencCudaEncoder {
self.cu_ctx = cuda::context().context("shared CUDA context (Linux direct NVENC)")?;
cuda::make_current().context("cuCtxSetCurrent (encode thread)")?;
self.query_caps()?;
if let Err(e) = self.query_caps() {
// The one place every session-open failure funnels through (the probe is the first
// open of any session) — run the one-shot self-diagnosis before propagating.
self.diagnose_failed_open();
return Err(e);
}
const FLOOR_BPS: u64 = 10_000_000;
let requested_bps = self.bitrate_bps;
// 2-way NVENC split-frame encoding (Ada dual-NVENC) above ~1 Gpix/s; env override
@@ -818,7 +765,7 @@ impl NvencCudaEncoder {
};
(api().create_bitstream_buffer)(enc, &mut cb)
.nv_ok()
.map_err(|e| anyhow!("create_bitstream_buffer: {e:?}"))?;
.map_err(|e| nvenc_status::call_err("create_bitstream_buffer", e))?;
self.bitstreams.push(cb.bitstreamBuffer);
}
@@ -849,7 +796,7 @@ impl NvencCudaEncoder {
};
(api().register_resource)(self.encoder, &mut rr)
.nv_ok()
.map_err(|e| anyhow!("register_resource (CUDADEVICEPTR): {e:?}"))?;
.map_err(|e| nvenc_status::call_err("register_resource (CUDADEVICEPTR)", e))?;
self.ring.push(RingSlot {
surface,
reg: rr.registeredResource,
@@ -858,14 +805,12 @@ impl NvencCudaEncoder {
self.inited = true;
tracing::info!(
"NVENC CUDA session: {}x{}@{} {}-bit {} Mbps {:?} fmt={:?}",
self.width,
self.height,
self.fps,
self.bit_depth,
self.bitrate_bps / 1_000_000,
self.codec_guid,
self.buffer_fmt,
mode = %format_args!("{}x{}@{}", self.width, self.height, self.fps),
bit_depth = self.bit_depth,
mbps = self.bitrate_bps / 1_000_000,
codec = ?self.codec_guid,
fmt = ?self.buffer_fmt,
"NVENC CUDA session ready"
);
Ok(())
}
@@ -970,9 +915,19 @@ impl Encoder for NvencCudaEncoder {
if let Some(cb) = &self.cursor {
if self.cursor_serial != ov.serial {
match cb.upload(ov.rgba.as_slice(), ov.w, ov.h) {
Ok(()) => self.cursor_serial = ov.serial,
Ok(()) => {
self.cursor_serial = ov.serial;
self.cursor_upload_warned = false;
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "cursor upload failed")
if !self.cursor_upload_warned {
self.cursor_upload_warned = true;
tracing::warn!(
error = %format!("{e:#}"),
serial = ov.serial,
"NVENC (Linux): cursor upload failed — cursor not composited"
);
}
}
}
}
@@ -991,7 +946,15 @@ impl Encoder for NvencCudaEncoder {
_ => cb.blend_argb(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y),
};
if let Err(e) = r {
tracing::warn!(error = %format!("{e:#}"), "cursor blend launch failed");
if !self.cursor_blend_warned {
self.cursor_blend_warned = true;
tracing::warn!(
error = %format!("{e:#}"),
"NVENC (Linux): cursor blend launch failed — cursor not composited"
);
}
} else {
self.cursor_blend_warned = false;
}
}
}
@@ -1014,7 +977,7 @@ impl Encoder for NvencCudaEncoder {
};
(api().map_input_resource)(self.encoder, &mut mp)
.nv_ok()
.map_err(|e| anyhow!("map_input_resource: {e:?}"))?;
.map_err(|e| nvenc_status::call_err("map_input_resource", e))?;
let pts = self.frame_idx as u64;
self.frame_idx += 1;
@@ -1086,7 +1049,7 @@ impl Encoder for NvencCudaEncoder {
}
(api().encode_picture)(self.encoder, &mut pic)
.nv_ok()
.map_err(|e| anyhow!("encode_picture: {e:?}"))?;
.map_err(|e| nvenc_status::call_err("encode_picture", e))?;
self.pending.push_back((
self.bitstreams[slot],
mp.mappedResource,
@@ -1186,7 +1149,7 @@ impl Encoder for NvencCudaEncoder {
};
(api().lock_bitstream)(self.encoder, &mut lock)
.nv_ok()
.map_err(|e| anyhow!("lock_bitstream: {e:?}"))?;
.map_err(|e| nvenc_status::call_err("lock_bitstream", e))?;
let data = std::slice::from_raw_parts(
lock.bitstreamBufferPtr as *const u8,
lock.bitstreamSizeInBytes as usize,
@@ -1198,7 +1161,7 @@ impl Encoder for NvencCudaEncoder {
);
(api().unlock_bitstream)(self.encoder, bs)
.nv_ok()
.map_err(|e| anyhow!("unlock_bitstream: {e:?}"))?;
.map_err(|e| nvenc_status::call_err("unlock_bitstream", e))?;
if !map.is_null() {
let _ = (api().unmap_input_resource)(self.encoder, map);
}
@@ -1242,7 +1205,15 @@ impl Encoder for NvencCudaEncoder {
};
let mut params = nv::NV_ENC_RECONFIGURE_PARAMS {
version: nv::NV_ENC_RECONFIGURE_PARAMS_VER,
reInitEncodeParams: self.build_init_params(&mut cfg, self.split_mode),
reInitEncodeParams: build_init_params(
self.codec_guid,
self.width,
self.height,
self.fps,
&mut cfg,
self.split_mode,
false,
),
..Default::default()
};
// Keep the encoder's RC state and reference chain: no reset, no IDR — the in-flight
@@ -1514,4 +1485,161 @@ mod tests {
"negative first → decline"
);
}
fn open_h265() -> NvencCudaEncoder {
NvencCudaEncoder::open(
Codec::H265,
PixelFormat::Nv12,
1280,
720,
60,
20_000_000,
true,
8,
ChromaFormat::Yuv420,
)
.expect("open NVENC CUDA encoder")
}
/// ON-HARDWARE: the codec-switch lifecycle from the 2026-07 field report ("switching the
/// codec leaves the host unable to bring the encoder up until a restart") — cycle sessions
/// across codecs in ONE process, clean drain per leg. Every leg must open and encode. Run:
/// cargo test -p punktfunk-host --features nvenc -- --ignored nvenc_cuda_codec_switch --nocapture
#[test]
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
fn nvenc_cuda_codec_switch_reopen() {
const W: u32 = 1280;
const H: u32 = 720;
crate::zerocopy::cuda::make_current().expect("shared CUDA context current");
for (leg, codec) in [
Codec::H265,
Codec::Av1,
Codec::H265,
Codec::H264,
Codec::H265,
]
.into_iter()
.enumerate()
{
let mut enc = NvencCudaEncoder::open(
codec,
PixelFormat::Nv12,
W,
H,
60,
20_000_000,
true,
8,
ChromaFormat::Yuv420,
)
.expect("open");
for f in 0..4u32 {
let frame = nv12_frame(W, H, f);
enc.submit_indexed(&frame, f)
.unwrap_or_else(|e| panic!("leg {leg} {codec:?} submit failed: {e:#}"));
while enc.poll().expect("poll").is_some() {}
}
drop(enc);
}
println!("nvenc_cuda codec-switch: 5 legs across H265/AV1/H264, all clean");
}
/// ON-HARDWARE: dirty teardown — drop encoders with encodes still in flight (what a
/// mid-stream session kill does), several times, then a fresh session must still open. Guards
/// the teardown-with-pending path against driver-side session-slot leaks.
#[test]
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
fn nvenc_cuda_dirty_teardown_reopen() {
const W: u32 = 1280;
const H: u32 = 720;
crate::zerocopy::cuda::make_current().expect("shared CUDA context current");
for round in 0..3 {
let mut enc = open_h265();
for f in 0..4u32 {
let frame = nv12_frame(W, H, f);
enc.submit_indexed(&frame, f)
.unwrap_or_else(|e| panic!("round {round} submit {f} failed: {e:#}"));
}
drop(enc); // teardown with 4 in-flight encodes
}
let mut enc = open_h265();
let frame = nv12_frame(W, H, 0);
enc.submit_indexed(&frame, 0)
.expect("reopen after dirty teardowns");
while enc.poll().expect("poll").is_some() {}
println!("nvenc_cuda dirty-teardown: 3 dirty drops, reopen clean");
}
/// ON-HARDWARE: the session-open failure path end to end — exhaust the driver's concurrent-
/// session cap with raw opens, assert a real encoder open fails with the actionable error
/// (and fires the one-shot self-diagnosis), then free the slots and assert the SAME encoder
/// rebuilds in place and produces an AU. This is the transient the session loop's rebuild
/// backoff is sized to outlive; on the RTX 5070 Ti (driver 610.43.03) the cap is 12 sessions
/// and the failure status is `NV_ENC_ERR_INCOMPATIBLE_CLIENT_KEY`.
#[test]
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
fn nvenc_cuda_open_failure_diagnosis_and_recovery() {
const W: u32 = 1280;
const H: u32 = 720;
crate::zerocopy::cuda::make_current().expect("shared CUDA context current");
try_api().expect("nvenc api");
let shared = cuda::context().expect("shared ctx");
let open_raw = |device: *mut c_void| -> (nv::NVENCSTATUS, *mut c_void) {
let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
deviceType: nv::NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_CUDA,
device,
apiVersion: nv::NVENCAPI_VERSION,
..Default::default()
};
let mut enc: *mut c_void = ptr::null_mut();
// SAFETY: live params/out-param across the synchronous call; test-only.
let st = unsafe { (api().open_encode_session_ex)(&mut params, &mut enc) };
(st, enc)
};
// Exhaust the concurrent-session cap.
let mut held = Vec::new();
loop {
let (st, enc) = open_raw(shared);
if st != nv::NVENCSTATUS::NV_ENC_SUCCESS {
if !enc.is_null() {
// SAFETY: destroy the failed-open residue per the NVENC docs.
unsafe {
let _ = (api().destroy_encoder)(enc);
}
}
break;
}
held.push(enc);
}
assert!(!held.is_empty(), "expected a finite session cap");
// A real encoder open must now fail (lazy init → caps probe) with the actionable error.
let mut enc = open_h265();
let frame = nv12_frame(W, H, 0);
let err = enc
.submit_indexed(&frame, 0)
.expect_err("submit must fail while the cap is exhausted");
println!("at-cap error (self-diagnosis logged alongside): {err:#}");
// The transient clears (slots freed) → the SAME encoder rebuilds in place and encodes.
for e in held {
// SAFETY: e came from a successful raw open above; destroyed exactly once.
unsafe {
let _ = (api().destroy_encoder)(e);
}
}
assert!(enc.reset(), "in-place reset must be available");
let frame = nv12_frame(W, H, 1);
enc.submit_indexed(&frame, 1)
.expect("rebuild after the transient cleared");
let mut got = false;
while enc.poll().expect("poll").is_some() {
got = true;
}
assert!(got, "recovered encoder must produce an AU");
println!("nvenc_cuda open-failure recovery: cap hit → diagnosed → recovered in place");
}
}
@@ -1211,9 +1211,9 @@ impl Encoder for PyroWaveEncoder {
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
};
let mut enc: pw::pyrowave_encoder = std::ptr::null_mut();
if pw::pyrowave_encoder_create(&einfo, &mut enc) != pw::pyrowave_result_PYROWAVE_SUCCESS
{
tracing::error!("pyrowave: encoder rebuild failed");
let r = pw::pyrowave_encoder_create(&einfo, &mut enc);
if r != pw::pyrowave_result_PYROWAVE_SUCCESS {
tracing::error!(result = ?r, "pyrowave: encoder rebuild failed");
return false;
}
self.pw_enc = enc;
@@ -1228,7 +1228,7 @@ impl Encoder for PyroWaveEncoder {
// (plan §4.6 — wavelet quality collapses well above the AIMD floor); until then this
// faithfully applies whatever the caller asks.
self.frame_budget = budget_for(bps, self.fps);
tracing::info!(
tracing::debug!(
mbps = bps / 1_000_000,
budget_kib = self.frame_budget / 1024,
"pyrowave: per-frame rate budget retargeted in place"
+14 -53
View File
@@ -26,7 +26,7 @@ use super::{Codec, EncodedFrame, Encoder};
use crate::capture::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat};
use anyhow::{anyhow, bail, Context, Result};
use ffmpeg::format::Pixel;
use ffmpeg::{codec, encoder, Dictionary, Packet, Rational};
use ffmpeg::{codec, encoder, Dictionary};
use ffmpeg_next as ffmpeg;
use std::ffi::{CStr, CString};
use std::os::fd::AsRawFd;
@@ -34,18 +34,11 @@ use std::os::raw::c_int;
use std::ptr;
use std::sync::atomic::{AtomicU8, Ordering};
use super::libav::{
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_ITU709, SWS_POINT,
};
use ffmpeg::ffi; // = ffmpeg_sys_next
// libswscale scaler-flag + colour-space constants (not exported as Rust consts by the bindings;
// these are the stable `<libswscale/swscale.h>` #defines). No-rescale → POINT is cheapest.
const SWS_POINT: c_int = 0x10;
const SWS_CS_ITU709: c_int = 1;
/// `ffmpeg::format::Pixel` → raw `AVPixelFormat` (the documented ffmpeg-next conversion).
fn pixel_to_av(p: Pixel) -> ffi::AVPixelFormat {
ffi::AVPixelFormat::from(p)
}
/// `fourcc(a,b,c,d)` — DRM FourCC packing (`a | b<<8 | c<<16 | d<<24`).
const fn fourcc(a: u8, b: u8, c: u8, d: u8) -> u32 {
(a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
@@ -191,20 +184,9 @@ unsafe fn open_vaapi_encoder_mode(
video.set_width(width);
video.set_height(height);
video.set_format(Pixel::NV12); // sw view; pix_fmt overridden to VAAPI below
video.set_time_base(Rational(1, fps as i32));
video.set_frame_rate(Some(Rational(fps as i32, 1)));
video.set_bit_rate(bitrate_bps as usize);
video.set_max_bit_rate(bitrate_bps as usize); // == target → vaapi_encode picks CBR when supported
let vbv_frames = std::env::var("PUNKTFUNK_VBV_FRAMES")
.ok()
.and_then(|s| s.parse::<f32>().ok())
.filter(|v| v.is_finite() && *v > 0.0)
.unwrap_or(1.0);
let vbv_bits =
((bitrate_bps as f64 / fps.max(1) as f64) * vbv_frames as f64).clamp(1.0, i32::MAX as f64);
video.set_max_b_frames(0);
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
apply_low_latency_rc(&mut video, fps, bitrate_bps);
let raw = video.as_mut_ptr();
(*raw).rc_buffer_size = vbv_bits as i32;
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
// We hand the encoder BT.709 *limited* NV12 (swscale CSC on the CPU path; scale_vaapi pinned
// to `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range
@@ -285,32 +267,6 @@ pub fn probe_can_encode_444(_codec: Codec) -> bool {
false
}
/// Drain the encoder for one packet (shared poll logic).
fn poll_encoder(enc: &mut encoder::video::Encoder, fps: u32) -> Result<Option<EncodedFrame>> {
let mut pkt = Packet::empty();
match enc.receive_packet(&mut pkt) {
Ok(()) => {
let data = pkt.data().map(|d| d.to_vec()).unwrap_or_default();
let pts = pkt.pts().unwrap_or(0).max(0) as u64;
Ok(Some(EncodedFrame {
data,
pts_ns: pts * 1_000_000_000 / fps as u64,
keyframe: pkt.is_key(),
recovery_anchor: false,
chunk_aligned: false,
}))
}
Err(ffmpeg::Error::Other { errno })
if errno == ffmpeg::util::error::EAGAIN
|| errno == ffmpeg::util::error::EWOULDBLOCK =>
{
Ok(None)
}
Err(ffmpeg::Error::Eof) => Ok(None),
Err(e) => Err(e).context("receive_packet"),
}
}
// ---------------------------------------------------------------------------------------------
// CPU upload path (Phase 1): swscale RGB→NV12 → upload into a pooled VA surface → encode.
// ---------------------------------------------------------------------------------------------
@@ -1157,9 +1113,14 @@ impl Encoder for VaapiEncoder {
.min(std::time::Duration::from_millis(12));
let deadline = std::time::Instant::now() + budget;
loop {
if let Some(au) = poll_encoder(enc, self.fps)? {
self.in_flight = self.in_flight.saturating_sub(1);
return Ok(Some(au));
match poll_encoder(enc, self.fps)? {
PollOutcome::Packet(au) => {
self.in_flight = self.in_flight.saturating_sub(1);
return Ok(Some(au));
}
// No AU yet (EAGAIN) or drained (EOF) both fall through to the budget check,
// exactly as the previous `Option::None` did.
PollOutcome::Again | PollOutcome::Eof => {}
}
// Nothing ready: only wait when a frame is actually in flight (a drained/EOF'd
// encoder must not spin the budget), and give the ASIC ~250 µs between checks.
@@ -1304,7 +1304,7 @@ impl VulkanVideoEncoder {
// The retarget control command is recorded (execution follows submission order): the
// session's RC state IS the new rate from this frame on — later begins declare it.
self.bitrate = nb;
tracing::info!(
tracing::debug!(
mbps = nb / 1_000_000,
"vulkan-encode: rate control retargeted in place (no IDR)"
);
@@ -0,0 +1,245 @@
//! Shared direct-SDK NVENC core — the platform-agnostic pieces of the two `nvEncodeAPI` backends,
//! Windows D3D11 (`encode/windows/nvenc.rs`) and Linux CUDA (`encode/linux/nvenc_cuda.rs`), so the
//! byte-identical glue lives once (plan §2.2, the direct-NVENC Tier-2). The per-platform parts —
//! the entry-table load (`nvEncodeAPI64.dll` via `LoadLibrary` vs `libnvidia-encode.so` via
//! `libloading`), the device binding (D3D11 vs CUDA), input-surface registration, and the
//! Windows-only async retrieve — stay in their backends. Sibling of [`super::nvenc_status`].
use super::Codec;
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
/// Local `NVENCSTATUS` → `Result` (replaces the sdk's `result_without_string`, which lives in the
/// crate's `safe` module — code these backends must not pull in). The raw status's Debug repr
/// (`NV_ENC_ERR_INVALID_PARAM`, …) is the error payload; callers fold it through
/// [`super::nvenc_status`] for an operator-actionable cause.
pub(super) trait NvStatusExt {
fn nv_ok(self) -> std::result::Result<(), nv::NVENCSTATUS>;
}
impl NvStatusExt for nv::NVENCSTATUS {
fn nv_ok(self) -> std::result::Result<(), nv::NVENCSTATUS> {
match self {
nv::NVENCSTATUS::NV_ENC_SUCCESS => Ok(()),
err => Err(err),
}
}
}
/// The NVENC codec GUID for a session [`Codec`]. PyroWave never opens the direct-NVENC backend
/// (guarded by the `open_video` dispatch), so it is unreachable here.
pub(super) fn codec_guid(codec: Codec) -> nv::GUID {
match codec {
Codec::H264 => nv::NV_ENC_CODEC_H264_GUID,
Codec::H265 => nv::NV_ENC_CODEC_HEVC_GUID,
Codec::Av1 => nv::NV_ENC_CODEC_AV1_GUID,
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
}
}
/// Reference-frame DPB depth when RFI is supported (Apollo uses 5). A deeper DPB lets an invalidated
/// reference fall back to an older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each
/// P-frame single-reference for low latency. Also the window the backends' `invalidate_ref_frames`
/// paths check against (`frame_idx - RFI_DPB` = the oldest frame still in the DPB).
pub(super) const RFI_DPB: u32 = 5;
/// The per-session knobs both direct-NVENC backends feed [`apply_low_latency_config`]. `Copy` so the
/// backend fills it from `self` at the call. The two input-format fields bridge the only real
/// divergence between the CUDA and D3D11 paths (which surface formats can carry full chroma / 10-bit
/// input); everything else in the config is identical across platforms.
#[derive(Clone, Copy)]
pub(super) struct LowLatencyConfig {
pub codec: Codec,
/// Target bitrate (bps); CBR average == max.
pub bitrate: u64,
pub fps: u32,
/// This GPU advertises custom VBV — else leave the preset default (per the caps probe).
pub custom_vbv: bool,
/// A 4:4:4 session was negotiated (HEVC Range Extensions).
pub chroma_444: bool,
/// The input surface can carry full chroma — Linux feeds a YUV444 surface, Windows a packed-RGB
/// surface NVENC CSCs internally. 4:4:4 engages only when this AND [`chroma_444`](Self::chroma_444).
pub full_chroma_input: bool,
/// Output bit depth (8 or 10).
pub bit_depth: u8,
/// AV1 `inputPixelBitDepthMinus8` — the encoder's view of the INPUT depth (Linux is 8-bit in
/// today, so 0; Windows derives it from the surface format). `u32` to match the SDK setter.
pub av1_input_depth_minus8: u32,
pub hdr: bool,
/// This GPU supports reference-frame invalidation (a deeper DPB for graceful loss recovery).
pub rfi_supported: bool,
}
/// Author the shared `NV_ENC_INITIALIZE_PARAMS` (P1/ULL preset, PTD, the session dimensions/rate)
/// pointing at `cfg`. `enable_async` drives the Windows two-thread async retrieve — Linux is
/// sync-only and passes `false`, leaving `enableEncodeAsync` at 0 as before. The returned struct
/// borrows `cfg` as a raw pointer; the caller must keep `cfg` alive across the NVENC call it feeds
/// this into. Used at open and at in-place reconfigure, which must present the SAME init params.
#[allow(clippy::too_many_arguments)]
pub(super) fn build_init_params(
codec_guid: nv::GUID,
width: u32,
height: u32,
fps: u32,
cfg: &mut nv::NV_ENC_CONFIG,
split_mode: u32,
enable_async: bool,
) -> nv::NV_ENC_INITIALIZE_PARAMS {
let mut init = nv::NV_ENC_INITIALIZE_PARAMS {
version: nv::NV_ENC_INITIALIZE_PARAMS_VER,
encodeGUID: codec_guid,
presetGUID: nv::NV_ENC_PRESET_P1_GUID,
tuningInfo: nv::NV_ENC_TUNING_INFO::NV_ENC_TUNING_INFO_ULTRA_LOW_LATENCY,
encodeWidth: width,
encodeHeight: height,
darWidth: width,
darHeight: height,
frameRateNum: fps,
frameRateDen: 1,
enablePTD: 1,
enableEncodeAsync: enable_async as u32,
encodeConfig: cfg,
..Default::default()
};
// splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field.
init.set_splitEncodeMode(split_mode);
init
}
/// Author the shared low-latency NVENC config onto a **preset-seeded** `cfg`: CBR + infinite GOP +
/// P-only + ~1-frame VBV, per-codec tier/level, chroma + bit depth, unconditional colour signaling,
/// and the RFI DPB. The caller seeds `cfg` from the P1/ULL preset first (that call needs the
/// per-platform entry table) and passes the input-format specifics via [`LowLatencyConfig`]; the
/// per-platform surface registration, device binding, and async retrieve stay in the backends.
///
/// # Safety
/// Writes NVENC codec-config union fields on `cfg`, which must be a valid, preset-seeded
/// `NV_ENC_CONFIG` whose active union arm matches [`LowLatencyConfig::codec`].
pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: LowLatencyConfig) {
// CBR, infinite GOP, P-only, ~1-frame VBV — mirrors the AMF/VAAPI/QSV libav RC config.
cfg.gopLength = nv::NVENC_INFINITE_GOPLENGTH;
cfg.frameIntervalP = 1;
cfg.rcParams.rateControlMode = nv::NV_ENC_PARAMS_RC_MODE::NV_ENC_PARAMS_RC_CBR;
let bps = c.bitrate.min(u32::MAX as u64) as u32;
cfg.rcParams.averageBitRate = bps;
cfg.rcParams.maxBitRate = bps;
// Shrink the VBV with the bitrate (NVENC validates it against the same level ceiling), but only
// when the GPU advertises custom-VBV support — else keep the preset default.
if c.custom_vbv {
// ~1-frame VBV by default; PUNKTFUNK_VBV_FRAMES scales it (parity with AMF/VAAPI/QSV).
let vbv = ((c.bitrate as f64 / c.fps.max(1) as f64) * crate::encode::vbv_frames_env())
.clamp(1.0, u32::MAX as f64) as u32;
cfg.rcParams.vbvBufferSize = vbv;
cfg.rcParams.vbvInitialDelay = vbv;
}
// Tier + autoselect level, PER CODEC (the union writes must match the negotiated codec). HEVC
// keeps HIGH tier for its higher per-level bitrate ceiling (Main at 5K ≈ 240 Mbps, HIGH ≈ 800
// Mbps); AV1 supports the Main tier ONLY — tier=1 fails the session open with INVALID_PARAM, and
// its level enum's 0 is LEVEL 2.0 (NOT autoselect), so AV1 takes NO writes (the preset defaults
// are the only accepted config). H.264 has no tier. Level 0 = autoselect for HEVC.
match c.codec {
Codec::H265 => {
cfg.encodeCodecConfig.hevcConfig.tier = 1;
cfg.encodeCodecConfig.hevcConfig.level = 0;
}
Codec::Av1 => {}
Codec::H264 => {}
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
}
// Chroma + bit depth. Full-chroma 4:4:4 (HEVC Range Extensions, chromaFormatIDC=3 under the FREXT
// profile) takes precedence and composes with 10-bit (Main 4:4:4 10); it needs a full-chroma-
// capable input. Otherwise 10-bit selects Main10 (HEVC) or the AV1 output depth — stamping the
// HEVC Main10 GUID onto an AV1 session is an INVALID_PARAM, so bit depth is set PER CODEC.
if c.chroma_444 && c.full_chroma_input {
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_FREXT_GUID;
cfg.encodeCodecConfig.hevcConfig.set_chromaFormatIDC(3);
if c.bit_depth == 10 {
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2); // Main 4:4:4 10
}
} else if c.bit_depth == 10 {
match c.codec {
Codec::H265 => {
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_MAIN10_GUID;
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2);
}
Codec::Av1 => {
cfg.encodeCodecConfig.av1Config.set_pixelBitDepthMinus8(2);
cfg.encodeCodecConfig
.av1Config
.set_inputPixelBitDepthMinus8(c.av1_input_depth_minus8);
}
Codec::H264 => {} // no 10-bit H.264 encode on NVENC — negotiation never asks
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
}
}
// Colour signaling, written UNCONDITIONALLY: the input is already CSC'd to a specific matrix
// (BT.709 limited SDR, or BT.2020 PQ for HDR), so the stream must say so — a decoder whose
// "unspecified" default is 601 (Moonlight/third-party/Android-vendor at sub-HD) otherwise
// mis-renders. HEVC/H.264 carry it in the VUI; AV1 has no VUI, so the same CICP code points go in
// the sequence-header colour config.
{
let (prim, trc, mat) = if c.hdr {
(
nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT2020,
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084,
nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL,
)
} else {
(
nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT709,
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_BT709,
nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT709,
)
};
match c.codec {
Codec::H265 => {
let vui = &mut cfg.encodeCodecConfig.hevcConfig.hevcVUIParameters;
vui.videoSignalTypePresentFlag = 1;
vui.videoFullRangeFlag = 0;
vui.colourDescriptionPresentFlag = 1;
vui.colourPrimaries = prim;
vui.transferCharacteristics = trc;
vui.colourMatrix = mat;
}
Codec::H264 => {
let vui = &mut cfg.encodeCodecConfig.h264Config.h264VUIParameters;
vui.videoSignalTypePresentFlag = 1;
vui.videoFullRangeFlag = 0;
vui.colourDescriptionPresentFlag = 1;
vui.colourPrimaries = prim;
vui.transferCharacteristics = trc;
vui.colourMatrix = mat;
}
Codec::Av1 => {
let av1 = &mut cfg.encodeCodecConfig.av1Config;
av1.colorPrimaries = prim;
av1.transferCharacteristics = trc;
av1.matrixCoefficients = mat;
av1.colorRange = 0; // studio/limited swing
}
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
}
}
// Reference-frame invalidation: a deeper DPB so an invalidated reference can fall back to an
// older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each P-frame
// single-reference for low latency. Only when this GPU supports RFI.
if c.rfi_supported {
let one = nv::NV_ENC_NUM_REF_FRAMES::NV_ENC_NUM_REF_FRAMES_1;
match c.codec {
Codec::H264 => {
cfg.encodeCodecConfig.h264Config.maxNumRefFrames = RFI_DPB;
cfg.encodeCodecConfig.h264Config.numRefL0 = one;
}
Codec::H265 => {
cfg.encodeCodecConfig.hevcConfig.maxNumRefFramesInDPB = RFI_DPB;
cfg.encodeCodecConfig.hevcConfig.numRefL0 = one;
}
Codec::Av1 => {
cfg.encodeCodecConfig.av1Config.maxNumRefFramesInDPB = RFI_DPB;
}
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
}
}
}
@@ -0,0 +1,80 @@
//! Actionable explanations for `NVENCSTATUS` failures — shared by the direct-SDK NVENC backends on
//! Windows (`encode/windows/nvenc.rs`) and Linux (`encode/linux/nvenc_cuda.rs`).
//!
//! Every NVENC entry-point failure used to be annotated `(no NVIDIA GPU?)`, which actively misled
//! triage: the direct-NVENC path only loads on a machine that HAS an NVIDIA GPU, and the failure a
//! user actually hit — `NV_ENC_ERR_INVALID_VERSION` from a userspace/kernel driver version skew,
//! fixed by a reboot — has nothing to do with a missing GPU. This maps each status to what it really
//! means and what the operator should do, and folds that cause into the `anyhow::Error` at
//! construction, so every downstream `{e:#}` log (the encode-recovery loop, session teardown) says
//! the useful thing without extra plumbing.
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
/// A one-line, operator-actionable cause for an NVENC status. Does not repeat the raw code —
/// callers print that alongside (see [`call_err`]). Public for the few sites that build a
/// `String`/`format!` error instead of an `anyhow::Error`.
pub(super) fn explain(status: nv::NVENCSTATUS) -> String {
match status {
// The one this whole module exists for: a version word the driver rejects. Either the
// driver is genuinely older than our headers, or (the sneaky case) the userspace
// `libnvidia-encode` reports a new-enough version to the pre-flight probe but the running
// kernel module is older and rejects the session — the classic "updated the driver, didn't
// reboot" skew. Both heal the same way.
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_VERSION => format!(
"the NVIDIA driver is older than this build's NVENC headers (needs NVENC API {}.{} or \
newer), or the userspace and kernel-module driver versions are mismatched common \
right after a driver update without a reboot. Update the NVIDIA driver, or reboot if \
you just updated it (a host restart is the usual fix).",
nv::NVENCAPI_MAJOR_VERSION,
nv::NVENCAPI_MINOR_VERSION,
),
nv::NVENCSTATUS::NV_ENC_ERR_NO_ENCODE_DEVICE => {
"this GPU exposes no usable NVENC engine — it has no hardware video encoder, or NVENC is \
disabled on this card"
.to_string()
}
nv::NVENCSTATUS::NV_ENC_ERR_UNSUPPORTED_DEVICE => {
"this GPU model is not supported by NVENC".to_string()
}
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_ENCODERDEVICE
| nv::NVENCSTATUS::NV_ENC_ERR_INVALID_DEVICE => {
"the device/context handed to NVENC is invalid — a GPU reset or driver reload can cause \
this"
.to_string()
}
nv::NVENCSTATUS::NV_ENC_ERR_DEVICE_NOT_EXIST => {
"the NVENC device no longer exists — the driver reset, or the GPU fell off the bus"
.to_string()
}
nv::NVENCSTATUS::NV_ENC_ERR_OUT_OF_MEMORY => "the GPU is out of memory".to_string(),
nv::NVENCSTATUS::NV_ENC_ERR_INCOMPATIBLE_CLIENT_KEY => {
"NVENC rejected the client key — the GeForce concurrent-NVENC-session limit was reached, \
or the driver is unlicensed for this many encoders"
.to_string()
}
nv::NVENCSTATUS::NV_ENC_ERR_UNIMPLEMENTED
| nv::NVENCSTATUS::NV_ENC_ERR_UNSUPPORTED_PARAM => {
"this driver/GPU does not implement the requested NVENC encode mode".to_string()
}
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_PARAM => {
"NVENC rejected a parameter — an encode mode this GPU does not support".to_string()
}
nv::NVENCSTATUS::NV_ENC_ERR_ENCODER_BUSY => {
"the NVENC engine is busy — retry, or reduce the number of concurrent encode sessions"
.to_string()
}
nv::NVENCSTATUS::NV_ENC_ERR_GENERIC => {
"the NVIDIA driver returned a generic NVENC failure — check dmesg and the driver install"
.to_string()
}
other => format!("unexpected NVENC status ({other:?})"),
}
}
/// Build an actionable `anyhow::Error` for a failed NVENC entry-point call. `call` names the API
/// (e.g. `"open_encode_session_ex"`); the message carries both the raw status and its real-world
/// cause, so triage never again reads a version mismatch as "(no NVIDIA GPU?)".
pub(super) fn call_err(call: &str, status: nv::NVENCSTATUS) -> anyhow::Error {
anyhow::anyhow!("NVENC {call} failed: {status:?} — {}", explain(status))
}
+79 -18
View File
@@ -596,7 +596,7 @@ fn try_factory() -> std::result::Result<&'static AmfLib, &'static str> {
let lib = load_factory();
if let Err(e) = &lib {
// Once per process; only reachable when the backend resolved to AMF on this box.
tracing::warn!("native AMF runtime unavailable: {e}");
tracing::warn!(error = %e, "native AMF runtime unavailable");
}
lib
})
@@ -1101,7 +1101,8 @@ unsafe fn set_prop(
} else {
tracing::debug!(
property = %name,
result = %format!("{} ({r})", result_name(r)),
result = result_name(r),
amf_code = r,
"optional AMF encoder property rejected (VCN generation/driver) — continuing"
);
Ok(false)
@@ -1666,15 +1667,18 @@ impl AmfEncoder {
tracing::info!(
codec = ?self.codec,
context = context_no,
device = format!("{:#x}", device.as_raw() as usize),
"native AMF encode active (context #{context_no}, {}x{}@{}, zero-copy D3D11 {} ring, runtime {}.{}.{})",
self.width,
self.height,
self.fps,
if self.ten_bit { "P010" } else { "NV12" },
(lib.version >> 48) & 0xffff,
(lib.version >> 32) & 0xffff,
(lib.version >> 16) & 0xffff,
device = %format_args!("{:#x}", device.as_raw() as usize),
width = self.width,
height = self.height,
fps = self.fps,
ring = if self.ten_bit { "P010" } else { "NV12" },
runtime = %format_args!(
"{}.{}.{}",
(lib.version >> 48) & 0xffff,
(lib.version >> 32) & 0xffff,
(lib.version >> 16) & 0xffff
),
"native AMF encode active (zero-copy D3D11)"
);
self.inner = Some(Inner {
comp,
@@ -1766,6 +1770,30 @@ pub fn probe_can_encode(codec: Codec) -> bool {
/// [`probe_can_encode`] against an explicit device (separated so the live tests can pin the AMD
/// adapter on a hybrid box).
fn probe_can_encode_on(device: &ID3D11Device, codec: Codec) -> bool {
probe_open_on(device, codec, false)
}
/// Native factory probe for **10-bit** encode: can this GPU's AMF runtime `Init` a `codec`
/// encoder at 10-bit (Main10 profile / `*ColorBitDepth` 10, P010 input)? The driver rejects the
/// profile/depth props on VCN generations that can't encode them, so a successful tiny `Init` is
/// the honest per-codec answer — read *before* the Welcome by
/// [`crate::encode::can_encode_10bit`] so the negotiated bit depth matches what the session's
/// encoder will really open. H.264 is always `false` (High10 is not a VCN mode — the session
/// open bails on it too).
pub fn probe_can_encode_10bit(codec: Codec) -> bool {
if !codec.supports_10bit() {
return false;
}
let Some(device) = selected_adapter_device() else {
return false;
};
probe_open_on(&device, codec, true)
}
/// Shared probe body: a context on `device`, the codec's component, the usage preset, optionally
/// the 10-bit profile/depth props, then a tiny `Init` (P010 surface when `ten_bit`, NV12
/// otherwise). Everything is torn down before returning; `false` on any failure.
fn probe_open_on(device: &ID3D11Device, codec: Codec, ten_bit: bool) -> bool {
if try_factory().is_err() {
return false;
}
@@ -1774,7 +1802,8 @@ fn probe_can_encode_on(device: &ID3D11Device, codec: Codec) -> bool {
// object is moved into a guard (`Ctx`/`Component`) immediately, so each early return releases
// exactly once; `InitDX11` borrows the live `device` for the synchronous call (AMF holds its
// own device reference until the guard's Terminate). Usage must be set before `Init` (the
// header marks its default "N/A") — the probe mirrors the session's open order.
// header marks its default "N/A") — the probe mirrors the session's open order, including
// the 10-bit profile/depth props (`configure`'s required set_props) when probing 10-bit.
unsafe {
let Ok(lib) = try_factory() else { return false };
let mut ctx: *mut sys::AmfContext = ptr::null_mut();
@@ -1807,7 +1836,32 @@ fn probe_can_encode_on(device: &ID3D11Device, codec: Codec) -> bool {
{
return false;
}
((*(*comp.0).vtbl).init)(comp.0, sys::AMF_SURFACE_NV12, 640, 480) == sys::AMF_OK
if ten_bit {
// The same required props `configure` sets for a 10-bit session — a driver that can't
// honor them rejects here, which is exactly the probe's answer.
let depth_props: &[(PCWSTR, i64)] = match codec {
Codec::H265 => &[
(w!("HevcProfile"), HEVC_PROFILE_MAIN_10),
(w!("HevcColorBitDepth"), COLOR_BIT_DEPTH_10),
],
// 10-bit is part of AV1 Main profile — only the surface depth needs forcing.
Codec::Av1 => &[(w!("Av1ColorBitDepth"), COLOR_BIT_DEPTH_10)],
Codec::H264 | Codec::PyroWave => return false,
};
for (name, value) in depth_props {
if ((*(*comp.0).vtbl).set_property)(comp.0, name.0, AmfVariant::from_i64(*value))
!= sys::AMF_OK
{
return false;
}
}
}
let surface = if ten_bit {
sys::AMF_SURFACE_P010
} else {
sys::AMF_SURFACE_NV12
};
((*(*comp.0).vtbl).init)(comp.0, surface, 640, 480) == sys::AMF_OK
}
}
@@ -2147,7 +2201,8 @@ impl Encoder for AmfEncoder {
);
if r != sys::AMF_OK {
tracing::warn!(
result = %format!("{} ({r})", result_name(r)),
result = result_name(r),
amf_code = r,
"AMF forced-keyframe picture type rejected"
);
}
@@ -2192,7 +2247,8 @@ impl Encoder for AmfEncoder {
if r != sys::AMF_OK {
tracing::warn!(
slot,
result = %format!("{} ({r})", result_name(r)),
result = result_name(r),
amf_code = r,
"AMF LTR mark rejected"
);
}
@@ -2212,7 +2268,8 @@ impl Encoder for AmfEncoder {
} else {
tracing::warn!(
slot,
result = %format!("{} ({r})", result_name(r)),
result = result_name(r),
amf_code = r,
"AMF LTR force-reference rejected — client stays frozen until its IDR fallback"
);
}
@@ -2558,7 +2615,11 @@ impl Encoder for AmfEncoder {
// end-of-stream (remaining AUs then surface through `poll` until AMF_EOF).
let r = unsafe { ((*(*inner.comp.0).vtbl).drain)(inner.comp.0) };
if r != sys::AMF_OK {
tracing::debug!(result = %format!("{} ({r})", result_name(r)), "AMF Drain");
tracing::debug!(
result = result_name(r),
amf_code = r,
"AMF Drain returned non-OK at flush"
);
}
Ok(())
}
@@ -2690,7 +2751,7 @@ mod tests {
}
/// Drive `enc` at the real frame cadence and return each frame's **submit→AU** wall-clock
/// (µs) — the `encode_us` the punktfunk1 loop records. Mirrors the depth-1 loop exactly:
/// (µs) — the `encode_us` the native loop records. Mirrors the depth-1 loop exactly:
/// pace to `1/fps`, timestamp the submit, then drain whatever AUs are ready and FIFO-pair
/// them to their submit stamps. The libavcodec AMF wrapper's ~2-frame output hold therefore
/// shows up here as ~2 frame periods (the AU for frame N emerges only once N+2 is submitted),
@@ -44,7 +44,7 @@ use super::{ChromaFormat, Codec, EncodedFrame, Encoder};
use crate::capture::{dxgi::D3d11Frame, CapturedFrame, FramePayload, PixelFormat};
use anyhow::{anyhow, bail, Context, Result};
use ffmpeg::format::Pixel;
use ffmpeg::{codec, encoder, Dictionary, Packet, Rational};
use ffmpeg::{codec, encoder, Dictionary};
use ffmpeg_next as ffmpeg;
use std::os::raw::{c_int, c_uint, c_void};
use std::ptr;
@@ -60,14 +60,12 @@ use windows::Win32::Graphics::Dxgi::Common::{
DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_SAMPLE_DESC,
};
use super::libav::{
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_BT2020, SWS_CS_ITU709,
SWS_POINT,
};
use ffmpeg::ffi; // = ffmpeg_sys_next
// libswscale scaler-flag + colour-space constants (not exported as Rust consts by the bindings —
// the stable `<libswscale/swscale.h>` #defines, same as the VAAPI path uses).
const SWS_POINT: c_int = 0x10;
const SWS_CS_ITU709: c_int = 1;
const SWS_CS_BT2020: c_int = 9;
/// `AVD3D11VADeviceContext` (libavutil/hwcontext_d3d11va.h) — mirrored (the ffmpeg-sys bindings
/// don't allowlist that header). We set `device` to the capturer's `ID3D11Device` so AMF/QSV share
/// it; `av_hwdevice_ctx_init` fills `device_context`/`video_device`/`video_context`/the default
@@ -149,11 +147,6 @@ fn is_10bit_format(format: PixelFormat, bit_depth: u8) -> bool {
bit_depth >= 10 || matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2)
}
/// `ffmpeg::format::Pixel` → raw `AVPixelFormat`.
fn pixel_to_av(p: Pixel) -> ffi::AVPixelFormat {
ffi::AVPixelFormat::from(p)
}
/// Build the FFmpeg encoder context shared by both inner paths: name, mode, low-latency RC,
/// infinite GOP, the BT.709-limited (SDR) or BT.2020-PQ (HDR) VUI, the given `pix_fmt`, and the
/// optional hw device/frames contexts (null for the system path). Returns the opened encoder.
@@ -187,20 +180,9 @@ unsafe fn open_win_encoder(
// Software view of the input layout (NV12 / P010). For the hw paths `pix_fmt` is overridden to
// D3D11/QSV below; libavcodec still uses this as `sw_pix_fmt`.
video.set_format(Pixel::from(sw_pix_fmt));
video.set_time_base(Rational(1, fps as i32));
video.set_frame_rate(Some(Rational(fps as i32, 1)));
video.set_bit_rate(bitrate_bps as usize);
video.set_max_bit_rate(bitrate_bps as usize); // target == max → CBR
let vbv_frames = std::env::var("PUNKTFUNK_VBV_FRAMES")
.ok()
.and_then(|s| s.parse::<f32>().ok())
.filter(|v| v.is_finite() && *v > 0.0)
.unwrap_or(1.0);
let vbv_bits =
((bitrate_bps as f64 / fps.max(1) as f64) * vbv_frames as f64).clamp(1.0, i32::MAX as f64);
video.set_max_b_frames(0);
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
apply_low_latency_rc(&mut video, fps, bitrate_bps);
let raw = video.as_mut_ptr();
(*raw).rc_buffer_size = vbv_bits as i32;
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
if ten_bit {
// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer. The client auto-detects PQ from
@@ -277,7 +259,7 @@ unsafe fn open_win_encoder(
/// `false` keeps the negotiation honest: an AMF/QSV host resolves every session to 4:2:0 before the
/// Welcome. (Follow-up: implement + validate on an RDNA3+/Arc Windows box.)
pub fn probe_can_encode_444(_vendor: WinVendor, _codec: Codec) -> bool {
tracing::info!("AMF/QSV HEVC 4:4:4 encode is not implemented yet — declining (encoding 4:2:0)");
tracing::debug!("AMF/QSV HEVC 4:4:4 encode not implemented — declining (4:2:0)");
false
}
@@ -320,40 +302,6 @@ pub fn probe_can_encode(vendor: WinVendor, codec: Codec) -> bool {
}
}
/// One `receive_packet` attempt, with the not-ready states kept distinct so the blocking poll
/// below can tell "still encoding" (retry) from "stream over" (stop).
enum PollOutcome {
Packet(EncodedFrame),
Again,
Eof,
}
/// Drain the encoder for one packet (shared poll logic, identical to the VAAPI/NVENC paths).
fn poll_encoder(enc: &mut encoder::video::Encoder, fps: u32) -> Result<PollOutcome> {
let mut pkt = Packet::empty();
match enc.receive_packet(&mut pkt) {
Ok(()) => {
let data = pkt.data().map(|d| d.to_vec()).unwrap_or_default();
let pts = pkt.pts().unwrap_or(0).max(0) as u64;
Ok(PollOutcome::Packet(EncodedFrame {
data,
pts_ns: pts * 1_000_000_000 / fps as u64,
keyframe: pkt.is_key(),
recovery_anchor: false,
chunk_aligned: false,
}))
}
Err(ffmpeg::Error::Other { errno })
if errno == ffmpeg::util::error::EAGAIN
|| errno == ffmpeg::util::error::EWOULDBLOCK =>
{
Ok(PollOutcome::Again)
}
Err(ffmpeg::Error::Eof) => Ok(PollOutcome::Eof),
Err(e) => Err(e).context("receive_packet"),
}
}
/// The immediate context of an `ID3D11Device` (for `CopyResource`/`CopySubresourceRegion`).
unsafe fn immediate_context(device: &ID3D11Device) -> ID3D11DeviceContext {
// windows-rs 0.62: the inherent method takes no args and returns the context (the OutRef form is
+134 -278
View File
@@ -36,6 +36,10 @@
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::nvenc_core::{
apply_low_latency_config, build_init_params, codec_guid, LowLatencyConfig, NvStatusExt, RFI_DPB,
};
use super::nvenc_status;
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
use anyhow::{anyhow, bail, Context, Result};
@@ -116,21 +120,6 @@ struct EncodeApi {
invalidate_ref_frames: unsafe extern "C" fn(*mut c_void, u64) -> nv::NVENCSTATUS,
}
/// Local `NVENCSTATUS` → `Result` (replaces the sdk's `result_without_string`, which lives in the
/// crate's `safe` module — code this file must not pull in, see [`EncodeApi`]). The raw status's
/// Debug repr (`NV_ENC_ERR_INVALID_PARAM`, …) is the error payload.
trait NvStatusExt {
fn nv_ok(self) -> std::result::Result<(), nv::NVENCSTATUS>;
}
impl NvStatusExt for nv::NVENCSTATUS {
fn nv_ok(self) -> std::result::Result<(), nv::NVENCSTATUS> {
match self {
nv::NVENCSTATUS::NV_ENC_SUCCESS => Ok(()),
err => Err(err),
}
}
}
/// Resolve the table once per process. `Err` = NVENC genuinely unavailable on this machine (no
/// NVIDIA driver/DLL, or a driver older than our headers) — the entry points
/// ([`NvencD3d11Encoder::open`], [`probe_can_encode_444`]) gate on it and the AMF/QSV/software
@@ -144,7 +133,7 @@ fn try_api() -> std::result::Result<&'static EncodeApi, &'static str> {
if let Err(e) = &table {
// Once per process. Only reachable when something resolved to NVENC on this box
// (backend misdetect or a forced PUNKTFUNK_ENCODER=nvenc) — say why it will fail.
tracing::warn!("NVENC API unavailable: {e}");
tracing::warn!(error = %e, "NVENC API unavailable");
}
table
})
@@ -234,20 +223,6 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
// GPU-saturating game; this must be ≥ the helper's `PUNKTFUNK_ENCODE_DEPTH` (default 4, clamped ≤ 6).
const POOL: usize = 8;
/// Reference-frame DPB depth when RFI is supported (Apollo uses 5 for H.264/HEVC). A deeper DPB
/// lets an invalidated reference fall back to an older still-valid frame instead of a full IDR;
/// `numRefL0 = 1` keeps each P-frame single-reference for low latency.
const RFI_DPB: u32 = 5;
fn codec_guid(codec: Codec) -> nv::GUID {
match codec {
Codec::H264 => nv::NV_ENC_CODEC_H264_GUID,
Codec::H265 => nv::NV_ENC_CODEC_HEVC_GUID,
Codec::Av1 => nv::NV_ENC_CODEC_AV1_GUID,
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
}
}
/// Live NVENC hardware-session units held by THIS host process (a plain session = 1; a forced
/// split-encode session occupies one session per engine = 23) — the Stage-W3 encoder budget
/// (`design/windows-parallel-virtual-displays.md` §4.5). Kept in ONE place so admitting a parallel
@@ -346,7 +321,7 @@ fn retrieve_loop(
work_rx: mpsc::Receiver<RetrieveJob>,
done_tx: mpsc::Sender<RetrieveDone>,
) {
crate::punktfunk1::boost_thread_priority(false);
crate::native::boost_thread_priority(false);
while let Ok(job) = work_rx.recv() {
// SAFETY: `job.event` is one of the auto-reset events `init_session` created and
// registered for exactly this session, and `job.bs` one of its pool bitstreams; both stay
@@ -381,7 +356,10 @@ fn retrieve_loop(
let _ = (api().unlock_bitstream)(enc as *mut c_void, job.bs as *mut c_void);
Ok((data, keyframe))
}
Err(e) => Err(format!("lock_bitstream (async): {e:?}")),
Err(e) => Err(format!(
"lock_bitstream (async): {e:?} — {}",
nvenc_status::explain(e)
)),
}
}
};
@@ -585,7 +563,16 @@ impl NvencD3d11Encoder {
for &bs in &self.bitstreams {
let _ = (api().destroy_bitstream_buffer)(self.encoder, bs);
}
let _ = (api().destroy_encoder)(self.encoder);
// A destroy failure means the driver may still hold this session's slot (the concurrent-
// session cap is per process and only a restart clears a leak) — make it visible instead
// of silently discarding the status.
if let Err(e) = (api().destroy_encoder)(self.encoder).nv_ok() {
tracing::warn!(
status = ?e,
"NVENC destroy_encoder failed at teardown — the driver may have leaked this \
session's slot toward the concurrent-session cap"
);
}
// Return this session's units to the budget (see LIVE_SESSION_UNITS).
LIVE_SESSION_UNITS.fetch_sub(self.session_units, std::sync::atomic::Ordering::Relaxed);
self.session_units = 0;
@@ -633,11 +620,19 @@ impl NvencD3d11Encoder {
..Default::default()
};
let mut enc: *mut c_void = ptr::null_mut();
(api().open_encode_session_ex)(&mut params, &mut enc)
.nv_ok()
.map_err(|e| {
anyhow!("NVENC open_encode_session_ex (caps probe): {e:?} (no NVIDIA GPU?)")
})?;
if let Err(e) = (api().open_encode_session_ex)(&mut params, &mut enc).nv_ok() {
// The NVENC docs require NvEncDestroyEncoder even after a FAILED open (the driver may
// have allocated the session slot before erroring) — without it, every failed open in
// a retry loop leaks a slot toward the concurrent-session cap, turning a transient
// failure into permanent exhaustion that only a host restart clears.
if !enc.is_null() {
let _ = (api().destroy_encoder)(enc);
}
return Err(nvenc_status::call_err(
"open_encode_session_ex (caps probe)",
e,
));
}
let wmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_WIDTH_MAX);
let hmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_HEIGHT_MAX);
let ten_bit = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_10BIT_ENCODE);
@@ -712,222 +707,41 @@ impl NvencD3d11Encoder {
&mut preset,
)
.nv_ok()
.map_err(|e| anyhow!("get_encode_preset_config_ex: {e:?}"))?;
.map_err(|e| nvenc_status::call_err("get_encode_preset_config_ex", e))?;
let mut cfg = preset.presetCfg;
// Mirror the Linux RC config: CBR, infinite GOP, P-only, ~1-frame VBV.
cfg.gopLength = nv::NVENC_INFINITE_GOPLENGTH;
cfg.frameIntervalP = 1;
cfg.rcParams.rateControlMode = nv::NV_ENC_PARAMS_RC_MODE::NV_ENC_PARAMS_RC_CBR;
let bps = bitrate.min(u32::MAX as u64) as u32;
cfg.rcParams.averageBitRate = bps;
cfg.rcParams.maxBitRate = bps;
// Shrink the VBV with the bitrate — NVENC validates it against the same level ceiling. Only
// when the GPU advertises custom-VBV support (else leave the preset default, per the caps probe).
if self.custom_vbv {
// ~1-frame VBV by default; PUNKTFUNK_VBV_FRAMES scales it (parity with AMF/VAAPI/QSV).
let vbv = ((bitrate as f64 / self.fps.max(1) as f64) * crate::encode::vbv_frames_env())
.clamp(1.0, u32::MAX as f64) as u32;
cfg.rcParams.vbvBufferSize = vbv;
cfg.rcParams.vbvInitialDelay = vbv;
}
// Tier + autoselect level, PER CODEC — these union writes must match the negotiated codec.
// The old unconditional `hevcConfig.tier = 1` relied on "HEVC/AV1 share the union offset",
// which is true for the offsets but WRONG for the values: NVENC's AV1 encoder supports the
// Main tier only, and tier=1 fails the whole session open with NV_ENC_ERR_INVALID_PARAM
// (the "AV1 negotiates fine but the encoder rejects at any bitrate" field bug). It also
// scribbled HEVC offsets into h264Config, where they alias unrelated fields.
// HEVC keeps HIGH tier: its PER-LEVEL bitrate ceiling is otherwise the MAIN-tier cap — at
// 5K that's Level 6.2 Main ≈ 240 Mbps; HIGH lifts it to ≈800 Mbps. AV1's Main-tier level
// ceilings are high enough that autoselect alone suffices. Level 0 = autoselect for both.
match self.codec {
Codec::H265 => {
cfg.encodeCodecConfig.hevcConfig.tier = 1;
cfg.encodeCodecConfig.hevcConfig.level = 0;
}
Codec::Av1 => {
// Deliberately NO writes: the preset defaults are already the only accepted
// configuration — Main tier (tier=1 fails init: NVENC AV1 has no HIGH tier) and
// autoselect level. Do NOT copy HEVC's `level = 0` here: in the AV1 level enum
// 0 is LEVEL 2.0 (autoselect is a distinct constant), so "0 = autoselect" is an
// HEVC-ism that pins AV1 to its smallest level and rejects any real stream.
// idrPeriod likewise stays at the preset default: with PTD enabled the driver
// follows `gopLength` (INFINITE above), and writing INFINITE into it explicitly
// is itself rejected (all verified live on a 4090 / driver 561).
}
// H.264 has no tier; the preset default level is already autoselect.
Codec::H264 => {}
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
}
// Chroma + bit depth. Full-chroma 4:4:4 (HEVC Range Extensions) takes precedence and composes
// with 10-bit (Main 4:4:4 10): NVENC ingests the RGB input (ARGB / ABGR10) and CSCs it to
// YUV444 internally when `chromaFormatIDC = 3` under the FREXT profile. Only valid on an RGB
// input — a subsampled NV12/P010 source can't reconstruct full chroma (so the capturer is
// forced to RGB for a 4:4:4 session, and we guard on the input format here too).
//
// ON-GLASS MEASURED (RTX 5070 Ti, driver 610.43, 2026-07-10 — `nvenc_444_on_glass_probe`
// below + colour-bar analysis): ARGB + chromaFormatIDC=3 + FREXT yields a TRUE 4:4:4
// stream (1-px chroma stripes survive, adjacent-column |dU| ≈ 138), and NVENC's internal
// RGB→YUV conversion FOLLOWS THE CONFIGURED VUI MATRIX (bars match BT.709 within ±1 code
// with our 709 VUI; the same driver produces exact BT.601 when libavcodec's nvenc wrapper
// sets its BT470BG VUI on Linux). The always-written SDR VUI above therefore makes the
// pixels and the signaling agree by construction — no AYUV shader needed.
// Steps 3-7 (RC/VBV, tier+level, chroma+bit-depth, colour VUI, RFI DPB) are the shared
// low-latency contract. On Windows the full-chroma input is a packed-RGB surface (NVENC
// CSCs it internally under FREXT), and AV1's input-depth follows the surface format — 10-bit
// for an ABGR10 / YUV420_10BIT input, else 8-bit.
let rgb_input = matches!(
self.buffer_fmt,
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB
| nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
);
if self.chroma_444 && rgb_input {
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_FREXT_GUID;
cfg.encodeCodecConfig.hevcConfig.set_chromaFormatIDC(3);
if self.bit_depth == 10 {
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2); // Main 4:4:4 10
}
} else if self.bit_depth == 10 {
// 10-bit (HDR foundation): NVENC upconverts an 8-bit input; 8-bit leaves the preset
// default profile untouched. PER CODEC — stamping the HEVC Main10 GUID + hevcConfig
// bitfields onto an AV1 session was an unconditional INVALID_PARAM (the "AV1 10-bit
// session never opens" field bug); AV1's Main profile already covers 10-bit, it only
// needs the output depth set on its own config.
match self.codec {
Codec::H265 => {
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_MAIN10_GUID;
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2);
// 10 - 8
}
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
Codec::Av1 => {
cfg.encodeCodecConfig.av1Config.set_pixelBitDepthMinus8(2);
// The input rides at its real depth; NVENC upconverts (mirrors the HEVC path).
let ten_bit_in = matches!(
self.buffer_fmt,
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
| nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV420_10BIT
);
cfg.encodeCodecConfig
.av1Config
.set_inputPixelBitDepthMinus8(if ten_bit_in { 2 } else { 0 });
}
Codec::H264 => {} // no 10-bit H.264 encode on NVENC — negotiation never asks
}
}
// Colour signaling, written UNCONDITIONALLY (was HDR-only): the capturer hands NVENC
// pre-converted NV12 (BT.709 limited, the IDD VideoConverter) or P010 (BT.2020 PQ
// limited, the FP16→P010 shader), so the stream must SAY so — an SDR stream with no
// colour description decodes correctly only on clients whose "unspecified" default
// happens to be BT.709 limited (ours are, but Moonlight/third-party/Android-vendor
// decoders default 601 at sub-HD resolutions). HEVC/H.264 carry it in the VUI; AV1 has
// NO VUI, so the SAME CICP code points go in the sequence-header colour config
// (`colorPrimaries`/`transferCharacteristics`/`matrixCoefficients`/`colorRange`).
//
// This is the per-stream colour *description* only. The static mastering-display (ST.2086)
// and content-light (MaxCLL/MaxFALL) metadata — HEVC SEI / AV1 METADATA OBUs — is a
// separate follow-up, as is wiring AV1/H.264 to a true 10-bit (Main10) encode (only HEVC
// sets Main10 above today).
{
let (prim, trc, mat) = if self.hdr {
(
nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT2020,
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084,
nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL,
)
} else {
(
nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT709,
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_BT709,
nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT709,
)
};
match self.codec {
Codec::H265 => {
let vui = &mut cfg.encodeCodecConfig.hevcConfig.hevcVUIParameters;
vui.videoSignalTypePresentFlag = 1;
vui.videoFullRangeFlag = 0;
vui.colourDescriptionPresentFlag = 1;
vui.colourPrimaries = prim;
vui.transferCharacteristics = trc;
vui.colourMatrix = mat;
}
Codec::H264 => {
let vui = &mut cfg.encodeCodecConfig.h264Config.h264VUIParameters;
vui.videoSignalTypePresentFlag = 1;
vui.videoFullRangeFlag = 0;
vui.colourDescriptionPresentFlag = 1;
vui.colourPrimaries = prim;
vui.transferCharacteristics = trc;
vui.colourMatrix = mat;
}
Codec::Av1 => {
let av1 = &mut cfg.encodeCodecConfig.av1Config;
av1.colorPrimaries = prim;
av1.transferCharacteristics = trc;
av1.matrixCoefficients = mat;
av1.colorRange = 0; // studio/limited swing
}
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
}
}
// Reference-frame invalidation: keep a deeper DPB so an invalidated reference can fall back
// to an older still-valid frame instead of a full IDR, while `numRefL0 = 1` keeps each
// P-frame single-reference for low latency. Only when this GPU supports RFI (else leave the
// preset default — `invalidate_ref_frames` then returns false and the caller forces an IDR).
if self.rfi_supported {
let one = nv::NV_ENC_NUM_REF_FRAMES::NV_ENC_NUM_REF_FRAMES_1;
match self.codec {
Codec::H264 => {
cfg.encodeCodecConfig.h264Config.maxNumRefFrames = RFI_DPB;
cfg.encodeCodecConfig.h264Config.numRefL0 = one;
}
Codec::H265 => {
cfg.encodeCodecConfig.hevcConfig.maxNumRefFramesInDPB = RFI_DPB;
cfg.encodeCodecConfig.hevcConfig.numRefL0 = one;
}
Codec::Av1 => {
cfg.encodeCodecConfig.av1Config.maxNumRefFramesInDPB = RFI_DPB;
}
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
}
}
let ten_bit_in = matches!(
self.buffer_fmt,
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
| nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV420_10BIT
);
apply_low_latency_config(
&mut cfg,
LowLatencyConfig {
codec: self.codec,
bitrate,
fps: self.fps,
custom_vbv: self.custom_vbv,
chroma_444: self.chroma_444,
full_chroma_input: rgb_input,
bit_depth: self.bit_depth,
av1_input_depth_minus8: if ten_bit_in { 2 } else { 0 },
hdr: self.hdr,
rfi_supported: self.rfi_supported,
},
);
Ok(cfg)
}
/// Author the `NV_ENC_INITIALIZE_PARAMS` pointing at `cfg`. Shared by [`try_open_session`]
/// and [`Encoder::reconfigure_bitrate`] — a reconfigure must present the SAME init params as
/// the open. The returned struct borrows `cfg` raw; the caller keeps `cfg` alive across the
/// NVENC call it feeds this into.
fn build_init_params(
&self,
cfg: &mut nv::NV_ENC_CONFIG,
split_mode: u32,
enable_async: bool,
) -> nv::NV_ENC_INITIALIZE_PARAMS {
let mut init = nv::NV_ENC_INITIALIZE_PARAMS {
version: nv::NV_ENC_INITIALIZE_PARAMS_VER,
encodeGUID: self.codec_guid,
presetGUID: nv::NV_ENC_PRESET_P1_GUID,
tuningInfo: nv::NV_ENC_TUNING_INFO::NV_ENC_TUNING_INFO_ULTRA_LOW_LATENCY,
encodeWidth: self.width,
encodeHeight: self.height,
darWidth: self.width,
darHeight: self.height,
frameRateNum: self.fps,
frameRateDen: 1,
enablePTD: 1,
// Two-thread async retrieve (§5.B): completion events signal the retrieve thread
// instead of `lock_bitstream` blocking the submit thread.
enableEncodeAsync: enable_async as u32,
encodeConfig: cfg,
..Default::default()
};
// splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field.
init.set_splitEncodeMode(split_mode);
init
}
/// Open + configure + initialize ONE NVENC session at `bitrate` (bps) and `split_mode`. Returns
/// the session handle, or destroys it and returns the error. NVENC has no re-init after a failed
/// `initialize_encoder`, so the bitrate-clamp search in `init_session` calls this once per probe.
@@ -946,9 +760,14 @@ impl NvencD3d11Encoder {
..Default::default()
};
let mut enc: *mut c_void = ptr::null_mut();
(api().open_encode_session_ex)(&mut params, &mut enc)
.nv_ok()
.map_err(|e| anyhow!("NVENC open_encode_session_ex: {e:?} (no NVIDIA GPU?)"))?;
if let Err(e) = (api().open_encode_session_ex)(&mut params, &mut enc).nv_ok() {
// Destroy-on-failed-open, as in `query_caps`: a failed open may still hold a session
// slot that must be released.
if !enc.is_null() {
let _ = (api().destroy_encoder)(enc);
}
return Err(nvenc_status::call_err("open_encode_session_ex", e));
}
let mut cfg = match self.build_config(enc, bitrate) {
Ok(cfg) => cfg,
@@ -957,13 +776,21 @@ impl NvencD3d11Encoder {
return Err(e);
}
};
let mut init = self.build_init_params(&mut cfg, split_mode, enable_async);
let mut init = build_init_params(
self.codec_guid,
self.width,
self.height,
self.fps,
&mut cfg,
split_mode,
enable_async,
);
match (api().initialize_encoder)(enc, &mut init).nv_ok() {
Ok(()) => Ok(enc),
Err(e) => {
let _ = (api().destroy_encoder)(enc);
Err(anyhow!("initialize_encoder: {e:?}"))
Err(nvenc_status::call_err("initialize_encoder", e))
}
}
}
@@ -1023,11 +850,11 @@ impl NvencD3d11Encoder {
}
_ => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_MODE as u32,
};
tracing::info!(
tracing::debug!(
split_mode,
bit_depth = self.bit_depth,
pixel_rate,
"NVENC split-encode mode (0=auto 1=auto-forced 2=two 3=three 15=disable)"
"NVENC split-encode mode selected"
);
// Find the highest bitrate the GPU's codec LEVEL accepts and CLAMP to it. NVENC rejects
// `initialize_encoder` (InvalidParam) when the bitrate exceeds the level ceiling (e.g. a
@@ -1122,13 +949,8 @@ impl NvencD3d11Encoder {
// mode (a split session occupies one hardware session per engine).
self.session_units = split_mode_units(split_mode);
LIVE_SESSION_UNITS.fetch_add(self.session_units, std::sync::atomic::Ordering::Relaxed);
if self.bitrate_bps < requested_bps {
tracing::info!(
requested_mbps = requested_bps / 1_000_000,
applied_mbps = self.bitrate_bps / 1_000_000,
"NVENC bitrate capped to this GPU's max for the codec"
);
}
// (The clamp path above already logs the requested→clamped bitrate at warn; no second
// info line for the same event here.)
// 5. one output bitstream per in-flight slot. There is NO encoder-owned input pool: the
// capturer's textures are registered on demand in `submit` and encoded in place.
@@ -1139,7 +961,7 @@ impl NvencD3d11Encoder {
};
(api().create_bitstream_buffer)(enc, &mut cb)
.nv_ok()
.map_err(|e| anyhow!("create_bitstream_buffer: {e:?}"))?;
.map_err(|e| nvenc_status::call_err("create_bitstream_buffer", e))?;
self.bitstreams.push(cb.bitstreamBuffer);
}
// Async retrieve: one auto-reset completion event per pool bitstream, registered with
@@ -1156,7 +978,7 @@ impl NvencD3d11Encoder {
};
(api().register_async_event)(enc, &mut ep)
.nv_ok()
.map_err(|e| anyhow!("register_async_event: {e:?}"))?;
.map_err(|e| nvenc_status::call_err("register_async_event", e))?;
self.events.push(ev.0 as usize);
}
let (work_tx, work_rx) = mpsc::sync_channel::<RetrieveJob>(POOL);
@@ -1297,7 +1119,7 @@ impl Encoder for NvencD3d11Encoder {
// 4:4:4 honesty: the FREXT/chromaFormatIDC=3 config engages only on an RGB input (a
// subsampled NV12/P010 source can't reconstruct full chroma). If the capturer handed
// native YUV despite a 4:4:4 negotiation, this session encodes 4:2:0 — clear the flag
// NOW so `caps().chroma_444` (and punktfunk1's post-open cross-check) reports what
// NOW so `caps().chroma_444` (and native's post-open cross-check) reports what
// the stream really carries instead of silently claiming full chroma.
if self.chroma_444
&& !matches!(
@@ -1372,7 +1194,7 @@ impl Encoder for NvencD3d11Encoder {
};
(api().register_resource)(self.encoder, &mut rr)
.nv_ok()
.map_err(|e| anyhow!("register_resource: {e:?}"))?;
.map_err(|e| nvenc_status::call_err("register_resource", e))?;
self.regs
.insert(key, (rr.registeredResource, frame.texture.clone()));
}
@@ -1385,7 +1207,7 @@ impl Encoder for NvencD3d11Encoder {
};
(api().map_input_resource)(self.encoder, &mut mp)
.nv_ok()
.map_err(|e| anyhow!("map_input_resource: {e:?}"))?;
.map_err(|e| nvenc_status::call_err("map_input_resource", e))?;
let pts = self.frame_idx as u64;
self.frame_idx += 1;
@@ -1469,7 +1291,7 @@ impl Encoder for NvencD3d11Encoder {
}
(api().encode_picture)(self.encoder, &mut pic)
.nv_ok()
.map_err(|e| anyhow!("encode_picture: {e:?}"))?;
.map_err(|e| nvenc_status::call_err("encode_picture", e))?;
self.pending.push_back((
self.bitstreams[slot],
mp.mappedResource,
@@ -1511,7 +1333,11 @@ impl Encoder for NvencD3d11Encoder {
// session is in HDR mode. Both are the real capabilities the session glue routes on.
EncoderCaps {
supports_rfi: self.rfi_supported,
supports_hdr_metadata: self.hdr,
// In-band mastering/CLL is attached as keyframe SEI on HEVC/H.264 only — AV1 carries
// it in METADATA OBUs (`HDR_MDCV`/`HDR_CLL`), which this backend doesn't emit yet
// (see `submit`); the grade still reaches punktfunk clients out-of-band via the 0xCE
// datagram. Don't claim a capability the AV1 path doesn't have.
supports_hdr_metadata: self.hdr && self.codec != Codec::Av1,
// Reflects what the session actually configured (cleared in `query_caps` if the GPU lacks
// YUV444 encode), so the glue can confirm 4:4:4 vs the negotiated request.
chroma_444: self.chroma_444,
@@ -1629,7 +1455,7 @@ impl Encoder for NvencD3d11Encoder {
};
(api().lock_bitstream)(self.encoder, &mut lock)
.nv_ok()
.map_err(|e| anyhow!("lock_bitstream: {e:?}"))?;
.map_err(|e| nvenc_status::call_err("lock_bitstream", e))?;
let data = std::slice::from_raw_parts(
lock.bitstreamBufferPtr as *const u8,
lock.bitstreamSizeInBytes as usize,
@@ -1641,7 +1467,7 @@ impl Encoder for NvencD3d11Encoder {
);
(api().unlock_bitstream)(self.encoder, bs)
.nv_ok()
.map_err(|e| anyhow!("unlock_bitstream: {e:?}"))?;
.map_err(|e| nvenc_status::call_err("unlock_bitstream", e))?;
if !map.is_null() {
let _ = (api().unmap_input_resource)(self.encoder, map);
}
@@ -1694,7 +1520,11 @@ impl Encoder for NvencD3d11Encoder {
};
let mut params = nv::NV_ENC_RECONFIGURE_PARAMS {
version: nv::NV_ENC_RECONFIGURE_PARAMS_VER,
reInitEncodeParams: self.build_init_params(
reInitEncodeParams: build_init_params(
self.codec_guid,
self.width,
self.height,
self.fps,
&mut cfg,
self.split_mode,
self.session_async,
@@ -1738,10 +1568,34 @@ impl Drop for NvencD3d11Encoder {
}
/// Probe whether the active NVIDIA GPU can encode HEVC **4:4:4** (`NV_ENC_CAPS_SUPPORT_YUV444_ENCODE`).
/// Creates a throwaway hardware D3D11 device + NVENC session, queries the cap, and tears down. HEVC-only;
/// the result is cached by the caller ([`crate::encode::can_encode_444`]) and read *before* the Welcome
/// so the host advertises the chroma it can really encode (honest downgrade to 4:2:0 on a card without it).
/// HEVC-only; the result is cached by the caller ([`crate::encode::can_encode_444`]) and read *before*
/// the Welcome so the host advertises the chroma it can really encode (honest downgrade to 4:2:0 on a
/// card without it). See [`probe_encode_cap`] for the throwaway-session mechanics.
pub fn probe_can_encode_444(codec: Codec) -> bool {
if codec != Codec::H265 {
return false;
}
probe_encode_cap(codec, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE)
}
/// Probe whether the active NVIDIA GPU can encode `codec` at **10-bit**
/// (`NV_ENC_CAPS_SUPPORT_10BIT_ENCODE` against the codec's own GUID — HEVC Main10 / AV1 10-bit).
/// The result is cached by the caller ([`crate::encode::can_encode_10bit`]) and read *before* the
/// Welcome so the negotiated bit depth — and the HDR label derived from it — matches what NVENC
/// will really emit. The session-open path re-checks the same cap as a belt-and-braces guard
/// ([`NvencD3d11Encoder::probe_caps`]'s 8-bit fallback).
pub fn probe_can_encode_10bit(codec: Codec) -> bool {
if !codec.supports_10bit() {
return false;
}
probe_encode_cap(codec, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_10BIT_ENCODE)
}
/// Query ONE NVENC capability for `codec`: creates a throwaway hardware D3D11 device + NVENC
/// session on the **selected render adapter**, reads the cap, and tears everything down. `false`
/// on any failure (no loadable NVENC, no device, failed open) — the honest answer for a
/// capability that couldn't be confirmed.
fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
use windows::Win32::Foundation::HMODULE;
use windows::Win32::Graphics::Direct3D::{
D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN, D3D_FEATURE_LEVEL_11_0,
@@ -1750,9 +1604,6 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
D3D11CreateDevice, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION,
};
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
if codec != Codec::H265 {
return false;
}
// No loadable NVENC on this box (non-NVIDIA / no driver) → the honest 4:4:4 answer is "no".
// This is also the `api()` gate for every NVENC call below.
if try_api().is_err() {
@@ -1762,8 +1613,9 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
// `EnumAdapterByLuid` return owned COM objects or err (→ default-adapter fallback).
// `D3D11CreateDevice` (explicit adapter + UNKNOWN driver type, or NULL adapter + HARDWARE)
// fills `device` or returns Err (→ false). `open_encode_session_ex` opens an NVENC session
// against that device's raw pointer (valid while `device` is held) or errors (→ false, tearing
// nothing down). `get_encode_caps` reads one scalar cap into `val` via the loaded API table.
// against that device's raw pointer (valid while `device` is held) or errors (→ false, after
// destroying any residue session the failed open left — the docs require it).
// `get_encode_caps` reads one scalar cap into `val` via the loaded API table.
// `destroy_encoder` frees the session exactly once; `device`/its context drop with the COM
// wrappers. No handle escapes this call and nothing runs concurrently.
unsafe {
@@ -1816,15 +1668,19 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
.nv_ok()
.is_err()
{
// Destroy-on-failed-open: a failed open may still hold a session slot.
if !enc.is_null() {
let _ = (api().destroy_encoder)(enc);
}
return false;
}
let mut param = nv::NV_ENC_CAPS_PARAM {
version: nv::NV_ENC_CAPS_PARAM_VER,
capsToQuery: nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE,
capsToQuery: cap,
reserved: [0; 62],
};
let mut val: i32 = 0;
let ok = (api().get_encode_caps)(enc, nv::NV_ENC_CODEC_HEVC_GUID, &mut param, &mut val)
let ok = (api().get_encode_caps)(enc, codec_guid(codec), &mut param, &mut val)
.nv_ok()
.is_ok()
&& val != 0;
+449
View File
@@ -0,0 +1,449 @@
//! Host lifecycle event bus (scripting-and-hooks RFC §4, phase 0).
//!
//! A process-wide broadcast bus + bounded catch-up ring for **lifecycle events**: client
//! connect/disconnect, session and stream start/end, pairing decisions, virtual-display
//! create/release, library mutations, host start/stop. Fire sites on BOTH planes call
//! [`emit`]; consumers ([`EventBus::subscribe`]) get a ring-backed catch-up plus a live
//! tail — the shape `GET /api/v1/events` (SSE, phase 1) and the hook runner (phase 2)
//! consume. Until those land, the bus is host-internal.
//!
//! Design notes (mirrors [`crate::log_capture`], the shipped ring precedent):
//! - Events carry a monotonically increasing `seq` (1-based) and a wall-clock `ts_ms`.
//! A consumer resumes with `since = last seen seq`; one that fell off the ring gets
//! `dropped = true` and should resync via the REST snapshots.
//! - The wire shape is **versioned and additive-only** within a major ([`SCHEMA_VERSION`]):
//! fields and kinds may be added, never removed or renamed. The JSON snapshot tests below
//! are the review gate — a failing snapshot IS a schema change.
//! - **Payload hygiene: events never carry secrets** — no PINs, no tokens, no key material.
//! Client names and fingerprints are fine (already exposed via the management API).
//! - Emission is fire-and-forget and cheap (a mutex push + a non-blocking broadcast send);
//! nothing here sits in a streaming hot path. Slow consumers lag (`RecvError::Lagged`)
//! rather than buffering unboundedly; the SSE layer disconnects them.
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::broadcast;
use utoipa::ToSchema;
/// Wire-shape major version, carried on every event. Additive-only within a major; removing
/// or renaming a field is a new major (and, at the API layer, a new endpoint negotiation).
pub const SCHEMA_VERSION: u32 = 1;
/// Catch-up ring capacity. Events are small (a few hundred bytes) and low-rate (lifecycle,
/// not per-frame), so 1024 spans hours of ordinary host activity.
const RING_CAPACITY: usize = 1024;
/// Live-tail channel depth per subscriber before a slow consumer starts lagging.
const BROADCAST_CAPACITY: usize = 256;
/// One host lifecycle event, as it will appear on the wire (`data:` of one SSE frame).
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
pub struct HostEvent {
/// Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.
pub seq: u64,
/// Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).
pub ts_ms: u64,
/// Wire-shape version ([`SCHEMA_VERSION`]).
pub schema: u32,
/// The event kind + payload, flattened: `"kind": "stream.started", …payload…`.
#[serde(flatten)]
pub kind: EventKind,
}
/// Which protocol plane an event originated from. Hooks and scripts filter on it — a hook
/// that fires for native clients but not Moonlight clients is a bug, not a v2 feature.
#[derive(Serialize, Deserialize, ToSchema, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Plane {
/// The native punktfunk/1 plane (QUIC).
Native,
/// The GameStream/Moonlight compat plane (`--gamestream`).
Gamestream,
}
/// Why a client went away. `Quit` is a deliberate user "stop" (the typed close code);
/// `Timeout` is a transport idle timeout (the client vanished); `Error` is everything else.
#[derive(Serialize, Deserialize, ToSchema, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DisconnectReason {
Quit,
Timeout,
Error,
}
/// The connecting/disconnecting client's identity.
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
pub struct ClientRef {
/// Client-supplied device name; may be empty (an anonymous or compat-plane client).
pub name: String,
/// Hex SHA-256 certificate fingerprint, when the client presented one.
#[serde(skip_serializing_if = "Option::is_none")]
pub fingerprint: Option<String>,
pub plane: Plane,
}
/// A live A/V session (the plane-neutral notion the Dashboard shows).
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
pub struct SessionRef {
/// Host-local session id (unique within this host process).
pub id: u64,
/// Short client label (cert-fingerprint prefix, or peer IP for an anonymous client).
pub client: String,
/// Negotiated mode, `WxH@Hz` (e.g. `"3840x2160@120"`).
pub mode: String,
pub hdr: bool,
}
/// A live video stream (what the stream marker file reflects).
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
pub struct StreamRef {
/// Negotiated mode, `WxH@Hz`.
pub mode: String,
pub hdr: bool,
/// Client-supplied device name; may be empty.
pub client: String,
/// The launched app/title for this stream, when one was requested (store-qualified id on
/// the native plane, app title on the GameStream plane).
#[serde(skip_serializing_if = "Option::is_none")]
pub app: Option<String>,
pub plane: Plane,
}
/// A device in the pairing flow.
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
pub struct DeviceRef {
/// Sanitized device name (the pairing store's copy).
pub name: String,
/// Hex certificate fingerprint.
pub fingerprint: String,
pub plane: Plane,
}
/// The event catalog (RFC §4). Serialized internally tagged as `"kind": "<domain>.<verb>"`,
/// flattened into [`HostEvent`]. **Additive-only** within [`SCHEMA_VERSION`].
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
#[serde(tag = "kind")]
pub enum EventKind {
#[serde(rename = "client.connected")]
ClientConnected { client: ClientRef },
#[serde(rename = "client.disconnected")]
ClientDisconnected {
client: ClientRef,
reason: DisconnectReason,
},
#[serde(rename = "session.started")]
SessionStarted { session: SessionRef },
#[serde(rename = "session.ended")]
SessionEnded { session: SessionRef },
#[serde(rename = "stream.started")]
StreamStarted { stream: StreamRef },
#[serde(rename = "stream.stopped")]
StreamStopped { stream: StreamRef },
#[serde(rename = "pairing.pending")]
PairingPending { device: DeviceRef },
#[serde(rename = "pairing.completed")]
PairingCompleted { device: DeviceRef },
#[serde(rename = "pairing.denied")]
PairingDenied { device: DeviceRef },
#[serde(rename = "display.created")]
DisplayCreated {
/// The virtual-display backend that minted it (`VirtualDisplay::name`).
backend: String,
/// `WxH@Hz`.
mode: String,
},
#[serde(rename = "display.released")]
DisplayReleased {
/// How many kept displays this release retired.
count: u32,
},
#[serde(rename = "library.changed")]
LibraryChanged {
/// What mutated the library: `"manual"` today; a provider id once the provider
/// API (RFC §8) lands.
source: String,
},
#[serde(rename = "host.started")]
HostStarted {
version: String,
/// Whether the GameStream/Moonlight compat plane is enabled.
gamestream: bool,
},
#[serde(rename = "host.stopping")]
HostStopping,
}
impl EventKind {
/// The wire kind string (`"stream.started"`, …) — for filters and log lines.
pub fn name(&self) -> &'static str {
match self {
EventKind::ClientConnected { .. } => "client.connected",
EventKind::ClientDisconnected { .. } => "client.disconnected",
EventKind::SessionStarted { .. } => "session.started",
EventKind::SessionEnded { .. } => "session.ended",
EventKind::StreamStarted { .. } => "stream.started",
EventKind::StreamStopped { .. } => "stream.stopped",
EventKind::PairingPending { .. } => "pairing.pending",
EventKind::PairingCompleted { .. } => "pairing.completed",
EventKind::PairingDenied { .. } => "pairing.denied",
EventKind::DisplayCreated { .. } => "display.created",
EventKind::DisplayReleased { .. } => "display.released",
EventKind::LibraryChanged { .. } => "library.changed",
EventKind::HostStarted { .. } => "host.started",
EventKind::HostStopping => "host.stopping",
}
}
}
/// Formats a mode as the wire's `WxH@Hz` string.
pub fn mode_str(width: u32, height: u32, hz: u32) -> String {
format!("{width}x{height}@{hz}")
}
/// One consumer's view: the ring-backed catch-up plus a live-tail receiver, taken atomically
/// (no event can fall between `catch_up` and the first `rx.recv()`, and none is in both).
pub struct Subscription {
/// Events with `seq > since`, oldest first.
pub catch_up: Vec<HostEvent>,
/// True when events between `since` and the first caught-up one were already evicted —
/// the consumer should resync via the REST snapshots (the `LogPage.dropped` contract).
pub dropped: bool,
/// The live tail. A consumer that can't keep up sees `RecvError::Lagged`.
pub rx: broadcast::Receiver<HostEvent>,
}
/// The process-wide event bus: a bounded seq-numbered ring (catch-up) + a broadcast channel
/// (live tail).
pub struct EventBus {
inner: Mutex<Ring>,
tx: broadcast::Sender<HostEvent>,
}
struct Ring {
events: VecDeque<HostEvent>,
next_seq: u64,
}
impl EventBus {
fn new() -> Self {
let (tx, _) = broadcast::channel(BROADCAST_CAPACITY);
Self {
inner: Mutex::new(Ring {
events: VecDeque::with_capacity(RING_CAPACITY),
next_seq: 1,
}),
tx,
}
}
/// Stamp, ring-buffer, and broadcast one event. Fire-and-forget: no receivers is fine
/// (the ring still records it for a later subscriber's catch-up).
pub fn emit(&self, kind: EventKind) {
let ts_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let mut ring = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let ev = HostEvent {
seq: ring.next_seq,
ts_ms,
schema: SCHEMA_VERSION,
kind,
};
ring.next_seq += 1;
if ring.events.len() == RING_CAPACITY {
ring.events.pop_front();
}
ring.events.push_back(ev.clone());
// Send while still holding the ring lock: it serializes with `subscribe` (which also
// takes the lock), so an event lands either in a subscriber's catch-up or on its live
// tail — never both, never neither. `send` is non-blocking, the hold is trivial.
let _ = self.tx.send(ev);
}
/// Subscribe with a resume cursor: events with `seq > since` come back as catch-up, the
/// returned receiver carries everything after. `since = 0` means "from the ring start".
pub fn subscribe(&self, since: u64) -> Subscription {
let ring = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let rx = self.tx.subscribe();
let first_seq = ring.events.front().map_or(ring.next_seq, |e| e.seq);
let dropped = since != 0 && since + 1 < first_seq;
let catch_up = ring
.events
.iter()
.filter(|e| e.seq > since)
.cloned()
.collect();
Subscription {
catch_up,
dropped,
rx,
}
}
}
/// The process-wide bus — a `OnceLock` singleton (the [`crate::log_capture::ring`] shape) so
/// fire sites across both planes and the API layer share it without threading an `Arc`.
pub fn bus() -> &'static EventBus {
static BUS: OnceLock<EventBus> = OnceLock::new();
BUS.get_or_init(EventBus::new)
}
/// Emit one lifecycle event on the process-wide bus. Cheap and non-blocking — safe from any
/// thread, including RAII `Drop` paths.
pub fn emit(kind: EventKind) {
bus().emit(kind);
}
#[cfg(test)]
mod tests {
use super::*;
fn ev(name: &str) -> EventKind {
EventKind::LibraryChanged {
source: name.to_string(),
}
}
#[test]
fn seq_is_monotonic_and_catch_up_resumes() {
let bus = EventBus::new();
for i in 0..5 {
bus.emit(ev(&format!("m{i}")));
}
let sub = bus.subscribe(0);
assert_eq!(
sub.catch_up.iter().map(|e| e.seq).collect::<Vec<_>>(),
vec![1, 2, 3, 4, 5]
);
assert!(!sub.dropped);
assert!(sub.catch_up.iter().all(|e| e.schema == SCHEMA_VERSION));
// Resume from a cursor mid-ring.
let sub = bus.subscribe(3);
assert_eq!(
sub.catch_up.iter().map(|e| e.seq).collect::<Vec<_>>(),
vec![4, 5]
);
assert!(!sub.dropped);
// Cursor at the tip: empty catch-up, not a gap.
let sub = bus.subscribe(5);
assert!(sub.catch_up.is_empty());
assert!(!sub.dropped);
}
#[test]
fn eviction_reports_dropped() {
let bus = EventBus::new();
for i in 0..(RING_CAPACITY + 50) {
bus.emit(ev(&format!("m{i}")));
}
// Seqs 1..=50 were evicted; a cursor inside the gap must flag it.
let sub = bus.subscribe(10);
assert!(sub.dropped);
assert_eq!(sub.catch_up.first().map(|e| e.seq), Some(51));
// A fresh consumer (since = 0) is a backfill, not a gap.
let sub = bus.subscribe(0);
assert!(!sub.dropped);
assert_eq!(sub.catch_up.len(), RING_CAPACITY);
}
#[tokio::test]
async fn live_tail_continues_exactly_after_catch_up() {
let bus = EventBus::new();
bus.emit(ev("before-1"));
bus.emit(ev("before-2"));
let mut sub = bus.subscribe(0);
assert_eq!(sub.catch_up.len(), 2);
// Emitted after subscribe → on the live tail only, starting at exactly seq 3.
bus.emit(ev("after"));
let live = sub.rx.recv().await.expect("live event");
assert_eq!(live.seq, 3);
assert_eq!(live.kind.name(), "library.changed");
// Nothing duplicated: the tail holds only what wasn't in the catch-up.
assert!(sub.rx.try_recv().is_err());
}
/// The wire shape IS the contract (additive-only, RFC §4): these snapshots are the review
/// gate — if one fails, the change renames/removes a field and needs a schema-version bump,
/// not a test update.
#[test]
fn wire_shape_snapshots() {
let ev = HostEvent {
seq: 4182,
ts_ms: 1_700_000_000_000,
schema: 1,
kind: EventKind::StreamStarted {
stream: StreamRef {
mode: mode_str(3840, 2160, 120),
hdr: true,
client: "Living Room TV".into(),
app: Some("steam:570".into()),
plane: Plane::Native,
},
},
};
assert_eq!(
serde_json::to_string(&ev).unwrap(),
r#"{"seq":4182,"ts_ms":1700000000000,"schema":1,"kind":"stream.started","stream":{"mode":"3840x2160@120","hdr":true,"client":"Living Room TV","app":"steam:570","plane":"native"}}"#
);
let ev = HostEvent {
seq: 1,
ts_ms: 1_700_000_000_000,
schema: 1,
kind: EventKind::ClientDisconnected {
client: ClientRef {
name: "Deck".into(),
fingerprint: Some("b1c2".into()),
plane: Plane::Gamestream,
},
reason: DisconnectReason::Timeout,
},
};
assert_eq!(
serde_json::to_string(&ev).unwrap(),
r#"{"seq":1,"ts_ms":1700000000000,"schema":1,"kind":"client.disconnected","client":{"name":"Deck","fingerprint":"b1c2","plane":"gamestream"},"reason":"timeout"}"#
);
let ev = HostEvent {
seq: 2,
ts_ms: 1_700_000_000_000,
schema: 1,
kind: EventKind::HostStopping,
};
assert_eq!(
serde_json::to_string(&ev).unwrap(),
r#"{"seq":2,"ts_ms":1700000000000,"schema":1,"kind":"host.stopping"}"#
);
}
#[test]
fn wire_shape_roundtrips() {
let ev = HostEvent {
seq: 7,
ts_ms: 3,
schema: 1,
kind: EventKind::PairingPending {
device: DeviceRef {
name: "iPad Pro".into(),
fingerprint: "ab12".into(),
plane: Plane::Native,
},
},
};
let json = serde_json::to_string(&ev).unwrap();
let back: HostEvent = serde_json::from_str(&json).unwrap();
assert_eq!(back.seq, 7);
assert_eq!(back.kind.name(), "pairing.pending");
match back.kind {
EventKind::PairingPending { device } => {
assert_eq!(device.name, "iPad Pro");
assert_eq!(device.plane, Plane::Native);
}
other => panic!("wrong kind: {other:?}"),
}
}
}
@@ -262,7 +262,7 @@ fn run(
punktfunk_core::transport::grow_socket_buffers(&sock);
// The client pings the audio port (~every 500ms) so we learn where to send.
sock.set_read_timeout(Some(Duration::from_secs(10)))?;
tracing::info!(port = AUDIO_PORT, "audio: awaiting client ping");
tracing::debug!(port = AUDIO_PORT, "audio: awaiting client ping");
let mut probe = [0u8; 256];
let (_, client) = sock
.recv_from(&mut probe)
@@ -275,7 +275,7 @@ fn run(
&sock,
punktfunk_core::transport::MediaClass::Audio,
);
tracing::info!(%client, "audio: client endpoint learned");
tracing::debug!(%client, "audio: client endpoint learned");
// Reuse the persistent capturer when its channel count still matches (drain stale
// buffered audio); otherwise drop it (clean PipeWire teardown) and open at the new count.
@@ -468,7 +468,7 @@ fn audio_body(
timestamp = timestamp.wrapping_add(frame_ms as u32);
sent += 1;
if sent % 400 == 0 {
tracing::info!(sent, "audio: streaming");
tracing::debug!(sent, "audio: streaming");
}
// Hold each frame to its packet-duration slot (skip if we've fallen behind a burst).
@@ -101,7 +101,7 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
},
Ok(None) => break,
Err(e) => {
tracing::warn!(error = %format!("{e:?}"), "control: service error");
tracing::warn!(error = ?e, "control: service error");
break;
}
}
@@ -132,7 +132,7 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
for wire in out {
if let Err(e) = host.peer_mut(pid).send(0, &Packet::reliable(&wire[..]))
{
tracing::warn!(error = %format!("{e:?}"), "rumble send failed");
tracing::warn!(error = ?e, "rumble send failed");
}
}
}
@@ -214,12 +214,12 @@ fn on_receive(
if inner == 0x0301 {
if let Some((first, last)) = decode_rfi_range(&pt) {
*state.rfi_range.lock().unwrap() = Some((first, last));
tracing::info!(first, last, "control: RFI request → invalidate ref frames");
tracing::debug!(first, last, "control: RFI request → invalidate ref frames");
} else {
state
.force_idr
.store(true, std::sync::atomic::Ordering::SeqCst);
tracing::info!("control: RFI request (no range) → keyframe");
tracing::debug!("control: RFI request (no range) → keyframe");
}
return;
}
@@ -227,8 +227,8 @@ fn on_receive(
state
.force_idr
.store(true, std::sync::atomic::Ordering::SeqCst);
tracing::info!(
ty = format!("{inner:#06x}"),
tracing::debug!(
ty = %format_args!("{inner:#06x}"),
"control: IDR request → keyframe"
);
return;
@@ -61,7 +61,7 @@ pub struct GamepadFrame {
// These are `pub const` aliases rather than a `pub use` re-export on purpose: on Windows the sole
// consumer (the Linux uinput map) is cfg'd out, and an unused re-export lints as an error there,
// whereas an unused `pub const` does not. The values still come only from core, so they can't drift;
// the exact wire values are pinned by `punktfunk1.rs::gamepad_wire_bits_are_pinned`.
// the exact wire values are pinned by `native.rs::gamepad_wire_bits_are_pinned`.
use punktfunk_core::input::gamepad as wire;
pub const BTN_DPAD_UP: u32 = wire::BTN_DPAD_UP;
pub const BTN_DPAD_DOWN: u32 = wire::BTN_DPAD_DOWN;

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