feat(protocol,clients): codec preference negotiation + Linux client decodes per Welcome (Phase 2a)

Adds a client-selectable **preferred codec** and wires the core + ABI + probe + Linux client to
negotiate and decode it. (Windows/Apple/Android follow in 2b.)

**Core:**
- `Hello.preferred_codec` (a single CODEC_* bit, 0 = auto) — a soft hint appended after
  `video_codecs`. `resolve_codec(client, host, preferred)` now honors the preference when the host
  can also emit it, else falls back to precedence (HEVC > AV1 > H.264). Roundtrip + preference tests.
- `NativeClient::connect` takes `video_codecs` + `preferred_codec`; `NativeClient.codec` exposes the
  resolved `Welcome.codec`.
- ABI: `punktfunk_connect_ex7` (adds the two codec params; `ex6` delegates to it advertising
  HEVC-only) + `punktfunk_connection_codec` getter + `PUNKTFUNK_CODEC_{H264,HEVC,AV1}` constants
  (drift-guarded against the wire values). Header regenerated.

**Host:** passes `hello.preferred_codec` into `resolve_codec`.

**probe:** `--codec h264|hevc|av1|auto` sets the preference (still advertises it can decode all
three); the dump extension already follows the resolved codec.

**Linux client:** advertises the codecs FFmpeg can actually decode (`decodable_codecs()`), threads
the user's `codec` setting as the preference, and builds the decoder — both the software and VAAPI
paths, plus the mid-session VAAPI→software demotion — from the negotiated `Welcome.codec` instead of
hardcoding HEVC. New "Video codec" dropdown in Preferences (Automatic/HEVC/H.264/AV1).

