Phases 1–4 of design/encoder-recovery-hardening.md — make the shipped RFI/
freeze-until-reanchor recovery honest and rebuild-safe across every backend.
F1 — frame-index domain desync: the encode loop now owns a session-lifetime
`au_seq`; `Encoder::submit_indexed(au_seq + inflight)` pins NVENC inputTimeStamp
and AMF LTR slots to the WIRE frame index, so `invalidate_ref_frames` compares
client frame numbers in the same domain and survives adaptive-bitrate rebuilds
(an internal counter desynced on the first rebuild → RFI silently dead / an AMF
force-ref onto a never-decoded frame). `FrameMsg.frame_index` →
`Session::seal_frame_at`; GameStream gets the same via `VideoPacketizer::
packetize(.., Some(idx))`.
F2 — Windows NVENC left the client frozen ~1s per loss: NVENC RFI was
transparent (no anchor tag) while the session glue armed the 750ms IDR cooldown,
so the freeze only lifted on the ~1s keyframe re-ask. NVENC now mirrors AMF —
`pending_anchor` tags the first post-invalidate AU (the clean re-anchor
P-frame) `recovery_anchor`, incl. the covering-range dedupe re-arm; the client
lifts at ~RTT.
F3 — speed-test probe filler burned video frame indexes: moved to its own index
space (`Packetizer::alloc_probe_index` + `Session::submit_probe_frame`) with a
second client reassembly window routed on FLAG_PROBE, gated on the new
VIDEO_CAP_PROBE_SEQ Hello bit (mid-session probes declined for older clients).
F4 — RFI range sanity cap: forward gaps wider than `packet::RFI_MAX_RANGE` (256)
resync via keyframe instead of an out-of-range RFI, host- and client-side
(client huge-gap → keyframe in `RfiRecovery::observe` + the pf-client-core pump).
F5 — reset() parity: Windows NVENC (teardown + lazy re-init), Linux VAAPI
(drop-inner), Linux NVENC (reopen from stored OpenArgs) now give the stall
watchdog a heal lever instead of ending the session.
F6 — sw.rs `pending: VecDeque` (was `Option`), killing the silent AU drop at
capturer pipeline depth > 1. F7 — doc sweep on the RFI/anchor comments.
Verified: punktfunk-core lib tests (macOS + Linux), full punktfunk-host suite on
Linux (RTX 5070 Ti), Windows compile. Owed: the on-glass client matrix (F2
freeze A/B, AMF LTR spike across a bitrate rebuild).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `cargo fmt --check` step on the x86_64-pc-windows-msvc job was
failing: the mid-stream loss-recovery and resize-overlay commits landed
with unformatted wraps across pf-presenter, pf-client-core, punktfunk-core,
pf-console-ui, and a few host files.
Applied `cargo fmt`, and hand-relocated two trailing comments in
session.rs (a decoded-frame note and the wrap-counter note) to their own
lines so rustfmt no longer column-aligns the following comment block to
a deep indent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Centralize the client-side loss-range detector in punktfunk-core so every
embedder shares one implementation instead of re-deriving the wrapping
frame-index arithmetic:
- NativeClient::note_frame_index(frame_index) folds each received AU (in
receive order) through RfiRecovery::observe, firing a throttled RFI request
for the exact lost span [first_missing, frame_index-1] on a forward gap. A
host that can RFI (AMD LTR / NVENC) re-references a known-good frame instead
of paying a 20-40x IDR spike; the frames_dropped-driven keyframe path stays
the backstop for when the recovery frame itself is lost.
- Export request_rfi + note_frame_index over the C ABI (Apple client).
- Call it from the Android (hw+sw pumps), Apple (StreamPump + Stage2Pipeline
via PunktfunkConnection.noteFrameIndex), and Windows in-process pumps.
Linux/Deck inherit it through pf-client-core's session pump.
- Split the decision into a pure RfiRecovery::observe(frame_index, now) and add
8 unit tests: arming, contiguous runs, exact lost-range, single-frame drop,
the 100ms throttle (burst-suppress then re-open), reorder stragglers, and
u32 wraparound (contiguous + gap-range).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Removes the "gray frames with motion" artifact on Vulkan-Video clients and lets
AMD/NVENC hosts re-anchor after loss WITHOUT a 20-40x IDR spike.
Client (pf-client-core): after a reference loss the hardware decoder conceals the
missing-reference deltas (on RADV, a gray plate with new motion painted over) and
returns Ok. The pump now freezes on the last good picture until a clean re-anchor
instead of showing the concealment — lifting on a real IDR, an intra-refresh
recovery mark (2nd wave boundary), or an LTR-RFI recovery anchor (1st). The
frame_index gap is the early, precise loss signal and drives an RFI request.
Host recovery signals (inert unless the backend supports them):
- USER_FLAG_RECOVERY_POINT — intra-refresh wave boundary (NVENC constrained GDR).
- USER_FLAG_RECOVERY_ANCHOR — AMD LTR reference-frame-invalidation recovery frame.
AMD LTR-RFI (encode/windows/amf.rs) — the AMD twin of NVENC RFI. AMF's AVC/HEVC API
has no constrained-intra property (intra-refresh cannot heal; PSNR-proven), so the
only clean-recovery lever is user LTR: mark frames as long-term references, and on
loss force the next frame to re-reference the newest known-good one — a clean
P-frame, not an IDR. Two rotating LTR slots, ~0.5s mark cadence, on by default for
AVC/HEVC (PUNKTFUNK_NO_AMF_LTR disables). invalidate_ref_frames picks the newest LTR
before the loss; a range older than the live slots falls back to a keyframe.
Protocol (punktfunk-core): RfiRequest control message + NativeClient::request_rfi().
Host: RfiRequest dispatch -> invalidate_ref_frames (IDR fallback); an RFI success
anchors the keyframe cooldown so the client's frames_dropped echo of the same loss
is coalesced away rather than emitting a redundant IDR.
Spike: synthetic NV12 GPU source for headless AMF encoder testing.
Validated: core rfi_request_roundtrip; pf-client-core 31 unit tests
(incl. an_rfi_anchor_lifts_immediately); punktfunk-host builds + 271 tests on Linux;
punktfunk-host builds clean on Windows; real AMD iGPU spike (invalidate at frame 90
forced re-reference to LTR frame 60 — 180 frames, keyframes=1, no recovery IDR).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rumble was level-triggered, unbounded state on a lossy channel: a non-zero
level meant "buzz until further notice", healed only by the host re-sending
state every 500 ms, and every client guessed when the host had died with its own
magic timeout (SDL 1.5 s, Apple 1.6 s, Android up to 60 s). A lost stop, a
reordered start, or a dead host could drone the motor for seconds.
Make "stuck rumble" inexpressible on the wire. The 0xCA datagram grows a
length-tolerant tail — [u8 seq][u16 ttl_ms] — so it self-terminates: the host
authorizes a level for at most ttl_ms and renews it (~120 ms) while it holds,
letting an abandoned one lapse client-side. seq is a per-pad wrapping reorder
gate (reusing GamepadSnapshot::seq_newer) so a reordered stale start can't
re-light a stopped motor. Decoders read the first 7 bytes as a plain level and
ignore the tail, so no wire-version bump: an old client renders a new host's
levels, and a new client falls back to its prior staleness heuristic against an
old host (ttl = None). All four generation pairings render correctly.
- core: encode_rumble_datagram_v2 / decode_rumble_envelope (datagram.rs); the
client demux applies the seq gate then forwards (pad, low, high, Option<ttl>);
next_rumble is unchanged (drops ttl), next_rumble_ttl keeps it; ABI adds
punktfunk_connection_next_rumble2 + PUNKTFUNK_RUMBLE_NO_TTL, ABI_VERSION 4->5
(WIRE_VERSION unchanged — the tail is backward-compatible).
- host (punktfunk1.rs): the flat 500 ms refresh becomes a renewal loop that bumps
seq + stamps a fresh TTL on active pads and drains a short post-stop zero burst,
then goes quiet. Hatches: PUNKTFUNK_RUMBLE_ENVELOPE=0 (legacy v1 + flat refresh,
a bisect switch), PUNKTFUNK_RUMBLE_TTL_MS (clamped [150, 5000]).
- renderers honor the TTL as their playback duration/deadline and keep their old
heuristic only for a legacy (ttl=None) update: pf-client-core (the Deck haptic
keep-alive is now deadline-bounded so it can't sustain a host-stopped rumble),
clients/windows (SDL duration), android (JNI packs the lease out-of-band in bit
48 so any u16 ttl is unambiguous; Kotlin createOneShot(ttl)), apple
(RumbleRenderer.envelopeDeadline + nextRumble2; sessionStaleSeconds demoted to
the legacy fallback).
- tests: codec round-trip + tail tolerance + seq-gate reorder (Rust); the probe
asserts the v2 tail arrived under PUNKTFUNK_TEST_FEEDBACK; the Apple loopback
asserts ttlMs round-trips end to end; RumbleTuning lease-decision cases.
The host-side idle-timeout from the previous commit is defense in depth on the
game side; this is the guarantee on the client side. Design:
punktfunk-planning/design/rumble-envelope-plan.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
design/windows-parallel-virtual-displays.md (display-management Stage 7 / §6.6): N
simultaneously-live pf-vdisplay monitors, one sealed ring each, every idd-push-security
invariant preserved per-ring.
- proto v3: SharedHeader._pad → target_id — the ring NAMES its monitor, host-stamped
before the magic; the driver publisher refuses a cross-bound ring via the shared,
unit-tested frame::check_attach (new DRV_STATUS_BIND_FAIL — the gamepad pad_index
validation applied to frames, invariant #10); the host's wait_for_attach surfaces the
refusal loudly and self-checks its own stamp.
- manager: the one-monitor MgrState becomes a slot map keyed by the client's identity
slot (0 = anonymous/GameStream); per-slot reconnect + dead-WUDFHost preempts,
slot-scoped begin_idd_setup (a different identity is an admission question, never a
preempt), ONE device-level watchdog pinger, per-slot /display/state + /display/release.
- group topology: isolate_displays_ccd takes the managed target SET (a sibling slot is
never deactivated); SavedConfig + the DDC/PnP axes move to the group record (first-in
captures, last-out restores); desktop layout via CCD source origins from the pure
layout::arrange (auto-row default, manual pins win), re-applied on create + reconfigure.
- admission: the Windows separate→reject override now sits behind the
PUNKTFUNK_WIN_SEPARATE=1 validation hatch (the wedge it guarded is structurally gone —
a second identity gets its own monitor + ring; default flips in W5 after soak);
max_displays and NVENC session-unit budgets decline an unaffordable display AT
admission; kick_dwm_compose is process-globally throttled and per-display — cursor
jump + 35 ms dwell (a sub-tick jump composes nothing; DWM reads dirties from current
state at the next vsync tick).
On-glass on the RTX box: V1/V2/V4/V5/V6/V9 green — two paired clients on two monitors
streaming ~60 fps each with zero mismatches and zero bind failures, churn-hammer clean
(no 0x80070490), per-ring mode-change recreate leaves the sibling untouched, typed
budget rejection, fault-injected cross-bind refused loudly with the sibling undisturbed.
V7: WUDFHost-kill shared fate is clean; in-process device recovery is a known follow-up
(the retired-never-closed control handles block the adapter cycle — reset-pf-vdisplay.ps1
recovers). DWM composes two IDD monitors concurrently at 60 fps — the plan's
load-bearing unknown, answered yes.
Also carries the client-HDR EDID forwarding that shared this working tree
(Hello::display_hdr → AddRequest luminance tail → the monitor's CTA-861.3 HDR block,
PUNKTFUNK_CLIENT_PEAK_NITS hatch) and the Deck client fixes (40 ms rumble keep-alive
with 1-LSB jitter, HDR self-diagnosing presenter warn, flatpak HDR env).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Capability now lives on the host, choice on the client. PUNKTFUNK_444 flips
to DEFAULT ON with an explicit-off grammar (0/false/off/no — the old
presence-only flag() would have read =0 as on); every existing gate still
applies (client advertisement, HEVC, full-chroma capture, encode probe,
Windows HDR-display downgrade), so an unset host merely stops refusing. The
Apple client's "Full chroma (4:4:4)" toggle flips to DEFAULT OFF: full chroma
is a per-session trade — a clear win for desktop/text, but at a fixed bitrate
game content spends those bits better at 4:2:0, and the encode/decode pixel
rate rises. Persisted user choices survive both flips.
Live-verified on the CachyOS VM: host with no env negotiates
chroma_format_idc=3 for a 4:4:4-advertising client; PUNKTFUNK_444=0 resolves
4:2:0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI's Windows clippy (-D warnings) rejects `raw as u64` in the qWAVE flow
guard: std's RawSocket is u64 on Windows, so the cast is a no-op
(clippy::unnecessary_cast). Verified with the CI's exact invocation
(cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings)
on the RTX box.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rustfmt pass over the files the deferred-plan items touched (pinned
toolchain 1.96.0); no semantic change. cargo fmt --all --check now clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Networking-audit deferred plan §6:
- 6.1 client reassembler ceiling derived from the negotiated rate:
Welcome::session_config (client role) now sets max_frame_bytes to
clamp(4 × bitrate_kbps×125 / refresh_hz, 8 MiB, 64 MiB) instead of the
blanket 64 MiB p1_defaults bound — the hostile-header memory ceiling was
~10× larger than any real access unit. Local only (the host never
reassembles video; the wire is self-describing); a bitrate-0 (older)
host keeps the old bound. Unit-tested floor/derived/host/old-host cases.
- 6.2 ProbeState.active is cleared when the host's ProbeResult lands, so
the pump stops mirroring receive counters once the burst is over.
- 6.3 Android: an AU larger than the codec input buffer is DROPPED with a
recovery-keyframe request and a counter, on both the sync (feed) and
async (feed_ready) paths — a truncated AU is corrupt input the decoder
chews on silently, poisoning the reference chain until the next IDR. The
async path recycles the never-queued input slot; the sync path returns
the dequeued slot with zero valid bytes.
- 6.4 bounded uplink channels: mic_tx at 64 (~320 ms of 5 ms frames;
overflow sheds the fresh frame with a debug log — a tokio mpsc can't
shed from the head, and past 320 ms of backlog the mic is broken either
way; the bound is about memory) and ctrl_tx at 32 (sparse requests; a
full queue means a wedged control task, reported as Closed). input_tx
stays unbounded per the plan: keyboard/mouse events must never silently
drop, and gamepad state is snapshot-healed.
- 6.5 (wire version byte says P1 while streaming Gf16): record-only,
resolves with the P2 packet revision.
include/punktfunk_core.h: cbindgen re-emitted in the new module order
after the quic/ split (item 3) — no semantic change beyond the reorder.
cargo ndk check (arm64-v8a), workspace clippy, core+host tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Networking-audit deferred plan §4 (the qos.rs follow-up). On Windows
set_tos_v4 succeeds but the stack strips the mark without a qWAVE flow, so
PUNKTFUNK_DSCP=1 was a silent wire no-op there. Now (Apollo/Sunshine's
approach): QOSCreateHandle once per process; QOSAddSocketToFlow per
connected media socket — video → QOSTrafficTypeAudioVideo, audio →
QOSTrafficTypeVoice (QOS_NON_ADAPTIVE_FLOW) — then best-effort
QOSSetFlow(QOSSetOutgoingDSCPValue, 40/48) to pin the exact CS5/CS6 the
other platforms mark. The pin lands for elevated processes (the host runs
as the SYSTEM service — exactly where the video egress is) or under the
"allow non-admin DSCP" policy; otherwise the traffic-type default marking
stands (still WMM-useful). Gating + contract unchanged: opt-in via
dscp_enabled(), every step debug-logs and continues.
set_media_qos now returns an RAII QosFlow guard (QOSRemoveSocketFromFlow on
drop) that must outlive the socket's traffic: stored in UdpTransport
(declared before the socket, so drop order removes the flow first) and held
for the stream's scope by the GameStream video/audio senders — whose
tagging moved after connect(), since qWAVE derives the flow's 5-tuple from
the connected socket (behavior-neutral on Linux). Off-Windows the guard is
inert and never constructed.
Validated: cargo check -p punktfunk-core --target x86_64-pc-windows-msvc
green (the full host can't cross-check from Linux — aws-lc-sys needs MSVC
tooling; it builds on-box via deploy-host.ps1). Remaining on the next
Windows pass per plan: deploy to the RTX box and pktmon/Wireshark the
client side — DSCP ≠ 0 on video egress with PUNKTFUNK_DSCP=1, 0 without.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Networking-audit deferred plan §3. One file per concern, zero logic edits:
quic/mod.rs MAGIC/CTL_MAGIC + re-exports (every crate::quic::X path
compiles unchanged across host + all clients)
quic/msgs.rs Hello/Welcome/Start, typed control msgs + type bytes,
resolve_codec, ColorInfo, window_loss_ppm, pairing msgs
quic/pake.rs the SPAKE2 pairing exchange
quic/datagram.rs 0xC9–0xCF plane codecs (audio/rumble/mic/rich-input/
hidout/HdrMeta/HostTiming)
quic/io.rs length-prefixed stream IO
quic/clock.rs clock_offset_ns estimator, clock_sync, ClockResync
quic/endpoint.rs quinn config, ALPN, pinning verifiers, keep-alive
quic/tests.rs the cross-cutting test module, unchanged
Mechanical deltas only: the nested `pub mod` wrappers became files (one
dedent), submodules import what they previously inherited from the parent
scope, and the three RichInput kind tags are pub(super) for the tests
(same-module before). Verified line-multiset-identical after normalizing
indentation. cargo check --workspace, core tests (quic), clippy, and
cargo ndk check all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Networking-audit deferred plan §2. The host↔client offset was measured once
at connect; an NTP step or slow drift silently corrupted the clock-based
jump-to-live signal, the ABR one-way-delay signal, and every latency stat —
4a3b1ae2's disarm backstop stopped the IDR storm but lost the detector for
the session. Now the client re-estimates mid-stream and recovers it.
- quic: ClockResync — the connect-time 8-round probe/echo estimate as a
select!-driven state machine (rounds matched by echoed t1, stale batches
ignored), plus accept_resync (batch min-RTT ≤ max(2 ms, 1.5× connect RTT)
so a congested window can never bias the offset). No wire change: the
host has always answered ClockProbe at any time on the control stream.
- client: the offset lives in an Arc<AtomicI64> seeded at connect; the
control task re-probes every 60 s and immediately after the pump's FIRST
no-op clock flush (the "clock stepped under me" signal, sent on the next
report tick). On apply: store, reset stale_frames/noop_clock_flushes,
re-arm the clock detector if a step had disarmed it. The disarm heuristic
stays as the final backstop. Public NativeClient::clock_offset_ns keeps
the connect-time value (ABI untouched); new clock_offset_now_ns() /
clock_offset_shared() expose the live value.
- consumers migrated to the live offset: pf-client-core session stats, the
pf-presenter e2e stamp, Windows session/render, Android feeder/drain/
DisplayTracker (the tracker holds the shared handle, not the client, so
the leaked render-callback refcount can't pin the session).
- probe: --clock-resync runs a second full handshake mid-connection and
asserts a sane, consistent estimate. Live against the local canary host:
offsets 8646/2139 ns, disagreement 6 µs, 8/8 rounds — OK.
Unit tests cover the round collection, stale-echo rejection, batch restart,
min-RTT selection, and the acceptance guard. cargo ndk check green.
Remaining manual validation: `sudo date -s "+2 sec"` on a live streaming
client → expect one no-op flush, a re-sync, re-armed detector, no IDR pulse.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stage B of the zero-copy host packetize path (networking-audit deferred
plan §1): Packetizer::packetize_each yields (header, shard) pairs in exact
wire order; Session::seal_frame writes seq(8) ‖ header(40) ‖ shard ‖ tag
scratch directly into the pooled wire buffer and seals [8..] in place. The
per-packet intermediate Vec (header ++ body) and its extra memcpy are gone
— with Stage A, every data byte is now copied once (frame → wire) instead
of three times, and the ~2 transient allocs/packet on the send thread are
zero after pool warmup (~180k allocs/s at 1 Gbps rates).
packetize() stays as a thin wrapper over packetize_each — the reference
implementation used by tests and the loss harness.
- wire-equivalence test: pooled path vs wrapper path byte-identical across
multi-block/partial-tail/exact-multiple/empty frames, fec 0%/50%, both
schemes, crypto on/off
- loss-harness sweep: recovery rates identical to the pre-item-1 baseline
- bench pipeline (end-to-end incl. client half) vs pre-item-1 baseline,
stages A+B cumulative: gf16/64K -3.6%, gf16/1M -3.2%; gf8 cases are
Cauchy-math-bound and unchanged within noise
- cargo ndk check (arm64-v8a) green
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stage A of the zero-copy host packetize path (networking-audit deferred
plan §1): ErasureCoder::encode now takes &[&[u8]], so Packetizer::packetize
builds each block's data shards as slices straight into the frame buffer
instead of allocating + copying a Vec per data shard. Only the frame's
final (possibly partial) shard is staged in a reusable zero-padded scratch;
blocks are consecutive shard ranges, so every other shard is a full
payload-sized slice.
- gf8: encode_sep() over the same Cauchy codec — parity byte-identical to
nanors/Moonlight (nanors_exact_parity_vectors unchanged and green)
- gf16: reed_solomon_simd::encode is already generic over AsRef<[u8]>
- loss-harness sweep: recovery rates identical before/after
- bench pipeline (end-to-end, host+client): gf8/64K -3.0%, gf16/64K -2.2%,
gf16/1M -3.4%, gf8/1M -0.7%
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The four trailing single-byte fields (video_caps, audio_channels, video_codecs,
preferred_codec) each recomputed the name/launch offset chain from scratch —
four copies of the same three-line walk, each a chance to diverge when the next
trailing field lands. Compute name_len/launch_off/tail once and index from
there; name/launch decode from the same bindings. Wire behaviour pinned by the
existing roundtrip + back-compat tests (all green).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 0xC9 audio datagrams ride the lossy plane with no FEC, and no client ever
consulted the per-packet sequence: a lost 5 ms Opus packet played out as a hard
gap in the ring — an audible click/pop on every drop, i.e. constantly on the
Wi-Fi links where video loss is already being FEC-absorbed.
Now a shared `AudioGapTracker` (punktfunk-core::audio — pure data, wrap-safe,
unit-tested incl. u32 wraparound / reorder / duplicate cases) tells the decoder
how many packets went missing immediately before each received one, and both
native clients (pf-client-core PipeWire path, Android AAudio path) synthesize
that many frames of libopus packet-loss concealment first: `decode` with empty
input (the opus crate maps it to a NULL data pointer = PLC), sized by the last
real frame's sample count. Interpolated fade instead of a click.
Bounds: a gap is capped at 10 packets (50 ms) — libopus PLC fades to silence
after a few frames anyway, so past the cap the rings' existing underrun/re-prime
path takes over. Reorders and duplicates conceal nothing (the plane has no
reorder buffer; playing a late packet where it lands is the existing behaviour).
In-band Opus FEC (LBRR) is deliberately NOT used: the host sends 5 ms frames
and LBRR needs ≥10 ms frames to carry anything.
The cap is a crate-private const so cbindgen keeps it out of the C ABI header.
Host cargo tests + clippy green; android crate verified via cargo ndk check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The clock-based jump-to-live detector compares wall-clock receive time against
the CONNECT-TIME skew offset. A wall-clock step on either end (NTP mid-session,
resume-from-sleep correction) shifts every future frame's apparent latency by a
constant: past the 400 ms bound the detector fires forever — one backlog flush +
recovery IDR every 2 s cooldown, and the bitrate controller rides the repeated
"flushed" bad windows down to its floor. A stream that was perfectly live turns
into a periodic quality pulse with no recovery path.
The tell is in the flush itself: a genuine 400 ms backlog is ≥~170 datagrams
even at the 5 Mbps bitrate floor, but a clock-step flush finds nothing to
discard. So: two consecutive clock-triggered flushes that discarded <64
datagrams and zero queued AUs disarm the clock detector for the session (logged).
This also covers upstream router bufferbloat — delay standing in a queue a local
flush can't drain, where the OWD signal to the bitrate controller is the actual
remedy and a 2 s IDR cadence only feeds the congestion. The clock-free
queue-depth detector stays armed either way; it measures the local queue
directly and can't be fooled by a clock.
Rode along: the 11-field `Negotiated` tuple is now a documented struct — the
connect/worker plumbing reads as named fields instead of positional magic.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two receive-path findings from the networking audit:
1. The anti-replay window (4096 seqs) silently re-tightened the "late ≠ lost"
fix: at 1 Gbps (~125k pkt/s) it spans only ~33 ms, so a Wi-Fi-retry-delayed
shard the reassembler's 120 ms loss window would still use was dropped HERE
first as "older than the window" — recreating the false-loss → recovery-IDR
churn the time-based loss window was built to kill, exactly on the high-rate
links punktfunk targets. Widened to 32768 (covers 120 ms up to ~270k pkt/s,
≈2 Gbps+); the bitmap costs 4 KiB per session and the replay-hiding bound
stays finite.
2. Every received datagram still paid one Vec allocation in the AES-GCM open
(and a to_vec on the plaintext probe path) — ~125k allocs/s of cross-thread
allocator churn at line rate, the same class of overhead that was the
documented single-core wall on the macOS receive path. New
`SessionCrypto::open_in_place` (mirror of seal_in_place; GCM verifies the
tag BEFORE decrypting, so a forged packet never yields plaintext) lets
`poll_frame` decrypt inside the recv ring and hand the reassembler a slice.
Byte-identical semantics, unit-tested against `open` incl. tamper/runt
cases; criterion entry added next to seal_in_place.
Tests: 94 core unit + loopback/c_abi suites green; clippy clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`cargo fmt --all --check` on main flags decode.rs (android dlsym fix),
probe/main.rs (0600 key fix), and session.rs (anti-replay tests). The probe
one is restructured rather than machine-formatted: rustfmt wanted the key-
permissions comment gutter-aligned to the trailing `// the certificate is
public` comment, so fold both into one block comment above the write instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address findings from a repo security review:
- core: add a sliding-window anti-replay filter over the AEAD-authenticated
sequence in Session (poll_input/poll_frame), closing the input-replay gap the
data plane previously left to the LAN/VPN trust assumption. 4096-deep window,
unit-tested; the encrypted loopback suite confirms no false drops.
- clients: write the mTLS client private key 0600 and lock the config dir 0700
on Unix (it was world-readable at the umask default), re-locking existing
stores on load. pf-client-core::trust plus the probe's own identity writer.
Windows keeps the %APPDATA% ACL; Android/Apple already wrap the key.
- web: fix a post-login open redirect — resolve `next` via URL and require it to
stay same-origin, rejecting `/\evil.com` and tab/encoding variants the old
`!startsWith("//")` guard missed. Also fixes the dead safeNextPath helper.
- ci: SHA-256-pin the BtbN FFmpeg DLLs bundled into the signed Windows installer
(were fetched from the rolling `latest` tag unverified); fails closed on a
re-roll, matching the VB-CABLE gate.
- ci: fail-open fork-guard on the Windows/Apple host-mode PR build jobs that
share runner labels with the signing jobs. Definitive fix stays server-side
(Gitea outside-collaborator approval / isolated PR runners) — see the notes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Six parallel audits swept the root docs, docs-site, every per-directory
README, and the packaging docs; every claim below was verified against
the source before editing.
- README: Layout gains the six missing crates (pf-client-core,
pf-presenter, pf-console-ui, pf-ffvk, pf-driver-proto, punktfunk-tray),
clients/session, api/ and ci/; Linux/Windows client rows reflect the
shell + Vulkan-session split and the Vulkan Video -> VAAPI/D3D11VA ->
software decode chains; the "every client over a C ABI" claim is
corrected (Rust clients link the core directly); tiered stats overlay
+ console shell noted; Apple row mentions AV1.
- CONTRIBUTING: drop the dead CLAUDE.md link (deliberately untracked);
point at the README's build/invariants sections. SECURITY: 0.9.0.
- host-cli/pairing: --allow-pairing/--require-pairing are no-op legacy
names — pairing is required by default, --allow-tofu is the real flag;
document --data-port and --idle-timeout-ms.
- configuration: document PUNKTFUNK_RECOVER_SESSION_CMD (session-crash
recovery hook), PUNKTFUNK_MDNS, PUNKTFUNK_DATA_PORT.
- virtual-displays/gnome: GNOME per-client scaling shipped (host-
persisted) — flip the ❌ to ✅ and describe how it works.
- stats: new "Detail levels" section (Off/Compact/Normal/Detailed +
per-platform cycle gestures); retire the GTK hand-off note.
- clients/install-client/status/roadmap: decode chains, Windows client
validation narrowed to HDR-only pending, adaptive bitrate, console
shell, Apple AV1, Windows host vendor list.
- Sub-READMEs: clients/linux rewritten for the re-architecture; session
Windows decode rung + d3d11va knob; Windows tiered overlay; Android
minSdk 28; decky file table; host zerocopy/ path; scripts port
47992 and steamos-host.md; pf-dualsense source path.
- packaging: canary version bases are tag-derived (<next-minor> via
pf-version.sh/.ps1), codecs-extra not ffmpeg-full, document the
pinned offline-Skia tarball + SKIA_BINARIES_URL and vulkan-headers.
- Convert 15 dangling design/*.md links to the punktfunk-planning
prose convention (those docs live in the private planning repo).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two causes behind one field report (a held trigger jittering mid-game,
Android client → Windows host):
Android folded joystick ACTION_MOVEs from EVERY device into one axis
state. A controller's joystick-classified sibling node (DualSense/DS4
motion sensors) or a second/drifting pad reports every pad axis as 0,
so a held trigger flapped value→0→value on each event interleave. The
mapper now qualifies the source DEVICE (its source classes must include
GAMEPAD — a joystick event's own source is always plain JOYSTICK), pins
to one deviceId until that device disconnects, and merges LTRIGGER/BRAKE
(and RTRIGGER/GAS) with max, the same fold as the Controllers probe.
Underneath, gamepad input rode per-transition events over unreliable,
unordered QUIC datagrams — no sequence numbers, sharing the 4 KiB
oldest-first-shed send buffer — so one dropped or reordered event
corrupted held pad state until the NEXT change. Gamepad state now
travels the way rumble already does: idempotent state, refreshed.
InputKind::GamepadState packs the whole pad + a wrapping u8 seq into
the existing 18-byte layout; the host advertises HOST_CAP_GAMEPAD_STATE
(Welcome trailing byte, offset 67) and applies snapshots through a
per-pad stale-seq gate, skipping frame emits for unchanged refreshes;
the client folds embedder events into snapshots inside NativeClient's
input task (send on change + 100 ms refresh of touched pads), so the
SDL clients (Linux/Windows/session), Android, and Apple (C ABI) are all
covered with zero capture-code changes. Either end older ⇒ the legacy
per-transition path runs unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The windows workflow has been red since a69a83b5: clippy 1.96 rejects the two
field-reassign-with-default view-desc initializers in video_d3d11.rs (now struct
literals), and with clippy failing first, the rustfmt step never ran — cargo fmt
--all had genuine misses queued up in video_d3d11.rs / pf-presenter d3d11.rs +
vk.rs / core abr.rs + client.rs (plus this session's config.rs). Formatting only
beyond the two initializers; no behaviour change.
Verified: clippy -p pf-client-core --all-targets -D warnings clean on the RTX
Windows box, cargo fmt --all --check clean, core lib tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 1 of the dual-stack plan (design/client-parity-and-network-resilience.md, plan 5):
the host sizes each session's shard_payload from the QUIC remote's address family instead
of assuming IPv4 — 1408 over v4 (unchanged), 1388 over v6 (40-byte header). Rides the
existing Welcome::shard_payload negotiation, so there is zero wire change and old clients
simply follow.
This has to land before any v6 data path exists: the v4-maximal 1408 makes every sealed
video datagram overshoot a 1500-MTU IPv6 hop, and v6 routers never fragment — that's a
blackhole (every datagram dropped), not the graceful-ish degradation of the b5c30df v4
fragmentation saga. IPv4-mapped v6 remotes (::ffff:a.b.c.d, what a dual-stack [::] socket
reports for a v4 client) correctly keep the v4 size — they ride IPv4 on the wire.
New mtu1500_shard_payload_v6()/mtu1500_shard_payload_for() in core config with the same
pinned never-fragments/maximality tests as the v4 constant, plus a family-selection test.
Verified: 82 core lib tests + loopback/c_abi green and host check/clippy clean on Linux
(home-worker-2); core tests green on macOS.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New SetBitrate (0x05) / BitrateChanged (0x06) control messages: the host clamps like the
Hello request, acks the resolved rate, and rebuilds the ENCODER ONLY in place (same mode,
first new-rate frame is an IDR — the proven mode-switch resync, minus the pipeline churn).
The client side is an AIMD controller (core abr.rs) in the data-plane pump, armed only when
the user's bitrate is Automatic (Hello bitrate_kbps == 0): ×0.7 after two bad 750 ms windows
(FEC-unrecoverable frames, ≥2% loss, one-way-delay rise above its rolling baseline — the
pre-loss bufferbloat signal off the clock-skew handshake — or a jump-to-live flush), ~+6%
after ~10 s clean, ceiling = the session's starting rate, 3 s cooldown, self-disables against
a host that never acks (older build). Division of labour: adaptive FEC keeps answering fast
random loss; bitrate now answers persistent congestion, closing the FEC death-spiral gap.
The web-console sample reports the live rate. Also: join_host_port() brackets bare IPv6
literals before SocketAddr parsing (parse-side IPv6 groundwork, pairs with the next commit).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a bounded, trust-agnostic, mDNS-INDEPENDENT QUIC reachability probe and
surface it everywhere saved-host presence is shown, so a host reached over a
routed network (Tailscale/VPN/multicast-filtering LAN) no longer reads Offline
just because it isn't advertising — the display-side companion to the 0.8.4
dial-first connect fix.
Core:
- punktfunk-core: NativeClient::probe (bounded handshake; a real host answers even
on trust mismatch, a wrong/closed/TCP-only port fails) + punktfunk_probe C ABI
(ABI_VERSION 3->4, header regenerated).
- pf-client-core: trust::probe_reachable_many (parallel per-host sweep).
Presence pips now read `advertising OR probed-reachable`, refreshed by a ~10-12s
background sweep off the UI thread:
- Linux (relm4): ui_hosts probed map + HostsMsg::Probed sweep.
- Windows (windows-reactor): pf-probe worker -> HostsProps.probed.
- Apple (SwiftUI): HostStore.refreshReachability, driven by HomeView + GamepadHomeView .task.
- Android (Compose): nativeProbe JNI seam + periodic LaunchedEffect (LNP-gated),
online dot added to the touch HostCard.
- Decky already probes via --list-hosts --probe.
Decky client: make the flatpak client's known-hosts store the single source of
truth via new headless CLI modes (--list-hosts / --add-host / --set-host /
--forget-host / --reset / --reachable). The plugin can now add a host by address,
edit/forget hosts, reset all state (keeping the client identity), and shows
probe-backed online pips — state is shared with the desktop client, not duplicated.
Also lands in-progress Android 17 LNP groundwork (targetSdk 37 +
ACCESS_LOCAL_NETWORK runtime flow, permission dialogs) that was already present in
the working tree.
Verified: cargo check + clippy clean (punktfunk-core, pf-client-core, linux,
android native); android assembleDebug BUILD SUCCESSFUL; decky typecheck + rollup
build clean; probe true/false-positive behaviour exercised against a live host.
Windows and Apple were not compiled locally (no MSVC/Xcode on this Linux box).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
quinn's default 1 MiB datagram FIFO holds tens of seconds of Opus; on a
congested link the audio/rumble/input planes built a standing delay that
never drained while video (own latest-wins UDP path) stayed live —
observed on the Deck as audio and rumble lagging together by the same
experience-destroying amount. quinn sheds oldest-first at the cap, so a
4 KiB bound makes the plane latest-wins at the source: ~200 ms of stereo
Opus worst case, and sustained congestion costs concealable drops
instead of lag.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stats overlay verbosity tiers (Off/Compact/Normal/Detailed, 3-finger tap
cycles live, old Boolean pref migrated) and the unified-spec line-4
reliability counters: lost/FEC windowed from the connector's cumulative
totals, skipped from the client's own newest-wins drops. Adds the
fec_recovered_shards accessor to NativeClient, mirrored from the
data-plane pump like frames_dropped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both Deck trackpads were inverted on the virtual pad (first live Deck-to-Deck
session): the client sends SDL's screen-convention coordinates (+y down)
onto the wire, but the Deck's raw report fields are stick convention (+y up)
— and Steam Input parses our report as real Deck hardware — while the host
applier passed the value straight through.
Pin the wire meaning down (quic.rs: TouchpadEx/Touchpad are screen
convention, +x right +y down — what every client capture API produces) and
translate to device-raw in the host applier, the layer that knows the
quirk: steam_proto's apply_rich now negates y (saturating: -32768 has no
i16 negation) for both TouchpadEx surfaces and the DualSense-standin
Touchpad arm, which carried the same latent inversion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The embedder-facing frame queue was a 16-deep sync_channel whose try_send
dropped the NEWEST access unit on overflow — backwards for a live stream (keeps
stale, discards fresh), a ~266 ms floor that could not self-drain (producer and
consumer both run at frame rate, so any depth a burst injects is conserved
forever — the latency ratchet), and a silent reference-chain break the loss
counters never saw. The clock-based flush meant to catch it was gated on the
skew handshake and never even drained that queue.
Replace it with a purpose-built FrameChannel (VecDeque + Condvar) exposing
depth() and clear(). Pre-decode AUs are reference-chained under the host's
infinite GOP, so they are never dropped mid-stream; instead, when the embedder
falls persistently behind, the pump JUMPS TO LIVE — flush_backlog() + clear the
queued AUs + request a keyframe — so decode re-anchors cleanly at an IDR.
Two cooldown-gated detectors, both suspended during a speed test:
- clock-based (existing): > FLUSH_LATENCY behind the skew-corrected clock for
FLUSH_AFTER_FRAMES straight; also catches kernel/reassembler backlog.
- clock-free (new): the hand-off queue sat >= QUEUE_HIGH without draining to
QUEUE_LOW for STANDING_FRAMES straight. Works on same-clock / no-handshake
sessions where the clock path is disarmed — the direct "the embedder can't
keep up" signal. A transient Wi-Fi clump drains in a few frames and never
trips it.
Bounded (90-frame hard cap, drop-oldest memory backstop) and diagnosable (each
jump logs queue_depth / flushed_datagrams / dropped_frames). next_frame's
external Timeout/Closed contract is unchanged, so every native client inherits
the fix. Adds 5 FrameChannel unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root-caused live on a phone at 100 Mbps (stream stuck seconds behind, then
oscillating): a stack of transport defects, each amplifying the next.
- MTU-safe shards: shard_payload 1452 overshot the IPv4/1500 budget (the old
math forgot the 40 B header + 24 B crypto ride inside the UDP payload and
counted IP+UDP as 8 B) — the kernel silently split EVERY video datagram into
two IP fragments, doubling per-datagram loss on Wi-Fi. New
config::mtu1500_shard_payload() = 1408 (1472 sealed = the exact ceiling),
negotiated in the Welcome, pinned by a unit test.
- Android batched I/O: recv/send batching was cfg(linux); Android is
target_os="android" and silently fell back to a syscall per datagram. The
libc crate binds neither recvmmsg/sendmmsg nor mmsghdr for Android, so a
local bionic extern binding provides them (API 21+, floor is 28); cbindgen
excludes them from the C header. The pump/runtime threads also get the
Apple-QoS analogue on Android: nice −8 (below the decode thread's −10).
- Latency-bounded receive: packets are consumed strictly in order at exactly
the arrival rate, so a standing queue (Wi-Fi stall, power-save clumping)
NEVER drains — observed as a stream permanently 6-7 s behind with both 32 MB
socket buffers full. The pump now flushes the entire backlog
(Session::flush_backlog: discard ring + kernel queue at memcpy speed, reset
the reassembler) and requests a keyframe when frames keep completing > 400 ms
behind the skew-corrected capture clock (30 consecutive, 2 s cooldown,
logged).
- Time-based loss window: the reassembler declared an incomplete frame lost a
fixed 4 INDICES behind the newest — 33 ms at 120 fps, inside normal Wi-Fi
retry/reorder timescales, so merely-late frames were pruned every few
seconds, each costing a recovery-IDR burst + an inflated loss report.
Now 120 ms of capture time (LOSS_WINDOW_NS), same fuse at every refresh
rate, with a 64-index hard cap bounding memory against hostile pts.
- Adaptive-FEC hysteresis: the controller was memoryless — one clean 750 ms
report dropped FEC from 8 % straight back to the 1 % floor, so periodic burst
loss (Wi-Fi scan / BT coexistence beats) always hit an unprotected stream and
ping-ponged 1↔8 % with a frozen frame per cycle (observed in the host log as
alternating loss_ppm=0/50000). Attack stays instant; decay is now one point
per clean report.
Verified: full core suite (incl. new flush + time-window tests) on macOS +
Linux, host release build, arm64 cargo-ndk build, and a 30 s wired probe run
at 2800x1260@120 — 3559/3559 frames, zero loss, capture→received p50 5.3 ms
(host 5.1 + network 0.3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 5dc24a0 low-latency overhaul regressed badly on some phones. Every piece
of it — decoder ranking, per-SoC vendor keys, the async decode loop, pipeline
thread boosts, the ADPF max-performance bias, game-tagged AAudio, DSCP marking,
the Wi-Fi low-latency lock, HDMI ALLM and the forced TV mode switch — now rides
the "Low-latency mode (experimental)" toggle, default OFF. Off restores the
pre-overhaul pipeline byte-for-byte: the sync poll loop, the platform-default
decoder, and the original format keys (standard low-latency + blind Qualcomm
twin + priority=0 + operating-rate=MAX together).
- New pref key (low_latency_mode_experimental): the old key shipped default-ON,
so any install that ever saved settings persisted true — flipping the default
under the old key would leave exactly the regressed devices stuck on.
- DSCP is applied at socket creation, so the toggle reaches the transport via
NativeBridge.nativeSetLowLatencyMode → transport::set_dscp_default, called in
the connect choke point before nativeConnect; the core DSCP default reverts
to off everywhere.
- nativeStartAudio(handle, lowLatencyMode) gates AAudio usage=Game.
- VideoDecoders.pickDecoder now skips `.secure` decoder twins and decoders that
require FEATURE_SecurePlayback: they need a secure surface, and a secure twin
could out-score its plain sibling (only it advertising FEATURE_LowLatency),
which black-screens a clear stream.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The core's deliberate-quit close (NativeClient::disconnect_quit → QUIT_CLOSE_CODE,
host skips the keep-alive linger) was implemented but never called by any client.
Wire it to each client's explicit user-disconnect action — NOT to a network drop /
host-ended / app-background (those keep the linger for a reconnect):
- core: new C-ABI punktfunk_connection_disconnect_quit(c) for the ABI clients
- Linux (direct-core): Ctrl+Alt+Shift+D + the controller escape chord
- Windows (direct-core): Ctrl+Alt+Shift+D
- Apple (C-ABI): PunktfunkConnection.disconnectQuit() + a `deliberate` flag on
SessionModel.disconnect() (sessionEnded passes false → keeps the linger)
- Android (JNI): new nativeDisconnectQuit export, called from the back gesture +
the Select+Start+L1+R1 chord (not the host-gone watchdog)
- probe already did this via --quit (77871d6)
Verified: core + Linux client + Android (cargo-ndk + gradle) build clean;
Windows/Apple compile-checked by CI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Close the latency gap on the Android client with per-SoC decoder tuning, an
event-driven decode loop, and full system integration.
- Decoder selection: rank MediaCodecList decoders in Kotlin (hardware/vendor
preferred, software avoided, FEATURE_LowLatency probed) and create the chosen
one by name. Per-SoC low-latency keys gated on the codec-name prefix: Qualcomm
picture-order + low-latency, Exynos (also Google Tensor), Amlogic, HiSilicon;
MediaTek vdec-lowlatency set unconditionally. operating-rate = MAX (Qualcomm)
vs priority = 0 (else) are mutually exclusive. NVIDIA/Rockchip/Realtek have no
vendor key — covered by ranking + the standard low-latency key.
- Async decode loop: AMediaCodec async-notify replaces the poll loop, presenting a
decoded frame the instant it is ready instead of waiting out a poll interval.
Behind USE_ASYNC_DECODE with the synchronous loop kept for A/B during bring-up.
- System integration: Wi-Fi FULL_LOW_LATENCY lock and HDMI ALLM
(setPreferMinimalPostProcessing) for the stream's lifetime; game_mode_config.xml
opting out of OEM downscaling / FPS overrides.
- Pipeline: boost the data-plane pump + audio thread priorities, AAudio usage=Game,
DSCP marking on by default on Android, ADPF setPreferPowerEfficiency(false),
and setFrameRateWithChangeStrategy(ALWAYS) to force the HDMI mode switch on TV.
- lowLatencyMode master toggle (default on) as the escape hatch; the stats HUD now
shows the resolved decoder name.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On-glass testing (Test 2, KWin .116) surfaced that a reconnect within the QUIC idle-timeout
window (~8s) lands on a fresh SECOND display instead of reusing the kept one: the old session
was still Active (not yet Lingering), so the registry's keep-alive reuse (which only matches
Lingering) skipped it and the old session kept streaming to nobody. Three fixes:
#3 Same-client reconnect preempt (the real fix): admission::preempt_same_identity() lists a
reconnecting client's OWN still-live session(s) (same cert fingerprint); serve_session signals
their stop + waits the release grace BEFORE acquiring, so the zombie tears down → its display
lingers → the reconnect REUSES it instead of making a second. Implements the "preempts
downstream" the admission docs already promised. Independent of the mode_conflict policy; the
pure core (same_identity_stops) is unit-tested.
#2 Deliberate quit skips linger: a client that deliberately disconnects closes the QUIC connection
with QUIT_CLOSE_CODE (0x51, shared in core::quic); the host reads the ApplicationClosed reason
and tears the display down immediately (registry release() gained force_immediate →
Linger::Immediate; multi-session-safe via the pure lifecycle machine), while a bare disconnect
still lingers for reconnect. Threaded via a session quit flag → the DisplayLease.
NativeClient::disconnect_quit() + punktfunk-probe --quit drive it; GameStream (Quit App /
h_cancel) is a documented follow-up.
#1 Configurable disconnect-detection latency: the QUIC control-connection idle timeout
(stream_transport, 8s default) is host-tunable via --idle-timeout-ms / PUNKTFUNK_IDLE_TIMEOUT_MS,
clamped >=1s with a keep-alive that scales to it so a live session never false-closes. Default
unchanged (8s stays load-bearing for the Windows IDD-push reconnect flow).
Workspace check + 63 core / 215 host / 47 vdisplay tests green; clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native data plane used a random ephemeral UDP port (hole-punched), which a
strict firewall can't pre-open — so remote clients behind one couldn't connect.
Add an optional fixed data port:
- `Punktfunk1Options`/`NativeServe` gain `data_port`; `bind_data_socket` binds the
fixed port (→ direct, no hole-punch) or falls back to a random port + hole-punch
when unset or the fixed port is busy (a concurrent session already holds it).
- `UdpTransport::from_socket`/`from_socket_punch` adopt an already-bound socket, so
the host keeps the SAME data socket from handshake through streaming — no
drop-then-rebind window in which a concurrent session could steal a fixed port.
- `main.rs` wires the CLI flag through to `NativeServe`.
- Firewall docs updated (troubleshooting.md + apt/pacman/bazzite READMEs): control
plane is the fixed UDP 9777; the data plane is a separate random port that usually
needs no rule, with the fixed-port option for strict firewalls.
Unit-tested: default random+hole-punch, and fixed-port-then-fallback-when-busy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ABI_VERSION was doing double duty: the embeddable C surface AND the punktfunk/1
Hello/Welcome version that hosts equality-check. The WoL feature's v3 bump added
a client-local FFI function without changing a single wire byte — and every new
client started refusing against every deployed host ("ABI mismatch: client 3
host 2", observed live Deck → Bazzite). The wire now carries its own
WIRE_VERSION (still 2); ABI_VERSION stays 3 for the C header and the mgmt API's
informational field. Bump WIRE_VERSION only when the handshake/planes actually
change incompatibly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Wake-on-LAN batch landed with lints that fail `clippy -D warnings`
(doc continuation, char-array split, io::Error::other, redundant closure)
and an ungated `mod wol;` in the Windows client, which pulls windows-only
crates into the non-Windows stub build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a runtime-free Wake-on-LAN sender in punktfunk-core (per-interface subnet-directed broadcast + 255.255.255.255 on ports 9/7, repeated, optional last-known-IP unicast) exposed both as a Rust fn and a punktfunk_wake_on_lan C-ABI (ABI v3), plus a parse_mac helper. The host enumerates its wake-capable NIC MAC(s) and advertises them in a new mDNS `mac` TXT record (routed NIC first), and best-effort detects & warns (never modifies) when the NIC isn't armed for WoL.
MAC delivery is via the unauthenticated mDNS TXT rather than the connection handshake by design: a spoofed MAC only makes a wake fail (the packet is inert; the cert fingerprint still gates the connection), and it avoids threading through the hot connect path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The unified-stats equation's host+network stage was one opaque number
because the wire carried nothing but pts_ns. Now the host reports its own
share per frame: when the client's Hello sets VIDEO_CAP_HOST_TIMING (0x08),
the send thread emits a 13-byte 0xCF datagram — [tag][pts_ns u64][host_us
u32] — right after the AU's last packet leaves the socket, so host_us =
capture→fully-sent (capture read/convert, encode, FEC+seal, paced send)
against the same anchor the wire pts carries. Clients correlate by pts_ns
and derive network = (received + clock_offset − pts) − host_us; the two
terms tile per frame by construction.
Back-compat is free in all four combinations: old clients ignore unknown
datagram tags, old hosts ignore unknown cap bits (client keeps the combined
stage). The hardened data-plane format is untouched — this rides the
established QUIC side-plane pattern (0xC8…0xCE). NativeClient ORs the bit
in unconditionally and exposes next_host_timing(); the C ABI gains
PunktfunkHostTiming + punktfunk_connection_next_host_timing (additive).
The synthetic host emits 0xCF too, so pure-loopback protocol tests cover
the plane.
The probe reports the split (host_p50/p95_us · net_p50/p95_us) and is our
direct analogue of Sunshine's "host processing latency" — ours additionally
includes the paced send.
Validated on loopback (synthetic host + probe, debug build): 240/240 AUs
matched, host_p50 6.5 ms + net_p50 6.4 ms ≈ capture→received p50 13.0 ms.
Core suite + new 0xCF roundtrip/truncation test green; host+core+probe
clippy clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One stat model everywhere (design/stats-unification.md): four measurement
points (capture/received/decoded/displayed), three stages that tile the
interval exactly, and a HUD that shows the addition explicitly —
end-to-end 14.2 ms p50 · 19.8 p95 · capture→on-glass
= host+network 9.8 + decode 2.1 + display 2.3
replacing each client's ad-hoc mix of overlapping absolutes (the Apple HUD's
three arrow lines that looked sequential but weren't), mean-vs-median decode
times (Windows/Linux), missing same-host-clock flags (Windows/Linux), and
three different names for the same capture→received measurement (probe's
"reassembled", Apple/Android's "client", Windows/Linux's post-decode "lat").
Per client: Apple threads receivedNs through the VT decode via the frame
refcon bit pattern so the decode stage exists at all (stage-1 fallback
honestly degrades to a capture→received headline); Windows carries
FrameTimes through the existing frame channel to the render thread and adds
e2e p50/p95 post-Present; Linux stamps received at AU pop and rides
decoded_ns on DecodedFrame to the paintable-set site; Android pairs receipt
stamps with MediaCodec output buffers via the codec's pts round-trip (JNI
stats array 14→16 doubles, indexes 0-13 unchanged). fps now uniformly counts
received AUs; lost/(received+lost) per window, hidden at zero.
docs-site gains "Understanding the Stats Overlay": what each line means, why
the equation only approximately sums (percentiles), and a line-by-line
Moonlight/Sunshine matrix — including that Moonlight has no end-to-end
number and its "network latency" is an ENet control RTT, so punktfunk's
headline must not be compared against any single Moonlight line.
Verified here: linux client + probe + core check/clippy/fmt green, android
native cargo-ndk arm64 check green. Pending: Windows CI + on-glass, swift
test on the mac, on-device Android.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three levers to lower and steady decode latency on Snapdragon (Adreno) devices:
- ADPF (Adaptive Performance Framework): a new dlsym-resolved hint session
(native/src/adpf.rs; API-33+, resolved at runtime so there's no build-time
link dependency and libpunktfunk_android.so still loads on API 31/32) tells
the CPU governor the video pipeline runs a per-frame real-time workload, so it
keeps those threads on fast cores at high clocks. It now covers all three
latency-critical threads — the pf-decode feed/drain/present loop, the core
data-plane pump (UDP receive + FEC reassembly), and the audio thread — via a
new generic hot-thread registry on NativeClient (register_hot_thread /
hot_thread_ids; the pump self-registers). The session is built lazily on the
first presented frame, since ADPF createSession rejects a set containing any
not-yet-live tid.
- operating-rate -> Short.MAX ("as fast as possible"): pushes the Qualcomm
decoder to run each frame at max clocks instead of merely sustaining the
display rate at a power-saving clock that adds per-frame decode latency.
- appCategory="game": makes the app eligible for OEM Game Mode / Game Dashboard
performance profiles.
The core registry is cross-platform (gettid on Linux/Android, a no-op
elsewhere) — no Android-specific pollution of the shared core. Host workspace +
64 core tests green; Android arm64-v8a + x86_64 (platform 31) build + clippy
clean. On-device Snapdragon validation pending.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a client-selectable **preferred codec** and wires the core + ABI + probe + Linux client to
negotiate and decode it. (Windows/Apple/Android follow in 2b.)
**Core:**
- `Hello.preferred_codec` (a single CODEC_* bit, 0 = auto) — a soft hint appended after
`video_codecs`. `resolve_codec(client, host, preferred)` now honors the preference when the host
can also emit it, else falls back to precedence (HEVC > AV1 > H.264). Roundtrip + preference tests.
- `NativeClient::connect` takes `video_codecs` + `preferred_codec`; `NativeClient.codec` exposes the
resolved `Welcome.codec`.
- ABI: `punktfunk_connect_ex7` (adds the two codec params; `ex6` delegates to it advertising
HEVC-only) + `punktfunk_connection_codec` getter + `PUNKTFUNK_CODEC_{H264,HEVC,AV1}` constants
(drift-guarded against the wire values). Header regenerated.
**Host:** passes `hello.preferred_codec` into `resolve_codec`.
**probe:** `--codec h264|hevc|av1|auto` sets the preference (still advertises it can decode all
three); the dump extension already follows the resolved codec.
**Linux client:** advertises the codecs FFmpeg can actually decode (`decodable_codecs()`), threads
the user's `codec` setting as the preference, and builds the decoder — both the software and VAAPI
paths, plus the mid-session VAAPI→software demotion — from the negotiated `Welcome.codec` instead of
hardcoding HEVC. New "Video codec" dropdown in Preferences (Automatic/HEVC/H.264/AV1).
Live-validated on the dev box: probe `--codec hevc` against a software (H.264-only) host resolves to
H.264 (graceful soft-preference fallback), no failure. clippy + core (57) + host (133) tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 1 of codec negotiation, and the Linux software H.264 encode path it unblocks.
**Codec negotiation (core `quic`):**
- `Hello.video_codecs` (bitfield: CODEC_H264/HEVC/AV1) — the client advertises what it can
decode; appended as a trailing byte (older client → 0 = HEVC-only, back-compat).
- `Welcome.codec` — the single codec the host resolved and will emit; trailing byte (older
host → HEVC).
- `resolve_codec(client, host_capable)` picks the shared codec (precedence HEVC > AV1 > H.264)
or `None` → the host refuses honestly rather than sending an undecodable stream.
- Roundtrip + back-compat tests; cbindgen exports the CODEC_* constants.
**Software encoder (host):**
- The openh264 `OpenH264Encoder` (was Windows-only) is now built on Linux too — it's
platform-agnostic (consumes CPU RGB `CapturedFrame`s, statically-bundled openh264). `openh264`
moved to the shared linux+windows Cargo target.
- `PUNKTFUNK_ENCODER=software` selects it: `open_video` gains a `software` branch (H.264 only),
and `session_plan::resolve_encoder` / `capture::gpu_encode` resolve `EncoderBackend::Software`
→ `output_format().gpu = false`, so the portal capturer delivers CPU RGB. Explicit-only (auto
never picks it — a box with a dead driver still has /dev/nvidiactl and would mis-resolve NVENC).
**Host codec resolution (`punktfunk1`):**
- The native path no longer hardcodes HEVC: it resolves the codec from the client's advertised
set ∩ the host's capability (`Codec::host_wire_caps`: software→H.264, else HEVC), threads it
through `SessionPlan.codec`, and opens the encoder + validates reconfigures at that codec. A
software host + HEVC-only client is refused with a clear error.
- 4:4:4 is gated on HEVC (it's HEVC-only).
**Probe:** advertises H264|HEVC|AV1 and logs the resolved codec.
Validated on the GPU-less dev box: negotiation is live end-to-end (probe advertises 0x07 → host
resolves H.264 → Welcome reports it → plan = Software/H264), and the openh264 unit test (CPU RGB →
AnnexB IDR) now runs on Linux. Full capture→encode still needs a GPU on this box — every
compositor screencast path (KWin GL, gamescope VK_EXT_physical_device_drm, wlroots EGL) requires
one; software render (llvmpipe/pixman) can't be captured — so this box exercises negotiation +
encoder, not live capture. The software path unblocks GPU-less-*encode* boxes that still have a
display GPU. Phase 2 (clients advertising real codecs + decoding per Welcome.codec) is a follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rework the client READMEs to be accurate and inviting to first-time
visitors, and fill in the gaps where crates and tools had none.
- Rewrite clients/{apple,android,decky} READMEs (features-first, trim
dense internal narrative; drop the stale "one session at a time" /
"renegotiation not implemented" section from the Apple README).
- Add READMEs for clients/{linux,windows,probe}, which had none.
- Add crate READMEs for punktfunk-host, punktfunk-core, pf-driver-proto.
- Add brief READMEs for tools/{loss-harness,latency-probe}.
- Fix packaging/README duplicate "Option B" heading (bootc -> Option C).
- Fix docs-site/README stale docs/ -> design/ reference.
- De-stale packaging/windows/drivers/pf-dualsense README (drop "M0 spike"
/ external-checkout framing; reflect in-tree workspace + shipped +
installer-bundled + multi-pad), keeping the driver-authoring lore.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Finish the client side of the Steam Controller / Steam Deck pipeline.
- C-ABI (core abi.rs): PunktfunkRichInputEx — a size-prefixed superset of
PunktfunkRichInput that can express the second trackpad (surface), a distinct
click vs touch, signed coords + pressure — plus
punktfunk_connection_send_rich_input2 (the struct_size ABI-skew-guard
precedent). The only way a C client (Apple/embedders) can emit a TouchpadEx;
the legacy struct + send_rich_input stay byte-for-byte. punktfunk_core.h
regenerated.
- Decky (clients/decky): a "Steam Deck" gamepad type in Settings + an unmissable
Disable-Steam-Input instruction shown when it's selected (in Game Mode Steam
Input holds 0x1205, so the SDL HIDAPI Steam driver can't open the Deck's
controls until the user disables Steam Input for the shortcut). Plus a
best-effort, feature-detected disableSteamInputForShortcut() in launchStream —
never blocks/throws; the manual toggle is the documented source of truth.
- Apple parity (PunktfunkConnection.swift): GamepadType.steamController/steamDeck
(wire 5/6) + name parsing, so the resolved type round-trips. Capture is blocked
(GameController never surfaces a 0x28DE HID device).
- Android parity (Gamepad.kt): PREF_STEAMCONTROLLER/STEAMDECK + the Valve 0x28DE
PIDs in prefFor(). Rich-input capture stays out of scope (no rich-input plane
yet) — standard buttons/sticks resolve to the host's Steam Deck pad.
Rust workspace clippy/fmt/test green; Decky src/ typechecks clean (only a
pre-existing @decky/api dep resolution error remains); Swift/Kotlin compile on
their CI. The full pipeline is now BUILT; what remains is validation that needs
hardware we don't have (a running Steam on the host, a live Deck client, the
Moonlight paddle regression). Not pushed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>