refactor: drop milestone names + consolidate clients; loss-recovery & rumble fixes
Two bodies of work in one commit (the rename moved files the fixes also touched). Naming/structure cleanup (pre-launch): - Host modules m3.rs->punktfunk1.rs, m0.rs->spike.rs; CLI m3-host->punktfunk1-host, m0->spike; bare `punktfunk-host` now prints help. Types M3Options/M3Source-> Punktfunk1Options/Punktfunk1Source. - Clients consolidated out of crates/ into clients/: punktfunk-client-rs-> clients/probe (crate punktfunk-probe), client-linux->clients/linux, client-windows->clients/windows, punktfunk-android->clients/android/native (crate punktfunk-client-android; kept [lib] name=punktfunk_android so the JNI contract is unchanged). crates/ now holds only core + host. - Milestone codes M0-M4 purged from code/CLI/CLAUDE.md/README/docs/docs-site, kept only in docs/implementation-plan.md. docs/m2-plan.md-> docs/gamestream-host-plan.md. CI/gradle/flatpak paths updated. Client loss-recovery (video froze and never recovered after a brief drop): - Export punktfunk_connection_frames_dropped through the C ABI (the core already tracked it for the client keyframe-recovery loop; it was never reachable from the ABI clients). Regenerated punktfunk_core.h. - Apple (StreamPump + Stage2Pipeline) and Android (decode.rs) now poll frames_dropped and request a keyframe when it climbs -- the same loss-driven recovery Linux/Windows already had. Under infinite GOP the decoder silently conceals reference-missing frames, so the decode-error trigger rarely fires. Apple rumble robustness (worked then went spotty -- DualSense + Xbox): - Add CHHapticEngine stopped/reset handlers (rebuild on app background / audio interruption / server reset) and drop the permanent `broken` latch on a transient drive failure; latch only when the controller truly has no haptics. - Surface swallowed SDL set_rumble errors on Linux/Windows + diagnostic logging. Verified: cargo build/clippy/fmt --workspace, C-ABI harness, header drift. Not runnable on this box (verify in CI): Gitea workflows, gradle/Android, flatpak, Swift/decky. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@@ -1,4 +1,4 @@
|
||||
# Android client CI (Gitea Actions). Builds the Rust JNI core (crates/punktfunk-android) via
|
||||
# Android client CI (Gitea Actions). Builds the Rust JNI core (clients/android/native) via
|
||||
# cargo-ndk for both shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml
|
||||
# but on a Linux runner — the NDK is cross-platform, so no self-hosted host is needed.
|
||||
#
|
||||
|
||||
@@ -26,7 +26,7 @@ on:
|
||||
# The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every
|
||||
# docs/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too.
|
||||
paths:
|
||||
- 'crates/punktfunk-client-linux/**'
|
||||
- 'clients/linux/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'packaging/flatpak/**'
|
||||
- 'Cargo.lock'
|
||||
@@ -40,6 +40,8 @@ env:
|
||||
APP_ID: io.unom.Punktfunk
|
||||
MANIFEST: packaging/flatpak/io.unom.Punktfunk.yml
|
||||
PACKAGE: punktfunk-client-flatpak # generic-registry package name
|
||||
REPO_URL: https://flatpak.unom.io # shared unom OSTree repo (reusable across unom apps)
|
||||
DEPLOY_DIR: unom-flatpak # ~/<dir> on unom-1 (compose + ./site tree)
|
||||
|
||||
jobs:
|
||||
build-publish:
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# makeappx/signtool are baked into the runner's daemon env, same as windows.yml.
|
||||
#
|
||||
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
|
||||
# Packaging internals: crates/punktfunk-client-windows/packaging/README.md. BOM/MAX_PATH runner
|
||||
# Packaging internals: clients/windows/packaging/README.md. BOM/MAX_PATH runner
|
||||
# gotchas baked into the daemon env + windows.yml: see that workflow.
|
||||
#
|
||||
# Versioning — MSIX requires a strictly 4-part numeric version (no ~/- suffixes), so:
|
||||
@@ -25,7 +25,7 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'crates/punktfunk-client-windows/**'
|
||||
- 'clients/windows/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
||||
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
||||
run: |
|
||||
& crates/punktfunk-client-windows/packaging/pack-msix.ps1 `
|
||||
& clients/windows/packaging/pack-msix.ps1 `
|
||||
-Version $env:MSIX_VERSION -TargetDir C:\t\release -OutDir C:\t\msix
|
||||
|
||||
- name: Publish to Gitea generic registry
|
||||
|
||||
@@ -24,14 +24,14 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'crates/punktfunk-client-windows/**'
|
||||
- 'clients/windows/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'crates/punktfunk-client-windows/**'
|
||||
- 'clients/windows/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
|
||||
@@ -20,3 +20,11 @@ xcuserdata/
|
||||
# Windows App SDK staging by windows-reactor build.rs
|
||||
/temp/
|
||||
/winmd/
|
||||
|
||||
# Client crate build artifacts (clients moved out of crates/ -> clients/ 2026-06-18)
|
||||
/clients/*/target
|
||||
/clients/*/*/target
|
||||
|
||||
# Python bytecode (e.g. clients/android/ci tooling)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -6,10 +6,10 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
|
||||
## Where the work stands
|
||||
|
||||
- **M1 (`punktfunk-core` + C ABI): complete and hardened.** FEC recovery, loopback-under-loss,
|
||||
- **Core (`punktfunk-core` + C ABI): complete and hardened.** FEC recovery, loopback-under-loss,
|
||||
proptests, C ABI harness all green; 13 adversarial-review findings fixed +
|
||||
regression-tested (`a913042`).
|
||||
- **M2 (GameStream host): working end-to-end with a stock Moonlight client.** Validated live
|
||||
- **GameStream host: working end-to-end with a stock Moonlight client.** Validated live
|
||||
on this box: pairing (persists across restarts), serverinfo/applist (app catalog from
|
||||
`~/.config/punktfunk/apps.json` → each entry picks a compositor + nested command), RTSP, ENet
|
||||
control, audio, and video at the **client's native resolution and refresh** — the host
|
||||
@@ -28,11 +28,11 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
socket, wlr protocols on Sway) and **gamepads** (uinput X-Box-360 pads + rumble
|
||||
back-channel; validated live — pad created/destroyed with the session). Management REST API +
|
||||
checked-in OpenAPI doc (`mgmt.rs`).
|
||||
- **M3 (`punktfunk/1`, the native protocol): full session planes, validated live.** QUIC
|
||||
- **Native protocol (`punktfunk/1`): full session planes, validated live.** QUIC
|
||||
control plane (`punktfunk-core` `quic` feature: Hello{mode}/Welcome{full Config}/Start), data
|
||||
plane = the hardened M1 `Session` over raw UDP with **GF(2¹⁶) Leopard FEC + AES-GCM**
|
||||
plane = the hardened core `Session` over raw UDP with **GF(2¹⁶) Leopard FEC + AES-GCM**
|
||||
(inexpressible in GameStream), host creates the native virtual output at the client's
|
||||
requested mode. `m3-host` is a **persistent listener** (sessions back to back;
|
||||
requested mode. `punktfunk1-host` is a **persistent listener** (sessions back to back;
|
||||
`--max-sessions`). QUIC datagrams carry the side planes, demuxed by first byte: input
|
||||
0xC8 (incl. **gamepads** — incremental events accumulated into the uinput xpad), **Opus
|
||||
audio** 0xC9 (48 kHz stereo, 5 ms, host→client), **rumble** 0xCA (host→client). **Trust:**
|
||||
@@ -41,15 +41,15 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
ceremony** (host arms pairing and displays a 4-digit PIN; a PAKE binds both cert fingerprints so an
|
||||
attacker gets one online guess, no offline dictionary attack) — PIN pairing is the default for new
|
||||
hosts. **TOFU on first connect** (`endpoint::client_pinned`) stays as an explicit host opt-in
|
||||
(`m3-host --allow-tofu` / `serve --open`, advertised as `pair=optional`) for fully trusted LANs;
|
||||
(`punktfunk1-host --allow-tofu` / `serve --open`, advertised as `pair=optional`) for fully trusted LANs;
|
||||
clients only offer the TOFU "Trust" path for a host that advertised `pair=optional`, route every
|
||||
other new host straight to the PIN ceremony, and on a pinned-fingerprint change force re-pairing
|
||||
(no re-TOFU shortcut). Clients present persistent identities via QUIC client auth, the host stores
|
||||
paired fingerprints (`punktfunk1-paired.json`) and gates sessions with `--require-pairing` (the
|
||||
default; `--allow-tofu`/`--open` accept unpaired clients).
|
||||
**LAN auto-discovery**: both `serve --native` and `m3-host` advertise the native service over
|
||||
**LAN auto-discovery**: both `serve --native` and `punktfunk1-host` advertise the native service over
|
||||
mDNS (`_punktfunk._udp`, `crate::discovery`) with TXT `proto`/`fp`(cert fingerprint to
|
||||
pin)/`pair`(required|optional)/`id`; `punktfunk-client-rs --discover` lists hosts, Apple clients
|
||||
pin)/`pair`(required|optional)/`id`; `punktfunk-probe --discover` lists hosts, Apple clients
|
||||
browse the same service via NWBrowser (validated cross-LAN 2026-06-12).
|
||||
**Mid-stream mode renegotiation**: `Reconfigure` on the still-open control stream — the
|
||||
host rebuilds output+encoder at the new mode in ~90 ms while the data plane runs on
|
||||
@@ -58,7 +58,7 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
(`ClockProbe`/`ClockEcho`, 8 NTP rounds after `Start`, `clock_offset_ns`) aligns the client to the
|
||||
host clock, so that latency is now valid **cross-machine** (`skew_corrected=true`) — measured GNOME
|
||||
box → dev box over the LAN: **p50 1.30 ms** (the −1.57 ms inter-box clock offset removed).
|
||||
`punktfunk-client-rs` is the
|
||||
`punktfunk-probe` is the
|
||||
working reference client (`--pin`, datagram counters, `--input-test` incl. gamepad).
|
||||
The embeddable connector (`NativeClient`) exposes it all over the C ABI: `punktfunk_connect`
|
||||
(pin/TOFU) + `next_au`/`next_audio`/`next_rumble`/`next_hidout`/`send_input`/
|
||||
@@ -69,7 +69,7 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
|
||||
## What's left
|
||||
|
||||
1. **M4 — client decode + present: macOS stage 1 done, first light achieved
|
||||
1. **Native clients — decode + present: macOS stage 1 done, first light achieved
|
||||
(2026-06-10).** PunktfunkKit compiles and is tested on macOS (AnnexB → VideoToolbox →
|
||||
`AVSampleBufferDisplayLayer`, GCMouse/GCKeyboard capture, `PunktfunkClient` app shell);
|
||||
validated live Mac ↔ this box at 720p60 — vkcube on glass, input injected via gamescope
|
||||
@@ -85,13 +85,13 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
Loopback-tested end to end (`PUNKTFUNK_TEST_FEEDBACK=1` scripted burst); DualSense
|
||||
motion sign/scale derived, not yet live-verified. Tests: `swift test` in
|
||||
`clients/apple` (unit + real-codec round trip),
|
||||
`test-loopback.sh` (Swift client vs synthetic m3-hosts on loopback — runs on macOS;
|
||||
`test-loopback.sh` (Swift client vs synthetic punktfunk1-hosts on loopback — runs on macOS;
|
||||
includes the pairing ceremony + `--require-pairing` gate),
|
||||
`RemoteFirstLightTests` (full pipeline over the LAN). See
|
||||
[`clients/apple/README.md`](clients/apple/README.md). Next: stage 2 presenter
|
||||
(`VTDecompressionSession` + `CAMetalLayer` frame pacing), glass-to-glass numbers via
|
||||
`tools/latency-probe` (scaffold), iOS variant.
|
||||
**Linux stage 1 done, first light 2026-06-12** (`crates/punktfunk-client-linux`, binary
|
||||
**Linux stage 1 done, first light 2026-06-12** (`clients/linux`, binary
|
||||
`punktfunk-client`): GTK4/libadwaita shell linking `punktfunk-core` directly (no C ABI;
|
||||
`NativeClient` is now `Sync` — mutexed plane receivers), mDNS host list, TOFU + SPAKE2
|
||||
PIN dialogs (identity shared with client-rs), FFmpeg software HEVC decode (LOW_DELAY,
|
||||
@@ -118,7 +118,7 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
reconfirm. Next: the stage-2 raw-Wayland
|
||||
presenter (wp_presentation feedback, tearing-control, Vulkan Video on NVIDIA) —
|
||||
**wgpu/winit rejected** (no dmabuf import / presentation feedback / shortcuts-inhibit).
|
||||
**Windows stage 1 done 2026-06-15** (`crates/punktfunk-client-windows`, binary
|
||||
**Windows stage 1 done 2026-06-15** (`clients/windows`, binary
|
||||
`punktfunk-client`): pure-Rust **WinUI 3** UI via **windows-reactor** (a declarative React-like
|
||||
framework backed by WinUI; PR #4499 added the `SwapChainPanel` widget + `set_swap_chain`). The
|
||||
video is a **`SwapChainPanel`** bound to a **D3D11 composition swapchain** (WARP fallback for
|
||||
@@ -150,7 +150,7 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
unpaired clients); clients render TOFU only for a `pair=optional` host and force re-pairing on a
|
||||
fingerprint change. Next (see roadmap): **delegated pairing approval** (an already-paired device
|
||||
approves a new one).
|
||||
4. **M2 polish**: HDR/10-bit (needs HDR capture + metadata plumbing; `av1_nvenc
|
||||
4. **GameStream host polish**: HDR/10-bit (needs HDR capture + metadata plumbing; `av1_nvenc
|
||||
-highbitdepth 1` already encodes Main10 from 8-bit input on this box),
|
||||
reconnect-at-new-mode robustness. AV1 negotiation and surround audio are implemented
|
||||
and unit/live-capture tested — both still need a live Moonlight confirmation (select
|
||||
@@ -193,9 +193,13 @@ crates/punktfunk-host/
|
||||
vdisplay/{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)
|
||||
inject/{libei,wlr,gamepad,dualsense}.rs input backends (uinput xpad + UHID DualSense)
|
||||
capture.rs · encode.rs · audio.rs · m0.rs · m3.rs · mgmt.rs · native_pairing.rs
|
||||
crates/punktfunk-client-rs/ punktfunk/1 reference client (M3 headless test/measurement tool)
|
||||
crates/punktfunk-client-linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
|
||||
capture.rs · encode.rs · audio.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs
|
||||
clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool)
|
||||
clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
|
||||
clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3)
|
||||
clients/apple/ native macOS/iOS client (Swift · VideoToolbox · GameController)
|
||||
clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core)
|
||||
clients/decky/ Steam Deck Decky plugin
|
||||
web/ TanStack web console over the mgmt API (status · devices · pairing)
|
||||
packaging/ Fedora/Bazzite RPM · bootc · COPR (packaging/bazzite/README.md)
|
||||
tools/{loss-harness,latency-probe}/ measurement (plan §10)
|
||||
@@ -215,7 +219,7 @@ include/punktfunk_core.h generated C header
|
||||
- **FEC is the wall-breaker.** GF(2⁸) (≤255 shards/block, Moonlight-compatible) and GF(2¹⁶)
|
||||
Leopard (≤65535 shards/block) — punktfunk/1 negotiates the latter, removing the ~1 Gbps
|
||||
ceiling.
|
||||
- **M1 security hardening stays intact**: reassembler bounds attacker-controlled fields
|
||||
- **Core security hardening stays intact**: reassembler bounds attacker-controlled fields
|
||||
before allocating (`ReassemblerLimits`); AES-GCM per-direction nonce salts + seq-as-AAD;
|
||||
ABI `struct_size` checks. Regression tests exist — keep them green.
|
||||
- **PipeWire consumer discipline**: our capture streams set `node.dont-reconnect` and tear
|
||||
@@ -240,8 +244,8 @@ PUNKTFUNK_ZEROCOPY=1 cargo run -rp punktfunk-host -- serve
|
||||
|
||||
# punktfunk/1 native loopback test (no Moonlight needed; same env as serve, listener persists
|
||||
# across sessions — bound it with --max-sessions):
|
||||
cargo run -rp punktfunk-host -- m3-host --source virtual --seconds 10 --max-sessions 1
|
||||
cargo run -rp punktfunk-client-rs -- --mode 1280x720x120 --out /tmp/a.h265 --input-test # + --pin HEX
|
||||
cargo run -rp punktfunk-host -- punktfunk1-host --source virtual --seconds 10 --max-sessions 1
|
||||
cargo run -rp punktfunk-probe -- --mode 1280x720x120 --out /tmp/a.h265 --input-test # + --pin HEX
|
||||
```
|
||||
|
||||
Pinned crate facts: `ashpd` 0.13 + `pipewire` 0.9 (must match ashpd's) + `ffmpeg-next` 8.x
|
||||
|
||||
@@ -2540,7 +2540,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-android"
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
@@ -2571,20 +2571,6 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-rs"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
"opus",
|
||||
"punktfunk-core",
|
||||
"quinn",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.0.1"
|
||||
@@ -2693,6 +2679,20 @@ dependencies = [
|
||||
"xkbcommon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
"opus",
|
||||
"punktfunk-core",
|
||||
"quinn",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
|
||||
@@ -3,10 +3,10 @@ resolver = "2"
|
||||
members = [
|
||||
"crates/punktfunk-core",
|
||||
"crates/punktfunk-host",
|
||||
"crates/punktfunk-client-rs",
|
||||
"crates/punktfunk-client-linux",
|
||||
"crates/punktfunk-client-windows",
|
||||
"crates/punktfunk-android",
|
||||
"clients/probe",
|
||||
"clients/linux",
|
||||
"clients/windows",
|
||||
"clients/android/native",
|
||||
"tools/latency-probe",
|
||||
"tools/loss-harness",
|
||||
]
|
||||
|
||||
@@ -12,10 +12,10 @@ negotiated extension. See [`docs/implementation-plan.md`](docs/implementation-pl
|
||||
|
||||
| Milestone | State |
|
||||
|-----------|-------|
|
||||
| **M1 — `punktfunk-core` + C ABI** | ✅ done & hardened (FEC, packetization, AES-GCM, session, adversarial-review fixes, `punktfunk_core.h`) |
|
||||
| **M2 — GameStream host → stock Moonlight** | ✅ live end-to-end: pairing, RTSP, audio, per-client virtual output at native res, GPU zero-copy NVENC, gamepads |
|
||||
| **M3 — `punktfunk/1` native protocol** | ✅ validated live: QUIC control + GF(2¹⁶) FEC/AES data plane, SPAKE2 PIN pairing, mid-stream mode renegotiation |
|
||||
| **M4 — client decode + present (Apple)** | 🟡 macOS first light: AnnexB→VideoToolbox HEVC on glass + input/pairing over `punktfunk/1` (`clients/apple`); iOS + presenter next |
|
||||
| **Core — `punktfunk-core` + C ABI** | ✅ done & hardened (FEC, packetization, AES-GCM, session, adversarial-review fixes, `punktfunk_core.h`) |
|
||||
| **GameStream host → stock Moonlight** | ✅ live end-to-end: pairing, RTSP, audio, per-client virtual output at native res, GPU zero-copy NVENC, gamepads |
|
||||
| **Native protocol — `punktfunk/1`** | ✅ validated live: QUIC control + GF(2¹⁶) FEC/AES data plane, SPAKE2 PIN pairing, mid-stream mode renegotiation |
|
||||
| **Native clients — decode + present** | 🟡 macOS first light: AnnexB→VideoToolbox HEVC on glass + input/pairing over `punktfunk/1` (`clients/apple`); iOS + presenter next |
|
||||
| **Web console + management API** | ✅ TanStack web console (`web/`) over the OpenAPI mgmt API: host status, paired devices, on-demand native pairing (arm → show PIN) |
|
||||
|
||||
The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA
|
||||
@@ -26,7 +26,7 @@ per-session virtual output (KWin, gamescope, Mutter, Sway backends), encoded wit
|
||||
**`punktfunk/1`** protocol adds a QUIC control plane and a GF(2¹⁶) Leopard-FEC + AES-GCM data
|
||||
plane (p50 ~0.8 ms capture→reassembled at 720p120). Its trust model is **SPAKE2 PIN pairing by
|
||||
default** — a new host requires the PIN ceremony; trust-on-first-use is an explicit host opt-in
|
||||
(`m3-host --allow-tofu` / `serve --open`, advertised as `pair=optional`) for fully trusted LANs. Both
|
||||
(`punktfunk1-host --allow-tofu` / `serve --open`, advertised as `pair=optional`) for fully trusted LANs. Both
|
||||
run from **one process** (`serve --native`), managed through a REST API + web console. Builds
|
||||
against FFmpeg 7 or 8; deployed live on Bazzite. Full status: [`CLAUDE.md`](CLAUDE.md);
|
||||
roadmap, setup guides & progress: the docs site ([`docs-site/`](docs-site) — Fumadocs;
|
||||
@@ -55,9 +55,12 @@ Building from source (below) is a fallback.
|
||||
```
|
||||
crates/
|
||||
punktfunk-core/ protocol · FEC · pacing · crypto · quic — the C ABI (lib + cdylib + staticlib)
|
||||
punktfunk-host/ Linux host: vdisplay · capture · encode · inject · gamestream · m3 · mgmt · native_pairing
|
||||
punktfunk-client-rs/ punktfunk/1 reference client (M3 headless; M4 adds decode+present)
|
||||
clients/{apple,android}/ native client scaffolds (import punktfunk_core.h); apple = macOS first light
|
||||
punktfunk-host/ Linux host: vdisplay · capture · encode · inject · gamestream · punktfunk1 · mgmt · native_pairing
|
||||
clients/
|
||||
probe/ punktfunk/1 reference/probe client (headless test + latency measurement)
|
||||
linux/ windows/ native desktop clients (Rust: GTK4 / WinUI 3, link punktfunk-core directly)
|
||||
apple/ android/ Swift (macOS+iOS) · Kotlin app + native/ Rust JNI core
|
||||
decky/ Steam Deck Decky plugin
|
||||
web/ TanStack web console (host status · paired devices · pairing) over the mgmt API
|
||||
packaging/ Fedora/Bazzite RPM · bootc image · COPR (see packaging/bazzite/README.md)
|
||||
include/punktfunk_core.h cbindgen-generated C header (checked in)
|
||||
|
||||
@@ -11,7 +11,7 @@ machine, trust logic) instead of re-porting it into Kotlin.
|
||||
|
||||
| Side | Owns |
|
||||
|------|------|
|
||||
| **Rust** (`crates/punktfunk-android` → `libpunktfunk_android.so`) | the JNI seam, `NativeClient` (QUIC control + UDP data plane), AnnexB→`AMediaCodec` decode, Opus+Oboe audio, VK keymap, latency math, trust/pairing |
|
||||
| **Rust** (`clients/android/native` → `libpunktfunk_android.so`) | the JNI seam, `NativeClient` (QUIC control + UDP data plane), AnnexB→`AMediaCodec` decode, Opus+Oboe audio, VK keymap, latency math, trust/pairing |
|
||||
| **Kotlin** (`clients/android`) | Compose UI (host grid / settings / stream), `SurfaceView` lifecycle, input capture, `NsdManager` discovery, Keystore identity, permissions |
|
||||
|
||||
The single seam is `io.unom.punktfunk.kit.NativeBridge` ⇄ `Java_io_unom_punktfunk_kit_NativeBridge_*`.
|
||||
@@ -19,7 +19,7 @@ The single seam is `io.unom.punktfunk.kit.NativeBridge` ⇄ `Java_io_unom_punktf
|
||||
## Layout
|
||||
|
||||
```
|
||||
crates/punktfunk-android/ Rust cdylib (workspace member)
|
||||
clients/android/native/ Rust cdylib (workspace member)
|
||||
src/lib.rs JNI_OnLoad + abiVersion/coreVersion (native-link proof)
|
||||
src/session.rs session handle lifecycle (connect/close); plane pumps = TODO
|
||||
|
||||
@@ -63,7 +63,7 @@ The debug APK lands in `app/build/outputs/apk/debug/`. The scaffold screen calls
|
||||
- **Scaffold (done):** Gradle modules, cargo-ndk wiring, JNI native-link proof, phone+TV-installable
|
||||
manifest. `crates/punktfunk-core` `rcgen` switched to the `ring` backend so the client `.so` is
|
||||
aws-lc-free.
|
||||
- **Next (M4 Android stage 1):** video decode (`AMediaCodec` async → `SurfaceView`), audio
|
||||
- **Next (Android stage 1):** video decode (`AMediaCodec` async → `SurfaceView`), audio
|
||||
(Opus + Oboe + jitter ring), input capture → `send_input`, pairing/identity (Keystore-wrapped),
|
||||
mDNS discovery, the phone/TV Compose UI. The Rust-side homes are stubbed in
|
||||
`crates/punktfunk-android/src/session.rs` with port pointers to `crates/punktfunk-client-linux`.
|
||||
`clients/android/native/src/session.rs` with port pointers to `clients/linux`.
|
||||
|
||||
@@ -32,7 +32,7 @@ dependencies {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// cargo-ndk: cross-compile crates/punktfunk-android into this module's jniLibs/<abi>/ so the
|
||||
// cargo-ndk: cross-compile clients/android/native (punktfunk-client-android) into this module's jniLibs/<abi>/ so the
|
||||
// resulting libpunktfunk_android.so is packaged into the app (and any AAR this module produces).
|
||||
// NDK r28+ aligns to 16 KB pages by default — no extra linker flags. Prereqs (see clients/android
|
||||
// /README.md): `cargo install cargo-ndk` + `rustup target add aarch64-linux-android x86_64-linux-android`.
|
||||
@@ -57,7 +57,7 @@ fun androidSdkDir(): String {
|
||||
fun registerCargoNdk(taskName: String, release: Boolean) =
|
||||
tasks.register<Exec>(taskName) {
|
||||
group = "rust"
|
||||
description = "cargo-ndk build of punktfunk-android (${if (release) "release" else "debug"})"
|
||||
description = "cargo-ndk build of punktfunk-client-android (${if (release) "release" else "debug"})"
|
||||
workingDir = repoRoot
|
||||
val sdk = androidSdkDir()
|
||||
// A GUI Android Studio launch does not source the login shell, so make cargo, the NDK, and
|
||||
@@ -84,7 +84,7 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
|
||||
// Link against the minSdk-31 sysroot so libaaudio (API 26+) is found.
|
||||
"--platform", "31",
|
||||
"-o", file("src/main/jniLibs").absolutePath,
|
||||
"build", "-p", "punktfunk-android",
|
||||
"build", "-p", "punktfunk-client-android",
|
||||
)
|
||||
if (release) cmd += "--release"
|
||||
commandLine(cmd)
|
||||
|
||||
@@ -3,7 +3,7 @@ package io.unom.punktfunk.kit
|
||||
/**
|
||||
* The single JNI seam to `libpunktfunk_android.so` (the Rust-heavy client core).
|
||||
*
|
||||
* Symbols are implemented in `crates/punktfunk-android`. This object is intentionally thin —
|
||||
* Symbols are implemented in `clients/android/native`. This object is intentionally thin —
|
||||
* all protocol logic lives in Rust (`punktfunk-core` + the connector); Kotlin only marshals.
|
||||
*/
|
||||
object NativeBridge {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "punktfunk-android"
|
||||
name = "punktfunk-client-android"
|
||||
description = "punktfunk Android client — JNI bridge ('nativecore') over punktfunk-core (Rust-heavy client model)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
@@ -16,7 +16,7 @@ crate-type = ["cdylib"]
|
||||
[dependencies]
|
||||
# The whole protocol/transport/FEC/crypto + the embeddable NativeClient connector. `quic` pulls
|
||||
# the punktfunk/1 control plane (now ring-only — no aws-lc, see punktfunk-core/Cargo.toml).
|
||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||
punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"] }
|
||||
jni = "0.21"
|
||||
log = "0.4"
|
||||
|
||||
@@ -15,7 +15,7 @@ use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::error::PunktfunkError;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The decode loop. Runs on the `pf-decode` thread until `shutdown` is set or the session closes.
|
||||
pub fn run(client: Arc<NativeClient>, window: NativeWindow, shutdown: Arc<AtomicBool>) {
|
||||
@@ -56,6 +56,10 @@ pub fn run(client: Arc<NativeClient>, window: NativeWindow, shutdown: Arc<Atomic
|
||||
|
||||
let mut fed: u64 = 0;
|
||||
let mut rendered: u64 = 0;
|
||||
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it
|
||||
// climbs.
|
||||
let mut last_dropped = client.frames_dropped();
|
||||
let mut last_kf_req: Option<Instant> = None;
|
||||
while !shutdown.load(Ordering::Relaxed) {
|
||||
match client.next_frame(Duration::from_millis(5)) {
|
||||
Ok(frame) => {
|
||||
@@ -74,6 +78,24 @@ pub fn run(client: Arc<NativeClient>, window: NativeWindow, shutdown: Arc<Atomic
|
||||
Err(_) => break, // session closed
|
||||
}
|
||||
rendered += drain(&codec);
|
||||
|
||||
// Loss recovery: under infinite GOP the only recovery keyframe is one we request. The
|
||||
// reassembler drops unrecoverable AUs (frames_dropped); the decoder then conceals the
|
||||
// reference-missing delta frames that follow and renders them without error, so keying off
|
||||
// a decode error rarely fires. Request an IDR when the drop count climbs, throttled — the
|
||||
// decode stays wedged for several frames until the IDR lands, so requesting every frame
|
||||
// would flood the control stream.
|
||||
let dropped = client.frames_dropped();
|
||||
if dropped > last_dropped {
|
||||
last_dropped = dropped;
|
||||
let now = Instant::now();
|
||||
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
||||
last_kf_req = Some(now);
|
||||
let _ = client.request_keyframe();
|
||||
log::debug!("decode: requested keyframe (loss recovery, dropped={dropped})");
|
||||
}
|
||||
}
|
||||
|
||||
if fed > 0 && fed % 300 == 0 {
|
||||
log::info!("decode: fed={fed} rendered={rendered}");
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
//! Kotlin side), `nativeConnect` with identity + pin (TOFU / pinned), and `nativePair` (SPAKE2 PIN).
|
||||
//!
|
||||
//! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode
|
||||
//! renegotiation. Port the remaining orchestration from `crates/punktfunk-client-linux`.
|
||||
//! renegotiation. Port the remaining orchestration from `clients/linux`.
|
||||
|
||||
use jni::objects::{JObject, JString};
|
||||
use jni::sys::{jboolean, jint, jlong};
|
||||
@@ -20,8 +20,8 @@ full session: video AUs, **Opus audio** (`nextAudio()`), **rumble** (`nextRumble
|
||||
**DualSense feedback** (`nextHidOutput()` — lightbar, player LEDs, adaptive-trigger
|
||||
effects), input incl. gamepads + DualSense touchpad/motion (`sendTouchpad`/`sendMotion`),
|
||||
and **cert pinning + TOFU** (`pinSHA256:`/`hostFingerprint`) — see
|
||||
`m3.rs::tests::c_abi_connection_roundtrip` (three sequential sessions: TOFU, pinned
|
||||
reconnect, wrong-pin rejection). The host (`punktfunk-host m3-host`) is a persistent listener:
|
||||
`punktfunk1.rs::tests::c_abi_connection_roundtrip` (three sequential sessions: TOFU, pinned
|
||||
reconnect, wrong-pin rejection). The host (`punktfunk-host punktfunk1-host`) is a persistent listener:
|
||||
reconnect at will during development.
|
||||
|
||||
What's here, all compiled and tested on macOS (Xcode 26.5 / Swift 6.3):
|
||||
@@ -127,10 +127,10 @@ bash test-loopback.sh # full loopback proof: builds punktfunk
|
||||
# (synthetic source — runs on macOS), streams
|
||||
# byte-verified frames into the Swift client
|
||||
|
||||
# against the real host (Linux box, see CLAUDE.md "Running on this box") — m3-host is a
|
||||
# against the real host (Linux box, see CLAUDE.md "Running on this box") — punktfunk1-host is a
|
||||
# persistent listener, reconnect at will:
|
||||
# PUNKTFUNK_COMPOSITOR=gamescope PUNKTFUNK_GAMESCOPE_APP=vkcube PUNKTFUNK_ZEROCOPY=1 \
|
||||
# cargo run -rp punktfunk-host -- m3-host --source virtual --seconds 60
|
||||
# cargo run -rp punktfunk-host -- punktfunk1-host --source virtual --seconds 60
|
||||
PUNKTFUNK_REMOTE_HOST=<box-ip> swift test --filter RemoteFirstLightTests # headless
|
||||
# (+ PUNKTFUNK_REMOTE_PORT / PUNKTFUNK_REMOTE_COMPOSITOR=gamescope|kwin|… /
|
||||
# PUNKTFUNK_REMOTE_PIN=<arming-pin> for the remote pairing test)
|
||||
|
||||
@@ -58,7 +58,13 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
private var controller: GCController?
|
||||
private var low: Motor?
|
||||
private var high: Motor?
|
||||
// `broken` latches OFF only for a controller that genuinely has no haptics engine (an Xbox pad
|
||||
// on an OS that doesn't expose rumble through GameController, a Siri Remote) — nothing to retry
|
||||
// until the controller changes. A transient engine failure does NOT latch it; it tears down for
|
||||
// a lazy rebuild instead, so a single hiccup can't kill rumble for the whole session.
|
||||
private var broken = false
|
||||
/// Last logged active/silent state — for a one-line transition log, not per-event spam.
|
||||
private var wasActive = false
|
||||
|
||||
func retarget(_ c: GCController?) {
|
||||
queue.async {
|
||||
@@ -70,8 +76,14 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
|
||||
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
|
||||
queue.async {
|
||||
let active = lowAmp != 0 || highAmp != 0
|
||||
if active != self.wasActive {
|
||||
self.wasActive = active
|
||||
log.debug(
|
||||
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
|
||||
}
|
||||
guard !self.broken else { return }
|
||||
if (lowAmp != 0 || highAmp != 0), self.low == nil, self.high == nil {
|
||||
if active, self.low == nil, self.high == nil {
|
||||
self.setup()
|
||||
}
|
||||
if self.high != nil {
|
||||
@@ -92,7 +104,15 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
/// high = right/light — the Xbox/XInput convention the wire carries); one combined
|
||||
/// engine otherwise, driven by whichever amplitude is stronger.
|
||||
private func setup() {
|
||||
guard let haptics = controller?.haptics else { return }
|
||||
guard let haptics = controller?.haptics else {
|
||||
// No haptics engine at all — an Xbox controller on an OS/firmware that doesn't expose
|
||||
// rumble through GameController (works on Android via the standard Vibrator path, but
|
||||
// Apple's support is controller/OS-dependent), or a Siri Remote. Nothing to retry until
|
||||
// the controller changes; latch off (retarget clears it) and say so once.
|
||||
log.info("rumble: active controller exposes no haptics engine — rumble unavailable")
|
||||
broken = true
|
||||
return
|
||||
}
|
||||
let localities = haptics.supportedLocalities
|
||||
if localities.contains(.leftHandle), localities.contains(.rightHandle) {
|
||||
low = makeMotor(haptics, .leftHandle)
|
||||
@@ -100,13 +120,28 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
} else {
|
||||
low = makeMotor(haptics, .default)
|
||||
}
|
||||
if low == nil && high == nil {
|
||||
broken = true // no usable engine (e.g. Siri Remote) — stay silent
|
||||
if low == nil, high == nil {
|
||||
// Haptics present but no engine could be built right now (server busy / a transient
|
||||
// error). Do NOT latch broken — the next nonzero amplitude retries setup().
|
||||
log.warning("rumble: haptics present but engine setup failed — will retry on next rumble")
|
||||
}
|
||||
}
|
||||
|
||||
private func makeMotor(_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality) -> Motor? {
|
||||
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
|
||||
// The haptic server can stop or reset the engine out from under us — app backgrounding, an
|
||||
// audio-session interruption (a call, Siri, another audio app), or a server crash. Left
|
||||
// unhandled the players go dead and every later rumble throws, latching rumble off for the
|
||||
// rest of the session (the "rumble worked, then went spotty" failure). Tear down on the
|
||||
// serial queue so the next nonzero amplitude lazily rebuilds the engine, instead.
|
||||
engine.stoppedHandler = { [weak self] reason in
|
||||
log.info("rumble: haptic engine stopped (reason \(reason.rawValue, privacy: .public)) — will rebuild")
|
||||
self?.queue.async { self?.teardown() }
|
||||
}
|
||||
engine.resetHandler = { [weak self] in
|
||||
log.info("rumble: haptic engine reset — will rebuild")
|
||||
self?.queue.async { self?.teardown() }
|
||||
}
|
||||
do {
|
||||
try engine.start()
|
||||
let event = CHHapticEvent(
|
||||
@@ -141,14 +176,19 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
}
|
||||
motor = m
|
||||
} catch {
|
||||
log.warning("haptic update failed — rumble disabled: \(error, privacy: .public)")
|
||||
// A transient failure (the engine stopped/reset between its handler firing and now).
|
||||
// Tear down so the next nonzero amplitude rebuilds — do NOT latch rumble off for the
|
||||
// session (that was the old "spotty" behaviour).
|
||||
log.warning("rumble: haptic update failed — rebuilding: \(error, privacy: .public)")
|
||||
teardown()
|
||||
broken = true
|
||||
}
|
||||
}
|
||||
|
||||
private func teardown() {
|
||||
for m in [low, high].compactMap({ $0 }) {
|
||||
// Drop the handlers before stopping so stop() can't re-enter teardown via stoppedHandler.
|
||||
m.engine.stoppedHandler = nil
|
||||
m.engine.resetHandler = nil
|
||||
try? m.player.stop(atTime: CHHapticTimeImmediate)
|
||||
m.engine.stop()
|
||||
}
|
||||
|
||||
@@ -362,6 +362,21 @@ public final class PunktfunkConnection {
|
||||
_ = punktfunk_connection_request_keyframe(h)
|
||||
}
|
||||
|
||||
/// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
|
||||
/// rebuild them). The video pump polls this and calls `requestKeyframe()` when it climbs — the
|
||||
/// correct loss trigger under the host's infinite GOP, where unrecoverable loss yields
|
||||
/// reference-missing delta frames the decoder *silently conceals* (a frozen / garbage picture,
|
||||
/// no decode error and no `.failed` layer), so a decode-error trigger rarely fires. Monotonic
|
||||
/// for the session; 0 after close. Cheap (an atomic load) — safe to poll every pump iteration.
|
||||
public func framesDropped() -> UInt64 {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return 0 }
|
||||
var out: UInt64 = 0
|
||||
_ = punktfunk_connection_frames_dropped(h, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
/// The currently active session mode (updated by accepted `requestMode` switches).
|
||||
public func currentMode() -> (width: UInt32, height: UInt32, refreshHz: UInt32) {
|
||||
abiLock.lock()
|
||||
|
||||
@@ -113,8 +113,21 @@ public final class Stage2Pipeline {
|
||||
let recovery = recovery
|
||||
let thread = Thread {
|
||||
var format: CMVideoFormatDescription?
|
||||
var lastFramesDropped = connection.framesDropped()
|
||||
while token.isLive {
|
||||
do {
|
||||
// Loss recovery (the primary recovery path). The reassembler drops unrecoverable
|
||||
// AUs (framesDropped) and the decoder then conceals the reference-missing delta
|
||||
// frames that follow — often rendering them WITHOUT an error callback — so the
|
||||
// onDecodeError trigger rarely fires after a real network blip. Ask the host for
|
||||
// a fresh IDR whenever the drop count climbs (throttled in KeyframeRecovery).
|
||||
// Polled every iteration so a total-loss drought recovers the moment packets
|
||||
// resume and the reassembler counts the gap.
|
||||
let dropped = connection.framesDropped()
|
||||
if dropped > lastFramesDropped {
|
||||
lastFramesDropped = dropped
|
||||
recovery.request()
|
||||
}
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||
onFrame?(au)
|
||||
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
||||
|
||||
@@ -46,27 +46,44 @@ final class StreamPump {
|
||||
let thread = Thread {
|
||||
var format: CMVideoFormatDescription?
|
||||
var lastKeyframeRequest = Date.distantPast
|
||||
var lastFramesDropped = connection.framesDropped()
|
||||
// Coalesced host keyframe request: the decode stays wedged for several frames until
|
||||
// the IDR lands, so requesting on every frame would flood the control stream.
|
||||
func requestKeyframeThrottled() {
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastKeyframeRequest) > 0.25 {
|
||||
connection.requestKeyframe()
|
||||
lastKeyframeRequest = now
|
||||
}
|
||||
}
|
||||
while token.isLive {
|
||||
do {
|
||||
// Loss recovery (the primary recovery path). Under the host's infinite GOP the
|
||||
// only recovery keyframe is one we request. The reassembler drops unrecoverable
|
||||
// AUs (framesDropped); the decoder then *conceals* the reference-missing delta
|
||||
// frames that follow — a frozen / garbage picture, WITHOUT flipping the layer to
|
||||
// .failed — so the .failed check below rarely fires after a real network blip.
|
||||
// Ask the host for a fresh IDR whenever the drop count climbs. Polled every
|
||||
// iteration (not just per AU) so a total-loss drought still recovers the moment
|
||||
// packets resume and the reassembler counts the gap.
|
||||
let dropped = connection.framesDropped()
|
||||
if dropped > lastFramesDropped {
|
||||
lastFramesDropped = dropped
|
||||
requestKeyframeThrottled()
|
||||
}
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||
onFrame?(au)
|
||||
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
}
|
||||
if layer.status == .failed {
|
||||
// Decode wedged: flush and re-gate on the next in-band parameter sets
|
||||
// (resuming with a delta frame can't recover), AND ask the host for a
|
||||
// fresh IDR. With the host's infinite GOP the next keyframe could be
|
||||
// far off, so without the request the picture stays frozen — the
|
||||
// intermittent first-connect freeze. Throttled: the layer stays .failed
|
||||
// across several polls until the IDR lands, and one request suffices.
|
||||
// Decode wedged hard (the cold-first-connect case — a lost/corrupt opening
|
||||
// IDR): flush and re-gate on the next in-band parameter sets (resuming with
|
||||
// a delta frame can't recover), AND ask the host for a fresh IDR. Throttled:
|
||||
// the layer stays .failed across several polls until the IDR lands.
|
||||
layer.flush()
|
||||
format = AnnexB.formatDescription(fromIDR: au.data)
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastKeyframeRequest) > 0.25 {
|
||||
connection.requestKeyframe()
|
||||
lastKeyframeRequest = now
|
||||
}
|
||||
requestKeyframeThrottled()
|
||||
}
|
||||
guard let f = format,
|
||||
let sample = AnnexB.sampleBuffer(au: au, format: f),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Integration: the Swift wrapper against a real punktfunk/1 host over QUIC + UDP on loopback —
|
||||
// the Swift twin of punktfunk-host's m3.rs::c_abi_connection_roundtrip, this time through the
|
||||
// statically linked xcframework. Driven by clients/apple/test-loopback.sh, which builds and
|
||||
// starts `punktfunk-host m3-host --source synthetic` and sets PUNKTFUNK_LOOPBACK_PORT.
|
||||
// starts `punktfunk-host punktfunk1-host --source synthetic` and sets PUNKTFUNK_LOOPBACK_PORT.
|
||||
|
||||
import XCTest
|
||||
@testable import PunktfunkKit
|
||||
@@ -11,7 +11,7 @@ final class LoopbackIntegrationTests: XCTestCase {
|
||||
guard let portStr = ProcessInfo.processInfo.environment["PUNKTFUNK_LOOPBACK_PORT"],
|
||||
let port = UInt16(portStr)
|
||||
else {
|
||||
throw XCTSkip("needs a running m3-host — use clients/apple/test-loopback.sh")
|
||||
throw XCTSkip("needs a running punktfunk1-host — use clients/apple/test-loopback.sh")
|
||||
}
|
||||
|
||||
let conn = try PunktfunkConnection(
|
||||
@@ -139,7 +139,7 @@ final class LoopbackIntegrationTests: XCTestCase {
|
||||
guard let portStr = env["PUNKTFUNK_PAIRING_PORT"], let port = UInt16(portStr),
|
||||
let pin = env["PUNKTFUNK_PAIRING_PIN"]
|
||||
else {
|
||||
throw XCTSkip("needs an armed m3-host — use clients/apple/test-loopback.sh")
|
||||
throw XCTSkip("needs an armed punktfunk1-host — use clients/apple/test-loopback.sh")
|
||||
}
|
||||
|
||||
let identity = try generateIdentity()
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//
|
||||
// Run (host side, on the Linux box):
|
||||
// PUNKTFUNK_COMPOSITOR=gamescope PUNKTFUNK_GAMESCOPE_APP=vkcube PUNKTFUNK_ZEROCOPY=1 \
|
||||
// punktfunk-host m3-host --source virtual --seconds 120
|
||||
// punktfunk-host punktfunk1-host --source virtual --seconds 120
|
||||
// Then here:
|
||||
// PUNKTFUNK_REMOTE_HOST=192.168.1.70 swift test --filter RemoteFirstLightTests
|
||||
|
||||
@@ -54,7 +54,7 @@ final class RemoteFirstLightTests: XCTestCase {
|
||||
func testRemoteAudioBothDirections() throws {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
guard let host = env["PUNKTFUNK_REMOTE_HOST"] else {
|
||||
throw XCTSkip("set PUNKTFUNK_REMOTE_HOST (and start m3-host --source virtual there)")
|
||||
throw XCTSkip("set PUNKTFUNK_REMOTE_HOST (and start punktfunk1-host --source virtual there)")
|
||||
}
|
||||
let port = env["PUNKTFUNK_REMOTE_PORT"].flatMap(UInt16.init) ?? 9777
|
||||
|
||||
@@ -106,7 +106,7 @@ final class RemoteFirstLightTests: XCTestCase {
|
||||
func testRemoteStreamDecodesToPixels() throws {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
guard let host = env["PUNKTFUNK_REMOTE_HOST"] else {
|
||||
throw XCTSkip("set PUNKTFUNK_REMOTE_HOST (and start m3-host --source virtual there)")
|
||||
throw XCTSkip("set PUNKTFUNK_REMOTE_HOST (and start punktfunk1-host --source virtual there)")
|
||||
}
|
||||
let port = env["PUNKTFUNK_REMOTE_PORT"].flatMap(UInt16.init) ?? 9777
|
||||
// PUNKTFUNK_REMOTE_COMPOSITOR=kwin|gamescope|… asks the host for a specific
|
||||
|
||||
@@ -22,10 +22,10 @@ trap 'kill "${HOST_PID:-}" "${PAIR_PID:-}" 2>/dev/null || true' EXIT
|
||||
# The open host also scripts a feedback burst (rumble + DualSense hidout) right after the
|
||||
# handshake, so the Swift test can assert the host→client feedback planes end to end.
|
||||
HOME="$CFG/open" XDG_CONFIG_HOME="$CFG/open/.config" PUNKTFUNK_TEST_FEEDBACK=1 \
|
||||
target/release/punktfunk-host m3-host --port "$PORT" --source synthetic --frames 300 &
|
||||
target/release/punktfunk-host punktfunk1-host --port "$PORT" --source synthetic --frames 300 &
|
||||
HOST_PID=$!
|
||||
HOME="$CFG/paired" XDG_CONFIG_HOME="$CFG/paired/.config" \
|
||||
target/release/punktfunk-host m3-host --port "$PAIR_PORT" --source synthetic --frames 300 \
|
||||
target/release/punktfunk-host punktfunk1-host --port "$PAIR_PORT" --source synthetic --frames 300 \
|
||||
--require-pairing >"$PAIR_LOG" 2>&1 &
|
||||
PAIR_PID=$!
|
||||
sleep 1
|
||||
|
||||
@@ -81,7 +81,7 @@ argv and a clear `client-not-found` error surface to the UI. The child PID is tr
|
||||
installed and runnable on the Deck — via `.deb`/RPM/flatpak, or symlinked into
|
||||
`~/.local/bin`.
|
||||
- **avahi** (`avahi-daemon` + `avahi-browse`) for discovery — present on SteamOS/Bazzite.
|
||||
- A punktfunk/1 host on the LAN (`punktfunk-host serve --native` or `m3-host`).
|
||||
- A punktfunk/1 host on the LAN (`punktfunk-host serve --native` or `punktfunk1-host`).
|
||||
|
||||
## Build
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ path = "src/main.rs"
|
||||
# Everything is Linux-gated so `cargo build --workspace` stays green on macOS (the Mac
|
||||
# client lives in clients/apple); on other platforms this builds as a stub binary.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||
|
||||
# UI shell. GraphicsOffload needs GTK ≥ 4.14; black-background ≥ 4.16. AlertDialog/
|
||||
# PreferencesDialog need libadwaita ≥ 1.5.
|
||||
@@ -536,7 +536,17 @@ fn run(
|
||||
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
|
||||
if pad == 0 {
|
||||
if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) {
|
||||
let _ = p.set_rumble(low, high, 5_000);
|
||||
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in
|
||||
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
|
||||
// host logs the send side on 0xCA, so the two together pinpoint host-game vs
|
||||
// client-render.
|
||||
if let Err(e) = p.set_rumble(low, high, 5_000) {
|
||||
tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed");
|
||||
} else {
|
||||
tracing::debug!(low, high, "rumble: rendered");
|
||||
}
|
||||
} else {
|
||||
tracing::debug!(low, high, "rumble: received but no active pad to render");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Client identity, the known-hosts (pinned fingerprint) store, and app settings.
|
||||
//!
|
||||
//! The identity shares `~/.config/punktfunk/client-{cert,key}.pem` with `punktfunk-client-rs`
|
||||
//! The identity shares `~/.config/punktfunk/client-{cert,key}.pem` with `punktfunk-probe`
|
||||
//! so a box pairs once whichever client it uses.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "punktfunk-client-rs"
|
||||
description = "punktfunk reference client (M4): VAAPI decode + wgpu/Vulkan present"
|
||||
name = "punktfunk-probe"
|
||||
description = "punktfunk reference/probe client: headless punktfunk/1 client for testing + latency measurement"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
@@ -9,7 +9,7 @@ authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||
quinn = "0.11"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "macros"] }
|
||||
anyhow = "1"
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `punktfunk-client-rs` — the reference client for `punktfunk/1` (M3): QUIC control plane, UDP data
|
||||
//! `punktfunk-probe` — the reference client for `punktfunk/1` (M3): QUIC control plane, UDP data
|
||||
//! plane, input over QUIC datagrams. Two modes, decided by the host's Welcome:
|
||||
//!
|
||||
//! * **verification** (`frames > 0`, synthetic host): byte-checks deterministic test frames;
|
||||
@@ -35,7 +35,7 @@
|
||||
//! over mDNS, prints each (name, addr:port, pairing requirement, cert fingerprint to pin), and
|
||||
//! exits without connecting.
|
||||
//!
|
||||
//! Usage: `punktfunk-client-rs [--connect HOST:PORT] [--mode WxHxFPS] [--out FILE] [--input-test]
|
||||
//! Usage: `punktfunk-probe [--connect HOST:PORT] [--mode WxHxFPS] [--out FILE] [--input-test]
|
||||
//! [--pin HEX] [--compositor NAME] [--gamepad NAME] | --discover [SECS]`
|
||||
//! (M4 adds VAAPI decode + wgpu present on this skeleton.)
|
||||
|
||||
@@ -193,7 +193,7 @@ fn parse_args() -> Args {
|
||||
pin,
|
||||
remode,
|
||||
pair: get("--pair").map(String::from),
|
||||
name: get("--name").unwrap_or("punktfunk-client-rs").to_string(),
|
||||
name: get("--name").unwrap_or("punktfunk-probe").to_string(),
|
||||
compositor,
|
||||
gamepad,
|
||||
bitrate_kbps: get("--bitrate").and_then(|s| s.parse().ok()).unwrap_or(0),
|
||||
@@ -337,7 +337,7 @@ fn discover(secs: u64) -> Result<()> {
|
||||
println!("{row}");
|
||||
}
|
||||
println!(
|
||||
"\nconnect with: punktfunk-client-rs --connect <addr:port> [--pin <fp> | --pair <PIN>]"
|
||||
"\nconnect with: punktfunk-probe --connect <addr:port> [--pin <fp> | --pair <PIN>]"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
@@ -13,13 +13,13 @@ name = "punktfunk-client"
|
||||
path = "src/main.rs"
|
||||
|
||||
# Everything is Windows-gated so `cargo build --workspace` stays green on Linux/macOS (the
|
||||
# other native clients live in crates/punktfunk-client-linux and clients/apple); on other
|
||||
# other native clients live in clients/linux and clients/apple); on other
|
||||
# platforms this builds as a stub binary. Mirrors the Linux client's cfg(target_os="linux")
|
||||
# gating exactly.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# The protocol core, linked directly (no C ABI) — same as the GTK Linux client. NativeClient
|
||||
# is Sync (mutexed plane receivers), so it drops into a UI app cleanly.
|
||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||
|
||||
# WinUI 3 UI via windows-reactor (a declarative React-like framework backed by WinUI). Its
|
||||
# `build.rs` downloads the Windows App SDK NuGets and stages the bootstrap DLL + resources.pri
|
||||
@@ -71,7 +71,7 @@ On the Windows runner / dev VM (MSVC + Windows SDK present), after a release bui
|
||||
|
||||
```powershell
|
||||
cargo build --release -p punktfunk-client-windows
|
||||
pwsh -File crates/punktfunk-client-windows/packaging/pack-msix.ps1 `
|
||||
pwsh -File clients/windows/packaging/pack-msix.ps1 `
|
||||
-Version 0.2.0.0 -TargetDir C:\t\release -OutDir C:\t\msix
|
||||
```
|
||||
|
||||
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -499,7 +499,17 @@ fn run(
|
||||
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
|
||||
if pad == 0 {
|
||||
if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) {
|
||||
let _ = p.set_rumble(low, high, 5_000);
|
||||
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in
|
||||
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
|
||||
// host logs the send side on 0xCA, so the two together pinpoint host-game vs
|
||||
// client-render.
|
||||
if let Err(e) = p.set_rumble(low, high, 5_000) {
|
||||
tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed");
|
||||
} else {
|
||||
tracing::debug!(low, high, "rumble: rendered");
|
||||
}
|
||||
} else {
|
||||
tracing::debug!(low, high, "rumble: received but no active pad to render");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ fn main() {
|
||||
}
|
||||
|
||||
/// `--headless --connect host[:port] …`: connect from the CLI, count frames, print stats — the
|
||||
/// Windows analogue of `punktfunk-client-rs`.
|
||||
/// Windows analogue of `punktfunk-probe`.
|
||||
#[cfg(windows)]
|
||||
fn run_headless_cli(args: &[String], identity: (String, String)) {
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
@@ -241,18 +241,18 @@ fn discover_and_print() {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
if seen.is_empty() {
|
||||
println!(" (none found — is a host running with --native / m3-host?)");
|
||||
println!(" (none found — is a host running with --native / punktfunk1-host?)");
|
||||
}
|
||||
}
|
||||
|
||||
/// WinUI 3 / Direct3D11 / WASAPI / SDL3 are Windows turf; this stub keeps `cargo build
|
||||
/// --workspace` green on Linux/macOS (the other native clients live in
|
||||
/// crates/punktfunk-client-linux and clients/apple).
|
||||
/// clients/linux and clients/apple).
|
||||
#[cfg(not(windows))]
|
||||
fn main() {
|
||||
eprintln!(
|
||||
"punktfunk-client-windows is Windows-only — the Linux client lives in \
|
||||
crates/punktfunk-client-linux, the macOS client in clients/apple"
|
||||
clients/linux, the macOS client in clients/apple"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ repository.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "punktfunk_core"
|
||||
# `lib` — so punktfunk-host / punktfunk-client-rs / tools link it as a normal Rust crate.
|
||||
# `lib` — so punktfunk-host / punktfunk-probe / tools link it as a normal Rust crate.
|
||||
# `staticlib` — `libpunktfunk_core.a` for the C test harness and static embedding.
|
||||
# `cdylib` — `libpunktfunk_core.{so,dylib}` for Swift/Kotlin clients via the C ABI.
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
@@ -1494,6 +1494,35 @@ pub unsafe extern "C" fn punktfunk_connection_request_keyframe(
|
||||
})
|
||||
}
|
||||
|
||||
/// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
|
||||
/// rebuild them). A video loop polls this and calls [`punktfunk_connection_request_keyframe`]
|
||||
/// when it climbs — the correct loss trigger under the host's infinite GOP, where unrecoverable
|
||||
/// loss yields reference-missing delta frames the decoder *silently conceals* (frozen / garbage
|
||||
/// picture, no decode error), so a decode-error trigger rarely fires. Monotonic for the session;
|
||||
/// compare against the last observed value. Writes 0 to `out` on a NULL connection.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is writable (NULL is skipped).
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_frames_dropped(
|
||||
c: *const PunktfunkConnection,
|
||||
out: *mut u64,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
unsafe {
|
||||
if !out.is_null() {
|
||||
*out = c.inner.frames_dropped();
|
||||
}
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// A speed-test measurement, filled by [`punktfunk_connection_probe_result`]. `done` is 0 until
|
||||
/// the host's end-of-burst report lands, then 1 (the numbers are final). `throughput_kbps` is the
|
||||
/// measured goodput to drive a bitrate choice from; `loss_pct` is the delivery loss at that rate.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! The embeddable `punktfunk/1` client connector (M4 groundwork), behind the `quic` feature.
|
||||
//! The embeddable `punktfunk/1` client connector, behind the `quic` feature.
|
||||
//!
|
||||
//! [`NativeClient::connect`] runs the full client side of the protocol — QUIC handshake
|
||||
//! ([`crate::quic`]), UDP data plane ([`crate::session::Session`] on a native thread), input
|
||||
//! datagrams — and hands the embedder a dead-simple surface: *pull reassembled access units,
|
||||
//! push input events*. This is what the platform clients (SwiftUI/VideoToolbox, Android, …)
|
||||
//! link via the C ABI (`punktfunk_connect` & co. in [`crate::abi`]); `punktfunk-client-rs` is the
|
||||
//! link via the C ABI (`punktfunk_connect` & co. in [`crate::abi`]); `punktfunk-probe` is the
|
||||
//! Rust-native consumer of the same flow.
|
||||
//!
|
||||
//! Threading: one worker thread owns a tokio runtime (QUIC control plane only — design
|
||||
@@ -166,7 +166,7 @@ pub struct NativeClient {
|
||||
/// kernel sees a high-QoS thread parked waiting on a lower-QoS one and the Thread Performance
|
||||
/// Checker flags a priority inversion. Matching the producers to the consumers' QoS removes
|
||||
/// the inversion without slowing the Swift side. No-op off Apple (the Linux client/host don't
|
||||
/// run a QoS scheduler, and `punktfunk-client-rs` doesn't care).
|
||||
/// run a QoS scheduler, and `punktfunk-probe` doesn't care).
|
||||
#[cfg(target_vendor = "apple")]
|
||||
fn pin_thread_user_interactive() {
|
||||
// SAFETY: sets only the current thread's QoS class — always valid to call.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! `frame_index`↔`frameIndex`, `stream_seq`↔`streamPacketIndex`,
|
||||
//! (`block_index`,`block_count`)↔the `multiFecBlocks` nibbles, and
|
||||
//! (`data_shards`,`recovery_shards`,`shard_index`)↔the `fecInfo` bitfield. We carry them
|
||||
//! as explicit fields rather than bit-packing; full GameStream wire-exactness is an M2
|
||||
//! as explicit fields rather than bit-packing; full GameStream wire-exactness is a GameStream-host
|
||||
//! concern (it also needs RTP framing + RTSP), this is the coherent internal format.
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `punktfunk/1` — the native control plane (M3), gated behind the `quic` feature.
|
||||
//! `punktfunk/1` — the native control plane, gated behind the `quic` feature.
|
||||
//!
|
||||
//! GameStream is punktfunk's compatibility layer; this is the start of its own protocol. A QUIC
|
||||
//! connection (quinn, tokio — control plane only, never the per-frame path) carries a
|
||||
@@ -12,9 +12,9 @@
|
||||
//!
|
||||
//! after which both sides bring up a [`crate::session::Session`] over a plain
|
||||
//! [`UdpTransport`](crate::transport::udp) (native threads, no async) and the host streams.
|
||||
//! The Welcome carries everything the M1 core negotiates — FEC scheme (including GF(2¹⁶)
|
||||
//! The Welcome carries everything the core negotiates — FEC scheme (including GF(2¹⁶)
|
||||
//! Leopard, which GameStream can't express), shard sizing, crypto key/salt — so the data
|
||||
//! plane is exactly the hardened M1 `Session`.
|
||||
//! plane is exactly the hardened core `Session`.
|
||||
//!
|
||||
//! Transport security: the host presents a long-lived self-signed certificate
|
||||
//! ([`endpoint::server_with_identity`]) and the client pins its SHA-256 fingerprint
|
||||
|
||||
@@ -31,7 +31,7 @@ pub struct Frame {
|
||||
/// Note: the AEAD layer authenticates each datagram but does **not** provide anti-replay.
|
||||
/// Video replays are largely absorbed by the reassembler's per-frame dedup, but replayed
|
||||
/// input events are not yet filtered. A sliding-window replay filter keyed on the
|
||||
/// authenticated sequence belongs with the pairing/handshake layer (M2); until then,
|
||||
/// authenticated sequence belongs with the pairing/handshake layer (the GameStream host); until then,
|
||||
/// rely on the LAN/VPN transport assumption (plan §1).
|
||||
pub struct Session {
|
||||
config: Config,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Send is batched via `sendmmsg` ([`Transport::send_batch`], ≤64/syscall) and recv via `recvmmsg`
|
||||
//! ([`Transport::recv_batch`], ≤32/syscall into a reused ring) — the 1 Gbps+ syscall lever
|
||||
//! (~125k → a few-k syscalls/sec at line rate). The host additionally paces each frame's send
|
||||
//! across the frame interval (see `m3.rs::paced_submit`) so a real NIC doesn't drop a line-rate
|
||||
//! across the frame interval (see `punktfunk1.rs::paced_submit`) so a real NIC doesn't drop a line-rate
|
||||
//! burst. All three layer on this same [`Transport`] seam (scalar fallbacks for loopback/non-Linux).
|
||||
|
||||
use super::Transport;
|
||||
@@ -397,7 +397,7 @@ impl UdpTransport {
|
||||
/// Sized for 1 Gbps+: at ~1.2 Gbps on the wire an 8 MB buffer is only ~49 ms of steady state,
|
||||
/// and a single multi-MB IDR keyframe (~4 MB ≈ 3300 packets) instantly fills most of it. 32 MB
|
||||
/// gives ~200 ms of headroom and absorbs a keyframe burst without EAGAIN drops. (Paced sending
|
||||
/// — `m3.rs::paced_submit` — now spreads a big frame's overflow, so this buffer mostly absorbs
|
||||
/// — `punktfunk1.rs::paced_submit` — now spreads a big frame's overflow, so this buffer mostly absorbs
|
||||
/// the immediate microburst rather than a whole unpaced frame.)
|
||||
const TARGET_SOCKBUF: usize = 32 * 1024 * 1024;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! M1 acceptance: round-trip access units through the full host→client path
|
||||
//! Core acceptance: round-trip access units through the full host→client path
|
||||
//! (packetize → FEC → loopback with simulated loss → recover → reassemble) and assert
|
||||
//! byte-exact recovery, for both FEC schemes, with and without encryption. Plus
|
||||
//! property tests over the FEC layer's loss patterns.
|
||||
|
||||
@@ -8,7 +8,7 @@ use anyhow::Result;
|
||||
|
||||
/// Opus/GameStream audio is 48 kHz.
|
||||
pub const SAMPLE_RATE: u32 = 48_000;
|
||||
/// Stereo channel count — the default and the punktfunk/1 (M3) audio plane's fixed layout.
|
||||
/// Stereo channel count — the default and the punktfunk/1 audio plane's fixed layout.
|
||||
pub const CHANNELS: usize = 2;
|
||||
|
||||
/// Produces interleaved `f32` PCM at [`SAMPLE_RATE`] in the channel count it was opened
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Frame capture (plan §7). On Linux: a PipeWire ScreenCast portal stream. M0 uses the
|
||||
//! Frame capture (plan §7). On Linux: a PipeWire ScreenCast portal stream. The spike uses the
|
||||
//! CPU-copy fallback (the portal delivers a CPU buffer; the encoder uploads it to the GPU
|
||||
//! internally). Zero-copy dmabuf→NVENC import is deferred (plan §9 risk).
|
||||
|
||||
@@ -45,7 +45,7 @@ impl PixelFormat {
|
||||
}
|
||||
|
||||
/// A captured frame. [`format`](Self::format)/dimensions describe the pixels regardless of
|
||||
/// where they live — [`payload`](Self::payload) is either a CPU buffer (the M0/fallback path)
|
||||
/// where they live — [`payload`](Self::payload) is either a CPU buffer (the spike/fallback path)
|
||||
/// or a GPU buffer already on the device (the zero-copy path, plan §9).
|
||||
pub struct CapturedFrame {
|
||||
pub width: u32,
|
||||
@@ -103,7 +103,7 @@ pub trait Capturer: Send {
|
||||
fn set_active(&self, _active: bool) {}
|
||||
}
|
||||
|
||||
/// A deterministic moving test pattern (BGRx). Lets M0 exercise the encode → file →
|
||||
/// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file →
|
||||
/// `punktfunk_core` path with no live capture session, and produces obviously non-static
|
||||
/// content (a sweeping bar + animated gradient) so the encoded output is verifiable.
|
||||
pub struct SyntheticCapturer {
|
||||
|
||||
@@ -1319,7 +1319,7 @@ pub struct DuplCapturer {
|
||||
ever_got_frame: bool,
|
||||
/// Consecutive rebuilds that produced a BORN-LOST duplication (created OK, but its first
|
||||
/// AcquireNextFrame instantly returned ACCESS_LOST). On the NORMAL desktop this is the hybrid
|
||||
/// reparent/flip storm — once it persists, `acquire` returns Err so the m3 loop cold-rebuilds the
|
||||
/// reparent/flip storm — once it persists, `acquire` returns Err so the punktfunk1 loop cold-rebuilds the
|
||||
/// whole pipeline (new device/output) instead of spinning on a dead dup forever (the bug where the
|
||||
/// stream froze on the last frame). Reset to 0 by any real frame. NOT armed on the secure
|
||||
/// (Winlogon) desktop, where a long static dwell is legitimate and must never end the session.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! docs/windows-secure-desktop.md — step 4).
|
||||
//!
|
||||
//! WGC won't activate under the SYSTEM account, so the SYSTEM host can't capture the normal desktop
|
||||
//! itself. Instead it spawns `m3-host wgc-helper` in the **interactive user session** (so WGC works)
|
||||
//! itself. Instead it spawns `punktfunk-host wgc-helper` in the **interactive user session** (so WGC works)
|
||||
//! via `CreateProcessAsUserW`, with the helper's **stdout** redirected to an anonymous pipe the host
|
||||
//! reads. The helper ships framed Annex-B access units; this module parses them back into AUs the
|
||||
//! host relays onto the live QUIC session (same `EncodedFrame` flow, just sourced over a pipe instead
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Hardware video encode (plan §7). Binds FFmpeg (NVENC); never rewrites codecs.
|
||||
//! Low-latency preset, B-frames off. M0 feeds BGRx CPU frames directly — `*_nvenc`
|
||||
//! Low-latency preset, B-frames off. The spike feeds BGRx CPU frames directly — `*_nvenc`
|
||||
//! accepts `bgr0` input and converts to YUV on the GPU, so no host-side swscale is
|
||||
//! needed (dmabuf zero-copy import is deferred; plan §9).
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! GameStream (P1) control plane — what a stock Moonlight/Artemis client talks to around
|
||||
//! the media streams: mDNS discovery, the nvhttp serverinfo + pairing HTTP(S) API, RTSP,
|
||||
//! and the ENet control stream. `tokio`/`axum` live here (control plane, I/O-bound — never
|
||||
//! the per-frame hot path; that is `punktfunk_core`'s P1 wire codec). See `docs/m2-plan.md`.
|
||||
//! the per-frame hot path; that is `punktfunk_core`'s P1 wire codec). See `docs/gamestream-host-plan.md`.
|
||||
//!
|
||||
//! Status: P1.1 — mDNS `_nvstream._tcp` advertisement + `/serverinfo`. Pairing, RTSP, and
|
||||
//! the media streams follow (see the M2 task list / plan).
|
||||
//! the media streams follow (see the GameStream host task list / plan).
|
||||
|
||||
pub mod apps;
|
||||
// Platform-neutral wire/negotiation logic + the Linux capture/encode pipeline (non-Linux
|
||||
@@ -149,7 +149,10 @@ impl AppState {
|
||||
/// QUIC server on `cfg.port` in the same process, sharing one [`crate::native_pairing`] handle with
|
||||
/// the management API so the web console can arm pairing and show the PIN. `None` = GameStream only
|
||||
/// (the mgmt API's native endpoints report `enabled: false`).
|
||||
pub fn serve(mgmt: crate::mgmt::Options, native: Option<crate::m3::NativeServe>) -> Result<()> {
|
||||
pub fn serve(
|
||||
mgmt: crate::mgmt::Options,
|
||||
native: Option<crate::punktfunk1::NativeServe>,
|
||||
) -> Result<()> {
|
||||
let host = Host::detect()?;
|
||||
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
|
||||
let state = Arc::new(AppState::new(host, identity));
|
||||
@@ -187,7 +190,7 @@ pub fn serve(mgmt: crate::mgmt::Options, native: Option<crate::m3::NativeServe>)
|
||||
tokio::try_join!(
|
||||
nvhttp::run(state.clone()),
|
||||
crate::mgmt::run(state.clone(), mgmt, Some(np.clone())),
|
||||
crate::m3::serve(crate::m3::native_serve_opts(&cfg), np),
|
||||
crate::punktfunk1::serve(crate::punktfunk1::native_serve_opts(&cfg), np),
|
||||
)?;
|
||||
}
|
||||
_ => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The video data plane: on RTSP PLAY, learn the client's UDP endpoint (it pings the video
|
||||
//! port), then run capture → NVENC encode → [`VideoPacketizer`] → UDP send. The source is
|
||||
//! either real portal desktop capture (`PUNKTFUNK_VIDEO_SOURCE=portal`, the M0 PipeWire path) or
|
||||
//! either real portal desktop capture (`PUNKTFUNK_VIDEO_SOURCE=portal`, the portal PipeWire path) or
|
||||
//! a synthetic test pattern (default). Runs on its own native thread.
|
||||
|
||||
use super::video::{FrameType, VideoPacketizer};
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
//! `#[cfg(target_os = "linux")]`; the crate compiles everywhere so the workspace builds
|
||||
//! on non-Linux dev machines — it just can't run the pipeline there.
|
||||
//!
|
||||
//! Status: M0. The `m0` subcommand runs the capture→encode→file pipeline spike and feeds
|
||||
//! the encoded AUs through a `punktfunk_core` loopback. M2 wires the full P1 host that a stock
|
||||
//! Moonlight client connects to.
|
||||
//! Subcommands: `serve` runs the GameStream-compatible host + management REST API (and, with
|
||||
//! `--native`, the native punktfunk/1 host in-process); `punktfunk1-host` runs the native
|
||||
//! punktfunk/1 host standalone; `spike` is a capture→encode→file pipeline dev tool that also
|
||||
//! round-trips the encoded AUs through a `punktfunk_core` loopback.
|
||||
|
||||
// Scaffold: trait methods and config paths are defined ahead of their backends.
|
||||
#![allow(dead_code)]
|
||||
@@ -24,15 +25,15 @@ mod encode;
|
||||
mod gamestream;
|
||||
mod inject;
|
||||
mod library;
|
||||
mod m0;
|
||||
mod m3;
|
||||
mod mgmt;
|
||||
mod mgmt_token;
|
||||
mod native_pairing;
|
||||
mod pipeline;
|
||||
mod punktfunk1;
|
||||
mod pwinit;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod service;
|
||||
mod spike;
|
||||
mod vdisplay;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod wgc_helper;
|
||||
@@ -41,7 +42,7 @@ mod zerocopy;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use encode::Codec;
|
||||
use m0::{Options, Source};
|
||||
use spike::{Options, Source};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
@@ -185,10 +186,10 @@ fn real_main() -> Result<()> {
|
||||
println!("dualsense-test: done");
|
||||
Ok(())
|
||||
}
|
||||
// M0 pipeline spike.
|
||||
Some("m0") => m0::run(parse_m0(&args[1..])?),
|
||||
// M3: native punktfunk/1 host (QUIC control plane + UDP data plane).
|
||||
Some("m3-host") => {
|
||||
// Capture→encode→file pipeline spike (dev tool).
|
||||
Some("spike") => spike::run(parse_spike(&args[1..])?),
|
||||
// Native punktfunk/1 host (QUIC control plane + UDP data plane).
|
||||
Some("punktfunk1-host") => {
|
||||
let get = |flag: &str| {
|
||||
args.iter()
|
||||
.skip_while(|a| *a != flag)
|
||||
@@ -196,10 +197,10 @@ fn real_main() -> Result<()> {
|
||||
.map(String::as_str)
|
||||
};
|
||||
let source = match get("--source") {
|
||||
Some("virtual") => m3::M3Source::Virtual,
|
||||
_ => m3::M3Source::Synthetic,
|
||||
Some("virtual") => punktfunk1::Punktfunk1Source::Virtual,
|
||||
_ => punktfunk1::Punktfunk1Source::Synthetic,
|
||||
};
|
||||
m3::run(m3::M3Options {
|
||||
punktfunk1::run(punktfunk1::Punktfunk1Options {
|
||||
port: get("--port").and_then(|s| s.parse().ok()).unwrap_or(9777),
|
||||
source,
|
||||
seconds: get("--seconds").and_then(|s| s.parse().ok()).unwrap_or(30),
|
||||
@@ -209,7 +210,7 @@ fn real_main() -> Result<()> {
|
||||
.unwrap_or(0),
|
||||
max_concurrent: get("--max-concurrent")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(m3::DEFAULT_MAX_CONCURRENT),
|
||||
.unwrap_or(punktfunk1::DEFAULT_MAX_CONCURRENT),
|
||||
// Secure by default: REQUIRE PIN pairing (reject unpaired clients) unless
|
||||
// --allow-tofu opts into trust-on-first-use — the host then accepts unpaired
|
||||
// clients and advertises pair=optional. Pairing is always armed so a PIN is
|
||||
@@ -259,8 +260,9 @@ fn real_main() -> Result<()> {
|
||||
print_usage();
|
||||
Ok(())
|
||||
}
|
||||
// Bare flags (no subcommand) default to the m0 spike for back-compat.
|
||||
Some(_) => m0::run(parse_m0(&args)?),
|
||||
// Unknown subcommand → usage. (No implicit default; a bare `punktfunk-host` with no
|
||||
// args hits the None arm above and prints help.)
|
||||
Some(other) => bail!("unknown command '{other}' (try --help)"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +322,7 @@ fn input_test() -> Result<()> {
|
||||
/// the native punktfunk/1 host in-process (`--native`, the unified host). Returns the mgmt options
|
||||
/// and the native host config (`None` = GameStream only). Native pairing is **required by default**
|
||||
/// (an open host any LAN device can stream from is insecure); `--open` turns it off.
|
||||
fn parse_serve(args: &[String]) -> Result<(mgmt::Options, Option<m3::NativeServe>)> {
|
||||
fn parse_serve(args: &[String]) -> Result<(mgmt::Options, Option<punktfunk1::NativeServe>)> {
|
||||
let mut opts = mgmt::Options::default();
|
||||
let mut native_port: Option<u16> = None;
|
||||
let mut open = false;
|
||||
@@ -377,14 +379,14 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, Option<m3::NativeServe
|
||||
if opts.token.is_none() {
|
||||
opts.token = Some(crate::mgmt_token::load_or_generate()?);
|
||||
}
|
||||
let native = native_port.map(|port| m3::NativeServe {
|
||||
let native = native_port.map(|port| punktfunk1::NativeServe {
|
||||
port,
|
||||
require_pairing: !open,
|
||||
});
|
||||
Ok((opts, native))
|
||||
}
|
||||
|
||||
fn parse_m0(args: &[String]) -> Result<Options> {
|
||||
fn parse_spike(args: &[String]) -> Result<Options> {
|
||||
let mut source = Source::Portal;
|
||||
let mut width = 1920u32;
|
||||
let mut height = 1080u32;
|
||||
@@ -465,7 +467,7 @@ fn parse_m0(args: &[String]) -> Result<Options> {
|
||||
Codec::H265 => "h265",
|
||||
Codec::Av1 => "obu",
|
||||
};
|
||||
PathBuf::from(format!("/tmp/punktfunk-m0.{ext}"))
|
||||
PathBuf::from(format!("/tmp/punktfunk-spike.{ext}"))
|
||||
});
|
||||
|
||||
Ok(Options {
|
||||
@@ -486,12 +488,12 @@ fn print_usage() {
|
||||
"punktfunk-host — Linux streaming host
|
||||
|
||||
USAGE:
|
||||
punktfunk-host serve [OPTIONS] GameStream host control plane (M2: mDNS + serverinfo …)
|
||||
+ the management REST API
|
||||
punktfunk-host openapi print the management API's OpenAPI document (codegen)
|
||||
punktfunk-host m3-host [OPTIONS] native punktfunk/1 host (QUIC control plane + UDP data plane)
|
||||
punktfunk-host probe-compositor exit 0 iff the compositor is up + ready (session-bringup gate)
|
||||
punktfunk-host m0 [OPTIONS] M0 capture→encode→file pipeline spike
|
||||
punktfunk-host serve [OPTIONS] GameStream host control plane (mDNS + serverinfo …)
|
||||
+ the management REST API
|
||||
punktfunk-host openapi print the management API's OpenAPI document (codegen)
|
||||
punktfunk-host punktfunk1-host [OPTIONS] native punktfunk/1 host (QUIC control + UDP data plane)
|
||||
punktfunk-host probe-compositor exit 0 iff the compositor is up + ready (bringup gate)
|
||||
punktfunk-host spike [OPTIONS] capture→encode→file pipeline spike (dev tool)
|
||||
|
||||
SERVE OPTIONS:
|
||||
--mgmt-bind <IP:PORT> management API address (default: 127.0.0.1:47990)
|
||||
@@ -503,7 +505,7 @@ SERVE OPTIONS:
|
||||
--open disable mandatory native pairing (default: pairing REQUIRED —
|
||||
an open host any LAN device can stream from is insecure)
|
||||
|
||||
M3-HOST OPTIONS:
|
||||
PUNKTFUNK1-HOST OPTIONS:
|
||||
--port <N> QUIC listen port (default: 9777)
|
||||
--source <synthetic|virtual> test frames, or virtual display + NVENC (default: synthetic)
|
||||
--seconds <N> per-session stream duration, virtual source (default: 30)
|
||||
@@ -516,7 +518,7 @@ M3-HOST OPTIONS:
|
||||
unpaired clients and logs a 4-digit pairing PIN at startup;
|
||||
TOFU without pairing is insecure on a LAN
|
||||
|
||||
M0 OPTIONS:
|
||||
SPIKE OPTIONS:
|
||||
--source <synthetic|portal|kwin-virtual>
|
||||
frame source (default: portal). 'kwin-virtual' creates a
|
||||
KWin virtual output at --width x --height and captures it
|
||||
@@ -525,7 +527,7 @@ M0 OPTIONS:
|
||||
--codec <h264|h265|av1> NVENC codec (default: h265)
|
||||
--bitrate <MBPS> target bitrate in Mbps (default: 20)
|
||||
--width <W> --height <H> synthetic source size (default: 1920x1080)
|
||||
--out <PATH> raw Annex-B output (default: /tmp/punktfunk-m0.<ext>)
|
||||
--out <PATH> raw Annex-B output (default: /tmp/punktfunk-spike.<ext>)
|
||||
--no-loopback skip the punktfunk_core round-trip verification
|
||||
-h, --help this help
|
||||
|
||||
@@ -534,8 +536,8 @@ NOTES:
|
||||
(see docs/linux-setup.md). 'synthetic' needs no capture session and always runs.
|
||||
Encoded AUs are written to a playable file AND (unless --no-loopback) fed through a
|
||||
punktfunk_core host→client loopback that reassembles and byte-verifies each one.
|
||||
Both 'serve --native' and 'm3-host' advertise the native service over mDNS
|
||||
(_punktfunk._udp) for client auto-discovery — 'punktfunk-client-rs --discover' lists them."
|
||||
Both 'serve --native' and 'punktfunk1-host' advertise the native service over mDNS
|
||||
(_punktfunk._udp) for client auto-discovery — 'punktfunk-probe --discover' lists them."
|
||||
);
|
||||
#[cfg(target_os = "windows")]
|
||||
eprintln!(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Shared native (`punktfunk/1`) pairing state — the on-demand arming PIN (with expiry) plus the
|
||||
//! persistent paired-clients store. One [`NativePairing`] handle is shared by the punktfunk/1 QUIC
|
||||
//! accept loop ([`crate::m3`]) and the management API ([`crate::mgmt`]), so an operator can **arm
|
||||
//! accept loop ([`crate::punktfunk1`]) and the management API ([`crate::mgmt`]), so an operator can **arm
|
||||
//! pairing and read the PIN from the web console** instead of the service log.
|
||||
//!
|
||||
//! The PIN direction is inherent to the SPAKE2 ceremony: the *host* mints the PIN and the *client*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! M3 — the `punktfunk/1` native host: QUIC control plane + the hardened M1 data plane over UDP.
|
||||
//! The `punktfunk/1` native host: QUIC control plane + the hardened core data plane over UDP.
|
||||
//! This is punktfunk's own protocol, past the GameStream compatibility layer:
|
||||
//!
|
||||
//! * the Welcome negotiates **GF(2¹⁶) Leopard FEC** (inexpressible in GameStream) + AES-GCM;
|
||||
@@ -9,9 +9,9 @@
|
||||
//! * video frames carry a wall-clock `pts_ns`, so a same-host client measures the full
|
||||
//! capture→encode→FEC→UDP→reassemble latency per frame.
|
||||
//!
|
||||
//! `punktfunk-host m3-host [--port 9777] [--source synthetic|virtual] [--seconds 30]
|
||||
//! `punktfunk-host punktfunk1-host [--port 9777] [--source synthetic|virtual] [--seconds 30]
|
||||
//! [--frames 300]` serves sessions back to back (one at a time — the virtual output and
|
||||
//! encoder are single-tenant); `punktfunk-client-rs --connect host:9777` is the counterpart.
|
||||
//! encoder are single-tenant); `punktfunk-probe --connect host:9777` is the counterpart.
|
||||
//! The data plane runs on native threads (no async on the frame path).
|
||||
//!
|
||||
//! Alongside video + input, a session carries **audio** (desktop Opus, 5 ms frames, host →
|
||||
@@ -37,16 +37,16 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum M3Source {
|
||||
pub enum Punktfunk1Source {
|
||||
/// Deterministic test frames (protocol verification; the client byte-checks them).
|
||||
Synthetic,
|
||||
/// Real capture: virtual display at the client's requested mode → NVENC.
|
||||
Virtual,
|
||||
}
|
||||
|
||||
pub struct M3Options {
|
||||
pub struct Punktfunk1Options {
|
||||
pub port: u16,
|
||||
pub source: M3Source,
|
||||
pub source: Punktfunk1Source,
|
||||
/// Virtual-source stream duration.
|
||||
pub seconds: u32,
|
||||
/// Synthetic-source frame count.
|
||||
@@ -97,7 +97,7 @@ fn now_ns() -> u64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn run(opts: M3Options) -> Result<()> {
|
||||
pub fn run(opts: Punktfunk1Options) -> Result<()> {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
@@ -138,10 +138,10 @@ pub(crate) struct NativeServe {
|
||||
/// overflow clients wait in the accept queue. Override with `--max-concurrent`.
|
||||
pub(crate) const DEFAULT_MAX_CONCURRENT: usize = 4;
|
||||
|
||||
pub(crate) fn native_serve_opts(cfg: &NativeServe) -> M3Options {
|
||||
M3Options {
|
||||
pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options {
|
||||
Punktfunk1Options {
|
||||
port: cfg.port,
|
||||
source: M3Source::Virtual,
|
||||
source: Punktfunk1Source::Virtual,
|
||||
seconds: 7 * 24 * 3600, // per-session cap; large enough not to cut a live stream
|
||||
frames: 0,
|
||||
max_sessions: 0,
|
||||
@@ -153,7 +153,7 @@ pub(crate) fn native_serve_opts(cfg: &NativeServe) -> M3Options {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn serve(opts: M3Options, np: Arc<NativePairing>) -> Result<()> {
|
||||
pub(crate) async fn serve(opts: Punktfunk1Options, np: Arc<NativePairing>) -> Result<()> {
|
||||
let identity = crate::gamestream::cert::ServerIdentity::load_or_create()
|
||||
.context("load host identity (~/.config/punktfunk)")?;
|
||||
let fingerprint = endpoint::fingerprint_of_pem(&identity.cert_pem)
|
||||
@@ -427,7 +427,7 @@ async fn pair_ceremony(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn serve_session(
|
||||
conn: quinn::Connection,
|
||||
opts: &M3Options,
|
||||
opts: &Punktfunk1Options,
|
||||
audio_cap: &AudioCapSlot,
|
||||
inj_tx: std::sync::mpsc::Sender<InputEvent>,
|
||||
mic_tx: std::sync::mpsc::Sender<Vec<u8>>,
|
||||
@@ -514,7 +514,7 @@ async fn serve_session(
|
||||
// can report what we'll actually drive. Only the Virtual source has a compositor; the
|
||||
// synthetic source has no virtual output. Blocking probes → spawn_blocking.
|
||||
let compositor = match source {
|
||||
M3Source::Virtual => {
|
||||
Punktfunk1Source::Virtual => {
|
||||
let pref = hello.compositor;
|
||||
Some(
|
||||
tokio::task::spawn_blocking(move || resolve_compositor(pref))
|
||||
@@ -522,7 +522,7 @@ async fn serve_session(
|
||||
.context("resolve compositor task")??,
|
||||
)
|
||||
}
|
||||
M3Source::Synthetic => None,
|
||||
Punktfunk1Source::Synthetic => None,
|
||||
};
|
||||
|
||||
// Resolve a requested library launch (the client sends only the store-qualified id;
|
||||
@@ -600,8 +600,8 @@ async fn serve_session(
|
||||
key,
|
||||
salt: *b"pkf1",
|
||||
frames: match source {
|
||||
M3Source::Synthetic => frames,
|
||||
M3Source::Virtual => 0, // unbounded — client streams until we close
|
||||
Punktfunk1Source::Synthetic => frames,
|
||||
Punktfunk1Source::Virtual => 0, // unbounded — client streams until we close
|
||||
},
|
||||
// Report the resolved backends back to the client (compositor: Auto for the
|
||||
// synthetic source).
|
||||
@@ -726,7 +726,7 @@ async fn serve_session(
|
||||
let conn = conn.clone();
|
||||
let gamepad = welcome.gamepad;
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-m3-input".into())
|
||||
.name("punktfunk1-input".into())
|
||||
.spawn(move || input_thread(input_rx, rich_rx, conn, inj_tx, gamepad))
|
||||
.context("spawn input thread")?
|
||||
};
|
||||
@@ -778,12 +778,12 @@ async fn serve_session(
|
||||
// → host→client QUIC datagrams, on its own native thread. Best-effort on every failure
|
||||
// (no PipeWire audio, spawn error): the session continues without audio — and a spawn
|
||||
// error must NOT early-return here, the threads above are already running.
|
||||
let audio_handle = if opts.source == M3Source::Virtual {
|
||||
let audio_handle = if opts.source == Punktfunk1Source::Virtual {
|
||||
let conn = conn.clone();
|
||||
let stop = stop.clone();
|
||||
let cap = audio_cap.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-m3-audio".into())
|
||||
.name("punktfunk1-audio".into())
|
||||
.spawn(move || audio_thread(conn, stop, cap))
|
||||
.map_err(|e| tracing::error!(error = %e, "audio thread spawn failed — session continues without audio"))
|
||||
.ok()
|
||||
@@ -794,7 +794,7 @@ async fn serve_session(
|
||||
// Test hook (synthetic source only): a scripted feedback burst on the host→client
|
||||
// planes — rumble (0xCA) + DualSense HID-output (0xCD) — so loopback tests can assert
|
||||
// the client's feedback path without a real game writing output reports to a real pad.
|
||||
if opts.source == M3Source::Synthetic
|
||||
if opts.source == Punktfunk1Source::Synthetic
|
||||
&& std::env::var("PUNKTFUNK_TEST_FEEDBACK").as_deref() == Ok("1")
|
||||
{
|
||||
use punktfunk_core::quic::HidOutput;
|
||||
@@ -852,14 +852,14 @@ async fn serve_session(
|
||||
let mut session = Session::new(cfg, Box::new(transport))
|
||||
.map_err(|e| anyhow!("host session: {e:?}"))?;
|
||||
match source {
|
||||
M3Source::Synthetic => synthetic_stream(
|
||||
Punktfunk1Source::Synthetic => synthetic_stream(
|
||||
&mut session,
|
||||
frames,
|
||||
&stop_stream,
|
||||
&probe_rx,
|
||||
&probe_result_tx,
|
||||
),
|
||||
M3Source::Virtual => {
|
||||
Punktfunk1Source::Virtual => {
|
||||
let compositor = compositor
|
||||
.expect("the Virtual source resolves a compositor during the handshake");
|
||||
virtual_stream(
|
||||
@@ -986,7 +986,7 @@ impl InjectorService {
|
||||
fn start() -> InjectorService {
|
||||
let (tx, rx) = std::sync::mpsc::channel::<InputEvent>();
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("punktfunk-m3-injector".into())
|
||||
.name("punktfunk1-injector".into())
|
||||
.spawn(move || injector_service_thread(rx))
|
||||
{
|
||||
tracing::error!(error = %e, "injector service thread spawn failed — pointer/keyboard input disabled");
|
||||
@@ -1080,7 +1080,7 @@ impl MicService {
|
||||
fn start() -> MicService {
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("punktfunk-m3-mic".into())
|
||||
.name("punktfunk1-mic".into())
|
||||
.spawn(move || mic_service_thread(rx))
|
||||
{
|
||||
tracing::error!(error = %e, "mic service thread spawn failed — mic passthrough disabled");
|
||||
@@ -2117,7 +2117,7 @@ fn virtual_stream(
|
||||
let _watcher = if watch {
|
||||
let stop = stop.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-m3-watcher".into())
|
||||
.name("punktfunk1-watcher".into())
|
||||
.spawn(move || session_watcher_loop(session_tx, stop))
|
||||
.ok()
|
||||
} else {
|
||||
@@ -3014,9 +3014,9 @@ mod tests {
|
||||
use punktfunk_core::error::PunktfunkStatus;
|
||||
|
||||
let host = std::thread::spawn(|| {
|
||||
run(M3Options {
|
||||
run(Punktfunk1Options {
|
||||
port: 19777,
|
||||
source: M3Source::Synthetic,
|
||||
source: Punktfunk1Source::Synthetic,
|
||||
seconds: 0,
|
||||
frames: 25,
|
||||
max_sessions: 3,
|
||||
@@ -3182,9 +3182,9 @@ mod tests {
|
||||
.build()
|
||||
.unwrap();
|
||||
rt.block_on(serve(
|
||||
M3Options {
|
||||
Punktfunk1Options {
|
||||
port: 19779,
|
||||
source: M3Source::Synthetic,
|
||||
source: Punktfunk1Source::Synthetic,
|
||||
seconds: 0,
|
||||
frames: 25,
|
||||
max_sessions: 2, // the knock + the post-approval session
|
||||
@@ -3268,9 +3268,9 @@ mod tests {
|
||||
use punktfunk_core::quic::endpoint;
|
||||
|
||||
let host = std::thread::spawn(|| {
|
||||
run(M3Options {
|
||||
run(Punktfunk1Options {
|
||||
port: 19778,
|
||||
source: M3Source::Synthetic,
|
||||
source: Punktfunk1Source::Synthetic,
|
||||
seconds: 0,
|
||||
frames: 25,
|
||||
max_sessions: 4,
|
||||
@@ -1,9 +1,9 @@
|
||||
//! M0 — the pipeline spike (plan §8): capture → NVENC encode → playable file, with the
|
||||
//! The pipeline spike (plan §8): capture → NVENC encode → playable file, with the
|
||||
//! encoded access units also fed through a `punktfunk_core` host→client `Session` over an
|
||||
//! in-process loopback to prove the core's FEC + packetize + reassemble path on real
|
||||
//! encoder output.
|
||||
//!
|
||||
//! This is the spike runner, not the M2 hot path: it drives the stages on one thread (the
|
||||
//! This is the spike runner, not the production host path: it drives the stages on one thread (the
|
||||
//! per-stage-thread pipeline with bounded channels is [`crate::pipeline`]). Source is
|
||||
//! either a synthetic BGRx test pattern (no capture session needed) or the live xdg
|
||||
//! ScreenCast portal monitor.
|
||||
@@ -52,12 +52,12 @@ pub fn run(opts: Options) -> Result<()> {
|
||||
width = opts.width,
|
||||
height = opts.height,
|
||||
fps = opts.fps,
|
||||
"M0 source: synthetic BGRx test pattern"
|
||||
"spike source: synthetic BGRx test pattern"
|
||||
);
|
||||
Box::new(SyntheticCapturer::new(opts.width, opts.height, opts.fps))
|
||||
}
|
||||
Source::Portal => {
|
||||
tracing::info!("M0 source: xdg ScreenCast portal (live monitor)");
|
||||
tracing::info!("spike source: xdg ScreenCast portal (live monitor)");
|
||||
capture::open_portal_monitor().context("open portal capturer")?
|
||||
}
|
||||
Source::KwinVirtual => {
|
||||
@@ -66,7 +66,7 @@ pub fn run(opts: Options) -> Result<()> {
|
||||
width = opts.width,
|
||||
height = opts.height,
|
||||
?compositor,
|
||||
"M0 source: virtual output (PUNKTFUNK_COMPOSITOR)"
|
||||
"spike source: virtual output (PUNKTFUNK_COMPOSITOR)"
|
||||
);
|
||||
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
|
||||
let vout = vd
|
||||
@@ -104,7 +104,7 @@ pub fn run(opts: Options) -> Result<()> {
|
||||
opts.fps,
|
||||
opts.bitrate_bps,
|
||||
first.is_cuda(),
|
||||
8, // m0 synthetic harness: 8-bit
|
||||
8, // spike synthetic harness: 8-bit
|
||||
)
|
||||
.context("open encoder")?;
|
||||
|
||||
@@ -147,7 +147,7 @@ pub fn run(opts: Options) -> Result<()> {
|
||||
out = %opts.out.display(),
|
||||
elapsed_s = format!("{elapsed:.2}"),
|
||||
encode_fps = format!("{:.1}", stats.encoded as f64 / elapsed.max(1e-9)),
|
||||
"M0 capture→encode→file complete"
|
||||
"spike capture→encode→file complete"
|
||||
);
|
||||
|
||||
if let Some(lb) = lb {
|
||||
@@ -194,7 +194,7 @@ fn drain_encoder(
|
||||
|
||||
/// A host↔client `punktfunk_core` pair over a lossless in-process loopback. Each encoded AU is
|
||||
/// FEC-protected, packetized, sent, then reassembled on the client and byte-compared to the
|
||||
/// original — exercising the core on real encoder output (the M0 "feed into a Session" goal).
|
||||
/// original — exercising the core on real encoder output (the spike "feed into a Session" goal).
|
||||
struct Loopback {
|
||||
host: Session,
|
||||
client: Session,
|
||||
@@ -125,7 +125,7 @@ impl Compositor {
|
||||
/// installed (it spawns a nested session — independent of the running desktop), plus the live
|
||||
/// session's own compositor (KWin / Mutter / wlroots) when the host runs inside it. Cheap,
|
||||
/// side-effect-free probes — safe to call per management request. A concrete client preference
|
||||
/// is validated against this set before it's honored (see the m3 handshake's resolution).
|
||||
/// is validated against this set before it's honored (see the punktfunk/1 handshake's resolution).
|
||||
pub fn available() -> Vec<Compositor> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
|
||||
@@ -40,7 +40,7 @@ fn chooser_file() -> String {
|
||||
}
|
||||
|
||||
/// The managed xdpw config: per-session output selection with no GUI. The `|| echo` fallback
|
||||
/// keeps plain portal capture (`--source portal`, M0 flow) working when no session has written
|
||||
/// keeps plain portal capture (`--source portal` flow) working when no session has written
|
||||
/// the chooser file. xdpw runs `chooser_cmd` via `/bin/sh -c`, reads stdout.
|
||||
fn xdpw_config() -> String {
|
||||
format!(
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn run(opts: HelperOptions) -> Result<()> {
|
||||
// path. Elevate its OS priority so a CPU-heavy game can't deschedule it and delay submission (which
|
||||
// would leave our HIGH GPU priority with nothing queued to prioritise). Apollo's capture thread is
|
||||
// likewise CRITICAL.
|
||||
crate::m3::boost_thread_priority(true);
|
||||
crate::punktfunk1::boost_thread_priority(true);
|
||||
|
||||
// Capture the EXISTING SudoVDA output by GDI name / target id — do NOT create one (the host owns
|
||||
// the virtual output + its isolate/restore; a second topology owner breaks DDA recovery).
|
||||
|
||||
@@ -102,10 +102,10 @@ same-host-only, as today.
|
||||
- `swift test`: add a decode-output test (decode a known IDR built like
|
||||
`VideoToolboxRoundTripTests` → assert a `CVPixelBuffer` of the right dimensions + the
|
||||
decode callback fires). Present is display-bound — validate it **live** via the HUD number.
|
||||
- Live: connect to a Linux host (`m3-host --source virtual` on the GNOME box; see
|
||||
- Live: connect to a Linux host (`punktfunk1-host --source virtual` on the GNOME box; see
|
||||
[Ubuntu — GNOME](/docs/ubuntu-gnome)), confirm `capture→present` is a few ms over `capture→client`
|
||||
and that `decode→present` shrank vs. an `AVSampleBufferDisplayLayer` baseline.
|
||||
- Compare against the headless reference number: `punktfunk-client-rs` reports skew-corrected
|
||||
- Compare against the headless reference number: `punktfunk-probe` reports skew-corrected
|
||||
capture→reassembled (~1.3 ms p50 GNOME box → dev box); capture→present should be that **+ decode +
|
||||
present**.
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ punktfunk-client --connect <host>:9777 # skip the picker, start a session imme
|
||||
|
||||
## Windows desktop client (in development)
|
||||
|
||||
`punktfunk-client` for Windows (`crates/punktfunk-client-windows`) is the native graphical client
|
||||
`punktfunk-client` for Windows (`clients/windows`) is the native graphical client
|
||||
for Windows — pure Rust, the same `punktfunk/1` core as the Apple and Linux apps, with a **WinUI 3**
|
||||
UI (host list, settings, PIN pairing) and the video on a `SwapChainPanel`, plus WASAPI audio, FFmpeg
|
||||
decode, SDL3 controllers, network discovery, and PIN pairing. Launch it and pick a host from the
|
||||
@@ -74,13 +74,13 @@ Until it ships, **Moonlight** remains the recommended way to stream to Windows (
|
||||
|
||||
## Linux reference client (headless)
|
||||
|
||||
`punktfunk-client-rs` (in the repo) is a command-line client for the native protocol, used for
|
||||
`punktfunk-probe` (in the repo) is a command-line client for the native protocol, used for
|
||||
testing, development, and latency measurement — not an everyday client. It connects, streams to a
|
||||
file, runs the speed test, and can discover hosts:
|
||||
|
||||
```sh
|
||||
punktfunk-client-rs --discover # list hosts on the network
|
||||
punktfunk-client-rs --connect <host>:9777 --pin <fp> # connect to one
|
||||
punktfunk-probe --discover # list hosts on the network
|
||||
punktfunk-probe --connect <host>:9777 --pin <fp> # connect to one
|
||||
```
|
||||
|
||||
## Which should I use?
|
||||
@@ -90,6 +90,6 @@ punktfunk-client-rs --connect <host>:9777 --pin <fp> # connect to one
|
||||
| A Mac, iPhone, iPad, or Apple TV | The **Apple app** |
|
||||
| A Linux desktop or laptop, or a Steam Deck | **`punktfunk-client`** (GTK4) |
|
||||
| Windows, Android, a browser, a TV | **Moonlight** |
|
||||
| Automated tests / latency measurement | **`punktfunk-client-rs`** (headless) |
|
||||
| Automated tests / latency measurement | **`punktfunk-probe`** (headless) |
|
||||
|
||||
Whichever you choose, the first connection needs a one-time [pairing](/docs/pairing).
|
||||
|
||||
@@ -47,7 +47,7 @@ Today the native `punktfunk/1` host (`serve --native`) streams **one session at
|
||||
clients wait in the accept queue until the active session ends. Each session gets its own virtual
|
||||
display at the client's exact resolution; concurrent native sessions are on the roadmap.
|
||||
|
||||
(`m3-host`, the standalone test host, has a `--max-concurrent N` knob, default 4, bounded by your
|
||||
(`punktfunk1-host`, the standalone test host, has a `--max-concurrent N` knob, default 4, bounded by your
|
||||
GPU's encoder — see the [Host CLI](/docs/host-cli) reference — but `serve --native` does **not** take
|
||||
that flag.)
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ Each gamescope **process is per-session** (`vdisplay/gamescope.rs::create()` spa
|
||||
- **EIS input socket — single global file.** gamescope exports `LIBEI_SOCKET` for its children; a
|
||||
shell wrapper relays it to the fixed path `/tmp/punktfunk-gamescope-ei` (`EI_SOCKET_FILE`).
|
||||
**Two concurrent instances overwrite each other's socket name** in that one file.
|
||||
- **Injector — one host-lifetime `!Send` service.** `m3.rs::InjectorService` opens **one**
|
||||
- **Injector — one host-lifetime `!Send` service.** `punktfunk1.rs::InjectorService` opens **one**
|
||||
`inject::open(backend)` for the whole run and forwards events over an mpsc channel. It was made
|
||||
shared deliberately (the portal `CreateSession` churn wedged KWin's EIS — "EIS setup timed out").
|
||||
For gamescope it reads the one global socket file, so all sessions' input lands in whichever
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "M2 — Moonlight Host"
|
||||
title: "GameStream Host"
|
||||
description: "Stream to a stock Moonlight client on a client-sized virtual display."
|
||||
---
|
||||
|
||||
@@ -72,13 +72,13 @@ Ground-truth protocol reference: [`research/gamestream-protocol-research.json`](
|
||||
handshake, negotiate `Config`, create a wlroots virtual output sized to the client.
|
||||
*Acceptance: Moonlight completes RTSP and the host stands up the UDP streams.*
|
||||
- **P1.3 — Video (punktfunk-core P1 codec), plaintext, clean-LAN.** RTP+NV framing + FEC shard
|
||||
layout in punktfunk-core; wire M0's NVENC AUs → UDP 47998. *Acceptance: Moonlight DISPLAYS video.*
|
||||
layout in punktfunk-core; wire the spike's NVENC AUs → UDP 47998. *Acceptance: Moonlight DISPLAYS video.*
|
||||
- **P1.4 — Control + input.** ENet (`rusty_enet`) control stream; decode input → `inject.rs`
|
||||
(uinput/reis); request-IDR → force NVENC keyframe. *Acceptance: mouse/keyboard work.*
|
||||
- **P1.5 — Robustness: FEC recovery + encryption.** nanors-exact FEC; per-shard AES-GCM.
|
||||
*Acceptance: stable under `tc netem` loss; encrypted streams.*
|
||||
- **P1.6 — Audio + polish.** Opus + audio RTP/FEC/CBC (UDP 47999); disconnect teardown; KWin
|
||||
backend for the user's KDE box. *Acceptance: full game stream with sound — the M2 goal.*
|
||||
backend for the user's KDE box. *Acceptance: full game stream with sound — the GameStream-host goal.*
|
||||
|
||||
## Crates (verified available)
|
||||
|
||||
@@ -32,15 +32,15 @@ token is **required** when you bind the API off loopback with `--mgmt-bind`.
|
||||
By default the host **requires pairing** — see [Pairing & Trust](/docs/pairing). On `serve --native` you
|
||||
**arm pairing from the web console** (or mgmt API); the host then displays a 4-digit PIN. Pass `--open` to
|
||||
turn off the mandatory-pairing default and serve any device on the network (trusted single-user setups
|
||||
only). The pairing flags below are `m3-host`-only and do **not** apply to `serve`.
|
||||
only). The pairing flags below are `punktfunk1-host`-only and do **not** apply to `serve`.
|
||||
|
||||
## `m3-host`
|
||||
## `punktfunk1-host`
|
||||
|
||||
A standalone native-only host, mainly for testing the `punktfunk/1` path without the GameStream server
|
||||
or web console.
|
||||
|
||||
```sh
|
||||
punktfunk-host m3-host --source virtual
|
||||
punktfunk-host punktfunk1-host --source virtual
|
||||
```
|
||||
|
||||
| Flag | Meaning |
|
||||
@@ -53,12 +53,12 @@ punktfunk-host m3-host --source virtual
|
||||
| `--allow-pairing` | Accept PIN pairing; the host prints a PIN when a client pairs. |
|
||||
| `--require-pairing` | Only serve paired devices (implies `--allow-pairing`). |
|
||||
|
||||
`--max-concurrent`, `--allow-pairing`, and `--require-pairing` are **`m3-host`-only** — `serve` does not
|
||||
`--max-concurrent`, `--allow-pairing`, and `--require-pairing` are **`punktfunk1-host`-only** — `serve` does not
|
||||
accept them. On `serve --native` you arm pairing from the web console instead, and concurrency is not
|
||||
yet capped from the command line.
|
||||
|
||||
Both `serve --native` and `m3-host` advertise the host on the network so clients can discover it. List
|
||||
hosts from another machine with `punktfunk-client-rs --discover`.
|
||||
Both `serve --native` and `punktfunk1-host` advertise the host on the network so clients can discover it. List
|
||||
hosts from another machine with `punktfunk-probe --discover`.
|
||||
|
||||
## Environment
|
||||
|
||||
|
||||
@@ -276,7 +276,7 @@ punktfunk/
|
||||
│ │ ├── src/vdisplay/ # trait + kwin/wlroots/mutter impls
|
||||
│ │ ├── src/input/ # reis + uinput
|
||||
│ │ └── src/web/ # axum config/pairing API
|
||||
│ └── punktfunk-client-rs/ # reference Rust client (M4)
|
||||
│ └── punktfunk-probe/ # reference Rust client (M4)
|
||||
├── clients/
|
||||
│ ├── apple/ # Swift package, imports punktfunk_core.h (M5)
|
||||
│ └── android/ # Kotlin + JNI (later)
|
||||
|
||||
@@ -45,7 +45,7 @@ host's management console, click to arm pairing, and the host displays a 4-digit
|
||||
list of paired devices. This works on a headless host over the network — there is no command-line flag
|
||||
to arm pairing on `serve`.
|
||||
|
||||
(The standalone headless test host, `m3-host`, takes `--allow-pairing`/`--require-pairing` on its
|
||||
(The standalone headless test host, `punktfunk1-host`, takes `--allow-pairing`/`--require-pairing` on its
|
||||
command line instead; the production `serve --native` host arms pairing from the console.)
|
||||
|
||||
Then, on the client:
|
||||
@@ -60,13 +60,13 @@ the right setting on a shared network: a device has to complete the PIN ceremony
|
||||
connect.
|
||||
|
||||
If you're on a fully trusted single-user network and want to skip pairing, run the host open with
|
||||
`serve --native --open` (or `m3-host --allow-tofu` for the standalone host) — it then advertises
|
||||
`serve --native --open` (or `punktfunk1-host --allow-tofu` for the standalone host) — it then advertises
|
||||
`pair=optional` and accepts unpaired clients. Requiring pairing is strongly recommended.
|
||||
|
||||
## Trust-on-first-use (host opt-in)
|
||||
|
||||
Trust-on-first-use (TOFU) is **off by default** and is an explicit *host* opt-in for fully trusted
|
||||
networks. A host enables it by running open — `m3-host --allow-tofu` or `serve --open` — which makes
|
||||
networks. A host enables it by running open — `punktfunk1-host --allow-tofu` or `serve --open` — which makes
|
||||
it advertise `pair=optional` over mDNS and accept unpaired clients. Only then does a client offer the
|
||||
TOFU path: connecting to such a host for the first time shows the host's fingerprint and asks you to
|
||||
confirm it (compare it with the one the host logged at startup), then pins it. The client presents
|
||||
|
||||
@@ -20,7 +20,7 @@ Steam session at the **client's exact resolution + refresh** — games see it (v
|
||||
change, reused (no Steam restart) on the same mode. Plus macOS/iPad input fixes (NSEvent motion +
|
||||
iPad pointer-lock) and a 4K/5K one-frame-freeze fix (grow the UDP socket buffers).
|
||||
|
||||
**Next:** **§8 pairing & trust hardening** (mandatory PIN by default + delegated approval), the M4
|
||||
**Next:** **§8 pairing & trust hardening** (mandatory PIN by default + delegated approval), the native
|
||||
client presenter + iOS (§6), and a Windows host (§7 — now **de-risked via SudoVDA**, no custom
|
||||
signed driver needed). **§10 HDR/10-bit is parked — blocked upstream at the compositor** (no
|
||||
gamescope/KWin PipeWire 10-bit producer yet).
|
||||
@@ -88,7 +88,7 @@ select = a `pw_stream` with `Direction::Output` + `media.class=Audio/Source`.
|
||||
- **Touch — implemented (host path), pending a backend that lands it.** `TouchDown/Move/Up`
|
||||
InputKinds (reuse the abs-pointer `flags=(w<<16)|h` mapping, `code`=touch id); host
|
||||
`inject/libei.rs` requests the `Touchscreen` device type + binds the `Touch` capability and
|
||||
injects `ei_touchscreen` down/motion/up; `punktfunk-client-rs --touch-test` drags a finger.
|
||||
injects `ei_touchscreen` down/motion/up; `punktfunk-probe --touch-test` drags a finger.
|
||||
**Validated:** KWin's RemoteDesktop portal *grants* the Touchscreen device type, but its EIS
|
||||
server creates **no touchscreen device** (headless KWin) — so touch currently no-ops on KWin
|
||||
(now logged once). The code is correct; it needs a backend that exposes `ei_touchscreen`
|
||||
@@ -102,14 +102,14 @@ select = a `pw_stream` with `Direction::Output` + `media.class=Audio/Source`.
|
||||
trigger effects (L2/R2)**. Protocol carries new side-planes: rich-input `0xCC`
|
||||
(touchpad/motion) + HID-output `0xCD` (LED/triggers). `/dev/uhid` udev rule shipped.
|
||||
- **Rich DualSense — Phase C/D/E end-to-end, validated live.** `PUNKTFUNK_GAMEPAD=dualsense`
|
||||
selects a per-session `DualSenseManager` (the `PadBackend` enum in `m3.rs`): client gamepad frames
|
||||
selects a per-session `DualSenseManager` (the `PadBackend` enum in `punktfunk1.rs`): client gamepad frames
|
||||
build the DualSense report; the kernel's feedback comes back as `HidOutput` on the **0xCD** plane
|
||||
(lightbar / player LEDs / adaptive triggers) while **rumble stays on the universal 0xCA plane**
|
||||
(so non-DualSense clients still feel it); touchpad + motion ride the **0xCC** rich-input plane
|
||||
(`DualSenseManager::apply_rich`, merged with button state). The connector + C ABI gained
|
||||
`punktfunk_connection_next_hidout` (→ `PunktfunkHidOutput`) and `punktfunk_connection_send_rich_input`
|
||||
(← `PunktfunkRichInput`); header regenerated. Validated on-box: a synthetic-source `m3-host` +
|
||||
`punktfunk-client-rs --rich-input-test` created the real kernel DualSense, drove 0xCC, and decoded
|
||||
(← `PunktfunkRichInput`); header regenerated. Validated on-box: a synthetic-source `punktfunk1-host` +
|
||||
`punktfunk-probe --rich-input-test` created the real kernel DualSense, drove 0xCC, and decoded
|
||||
12 live 0xCD events (the kernel's actual lightbar/trigger init reports) — data plane unaffected
|
||||
(600/600 frames). *Remaining:* the Apple client renders adaptive triggers + rumble on a real
|
||||
DualSense (`GCDualSenseAdaptiveTrigger`) — handed off to the client agent for the real playtest.
|
||||
@@ -192,7 +192,7 @@ value) instead of guesswork that ends in a stuttering stream.
|
||||
and exposes it (`punktfunk_connection_speed_test()` + `punktfunk_connection_probe_result()` →
|
||||
`PunktfunkProbeResult{throughput_kbps, loss_pct, …}`). Probe filler is diverted from the decoder.
|
||||
Validated on loopback (synthetic source): a 20 Mbps/2 s probe measured 20050 kbps at 0% loss,
|
||||
interleaved probe AUs excluded from frame verification. `punktfunk-client-rs` gains `--bitrate` +
|
||||
interleaved probe AUs excluded from frame verification. `punktfunk-probe` gains `--bitrate` +
|
||||
`--speed-test KBPS:MS` as the reference/loopback driver.
|
||||
|
||||
**Done (Apple client UI):** Settings grows a Bitrate control (Automatic = host default; manual is
|
||||
@@ -244,7 +244,7 @@ the GF(2⁸)/Moonlight ~1 Gbps wall). A 6-way subagent investigation (2026-06-11
|
||||
**Verdict: ~halfway, and it's mostly clamps + ONE real piece of work.** Already 1 Gbps-ready and
|
||||
untouched: the integer/type path (u32 kbps → u64 → int64_t, no truncation); FEC (a 1 Gbps frame is
|
||||
only ~434–874 data shards = a single GF(2¹⁶) block, two orders under the 65535 ceiling); AES-GCM
|
||||
(RustCrypto auto AES-NI, ~10–25× headroom on x86_64); the u64 sequence/nonce space; and the **M1
|
||||
(RustCrypto auto AES-NI, ~10–25× headroom on x86_64); the u64 sequence/nonce space; and the **core
|
||||
`ReassemblerLimits`** — fully *derived* from the negotiated `FecConfig`, so they already admit every
|
||||
legit high-bitrate frame with nothing to relax. Security invariant to keep: every allocation size
|
||||
must trace to a host-negotiated parameter clamped to a scheme ceiling — scale via the negotiated
|
||||
@@ -271,7 +271,7 @@ params (`max_data_per_block`, `shard_payload`), never by widening a bound by han
|
||||
- **DoS hygiene (last):** derive the one hardcoded reassembler field (`max_frame_bytes` = 64 MiB,
|
||||
never set by `session_config`) from the negotiated mode/bitrate — strictly *tightens* the surface.
|
||||
- **Validate with the speed-test probe** (it reuses the real `submit_frame`→FEC+crypto+send path):
|
||||
`punktfunk-client-rs --speed-test KBPS:MS`, RELEASE build (debug is CPU-bound ~30 Mbps), watching
|
||||
`punktfunk-probe --speed-test KBPS:MS`, RELEASE build (debug is CPU-bound ~30 Mbps), watching
|
||||
`packets_send_dropped`. Open Qs: NVENC CBR rate-tracking at 0.5–1 Gbps (no explicit
|
||||
`rc_buffer_size`); LAN/QEMU-NIC jumbo/GSO support; any `web/` bitrate slider hardcoding 500 Mbps.
|
||||
|
||||
@@ -344,7 +344,7 @@ buffer; `sendmmsg`/`recvmmsg` batching; the capture-timestamp anchor placement.
|
||||
|
||||
The native protocol had no discovery — clients connected by `--connect HOST:PORT` only, while
|
||||
GameStream already auto-discovered via mDNS (`_nvstream._tcp`). Now both the unified host
|
||||
(`serve --native`) and standalone `m3-host` advertise the native service over mDNS:
|
||||
(`serve --native`) and standalone `punktfunk1-host` advertise the native service over mDNS:
|
||||
|
||||
- **Service**: `_punktfunk._udp.local.` (UDP — punktfunk/1 is QUIC; the advertised port is the QUIC
|
||||
control/data port). Host side: `crate::discovery::advertise_native`, wired into `m3::serve` so
|
||||
@@ -353,7 +353,7 @@ GameStream already auto-discovered via mDNS (`_nvstream._tcp`). Now both the uni
|
||||
- **TXT records**: `proto=punktfunk/1`, `fp=<host cert SHA-256>` (the value a client pins — advisory
|
||||
over unauthenticated mDNS, TOFU/pinning still verifies on connect), `pair=required|optional`
|
||||
(so a picker knows up front whether the PIN ceremony is needed), `id=<host uniqueid>` (dedup).
|
||||
- **Client**: `punktfunk-client-rs --discover [SECS]` browses and prints each host (name, addr:port,
|
||||
- **Client**: `punktfunk-probe --discover [SECS]` browses and prints each host (name, addr:port,
|
||||
pairing, fingerprint), then exits. Apple clients browse the same service natively via NWBrowser
|
||||
(Bonjour) — no Rust-connector dependency; this section's service type + TXT keys are the contract.
|
||||
- **Validated**: cross-LAN — dev box discovered the GNOME-box appliance
|
||||
|
||||
@@ -82,7 +82,7 @@ session unit — see [Bazzite](/docs/bazzite).
|
||||
After a reboot, from another machine on the network:
|
||||
|
||||
```sh
|
||||
punktfunk-client-rs --discover # or just look for the host in the Apple app / Moonlight
|
||||
punktfunk-probe --discover # or just look for the host in the Apple app / Moonlight
|
||||
```
|
||||
|
||||
If the host is listed, it's up. If not, check `journalctl --user -u punktfunk-host` on the host.
|
||||
|
||||
@@ -11,10 +11,10 @@ and the design in the [Implementation Plan](/docs/implementation-plan); this pag
|
||||
|
||||
| Milestone | State |
|
||||
|---|---|
|
||||
| **M1** — `punktfunk-core` + C ABI (protocol · FEC · crypto) | ✅ complete & hardened |
|
||||
| **M2** — GameStream host (Moonlight-compatible) | ✅ working end-to-end; HDR/surround-audio polish open |
|
||||
| **M3** — `punktfunk/1` native protocol (QUIC control + UDP data) | ✅ full session planes, validated live |
|
||||
| **M4** — native client decode + present (Apple first) | 🟡 macOS stage 1 live; stage-2 presenter built + decode-tested (opt-in, present needs live validation). **Linux GTK client stage 1 live** (2026-06-12) |
|
||||
| **Core** — `punktfunk-core` + C ABI (protocol · FEC · crypto) | ✅ complete & hardened |
|
||||
| **GameStream host** (Moonlight-compatible) | ✅ working end-to-end; HDR/surround-audio polish open |
|
||||
| **Native protocol** — `punktfunk/1` (QUIC control + UDP data) | ✅ full session planes, validated live |
|
||||
| **Native clients** — decode + present (Apple first) | 🟡 macOS stage 1 live; stage-2 presenter built + decode-tested (opt-in, present needs live validation). **Linux GTK client stage 1 live** (2026-06-12) |
|
||||
|
||||
## Live on the boxes
|
||||
|
||||
@@ -29,7 +29,7 @@ All three appliances advertise over mDNS (`_punktfunk._udp`) and require PIN pai
|
||||
## Progress log
|
||||
|
||||
### 2026-06-12
|
||||
- **Native Linux client — stage 1, first light** (`crates/punktfunk-client-linux`, binary
|
||||
- **Native Linux client — stage 1, first light** (`clients/linux`, binary
|
||||
`punktfunk-client`). GTK4/libadwaita app on the **Option A** architecture picked after a
|
||||
six-angle research pass (toolkits / hw decode / Wayland presentation / input capture /
|
||||
prior art / codebase): links `punktfunk-core` directly as a crate (no C ABI;
|
||||
@@ -81,7 +81,7 @@ All three appliances advertise over mDNS (`_punktfunk._udp`) and require PIN pai
|
||||
client's capture→reassembled latency valid **cross-machine**. Validated GNOME box → dev box:
|
||||
offset −1.57 ms removed, **p50 1.30 ms** skew-corrected. (`05bc9ab`)
|
||||
- **Native LAN auto-discovery** — host advertises `_punktfunk._udp` (TXT: fingerprint, pairing,
|
||||
proto); `punktfunk-client-rs --discover` lists hosts. Validated cross-LAN. (`4fff464`)
|
||||
proto); `punktfunk-probe --discover` lists hosts. Validated cross-LAN. (`4fff464`)
|
||||
- **Third test box stood up** — home-worker-3 (Ubuntu 26.04, RTX 4090, GNOME 50): first GNOME/Mutter
|
||||
zero-copy streaming on a real desktop; **1 Gbps probe clean** (625 MB/5 s, `send_dropped=0`).
|
||||
Two physical-NVIDIA gotchas documented in [Ubuntu — GNOME](/docs/ubuntu-gnome).
|
||||
|
||||
@@ -25,7 +25,7 @@ punktfunk is cleanly layered. **~95% of the codebase is platform-agnostic and re
|
||||
| QUIC control plane (`quic.rs`, pairing, mode negotiation) | quinn + tokio are portable |
|
||||
| GameStream P1.1 (mDNS, serverinfo, pairing, RTSP, ENet) — *except* `stream.rs`/`audio.rs` | pure wire logic |
|
||||
| Management REST API (`mgmt.rs`) + OpenAPI | axum/tokio, portable |
|
||||
| Pipeline + `m3.rs` orchestration | trait-generic — calls `capturer.next_frame()`, `encoder.submit/poll()`; **needs zero changes** |
|
||||
| Pipeline + `punktfunk1.rs` orchestration | trait-generic — calls `capturer.next_frame()`, `encoder.submit/poll()`; **needs zero changes** |
|
||||
| The **trait boundaries** themselves: `Capturer`, `Encoder`, `VirtualDisplay`, `InputInjector`, `AudioCapturer`, `VirtualMic` | platform-neutral signatures; Linux deps are already isolated under `[target.'cfg(target_os="linux")'.dependencies]` |
|
||||
|
||||
So a Windows host is **new `#[cfg(target_os = "windows")]` backend modules behind the existing
|
||||
|
||||
@@ -909,6 +909,19 @@ PunktfunkStatus punktfunk_connection_request_mode(const PunktfunkConnection *c,
|
||||
PunktfunkStatus punktfunk_connection_request_keyframe(const PunktfunkConnection *c);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
|
||||
// rebuild them). A video loop polls this and calls [`punktfunk_connection_request_keyframe`]
|
||||
// when it climbs — the correct loss trigger under the host's infinite GOP, where unrecoverable
|
||||
// loss yields reference-missing delta frames the decoder *silently conceals* (frozen / garbage
|
||||
// picture, no decode error), so a decode-error trigger rarely fires. Monotonic for the session;
|
||||
// compare against the last observed value. Writes 0 to `out` on a NULL connection.
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle; `out` is writable (NULL is skipped).
|
||||
PunktfunkStatus punktfunk_connection_frames_dropped(const PunktfunkConnection *c, uint64_t *out);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Start a bandwidth speed test: ask the host to burst filler over the data plane at
|
||||
// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 3 Gbps / ≤ 5 s),
|
||||
|
||||
@@ -236,9 +236,9 @@ journalctl --user -u punktfunk-host -f
|
||||
> **What `serve` actually starts.** The unit's `ExecStart` runs `punktfunk-host serve`, which is the
|
||||
> **GameStream / Moonlight-compatible** host (mDNS discovery, pairing, RTSP, the fixed GameStream
|
||||
> ports, **plus the management REST API on 47990**). The native `punktfunk/1` (QUIC) host is a
|
||||
> *separate* subcommand — `punktfunk-host m3-host` — and is **not** what the bundled systemd unit
|
||||
> *separate* subcommand — `punktfunk-host punktfunk1-host` — and is **not** what the bundled systemd unit
|
||||
> launches. So out of the box on Bazzite you get the **Moonlight-compatible** host.
|
||||
> (Source: `crates/punktfunk-host/src/main.rs` — `serve` → `gamestream::serve`; `m3-host` is its own
|
||||
> (Source: `crates/punktfunk-host/src/main.rs` — `serve` → `gamestream::serve`; `punktfunk1-host` is its own
|
||||
> path.)
|
||||
|
||||
> **Unit caveat:** `scripts/punktfunk-host.service` declares only `After=pipewire.service` and (in
|
||||
@@ -253,7 +253,7 @@ journalctl --user -u punktfunk-host -f
|
||||
|
||||
> ⚠️ **There is no firewall script or firewall doc in the repo.** The ports below are derived
|
||||
> directly from the code constants (`crates/punktfunk-host/src/gamestream/mod.rs`, `mgmt.rs`) and
|
||||
> the M2 port-map (`docs/m2-plan.md`). Treat the `firewall-cmd` lines as recommended-but-verified,
|
||||
> the GameStream-host port-map (`docs/gamestream-host-plan.md`). Treat the `firewall-cmd` lines as recommended-but-verified,
|
||||
> not a checked-in script.
|
||||
|
||||
**GameStream / Moonlight ports** (fixed; Moonlight derives them from the HTTP base):
|
||||
@@ -285,16 +285,16 @@ sudo firewall-cmd --permanent --add-port=47998/udp \
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
**If you also run the native `punktfunk/1` host** (`punktfunk-host m3-host`, not started by the
|
||||
**If you also run the native `punktfunk/1` host** (`punktfunk-host punktfunk1-host`, not started by the
|
||||
default unit):
|
||||
|
||||
- **QUIC control plane: UDP 9777** (default `--port`; change with `--port N`).
|
||||
- **Data plane: an *ephemeral* UDP port** — `m3-host` binds `0.0.0.0:0` and tells the client which
|
||||
- **Data plane: an *ephemeral* UDP port** — `punktfunk1-host` binds `0.0.0.0:0` and tells the client which
|
||||
port it got, so there is **no fixed data port to open**. For a restrictive firewall you'd need to
|
||||
allow the ephemeral UDP range; the repo does not pin one.
|
||||
|
||||
```sh
|
||||
# Only if you run `m3-host`:
|
||||
# Only if you run `punktfunk1-host`:
|
||||
sudo firewall-cmd --permanent --add-port=9777/udp && sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
@@ -341,8 +341,8 @@ advertising`, and an RTSP listening line on port 48010. No NVENC/EGL errors on t
|
||||
- Launch the app — you should get video at your client's native resolution/refresh, with the nested
|
||||
`steam -gamepadui` (or whatever `PUNKTFUNK_GAMESCOPE_APP` you set) running inside gamescope.
|
||||
|
||||
**3. (Optional) native punktfunk/1 client** — only if you're running the separate `m3-host`. The
|
||||
repo's reference client is `punktfunk-client-rs`, e.g. `punktfunk-client-rs --mode 1280x720x120 --out
|
||||
**3. (Optional) native punktfunk/1 client** — only if you're running the separate `punktfunk1-host`. The
|
||||
repo's reference client is `punktfunk-probe`, e.g. `punktfunk-probe --mode 1280x720x120 --out
|
||||
/tmp/a.h265` (add `--pin HEX` for PIN pairing). This is a headless/decode-to-file reference, not a
|
||||
desktop viewer.
|
||||
|
||||
@@ -414,5 +414,5 @@ matching your Bazzite Fedora base (`rpm -E %fedora`).
|
||||
1. The COPR is **operator-run / not assumed published** — both install paths depend on it.
|
||||
2. There is **no firewall script/doc in the repo** — the ports above are derived from the code.
|
||||
3. The bundled systemd unit runs the **GameStream/Moonlight** `serve` host, **not** the native
|
||||
`punktfunk/1` QUIC host (`m3-host` is separate and unmanaged by the unit).
|
||||
`punktfunk/1` QUIC host (`punktfunk1-host` is separate and unmanaged by the unit).
|
||||
4. The mgmt port (47990) is **loopback-only by default** — don't open it.
|
||||
|
||||
@@ -10,7 +10,7 @@ The same workflow also publishes **`punktfunk-web`** (the browser management con
|
||||
status) and **`punktfunk-client`** (the GTK4 couch/Deck client). `punktfunk-host` **Recommends**
|
||||
`punktfunk-web`, so a default `apt install punktfunk-host` pulls the console too (alongside the
|
||||
udev/sysctl bits) unless you've disabled weak deps; `punktfunk-client` is independent — install it
|
||||
on the box you stream *to*. (`punktfunk-client-rs` is the headless reference/test tool, not packaged
|
||||
on the box you stream *to*. (`punktfunk-probe` is the headless reference/test tool, not packaged
|
||||
here.)
|
||||
|
||||
Package layout mirrors the Fedora RPM (`../rpm/punktfunk.spec`): the host binary, the `/dev/uinput`
|
||||
|
||||
@@ -71,7 +71,7 @@ finish-args:
|
||||
- --socket=pulseaudio
|
||||
# --- network: QUIC control + UDP data plane + mDNS discovery (_punktfunk._udp) ---
|
||||
- --share=network
|
||||
# --- persistent client identity / pairing store (shared with punktfunk-client-rs) ---
|
||||
# --- persistent client identity / pairing store (shared with punktfunk-probe) ---
|
||||
- --filesystem=~/.config/punktfunk:create # client-{cert,key}.pem, known-hosts, settings
|
||||
|
||||
build-options:
|
||||
@@ -145,7 +145,7 @@ modules:
|
||||
# Windows client; this edits only the sandbox copy of Cargo.toml. (No --locked: the lock
|
||||
# no longer matches the reduced member set; --offline still pins every crate to the
|
||||
# vendored cargo-sources.json, so the build stays reproducible.)
|
||||
- sed -i '\#"crates/punktfunk-client-windows",#d' Cargo.toml
|
||||
- sed -i '\#"clients/windows",#d' Cargo.toml
|
||||
- cargo --offline build --release -p punktfunk-client-linux
|
||||
- install -Dm0755 target/release/punktfunk-client ${FLATPAK_DEST}/bin/punktfunk-client
|
||||
# Desktop entry (renamed to the app id; Exec is the in-sandbox binary).
|
||||
|
||||