Live-validated on the dev box: probe `--codec hevc` against a software (H.264-only) host resolves to
H.264 (graceful soft-preference fallback), no failure. clippy + core (57) + host (133) tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 00:13:26 +00:00
parent ffc0b07b46
commit 12843fe253
36 changed files with 529 additions and 144 deletions
+27 -19
View File
@@ -73,39 +73,43 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
`send_rich_input`. **Client-negotiated virtual pad type**: the Hello carries a gamepad `send_rich_input`. **Client-negotiated virtual pad type**: the Hello carries a gamepad
preference byte (same trailing-byte back-compat pattern as the compositor), the Welcome preference byte (same trailing-byte back-compat pattern as the compositor), the Welcome
echoes the resolved backend — precedence: explicit client choice > `PUNKTFUNK_GAMEPAD` echoes the resolved backend — precedence: explicit client choice > `PUNKTFUNK_GAMEPAD`
env > uinput Xbox 360. Backends: **Xbox 360** (uinput / ViGEm), **Xbox One/Series** (the same env > uinput Xbox 360. Backends: **Xbox 360** (uinput on Linux / the pf-xusb UMDF driver on
Windows), **Xbox One/Series** (the same
XInput backend with the One/Series USB identity for matching glyphs — no extra game-visible XInput backend with the One/Series USB identity for matching glyphs — no extra game-visible
capability; impulse-trigger rumble is unreachable through a virtual pad), and the UHID capability; impulse-trigger rumble is unreachable through a virtual pad), and the UHID
`hid-playstation` pads — **DualSense** (adaptive triggers, lightbar, touchpad, motion) and `hid-playstation` pads — **DualSense** (adaptive triggers, lightbar, touchpad, motion) and
**DualShock 4** (lightbar, touchpad, motion, rumble; DualSense minus adaptive triggers / player **DualShock 4** (lightbar, touchpad, motion, rumble; DualSense minus adaptive triggers / player
LEDs / mute). DualSense and DualShock 4 each have a Linux (UHID `hid-playstation`) **and a Windows LEDs / mute). DualSense and DualShock 4 each have a Linux (UHID `hid-playstation`) **and a Windows
(UMDF minidriver)** backend — `inject/dualsense_windows.rs` + `inject/dualshock4_windows.rs`, one (UMDF minidriver)** backend — `inject/windows/dualsense_windows.rs` + `inject/windows/dualshock4_windows.rs`, one
driver serving either identity per a `device_type` byte the host stamps into shared memory (the DS4 driver serving either identity per a `device_type` byte the host stamps into shared memory (the DS4
reuses the same SwDeviceCreate game-detection identity fix as the DualSense). One/Series stays reuses the same SwDeviceCreate game-detection identity fix as the DualSense). One/Series stays
Linux-only and folds into Xbox 360 off it. Clients auto-resolve the type from the physical controller Linux-only and folds into Xbox 360 off it. Clients auto-resolve the type from the physical controller
(DS5→DualSense, DS4→DualShock 4, Xbox One→Xbox One). **Windows uses ZERO external gamepad (DS5→DualSense, DS4→DualShock 4, Xbox One→Xbox One). **Windows uses ZERO external gamepad
dependencies — ViGEmBus is gone.** Xbox 360 (XInput) runs on a UMDF2 **XUSB companion** driver dependencies — ViGEmBus is gone.** Xbox 360 (XInput) runs on a UMDF2 **XUSB companion** driver
(`packaging/windows/xusb-driver/`, `inject/gamepad_windows.rs`) that registers `GUID_DEVINTERFACE_XUSB` (`packaging/windows/drivers/pf-xusb/`, `inject/windows/gamepad_windows.rs`) that registers `GUID_DEVINTERFACE_XUSB`
and answers the buffered XInput IOCTLs from a shared section, so classic `XInputGetState`/`SetState` and answers the buffered XInput IOCTLs from a shared section, so classic `XInputGetState`/`SetState`
work with no kernel bus driver (validated live: slot connected, state + rumble round-trip; Xbox One work with no kernel bus driver (validated live: slot connected, state + rumble round-trip; Xbox One
folds to this 360 path). All three UMDF drivers (DualSense/DS4 + XUSB) are bundled + pnputil-installed folds to this 360 path). All three UMDF drivers (DualSense/DS4 + XUSB) are built from source in CI
by the Inno Setup installer (`packaging/windows/gamepad-drivers/` + `install-gamepad-drivers.ps1`). (`packaging/windows/drivers/`) and installed by the Inno Setup installer via
`punktfunk-host.exe driver install --gamepad`.
**Multi-pad ready**: the host stamps each pad's index into the device Location (`pszDeviceLocation`), **Multi-pad ready**: the host stamps each pad's index into the device Location (`pszDeviceLocation`),
the driver reads it (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-<index>`, and the driver reads it (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-<index>`, and
`UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own host (per-pad statics) — `UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own host (per-pad statics) —
validated live with 2 distinct XInput slots + 2 DualSense pads. (Client-side multi-pad forwarding is validated live with 2 distinct XInput slots + 2 DualSense pads. (Client-side multi-pad forwarding is
the remaining piece.) the remaining piece.)
- **Windows host: implemented and shipping (all-vendor, x64-only).** `#[cfg(windows)]` backends - **Windows host: implemented and shipping (all-vendor, x64-only).** `#[cfg(windows)]` backends
behind the same traits as Linux — DXGI Desktop Duplication capture (`capture/dxgi.rs`), **SudoVDA** behind the same traits as Linux — **IDD-push capture** straight into the in-house all-Rust IddCx
virtual display per session (`vdisplay/sudovda.rs`), GPU encode (NVENC `--features nvenc`; AMD/Intel **pf-vdisplay** virtual display (`capture/windows/idd_push.rs`, `vdisplay/windows/pf_vdisplay.rs`;
`--features amf-qsv`), SendInput + **ViGEm** gamepads (`inject/gamepad_windows.rs`), WASAPI loopback DXGI Desktop Duplication / WGC as fallbacks, `capture/windows/dxgi.rs`), GPU encode (NVENC
+ virtual mic (`audio/wasapi_*`). Ships as a **signed Inno Setup installer** that registers a `--features nvenc`; AMD/Intel `--features amf-qsv`), SendInput + the in-house UMDF gamepad drivers
`LocalSystem` SCM service launching into the interactive session for secure-desktop (UAC/lock-screen) (`inject/windows/`), WASAPI loopback + virtual mic (`audio/windows/wasapi_*`). Ships as a **signed
capture (`service.rs`), bundles the SudoVDA driver + the FFmpeg DLLs, and is published by Inno Setup installer** that registers a `LocalSystem` SCM service launching into the interactive
session for secure-desktop (UAC/lock-screen) capture (`windows/service.rs`), bundles the
pf-vdisplay driver + the FFmpeg DLLs (+ VB-CABLE for the virtual mic), and is published by
`windows-host.yml`. **Encoder is GPU-aware** (`encode.rs` `open_video` + `windows_resolved_backend`): `windows-host.yml`. **Encoder is GPU-aware** (`encode.rs` `open_video` + `windows_resolved_backend`):
`PUNKTFUNK_ENCODER=auto` (the host.env default) detects the DXGI adapter vendor → **NVENC** (NVIDIA, `PUNKTFUNK_ENCODER=auto` (the host.env default) detects the DXGI adapter vendor → **NVENC** (NVIDIA,
direct SDK, `encode/nvenc.rs`), **AMF** (AMD) / **QSV** (Intel) via libavcodec direct SDK, `encode/windows/nvenc.rs`), **AMF** (AMD) / **QSV** (Intel) via libavcodec
(`encode/ffmpeg_win.rs`, the Windows analogue of the Linux VAAPI backend — `WinVendor{Amf,Qsv}`, (`encode/windows/ffmpeg_win.rs`, the Windows analogue of the Linux VAAPI backend — `WinVendor{Amf,Qsv}`,
system-memory NV12/P010 readback default + opt-in zero-copy D3D11 behind `PUNKTFUNK_ZEROCOPY` with a system-memory NV12/P010 readback default + opt-in zero-copy D3D11 behind `PUNKTFUNK_ZEROCOPY` with a
system fallback), or software H.264 (`encode/sw.rs`, GPU-less). GameStream codec advertisement is system fallback), or software H.264 (`encode/sw.rs`, GPU-less). GameStream codec advertisement is
probed per-GPU on AMF/QSV (`windows_codec_support``serverinfo`, AV1 gated). **HDR (10-bit)**: WGC probed per-GPU on AMF/QSV (`windows_codec_support``serverinfo`, AV1 gated). **HDR (10-bit)**: WGC
@@ -311,18 +315,22 @@ land on an already-provisioned box instead of the one that actually needed it.
crates/punktfunk-core/ protocol · FEC · crypto · quic (punktfunk/1 control plane, feature-gated) crates/punktfunk-core/ protocol · FEC · crypto · quic (punktfunk/1 control plane, feature-gated)
crates/punktfunk-host/ crates/punktfunk-host/
gamestream/ Moonlight compat: nvhttp · pairing · rtsp · control · stream · gamepad · apps gamestream/ Moonlight compat: nvhttp · pairing · rtsp · control · stream · gamepad · apps
vdisplay/{kwin,gamescope,mutter,wlroots}.rs per-compositor client-sized virtual outputs vdisplay/linux/{kwin,gamescope,mutter,wlroots}.rs per-compositor client-sized virtual outputs
zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan) vdisplay/windows/{pf_vdisplay,manager,identity}.rs all-Rust IddCx virtual display (pf-vdisplay)
inject/{libei,wlr,gamepad,dualsense}.rs input backends (uinput xpad + UHID DualSense) linux/zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
encode/{nvenc,linux,vaapi,ffmpeg_win,sw}.rs per-GPU encoders (NVENC · Linux NVENC/CUDA · VAAPI · AMF/QSV · openh264) inject/linux/{libei,wlr,gamepad,dualsense,dualshock4,steam_*}.rs Linux input (uinput xpad · UHID pads · virtual Deck)
capture.rs · encode.rs · audio.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs · stats_recorder.rs inject/windows/{sendinput,gamepad_windows,dualsense_windows,dualshock4_windows}.rs Windows input (UMDF shared-mem pads)
encode/linux/{mod,vaapi}.rs · encode/windows/{nvenc,ffmpeg_win}.rs · encode/sw.rs per-GPU encoders (NVENC/CUDA · VAAPI · AMF/QSV) + GPU-less openh264
capture/{linux/,windows/{dxgi,idd_push}}.rs · audio/{linux/,windows/wasapi_*}.rs
windows/{service,install,interactive}.rs SCM service + in-binary driver/web install
capture.rs · encode.rs · audio.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs · stats_recorder.rs · library.rs
clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool) clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool)
clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3) clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3) clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3)
clients/apple/ native macOS/iOS/tvOS client (Swift · VideoToolbox · GameController) clients/apple/ native macOS/iOS/tvOS client (Swift · VideoToolbox · GameController)
clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core) clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core)
clients/decky/ Steam Deck Decky plugin clients/decky/ Steam Deck Decky plugin
crates/punktfunk-host/src/{capture/dxgi,vdisplay/sudovda,encode/ffmpeg_win,inject/gamepad_windows,audio/wasapi_*,service}.rs Windows host backends packaging/windows/drivers/{pf-vdisplay,pf-dualsense,pf-xusb}/ in-house UMDF drivers (built from source in CI)
web/ TanStack web console over the mgmt API (status · devices · pairing · performance graphs) web/ TanStack web console over the mgmt API (status · devices · pairing · performance graphs)
packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs) packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs)
tools/{loss-harness,latency-probe}/ measurement (plan §10) tools/{loss-harness,latency-probe}/ measurement (plan §10)
+4 -2
View File
@@ -33,7 +33,9 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
a screen — tight, push-based integration that's unusual for a Windows streaming host. a screen — tight, push-based integration that's unusual for a Windows streaming host.
- **Low latency, GPU end to end.** Frames go straight from the compositor to the NVENC encoder with - **Low latency, GPU end to end.** Frames go straight from the compositor to the NVENC encoder with
zero CPU copies (dmabuf → CUDA/Vulkan → NVENC), over a transport tuned for responsiveness rather zero CPU copies (dmabuf → CUDA/Vulkan → NVENC), over a transport tuned for responsiveness rather
than throughput. Stable 240 fps at 5120×1440; sub-millisecond capture-to-reassembly on a LAN. than throughput. Stable 240 fps at 5120×1440; sub-millisecond capture-to-reassembly on-box,
~1.3 ms cross-machine on a LAN. (AMD/Intel encode via VAAPI, and a GPU-less software H.264
encoder exists as a fallback.)
- **Works with what you already have.** Any Moonlight/Artemis client connects over GameStream — and - **Works with what you already have.** Any Moonlight/Artemis client connects over GameStream — and
native apps for macOS, Linux, Windows, and Android use the lower-latency `punktfunk/1` protocol. native apps for macOS, Linux, Windows, and Android use the lower-latency `punktfunk/1` protocol.
- **Secure by default.** Hosts require a one-time SPAKE2 **PIN pairing**; after that, devices - **Secure by default.** Hosts require a one-time SPAKE2 **PIN pairing**; after that, devices
@@ -47,7 +49,7 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
| **Core**`punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened | | **Core**`punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads | | **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
| **Native protocol**`punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation | | **Native protocol**`punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
| **Windows host** (x64) | 🟡 Implemented & shipping as a signed installer: DXGI/WGC capture · its own all-Rust IddCx **virtual display** (secure-desktop capable) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green | | **Windows host** (x64) | 🟡 Implemented & shipping as a signed installer: DXGI/WGC capture · its own all-Rust IddCx **virtual display** (secure-desktop capable) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test | | **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test |
| **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch | | **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch |
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, Oboe audio, controllers, discovery, pairing | | **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, Oboe audio, controllers, discovery, pairing |
+1 -1
View File
@@ -107,7 +107,7 @@ PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli
- **App Store screenshots** are automated — `tools/screenshots.sh all` renders the real UI at the - **App Store screenshots** are automated — `tools/screenshots.sh all` renders the real UI at the
required pixel sizes via a DEBUG-only shot mode; the `apple` CI workflow captures the iOS sizes on required pixel sizes via a DEBUG-only shot mode; the `apple` CI workflow captures the iOS sizes on
every main push. See the script header for details. every main push. See the script header for details.
- Deeper design notes live in `docs-site/content/docs/apple-stage2-presenter.md`. - Deeper design notes live in [`design/apple-stage2-presenter.md`](../../design/apple-stage2-presenter.md).
## Related ## Related
+3
View File
@@ -532,6 +532,8 @@ fn speed_test(app: Rc<App>, req: ConnectRequest) {
0, // bitrate_kbps (host default) 0, // bitrate_kbps (host default)
0, // video_caps: the Linux client has no 10-bit/HDR present path yet 0, // video_caps: the Linux client has no 10-bit/HDR present path yet
2, // audio_channels: speed-test probe, stereo 2, // audio_channels: speed-test probe, stereo
crate::video::decodable_codecs(), // codecs (unused by the probe, but honest)
0, // preferred_codec: no preference for a speed-test probe
None, // launch: speed-test probe connect, no game None, // launch: speed-test probe connect, no game
pin, pin,
Some(identity), Some(identity),
@@ -689,6 +691,7 @@ fn start_session_with(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>,
bitrate_kbps: s.bitrate_kbps, bitrate_kbps: s.bitrate_kbps,
mic_enabled: s.mic_enabled, mic_enabled: s.mic_enabled,
audio_channels: s.audio_channels, audio_channels: s.audio_channels,
preferred_codec: s.preferred_codec(),
pin, pin,
identity: app.identity.clone(), identity: app.identity.clone(),
connect_timeout: opts.connect_timeout, connect_timeout: opts.connect_timeout,
+13 -1
View File
@@ -22,6 +22,9 @@ pub struct SessionParams {
pub bitrate_kbps: u32, pub bitrate_kbps: u32,
/// Requested audio channel count (2/6/8); the host echoes the resolved value. /// Requested audio channel count (2/6/8); the host echoes the resolved value.
pub audio_channels: u8, pub audio_channels: u8,
/// The user's preferred video codec (a `quic::CODEC_*` bit, `0` = auto). Soft — the host honors
/// it when it can emit it, else falls back; the resolved codec drives the decoder.
pub preferred_codec: u8,
/// Stream the default microphone to the host's virtual mic source. /// Stream the default microphone to the host's virtual mic source.
pub mic_enabled: bool, pub mic_enabled: bool,
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one). /// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
@@ -141,6 +144,8 @@ fn pump(
params.bitrate_kbps, params.bitrate_kbps,
0, // video_caps: the Linux client has no 10-bit/HDR present path yet 0, // video_caps: the Linux client has no 10-bit/HDR present path yet
params.audio_channels, params.audio_channels,
crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1)
params.preferred_codec, // the user's soft codec preference (0 = auto)
None, // launch: the Linux client has no library picker yet None, // launch: the Linux client has no library picker yet
params.pin, params.pin,
Some(params.identity), Some(params.identity),
@@ -170,7 +175,14 @@ fn pump(
fingerprint: connector.host_fingerprint, fingerprint: connector.host_fingerprint,
}); });
let mut decoder = match Decoder::new() { // Build the decoder for the codec the host resolved (never assume HEVC).
let codec_id = crate::video::ffmpeg_codec_id(connector.codec);
tracing::info!(
?codec_id,
welcome_codec = connector.codec,
"negotiated video codec"
);
let mut decoder = match Decoder::new(codec_id) {
Ok(d) => d, Ok(d) => d,
Err(e) => { Err(e) => {
let _ = ev_tx.send_blocking(SessionEvent::Ended(Some(format!("video decoder: {e}")))); let _ = ev_tx.send_blocking(SessionEvent::Ended(Some(format!("video decoder: {e}"))));
+21
View File
@@ -135,6 +135,26 @@ pub struct Settings {
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it /// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
/// can capture; the resolved count drives the decoder + playback layout. /// can capture; the resolved count drives the decoder + playback layout.
pub audio_channels: u8, pub audio_channels: u8,
/// Preferred video codec: `"auto"` (host decides), `"hevc"`, `"h264"`, or `"av1"`. A soft
/// preference — the host honors it when it can emit it, else falls back to the best shared codec.
#[serde(default = "default_codec")]
pub codec: String,
}
fn default_codec() -> String {
"auto".into()
}
impl Settings {
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
pub fn preferred_codec(&self) -> u8 {
match self.codec.as_str() {
"h264" | "avc" => punktfunk_core::quic::CODEC_H264,
"hevc" | "h265" => punktfunk_core::quic::CODEC_HEVC,
"av1" => punktfunk_core::quic::CODEC_AV1,
_ => 0,
}
}
} }
impl Default for Settings { impl Default for Settings {
@@ -149,6 +169,7 @@ impl Default for Settings {
inhibit_shortcuts: true, inhibit_shortcuts: true,
mic_enabled: false, mic_enabled: false,
audio_channels: 2, audio_channels: 2,
codec: "auto".into(),
} }
} }
} }
+12
View File
@@ -18,6 +18,9 @@ const RESOLUTIONS: &[(u32, u32)] = &[
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240]; const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense", "xboxone", "dualshock4"]; const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense", "xboxone", "dualshock4"];
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"]; const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
/// Codec setting values (persisted) paired with their display labels below.
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
const CODEC_LABELS: &[&str] = &["Automatic", "HEVC (H.265)", "H.264 (AVC)", "AV1"];
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page. /// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
const APP_LICENSE: &str = concat!( const APP_LICENSE: &str = concat!(
@@ -193,6 +196,12 @@ pub fn show(
])) ]))
.build(); .build();
audio.add(&surround_row); audio.add(&surround_row);
let codec_row = adw::ComboRow::builder()
.title("Video codec")
.subtitle("Preferred codec — the host falls back if it can't encode this one")
.model(&gtk::StringList::new(CODEC_LABELS))
.build();
stream.add(&codec_row);
let mic_row = adw::SwitchRow::builder() let mic_row = adw::SwitchRow::builder()
.title("Stream microphone") .title("Stream microphone")
.subtitle("Send the default input device to the host's virtual microphone") .subtitle("Send the default input device to the host's virtual microphone")
@@ -242,6 +251,8 @@ pub fn show(
8 => 2, 8 => 2,
_ => 0, _ => 0,
}); });
let codec_i = CODECS.iter().position(|&c| c == s.codec).unwrap_or(0);
codec_row.set_selected(codec_i as u32);
} }
let dialog = adw::PreferencesDialog::new(); let dialog = adw::PreferencesDialog::new();
@@ -263,6 +274,7 @@ pub fn show(
2 => 8, 2 => 8,
_ => 2, _ => 2,
}; };
s.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string();
s.save(); s.save();
}); });
dialog.present(Some(parent)); dialog.present(Some(parent));
+44 -12
View File
@@ -76,18 +76,48 @@ enum Backend {
pub struct Decoder { pub struct Decoder {
backend: Backend, backend: Backend,
/// The negotiated codec (from the host's Welcome), so a mid-session VAAPI→software demotion
/// rebuilds the software decoder for the SAME codec.
codec_id: ffmpeg::codec::Id,
}
/// Map a negotiated `quic` codec bit to the FFmpeg decoder id the client opens.
pub fn ffmpeg_codec_id(wire: u8) -> ffmpeg::codec::Id {
match wire {
punktfunk_core::quic::CODEC_H264 => ffmpeg::codec::Id::H264,
punktfunk_core::quic::CODEC_AV1 => ffmpeg::codec::Id::AV1,
_ => ffmpeg::codec::Id::HEVC,
}
}
/// The `quic` codec bitfield this client can decode — whatever FFmpeg has a decoder for (HEVC/H.264
/// always; AV1 when built in). Advertised to the host so it never emits a codec we can't decode.
pub fn decodable_codecs() -> u8 {
let _ = ffmpeg::init();
let mut bits = 0u8;
for (id, bit) in [
(ffmpeg::codec::Id::HEVC, punktfunk_core::quic::CODEC_HEVC),
(ffmpeg::codec::Id::H264, punktfunk_core::quic::CODEC_H264),
(ffmpeg::codec::Id::AV1, punktfunk_core::quic::CODEC_AV1),
] {
if ffmpeg::decoder::find(id).is_some() {
bits |= bit;
}
}
bits
} }
impl Decoder { impl Decoder {
pub fn new() -> Result<Decoder> { pub fn new(codec_id: ffmpeg::codec::Id) -> Result<Decoder> {
ffmpeg::init().context("ffmpeg init")?; ffmpeg::init().context("ffmpeg init")?;
let choice = std::env::var("PUNKTFUNK_DECODER").unwrap_or_default(); let choice = std::env::var("PUNKTFUNK_DECODER").unwrap_or_default();
if choice != "software" { if choice != "software" {
match VaapiDecoder::new() { match VaapiDecoder::new(codec_id) {
Ok(v) => { Ok(v) => {
tracing::info!("VAAPI hardware decode active (zero-copy dmabuf)"); tracing::info!(?codec_id, "VAAPI hardware decode active (zero-copy dmabuf)");
return Ok(Decoder { return Ok(Decoder {
backend: Backend::Vaapi(v), backend: Backend::Vaapi(v),
codec_id,
}); });
} }
Err(e) => { Err(e) => {
@@ -99,7 +129,8 @@ impl Decoder {
} }
} }
Ok(Decoder { Ok(Decoder {
backend: Backend::Software(SoftwareDecoder::new()?), backend: Backend::Software(SoftwareDecoder::new(codec_id)?),
codec_id,
}) })
} }
@@ -113,7 +144,7 @@ impl Decoder {
Ok(f) => Ok(f.map(DecodedFrame::Dmabuf)), Ok(f) => Ok(f.map(DecodedFrame::Dmabuf)),
Err(e) => { Err(e) => {
tracing::warn!(error = %e, "VAAPI decode failed — falling back to software"); tracing::warn!(error = %e, "VAAPI decode failed — falling back to software");
self.backend = Backend::Software(SoftwareDecoder::new()?); self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
Ok(None) Ok(None)
} }
}, },
@@ -131,9 +162,9 @@ struct SoftwareDecoder {
} }
impl SoftwareDecoder { impl SoftwareDecoder {
fn new() -> Result<SoftwareDecoder> { fn new(codec_id: ffmpeg::codec::Id) -> Result<SoftwareDecoder> {
let codec = let codec = ffmpeg::decoder::find(codec_id)
ffmpeg::decoder::find(ffmpeg::codec::Id::HEVC).ok_or(anyhow!("no HEVC decoder"))?; .ok_or_else(|| anyhow!("no {codec_id:?} decoder in libavcodec"))?;
let mut ctx = ffmpeg::codec::Context::new_with_codec(codec); let mut ctx = ffmpeg::codec::Context::new_with_codec(codec);
unsafe { unsafe {
let raw = ctx.as_mut_ptr(); let raw = ctx.as_mut_ptr();
@@ -142,7 +173,7 @@ impl SoftwareDecoder {
(*raw).thread_type = ffmpeg::ffi::FF_THREAD_SLICE; (*raw).thread_type = ffmpeg::ffi::FF_THREAD_SLICE;
(*raw).thread_count = 0; // auto (*raw).thread_count = 0; // auto
} }
let decoder = ctx.decoder().video().context("open HEVC decoder")?; let decoder = ctx.decoder().video().context("open video decoder")?;
Ok(SoftwareDecoder { decoder, sws: None }) Ok(SoftwareDecoder { decoder, sws: None })
} }
@@ -240,7 +271,7 @@ struct VaapiDecoder {
unsafe impl Send for VaapiDecoder {} unsafe impl Send for VaapiDecoder {}
impl VaapiDecoder { impl VaapiDecoder {
fn new() -> Result<VaapiDecoder> { fn new(codec_id: ffmpeg::codec::Id) -> Result<VaapiDecoder> {
use ffmpeg::ffi; use ffmpeg::ffi;
unsafe { unsafe {
let mut hw_device: *mut ffi::AVBufferRef = ptr::null_mut(); let mut hw_device: *mut ffi::AVBufferRef = ptr::null_mut();
@@ -254,10 +285,11 @@ impl VaapiDecoder {
if r < 0 { if r < 0 {
bail!("no VAAPI device ({})", ffmpeg::Error::from(r)); bail!("no VAAPI device ({})", ffmpeg::Error::from(r));
} }
let codec = ffi::avcodec_find_decoder(ffi::AVCodecID::AV_CODEC_ID_HEVC); // The negotiated codec's decoder id (av_codec_id maps 1:1 from ffmpeg::codec::Id).
let codec = ffi::avcodec_find_decoder(codec_id.into());
if codec.is_null() { if codec.is_null() {
ffi::av_buffer_unref(&mut hw_device); ffi::av_buffer_unref(&mut hw_device);
bail!("no HEVC decoder"); bail!("no {codec_id:?} decoder");
} }
let ctx = ffi::avcodec_alloc_context3(codec); let ctx = ffi::avcodec_alloc_context3(codec);
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device); (*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device);
+8 -3
View File
@@ -12,8 +12,10 @@ example of driving the protocol end to end: QUIC control plane, UDP data plane,
## What it does ## What it does
- **Receives a real stream**, writes a playable `.h265`, and reports per-frame - **Receives a real stream**, writes a playable elementary stream (`.h265`/`.h264`/`.av1` — the
**capture→…→reassembled latency** percentiles (the host stamps each frame with its capture clock). extension tracks the **negotiated codec**; the probe advertises all three and the host picks), and
reports per-frame **capture→…→reassembled latency** percentiles (the host stamps each frame with
its capture clock).
- **Verification mode** against a synthetic host — byte-checks deterministic test frames. - **Verification mode** against a synthetic host — byte-checks deterministic test frames.
- **Exercises every plane** with scripted test traffic: - **Exercises every plane** with scripted test traffic:
`--input-test` (mouse/keyboard), `--mic-test` (a 440 Hz Opus tone up to the host mic), `--input-test` (mouse/keyboard), `--mic-test` (a 440 Hz Opus tone up to the host mic),
@@ -25,7 +27,10 @@ example of driving the protocol end to end: QUIC control plane, UDP data plane,
- **Discovery** — `--discover [secs]` browses the LAN for `_punktfunk._udp` hosts and prints each - **Discovery** — `--discover [secs]` browses the LAN for `_punktfunk._udp` hosts and prints each
(name, addr:port, pairing requirement, cert fingerprint), then exits. (name, addr:port, pairing requirement, cert fingerprint), then exits.
- **Negotiation knobs** — `--mode WxHxFPS`, `--remode` (mid-stream mode change), `--bitrate`, - **Negotiation knobs** — `--mode WxHxFPS`, `--remode` (mid-stream mode change), `--bitrate`,
`--audio-channels` (stereo / 5.1 / 7.1), `--compositor`, `--gamepad`, `--launch`, `--speed-test`. `--codec auto|h264|hevc|av1` (preference; the host resolves), `--audio-channels`
(stereo / 5.1 / 7.1), `--compositor`, `--gamepad`, `--launch`, `--speed-test`.
Env: `PUNKTFUNK_CLIENT_10BIT=1` / `PUNKTFUNK_CLIENT_444=1` advertise the 10-bit / 4:4:4 client
caps (for testing a host's `PUNKTFUNK_10BIT`/`PUNKTFUNK_444`).
## Usage ## Usage
+24 -7
View File
@@ -1,10 +1,12 @@
//! `punktfunk-probe` — the reference client for `punktfunk/1` (M3): QUIC control plane, UDP data //! `punktfunk-probe` — the reference client for `punktfunk/1`: QUIC control plane, UDP data
//! plane, input over QUIC datagrams. Two modes, decided by the host's Welcome: //! plane, input over QUIC datagrams. Two modes, decided by the host's Welcome:
//! //!
//! * **verification** (`frames > 0`, synthetic host): byte-checks deterministic test frames; //! * **verification** (`frames > 0`, synthetic host): byte-checks deterministic test frames;
//! * **stream** (`frames == 0`, virtual host): receives real NVENC AUs, writes a playable //! * **stream** (`frames == 0`, virtual host): receives real encoded AUs, writes a playable
//! `.h265`, and reports per-frame **capture→…→reassembled latency** percentiles (the host //! elementary stream (the dump extension follows the negotiated codec — `.h265`/`.h264`/`.av1`;
//! stamps each frame with its capture wall clock; same-host runs share that clock). //! the probe advertises all three), and reports per-frame **capture→…→reassembled latency**
//! percentiles (the host stamps each frame with its capture wall clock; same-host runs share
//! that clock).
//! //!
//! `--input-test` exercises the input plane: scripted mouse/keyboard datagrams during the //! `--input-test` exercises the input plane: scripted mouse/keyboard datagrams during the
//! stream (watch them land in the host session, e.g. xev inside gamescope). `--mic-test` //! stream (watch them land in the host session, e.g. xev inside gamescope). `--mic-test`
@@ -36,9 +38,12 @@
//! over mDNS, prints each (name, addr:port, pairing requirement, cert fingerprint to pin), and //! over mDNS, prints each (name, addr:port, pairing requirement, cert fingerprint to pin), and
//! exits without connecting. //! exits without connecting.
//! //!
//! Usage: `punktfunk-probe [--connect HOST:PORT] [--mode WxHxFPS] [--out FILE] [--input-test] //! Usage: `punktfunk-probe [--connect HOST:PORT] [--mode WxHxFPS] [--remode WxHxFPS:SECS]
//! [--pin HEX] [--compositor NAME] [--gamepad NAME] | --discover [SECS]` //! [--out FILE] [--bitrate KBPS] [--codec auto|h264|hevc|av1] [--audio-channels 2|6|8]
//! (M4 adds VAAPI decode + wgpu present on this skeleton.) //! [--launch APP] [--name NAME] [--speed-test KBPS:MS]
//! [--input-test | --mic-test | --touch-test | --rich-input-test]
//! [--pin HEX | --pair PIN] [--compositor NAME] [--gamepad NAME] | --discover [SECS]`
//! Env: `PUNKTFUNK_CLIENT_10BIT=1` / `PUNKTFUNK_CLIENT_444=1` advertise the 10-bit / 4:4:4 caps.
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use punktfunk_core::config::GamepadPref; use punktfunk_core::config::GamepadPref;
@@ -82,6 +87,10 @@ struct Args {
/// multistream-decodes the host's frames and asserts the per-channel sample count, so it's the /// multistream-decodes the host's frames and asserts the per-channel sample count, so it's the
/// headless validator for the surround encode path. /// headless validator for the surround encode path.
audio_channels: u8, audio_channels: u8,
/// `--codec h264|hevc|av1|auto` — the preferred video codec (soft; the host honors it when it can
/// emit it, else falls back). The probe always advertises it can decode all three; this just sets
/// the preference byte. `auto` (default) = no preference (host decides). `0` = auto.
preferred_codec: u8,
/// `--launch ID` — ask the host to launch a library title in this session (a store-qualified /// `--launch ID` — ask the host to launch a library title in this session (a store-qualified
/// id from the host's `GET /api/v1/library`, e.g. `steam:570`). Host resolves it; `None` = none. /// id from the host's `GET /api/v1/library`, e.g. `steam:570`). Host resolves it; `None` = none.
launch: Option<String>, launch: Option<String>,
@@ -210,6 +219,12 @@ fn parse_args() -> Args {
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.unwrap_or(2), .unwrap_or(2),
), ),
preferred_codec: match get("--codec").unwrap_or("auto") {
"h264" | "avc" => punktfunk_core::quic::CODEC_H264,
"hevc" | "h265" => punktfunk_core::quic::CODEC_HEVC,
"av1" => punktfunk_core::quic::CODEC_AV1,
_ => 0, // auto — no preference
},
launch: get("--launch").map(str::to_string), launch: get("--launch").map(str::to_string),
speed_test: get("--speed-test").and_then(|s| { speed_test: get("--speed-test").and_then(|s| {
let (kbps, ms) = s.split_once(':')?; let (kbps, ms) = s.split_once(':')?;
@@ -428,6 +443,8 @@ async fn session(args: Args) -> Result<()> {
video_codecs: punktfunk_core::quic::CODEC_H264 video_codecs: punktfunk_core::quic::CODEC_H264
| punktfunk_core::quic::CODEC_HEVC | punktfunk_core::quic::CODEC_HEVC
| punktfunk_core::quic::CODEC_AV1, | punktfunk_core::quic::CODEC_AV1,
// `--codec` soft preference (0 = auto). The host honors it when it can emit it.
preferred_codec: args.preferred_codec,
} }
.encode(), .encode(),
) )
+1 -1
View File
@@ -4,7 +4,7 @@ The shared **host ↔ driver binary contract** for punktfunk's Windows **pf-vdis
the control IOCTLs and the IDD-push frame transport, defined exactly once. the control IOCTLs and the IDD-push frame transport, defined exactly once.
It's a path dependency of **both** the host workspace ([`crates/punktfunk-host`](../punktfunk-host)) It's a path dependency of **both** the host workspace ([`crates/punktfunk-host`](../punktfunk-host))
and the out-of-workspace driver workspace ([`packaging/windows/drivers/`](../../packaging/windows)), and the out-of-workspace driver workspace ([`packaging/windows/drivers/`](../../packaging/windows/drivers)),
so it must resolve identically from either build graph. That's why it's deliberately self-contained: so it must resolve identically from either build graph. That's why it's deliberately self-contained:
`no_std` (+ alloc), platform-neutral (GUID/LUID are plain integers each side converts to its own OS `no_std` (+ alloc), platform-neutral (GUID/LUID are plain integers each side converts to its own OS
type), and free of `*.workspace = true` inheritance. type), and free of `*.workspace = true` inheritance.
+98 -2
View File
@@ -844,12 +844,23 @@ pub const PUNKTFUNK_VIDEO_CAP_HDR: u8 = 0x02;
/// [`punktfunk_connection_chroma_format`] reports the real value. (Mirrors `quic::VIDEO_CAP_444`.) /// [`punktfunk_connection_chroma_format`] reports the real value. (Mirrors `quic::VIDEO_CAP_444`.)
pub const PUNKTFUNK_VIDEO_CAP_444: u8 = 0x04; pub const PUNKTFUNK_VIDEO_CAP_444: u8 = 0x04;
/// Codec bit for [`punktfunk_connect_ex7`] (`video_codecs` / `preferred_codec`) and the value
/// [`punktfunk_connection_codec`] returns: H.264 / AVC. (Mirrors `quic::CODEC_H264`.)
pub const PUNKTFUNK_CODEC_H264: u8 = 0x01;
/// Codec bit: H.265 / HEVC — the default codec. (Mirrors `quic::CODEC_HEVC`.)
pub const PUNKTFUNK_CODEC_HEVC: u8 = 0x02;
/// Codec bit: AV1. (Mirrors `quic::CODEC_AV1`.)
pub const PUNKTFUNK_CODEC_AV1: u8 = 0x04;
// Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift). // Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
#[cfg(feature = "quic")] #[cfg(feature = "quic")]
const _: () = { const _: () = {
assert!(PUNKTFUNK_VIDEO_CAP_10BIT == crate::quic::VIDEO_CAP_10BIT); assert!(PUNKTFUNK_VIDEO_CAP_10BIT == crate::quic::VIDEO_CAP_10BIT);
assert!(PUNKTFUNK_VIDEO_CAP_HDR == crate::quic::VIDEO_CAP_HDR); assert!(PUNKTFUNK_VIDEO_CAP_HDR == crate::quic::VIDEO_CAP_HDR);
assert!(PUNKTFUNK_VIDEO_CAP_444 == crate::quic::VIDEO_CAP_444); assert!(PUNKTFUNK_VIDEO_CAP_444 == crate::quic::VIDEO_CAP_444);
assert!(PUNKTFUNK_CODEC_H264 == crate::quic::CODEC_H264);
assert!(PUNKTFUNK_CODEC_HEVC == crate::quic::CODEC_HEVC);
assert!(PUNKTFUNK_CODEC_AV1 == crate::quic::CODEC_AV1);
}; };
// Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift). // Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift).
@@ -1160,8 +1171,8 @@ pub unsafe extern "C" fn punktfunk_connect_ex5(
/// Like [`punktfunk_connect_ex5`], but additionally requests the audio channel count: /// Like [`punktfunk_connect_ex5`], but additionally requests the audio channel count:
/// `2` (stereo, the default behaviour of every earlier variant), `6` (5.1) or `8` (7.1). The host /// `2` (stereo, the default behaviour of every earlier variant), `6` (5.1) or `8` (7.1). The host
/// clamps the request to what it can actually capture and echoes the resolved count via /// clamps the request to what it can actually capture and echoes the resolved count via
/// [`punktfunk_connection_audio_channels`]; the `0xC9` audio frames are Opus-(multi)stream encoded /// [`punktfunk_connection_audio_channels`]. Advertises HEVC-only with no codec preference (call
/// for that layout. A client that wants surround calls this; everything else inherits stereo. /// [`punktfunk_connect_ex7`] to negotiate the codec).
/// ///
/// # Safety /// # Safety
/// Same as [`punktfunk_connect`]. /// Same as [`punktfunk_connect`].
@@ -1185,6 +1196,62 @@ pub unsafe extern "C" fn punktfunk_connect_ex6(
client_cert_pem: *const std::os::raw::c_char, client_cert_pem: *const std::os::raw::c_char,
client_key_pem: *const std::os::raw::c_char, client_key_pem: *const std::os::raw::c_char,
timeout_ms: u32, timeout_ms: u32,
) -> *mut PunktfunkConnection {
unsafe {
punktfunk_connect_ex7(
host,
port,
width,
height,
refresh_hz,
compositor,
gamepad,
bitrate_kbps,
video_caps,
audio_channels,
PUNKTFUNK_CODEC_HEVC, // pre-negotiation default: HEVC-only, no preference
0,
launch_id,
pin_sha256,
observed_sha256_out,
client_cert_pem,
client_key_pem,
timeout_ms,
)
}
}
/// Like [`punktfunk_connect_ex6`], but additionally advertises the codecs the client can decode
/// (`video_codecs` — a bitfield of [`PUNKTFUNK_CODEC_H264`] / [`PUNKTFUNK_CODEC_HEVC`] /
/// [`PUNKTFUNK_CODEC_AV1`]) and a soft `preferred_codec` (a single codec bit, `0` = no preference).
/// The host resolves the codec it emits from these (preference honored when it can also produce it,
/// else best shared codec) and reports it via [`punktfunk_connection_codec`]. A client that omits
/// this (calls `ex6`) advertises HEVC-only, no preference — the pre-negotiation behaviour.
///
/// # Safety
/// Same as [`punktfunk_connect`].
#[cfg(feature = "quic")]
#[no_mangle]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn punktfunk_connect_ex7(
host: *const std::os::raw::c_char,
port: u16,
width: u32,
height: u32,
refresh_hz: u32,
compositor: u32,
gamepad: u32,
bitrate_kbps: u32,
video_caps: u8,
audio_channels: u8,
video_codecs: u8,
preferred_codec: u8,
launch_id: *const std::os::raw::c_char,
pin_sha256: *const u8,
observed_sha256_out: *mut u8,
client_cert_pem: *const std::os::raw::c_char,
client_key_pem: *const std::os::raw::c_char,
timeout_ms: u32,
) -> *mut PunktfunkConnection { ) -> *mut PunktfunkConnection {
let r = std::panic::catch_unwind(AssertUnwindSafe(|| { let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
if host.is_null() { if host.is_null() {
@@ -1235,6 +1302,8 @@ pub unsafe extern "C" fn punktfunk_connect_ex6(
bitrate_kbps, bitrate_kbps,
video_caps, video_caps,
crate::audio::normalize_channels(audio_channels), crate::audio::normalize_channels(audio_channels),
video_codecs,
preferred_codec,
launch, launch,
pin, pin,
identity, identity,
@@ -1763,6 +1832,33 @@ pub unsafe extern "C" fn punktfunk_connection_chroma_format(
}) })
} }
/// Read the video codec the host resolved for this session (from its Welcome): one of
/// [`PUNKTFUNK_CODEC_H264`] / [`PUNKTFUNK_CODEC_HEVC`] / [`PUNKTFUNK_CODEC_AV1`]. The embedder builds
/// its decoder from THIS (never assuming HEVC). `*out` is filled when non-NULL. Available
/// immediately after a successful connect (it doesn't change without a reconfigure). An older host
/// that didn't negotiate a codec reports [`PUNKTFUNK_CODEC_HEVC`].
///
/// # Safety
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_codec(
c: *mut PunktfunkConnection,
out: *mut u8,
) -> PunktfunkStatus {
guard(|| {
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
if !out.is_null() {
// SAFETY: `out` is non-null and the caller guarantees it is writable for one `u8`.
unsafe { *out = c.inner.codec };
}
PunktfunkStatus::Ok
})
}
/// Send one input event to the host as a QUIC datagram (non-blocking enqueue). /// Send one input event to the host as a QUIC datagram (non-blocking enqueue).
/// ///
/// # Safety /// # Safety
+27 -6
View File
@@ -41,8 +41,8 @@ enum CtrlRequest {
/// certificate fingerprint, the resolved encoder bitrate (kbps), and the host↔client clock offset /// certificate fingerprint, the resolved encoder bitrate (kbps), and the host↔client clock offset
/// (ns, host minus client; 0 = no skew correction / an old host that didn't answer the handshake). /// (ns, host minus client; 0 = no skew correction / an old host that didn't answer the handshake).
/// The trailing `u8`s are the resolved encode bit depth (8/10), the chroma `chroma_format_idc` /// The trailing `u8`s are the resolved encode bit depth (8/10), the chroma `chroma_format_idc`
/// (1 = 4:2:0, 3 = 4:4:4), and the resolved audio channel count (2/6/8), with [`ColorInfo`] the /// (1 = 4:2:0, 3 = 4:4:4), the resolved audio channel count (2/6/8), and the resolved video codec
/// resolved colour signalling — all from the [`Welcome`]. /// (`quic::CODEC_*`), with [`ColorInfo`] the resolved colour signalling — all from the [`Welcome`].
type Negotiated = ( type Negotiated = (
Mode, Mode,
CompositorPref, CompositorPref,
@@ -54,6 +54,7 @@ type Negotiated = (
ColorInfo, ColorInfo,
u8, u8,
u8, u8,
u8,
); );
/// Accumulated state of an in-flight / finished speed test. The data-plane pump mirrors the /// Accumulated state of an in-flight / finished speed test. The data-plane pump mirrors the
@@ -216,6 +217,10 @@ pub struct NativeClient {
/// host that omits it (→ `2`) yields working stereo. The `0xC9` audio frames are encoded with the /// host that omits it (→ `2`) yields working stereo. The `0xC9` audio frames are encoded with the
/// matching layout. /// matching layout.
pub audio_channels: u8, pub audio_channels: u8,
/// The video codec the host resolved and will emit ([`Welcome::codec`]) — [`quic::CODEC_H264`],
/// [`quic::CODEC_HEVC`] (default / older host), or [`quic::CODEC_AV1`]. The client builds its
/// decoder from THIS, never assuming HEVC.
pub codec: u8,
} }
/// Pin the calling thread to the user-interactive QoS class on Apple targets. /// Pin the calling thread to the user-interactive QoS class on Apple targets.
@@ -263,6 +268,11 @@ impl NativeClient {
// Requested audio channel count (2 = stereo / 6 = 5.1 / 8 = 7.1); the host clamps to what it // Requested audio channel count (2 = stereo / 6 = 5.1 / 8 = 7.1); the host clamps to what it
// can capture and echoes the result in [`NativeClient::audio_channels`]. // can capture and echoes the result in [`NativeClient::audio_channels`].
audio_channels: u8, audio_channels: u8,
// The codecs this client can decode (bitfield of quic::CODEC_H264 / CODEC_HEVC / CODEC_AV1)
// and the user's soft preference (a single codec bit, 0 = auto). The host resolves the codec
// it emits from these and echoes it in [`NativeClient::codec`].
video_codecs: u8,
preferred_codec: u8,
launch: Option<String>, launch: Option<String>,
pin: Option<[u8; 32]>, pin: Option<[u8; 32]>,
identity: Option<(String, String)>, identity: Option<(String, String)>,
@@ -316,6 +326,8 @@ impl NativeClient {
bitrate_kbps, bitrate_kbps,
video_caps, video_caps,
audio_channels, audio_channels,
video_codecs,
preferred_codec,
launch, launch,
pin, pin,
identity, identity,
@@ -349,6 +361,7 @@ impl NativeClient {
color, color,
chroma_format, chroma_format,
audio_channels, audio_channels,
codec,
) = match ready_rx.recv_timeout(timeout) { ) = match ready_rx.recv_timeout(timeout) {
Ok(Ok(t)) => t, Ok(Ok(t)) => t,
Ok(Err(e)) => return Err(e), Ok(Err(e)) => return Err(e),
@@ -382,6 +395,7 @@ impl NativeClient {
color, color,
chroma_format, chroma_format,
audio_channels, audio_channels,
codec,
}) })
} }
@@ -689,6 +703,8 @@ struct WorkerArgs {
bitrate_kbps: u32, bitrate_kbps: u32,
video_caps: u8, video_caps: u8,
audio_channels: u8, audio_channels: u8,
video_codecs: u8,
preferred_codec: u8,
launch: Option<String>, launch: Option<String>,
pin: Option<[u8; 32]>, pin: Option<[u8; 32]>,
identity: Option<(String, String)>, identity: Option<(String, String)>,
@@ -721,6 +737,8 @@ async fn worker_main(args: WorkerArgs) {
bitrate_kbps, bitrate_kbps,
video_caps, video_caps,
audio_channels, audio_channels,
video_codecs,
preferred_codec,
launch, launch,
pin, pin,
identity, identity,
@@ -789,10 +807,10 @@ async fn worker_main(args: WorkerArgs) {
video_caps, video_caps,
// Requested surround channel count; the host echoes the resolved value in Welcome. // Requested surround channel count; the host echoes the resolved value in Welcome.
audio_channels, audio_channels,
// Phase 1: the embeddable clients decode HEVC (their decoders are still HEVC-wired), // The codecs this client can decode + its soft preference (0 = auto). The host
// so advertise HEVC-only until Phase 2 threads real per-client codec caps through the // resolves the emitted codec from these and reports it in `Welcome::codec`.
// connect ABI and switches decoders on `Welcome::codec`. video_codecs,
video_codecs: crate::quic::CODEC_HEVC, preferred_codec,
} }
.encode(), .encode(),
) )
@@ -866,6 +884,7 @@ async fn worker_main(args: WorkerArgs) {
welcome.color, welcome.color,
welcome.chroma_format, welcome.chroma_format,
welcome.audio_channels, welcome.audio_channels,
welcome.codec,
)) ))
}; };
@@ -884,6 +903,7 @@ async fn worker_main(args: WorkerArgs) {
color, color,
chroma_format, chroma_format,
audio_channels, audio_channels,
codec,
) = match setup.await { ) = match setup.await {
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
@@ -902,6 +922,7 @@ async fn worker_main(args: WorkerArgs) {
color, color,
chroma_format, chroma_format,
audio_channels, audio_channels,
codec,
))); )));
// Input task: embedder events → QUIC datagrams. // Input task: embedder events → QUIC datagrams.
+88 -22
View File
@@ -94,6 +94,13 @@ pub struct Hello {
/// clients (decodes to `0`, which [`resolve_codec`] treats as HEVC-only — every pre-negotiation /// clients (decodes to `0`, which [`resolve_codec`] treats as HEVC-only — every pre-negotiation
/// build decoded HEVC). /// build decoded HEVC).
pub video_codecs: u8, pub video_codecs: u8,
/// The client's *preferred* codec (a single [`CODEC_H264`] / [`CODEC_HEVC`] / [`CODEC_AV1`] bit),
/// or `0` = no preference (host decides by its own precedence). A **soft** hint: the host emits
/// it when it can also produce it (and the client advertised it in `video_codecs`), else falls
/// back to the best shared codec — see [`resolve_codec`]. Mirrors the [`Hello::compositor`] /
/// [`Hello::gamepad`] preference pattern; the resolved codec is echoed in [`Welcome::codec`].
/// Appended after `video_codecs` as a single trailing byte. Omitted by older clients (→ `0`).
pub preferred_codec: u8,
} }
/// [`Hello::video_caps`] bit: the client can decode a 10-bit (Main10) HEVC stream. /// [`Hello::video_caps`] bit: the client can decode a 10-bit (Main10) HEVC stream.
@@ -120,12 +127,13 @@ pub const CODEC_AV1: u8 = 0x04;
/// Resolve which single codec the host will emit, from the client's advertised [`Hello::video_codecs`] /// Resolve which single codec the host will emit, from the client's advertised [`Hello::video_codecs`]
/// bitfield (`0` = an older client, treated as HEVC-only) intersected with what the host's chosen /// bitfield (`0` = an older client, treated as HEVC-only) intersected with what the host's chosen
/// encoder can produce (`host_capable`, also a bitfield). Precedence when several are shared: /// encoder can produce (`host_capable`, also a bitfield). `preferred` is the client's soft preference
/// **HEVC > AV1 > H.264** (HEVC is the established, best-tested path; H.264 is the compatibility / /// ([`Hello::preferred_codec`], `0` = none): when it's in the shared set it wins; otherwise the tie is
/// software floor). Returns the single-bit codec value, or `None` when the two share nothing — the /// broken by **HEVC > AV1 > H.264** (HEVC is the established, best-tested path; H.264 is the
/// caller then refuses the session with a clear error rather than emitting a stream the client can't /// compatibility / software floor). Returns the single-bit codec value, or `None` when client and host
/// decode. /// share nothing — the caller then refuses the session with a clear error rather than emitting a
pub fn resolve_codec(client_codecs: u8, host_capable: u8) -> Option<u8> { /// stream the client can't decode.
pub fn resolve_codec(client_codecs: u8, host_capable: u8, preferred: u8) -> Option<u8> {
// An older client (no codec byte) decodes HEVC — the only codec every pre-negotiation build sent. // An older client (no codec byte) decodes HEVC — the only codec every pre-negotiation build sent.
let client = if client_codecs == 0 { let client = if client_codecs == 0 {
CODEC_HEVC CODEC_HEVC
@@ -133,6 +141,13 @@ pub fn resolve_codec(client_codecs: u8, host_capable: u8) -> Option<u8> {
client_codecs client_codecs
}; };
let shared = client & host_capable; let shared = client & host_capable;
if shared == 0 {
return None;
}
// Honor the client's preference when the host can also emit it; else fall back to precedence.
if preferred != 0 && shared & preferred != 0 {
return Some(preferred);
}
// Precedence: HEVC > AV1 > H.264. // Precedence: HEVC > AV1 > H.264.
[CODEC_HEVC, CODEC_AV1, CODEC_H264] [CODEC_HEVC, CODEC_AV1, CODEC_H264]
.into_iter() .into_iter()
@@ -716,8 +731,13 @@ impl Hello {
// present (video_caps non-zero / audio_channels not stereo) the name/launch length bytes // present (video_caps non-zero / audio_channels not stereo) the name/launch length bytes
// AND the video_caps byte must still be emitted (0 / 0) so the later byte lands at a // AND the video_caps byte must still be emitted (0 / 0) so the later byte lands at a
// deterministic offset — the same discipline `launch` already imposes on `name`. // deterministic offset — the same discipline `launch` already imposes on `name`.
// Trailing single-byte fields, in wire order. Each is emitted when it (or ANY later field)
// carries a non-default value, so a present field always lands at a deterministic offset.
let ac_present = self.audio_channels != 2;
let vcodecs_present = self.video_codecs != 0;
let pref_present = self.preferred_codec != 0;
let need_placeholders = let need_placeholders =
self.video_caps != 0 || self.audio_channels != 2 || self.video_codecs != 0; self.video_caps != 0 || ac_present || vcodecs_present || pref_present;
match (&self.name, &self.launch) { match (&self.name, &self.launch) {
(None, None) if !need_placeholders => {} (None, None) if !need_placeholders => {}
(name, _) => { (name, _) => {
@@ -734,17 +754,21 @@ impl Hello {
} }
// video_caps: single trailing byte. Emitted when non-zero OR when a later field follows (so // video_caps: single trailing byte. Emitted when non-zero OR when a later field follows (so
// that field lands at a deterministic offset right after it). // that field lands at a deterministic offset right after it).
if self.video_caps != 0 || self.audio_channels != 2 || self.video_codecs != 0 { if self.video_caps != 0 || ac_present || vcodecs_present || pref_present {
b.push(self.video_caps); b.push(self.video_caps);
} }
// audio_channels: single trailing byte. Emitted when non-stereo OR when video_codecs follows. // audio_channels: emitted when non-stereo OR a later field follows.
if self.audio_channels != 2 || self.video_codecs != 0 { if ac_present || vcodecs_present || pref_present {
b.push(self.audio_channels); b.push(self.audio_channels);
} }
// video_codecs: single trailing byte. Last field; omitted when `0` (older client → HEVC-only). // video_codecs: emitted when non-zero OR preferred_codec follows.
if self.video_codecs != 0 { if vcodecs_present || pref_present {
b.push(self.video_codecs); b.push(self.video_codecs);
} }
// preferred_codec: single trailing byte. Last field; omitted when `0` (no preference).
if pref_present {
b.push(self.preferred_codec);
}
b b
} }
@@ -825,6 +849,15 @@ impl Hello {
let video_caps_off = launch_off + 1 + launch_len; let video_caps_off = launch_off + 1 + launch_len;
b.get(video_caps_off + 2).copied().unwrap_or(0) b.get(video_caps_off + 2).copied().unwrap_or(0)
}, },
// Optional trailing preferred-codec byte, one past video_codecs. Absent on an older
// client → `0` (no preference; the host decides by precedence).
preferred_codec: {
let name_len = b.get(26).copied().unwrap_or(0) as usize;
let launch_off = 27 + name_len;
let launch_len = b.get(launch_off).copied().unwrap_or(0) as usize;
let video_caps_off = launch_off + 1 + launch_len;
b.get(video_caps_off + 3).copied().unwrap_or(0)
},
}) })
} }
} }
@@ -2025,21 +2058,41 @@ mod tests {
#[test] #[test]
fn codec_negotiation_and_back_compat() { fn codec_negotiation_and_back_compat() {
// resolve_codec precedence (HEVC > AV1 > H.264) and the no-shared-codec refusal. // resolve_codec precedence (HEVC > AV1 > H.264), no preference (0).
assert_eq!( assert_eq!(
resolve_codec(CODEC_H264 | CODEC_HEVC, CODEC_HEVC | CODEC_AV1), resolve_codec(CODEC_H264 | CODEC_HEVC, CODEC_HEVC | CODEC_AV1, 0),
Some(CODEC_HEVC) Some(CODEC_HEVC)
); );
assert_eq!( assert_eq!(
resolve_codec(CODEC_H264 | CODEC_AV1, CODEC_AV1 | CODEC_H264), resolve_codec(CODEC_H264 | CODEC_AV1, CODEC_AV1 | CODEC_H264, 0),
Some(CODEC_AV1) Some(CODEC_AV1)
); );
assert_eq!(resolve_codec(CODEC_H264, CODEC_H264), Some(CODEC_H264)); assert_eq!(resolve_codec(CODEC_H264, CODEC_H264, 0), Some(CODEC_H264));
// A software host (H.264 only) + an HEVC-only client share nothing → refuse. // A software host (H.264 only) + an HEVC-only client share nothing → refuse.
assert_eq!(resolve_codec(CODEC_HEVC, CODEC_H264), None); assert_eq!(resolve_codec(CODEC_HEVC, CODEC_H264, 0), None);
// An older client (0 = no codec byte) is treated as HEVC-only. // An older client (0 = no codec byte) is treated as HEVC-only.
assert_eq!(resolve_codec(0, CODEC_HEVC | CODEC_H264), Some(CODEC_HEVC)); assert_eq!(
assert_eq!(resolve_codec(0, CODEC_H264), None); resolve_codec(0, CODEC_HEVC | CODEC_H264, 0),
Some(CODEC_HEVC)
);
assert_eq!(resolve_codec(0, CODEC_H264, 0), None);
// Soft preference: honored when the host can also emit it, overriding precedence...
assert_eq!(
resolve_codec(CODEC_H264 | CODEC_HEVC, CODEC_H264 | CODEC_HEVC, CODEC_H264),
Some(CODEC_H264)
);
assert_eq!(
resolve_codec(CODEC_HEVC | CODEC_AV1, CODEC_HEVC | CODEC_AV1, CODEC_AV1),
Some(CODEC_AV1)
);
// ...but falls back to precedence when the preferred codec isn't in the shared set.
assert_eq!(
resolve_codec(CODEC_HEVC | CODEC_H264, CODEC_HEVC | CODEC_H264, CODEC_AV1),
Some(CODEC_HEVC)
);
// A preference the host can't emit still can't rescue a no-shared-codec case.
assert_eq!(resolve_codec(CODEC_HEVC, CODEC_H264, CODEC_HEVC), None);
// A Hello advertising codecs roundtrips, and the wire form of a codec-only Hello decodes on // A Hello advertising codecs roundtrips, and the wire form of a codec-only Hello decodes on
// a build that ignores the trailing byte (back-compat: extra bytes are skipped). // a build that ignores the trailing byte (back-compat: extra bytes are skipped).
@@ -2058,15 +2111,23 @@ mod tests {
video_caps: 0, video_caps: 0,
audio_channels: 2, // stereo — forces the video_caps/audio_channels placeholders audio_channels: 2, // stereo — forces the video_caps/audio_channels placeholders
video_codecs: CODEC_H264 | CODEC_HEVC, video_codecs: CODEC_H264 | CODEC_HEVC,
preferred_codec: CODEC_H264,
}; };
let enc = h.encode(); let enc = h.encode();
let dec = Hello::decode(&enc).unwrap();
assert_eq!(dec.video_codecs, CODEC_H264 | CODEC_HEVC);
assert_eq!(dec.preferred_codec, CODEC_H264);
// Drop the preferred_codec byte → still decodes, video_codecs intact, preference gone.
let no_pref = &enc[..enc.len() - 1];
assert_eq!( assert_eq!(
Hello::decode(&enc).unwrap().video_codecs, Hello::decode(no_pref).unwrap().video_codecs,
CODEC_H264 | CODEC_HEVC CODEC_H264 | CODEC_HEVC
); );
// A pre-codec Hello (no trailing codec byte) decodes to 0 → HEVC-only via resolve_codec. assert_eq!(Hello::decode(no_pref).unwrap().preferred_codec, 0);
let legacy = &enc[..enc.len() - 1]; // drop the codec byte (it was the last field) // A pre-codec Hello (no video_codecs/preferred bytes) decodes to 0 → HEVC-only.
let legacy = &enc[..enc.len() - 2];
assert_eq!(Hello::decode(legacy).unwrap().video_codecs, 0); assert_eq!(Hello::decode(legacy).unwrap().video_codecs, 0);
assert_eq!(Hello::decode(legacy).unwrap().preferred_codec, 0);
// A pre-codec Welcome (no codec byte) decodes to HEVC. // A pre-codec Welcome (no codec byte) decodes to HEVC.
let mut w = Welcome::decode( let mut w = Welcome::decode(
@@ -2145,6 +2206,7 @@ mod tests {
video_caps: VIDEO_CAP_10BIT, video_caps: VIDEO_CAP_10BIT,
audio_channels: 2, audio_channels: 2,
video_codecs: CODEC_H264 | CODEC_HEVC, // exercise the codec bitfield roundtrip video_codecs: CODEC_H264 | CODEC_HEVC, // exercise the codec bitfield roundtrip
preferred_codec: CODEC_HEVC,
}; };
assert_eq!(Hello::decode(&h.encode()).unwrap(), h); assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
let s = Start { let s = Start {
@@ -2226,6 +2288,7 @@ mod tests {
video_caps: 0, video_caps: 0,
audio_channels: 2, audio_channels: 2,
video_codecs: 0, video_codecs: 0,
preferred_codec: 0,
}; };
let enc = h.encode(); let enc = h.encode();
assert_eq!(enc.len(), 26); assert_eq!(enc.len(), 26);
@@ -2332,6 +2395,7 @@ mod tests {
video_caps: 0, video_caps: 0,
audio_channels: 2, audio_channels: 2,
video_codecs: 0, video_codecs: 0,
preferred_codec: 0,
}; };
let enc = base.encode(); let enc = base.encode();
assert_eq!( assert_eq!(
@@ -2381,6 +2445,7 @@ mod tests {
video_caps: 0, video_caps: 0,
audio_channels: 2, audio_channels: 2,
video_codecs: 0, video_codecs: 0,
preferred_codec: 0,
}; };
// launch alone (no name): a zero-length name placeholder keeps the offset deterministic. // launch alone (no name): a zero-length name placeholder keeps the offset deterministic.
let with_launch = Hello { let with_launch = Hello {
@@ -2589,6 +2654,7 @@ mod tests {
video_caps: 0, video_caps: 0,
audio_channels: 2, audio_channels: 2,
video_codecs: 0, video_codecs: 0,
preferred_codec: 0,
} }
.encode(); .encode();
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair"); assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
+8 -1
View File
@@ -663,7 +663,8 @@ async fn serve_session(
// client's advertised codecs). A GPU-less software host emits H.264, so an HEVC-only client // client's advertised codecs). A GPU-less software host emits H.264, so an HEVC-only client
// shares nothing with it → refuse honestly rather than send a stream it can't decode. // shares nothing with it → refuse honestly rather than send a stream it can't decode.
let host_codecs = crate::encode::Codec::host_wire_caps(); let host_codecs = crate::encode::Codec::host_wire_caps();
let codec_bit = punktfunk_core::quic::resolve_codec(hello.video_codecs, host_codecs) let codec_bit =
punktfunk_core::quic::resolve_codec(hello.video_codecs, host_codecs, hello.preferred_codec)
.ok_or_else(|| { .ok_or_else(|| {
anyhow!( anyhow!(
"no shared video codec: client advertised 0x{:02x}, host can emit 0x{:02x} \ "no shared video codec: client advertised 0x{:02x}, host can emit 0x{:02x} \
@@ -3976,6 +3977,8 @@ mod tests {
0, 0,
0, // video_caps 0, // video_caps
2, // audio_channels (stereo) 2, // audio_channels (stereo)
0, // video_codecs (0 → HEVC-only)
0, // preferred_codec (auto)
None, // launch None, // launch
None, // pin: TOFU — the operator's approval (not a PIN) authorizes this client None, // pin: TOFU — the operator's approval (not a PIN) authorizes this client
Some((cert, key)), Some((cert, key)),
@@ -4036,6 +4039,8 @@ mod tests {
0, 0,
0, // video_caps 0, // video_caps
2, // audio_channels (stereo) 2, // audio_channels (stereo)
0, // video_codecs
0, // preferred_codec
None, // launch None, // launch
None, None,
None, None,
@@ -4062,6 +4067,8 @@ mod tests {
0, 0,
0, // video_caps 0, // video_caps
2, // audio_channels (stereo) 2, // audio_channels (stereo)
0, // video_codecs
0, // preferred_codec
None, // launch None, // launch
Some(host_fp), Some(host_fp),
Some((cert.clone(), key.clone())), Some((cert.clone(), key.clone())),
+8 -4
View File
@@ -14,12 +14,14 @@ holds the full originals.
| [`implementation-plan.md`](implementation-plan.md) | Master design thesis (why GF(2¹⁶) FEC + Linux virtual displays; three-phase de-risking), architecture invariants, latency budget, risk register | **Design reference** — §07,9 kept; milestones → CLAUDE.md | | [`implementation-plan.md`](implementation-plan.md) | Master design thesis (why GF(2¹⁶) FEC + Linux virtual displays; three-phase de-risking), architecture invariants, latency budget, risk register | **Design reference** — §07,9 kept; milestones → CLAUDE.md |
| [`apollo-comparison.md`](apollo-comparison.md) | Apollo↔punktfunk architecture map + file index + ~63-item transferable-improvement backlog (Windows-host focus) | **Reference + open backlog** — ~⅓ shipped (collapsed); rest open | | [`apollo-comparison.md`](apollo-comparison.md) | Apollo↔punktfunk architecture map + file index + ~63-item transferable-improvement backlog (Windows-host focus) | **Reference + open backlog** — ~⅓ shipped (collapsed); rest open |
| [`security-review.md`](security-review.md) | Whole-project security audit (2026-06-21), 12 findings | **Audit trail** — 11 fixed/inherent; **#12 open** | | [`security-review.md`](security-review.md) | Whole-project security audit (2026-06-21), 12 findings | **Audit trail** — 11 fixed/inherent; **#12 open** |
| [`security-review-2026-06-28.md`](security-review-2026-06-28.md) | Host-scoped follow-up audit (18 attack surfaces; 15 confirmed + 9 partial) | **Audit trail** — fixes landed; deferred/accepted items recorded |
| [`ci.md`](ci.md) | CI/CD architecture: Gitea workflows, runners, release model, signing | **Evergreen reference** | | [`ci.md`](ci.md) | CI/CD architecture: Gitea workflows, runners, release model, signing | **Evergreen reference** |
| [`linux-setup.md`](linux-setup.md) | Linux host bring-up (NVIDIA/headless) + troubleshooting | **Setup guide** (evergreen) | | [`linux-setup.md`](linux-setup.md) | Linux host bring-up (NVIDIA/headless) + troubleshooting | **Setup guide** (evergreen) |
| [`gamestream-host-plan.md`](gamestream-host-plan.md) | GameStream/Moonlight-compat host (P1.1P1.6) | **Shipped** — stub + the 2 deferral decisions | | [`gamestream-host-plan.md`](gamestream-host-plan.md) | GameStream/Moonlight-compat host (P1.1P1.6) | **Shipped** — stub + the 2 deferral decisions |
| [`stats-capture-plan.md`](stats-capture-plan.md) | Web-console performance capture | **Shipped** — stub | | [`stats-capture-plan.md`](stats-capture-plan.md) | Web-console performance capture | **Shipped** — stub |
| [`session-aware-host-followups.md`](session-aware-host-followups.md) | Session-aware host known limitations | **Open items**#2/#3 shipped; #1,#48 parked | | [`session-aware-host-followups.md`](session-aware-host-followups.md) | Session-aware host known limitations | **Open items**#2/#3 shipped; #1,#48 parked |
| [`gamescope-multiuser.md`](gamescope-multiuser.md) | Per-session gamescope isolation (the 4 plumbing items) | **Deferred**reference spec | | [`gamescope-multiuser.md`](gamescope-multiuser.md) | Per-session gamescope isolation (the 4 plumbing items) | **Superseded**absorbed by `multi-user-profiles.md` |
| [`multi-user-profiles.md`](multi-user-profiles.md) | Multi-user / profiles end to end: map a client to a real host OS user account (own isolated desktop), web-console config, per-profile passcode | **Design, schema-of-record** — not yet implemented |
| [`host-latency-plan.md`](host-latency-plan.md) | Latency under GPU contention — 4-tier plan | **Partly shipped** — superseded by ↓; diagnostics + open tiers kept | | [`host-latency-plan.md`](host-latency-plan.md) | Latency under GPU contention — 4-tier plan | **Partly shipped** — superseded by ↓; diagnostics + open tiers kept |
| [`gpu-contention-investigation.md`](gpu-contention-investigation.md) | GPU-contention root-cause + ranked levers (supersedes ↑) | **Active plan** — §5.A shipped; §5.B/C/E/F/G open | | [`gpu-contention-investigation.md`](gpu-contention-investigation.md) | GPU-contention root-cause + ranked levers (supersedes ↑) | **Active plan** — §5.A shipped; §5.B/C/E/F/G open |
| [`hdr-pipeline-plan.md`](hdr-pipeline-plan.md) | Glass-to-glass HDR | **Steps 03 shipped**; Step 4 (Linux) open | | [`hdr-pipeline-plan.md`](hdr-pipeline-plan.md) | Glass-to-glass HDR | **Steps 03 shipped**; Step 4 (Linux) open |
@@ -34,7 +36,8 @@ holds the full originals.
| [`apple-stage2-presenter.md`](apple-stage2-presenter.md) | Apple stage-2 (VTDecompressionSession + CAMetalLayer) presenter | **Shipped (opt-in)** — make-default + iOS open | | [`apple-stage2-presenter.md`](apple-stage2-presenter.md) | Apple stage-2 (VTDecompressionSession + CAMetalLayer) presenter | **Shipped (opt-in)** — make-default + iOS open |
| [`game-library-stores.md`](game-library-stores.md) | Multi-store game library | **Phases 14 shipped** — 6 providers + 8 Qs open | | [`game-library-stores.md`](game-library-stores.md) | Multi-store game library | **Phases 14 shipped** — 6 providers + 8 Qs open |
| [`dualsense-haptics.md`](dualsense-haptics.md) | DualSense advanced-haptics feasibility | **HID shipped**; audio haptics deferred (3 walls) | | [`dualsense-haptics.md`](dualsense-haptics.md) | DualSense advanced-haptics feasibility | **HID shipped**; audio haptics deferred (3 walls) |
| [`steam-controller-deck-support.md`](steam-controller-deck-support.md) | Rich Steam Controller / Steam Deck **input fidelity** (paddles · trackpads · gyro → virtual `hid-steam`) | **Design + M0 GREEN** (Linux bind proven); M1+ open | | [`steam-controller-deck-support.md`](steam-controller-deck-support.md) | Rich Steam Controller / Steam Deck **input fidelity** (paddles · trackpads · gyro → virtual `hid-steam`) | **M0M6 green** — full pipeline + fallback + conflict gate built; validation items open |
| [`steam-deck-passthrough-plan.md`](steam-deck-passthrough-plan.md) | Shippable virtual Deck on any Linux host (usbip/`vhci_hcd` + raw_gadget transport ladder) + client leave-shortcuts | **Built, CI-green** — on-glass validation open |
| [`controller-only-mode.md`](controller-only-mode.md) | Controller-only **session shape** — Deck/desktop as a remote gamepad, no video/audio (complements ↑) | **Design** — not yet implemented | | [`controller-only-mode.md`](controller-only-mode.md) | Controller-only **session shape** — Deck/desktop as a remote gamepad, no video/audio (complements ↑) | **Design** — not yet implemented |
| [`archive/windows-secure-desktop.md`](archive/windows-secure-desktop.md) | Two-process WGC secure-desktop design | **Archived** — shipped but now a fallback (IDD-push primary) | | [`archive/windows-secure-desktop.md`](archive/windows-secure-desktop.md) | Two-process WGC secure-desktop design | **Archived** — shipped but now a fallback (IDD-push primary) |
@@ -77,11 +80,12 @@ owning doc.)
- 6 remaining providers (Desktop/Flatpak, itch.io, Ubisoft Connect, Amazon Games, Battle.net, EA app); the `/library/art/<entryId>/<slot>` mgmt endpoint; refactor `library.rs` into a `library/` dir; 8 open design questions; optional SteamGridDB v2 enrichment. → `game-library-stores` - 6 remaining providers (Desktop/Flatpak, itch.io, Ubisoft Connect, Amazon Games, Battle.net, EA app); the `/library/art/<entryId>/<slot>` mgmt endpoint; refactor `library.rs` into a `library/` dir; 8 open design questions; optional SteamGridDB v2 enrichment. → `game-library-stores`
**Controllers / input** **Controllers / input**
- Rich Steam Controller / Steam Deck capture + virtual `hid-steam` inject (M1+ — Linux UHID, then clients, then deferred Windows UMDF). → `steam-controller-deck-support` - Steam Controller / Steam Deck remaining validation: Moonlight paddle regression + a live SDL-game consume test. → `steam-controller-deck-support`
- usbip virtual-Deck on-glass validation (non-SteamOS promote-in-game + Deck raw_gadget regression). → `steam-deck-passthrough-plan`
- Controller-only session shape (Deck/desktop as a remote gamepad, no video/audio) — `session_flags`/`SESSION_INPUT_ONLY` protocol bit + host skip-data-plane branch + client controller-only path. → `controller-only-mode` - Controller-only session shape (Deck/desktop as a remote gamepad, no video/audio) — `session_flags`/`SESSION_INPUT_ONLY` protocol bit + host skip-data-plane branch + client controller-only path. → `controller-only-mode`
**Multi-user / sessions** **Multi-user / sessions**
- gamescope per-session input/audio isolation (independent desktops) — the 4 plumbing items, deferred. → `gamescope-multiuser`, `implementation-plan` - Magic multi-user / profiles — the full client→host-OS-user design is written (schema-of-record, absorbs the gamescope isolation plumbing); implementation not started. → `multi-user-profiles`
**Security** **Security**
- **#12** — scope `NODE_TLS_REJECT_UNAUTHORIZED` to a per-request pinned agent (needs `bun add undici`); latent-only today, but **must fix before the web app gains any off-loopback server-side TLS**. → `security-review` - **#12** — scope `NODE_TLS_REJECT_UNAUTHORIZED` to a per-request pinned agent (needs `bun add undici`); latent-only today, but **must fix before the web app gains any off-loopback server-side TLS**. → `security-review`
+9 -7
View File
@@ -38,7 +38,7 @@ On Linux the host **rewrites `WAYLAND_DISPLAY` / `XDG_CURRENT_DESKTOP` / `XDG_RU
| `PUNKTFUNK_VIDEO_SOURCE` | `virtual` · `portal` | `virtual` creates a per-client display at the client's exact mode (the normal choice). `portal` captures an existing monitor instead. | | `PUNKTFUNK_VIDEO_SOURCE` | `virtual` · `portal` | `virtual` creates a per-client display at the client's exact mode (the normal choice). `portal` captures an existing monitor instead. |
| `PUNKTFUNK_ZEROCOPY` | `1` · `0` | GPU zero-copy capture→encode (dmabuf → CUDA → NVENC, or D3D11 on Windows). Leave on; it falls back to a CPU path automatically. | | `PUNKTFUNK_ZEROCOPY` | `1` · `0` | GPU zero-copy capture→encode (dmabuf → CUDA → NVENC, or D3D11 on Windows). Leave on; it falls back to a CPU path automatically. |
| `PUNKTFUNK_INPUT_BACKEND` | `libei` · `gamescope` · `wlr` · `uinput` | How input is injected. `libei` for GNOME/KDE, `gamescope` for Bazzite/gamescope, `wlr` for Sway/wlroots. Auto-detected with the compositor. | | `PUNKTFUNK_INPUT_BACKEND` | `libei` · `gamescope` · `wlr` · `uinput` | How input is injected. `libei` for GNOME/KDE, `gamescope` for Bazzite/gamescope, `wlr` for Sway/wlroots. Auto-detected with the compositor. |
| `PUNKTFUNK_ENCODER` | `auto` · `nvenc` · `vaapi` (Linux) · `amf` · `qsv` · `sw` (Windows) | Encoder backend. `auto` (default) detects the GPU vendor: NVIDIA→NVENC, AMD→VAAPI/AMF, Intel→VAAPI/QSV, else software. | | `PUNKTFUNK_ENCODER` | `auto` · `nvenc` · `vaapi` (Linux) · `amf` · `qsv` (Windows) · `software` | Encoder backend. `auto` (default) detects the GPU vendor: NVIDIA→NVENC, AMD→VAAPI/AMF, Intel→VAAPI/QSV. `software` (aliases `sw`/`openh264`) is the GPU-less H.264 path on both platforms — on Windows `auto` falls back to it when no GPU is found; on Linux it is **explicit-only** (`auto` never picks it). |
| `PUNKTFUNK_RENDER_NODE` | path | Linux DRM render node for zero-copy (default `/dev/dri/renderD128`). Set on multi-GPU boxes to pick the right GPU. | | `PUNKTFUNK_RENDER_NODE` | path | Linux DRM render node for zero-copy (default `/dev/dri/renderD128`). Set on multi-GPU boxes to pick the right GPU. |
Resolution and refresh are **not** set here — **the client chooses them.** When a device connects, Resolution and refresh are **not** set here — **the client chooses them.** When a device connects,
@@ -76,6 +76,7 @@ picture.
| `PUNKTFUNK_10BIT` | `1` | HEVC Main10 / HDR. Honored only when the client also advertises 10-bit. **Windows host only** (the Linux host stays 8-bit). | | `PUNKTFUNK_10BIT` | `1` | HEVC Main10 / HDR. Honored only when the client also advertises 10-bit. **Windows host only** (the Linux host stays 8-bit). |
| `PUNKTFUNK_444` | `1` | Full-chroma HEVC 4:4:4 (Range Extensions) — sharper text/desktop, no chroma loss. **punktfunk/1 native only** (Moonlight stays 4:2:0), HEVC-only, honored only when the client advertises 4:4:4 **and** the GPU supports it (probed; NVENC is the validated path — VAAPI/AMF/QSV decline). Independent of 10-bit. | | `PUNKTFUNK_444` | `1` | Full-chroma HEVC 4:4:4 (Range Extensions) — sharper text/desktop, no chroma loss. **punktfunk/1 native only** (Moonlight stays 4:2:0), HEVC-only, honored only when the client advertises 4:4:4 **and** the GPU supports it (probed; NVENC is the validated path — VAAPI/AMF/QSV decline). Independent of 10-bit. |
| `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. | | `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. |
| `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length (default 0 = encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. |
## Gamepads ## Gamepads
@@ -151,13 +152,14 @@ good value:
## Multiple devices at once ## Multiple devices at once
Today the native `punktfunk/1` host (`serve`) streams **one session at a time** — additional clients The native `punktfunk/1` host (`serve`) streams up to **4 sessions at once** by default (an encoder
wait in the accept queue until the active session ends. Each session gets its own virtual display at bound); further clients wait in the accept queue until a slot frees up. Each session gets its own
the client's exact resolution; concurrent native sessions are on the roadmap. (`punktfunk1-host`, the virtual display at the client's exact resolution, sharing the host's input/audio/mic services. The
standalone test host, has a `--max-concurrent N` knob — see the [Host CLI](/docs/host-cli) reference — limit isn't settable from `serve`'s command line yet — `punktfunk1-host`, the standalone test host,
but `serve` does not take that flag.) exposes it as `--max-concurrent N` (see the [Host CLI](/docs/host-cli) reference).
## Codec and FEC ## Codec and FEC
- The host encodes **HEVC (H.265)** by default; **AV1** is available for clients that support it. - Client and host **negotiate the codec**: **HEVC (H.265)** by default, **AV1** for clients that
support it, and **H.264** when the session runs on the GPU-less software encoder.
- The native protocol adds forward error correction for lossy links — see `PUNKTFUNK_FEC_PCT` above. - The native protocol adds forward error correction for lossy links — see `PUNKTFUNK_FEC_PCT` above.
+2 -2
View File
@@ -67,8 +67,8 @@ punktfunk-host punktfunk1-host --source virtual
| `--require-pairing` | Only serve paired devices (implies `--allow-pairing`). | | `--require-pairing` | Only serve paired devices (implies `--allow-pairing`). |
`--max-concurrent`, `--allow-pairing`, and `--require-pairing` are **`punktfunk1-host`-only** — `serve` does not `--max-concurrent`, `--allow-pairing`, and `--require-pairing` are **`punktfunk1-host`-only** — `serve` does not
accept them. On `serve` you arm pairing from the web console instead, and concurrency is not accept them. On `serve` you arm pairing from the web console instead, and concurrency is fixed at
yet capped from the command line. the built-in default (4 sessions) rather than settable from the command line.
Both `serve` and `punktfunk1-host` advertise the host on the network so clients can discover it. List Both `serve` and `punktfunk1-host` advertise the host on the network so clients can discover it. List
hosts from another machine with `punktfunk-probe --discover`. hosts from another machine with `punktfunk-probe --discover`.
+1
View File
@@ -28,6 +28,7 @@
"forgot-password", "forgot-password",
"---Project---", "---Project---",
"roadmap", "roadmap",
"status",
"channels", "channels",
"---Reference---", "---Reference---",
"[API Reference](/api)" "[API Reference](/api)"
+6 -1
View File
@@ -5,7 +5,8 @@ description: What you need to run a punktfunk host — GPU, driver, desktop, and
## Supported setups ## Supported setups
A punktfunk host runs primarily on a Linux machine with an NVIDIA GPU (a native A punktfunk host runs primarily on a Linux machine with a dedicated GPU — NVIDIA (NVENC) is the
most-exercised path, and AMD/Intel GPUs work via VAAPI (a native
[Windows host](/docs/windows-host) is also available — see below). These are the Linux desktop [Windows host](/docs/windows-host) is also available — see below). These are the Linux desktop
environments it supports today, each with its own guide: environments it supports today, each with its own guide:
@@ -32,6 +33,10 @@ listed, the host still needs one of these compositor backends to create a virtua
not just `nvidia-utils` — without it the compositor can't initialise the GPU and capture fails. Each not just `nvidia-utils` — without it the compositor can't initialise the GPU and capture fails. Each
setup guide installs the right package (e.g. `libnvidia-gl-<version>` on Ubuntu). setup guide installs the right package (e.g. `libnvidia-gl-<version>` on Ubuntu).
- **`nvidia-drm modeset=1`** must be enabled (Wayland on NVIDIA needs it). The setup guides cover this. - **`nvidia-drm modeset=1`** must be enabled (Wayland on NVIDIA needs it). The setup guides cover this.
- **AMD / Intel GPUs** encode via **VAAPI** instead (install `mesa-va-drivers` or
`intel-media-driver`; validated live on AMD RDNA3). The NVIDIA-specific notes above don't apply
there. A GPU-less software H.264 encoder also exists (`PUNKTFUNK_ENCODER=software`), meant as a
fallback rather than a daily driver.
> Consumer GeForce cards historically cap the number of **concurrent** NVENC sessions (a few at once); > Consumer GeForce cards historically cap the number of **concurrent** NVENC sessions (a few at once);
> workstation cards don't. This only matters if you stream to many devices simultaneously. > workstation cards don't. This only matters if you stream to many devices simultaneously.
+2 -2
View File
@@ -52,8 +52,8 @@ see [Status & Progress](/docs/status).
- **Windows client on-glass validation.** The hardware (D3D11VA) decode, HDR present, and GUI are - **Windows client on-glass validation.** The hardware (D3D11VA) decode, HDR present, and GUI are
built and ship as a signed MSIX — they just need verification on real GPU hardware. built and ship as a signed MSIX — they just need verification on real GPU hardware.
- **Apple stage-2 presenter as the default.** The lower-latency `VTDecompressionSession` - **Apple presenter polish.** The lower-latency `VTDecompressionSession` `CAMetalLayer` stage-2
`CAMetalLayer` path is live behind an opt-in flag and graduating to the default. path is now the default; HDR brightness and 4:4:4 still need on-glass validation.
- **Web console parity.** Surfacing the speed test and bitrate picker the apps already have. - **Web console parity.** Surfacing the speed test and bitrate picker the apps already have.
- **Windows host hardening.** Broader real-world testing — especially on-glass validation of the - **Windows host hardening.** Broader real-world testing — especially on-glass validation of the
AMD (AMF) and Intel (QSV) encode paths, which are CI-green but newer than NVENC. AMD (AMF) and Intel (QSV) encode paths, which are CI-green but newer than NVENC.
+3 -4
View File
@@ -15,7 +15,7 @@ A high-level view of where punktfunk stands. The ordered plan of work is on the
| **GameStream host** (Moonlight-compatible) | ✅ working end-to-end; HDR/surround-audio polish open | | **GameStream host** (Moonlight-compatible) | ✅ working end-to-end; HDR/surround-audio polish open |
| **Native protocol**`punktfunk/1` (QUIC control + UDP data, GF(2¹⁶) Leopard FEC + AES-GCM) | ✅ full session planes, validated live | | **Native protocol**`punktfunk/1` (QUIC control + UDP data, GF(2¹⁶) Leopard FEC + AES-GCM) | ✅ full session planes, validated live |
| **Windows host** (x64) | 🟡 implemented & shipping as a signed installer; NVIDIA/AMD/Intel encode, newer than the Linux host | | **Windows host** (x64) | 🟡 implemented & shipping as a signed installer; NVIDIA/AMD/Intel encode, newer than the Linux host |
| **macOS / iOS / iPadOS / tvOS client** | ✅ full client; on-glass stage-2 presenter behind an opt-in flag, becoming the default | | **macOS / iOS / iPadOS / tvOS client** | ✅ full client; on-glass-validated stage-2 presenter is the default |
| **Linux client** (`punktfunk-client`, GTK4/libadwaita) | ✅ full client; VAAPI zero-copy decode + software fallback | | **Linux client** (`punktfunk-client`, GTK4/libadwaita) | ✅ full client; VAAPI zero-copy decode + software fallback |
| **Windows client** (`punktfunk-client`, WinUI 3) | ✅ stage 1 complete; ships as signed MSIX; on-glass hardware validation pending | | **Windows client** (`punktfunk-client`, WinUI 3) | ✅ stage 1 complete; ships as signed MSIX; on-glass hardware validation pending |
| **Android client** (phone + Android TV) | ✅ full client; hardware HEVC decode + HDR10 | | **Android client** (phone + Android TV) | ✅ full client; hardware HEVC decode + HDR10 |
@@ -35,7 +35,7 @@ host is newer than the Linux host.)
**gamescope**, **Mutter**, and **Sway/wlroots**. **gamescope**, **Mutter**, and **Sway/wlroots**.
- **Zero-copy GPU pipeline.** Captured frames stay on the GPU (dmabuf → CUDA → NVENC) with - **Zero-copy GPU pipeline.** Captured frames stay on the GPU (dmabuf → CUDA → NVENC) with
automatic split-encode at very high resolutions. Stable 240 fps at 5120×1440 has been automatic split-encode at very high resolutions. Stable 240 fps at 5120×1440 has been
measured. measured. A GPU-less software H.264 encoder exists as an explicit fallback.
- **HDR (10-bit), on the Windows host.** An HDR Windows desktop is captured and encoded as HEVC - **HDR (10-bit), on the Windows host.** An HDR Windows desktop is captured and encoded as HEVC
Main10 (BT.2020 PQ) to HDR-capable clients (Windows, Android). Linux hosts stream 8-bit for now — Main10 (BT.2020 PQ) to HDR-capable clients (Windows, Android). Linux hosts stream 8-bit for now —
HDR there is blocked upstream at the compositor. HDR there is blocked upstream at the compositor.
@@ -55,7 +55,7 @@ host is newer than the Linux host.)
| Client | Highlights | | Client | Highlights |
|---|---| |---|---|
| **macOS / iOS / iPadOS / tvOS** | VideoToolbox HEVC decode, GameController capture, full DualSense feedback, mDNS discovery, PIN pairing + TOFU, network speed test, latency HUD. Stage-2 presenter (`VTDecompressionSession``CAMetalLayer`, ~11 ms p50 capture→present) is built and validated on glass behind an opt-in flag, becoming the default. Ships as one universal TestFlight build / App Store listing. | | **macOS / iOS / iPadOS / tvOS** | VideoToolbox HEVC decode, GameController capture, full DualSense feedback, mDNS discovery, PIN pairing + TOFU, network speed test, latency HUD. Stage-2 presenter (`VTDecompressionSession``CAMetalLayer`, ~11 ms p50 capture→present) is validated on glass and is the default (stage 1 remains the fallback when Metal is unavailable). Ships as one universal TestFlight build / App Store listing. |
| **Linux** (`punktfunk-client`) | GTK4/libadwaita. FFmpeg decode with VAAPI → DRM-PRIME dmabuf zero-copy (Intel/AMD; software fallback on NVIDIA), PipeWire audio + mic, SDL3 gamepads incl. DualSense, mDNS discovery, PIN pairing + TOFU, speed test. Ships as Flatpak, apt, rpm, and Arch packages. | | **Linux** (`punktfunk-client`) | GTK4/libadwaita. FFmpeg decode with VAAPI → DRM-PRIME dmabuf zero-copy (Intel/AMD; software fallback on NVIDIA), PipeWire audio + mic, SDL3 gamepads incl. DualSense, mDNS discovery, PIN pairing + TOFU, speed test. Ships as Flatpak, apt, rpm, and Arch packages. |
| **Windows** (`punktfunk-client`) | WinUI 3. D3D11VA zero-copy decode, HDR10, WASAPI audio + mic, SDL3 gamepads incl. DualSense, mDNS discovery, and the full PIN/TOFU trust surface are all implemented. Ships as a signed MSIX (x86_64 + ARM64). **Stage 1 complete; D3D11VA decode, HDR present, and the GUI are pending on-glass validation on real GPU hardware.** | | **Windows** (`punktfunk-client`) | WinUI 3. D3D11VA zero-copy decode, HDR10, WASAPI audio + mic, SDL3 gamepads incl. DualSense, mDNS discovery, and the full PIN/TOFU trust surface are all implemented. Ships as a signed MSIX (x86_64 + ARM64). **Stage 1 complete; D3D11VA decode, HDR present, and the GUI are pending on-glass validation on real GPU hardware.** |
| **Android** (phone + Android TV) | Kotlin app with a Rust core over JNI. NDK `AMediaCodec` hardware HEVC decode + HDR10 (Main10/BT.2020 PQ), Opus/Oboe audio + mic, gamepad input with rumble/HID feedback, mDNS discovery, PIN pairing + TOFU (Keystore identity), live stats HUD, and D-pad/controller focus navigation for TV. Ships to the Google Play Internal Testing track. | | **Android** (phone + Android TV) | Kotlin app with a Rust core over JNI. NDK `AMediaCodec` hardware HEVC decode + HDR10 (Main10/BT.2020 PQ), Opus/Oboe audio + mic, gamepad input with rumble/HID feedback, mDNS discovery, PIN pairing + TOFU (Keystore identity), live stats HUD, and D-pad/controller focus navigation for TV. Ships to the Google Play Internal Testing track. |
@@ -113,7 +113,6 @@ See the [Roadmap](/docs/roadmap) for the ordered list. Near-term:
- **True glass-to-glass latency** — combine the client present-stamp (decode → present) - **True glass-to-glass latency** — combine the client present-stamp (decode → present)
with the host render → capture term for a complete end-to-end number. with the host render → capture term for a complete end-to-end number.
- **Make the Apple stage-2 presenter the default** after a few more resolution/HDR checks.
- **On-glass validation of the Windows client** (D3D11VA decode, HDR present, GUI) on real - **On-glass validation of the Windows client** (D3D11VA decode, HDR present, GUI) on real
GPU hardware. GPU hardware.
- **gamescope multi-user isolation** — per-session input/audio so concurrent sessions can - **gamescope multi-user isolation** — per-session input/audio so concurrent sessions can
-2
View File
@@ -96,5 +96,3 @@ The check follows the [channel](/docs/channels) you installed from: a plugin ins
The plugin source lives in The plugin source lives in
[`clients/decky`](https://git.unom.io/unom/punktfunk/src/branch/main/clients/decky/README.md). [`clients/decky`](https://git.unom.io/unom/punktfunk/src/branch/main/clients/decky/README.md).
</content>
</invoke>
+3 -3
View File
@@ -69,9 +69,9 @@ Then log out and back in. On other distros this is `sudo usermod -aG input $USER
clients' [speed test](/docs/configuration#bitrate) picks a safe value; with Moonlight, set it clients' [speed test](/docs/configuration#bitrate) picks a safe value; with Moonlight, set it
manually. manually.
- Prefer a **wired** connection or 5 GHz Wi-Fi between host and client. - Prefer a **wired** connection or 5 GHz Wi-Fi between host and client.
- Streaming to **many devices at once** shares the GPU encoder. The production host - Streaming to **many devices at once** shares the GPU encoder. The host serves several
(`serve`) handles one native session at a time, with extra clients queued; heavy load is concurrent native sessions (up to 4 by default); heavy load is usually bitrate-bound, so
usually bitrate-bound, so lower the bitrate first. lower the bitrate first.
## Still stuck? ## Still stuck?
+55 -2
View File
@@ -133,6 +133,16 @@
// [`punktfunk_connection_chroma_format`] reports the real value. (Mirrors `quic::VIDEO_CAP_444`.) // [`punktfunk_connection_chroma_format`] reports the real value. (Mirrors `quic::VIDEO_CAP_444`.)
#define PUNKTFUNK_VIDEO_CAP_444 4 #define PUNKTFUNK_VIDEO_CAP_444 4
// Codec bit for [`punktfunk_connect_ex7`] (`video_codecs` / `preferred_codec`) and the value
// [`punktfunk_connection_codec`] returns: H.264 / AVC. (Mirrors `quic::CODEC_H264`.)
#define PUNKTFUNK_CODEC_H264 1
// Codec bit: H.265 / HEVC — the default codec. (Mirrors `quic::CODEC_HEVC`.)
#define PUNKTFUNK_CODEC_HEVC 2
// Codec bit: AV1. (Mirrors `quic::CODEC_AV1`.)
#define PUNKTFUNK_CODEC_AV1 4
// 16-byte AEAD authentication tag appended by GCM. // 16-byte AEAD authentication tag appended by GCM.
#define TAG_LEN 16 #define TAG_LEN 16
@@ -986,8 +996,8 @@ PunktfunkConnection *punktfunk_connect_ex5(const char *host,
// Like [`punktfunk_connect_ex5`], but additionally requests the audio channel count: // Like [`punktfunk_connect_ex5`], but additionally requests the audio channel count:
// `2` (stereo, the default behaviour of every earlier variant), `6` (5.1) or `8` (7.1). The host // `2` (stereo, the default behaviour of every earlier variant), `6` (5.1) or `8` (7.1). The host
// clamps the request to what it can actually capture and echoes the resolved count via // clamps the request to what it can actually capture and echoes the resolved count via
// [`punktfunk_connection_audio_channels`]; the `0xC9` audio frames are Opus-(multi)stream encoded // [`punktfunk_connection_audio_channels`]. Advertises HEVC-only with no codec preference (call
// for that layout. A client that wants surround calls this; everything else inherits stereo. // [`punktfunk_connect_ex7`] to negotiate the codec).
// //
// # Safety // # Safety
// Same as [`punktfunk_connect`]. // Same as [`punktfunk_connect`].
@@ -1009,6 +1019,36 @@ PunktfunkConnection *punktfunk_connect_ex6(const char *host,
uint32_t timeout_ms); uint32_t timeout_ms);
#endif #endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Like [`punktfunk_connect_ex6`], but additionally advertises the codecs the client can decode
// (`video_codecs` — a bitfield of [`PUNKTFUNK_CODEC_H264`] / [`PUNKTFUNK_CODEC_HEVC`] /
// [`PUNKTFUNK_CODEC_AV1`]) and a soft `preferred_codec` (a single codec bit, `0` = no preference).
// The host resolves the codec it emits from these (preference honored when it can also produce it,
// else best shared codec) and reports it via [`punktfunk_connection_codec`]. A client that omits
// this (calls `ex6`) advertises HEVC-only, no preference — the pre-negotiation behaviour.
//
// # Safety
// Same as [`punktfunk_connect`].
PunktfunkConnection *punktfunk_connect_ex7(const char *host,
uint16_t port,
uint32_t width,
uint32_t height,
uint32_t refresh_hz,
uint32_t compositor,
uint32_t gamepad,
uint32_t bitrate_kbps,
uint8_t video_caps,
uint8_t audio_channels,
uint8_t video_codecs,
uint8_t preferred_codec,
const char *launch_id,
const uint8_t *pin_sha256,
uint8_t *observed_sha256_out,
const char *client_cert_pem,
const char *client_key_pem,
uint32_t timeout_ms);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC) #if defined(PUNKTFUNK_FEATURE_QUIC)
// Generate a persistent client identity: a self-signed certificate + private key, both // Generate a persistent client identity: a self-signed certificate + private key, both
// PEM, NUL-terminated, written into the caller's buffers. Generate ONCE, store both // PEM, NUL-terminated, written into the caller's buffers. Generate ONCE, store both
@@ -1179,6 +1219,19 @@ PunktfunkStatus punktfunk_connection_color_info(PunktfunkConnection *c,
PunktfunkStatus punktfunk_connection_chroma_format(PunktfunkConnection *c, uint8_t *out); PunktfunkStatus punktfunk_connection_chroma_format(PunktfunkConnection *c, uint8_t *out);
#endif #endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Read the video codec the host resolved for this session (from its Welcome): one of
// [`PUNKTFUNK_CODEC_H264`] / [`PUNKTFUNK_CODEC_HEVC`] / [`PUNKTFUNK_CODEC_AV1`]. The embedder builds
// its decoder from THIS (never assuming HEVC). `*out` is filled when non-NULL. Available
// immediately after a successful connect (it doesn't change without a reconfigure). An older host
// that didn't negotiate a codec reports [`PUNKTFUNK_CODEC_HEVC`].
//
// # Safety
// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
PunktfunkStatus punktfunk_connection_codec(PunktfunkConnection *c,
uint8_t *out);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC) #if defined(PUNKTFUNK_FEATURE_QUIC)
// Send one input event to the host as a QUIC datagram (non-blocking enqueue). // Send one input event to the host as a QUIC datagram (non-blocking enqueue).
// //
+10 -4
View File
@@ -1,8 +1,9 @@
# Packaging punktfunk for Fedora / Bazzite # Packaging punktfunk for Fedora / Bazzite
The punktfunk host is Linux-only and links system FFmpeg (NVENC), PipeWire, Opus and The punktfunk host links system FFmpeg (NVENC on NVIDIA, VAAPI on AMD/Intel, with a GPU-less
the NVIDIA driver. This directory packages it for the **Fedora Atomic / Bazzite** world software-H.264 fallback), PipeWire and Opus. This page covers packaging it for the
(rpm-ostree + bootc), where most of those deps are already present. **Fedora Atomic / Bazzite** world (rpm-ostree + bootc), where most of those deps are already
present; the NVIDIA-specific notes below apply to the NVENC path.
> 👉 **Ubuntu/Debian hosts** install via `apt` from Gitea's package registry — see > 👉 **Ubuntu/Debian hosts** install via `apt` from Gitea's package registry — see
> [`debian/README.md`](debian/README.md) (`apt update && apt upgrade` for new builds). > [`debian/README.md`](debian/README.md) (`apt update && apt upgrade` for new builds).
@@ -20,6 +21,10 @@ packaging/
copr/ # COPR build-from-SCM settings copr/ # COPR build-from-SCM settings
``` ```
The other packaging targets have their own READMEs: [`debian/`](debian/README.md) (apt),
[`arch/`](arch/README.md) (PKGBUILD + sysext), [`flatpak/`](flatpak/README.md) (the client),
[`windows/`](windows/README.md) (host installer + drivers), plus `kde/` and `linux/` helpers.
## What's needed beyond base Fedora ## What's needed beyond base Fedora
| Dependency | Where it comes from | | Dependency | Where it comes from |
@@ -121,7 +126,8 @@ An RPM (or the bootc layer) installs into the host system where those just work.
## Building the SRPM/RPM locally (Fedora only) ## Building the SRPM/RPM locally (Fedora only)
```sh ```sh
git archive --format=tar.gz --prefix=punktfunk-0.0.1/ -o ~/rpmbuild/SOURCES/punktfunk-0.0.1.tar.gz HEAD git archive --format=tar.gz --prefix=punktfunk-0.3.0/ -o ~/rpmbuild/SOURCES/punktfunk-0.3.0.tar.gz HEAD
rpmbuild -ba packaging/rpm/punktfunk.spec # needs the BuildRequires from the spec rpmbuild -ba packaging/rpm/punktfunk.spec # needs the BuildRequires from the spec
# (0.3.0 = the spec's default %{pf_version}; the prefix and tarball name must match it)
``` ```
(Not buildable on Debian/Ubuntu — use a Fedora toolbox/container or COPR.) (Not buildable on Debian/Ubuntu — use a Fedora toolbox/container or COPR.)
+2 -2
View File
@@ -404,8 +404,8 @@ Debian/Ubuntu — the host links system FFmpeg/PipeWire and won't build there),
`packaging/README.md`: `packaging/README.md`:
```sh ```sh
git archive --format=tar.gz --prefix=punktfunk-0.0.1/ \ git archive --format=tar.gz --prefix=punktfunk-0.3.0/ \
-o ~/rpmbuild/SOURCES/punktfunk-0.0.1.tar.gz HEAD -o ~/rpmbuild/SOURCES/punktfunk-0.3.0.tar.gz HEAD # 0.3.0 = the spec's default version
rpmbuild -ba packaging/rpm/punktfunk.spec # needs the spec's BuildRequires + RPM Fusion rpmbuild -ba packaging/rpm/punktfunk.spec # needs the spec's BuildRequires + RPM Fusion
``` ```
+1 -1
View File
@@ -2,7 +2,7 @@
`punktfunk-host` is published as a `.deb` to **Gitea's Debian package registry** in the public `punktfunk-host` is published as a `.deb` to **Gitea's Debian package registry** in the public
`unom` org, so the Ubuntu hosts update with plain `apt`. CI (`.gitea/workflows/deb.yml`) builds `unom` org, so the Ubuntu hosts update with plain `apt`. CI (`.gitea/workflows/deb.yml`) builds
and publishes on every push to `main` (a rolling `0.3.0~ciN.g<sha>` build to the **`canary`** apt and publishes on every push to `main` (a rolling `0.5.0~ciN.g<sha>` build to the **`canary`** apt
distribution) and on `vX.Y.Z` tags (a clean `X.Y.Z` to the **`stable`** distribution, plus attached distribution) and on `vX.Y.Z` tags (a clean `X.Y.Z` to the **`stable`** distribution, plus attached
to the unified Gitea Release). The two are separate apt distributions, so a stable box never jumps to the unified Gitea Release). The two are separate apt distributions, so a stable box never jumps
to a canary build — see [Release Channels](https://punktfunk.unom.io/docs/channels). The repo line to a canary build — see [Release Channels](https://punktfunk.unom.io/docs/channels). The repo line
+6 -4
View File
@@ -3,7 +3,7 @@
`punktfunk-host` is published as an RPM to **Gitea's RPM package registry** in the public `unom` `punktfunk-host` is published as an RPM to **Gitea's RPM package registry** in the public `unom`
org (stable groups `bazzite`/`fedora-44`, canary groups `bazzite-canary`/`fedora-44-canary`), so org (stable groups `bazzite`/`fedora-44`, canary groups `bazzite-canary`/`fedora-44-canary`), so
Bazzite / Fedora Atomic hosts layer and update it with `rpm-ostree`. CI (`.gitea/workflows/rpm.yml`) Bazzite / Fedora Atomic hosts layer and update it with `rpm-ostree`. CI (`.gitea/workflows/rpm.yml`)
builds and publishes on every push to `main` (a rolling `0.3.0-0.ciN.<sha>` build to the `*-canary` builds and publishes on every push to `main` (a rolling `0.5.0-0.ciN.g<sha>` build to the `*-canary`
groups) and on `vX.Y.Z` tags (a clean `X.Y.Z-1` to the base groups, plus attached to the unified groups) and on `vX.Y.Z` tags (a clean `X.Y.Z-1` to the base groups, plus attached to the unified
Gitea Release) — separate repos, so a stable box never jumps to a canary build (see Gitea Release) — separate repos, so a stable box never jumps to a canary build (see
[Release Channels](https://punktfunk.unom.io/docs/channels)). The `baseurl` below subscribes to the [Release Channels](https://punktfunk.unom.io/docs/channels)). The `baseurl` below subscribes to the
@@ -107,8 +107,9 @@ tracking: `rpm-ostree override` / `rpm-ostree uninstall punktfunk`.
```sh ```sh
PF_VERSION=0.0.1 bash packaging/rpm/build-rpm.sh # host + client PF_VERSION=0.0.1 bash packaging/rpm/build-rpm.sh # host + client
PF_VERSION=0.0.1 PF_WITH_WEB=1 bash packaging/rpm/build-rpm.sh # + the noarch punktfunk-web (needs bun on PATH) PF_VERSION=0.0.1 PF_WITH_WEB=1 bash packaging/rpm/build-rpm.sh # + punktfunk-web (needs bun on PATH)
# -> dist/punktfunk-0.0.1-1.fcNN.x86_64.rpm (+ punktfunk-web-0.0.1-1.fcNN.noarch.rpm with PF_WITH_WEB=1) # -> dist/punktfunk-0.0.1-1.fcNN.x86_64.rpm (+ punktfunk-web-0.0.1-1.fcNN.x86_64.rpm with PF_WITH_WEB=1;
# the web subpackage vendors a bun binary, so it's arch-specific, not noarch)
``` ```
Run it inside the Fedora 43 builder image so the deps resolve and match Bazzite: Run it inside the Fedora 43 builder image so the deps resolve and match Bazzite:
@@ -119,4 +120,5 @@ docker run --rm -v "$PWD:/src" -w /src punktfunk-fedora-rpm \
bash -lc 'git config --global --add safe.directory /src && PF_VERSION=0.0.1 bash packaging/rpm/build-rpm.sh' bash -lc 'git config --global --add safe.directory /src && PF_VERSION=0.0.1 bash packaging/rpm/build-rpm.sh'
``` ```
A plain `rpmbuild`/COPR build with no `pf_version`/`pf_release` defines produces `0.0.1-1`. A plain `rpmbuild`/COPR build with no `pf_version`/`pf_release` defines produces `0.3.0-1` (the
spec defaults).
+2 -1
View File
@@ -108,7 +108,8 @@ read it from `%ProgramData%\punktfunk\web-password`.
## Dev iteration on the test box (driver) ## Dev iteration on the test box (driver)
Two helpers wrap the painful manual steps of iterating on the pf-vdisplay driver against a live host Two helpers wrap the painful manual steps of iterating on the pf-vdisplay driver against a live host
service. Run **elevated**; both default to the `PunktfunkHost` service. service. Run **elevated**; both default to the `PunktfunkHost` service. (The `C:\t-goal1\...` probe
path below is the maintainer's test box — substitute your own `punktfunk-probe.exe` build.)
```powershell ```powershell
# Recover a WEDGED driver. Symptom: every session fails with # Recover a WEDGED driver. Symptom: every session fails with
+2 -2
View File
@@ -10,8 +10,8 @@
`cargo build --release` builds the whole workspace (this shares wdk-sys/wdk-build + the bindgen pin with `cargo build --release` builds the whole workspace (this shares wdk-sys/wdk-build + the bindgen pin with
pf-vdisplay). Then, per driver: CLEAR the FORCE_INTEGRITY PE bit, sign the .dll, stampinf a DriverVer pf-vdisplay). Then, per driver: CLEAR the FORCE_INTEGRITY PE bit, sign the .dll, stampinf a DriverVer
into the INF; then Inf2Cat both catalogs and sign them. Both drivers share ONE self-signed cert (or a into the INF; then Inf2Cat both catalogs and sign them. Both drivers share ONE self-signed cert (or a
supplied DRIVER_CERT secret) + ONE exported .cer - the layout install-gamepad-drivers.ps1 consumes supplied DRIVER_CERT secret) + ONE exported .cer - the layout `punktfunk-host.exe driver install
(per-driver .inf/.cat/.dll + one shared punktfunk-driver.cer). --gamepad` consumes (per-driver .inf/.cat/.dll + one shared punktfunk-driver.cer).
Output (-Out): pf_dualsense.{dll,inf,cat} + pf_xusb.{dll,inf,cat} + punktfunk-driver.cer. Output (-Out): pf_dualsense.{dll,inf,cat} + pf_xusb.{dll,inf,cat} + punktfunk-driver.cer.
@@ -8,7 +8,7 @@ DualSense a near-native feel with **no external gamepad dependencies** (no ViGEm
Shipping: the driver is one member of the in-tree driver workspace Shipping: the driver is one member of the in-tree driver workspace
([`packaging/windows/drivers/`](../../README.md)), built from source in CI, and bundled + ([`packaging/windows/drivers/`](../../README.md)), built from source in CI, and bundled +
`pnputil`-installed by the Windows host [installer](../../README.md). The host feeds it over a shared `pnputil`-installed by the Windows host [installer](../../README.md). The host feeds it over a shared
memory channel from `crates/punktfunk-host/src/inject/dualsense_windows.rs`. The same UMDF driver also memory channel from `crates/punktfunk-host/src/inject/windows/dualsense_windows.rs`. The same UMDF driver also
serves the **DualShock 4** identity per a `device_type` byte the host stamps. serves the **DualShock 4** identity per a `device_type` byte the host stamps.
This README captures the driver-authoring lore — the bugs and the signing recipe that make a This README captures the driver-authoring lore — the bugs and the signing recipe that make a
@@ -37,7 +37,9 @@ $env:LIBCLANG_PATH = 'C:\Program Files\LLVM\bin'
$env:Version_Number = '10.0.26100.0' # else wdk-build picks 10.0.28000.0 (no km/crt) and bindgen fails $env:Version_Number = '10.0.26100.0' # else wdk-build picks 10.0.28000.0 (no km/crt) and bindgen fails
``` ```
Then, in the example dir: The shipping flow is `build-gamepad-drivers.ps1` (one level up): workspace `cargo build --release`
plus the sign steps below, staged for the installer. The original manual dev-box recipe, kept as
lore (paths reflect that era's cargo-make layout):
```powershell ```powershell
cargo make # -> target\debug\pf_dualsense_package\ (.inf/.cat/.dll) cargo make # -> target\debug\pf_dualsense_package\ (.inf/.cat/.dll)
@@ -45,7 +47,8 @@ cargo make # -> target\debug\pf_dualsense_package\
# *** CRITICAL: clear the PE FORCE_INTEGRITY bit *** # *** CRITICAL: clear the PE FORCE_INTEGRITY bit ***
# windows-drivers-rs links the DLL with /INTEGRITYCHECK, which forces a CI-trusted page-hash # windows-drivers-rs links the DLL with /INTEGRITYCHECK, which forces a CI-trusted page-hash
# signature a self-signed cert cannot satisfy (CodeIntegrity 3004 "hash not found" / # signature a self-signed cert cannot satisfy (CodeIntegrity 3004 "hash not found" /
# 3089 VerificationError 7). SudoVDA.dll has this bit OFF. Clear bit 0x80 at PE-header offset +0x5e: # 3089 VerificationError 7). SudoVDA.dll (third-party VDD prior art, not used by punktfunk) has
# this bit OFF. Clear bit 0x80 at PE-header offset +0x5e:
$f = 'target\debug\pf_dualsense_package\pf_dualsense.dll' $f = 'target\debug\pf_dualsense_package\pf_dualsense.dll'
$b = [IO.File]::ReadAllBytes($f); $pe = [BitConverter]::ToInt32($b,0x3c); $off = $pe + 0x5e $b = [IO.File]::ReadAllBytes($f); $pe = [BitConverter]::ToInt32($b,0x3c); $off = $pe + 0x5e
$dc = [BitConverter]::ToUInt16($b,$off); $bb = [BitConverter]::GetBytes([uint16]($dc -band 0xFF7F)) $dc = [BitConverter]::ToUInt16($b,$off); $bb = [BitConverter]::GetBytes([uint16]($dc -band 0xFF7F))
@@ -60,9 +63,10 @@ pnputil /add-driver target\debug\pf_dualsense_package\pf_dualsense.inf /install
devgen /add /hardwareid "root\pf_dualsense" # creates the (transient, SWD) device node devgen /add /hardwareid "root\pf_dualsense" # creates the (transient, SWD) device node
``` ```
`devgen` is at `...\Windows Kits\10\Tools\10.0.26100.0\x64\devgen.exe`. SWD devgen devices clear on `devgen` (under `Windows Kits\10\Tools\<ver>\x64\`) is only for manual testing — the shipping
reboot (recreate after each boot). TODO: drop the post-build PE patch by stopping wdk-build emitting install is `punktfunk-host.exe driver install --gamepad`, and the host SwDeviceCreate's the device
`/INTEGRITYCHECK`. per session (no persistent devnode). SWD devgen devices clear on reboot. TODO: drop the post-build
PE patch by stopping wdk-build emitting `/INTEGRITYCHECK`.
## The three bugs that made it work (porting a WDK C sample to Rust) ## The three bugs that made it work (porting a WDK C sample to Rust)
+11 -8
View File
@@ -43,19 +43,21 @@ Y `0x8000`). `dwPacketNumber` (GET_STATE `[5]`) must increment whenever the payl
@8` · `LT @10` · `RT @11` · `LX/LY/RX/RY i16 @12/@14/@16/@18` · `rumble_seq u32 @24` (driver bumps) · @8` · `LT @10` · `RT @11` · `LX/LY/RX/RY i16 @12/@14/@16/@18` · `rumble_seq u32 @24` (driver bumps) ·
`large @28` · `small @29`. `large @28` · `small @29`.
## Validated live on `.173` (2026-06-22) ## Validated live (2026-06-22, maintainer's RTX test box)
`XInputGetState(0)` returns **CONNECTED** with the pushed buttons/sticks and an incrementing `XInputGetState(0)` returns **CONNECTED** with the pushed buttons/sticks and an incrementing
`dwPacketNumber`; `XInputSetState(0xC000, 0x4000)` reaches the driver as `00 00 c0 40 02` → host sees `dwPacketNumber`; `XInputSetState(0xC000, 0x4000)` reaches the driver as `00 00 c0 40 02` → host sees
`large=192 small=64`. Test tools: `C:\Users\Public\giprobe\xusbtest.exe` (creates the `pf_xusb` `large=192 small=64`. Test tools (on that box): `xusbtest.exe` (creates the `pf_xusb`
devnode + cycling state via shm) and `xinputtest.exe` (`XInputGetState`/`SetState` harness). devnode + cycling state via shm) and `xinputtest.exe` (`XInputGetState`/`SetState` harness).
## Build / sign / install (same recipe as the DualSense driver) ## Build / sign / install (same recipe as the DualSense driver)
Built from `C:\Users\Public\m0\windows-drivers-rs\examples\pf-xusb` (the `../../crates` paths resolve Built as a member of the in-tree [`packaging/windows/drivers/`](../) workspace — one
there); these repo files are the canonical copies — keep them in sync. `cargo build --release` builds all three drivers; `build-gamepad-drivers.ps1` (one level up) wraps
the whole build/sign/stage flow in CI. The manual steps:
1. `cargo make` (env `LIBCLANG_PATH`, `Version_Number=10.0.26100.0`) → `target\debug\pf_xusb_package\`. 1. `cargo build --release` in the workspace (env `LIBCLANG_PATH`, `Version_Number=10.0.26100.0`) →
`target\x86_64-pc-windows-msvc\release\pf_xusb.dll`.
2. Clear the FORCE_INTEGRITY PE bit (bit `0x80` at `e_lfanew+0x5e` of `pf_xusb.dll`). 2. Clear the FORCE_INTEGRITY PE bit (bit `0x80` at `e_lfanew+0x5e` of `pf_xusb.dll`).
3. `signtool sign /fd SHA256 /sha1 6A52984E54376C45A1C236B1A2C8A746C5AB6131 pf_xusb.dll`. 3. `signtool sign /fd SHA256 /sha1 6A52984E54376C45A1C236B1A2C8A746C5AB6131 pf_xusb.dll`.
4. `Inf2Cat /driver:<pkg> /os:10_X64` → re-sign `pf_xusb.cat` with the same thumbprint. 4. `Inf2Cat /driver:<pkg> /os:10_X64` → re-sign `pf_xusb.cat` with the same thumbprint.
@@ -63,11 +65,12 @@ there); these repo files are the canonical copies — keep them in sync.
## Host integration (done) ## Host integration (done)
`crates/punktfunk-host/src/inject/gamepad_windows.rs` is the Windows `GamepadManager` (used by `crates/punktfunk-host/src/inject/windows/gamepad_windows.rs` is the Windows `GamepadManager` (used by
`PadBackend::Xbox360`): it SwDeviceCreate's the `pf_xusb` companion, maps `pfxusb-shm-<index>`, writes `PadBackend::Xbox360`): it SwDeviceCreate's the `pf_xusb` companion, maps `pfxusb-shm-<index>`, writes
the XInput state from the client's gamepad frame (already XInput-convention) and forwards rumble. There the XInput state from the client's gamepad frame (already XInput-convention) and forwards rumble. There
is **no ViGEmBus dependency** anymore. The driver is built from source (`packaging/windows/drivers/pf-xusb`), is **no ViGEmBus dependency** anymore. The driver is built + signed from source in CI
signed, and pnputil-installed by the Inno Setup installer (via `install-gamepad-drivers.ps1`). (`build-gamepad-drivers.ps1`) and installed by the Inno Setup installer via
`punktfunk-host.exe driver install --gamepad`.
## Multi-pad ## Multi-pad
+5 -4
View File
@@ -285,8 +285,9 @@ end;
function WebSetupParams(Param: String): String; function WebSetupParams(Param: String): String;
begin begin
{ Pass the password to web-setup.ps1 via a temp file, not the cmdline (which lands in the install { Pass the password to `punktfunk-host.exe web setup` via a temp file, not the cmdline (which
log). Only on a fresh install - on upgrade web-setup keeps the existing file. } lands in the install log). Only on a fresh install - on upgrade web setup keeps the existing
file. }
Result := '--app-dir "' + ExpandConstant('{app}') + '"'; Result := '--app-dir "' + ExpandConstant('{app}') + '"';
if FreshWebInstall then if FreshWebInstall then
Result := Result + ' --password-file "' + ExpandConstant('{tmp}\webpw.txt') + '"'; Result := Result + ' --password-file "' + ExpandConstant('{tmp}\webpw.txt') + '"';
@@ -312,7 +313,7 @@ end;
#ifdef WithWeb #ifdef WithWeb
{ Stop a running web console + free :3000 BEFORE the file copy, so the old server doesn't lock { Stop a running web console + free :3000 BEFORE the file copy, so the old server doesn't lock
.output / web-run.cmd / bun.exe and the new task can bind. Killing the :3000 listener owner is .output / web-run.cmd / bun.exe and the new task can bind. Killing the :3000 listener owner is
runtime-agnostic (an early install may have run node, the current one runs bun). web-setup.ps1 runtime-agnostic (an early install may have run node, the current one runs bun). `web setup`
repeats this idempotently after the copy. Best-effort; a fresh install is a no-op. } repeats this idempotently after the copy. Best-effort; a fresh install is a no-op. }
procedure StopWebConsole; procedure StopWebConsole;
var var
@@ -334,7 +335,7 @@ begin
StopHostServiceAndWait; StopHostServiceAndWait;
#ifdef WithWeb #ifdef WithWeb
StopWebConsole; { upgrade-safe: free :3000 + unlock the web files before the copy } StopWebConsole; { upgrade-safe: free :3000 + unlock the web files before the copy }
{ Stash the chosen password for web-setup.ps1 (fresh install only); the temp copy is auto-cleaned. } { Stash the chosen password for `web setup` (fresh install only); the temp copy is auto-cleaned. }
if FreshWebInstall then if FreshWebInstall then
SaveStringToFile(ExpandConstant('{tmp}\webpw.txt'), Trim(WebPwPage.Values[0]), False); SaveStringToFile(ExpandConstant('{tmp}\webpw.txt'), Trim(WebPwPage.Values[0]), False);
#endif #endif
+4
View File
@@ -39,9 +39,13 @@ git clone https://git.unom.io/unom/punktfunk ~/punktfunk
bash ~/punktfunk/scripts/steamdeck/install.sh # PIN pairing required (secure default) bash ~/punktfunk/scripts/steamdeck/install.sh # PIN pairing required (secure default)
bash ~/punktfunk/scripts/steamdeck/install.sh --open # trusted LAN: accept unpaired clients bash ~/punktfunk/scripts/steamdeck/install.sh --open # trusted LAN: accept unpaired clients
bash ~/punktfunk/scripts/steamdeck/install.sh --no-web # host only, no web console bash ~/punktfunk/scripts/steamdeck/install.sh --no-web # host only, no web console
bash ~/punktfunk/scripts/steamdeck/install.sh --no-gamestream # native punktfunk/1 only, no Moonlight surface
bash ~/punktfunk/scripts/steamdeck/update.sh # after pulling new source bash ~/punktfunk/scripts/steamdeck/update.sh # after pulling new source
``` ```
Note: unlike a bare `serve` (native-only by default), the Deck install enables `--gamestream` by
default so stock Moonlight clients work out of the box; `--no-gamestream` turns that surface off.
Env overrides: `PUNKTFUNK_SRC` (source dir, default `~/punktfunk`), `PUNKTFUNK_BOX` (container name, Env overrides: `PUNKTFUNK_SRC` (source dir, default `~/punktfunk`), `PUNKTFUNK_BOX` (container name,
default `pf2`), `PUNKTFUNK_MGMT_PORT` (47990), `PUNKTFUNK_WEB_PORT` (3000). default `pf2`), `PUNKTFUNK_MGMT_PORT` (47990), `PUNKTFUNK_WEB_PORT` (3000).
+3 -3
View File
@@ -36,7 +36,7 @@ won't start. The service is down only for the build duration.
On an **installed** host (the `setup.exe`) the console is set up automatically — no manual steps. On an **installed** host (the `setup.exe`) the console is set up automatically — no manual steps.
The installer bundles the built (self-contained, no-`node_modules`) `.output` server + a portable The installer bundles the built (self-contained, no-`node_modules`) `.output` server + a portable
bun and runs `scripts\windows\web-setup.ps1`, which registers the **`PunktfunkWeb`** scheduled task bun and runs `punktfunk-host.exe web setup`, which registers the **`PunktfunkWeb`** scheduled task
(at boot, as SYSTEM, restart-on-failure) running `{app}\web\web-run.cmd` (at boot, as SYSTEM, restart-on-failure) running `{app}\web\web-run.cmd`
`bun …\.output\server\index.mjs` on `:3000`, opens inbound TCP 3000, and writes the login password to `bun …\.output\server\index.mjs` on `:3000`, opens inbound TCP 3000, and writes the login password to
`%ProgramData%\punktfunk\web-password` (ACL'd to Administrators + SYSTEM). The mgmt bearer token it `%ProgramData%\punktfunk\web-password` (ACL'd to Administrators + SYSTEM). The mgmt bearer token it
@@ -52,8 +52,8 @@ powershell -ExecutionPolicy Bypass -File scripts\windows\build-web.ps1
`bun install && bun run build` (Nitro `noExternals` -> a self-contained `.output`, no `bun install && bun run build` (Nitro `noExternals` -> a self-contained `.output`, no
`node_modules`/`.npmrc`), then restarts the `PunktfunkWeb` task and checks `:3000/login`. Use `node_modules`/`.npmrc`), then restarts the `PunktfunkWeb` task and checks `:3000/login`. Use
this to iterate on the console against an installed host - `web-setup.ps1` (or a fresh install) is this to iterate on the console against an installed host - `punktfunk-host.exe web setup` (or a
what creates the task in the first place. fresh install) is what creates the task in the first place.
## Typical flow after pulling new code ## Typical flow after pulling new code