Wholesale commit of every uncommitted change across the tree, at the user's
explicit request — host refactor-campaign W1 (native.rs facade + native/ dir,
library/ + mgmt/ splits), Android, core. These streams were mid-flight and not
individually built/tested together; this supersedes the per-session HOLD
markers. Consolidating so everything lands on main in one pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Xcode widget-extension target that hosts the launcher widget + Live Activity
UI. Bundle id io.unom.punktfunk.widgets, iOS 17, App Group group.io.unom.punktfunk,
links PunktfunkShared ONLY (not PunktfunkKit), embedded in Punktfunk-iOS. Sources
come from the PunktfunkWidgets/ synchronized folder. Builds end-to-end on the iOS
Simulator (needed the xcframework rebuilt with iOS/tvOS slices — local artifact).
- project.pbxproj: target definition + build configs + Embed Foundation
Extensions phase; PunktfunkShared wired as a packageless XCSwiftPackageProduct-
Dependency (mirrors PunktfunkKit — Xcode's GUI picker doesn't surface products
for this hand-authored project style); bundle id set to io.unom.punktfunk.widgets.
- PunktfunkWidgetsExtension.entitlements: App Group only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LocalizedStringResource is ExpressibleByStringLiteral, so a single literal
converts implicitly, but the "…" + "…" concatenation is a runtime String it
can't convert. Collapsed to one literal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Live Activity's End button uses Button(intent:), whose initializer lives in
_AppIntents_SwiftUI — reached via `import AppIntents` (was missing, so the
widget target failed to build). Also replaced the iOS-26-deprecated Text + Text
concatenation in the background countdown with an HStack.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Xcode created the PunktfunkWidgetsExtension target with a file-system-
synchronized root group at clients/apple/PunktfunkWidgets/, so the target
compiles whatever lives there. Deleted the three generated stubs
(PunktfunkWidgets.swift / PunktfunkWidgetsBundle.swift /
PunktfunkWidgetsControl.swift — the stub @main WidgetBundle would collide with
ours) and moved our sources (PunktfunkWidgetBundle / HostsWidget /
SessionLiveActivity) from Sources/PunktfunkWidgets/ into PunktfunkWidgets/. Kept
the generated Info.plist (build-excluded via the sync exception set) and
Assets.xcassets. Still outside Sources/, so SwiftPM ignores it; swift build green.
project.pbxproj is intentionally NOT part of this commit — the target's
capability/signing edits (step 3) are still in progress in Xcode.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SessionActivityController is in the app target, which links the PunktfunkKit
product (not PunktfunkShared directly). Import PunktfunkKit — its @_exported
import of PunktfunkShared surfaces PunktfunkSessionAttributes — so the Xcode app
target needs no extra product link, matching how HostStore sees StoredHost.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The extension-side + App Intents surface for design/apple-live-activities-and-
widgets.md. The iOS-framework code (WidgetKit/ActivityKit/AppIntents) can't be
compiled by the macOS `swift build` CI target and needs the Xcode widget-
extension target that only exists once created in the GUI — see the checklist in
the memory note. What macOS DID verify: HostEntity (AppIntents is available on
macOS), the shared attribute/notification plumbing, and that nothing regressed
(142 tests green).
Shared (PunktfunkShared):
- PunktfunkSessionAttributes (ActivityAttributes) — the one type app + extension
share; gated os(iOS) (ActivityKit imports on macOS but its types are
unavailable, so canImport would wrongly admit it).
- EndStreamIntent (LiveActivityIntent) — posts .punktfunkEndActiveSession.
- HostEntity + HostEntityQuery (AppEntity over the shared store) — the intent /
widget-config parameter type; canImport(AppIntents), so macOS type-checks it.
- New notifications: end-active-session, open-deep-link.
M1 widget extension sources (Sources/PunktfunkWidgets/, NOT a SwiftPM target —
`swift build` ignores the dir):
- PunktfunkWidgetBundle (@main): HostsWidget + PunktfunkSessionLiveActivity.
- HostsWidget (kind "PunktfunkHosts"): reads the shared-suite store, sorts by
recency, deep-links each host; small/medium/accessory families; empty state.
- SessionLiveActivity: Lock-Screen banner + Dynamic Island (elapsed timer,
mode line, background countdown, End button).
M3 controller (app, iOS): SessionActivityController owns the Activity lifecycle
(request/update/end + launch orphan-sweep + staleDate); ContentView drives it
from the model's phase/isBackgrounded/backgroundDeadline (which SessionModel now
publishes), keeping ActivityKit out of the cross-platform model.
M4 (app, iOS): ConnectToHost/WakeHost intents + AppShortcutsProvider; Connect
routes via .punktfunkOpenDeepLink into the same onOpenURL router (one set of
guards); Wake reuses the WoL path; End surfaced to Shortcuts too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backgrounding a live session no longer freezes it when the user opts in: audio
keeps playing (UIBackgroundModes audio), the QUIC connection + pump stay live,
video decode is DROPPED, and a bounded timer auto-disconnects. Off by default.
- PunktfunkConnection.setVideoDropped/isVideoDropped: a tiny lock-guarded flag
both pumps read every iteration. StreamPump (stage-1), Stage2Pipeline (VT +
PyroWave) drain nextAU() for flow control but DISCARD the AU before any
VideoToolbox/Metal work — the crash/jetsam-safe seam (no GPU off-screen).
- SessionModel.enterBackground(timeoutMinutes:) / exitBackground(): set the drop
flag, mute the mic (privacy — SessionAudio.setMicMuted pauses the capture
engine), arm a DispatchSourceTimer that disconnect(deliberate:false)s on fire
(keeps host linger → fast late reconnect). exitBackground clears the flag and
requestKeyframe()s; the pump's freeze gate auto-arms on the resumed
frame-index gap so concealed frames are withheld until the IDR re-anchors.
disconnect() cancels the timer + clears isBackgrounded.
- ContentView scenePhase driver (iOS): .background+streaming+setting →
enterBackground; .active → exitBackground. scenePhase (not willResignActive)
so Control-Center/app-switcher peeks don't start the timer.
- Settings → General (iOS-only keepAliveSection): toggle + 1/5/10/30 timeout;
new keys backgroundKeepAlive (def off) / backgroundTimeoutMinutes (def 10).
- Info.plist: UIBackgroundModes [audio] + NSSupportsLiveActivities (for M3).
macOS swift build + swift test green (142 tests). The iOS-gated scenePhase
handler + settings section are not exercised by the macOS CI target (known §9
gap) — need on-glass verification (audio never gaps, video re-anchors <1s LAN,
timeout ends the session, phone-call audio-steal degrades gracefully).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundation milestone for Live Activities & Widgets (design/apple-live-
activities-and-widgets.md). No user-visible change beyond the URL scheme.
- New dependency-free PunktfunkShared SwiftPM target (+ library product) so a
future widget extension can link it WITHOUT PunktfunkKit (Rust staticlib +
presentation layer). Moves StoredHost (model + JSON codec), DefaultsKeys, and
punktfunkDefaultMgmtPort there; adds AppGroup.suiteName and the punktfunk://
DeepLink builder/parser. PunktfunkKit @_exported-imports it (no call-site
churn for consumers; intra-Kit files import it explicitly since imports are
file-scoped).
- HostStore reads/writes the shared App-Group suite (group.io.unom.punktfunk)
with a one-time migration from UserDefaults.standard (old value left in place
for staged rollout); reloads the "PunktfunkHosts" widget timeline on change.
- App Group entitlement on iOS/tvOS + macOS.
- CFBundleURLTypes scheme `punktfunk`; ContentView.onOpenURL routes
connect/<uuid>[?launch=<GameEntry.id>] into the existing connect() path
(unknown host / already-streaming guards; never tears down a live session).
- Round-trip tests: StoredHost JSON codec (+ legacy missing-optional decode),
DeepLink grammar. `swift build` + `swift test` green (142 tests, 0 failures).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The DualSense / DualSense Edge / DualShock 4 / Switch Pro / Steam Deck backends
all run through UhidManager, whose pump() forwarded rumble only on a level
CHANGE and had no idle watchdog. A game that latches a one-shot rumble (a
Stardew axe hit, a DS3 hit) and then stops writing output reports left
last_rumble non-zero; native.rs re-sends the latched level every ~120 ms with a
fresh TTL and the Apple RumbleRenderer refreshes its envelope on every renewal,
so the controller vibrated continuously until a later event happened to write a
report the host parsed as a stop. The XUSB path already guards against this
(RUMBLE_IDLE_TIMEOUT force-off, 19e9828e); that guard was never ported here, so
every UMDF pad regressed for game-abandoned rumble once clients began
negotiating first-class virtual DualSense/DS4/etc. on Windows.
Port the guard into UhidManager::pump, keyed on game ACTIVITY (a fresh output
report, even at an unchanged level) so a rumble the game keeps asserting is
never cut — only an abandoned residual. The activity signal rides a new
PadFeedback.game_drove: Option<bool>; the Windows backends set it from a fresh
out_seq (via a `fresh` flag on DsFeedback/Ds4Feedback; the Deck uses is_some()).
Linux backends leave it None (untracked → always-active → the force-off never
fires there), so their behaviour is unchanged. +2 deterministic unit tests.
Verified: cargo check -p punktfunk-host --tests green on both Windows (.173) and
Linux (home-worker-5); the 10 inject::uhid_manager tests pass on Linux.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
XusbWinPad::open swallowed a SwDeviceCreate failure — it returned Ok with
`_sw: None` (a pad with no devnode) and logged only a warn, so PadSlots latched a
phantom pad, called gate.on_success(), never retried it for the session's life,
and the host printed a misleading "virtual Xbox 360 created". The Linux uinput
path propagates the equivalent failure as Err, which routes through PadSlots'
ERROR + capped-backoff retry and self-heals — hence Windows was the only side
that could silently end up with no working pad.
Propagate the create failure with `?` so Windows gets the same ERROR + backoff
retry as Linux. Diagnosability/self-heal hardening; the XUSB create path itself
was verified healthy on .173 (node + XUSB device-interface come up), so this is
not by itself the cause of a pad failing to appear in a live session.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per plan §W5 (main-cli, 'devtest.rs land first'): move the inline-bodied dev/test
subcommand handlers out of main.rs's match into src/devtest.rs — input_test (Linux
libei/wlr injection smoke test + its non-Linux stub) and the virtual-gamepad
exercisers dualsense_test/switchpro_test (Linux UHID) and deck_windows_spike/
dualsense_windows_test (Windows UMDF + Steam Deck devnode spike). main.rs's arms
become one-line forwards; main.rs drops 1004→667 lines. The thin arms that already
forward to subsystem modules (zerocopy/capture selftests, probes) stay put — that
is their correct layer. Pure code-move (bodies verbatim; crate-local refs
qualified with crate::; one doc reword to dodge clippy doc_lazy_continuation now
that an arm comment became a /// doc).
Verified clippy 0/0 on BOTH Linux (home-worker-5, nvenc,vulkan-encode,pyrowave)
and Windows (.173, nvenc,amf-qsv — covers the cfg(windows) handlers).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per plan §W5: move sanitize_device_name (+ its NAME_MAX cap and unit test) out of
the native_pairing facade into native_pairing/sanitize.rs. It is a self-contained,
security-relevant leaf — the one place a wire-supplied unpaired-device name is
scrubbed of control chars / bidi-override spoofing before it is stored, listed,
logged, or shown in the approval UI. Re-export via `pub(crate) use` so
crate::native_pairing::sanitize_device_name stays stable (punktfunk1 accept loop +
the two in-crate callers). Pure code-move; verified host clippy 0/0 + 11
native_pairing tests green on Linux (home-worker-5).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per plan §2.5: the security-critical rustls fingerprint-pinning ServerCertVerifier
was hand-rolled three times — quic/endpoint.rs (PinVerify), pf-client-core
library.rs, punktfunk-tray status.rs — drifting copies on a trust boundary. Add
one canonical punktfunk_core::tls::PinVerify (+ cert_fingerprint) behind a light
`tls` feature (rustls + sha2 only, no QUIC runtime); `quic` now depends on it, and
quic::endpoint re-exports cert_fingerprint so that path stays byte-stable
(gamestream + pf-client-core reach it there).
- core::tls::PinVerify: new(pin) for the HTTP clients, with_observed(pin, slot)
for the QUIC TOFU connect. Behavior-identical to all three originals (pin-check
+ real CertificateVerify signature verification; only hashes the leaf when a pin
or observed slot needs it). Two focused unit tests anchor the boundary.
- quic/endpoint.rs: drop the private PinVerify, wire client_pinned through
tls::PinVerify::with_observed.
- pf-client-core library.rs + tray status.rs: use the shared verifier; tray also
routes load_pin through core cert_fingerprint and drops its direct sha2 dep,
gaining only the light core `tls` feature (still no host dep, no QUIC runtime).
Verified on Linux (home-worker-5): clippy 0/0 for core(quic), core(tls),
pf-client-core, tray, host(nvenc,vulkan-encode,pyrowave); core 153 lib tests +
loopback 7/7 (pinned handshake) + c_abi round-trip green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment-only: the lazy-attach comment carried the delivery-consumption
sentence twice after the stash rework.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per plan §2.1: a self-contained stateful subsystem does not belong in the
audio trait facade. Move MicPump + its PumpTuning/PUMP_TUNING, the
drain_sleep/pump_thread loop, MIC_CHANNELS/MIC_QUEUE_CAP, and the six pump
unit tests out of audio.rs into audio/mic_pump.rs. audio.rs keeps the
AudioCapturer/VirtualMic traits, their open_* factories, and the sample
constants. Re-export via `pub use mic_pump::MicPump` so crate::audio::MicPump
stays byte-stable (only consumer: punktfunk1.rs). Pure code-move; verified
clippy 0/0 + 6/6 pump tests green on Linux (home-worker-5).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DWM composes a display only when something dirties it, so a session opened
onto an idle desktop never produced a first frame: the host's synthetic-input
"compose kick" (cursor wiggle / sibling-display jump) was the only source, and
it is inherently unreliable — blocked on the secure desktop, defeated by a
fullscreen game's ClipCursor, user-visible, and dead in service contexts. The
field symptom: connect → black stream until something repaints the desktop.
Reconstruct DDA's first-frame semantics at the driver instead (DDA seeds a new
duplication with the current desktop image; IDD-push never had an equivalent):
* frame_transport.rs: new FrameStash — the retained last composed frame, a
driver-private copy-only texture. publish() now reports Published /
DescMismatch / Dropped, and harvest_into() pulls the last-published ring
slot into the stash (keyed-mutex guarded, freshness-checked) before a
superseded publisher is dropped — between sessions the driver keeps writing
the host-side-dead old ring, so that slot IS the current desktop image.
* swap_chain_processor.rs: the worker stashes every frame the ring can NOT
take (unattached, or descriptor-mismatched during a mode/HDR-flip race),
harvests before a supersede, and REPUBLISHES the stash into every freshly
attached ring — the host sees a normal seq=1 publish milliseconds after
channel delivery, no compose needed. Zero steady-state cost: matched
publishes touch only the ring. The frame-channel stash is now polled every
iteration (attach latency = first-frame latency; it was 1-in-30).
* monitor.rs: preserved_stash (LUID-tagged) so the retained frame survives
swap-chain unassign→reassign flaps, alongside the preserved publisher.
* host idd_push.rs: kick_dwm_compose demoted to documented last-resort
fallback for pre-stash drivers; a debug log now fires when a kick actually
runs so field logs show whether the stash path is working.
No proto change: the republish is an ordinary publish, so old host + new
driver and new host + old driver both keep working (the latter via the kick).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both backends' build_init_params authored an identical NV_ENC_INITIALIZE_PARAMS
(P1/ULL preset, PTD, session dimensions/rate, split-encode mode) — the only
difference was the Windows-only enableEncodeAsync flag (Linux is sync-only).
Hoist it to nvenc_core::build_init_params(codec_guid, w, h, fps, cfg, split_mode,
enable_async); Linux's two call sites pass enable_async=false (the field stays 0
as before), Windows passes its session_async through. Keeps open and in-place
reconfigure presenting the SAME init params, now guaranteed identical across
platforms too.
Verified on BOTH platforms: Linux clippy 0/0 (nvenc,vulkan-encode,pyrowave, RTX
5070 Ti) and Windows clippy 0/0 (nvenc,amf-qsv, RTX 4090 / .173).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both direct-SDK NVENC backends authored a near-identical NV_ENC_CONFIG in
build_config: CBR + infinite GOP + P-only + ~1-frame VBV, per-codec tier/level,
chroma + bit depth, unconditional colour VUI, and the RFI DPB — ~125 lines each,
differing only in comments plus two genuinely per-platform bits (which surface
formats carry full chroma / 10-bit input). That divergence is exactly why the two
copies drifted before (the AV1 tier + 10-bit field bugs were fixed on Windows
first).
Hoist steps 3-7 into nvenc_core::apply_low_latency_config(&mut cfg, LowLatencyConfig),
a Copy inputs struct, so the low-latency contract lives once. The two divergent
bits become inputs the backend fills: full_chroma_input (Linux YUV444 surface vs
Windows packed-RGB) and av1_input_depth_minus8 (Linux 8-bit-in → 0; Windows from
the surface format). Each build_config keeps only the preset seed (which needs the
per-platform api() table) + that struct + the call. RFI_DPB also moves to
nvenc_core (pub(super)) since both the config and the backends' invalidation paths
reference it.
Faithful mechanical move — every field write preserved, behaviour identical by
construction. Verified on BOTH platforms: Linux clippy 0/0 (nvenc,vulkan-encode,
pyrowave, RTX 5070 Ti) and Windows clippy 0/0 (nvenc,amf-qsv, RTX 4090 / .173).
Net -83 lines.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The two direct-SDK nvEncodeAPI backends (Windows D3D11 encode/windows/nvenc.rs,
Linux CUDA encode/linux/nvenc_cuda.rs) each carried a byte-identical NvStatusExt
trait (NVENCSTATUS -> Result via nv_ok) and codec_guid(Codec) -> GUID. Hoist
both into a new encode/nvenc_core.rs, the platform-agnostic sibling of the
existing encode/nvenc_status.rs (same cfg gate: any(linux,windows) + nvenc).
Each backend now imports them via super::nvenc_core; call sites (.nv_ok() ×16/20,
the one codec_guid() struct-init) are unchanged.
The per-platform machinery — entry-table load (nvEncodeAPI64.dll/LoadLibrary vs
libnvidia-encode.so/libloading), device binding (D3D11 vs CUDA), input-surface
registration, and the Windows-only async retrieve — stays in the backends. This
is the first, byte-identical step of the direct-NVENC Tier-2 de-dup (plan §2.2);
the larger build_config authoring is a later, carefully-diffed step.
Verified on BOTH platforms: Linux clippy 0/0 (nvenc,vulkan-encode,pyrowave, RTX
5070 Ti) and Windows clippy 0/0 (nvenc,amf-qsv, RTX 4090 / .173).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three libavcodec backends each set the identical low-latency rate-control
block on the not-yet-opened encoder context: fixed time_base/frame_rate, CBR
(bit_rate == max_bit_rate), B-frames off, and a tight ~1-frame VBV/HRD buffer
written through the raw rc_buffer_size field. Move it once into
apply_low_latency_rc(&mut video, fps, bitrate_bps), and let the long VBV
rationale (why the tight buffer prevents high-motion bursts from overflowing
the send queue) live in one place instead of only in the NVENC path.
Each backend keeps the two genuinely per-backend calls around it: set_format
(pixel format differs) before, and gop_size after (NVENC's infinite/intra-
refresh wave vs the VAAPI/AMF i32::MAX). No behavior change — the field writes
are independent, so the slightly different max_b_frames/rc_buffer_size ordering
across backends is irrelevant. Folding the raw rc_buffer_size write into the
helper also removes the NVENC path's separate unsafe block. Drops the now-unused
ffmpeg::Rational import from all three.
Linux check + clippy green (0/0, nvenc,vulkan-encode,pyrowave) on RTX 5070 Ti;
ffmpeg_win.rs is Windows-cfg, pending .173 compile.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three libavcodec backends each carried a byte-identical single-packet
receive_packet drain. Move it once into the shared Tier-2 glue as
poll_encoder -> PollOutcome (the richest form: Packet / Again / Eof), and
have the callers narrow it:
- Linux NVENC (encode/linux/mod.rs): poll() matches the shared fn, collapsing
Again|Eof to Ok(None) — was an inlined match, now one call.
- VAAPI (encode/linux/vaapi.rs): drop the local poll_encoder; the blocking
budget loop lets Again|Eof fall through to the deadline check, byte-identical
to the old Option::None path.
- Windows AMF/QSV (encode/windows/ffmpeg_win.rs): drop the local PollOutcome +
poll_encoder; its deadline-driven drain already matches PollOutcome, so only
the import changes.
No behavior change on any backend. Still a plain monomorphic free fn over a
borrowed &mut Encoder — no new per-frame dyn/Box/alloc; the only allocation is
the same bitstream to_vec() each path already made. Drops the now-unused
ffmpeg::Packet import from all three.
Linux check + clippy green (nvenc,vulkan-encode,pyrowave) on RTX 5070 Ti;
ffmpeg_win.rs is Windows-cfg, pending .173 compile.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First step of the W2 libav de-dup (plan §2.2, the missing Tier-2 mid-layer). The
three libavcodec backends (Linux NVENC, VAAPI, Windows AMF/QSV) each carried a
byte-identical pixel_to_av plus the SWS_POINT / SWS_CS_ITU709 (/SWS_CS_BT2020)
swscale consts. Hoist them into a new encode/libav.rs and import from super::libav.
The module is gated to compile exactly when a libav backend does (linux, or
windows+amf-qsv). Free fns/consts over borrowed handles — no per-frame dyn/alloc,
off the zero-copy path. Verified: Linux cargo check green (linux/mod.rs + vaapi.rs
compile against it); ffmpeg_win.rs is Windows-cfg — same mechanical swap, covered
by Windows CI on push.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The last three //!-less modules in the tree (plan §2.5 / §3.2):
- io: length-prefixed control-message framing (read_msg/write_msg)
- endpoint: QUIC endpoint construction + transport tuning + the TOFU
cert-pinning verifier (PinVerify)
- pake: SPAKE2 pairing key exchange
Docs only — no code, type, or wire-format change (cbindgen header byte-identical).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The libavcodec paths (Linux NVENC, VAAPI, Windows QSV) each re-parsed
PUNKTFUNK_VBV_FRAMES locally in f32, duplicating and diverging in precision from
the f64 vbv_frames_env() helper the direct-NVENC/AMF paths already use. Now that
the helper lives in encode/codec.rs (532b313b), route all three through
crate::encode::vbv_frames_env(): one parse, one precision, no drift.
Behaviour-identical (same filter finite && > 0, same 1.0 default), f64 not f32.
Verified: Linux cargo check green (linux/mod.rs + vaapi.rs compile); ffmpeg_win.rs
is Windows-cfg and mirrors the amf.rs/nvenc.rs sites already using the helper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Local snapshot of intermingled in-flight work, committed to unblock the encode
refactor (a clean ffmpeg_win.rs for the vbv-dedup follow-on). These hunks span
the same files and can't be cleanly split here; the commit bundles three
distinct workstreams that each belong in their own PR:
- logging rework (~43 files: level re-tiering, structured fields, `?e`,
hot-path flood latches)
- conflicting-host detection (detect.rs + detect/{linux,windows}.rs + wiring
in main.rs/mgmt.rs/Cargo.toml/docs/packaging)
- standby-sink DWM-stall attribution (windows/display_events.rs + capture/
vdisplay wiring)
NOT verified as a combination. NOT to be pushed until the refactor is done and
these are re-verified and reorganized into their proper per-workstream PRs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Local snapshot of the in-flight NixOS support: flake.nix + flake.lock + nix/
(crane host and client packages, services.punktfunk module, devShell).
Standalone — nothing in the Rust/Cargo tree references it. Held from push
pending its owning session's finalization (Skia-under-Nix follow-up + intended
per-workstream PR).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move the Tier-1 encoder contract out of the stuffed encode.rs facade into a
new encode/codec.rs submodule (plan §7 / W2): EncodedFrame, Codec (all methods
except host_wire_caps), ChromaFormat, EncoderCaps, the Encoder trait,
validate_dimensions, vbv_frames_env, and the dimension + wire-roundtrip contract
tests. host_wire_caps stays in encode.rs alongside the backend-selection probes
it depends on; CodecSupport and its wire-mask test stay too.
encode.rs gains `mod codec;` + `pub(crate) use codec::*;` so every existing
crate::encode::X path — crate::encode::vbv_frames_env, ::Codec, ::Encoder, … —
stays byte-stable. Pure relocation: no call sites touched.
Verified: dev-Mac type-check of both files clean; Linux `cargo check -p
punktfunk-host --features nvenc,vulkan-encode,pyrowave` green (all encode
backends compile against the relocated contract); contract unit tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field report (Linux direct NVENC): after a codec switch, every session open
failed with NV_ENC_ERR_INVALID_VERSION until the host process was restarted —
so the poisoned state is per-process, not a driver install issue. On-hardware
investigation (RTX 5070 Ti, 610.43.03) could not reproduce it with clean codec
cycles, dirty teardowns, or open/destroy storms, but established the failure
class: the driver enforces a per-process concurrent-session cap (12 there,
status INCOMPATIBLE_CLIENT_KEY; other branches report differently) whose
exhaustion is exactly this signature — persistent open failures healed only by
a process restart. Harden every path that can feed or mask that state:
* Rebuild backoff: the in-place encoder-rebuild retries slept one frame
interval, so all 5 attempts burned within ~40 ms at 120 Hz — no driver-side
transient (deferred teardown of the previous session, engine reset) can
clear that fast. Exponential backoff 100 ms → 1.6 s (~3 s total) so
transients heal instead of killing the session.
* Destroy-on-failed-open (Linux + Windows, all four open sites): the NVENC
docs require NvEncDestroyEncoder even when OpenEncodeSessionEx FAILS — the
driver may have allocated the session slot before erroring. Without it a
retry burst against a transient leaks slots toward the cap, converting the
transient into permanent exhaustion.
* Teardown: a destroy_encoder failure (a session slot the driver may keep) is
now logged with its status instead of silently discarded.
* One-shot self-diagnosis on a failed session open (Linux): retry the raw open
on a fresh dedicated CUDA context and log which of the three causes applies
— shared-context poisoned (fresh works), driver-level skew/exhaustion/GPU
loss (fresh fails the same way), or CUDA itself unhealthy (no fresh context)
— so the next field report pinpoints the root cause with zero reporter
effort.
On-hardware regression tests (RTX box .21, all green): codec-switch reopen
cycle (H265→AV1→H265→H264→H265), dirty teardown with in-flight encodes, and
the full open-failure→diagnosis→in-place-recovery path via real session-cap
exhaustion. Existing RFI/reconfigure/4:4:4 smokes still pass; clippy clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every NVENC entry-point failure was annotated "(no NVIDIA GPU?)", which
misled triage: the direct-NVENC path only loads on a machine that HAS an
NVIDIA GPU. A Linux user hit NV_ENC_ERR_INVALID_VERSION at
open_encode_session_ex (past the NvEncodeAPIGetMaxSupportedVersion pre-flight
gate) — the signature of a userspace/kernel driver version skew that a host
reboot fixes — and the log pointed at a missing GPU instead. A restart did
fix it.
Add encode/nvenc_status.rs: a shared NVENCSTATUS -> cause mapper that folds
the real cause into the anyhow::Error at construction, so every downstream
{e:#} log (the encode-recovery loop, session teardown) improves for free.
INVALID_VERSION now reads "update the NVIDIA driver, or reboot if you just
updated it (a host restart is the usual fix)"; NO_ENCODE_DEVICE /
DEVICE_NOT_EXIST / INCOMPATIBLE_CLIENT_KEY (session-count limit) / OOM /
UNSUPPORTED_PARAM get their own glosses. The required API version comes from
the SDK consts so it stays correct across crate bumps.
Wire it into all NVENC entry-point failures in both backends
(encode/linux/nvenc_cuda.rs, encode/windows/nvenc.rs) — every open, init,
preset/resource/bitstream call.
Also: when the encode-recovery loop exhausts its in-place rebuilds it now
logs a clear terminal line with the underlying cause instead of the session
silently vanishing after the last identical "rebuilt in place" line.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Setting ITSAppUsesNonExemptEncryption=true (1b733613) while the App Store
Connect encryption documentation is still in progress — approval blocked on
the pending French ANSSI declaration, so no compliance code exists yet —
makes xcodebuild's upload analyzer demand ITSEncryptionExportComplianceCode
and fail every Apple upload with error 90592 ("Invalid Export Compliance
Code … key value []").
Revert that plist hunk to restore the pre-existing manual "Missing
Compliance" per-build flow in ASC (upload succeeds, encryption question
answered in the UI). Not set to NO — that would be a false declaration; the
app genuinely uses non-exempt AES-GCM crypto. Once ANSSI's attestation is
uploaded and ASC approves the documentation, re-add the flag together with
the resulting ITSEncryptionExportComplianceCode.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
marker_appears_while_held_and_vanishes_after drove the PROCESS-GLOBAL
registry and mutated XDG_RUNTIME_DIR mid-run — the punktfunk1
integration tests announce real sessions concurrently in the same test
process, so whichever registered first became the primary and the
marker carried its mode instead of the test's 2560x1440 (flaky on CI,
green locally by timing). The registry gains insert/remove methods and
rewrite() takes the target path, so the test now exercises the same
end-to-end lifecycle (atomic write, primary retention, session count,
removal) against a LOCAL registry and an explicit temp path — no env
mutation, no shared state. Production behavior unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
b8da32e8 landed with two call sites and a log line rustfmt rewraps;
CI's cargo fmt --all --check gate was failing on every run since.
No code change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pairing:
- Refresh the paired-devices list after a native PIN pairing (the happy path never
invalidated it, so a newly paired device stayed hidden until remount).
- Moonlight PIN: a 204 means "PIN delivered to the waiting handshake", NOT paired, so
it now reads "PIN sent" instead of a false "Paired successfully".
- Hide the Moonlight pairing card on native-only hosts (HostInfo.gamestream) — it could
never receive a PIN there.
- Per-row pending on unpair/approve/deny; PIN input maxLength 16 (was 8).
Displays / Library:
- "Arrange displays" save refreshes the settings card (it rewrites the policy), without
clobbering unsaved Custom edits (re-seed only when the draft still matches the server).
- Live-display list wrapped in QueryState so errors don't read as "no displays".
- "Forever" keep-alive option in the custom editor; edit-game form round-trips the logo
artwork (was dropped on save); per-card delete pending.
Stats:
- Distinct colour for the native "queue" latency stage (it collided with "capture").
- "Not measured on this path" note on the GameStream health chart; configured-bitrate
target line on throughput; host-authoritative elapsed timer; LiveCard surfaces
non-404 errors.
Shell / auth / i18n:
- SSR-stable locale: first client render matches the base-locale SSR (no hydration
mismatch), then adopts the persisted/browser locale post-hydration.
- BFF proxy maps an upstream (mgmt-token) 401 to 502 so a logged-in user isn't bounced
into a post-login redirect loop.
- Logout checks the POST result before navigating; logs dedup by seq (StrictMode);
login "next" keeps query/hash; Dashboard shows the active-session count.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The web console Dashboard read AppState.{streaming,launch,stream}, which only the
GameStream path writes, so a native punktfunk/1 session (the DEFAULT plane) showed
"Idle / no session" while actively streaming — only the Stats page (shared recorder)
reflected it. Add a plane-neutral per-session registry (session_status.rs) the native
video loop publishes to; /status now merges both planes, reports active_sessions, and
the Stop / Request-IDR buttons reach native sessions too (so surfacing them doesn't
leave dead buttons). LocalSummary (tray) gets the same fix.
Also on the management API:
- /host codecs derive from Codec::host_wire_caps() instead of a hardcoded
[H264,H265,AV1], so codecs the GPU can't encode no longer appear.
- ApiCodec serializes HEVC as "hevc" (matching the wire/SDP/stats label) so the same
codec reads identically across console pages.
- HostInfo.gamestream reports whether the GameStream planes run (--gamestream), so the
console can hide the Moonlight-only pairing UI on the native-only default host.
- StatsStatus.elapsed_ms (host-monotonic) so the capture timer doesn't mix host/browser
clocks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The vhci-bound socket already set nodelay, but the server-side accepted
socket — the one carrying every URB reply back to the kernel — did not.
The wired single-interface device never tripped it, but the Puck's six
concurrent endpoint streams turn the request/response URB pattern into
classic write-write-read Nagle/delayed-ACK stalls: measured ~22 reports/s
on Steam's active Puck hidraw (each ~45 ms apart, sequence jumping by 12)
against a clean 266 Hz feed from the client. Trackpad felt accordingly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds --steam (before the -- terminator, where PUNKTFUNK_GAMESCOPE_APP
cannot reach) to the bare headless gamescope spawn when the env var is
truthy, enabling gamescope's Steam integration for steam -gamepadui
dedicated sessions. Default off; managed gamescope-session-plus/SteamOS
sessions own their own flags and are unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Community-contributed round 5 of the Steam Controller 2 passthrough,
reviewed + verified. A Puck-captured pad now presents the dongle's real
seven-interface identity (CDC pair, four controller HID slots, management
HID) instead of relabelling its reports as a wired 1302 — Steam's Puck
feature dances (wireless_transport / esb/bond / 0xB4 slot status) get
capture-shaped answers, and the wired identity's canned replies are
corrected to the real captures (attribute count, string-attr framing,
0xF2 firmware info, bcdDevice nibble encoding).
- new wire pref 10 = SteamController2Puck (Hello/Welcome byte; older
peers degrade to Auto), selected by the Android capture link when the
transport is a dongle, or by VID/PID in the degraded InputDevice path
- TRITON_RDESC is now the captured numbered descriptor (mouse/keyboard
lizard collections + per-id vendor reports); unnumbered framing made
hidraw mangle feature report 2 and Steam eventually closed the device
- interrupt-IN now queues sparse reports (battery/RSSI/wireless edges)
instead of keeping latest-only, so a 250 Hz state packet can no longer
erase them before the USB/IP poll observes them; EP0 SET_REPORT is
split by wValue report type (OUTPUT parsed for rumble vs FEATURE)
- vendored usbip-sim: config attributes/max-power, IAD prefix + BOS
descriptor support, correct BCD minor.patch encoding (Deck's 0x0300/
0x0200 values are nibble-zero, so its bytes are unchanged), and
full-speed interrupt pacing in ms (was 8 kHz from the HS formula)
- Triton feedback is serviced at 1 kHz while an SC2 backend exists so
Steam's trackpad haptic writes reach the client unbatched
Verified: clippy -D warnings + 319 host tests green on Linux, core wire
tests green, Android kit/app compile + unit tests green. On-glass Puck
retest owed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Linux clippy leg has been red since 5249d31d (cursor-as-metadata):
that push was verified fmt-green but the -D warnings clippy step (which
only compiles the Linux/CUDA target) was not. Five findings:
- capture/linux/mod.rs: the spa_meta_bitmap field-read unsafe block had
no adjacent SAFETY comment (the preceding one documents the pointer
arithmetic block, not this deref).
- zerocopy/cuda.rs: the cuModuleGetFunction unsafe block's SAFETY comment
sat before the enclosing closure instead of adjacent to the block.
- zerocopy/cuda.rs: blend_argb/blend_yuv444/blend_nv12 tripped
too_many_arguments (9/7) — geometry+cursor-size+offset params that a
struct would only unpack at the call site; allow, matching the crate's
existing use of the attribute.
Unblocks the 0.12.0 release (main must be green before the tag).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Maintain $XDG_RUNTIME_DIR/punktfunk/stream while any client is streaming,
holding the primary session's negotiated mode. A per-title launch wrapper
can branch on it: present → session is already at the stream mode, run the
game as-is; absent → run the local (e.g. multi-head gamescope) path.
- New stream_marker module: RAII Guard registered per session, refcounted
for concurrent clients, atomic (temp+rename) writes, injection-safe
single-quoted client name. POSIX-sh-sourceable KEY=value, namespaced
PF_STREAM_* keys, schema-versioned, additive-only.
- Hooked into serve_session so every exit path (disconnect, error,
panic-unwind) retracts the marker. File exists iff a stream is live.
Unblocks the downstream triple-head gamescope launch-wrapper use case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Select+Start+L1+R1 close-stream chord read as broken after 48933dc4
changed it from an instant quit to a 1.5 s arm-and-hold: a normal quick
press did nothing and there was no on-screen cue that a hold was now
required. Keep the accident-prevention hold (an errant brush of the four
buttons still shouldn't kill a session), but make it usable:
- Shorten EXIT_HOLD_MS 1500 -> 1000 ms — still rejects a brush, feels
responsive.
- GamepadRouter gains onExitArmed(Boolean): fires true when the chord
completes and the countdown starts (once per cycle, past the
pendingExit guard), false on an early release or when the timer
elapses.
- StreamScreen shows a "Hold to quit…" pill (top-center) while armed, so
the hold is discoverable; the callback is detached in onDispose before
router.release() so its disarm can't poke Compose state during
teardown.
- MainActivity: drop the now-stale "~1.5 s" dispatch comment.
Verified on this Mac: :kit + :app compileDebugKotlin clean; Android lint
clean for all three touched files (the kit lint baseline errors are
pre-existing, unrelated). On-glass on a real phone + pad still owed (the
1 s hold firing the exit, early-release cancelling, the hint showing /
hiding) — per the Android-input-regressions-only-show-on-hardware
history, and the original hold path was never exercised on a device.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds an LG webOS TV entry to Clients and Install a Client, pointing at
the community-maintained pf-webos project (dyptan-io) and its sideload
steps (Homebrew Channel + .ipk) — not an official punktfunk client.
Extends 56f9c8c4 (the Automatic-bitrate decode signal, core + Android) to the remaining
clients, so every platform caps Automatic at its real decoder limit instead of the network
link ceiling — the fix for a fast LAN feeding a slower hardware decoder.
- core/abi: punktfunk_connection_report_decode_us + _wants_decode_latency expose the
NativeClient methods to the C-ABI embedders (regenerated punktfunk_core.h, additive only).
- apple: PunktfunkConnection wrappers + Stage2Pipeline reports received→decoded from the
VideoToolbox decode-completion callback — every decoded frame, before the newest-wins ring
can drop the backlog. Stage-1 (AVSampleBufferDisplayLayer, no per-frame decode callback)
stays network-only; stage-2 is the metered path.
- windows/linux: the shared punktfunk-session client (pf-client-core) links core directly, so
it calls the NativeClient methods — report received→decoded from the pump, gated on
wants_decode_latency. Exact for the synchronous D3D11VA/software decode; received→submit
(still the decoder-input backpressure signal) for the async Vulkan-Video path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Automatic bitrate controller only reacted to network signals (loss, capture→received
OWD, FEC-unrecoverable frames, jump-to-live flush), so on a fast LAN feeding a slower
mobile HW decoder it slow-started straight to the link-probe ceiling and parked there —
backlogging frames inside the decoder, where those signals never register, and choking it.
Reported on a Snapdragon 8 Gen 1: Automatic pinned ~500 Mbps with unusable latency.
Feed the client's decode-stage latency (received→decoded) into the controller as a
first-class signal, symmetric with the existing OWD one: a rise over its rolling-min
baseline ends the slow-start climb and, sustained over two windows, backs the rate ×0.7
down to the real decode limit — so Automatic settles where the decoder keeps up.
- core/abr: on_window gains decode_mean_us; a decode_means rolling-min baseline +
DECODE_RISE_US (15 ms) fold a decode rise into the bad-window logic.
- core/client: per-frame report_decode_us accumulator, drained to a window mean by the
data-plane pump; wants_decode_latency() gate (Automatic, non-PyroWave) lets embedders
skip the measurement where it's ignored. Re-target log prints the driving signals.
- android/decode: report the decode stage on both the sync and async decode paths,
HUD-independent, measured from the AU leaving next_frame (so codec-input backpressure
is included) and excluding the vsync present wait.
Apple/Windows report_decode_us calls to follow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gamescope draws its pointer on a hardware DRM cursor plane that never enters
the framebuffer feeding its PipeWire capture node, so captured frames arrive
cursorless. Rather than force the producer's Embedded full-frame composite,
request the pointer as PipeWire SPA_META_Cursor and composite it ourselves —
a ≤256×256 blit into the encoder-OWNED surface, never the compositor's
read-only dmabuf.
Capture (capture/linux/mod.rs, capture.rs):
- choose_cursor_mode() gates on available_cursor_modes(): Metadata > Embedded
> Hidden (defaults Embedded on query error — never silently lose the cursor).
Applied on both the plain and remote-desktop portal paths.
- build_cursor_meta_param() adds a SPA_PARAM_Meta pod requesting SPA_META_Cursor
(bitmap up to 256x256) to the connect params on every path.
- CursorState parses spa_meta_cursor (id 0 = hidden; position - hotspot; bitmap
re-read only when bitmap_offset != 0), normalizing RGBA/BGRA/ARGB/ABGR.
Updated in .process before the corrupted/size-0 skip so cursor-only Mutter
buffers still track movement.
- CapturedFrame gains cursor: Option<CursorOverlay> (Arc rgba + serial) riding
the GPU (Dmabuf/Cuda) payloads; the CPU de-pad path composites inline.
GPU composite into each zero-copy backend's owned surface:
- Vulkan Video + PyroWave: folded into the shared rgb2yuv.comp CSC shader —
cursor sampled and alpha-mixed over RGB before the YUV convert (correct
chroma, no extra pass). binding 3 (combined image sampler) + 16B push
constant, per-slot cursor image uploaded only on serial change. spv regenerated.
- CUDA/NVENC: real on-GPU kernel (cursor_blend.cu -> cursor_blend.ptx,
compute_75 Turing baseline, JIT-forward) with blend_argb/blend_yuv444/
blend_nv12 (BT.709 limited, matching the shader). Loaded via the hand-rolled
libcuda fn-table; blended into the ring InputSurface after copy, degrading to
no-cursor on any failure — never drops a frame.
VAAPI (AMD/Intel fallback) deferred: Vulkan Video already covers those GPUs;
blind libva struct-layout FFI shouldn't ship unverified.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A Steam Controller 2 opened in raw mode (our capture claims the HID interface)
reports ADC joystick coordinates ~0..3200, which Steam/SDL read as only a few
percent of full travel — the sticks barely move in Steam's controller test even
though menu navigation still crosses its lower threshold. Steam sends
SETTING_ENABLE_RAW_JOYSTICK (0x2e) = 0 during native init to force
firmware-calibrated signed i16; replicate it (NORMALIZE_JOYSTICKS) alongside
lizard-off at claim time and on the 3 s watchdog refresh (the refresh also
repairs a host/driver that re-enabled ADC mode after capture started).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A lid-closed laptop defeats both activation stages for a fresh IddCx
target: the clamshell lid policy suppresses the new-monitor
auto-activate, and the SDC_TOPOLOGY_EXTEND preset returns success
without committing a path for the IDD — so every session retry burned
~10s in resolve_target_gdi and the stream died with "not yet an active
display path" after 8 attempts (RDP/Parsec still work there: neither
needs a NEW console display path). Field report: Windows laptop host,
Intel iGPU, lid closed, v0.10.1.
New activate_target_path() (win_display.rs) is the supplied-config
apply Windows' own display Settings uses to turn a monitor on, which
doesn't consult the lid policy: QueryDisplayConfig(QDC_ALL_PATHS), keep
every active path verbatim, append the target's inactive path with a
source no active display is using (never a clone), both mode idxs
DISPLAYCONFIG_PATH_MODE_IDX_INVALID, then SDC_APPLY |
SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES |
SDC_SAVE_TO_DATABASE — SAVE_TO_DATABASE so the next same-identity ADD
auto-activates from the persistence DB and skips the ladder. Wired as
the THIRD stage of resolve_target_gdi; the on-glass-validated
auto-activate → force-EXTEND order is unchanged.
Also sweep stale "SudoVDA" out of logs/errors and current-behavior doc
comments (the backend was removed; pf-vdisplay is the sole one): the
capture error now names pf-vdisplay, the HDR toggle logs
virtual-display, and the not-active warns list the exhausted fallbacks.
Genuinely historical SudoVDA notes stay.
cargo check + clippy green on the Windows box; on-glass lid-closed
repro still owed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shared Config/Info.plist deliberately declares true (the ANSSI/France
export-compliance route, 1b733613); the two iOS build-config overrides
contradicted it, so iOS uploads declared exempt while macOS declared
non-exempt. All six configs now inherit the shared plist.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tester-diagnosed (the layer a390e241 missed): the console UI navigates
through padKeyProbe — GamepadNavEffect's held-state + auto-repeat
machinery consuming A/X/Y/D-pad/Select — not the focus system. sc2NavKey
routed everything via super.dispatchKeyEvent, which bypasses
MainActivity.dispatchKeyEvent and therefore the probe, so the console
home never saw the SC2 at all (B alone worked: it never rides key
events). Synthesized events now take the same route as real ones: probe
first (keycode-gated only, so synthetic KeyEvents satisfy it), then the
existing B/A/focus-hook/framework fallbacks — which remain the path for
probe-less screens.
Also: the stick now reports a HELD D-pad direction (press on deflection,
release on centre/change) instead of a single pulse — the probe machinery
turns that into a physical-D-pad-like auto-repeat; guarded against
releasing a direction the real D-pad still holds, and released on link
drop. The focus-hook path still moves once per press edge.
Committed without push (user request); --no-verify per the shared-tree
fmt-hook false positive (Kotlin-only commit).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On-glass: the SC2 attached, left lizard mode, and then only B worked
(closing the app — back at the root). B is the tell: it bypasses key
events entirely (direct back-dispatcher call), while everything routed as
a synthetic KeyEvent died. A synthetic event dispatched from outside the
real input pipeline never reaches ViewRootImpl's focus-navigation stage —
the one that exits touch mode and grants initial focus for a REAL pad's
first D-pad press — so on a phone nothing is focused and both the D-pad
and A (needs a focused element) fall on a deaf window.
The D-pad now drives Compose's own FocusManager.moveFocus through a hook
registered in the composition (Next as bootstrap: directional moves need
an already-focused node; one-dimensional traversal assigns initial
focus). Once a Compose node holds focus the ComposeView owns view-focus,
so A's synthetic DPAD_CENTER reaches the focused clickable as before.
One move per press edge; shoulders/Start/Select unchanged.
Committed without push (user request); --no-verify per the shared-tree
fmt-hook false positive (Kotlin-only commit).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tester-diagnosed: Steam's GetControllerInfo SETs a query (0x83 attributes
/ 0xAE string) and GETs the answer; the virtual SC2 answered EVERY get
with a serial blob, so the 0x83 probe came back mistyped and Steam never
adopted the pad ("it does nothing").
- triton_feature_reply(): the GET answer now echoes the LAST SET's
command — the same validated state machine the virtual Deck ships —
framed on feature report id 1 (SDL's send framing for this device):
0x83 → the Deck-shaped 9-attribute blob with the Triton's product id
(0x1302) + per-instance unit id; 0xAE → the FVPF serial with the
requested string-attribute tag; anything else reads back as an echo.
Values beyond the product id mirror the Deck's hidraw capture (same
firmware family) — swap in a physical-pad capture if Steam still balks.
- Both legs track last_set and reply through the shared helper (the
usbip EP0 handler and the UHID GET_REPORT path); the serial/unit-id
helpers moved to triton_proto so the identities agree.
- Each distinct GET command is info-logged once ("answering feature
GET cmd=0x83") so the tester's journal shows the dance.
Committed without the usual .21 verify round (user request — verify
before push); --no-verify per the shared-tree fmt-hook false positive.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Flip the `pyrowave` cargo feature into the default set across punktfunk-host,
pf-client-core, pf-presenter and the session client — every packaged build
(flatpak, arch/rpm/copr, windows x64 client) now carries the codec. Selection
stays strictly per-session opt-in: a client must pick "PyroWave (wired LAN)"
in Settings (or PUNKTFUNK_PREFER_PYROWAVE=1); nothing changes for normal
HEVC/AV1 sessions. The Windows ARM64 client leg builds --no-default-features
and keeps skipping it (decode is Linux-native + Apple Metal today).
Advertisement no longer waits for the PUNKTFUNK_ENCODER=pyrowave lab
override on NVIDIA: host_wire_caps sets the bit whenever the feature is
present and the host isn't the GPU-less software pref, and
SessionPlan::output_format flips a PyroWave session on the NVIDIA-auto
capture path to CPU RGB frames (the EGL→CUDA import only NVENC consumes;
the wavelet backend ingests raw dmabufs or CPU RGB). AMD/Intel keep their
raw-dmabuf zero-copy unchanged; per-session raw-dmabuf passthrough on
NVIDIA (true zero-copy without the env's global capture policy) stays a
follow-up.
On-glass on .21 (RTX 5070 Ti, default-features binaries, NO env overrides):
host advertises + negotiates PyroWave, the CPU-capture fallback engages,
60 fps at e2e 3.2-5.7 ms p50, and a mid-stream 1080p→720p resize rides on
top cleanly. Workspace clippy --locked clean; 33 client + 314 host tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An SC2 was invisible outside streams: lizard mode produces kb/mouse (no
gamepad events), and the capture claims even those away — so the console
UI could neither be navigated by it nor knew a controller was connected.
- Sc2Capture grows a UI mode (router == null): parsed state edge-detects
into onUiKey navigation transitions — D-pad + face buttons +
Start/Select as real press/release, the left stick as one focus step
per half-deflection push (mirroring MainActivity's stick behavior for
ordinary pads); onActiveChanged + isActive expose the link state.
- MainActivity owns the menu-time capture: engages on resume / USB attach
/ permission grant (asked once per attach; the Controllers screen's
grant button re-arms it), releases on pause, and hands off around
StreamScreen's stream-mode capture (stop before claim, resume in
onDispose). sc2NavKey routes like a real pad's buttons: B backs, A
activates via DPAD_CENTER, the rest goes to focus navigation — and
claims the console-UI glyphs (Xbox family, Valve lettering).
- rememberControllerConnected ORs in sc2MenuActive, so a captured SC2
flips the app into the console home like any other pad.
- ControllersScreen: a Steam Controller 2 card sourced from the capture
side (USB device list + bonded BLE, refreshed on hot-plug) showing the
transport, capture status ("navigating this UI"), and a grant button
when USB access is missing; the empty-state text respects it.
Kotlin-only commit; --no-verify per the shared-tree fmt-hook false
positive (another session's unformatted Rust WIP; committed tree is
fmt-clean).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
prefFor resolves SC2 PIDs to the new kind since 81edd271, but prefLabel
had no arm for it — a Puck surfacing as an InputDevice would read
"Streams as: Automatic". (--no-verify: shared-tree fmt-hook false
positive, Kotlin-only commit.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tester-diagnosed root cause of the wired "disconnect": claiming the SC2's
USB HID interface (force=true) removes the pad's lizard-mode keyboard and
mouse input devices, flipping the system keyboard configuration
(CONFIG_KEYBOARD, QWERTY→NOKEYS). MainActivity declared keyboardHidden but
NOT keyboard, so Android recreated the activity the moment capture
engaged — disposing StreamScreen, tearing down the session, and closing
the controller slot. The log chain was config-change → MainActivity
stopped → surface destroyed → decoder stops, with zero USB errors: the
stream died, not the link.
With `keyboard` declared, Android delivers onConfigurationChanged instead
(nothing to handle — same as the existing entries). Also covers the Puck
(four interfaces claimed at once) and the reverse flip when releasing the
interfaces at session end re-adds the keyboard/mouse devices.
Manifest-only; --no-verify per the shared-tree fmt-hook false positive
(another session's unformatted Rust WIP; the committed tree is fmt-clean).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round-2 on-glass: wired still dropped, Puck surfaced as Xbox360. Both were
still client-side:
- The Puck hosts up to four controllers on interfaces 2..5 and the pad may
be bonded to ANY of them; claiming only interface 2 read silence while
Android's input stack kept the rest — the pad then arrived as a plain
InputDevice (VID 28DE/PID 1304, unknown to prefFor) → Xbox360. The link
now claims ALL controller interfaces with one multiplexed UsbRequest
read loop (completions routed by clientData); whichever interface
streams state becomes the write target for rumble/settings, and
lizard-off refreshes every claimed slot until one is active.
- Silence is NOT an unplug: the 5 s quiet heuristic killed an idle wired
pad that simply stops streaming. Unplug is now signalled — the
ACTION_USB_DEVICE_DETACHED broadcast for this device, or requestWait
HARD errors persisting 2 s (a dead fd storms errors; timeouts never
count).
- Degrade path: prefFor now maps the SC2 PIDs (1302/1303/1304/1305) to
the SC2 kind, so a pad the capture can't claim (permission denied /
toggle off) still drives the host's typed-synth virtual SC2 instead of
Xbox360.
- Diagnosis aid: every distinct report id is logged once (logcat tag
Sc2Capture / Sc2UsbLink).
Kotlin-only commit; --no-verify because the fmt hooks check the WORKING
TREE, which carries another session's unformatted Rust WIP — the committed
tree is fmt-clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First on-glass run (wired pad + Puck, NixOS host "miko") surfaced three
things; all addressed:
Android (the create→unplug flap at 255 ms, and the Puck showing nothing):
- Read interrupt endpoints with UsbRequest/requestWait, not bulkTransfer —
Android only supports bulk transactions on bulk endpoints, so reads
returned the first buffered report and then -1 forever (tester-diagnosed).
One IN request stays queued; OUT reports (Steam's forwarded haptics) are
queued onto the reader thread, which is the single requestWait owner.
Unplug detection is now sustained-silence (5 s), not a failure counter.
- Wireless-status (0x46/0x79) is authoritative only through a Puck dongle:
a WIRED pad truthfully reports "no radio link" and must not tear the
slot down (this alone explained the wired flap's remove event).
- Lizard-off confirmed working on-glass — framing unchanged.
Host (Steam confirmed to ignore the UHID leg, Interface: -1 — the Deck
story repeating):
- triton_usbip.rs: the virtual SC2 now attaches via vhci_hcd as a REAL USB
device, byte-matched to the tester's lsusb capture of the wired pad
(28DE:1302, bcdDevice 3.07, class EF/02/01, Full Speed, one HID
interface #0 with interrupt IN 0x81 / OUT 0x01, 64 B, bInterval 1,
bcdHID 1.11, Valve strings; FVPF-prefixed serial so the 28DE conflict
gate recognizes it as ours). Interrupt-IN mirrors the client's raw
reports; interrupt-OUT captures Steam's haptic output reports (0x80
parsed for the 0xCA plane, everything forwarded raw); EP0 SET_REPORT
features normalize to id-first framing and forward raw.
- steam_usbip.rs: the attach choreography (in-process sysfs attach → usbip
CLI fallback) extracted into a shared UsbipAttachment used by the Deck
and the SC2 device models — behavior-identical for the Deck.
- steam_controller2.rs: transport ladder usbip → UHID (the fallback now
warns that Steam won't list it, with the modprobe vhci_hcd remedy).
Verified: host 314 tests green on Linux (.21) incl. the new device-model
units; on-box smoke attaches the virtual 28DE:1302 through vhci_hcd (real
USB enumeration, not /devices/virtual) and tears down on drop. Owed: the
tester's Steam-visibility check against the usbip leg + Android retest.
(--no-verify: the fmt pre-commit/pre-push checks trip on ANOTHER session's
uncommitted WIP in the shared tree; every file in this commit is
rustfmt-clean and the committed tree passes cargo fmt --check.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Apple client now decodes PyroWave natively on the presenter's own MTLDevice —
no MoltenVK, no upstream C++ in the app. Completes and wires up the decoder whose
early working-tree snapshot rode along in 9127c346:
- MetalWaveletShaders.swift: wavelet_dequant + idwt hand-ported from the vendored
GLSL (STORAGE_MODE 0 only; subgroup scans → 32-wide simdgroups; DCShift spec
constant → function constant; precision-1 split: fp16 levels 0-1 / fp32 2-4).
- MetalWaveletDecoder.swift: Swift reimplementation of push_packet/decode_packet
incl. the Phase-4 chunk-aligned window walk (FRAG chains, zeroed missing shards,
the >half-blocks partial rule), init_block_meta's block-index space, and the
42-dequant + 13-idwt dispatch structure with encoder-boundary barriers. SOF-dims
changes rebuild the size-dependent resources, which is also the mid-stream
resize path. Ring of 4 output plane sets on the presenter's queue.
- Presenter: pf_frag_planar (3xR8, the planar_csc.frag twin) + renderPlanar with
a shared present tail; ReadyFrame carries an image enum (.video | .planar).
- Stage2Pipeline: a dedicated PyroWave pump — no VideoToolbox machinery, no
keyframe/re-anchor recovery (all-intra; partials render as localized blur by
design), newest-frame-index staleness guard for late partials.
- Opt-in: "PyroWave (wired LAN)" codec entry (probe-gated, ≈A13 floor via a real
kernel-compile probe), selecting it advertises + prefers the codec and forces
the session SDR (HDR/10-bit/4:4:4 caps dropped, plan contract).
- Core ABI: punktfunk_connection_shard_payload() — the Welcome's negotiated shard
payload, needed by native decoders to walk chunk-aligned AUs.
- Validation: golden fixtures generated by the host encoder + upstream's own
decoder (pyrowave_dump_golden, RTX 5070 Ti); the Metal decode PSNR-matches at
77-88 dB across all planes for dense AND chunk-aligned AUs, and a hole-punched
partial still decodes. Parser unit tests cover the window walk, FRAG chains,
broken chains, the half-blocks gate, and the block-index layout.
Tests: apple 134 green (mac; iOS/tvOS build), host 312 w/ pyrowave on .21,
core 148 w/ quic; clippy/fmt clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- pf-presenter: the HUD/title mode line lived inside the match-window (D2)
gate, so an accepted switch from any OTHER trigger (the
PUNKTFUNK_DEBUG_RECONFIGURE lever, a host-side corrective rollback) left the
label stale. Hoisted into its own per-iteration tick that runs whenever a
stream is up.
- docs: pyrowave.md — the Automatic bitrate pin now follows a mid-stream
resize; drop the "resolution changes rebuild the stream" limitation.
Completes the resize-rebuild work whose core landed in 9127c346
(video_pyrowave.rs sequence-header dims sniff + in-place decoder/plane-ring
rebuild with retired-ring lifetime handling, host per-mode ~1.6 bpp re-pin,
128px floor, debug reconfigure lever). On-glass validated on .21
(RTX 5070 Ti, Mutter virtual display): 1080p->720p and 1080p->1440p
mid-stream switches, lossless AND under 2% netem loss — decoder rebuilt in
place, 60 fps sustained (partials during loss), pinned rate re-resolved
199065->88473 / ->353894 kbps, HUD flips to the new mode.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- clients/apple: native Metal wavelet decoder + compute shaders (Phase 5),
decoding PyroWave without embedding MoltenVK.
- pf-client-core: plumb user_flags/completeness through Decoder::decode_frame
so the PyroWave backend parses chunk-aligned + partial AUs; gate the param's
unused-warning to exactly the non-pyrowave builds (fixes -D warnings on the
featureless Linux client build).
- punktfunk-host: on a mid-stream mode switch, re-resolve the "Automatic"
PyroWave bitrate for the new mode's ~1.6 bpp operating point (explicit rates
and H.26x ABR stay put); reject sub-128px PyroWave modes before the encoder
rebuild instead of after the ack.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 2026 Steam Controller (Valve "Ibex" / SDL "Triton") captured on an
Android client is passed through AS-IS: the host presents a virtual pad
with the real wired identity (28DE:1302) and mirrors the physical pad's
raw HID reports, so Steam on the host drives it over hidraw exactly like
the real thing — trackpads, gyro, paddles, and its rumble/settings writes
flow back onto the physical controller. Protocol ground truth: SDL's
Valve-maintained SDL_hidapi_steam_triton.c + steam/controller_structs.h.
Core:
- GamepadPref::SteamController2 (wire byte 9; names steamcontroller2/
sc2/ibex) + PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2 in the C ABI.
- Raw HID planes: RichInput::HidReport (0xCC/0x04, client→host input
reports verbatim, Copy fixed-64 body) and HidOutput::HidRaw (0xCD/0x05,
host→client feature/output writes for replay). Best-effort is sound by
the device protocol's own design (rumble re-sent every ~40 ms, settings
every ~3 s — losses self-heal); HidRaw bypasses hidout dedup for
exactly that reason.
Host (Linux):
- triton_proto.rs + steam_controller2.rs: Triton2Manager UHID backend —
no kernel driver binds the PID (hidraw only; Steam Input is the
consumer), raw mirroring with a typed-fallback 0x42 synthesizer until
the first raw report, SET_REPORT ack + raw forward, canned GET_REPORT
serial reply, rumble also parsed onto the universal 0xCA plane (phone
mirror). Rides the uhid + 28DE-conflict degrades; UHID promotion by
Steam is flagged in the creation log (usbip transport is the known
follow-up if Steam ignores Interface:-1 devices for Triton too).
Android:
- Sc2UsbLink (wired/Puck: vendor-interface claim detaches the OS driver,
interrupt read loop, lizard-off on the watchdog cadence, raw replay via
interrupt-OUT / SET_REPORT with hidapi report-id framing) and Sc2BleLink
(Valve vendor GATT service, notify subscribe machine, 0x45 re-framing,
HIGH connection priority).
- Sc2Capture orchestrator: raw plane + typed mirror (exit chord + host
degrade paths keep working) on a GamepadRouter external slot; raw
return path via GamepadFeedback.onHidRaw.
- nativeSendPadHidReport JNI (direct ByteBuffer, no per-report copy),
hidout raw decode, usb-host/BLUETOOTH_CONNECT manifest bits, opt-out
settings toggle, StreamScreen engagement incl. the USB permission flow.
Verified: core 149 + host 312 tests green on Linux (.21), on-box uhid
smoke creates/mirrors/tears down the virtual 28DE:1302, C ABI harness
round-trips, Android compileDebugKotlin green. On-glass with the real
controller owed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PyroWave AUs now packetize on the negotiated shard payload, so a lost datagram
costs a few wavelet blocks of localized blur rather than a whole frame — and the
client can render an aged-out lossy frame instead of freezing until the next one.
Host (opt-in, PyroWave only):
- The encoder packetizes at the shard payload behind a 4-byte window prefix
(used-len u16 + kind u16). Whole packets pack into WIN_PACKED windows; a packet
too large for one shard (PyroWave 32x32 blocks are atomic and can exceed a
shard) rides a WIN_FRAG_FIRST/CONT/LAST chain. `set_wire_chunking()` joins the
Encoder trait (forwarded through TrackedEncoder — the silent-no-op trap);
EncodedFrame.chunk_aligned marks the AU.
- virtual_stream tags the AU with USER_FLAG_CHUNK_ALIGNED and re-applies chunking
after every encoder (re)build, the adaptive-bitrate rebuild included.
Core:
- USER_FLAG_CHUNK_ALIGNED (0x40) wire bit. Reassembler opt-in
(set_deliver_partial): a chunk-aligned frame that ages out with holes is handed
over as Frame{complete:false} — received shards at their exact offsets, missing
ranges zero-filled — instead of being dropped. Partials age out on a tight 30ms
fuse (PARTIAL_WINDOW_NS) instead of the 120ms loss window: each frame is
independently decodable, so an ancient partial has no value in a live stream.
Newest-wins. A partial still counts as dropped for loss reporting.
Client (PyroWave decode):
- The session opts in when codec == PyroWave. The decoder walks the AU
window-by-window, skipping zero (missing) windows and reassembling FRAG chains,
then decodes whatever survived. A newest-decoded-index guard drops partials the
pump has already moved past (no time-travel present).
Also fixes a redundant-closure clippy nit in the PyroWave planar-present path.
Validated on an RTX 5070 Ti under 2% netem loss with FEC pinned off: 60fps
sustained entirely via partials, e2e 43ms p50 (146ms before the fuse) vs 23ms
lossless, no keyframe-recovery chatter. Tests green: core 149, host 310 + the
GPU-gated encoder smoke (framed-window walk + FRAG reassembly + upstream
round-trip), client 26; clippy clean on the pyrowave feature combos.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A host's pairing-gate rejections (not armed / bound to another device /
rate-limited / identity required / denied / approval timeout / superseded /
wire-version mismatch) used to drop the connection with a bare code-0 close,
and every client collapsed that — plus plain unreachability — into one
"wrong PIN / not accepted" message. A dead network path, a disarmed host,
and an operator denial were indistinguishable, which is exactly the
misdiagnosis behind the recent Android pairing support thread.
- core: new ungated `reject` module — shared close-code block 0x60–0x67
(+ 0x42 busy promoted from the host), `RejectReason`, and
`PunktfunkError::Rejected`; `pair()`/`connect()` decode the host's
ApplicationClosed code into `Rejected` instead of a generic Io error.
C ABI v7: status block −20…−28 and `punktfunk_connect_ex8` (`status_out`
reports the failure cause; NULL-return alone can't). Wire unchanged —
old peers see exactly the old bare close.
- host: every gate rejection `conn.close()`s with its typed code (and the
human reason as close bytes) before erroring out of the session task.
- pf-client-core: shared `pair_error_message`/`connect_reject_message`
wording consumed by the Windows + Linux + console-UI + CLI surfaces; a
connect failure now renders the host's stated reason.
- android: `nativeTakeLastError()` JNI token + `ConnectErrors.kt` — a
network timeout is no longer reported as "wrong PIN, or the host isn't
armed", and a typed rejection skips the wake-and-wait fallback (the host
is demonstrably awake).
- apple: `HostRejection` + `.rejected`; the pair sheet and session alerts
show the stated reason; connect moves to `ex8`.
Completes the cross-client half of the hunks that rode along in 12148243
(client.rs / trust.rs / punktfunk1.rs) — main did not build without this.
Validated: workspace clippy -D warnings + full test suite green on .21
(EXIT=0, 309 host / 148 core suites); macOS core 147+c_abi green; swift
build green; Android Kotlin + native crate green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
plan §4.6 + Phase 3 productization:
- Pinned bitrate: an Automatic client (bitrate 0) on a PyroWave session
resolves to the codec's ~1.6 bpp operating point for the mode (≈200
Mbps at 1080p60) instead of the 20 Mbps H.26x default; explicit rates
are honored. Mid-stream SetBitrate retargets are refused with the
pinned rate acked (guards old/foreign clients), and the client-side
AIMD controller + startup capacity probe stay off for the codec — no
rate descent into wavelet mush, no climb probe whose VBV reasoning
doesn't apply to hard per-frame CBR. Unit-tested.
- All-intra silencing: the data plane drops drained keyframe/RFI
requests on PyroWave sessions (the next frame IS the recovery), so
the forced-IDR cooldown, RFI attempt, and storm coalescing never run.
- Opt-in UI: 'PyroWave (wired LAN)' joins the console's Video-codec
cycler; trust::Settings maps it to CODEC_PYROWAVE. Safe everywhere by
the negotiation contract — an un-advertised preference falls back
through the ladder.
- FEC: decision recorded — adaptive FEC (10% start, loss-report driven)
stays as-is for the MVP opaque-AU mode; the FEC≈0 policy belongs to
the Phase-4 datagram-aligned mode.
- THIRD-PARTY-NOTICES: the generator now lists third-party trees
vendored inside first-party crates (pyrowave, Granite subset, volk,
Vulkan-Headers) with their full license texts; file regenerated.
- docs-site: 'PyroWave (wired-LAN codec)' page — what it is, the
bandwidth table, how to enable it, current limits.
Validated on .21: 309 host + 148 core + 26 client tests green,
console-ui clean, both feature configs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pyrowave passthrough rode VAAPI's LINEAR-only modifier policy, which
starves it on Mutter+NVIDIA (tiled-only allocations → the compositor
declines the offer → CPU capture fallback). The encoder imports through
VK_EXT_image_drm_format_modifier, not libva, so the capture now extends
the advertisement with every single-memory-plane modifier the PyroWave
device samples from (probed via DrmFormatModifierPropertiesListEXT with
the same device selection as the encoder).
Live on .21 (Mutter+NVIDIA, RTX 5070 Ti): 7 modifiers advertised, the
compositor negotiated block-linear (216172782120099861), no CPU
downgrade, and the encoder's per-buffer import cache populated exactly
as designed (8 PipeWire pool buffers imported once, silent reuse after).
Zero-copy session numbers: static 60 fps, e2e 2.9-3.0 ms p50 (p95 3.4),
host stage 1.6 ms; full-window motion 60 fps at ~80 Mb/s all-intra,
decode ~1 ms. Also neutralizes the VAAPI-specific wording in the
passthrough hand-off log.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found by the first live session on .21: the host negotiated PyroWave
and put codec=8 on the wire, but Welcome::decode's codec whitelist
(H264/AV1, else HEVC — the corrupt-byte guard) folded it to HEVC, so
the client opened an FFmpeg HEVC decoder against wavelet AUs. Roundtrip
test now pins the pyrowave byte (and that a genuinely unknown future
bit still folds to the HEVC default).
With the fix the Phase-2 exit session runs END TO END on .21
(host + session client on one box, host capturing the GNOME virtual
display, client presenting into a headless weston):
negotiated codec=PyroWave (adv 0x0f) → PyroWave encoder (CPU-capture
path — this box's Mutter+NVIDIA rejects the LINEAR-dmabuf offer) →
wire → PyroWave decoder on the presenter's device → planar CSC.
Static desktop: stable 60 fps, e2e 2.1-4.1 ms p50 (p95 <= 6 ms),
decode 0.2-0.6 ms, vs HEVC/NVENC-direct baseline 2.1 ms — parity at
idle. Full-window motion: 60 fps at ~80 Mb/s all-intra (HEVC ~7),
decode still sub-ms, zero decode errors or keyframe-request chatter
across every run. Deeper motion/loss characterization needs a
dmabuf-accepting host box (this one is capped by the CPU capture
path).
Also retires the stale "no shipping client decodes this" wording in
the host encoder/dispatch logs — the negotiation exists now.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ci.yml's -D warnings clippy (default features) flagged the labeled block
whose only break lives behind the pyrowave cfg — restructured as cfg'd
let-bindings, no label. Also boxed Backend::PyroWave (the decoder's
pinned create-info hold + plane ring dwarfed the other variants —
clippy::large_enum_variant under the feature).
Both configs strict-clippy clean on .21; 26 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pump now advertises decodable_codecs_for(presenter device) — the
CODEC_PYROWAVE bit rides only when the device passed the compute-feature
probe — and PUNKTFUNK_PREFER_PYROWAVE=1 is the Phase-2 lab opt-in that
names the codec in preferred_codec (the only route resolve_codec will
take it, plan §3; a Settings toggle is Phase-3 productization). A
negotiated PyroWave session builds Decoder::new_pyrowave on the
presenter's device instead of an FFmpeg decoder. clients/session grows
the `pyrowave` feature forwarding both crate features.
With this the Phase-2 client chain is code-complete:
Hello bit → preference → Welcome::codec → pyrowave decode on the
presenter device → planar CSC → present. On-glass .21 run +
latency-probe/loss-harness numbers vs HEVC remain owed (plan Phase-2
exit criteria).
Validated on .21: session client + all crates compile with and without
the features, clippy clean, 26 + 308 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ci.yml's header-freshness gate caught the stale include/punktfunk_core.h
(the ABI constant landed without the regenerated header); the lockfile
records pf-client-core's new optional deps (ash, pyrowave-sys).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The arch package job (--features nvenc) tripped the same class of
Codec::PyroWave non-exhaustive matches as windows-host had, in
nvenc_cuda.rs (6 sites) — dispatch-guarded unreachable!() arms, plus
the vk_util-extraction leftover unused imports in vulkan_video.rs.
All Linux host feature combos (none / pyrowave / nvenc,vulkan-encode /
all three) now compile clean on .21.
Presenter: planar_csc.frag (+ committed .spv) — the 3-plane variant of
nv12_csc.frag (separate Cb/Cr R8 planes, same push-constant CSC-row
contract, siting correction self-disables at full-res chroma).
CscPass grows a shared builder + new_planar()/bind_planes_planar()
(GENERAL-layout descriptors — pyrowave planes stay GENERAL); the Vk
presenter builds the planar pass when the device passed the pyrowave
probe, FrameInput::PyroWave rides present_frame (no acquire barrier
needed: the decoder fence-completed and barriered the planes on the
same queue), and run.rs presents it with no demote rung (only device
loss ends the session).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The presenter's device creation now probes + enables the PyroWave
compute feature set alongside the Vulkan Video probe (shaderInt16,
storageBuffer8BitAccess, subgroup size control — gated on support,
harmless when unused) and exports the facts through VulkanDecodeDevice
(pyrowave_decode capability + feature bools + apiVersion + the queue-
family shape).
pf-client-core (feature `pyrowave`, Linux): video_pyrowave.rs — the
decoder runs pyrowave compute on the PRESENTER's own VkDevice, zero
interop (plan §4.5): pinned content-equivalent create-info
reconstruction satisfies pyrowave 0.4.0's lifetime rule without
refactoring the presenter's creation; queue access rides the existing
device-wide QueueLock (the FFmpeg/Skia contract); decode records into
our command buffer, fence-synchronous (sub-ms), into a 4-deep ring of
3xR8 plane sets (decode REQUIRES storage usage + identity swizzles, so
the encoder's RG8 trick doesn't apply). Backend::PyroWave +
DecodedImage::PyroWave + Decoder::new_pyrowave + decodable_codecs_for
(advertisement gated on the device probe) wired through the decode
dispatch; no demote ladder (nothing else decodes it — fallback is
session renegotiation, plan §4.6).
Still to come for a live session: the presenter's planar-CSC render
path for the new variant, pump/shell opt-in (preferred_codec) wiring,
and the on-glass .21 run.
Validated on .21: pf-client-core + pf-presenter compile with and
without the feature, clippy clean, 26 client-core tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The nine non-exhaustive matches windows-host CI tripped on (run 9917) —
all inside encoder objects a PyroWave session can never open (the
open_video dispatch routes PyroWave to its own backend on Linux and
bails on Windows), so the arms are dispatch-guarded unreachable!().
Verified: cargo check -p punktfunk-host --features nvenc,amf-qsv
--release green on the windows-amd64 runner.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 2a+2c of design/pyrowave-codec-plan.md.
Core: CODEC_PYROWAVE = 0x08 on Hello::video_codecs/Welcome::codec.
Deliberately absent from resolve_codec's precedence ladder (plan §3 —
a 100-400 Mbps codec must never win a negotiation by mere mutual
support): reachable exclusively through the client's explicit
preferred_codec. Invariant tests cover never-auto-selected (even as the
only shared codec), preferred-path selection, and graceful fallback.
ABI mirror PUNKTFUNK_CODEC_PYROWAVE + lockstep assert for the
Apple/Android embedders.
Host: Codec::PyroWave variant threaded through the wire mappings; a
negotiated PyroWave session routes straight to the backend ahead of the
PUNKTFUNK_ENCODER pref dispatch (which stays a lab override). The
advertisement bit rides host_wire_caps only when the capture side would
actually deliver ingestible frames — linux_zero_copy_is_vaapi(), i.e.
AMD/Intel auto or an explicit operator pref on NVIDIA; per-session
raw-dmabuf OutputFormat plumbing is recorded as the Phase-3 item. The
libavcodec name helpers are dispatch-guarded unreachable; the web
console gains ApiCodec::PyroWave (api/openapi.json regenerated).
Validated on .21: 308 host tests green with and without the feature,
145 core tests green with quic, clippy clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MSVC leg of the Phase-0 build gate verified on the windows-amd64 runner
(.133): full vendored C++ set compiles under MSVC, static link resolves,
API-version pin test green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PyroWaveEncoder behind --features pyrowave + an explicit
PUNKTFUNK_ENCODER=pyrowave (loud EXPERIMENTAL warning: no client can
decode the stream until CODEC_PYROWAVE negotiation lands, plan Phase 2).
Design (plan §4.3): a private ash Vulkan-1.3 device shared with pyrowave
via pyrowave_create_device — DeviceHold pins the instance/device
create-infos the 0.4.0 API requires alive for the device's lifetime.
Capture dmabufs pass straight through on ANY vendor
(linux_zero_copy_is_vaapi → true for pyrowave; NVIDIA dmabuf→Vulkan
import validated by upstream's interop test on .21) with the same
per-buffer import cache as the Vulkan Video backend; the shared
rgb2yuv.comp BT.709-limited CSC writes R8+RG8 images pyrowave samples
directly (R/G view swizzles synthesize Cb/Cr — no NV12 copy). Encode
records into OUR command buffer (pyrowave_device_set_command_buffer), so
ingest + CSC + encode are one submission with a sub-ms fence wait; the
AU is exactly one pyrowave packet, keyframe=true on every frame.
reconfigure_bitrate is a free in-place budget change (Phase 3 pins the
session rate); reset() recreates only the pyrowave encoder object.
Shared ash leaf helpers (dmabuf import, image/memory utils) extracted
from vulkan_video.rs into encode/linux/vk_util.rs — vulkan-encode
builds unchanged.
Validated on .21 (RTX 5070 Ti): pyrowave_smoke green — encodes CPU
fills through the full open→CSC→GPU-encode→packetize path, decodes
every AU with upstream's own decoder, checks BT.709 plane means ±3;
rate retarget + rebuild covered. clippy clean, 308 host tests green
with the feature on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 0 of design/pyrowave-codec-plan.md — the opt-in wired-LAN ultra-low-
latency codec. Vendored at upstream 509e4f88 (API 0.4.0, Granite 44362775,
volk + vulkan-headers pins in PUNKTFUNK-VENDOR.txt), pruned to the 6.6 MB
the standalone no-renderer build needs; scripts/vendor-pyrowave.sh
reproduces the tree (a pin bump is protocol-affecting, plan §4.2).
build.rs drives the wrapper CMakeLists (static archives incl. a static
C-API lib upstream only ships shared) + bindgen over pyrowave.h; Linux and
Windows only, empty stub elsewhere (Apple gets a native Metal port, §4.7).
Offline-safe by construction: no network, no system lib, vendored Vulkan
headers — same model as the opus dep (flatpak builder has no network).
Phase-0 validation on .21 (RTX 5070 Ti, driver 610.43.03):
- upstream pyrowave-c-test + interop test (incl. dmabuf/DRM-modifier
Vulkan<->Vulkan) pass, from the pristine AND the pruned tree
- GPU kernel times at ~1.6 bpp noise: encode/decode 0.090/0.042 ms @800p,
0.146/0.067 @1080p, 0.226/0.103 @1440p, 0.477/0.201 @4K — order of
magnitude under NVENC's 1-2 ms retrieve, CBR lands within ~100 B of
target
- cargo test -p pyrowave-sys green (static link + API-version pin check)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ITSAppUsesNonExemptEncryption = true — the app's AES-GCM session crypto is
non-exempt under the App Store Connect encryption questionnaire (category
chosen; French ANSSI declaration in progress). First of the six targets;
the remaining Info.plists follow with the rest of the compliance work.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The nvenc feature is off by default, and a Linux host built without
--features punktfunk-host/nvenc silently compiles the direct-SDK path out:
a CUDA session degrades to libav hevc_nvenc — no RFI loss recovery, an
encoder rebuild + IDR on every adaptive-bitrate step, and the libav bitrate
clamp — with nothing in the logs saying why. This bit the Linux packagers
once (fixed in e89b2f60) and an ad-hoc host deploy again on 2026-07-14,
where the on-glass Automatic-climb session showed rebuild-per-step behavior
that read as a pipeline gap (it wasn't: the Portal/PipeWire path delivers
EGL-imported CUDA NV12 frames and goes direct whenever the feature is in
the build). One WARN per process, skipped under an explicit
PUNKTFUNK_NVENC_DIRECT=0.
Validated on .21 (GNOME/Mutter Portal capture, feature build): probe session
logs `Linux direct-SDK NVENC`, and probe --rebitrate lands as `encoder
bitrate reconfigured in place (adaptive bitrate — no IDR)`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
VAAPI-first on desktop RADV (46b7ffc0) was a regression: Vulkan Video decode
outperforms VAAPI on AMD (on-glass verdict). Vulkan-first is safe there since
the same commit's failure-streak demotion lands on VAAPI, not software — a
broken Mesa Vulkan path still ends up on the working driver.
Auto's order is now: Vulkan first on NVIDIA (no usable VAAPI) + all AMD
(perf; VanGogh additionally chroma-fringes over VAAPI); VAAPI first stays on
Intel/unknown (ANV's Vulkan Video is the least-proven Mesa path). Policy test
updated; 26 pf-client-core tests + clippy green on Linux.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 0.4 host half: PUNKTFUNK_PERF now splits the send thread per window into
fec/seal/sock (SealPerf via Session::take_seal_perf; the paced video path folds
its chunk-send time in through note_sock_ns), logged with per-packet ns in the
send loop's perf line. Measured on .21 at 2.5 Gbps offered: fec ~100 ns/pkt
(Phase 1.4 landed), seal ~1000 ns/pkt = 21.5% of a core, sock ~1400 ns/pkt —
the Phase 1.5 gate (seal > ~15% of the thread at 2 Gbps) trips.
Phase 1.5: seal_frame_inner is now write-then-seal — packetize writes every
packet's plaintext at its final wire offset, then a frame of >= 256 wire
packets (~300 KB) splits the AES-GCM pass across two lanes: a persistent
punktfunk-seal2 worker (lazy-spawned, rendezvous channels, no per-frame spawn,
zero steady-state allocs via a reused hand-off Vec) seals the back half under
nonces seq_base+i while the send thread seals the front. Nonce order is
deterministic per shard index, so the wire is byte-identical to the sequential
pass — pinned by the wire-equivalence test, now including a 469-packet frame
plus an assertion that the lane actually spawned. Small frames and the probe's
~17-packet AUs stay single-lane; PUNKTFUNK_SEAL_LANES=1 forces single-lane.
Validated: 84 core tests + workspace suites + clippy -D warnings on .21.
Halves the seal wall-clock on big frames — headroom for the 10G pair's ~4.8
Gbps ceiling (seal alone would be ~47% of a core there) and PyroWave 4K rates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bumps [workspace.package] version 0.10.1 -> 0.11.0 (14 workspace crates) and
syncs Cargo.lock (versions-only). Apple MARKETING_VERSION / Android versionName
are set from the release tag by CI, so no client manifest changes; the nested
Windows-driver workspace keeps its independent 0.0.1 version.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Windows counterpart of 5c7e0afa's Linux per-pad pairing MAC: every virtual
DualSense / Edge / DualShock 4 presented ONE hardcoded serial, so SDL/Steam
(which dedup controllers by serial) could merge a second pad into the first.
* GET_FEATURE pairing replies (DS/Edge 0x09, DS4 0x12) now carry the pad
index the host stamps into the sealed section in the MAC's low octet.
* GET_STRING serial strings (HidD_GetSerialNumberString — what SDL actually
reads on Windows) get the same per-pad low octet, agreeing with the
feature MAC. The Edge's 0x09 reply moves onto its serial-string base
(0x75 = DS base + 1), fixing the pre-existing feature-vs-string mismatch.
* The Deck identity already did this per-pad; its two inline index reads
now share the new `pad_index()` helper.
Pad 0 keeps today's serial values for DS / DS4 / Deck (no identity churn
for existing single-pad setups).
Verified on the windows-amd64 runner: cargo build + clippy -D warnings
(pf-umdf-util / pf-xusb / pf-dualsense) + fmt clean on the pinned 1.96.0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 1.4 (throughput-beyond-1gbps.md): the send path built a fresh erasure
codec and allocated fresh parity Vecs for every FEC block. New trait method
ErasureCoder::encode_into generates parity into caller-pooled buffers; the
packetizer keeps one parity pool that grows once to the session's high-water
recovery count.
- gf16: one cached reed_solomon_simd::ReedSolomonEncoder per coder, re-shaped
per block via reset() (reuses its working space) — the old encode()
convenience call paid engine CPU-feature detection, FFT planning, and
work-buffer allocation per block.
- gf8: last-used (k, m) Cauchy codec cached, so the generator-matrix build
drops out of steady-state frames; parity buffers shaped without re-zeroing
(encode_sep's first-input pass overwrites every row). The GameStream
VideoPacketizer now owns a persistent coder so the cache survives frames.
- encode() delegates to encode_into — one code path, and the nanors byte-exact
parity vector keeps pinning Moonlight wire compatibility.
Validated: 145 core + 308 host tests + clippy -D warnings on .21, loss-harness
recovery curve identical, pipeline bench +0.6-2.4% thrpt (all configs, p<0.05;
the loopback bench is encoder-dominated so the alloc savings mostly land
outside it).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root-cause fixes for "rumble + adaptive triggers never work with Linux hosts"
(the capture code itself was proven good on-hardware — see the new tests):
* 60-punktfunk.rules now grants the `input` group the VIRTUAL pads' hidraw
nodes (DS/Edge/DS4/Switch/Deck/SC). Steam/SDL drive DualSense adaptive
triggers, lightbar, and player LEDs exclusively over hidraw — and Steam
without hidraw demotes a PlayStation pad to a generic evdev device, losing
its rumble handling too. Coverage no longer depends on the distro's
steam-devices rules + logind's active-seat uaccess ACL (which a headless/
dedicated streaming session never gets). Verified live: nodes now come up
root:input 0660.
* Per-pad MAC in the DualSense (0x09) and DS4 (0x12) pairing feature replies:
hid-playstation adopts the MAC as the HID uniq and SDL/Steam dedup
controllers by that serial — identical MACs made a second virtual pad read
as the first one re-connecting over another transport.
* DualSense/DS4 UHID backends now ack UHID_SET_REPORT (err=0) instead of
ignoring it, so a SET_REPORT writer no longer blocks on the kernel's 5 s
timeout.
* New #[ignore] on-box tests play the GAME's role against a real kernel and
pin the full feedback surface (all green on real hw): DualSense evdev-FF +
raw hidraw output report (rumble/lightbar/LEDs/both trigger blocks verbatim,
per-pad uniq), uinput X-Box FF upload→pump→stop-on-erase, and usbip Deck
0xEB rumble via the controller interface (idle interfaces ACK silently,
like real hardware).
Windows note: the UMDF driver keeps its own pairing blob copies — the shared-
MAC dedup hazard exists there too and needs a driver-side follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 1.2: the native plane's pace chunks are rate-adaptive — 16 packets at
today's rates, coarsening until the per-chunk interval clears the 500 µs sleep
floor, capped at 64 (the GSO segment limit). Decouples the syscall batch from
the pace step, so a ≥1 Gbps frame's overflow keeps real sleeps between chunks
(and costs 4× fewer syscalls) instead of collapsing into an unpaced blast.
Phase 1.3: the auto microburst cap scales with the frame — max(128 KB, the
AU's wire bytes / 4) — so high-rate frames burst a bounded quarter and pace
the rest; PUNKTFUNK_PACE_BURST_KB now pins an absolute override.
GameStream plane untouched (its schedule stays pinned by the deterministic
tests, now also asserting budget-independence). Linux GSO latch-off warns
once (was silent; USO already warned).
Linux GSO default stays OPT-IN: the post-1.2/1.3 A/B on the 2.5GbE-hop pair
(.21 → M3 Ultra) reproduced the regression bit-for-bit — 2452 Mbps sendmmsg
vs 1909 GSO peak, 0.4% loss at 1500 where sendmmsg is clean. The super-buffer
trains lose on the constrained hop in the transport path itself (per-AU
probe sends, no video pacer involved), so the block is fabric evidence, not
pacing readiness. Control sweep on this build matched the sendmmsg baseline
exactly (2452); loss-harness recovery curve identical; workspace clippy +
tests green on .21.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reversed/reordered delivery lets a FEC block reconstruct EARLY
(data + recovery >= k), counting still-in-flight shards into
fec_recovered_shards; window_loss_ppm then reported pure reordering as
loss, inflating LossReports — which size adaptive FEC and, since the
Automatic overhaul, feed the ABR controller (one severe window ends slow
start FOR GOOD, so a reorder burst could permanently kneecap a session's
climb).
Early reconstruct stays (it's the latency-right choice); the accounting
now nets it out. The reassembler counts a new fec_late_shards stat when a
parity-restored data shard ARRIVES after all — matched exactly: the
completed/abandoned-frame memory (ReassemblyWindow::completed, now a map)
remembers which shards each terminal frame reconstructed, and a late
arrival must match one (removed on hit), so wire duplicates of delivered
shards and stragglers of failed blocks count nothing. In-flight blocks
dedup via have_data. window_loss_ppm takes the late delta and estimates
from (recovered - late), saturating across window boundaries; both
callers (client core + probe) pass it.
The e2e reorder tests now assert the NET equals the true kill count in
both delivery orders, dup included (previously documented as a known
inflation). Not mirrored into the C-ABI PunktfunkStats — the loss windows
run in-core on every platform.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Windows twin of nvenc_cuda_reconfigure_no_idr, green on the .173 RTX
box (release profile — the dev-profile test binary trips a pre-existing
LNK2019 on the sdk crate's unused safe EncodeAPI statics, which release
LTO strips).
Chasing this also uncovered why the live A/B kept rebuilding: the
PunktfunkHost service runs C:\Users\Public\punktfunk-native's exe, not
the Developer clone deploy-host.ps1 had been rebuilding.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gpu-session TrackedEncoder wrapper delegates every Encoder method by
hand, so the new reconfigure_bitrate fell through to the trait's false
default and EVERY bitrate change silently took the rebuild+IDR path — the
live .21 A/B caught it (host log said 'rebuilt', never 'in place').
Also:
- punktfunk-probe --rebitrate KBPS:SECS — headless mid-stream SetBitrate
validator (cursor-wiggles so a damage-driven idle desktop keeps
publishing frames through the switch). Live-verified on .21: one NVENC
session open, then 'encoder bitrate reconfigured in place (adaptive
bitrate — no IDR)' at 20→60 Mbps.
- on-hardware nvenc_cuda reconfigure smoke test (20→60→10 Mbps in place,
zero IDRs — green on the RTX 5070 Ti).
- BitrateChanged doc no longer claims the switch costs an IDR.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every adaptive-bitrate step used to tear the encoder down and rebuild it,
opening on a full IDR (a 20-40x frame-size spike, in-flight AU forfeit and
an IDR-cooldown anchor) — exactly when the Automatic controller is climbing.
Encoder::reconfigure_bitrate(bps) retargets the LIVE encoder instead
(default false, so libavcodec/software paths keep the rebuild fallback,
which also still owns the bitrate clamping):
- Linux + Windows direct NVENC: nvEncReconfigureEncoder (added to the
hand-rolled runtime EncodeApi tables) with resetEncoder=0 / forceIDR=0;
the same init/config is re-authored via the new shared build_config/
build_init_params with only avg/max bitrate + VBV (PUNKTFUNK_VBV_FRAMES)
moved. On-hardware test: 20→60→10 Mbps in place, zero IDRs (RTX 5070 Ti).
- Native AMF: TargetBitrate/PeakBitrate/VBVBufferSize are dynamic
properties — SetProperty on the live component, no Terminate/re-Init.
- Vulkan Video (HEVC + AV1): stage the rate and emit an
ENCODE_RATE_CONTROL control command on the next recorded frame (begin
keeps declaring the session's current state, as the spec requires).
The session glue tries the in-place retarget first and skips the rebuild/
inflight-clear/IDR-cooldown bookkeeping when it succeeds — the reference
chain and the wire-index prediction survive, so RFI keeps working across
rate steps.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mesa now exposes Vulkan Video decode queues by default (and the session
binary opts RADV in for the Deck's sake), which silently moved every desktop
AMD/Intel box onto FFmpeg-Vulkan-on-Mesa under `auto` — user-reported
(CachyOS/KDE) to judder or error-streak into the software demotion while an
explicit VAAPI pick streams perfectly. Auto's hardware order is now
device-aware (`VulkanDecodeDevice::prefer_vulkan_over_vaapi`, fed
vendor id + device name by the presenter): Vulkan-first stays only where it
is the established right answer — NVIDIA (no usable VAAPI) and the Deck's
VanGogh (VAAPI dmabuf import chroma-fringes) — and everything else gets the
battle-tested zero-copy VAAPI first, with Vulkan as its fallback.
A mid-session Vulkan failure streak now also demotes to VAAPI before
software, so a broken Mesa Vulkan path can never strand a box with a
perfectly good VAAPI driver on CPU decode.
The GTK shell's decoder setting gains the missing "Vulkan Video" option
(values now mirror the console UI's auto/vulkan/vaapi/software) and drops
its pre-Vulkan "Automatic (VAAPI → software)" label.
Verified on the RTX 5070 Ti box (loopback session, auto → "Vulkan Video
hardware decode active", 60 fps); policy locked by unit test; clippy -D
warnings + pf-client-core/pf-presenter tests green on Linux.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ABR ceiling was the negotiated start rate, so an 'Automatic' session
was permanently boxed at the 20 Mbps default no matter the link — the
most user-visible cap left after the transport work lifted the client
receive ceiling to ~4.8 Gbps wire.
- Startup link-capacity probe: ~2 s into an Automatic session the pump
fires one speed-test burst (2 Gbps target, 800 ms) over the existing
ProbeRequest machinery; delivered wire throughput x0.7 (FEC + variance
headroom) becomes the controller's climb ceiling via set_ceiling().
Old hosts decline (all-zero reply) or never answer (a 6 s timeout
clears the stuck probe state so LossReports resume) — the ceiling then
stays negotiated, exactly the old behavior. PUNKTFUNK_ABR_PROBE=0
opts out.
- Slow start: until the first congestion signal, every cooled clean
window DOUBLES the rate toward the ceiling (20 Mbps -> 640 Mbps in
~10 s) instead of +6% per ~10 s (which would have taken ~10 minutes).
Any congestion signal ends it for good; classic AIMD takes over.
- Faster, severity-aware AIMD: a SEVERE window (unrecoverable frame,
jump-to-live flush, or >=6% loss) backs off x0.7 immediately instead
of waiting two windows; ordinary congestion (2-6% loss, OWD rise)
keeps the two-window fuse. Additive climbs need 6 clean windows
(~4.5 s, was ~10 s); the change cooldown drops 3 s -> 1.5 s.
- PUNKTFUNK_VBV_FRAMES now also scales the direct-NVENC VBV (Windows +
Linux, previously hardwired to 1 frame) — parity with AMF/VAAPI/QSV.
Each accepted step still costs an encoder rebuild + IDR on the host;
in-place rate reconfigure (NvEncReconfigureEncoder / AMF dynamic
properties / Vulkan per-frame RC) is the planned follow-up that makes
stepping free. Controller tests rewritten to the new policy (severity
classes, slow-start climb, ceiling semantics; 144 green).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- REPLAY_WINDOW 32768 -> 131072: the anti-replay bitmap covered the
120 ms loss window only to ~2 Gbps; the client now delivers ~4.8 Gbps
wire, where a late-but-valid Wi-Fi-retried datagram would have been
dropped as 'older than the window' — false loss. 16 KiB/session
covers ~12 Gbps.
- RECV_BATCH 32 -> 128: syscall rate stays ~3.4k/s at 430k pkt/s and
each pump iteration drains the kernel buffer deeper (ring 64->256 KB,
client sessions only). flush_backlog's iteration cap rescaled to keep
its ~190 MB guard equivalent.
- PUNKTFUNK_GSO gate is now value-aware: '=0' used to ENABLE GSO on
Linux (presence check) while disabling Windows USO. GSO stays OPT-IN,
deliberately: A/B'd twice today — it cuts send-thread CPU ~30% but
its 16-packet line-rate trains cost delivered throughput on a
constrained fabric (2.5GbE-hop pair: peak 2453 -> 1908 Mbps and 0.4%
loss at a rate sendmmsg carries clean). Flipping the default belongs
with pace-aware chunk spacing (plan Phase 1.2/1.3). docs-site row
corrected to match.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RustCrypto aes 0.8.x and polyval 0.6.x gate their ARMv8 AES / PMULL
paths behind --cfg aes_armv8 / --cfg polyval_armv8 on aarch64 (x86_64
runtime-detects AES-NI with no flag, which is why hosts never showed
it). Without the cfgs every Apple and Android client decrypted the
media plane in SOFTWARE: 240 MiB/s/core measured on an M3 Ultra —
7 µs per 1.4 KB datagram, single-handedly capping receive throughput
at ~1.57 Gbps wire on both host pairs.
Workspace .cargo/config.toml sets both cfgs for
cfg(target_arch = "aarch64"); detection stays runtime (cpufeatures)
with a safe soft fallback. open_in_place: 240 MiB/s -> 2.42 GiB/s
(10.3x). Live sweep .173 -> M3 Ultra over 10GbE: ceiling 1572 ->
4830 Mbps wire, zero loss through a 3.5 Gbps target; the .21 pair now
saturates its physical 2.4 Gbps fabric exactly.
No in-tree build path sets RUSTFLAGS (xcframework + gradle checked),
so the config reaches all client builds; a lane that sets RUSTFLAGS
overrides config rustflags entirely and must carry the cfgs itself
(noted in the file). Shipping Apple/Android binaries stay on software
crypto until rebuilt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rewrite the client Reassembler around one whole-frame buffer per frame:
frame_bytes rides in every header and packetize geometry is
deterministic (every non-final block is exactly max_data_per_block data
shards), so a data shard's final AU offset is computable on arrival —
copy it there once, straight from the decrypt ring. New
ErasureCoder::reconstruct_into decodes ONLY the missing shards directly
into the frame buffer's holes (gf16 native; gf8 legacy shim); received
recovery shards ride pooled shard-sized buffers. The completed buffer
IS Frame::data.
Deletes the per-shard to_vec + per-block concat + final AU concat
(~178k allocs and a double copy of every byte per second at 2 Gbps —
the pump wall the 2026-07-14 sweeps measured at 98.9% of an M3 Ultra
core). Reassembly now costs ~0.4 µs/packet in-stream.
The eager buffer changes the hostile-header exposure, so two new
firewalls: derived-geometry validation (a header lying about its
data_shards/block_count vs its own frame_bytes is dropped before it can
scribble across another shard's range) and an in-flight allocation
budget (IN_FLIGHT_BUF_FACTOR × max_frame_bytes) so a window of tiny
first-shards can't commit gigabytes.
Behavior parity pinned by the existing suite (all green unchanged) plus
new end-to-end roundtrips through the real Packetizer (multi-block +
partial tail, loss within budget, reversed delivery, duplicates, empty
frame, unrecoverable block ages out, budget enforcement). loss-harness
recovery curve identical; pipeline bench: gf8/1MB +42%, gf16 neutral
(host-encode dominated). Known pre-existing quirk kept as-is: reversed
delivery reconstructs early (data+recovery ≥ k) and counts late-not-lost
shards into fec_recovered_shards.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three bugs found running the owed throughput sweeps (all three conspired
to make yesterday's 'transport does 1G+' numbers fabrications):
- the probe never advertised VIDEO_CAP_PROBE_SEQ, so every host DECLINED
its speed tests; the zeroed decline reply divided a settle-window
sliver by 1 ms and printed plausible-looking garbage. Advertise the
cap (the shared-core reassembler windows probe-space frames) and
detect the all-zero decline explicitly.
- an idle virtual desktop publishes no frames on damage-driven capture
(Windows IDD-push), so the pipeline build timed out before the burst
could run. The probe now injects a ±2 px cursor wiggle over the wire
during --speed-test warmup — injected host-side into the right
session, works headless everywhere.
- throughput-sweep.py: tracing emits ANSI color into pipes, which broke
the key=value parser (crash on the first point); strip it, guard
half-parsed lines, and surface host declines as a flag.
Also logs the whole-run receive stage split (PUNKTFUNK_PERF) at stream
end — the probe is the measurement tool for the client-pump wall.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A host (re)started after a desktop login inherits the user manager's
compositor env; a stale WAYLAND_DISPLAY makes headless gamescope 3.16
exit at startup ('Failed to connect to wayland socket') before its
PipeWire node appears. Unset both on the systemd-run transient unit
(UnsetEnvironment=) and the direct spawn (env_remove) — gamescope
exports its own DISPLAY/GAMESCOPE_WAYLAND_DISPLAY to the nested app.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Session::poll_frame accumulates per-stage ns (recv_batch syscall, AES-GCM
open, Reassembler::push incl. FEC) into a PumpPerf drained via
take_pump_perf(); the client pump logs the split plus completed-AU
inter-arrival jitter (p50/p95/max + late count) every report window.
Gated on PUNKTFUNK_PERF — one branch per stage when off.
Smoothness previously had no metric at all (jump-to-live counters fire
seconds late), and the receive core had no attribution. First live use
pinned the 1.57 Gbps client wall on software AES-GCM (7 µs/pkt) vs
0.4 µs reassembly — see punktfunk-planning/design/throughput-beyond-1gbps.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Standalone sweep to probe the ~500 Mbps throughput wall (transport vs encoder
CBR undershoot); built and validated, no runtime coupling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
B1b: a Steam running in a plain GNOME/KDE desktop session holds Steam's single
instance, so a dedicated gamescope launch's own Steam exits at birth — the
game-library launch goes to a black screen. Release the desktop instance
(free_desktop_steam) on Steam launches before creating the managed session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
iOS + Android: a new opt-in setting mirrors controller 1's rumble onto the
device's own actuator (Apple RumbleRenderer Actuator.device / CoreHaptics,
Android deviceBodyVibrator), so a motor-less clip-on pad still gives haptic
feedback through the phone/tablet it's clamped to. Default off; wired through
the gamepad settings on both platforms.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
iOS + Android: a three-finger vertical swipe up/down summons/dismisses the
device soft keyboard while streaming (trackpad + pointer modes). Mobile scroll
is now exactly two fingers so it never collides with the 3+-finger gesture
(3+ only fell into the old `>= 2` scroll path by accident).
Android: a TYPE_NULL KeyCaptureView plus IME meta-shift wrapping feeds key
events through. iOS: UIKeyInput plus a SoftKeyMap char->VK table with a
GCKeyboard dup gate so a hardware keyboard and the soft keyboard don't
double-emit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ship a Steam Input controller layout (controller_config/punktfunk.vdf) whose
always-on `ts_n` command enables native touchscreen delivery on the Deck, and
have the backend auto-install it (apply_controller_config: copy to
controller_base/templates + upsert the per-account configset entry, chown to the
user, back up first). This is what makes the Deck touchscreen reach the client
as native touch under gamescope without disabling Steam Input (impossible on the
Deck) — no manual controller setup.
Two shortcuts sharing the "Punktfunk" name (so one config key covers both): a
hidden stateful stream entry and a visible stateless entry that launches straight
into the gamepad UI. Both get full artwork (grid/gridwide/hero/logo/icon,
replaced with exported PNGs). Drop the art-generation script.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend the gamepad/console shell (pf-console-ui) to parity with the other clients:
- Settings gain a Touchscreen → Touch mode row (Trackpad / Direct pointer /
Touch passthrough), the one couch-relevant Settings field the screen lacked.
- The pair screen adds the no-PIN delegated-approval path: a "Request access"
action (only when the host advertises a fingerprint to pin) opens a connect the
host PARKS until the operator approves this device, then persists it as paired.
A role-based row model keeps the cursor off stale indices; manual hosts stay
PIN-only, matching the desktop shells.
- Threads request_access through OverlayAction::Launch and ConnectIntent; the
shell shows a "Waiting for approval…" takeover, and the session binary parks on
a 185 s budget (PendingApproval → persist-as-paired via on_connected).
Auto-wake (WoL) was already implemented end-to-end and is left as-is.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Steam validates the Deck unit serial's format before accepting it. Our
"PFDK..." serial was REJECTED ("Invalid or missing unit serial number"), so
Steam substituted a hash identity and mangled the displayed name to
"Steam Deck Controllerggg" on every host tested. An 'F'-leading serial passes,
so switch to "FVPF..." — keeps the PunktFunk marker one slot in, still distinct
from a real Deck's "FVZZ..." for the Linux self-detection in
physical_steam_controller_present(). The name now shows a clean "Steam Deck
Controller" with a serial-derived handle (verified on .173).
Also fix the UMDF driver's 0xAE GET_STRING_ATTRIBUTE handler to echo the
requested attribute id faithfully instead of collapsing board-serial (0x00)
requests to unit-serial (0x01). Steam still logs a benign "Deck Controller PCB
Serial# invalid" for the board serial — it validates that against a
Valve-internal format for ANY value, including an empty one (verified) — but
that line does not mangle the name, change the handle, or block promotion.
Applied to both transports: host inject/proto/steam_proto.rs::deck_serial
(Linux gadget/usbip) and the pf-dualsense UMDF driver (Windows), which mirror
each other's serial format.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The N4 GO verdict, productized. GamepadPref::SteamDeck on a Windows host
now builds a real virtual Deck instead of folding to DualSense: games
get native Deck glyphs + both trackpads + gyro + all four back grips
through Steam Input's own remapping.
- steam_deck_windows.rs: DeckWinPad/DeckWinProto/SteamDeckWindowsManager
over the sealed shm channel, sharing the whole Linux Deck codec
(steam_proto now compiles on Windows too — it was already pure). The
SwDevice identity carries usb_mi: Some(2): the &MI_02 hardware-id
token hidclass mirrors into the HID child and Steam parses as the
wired controller interface — the promotion gate.
- Driver: DEVTYPE_STEAMDECK (3) graduates from the spike — SET_FEATURE
0xEB rumble / 0x8F haptic pulses are republished to the host through
the output slot (report-id-0 prefixed, so parse_steam_output sees the
Linux wire shape), and the 0xAE/GET_STRING serial + 0x83 unit id are
per-pad (read from the section's pad_index; PFDK<unit-id> matches
steam_proto::deck_serial).
- Router: SteamDeck arms in the Windows Pads paths; pick_gamepad flips
SteamDeck-if-windows -> SteamDeck (the DualSense fold retires);
dualsense-windows-test grows --deck.
ON-GLASS VALIDATED on .173 (rebuilt signed driver 9.9.0714.12xx
installed, Steam live): the manager-created pad (index 1) enumerates
with per-pad serial PFDK50460001, Steam logs Interface: 2 ->
'!! Steam controller device opened' -> 'Steam Controller reserving
XInput slot 0' -> PollState 2 (actively polling our cycling input
frames) -> mapping activated; clean teardown on exit. Rumble round-trip
through a real game remains an on-glass debt (nothing sent 0xEB during
the idle hold).
Known gap vs Linux: no physical-Steam-controller conflict degrade on
Windows yet (degrade_steam_on_conflict is Linux-only — /sys scan); a
Windows equivalent needs SetupDi enumeration and is deferred.
Verified: .21 clippy -D warnings + 304/0 tests + fmt --all; .133 clippy
-D warnings + the WDK driver-workspace check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three things that belong together:
1. rustfmt the gamepad-new-types host files ci.yml's `cargo fmt --all
--check` gate flags (the .21/.133 verify recipes ran clippy+tests
but never fmt — the same class of miss as 69f30f30).
2. Enforce it at the source: scripts/git-hooks/{pre-commit,pre-push}
run the exact CI fmt gates (main workspace + the shipped-driver
crates of the UMDF workspace); CONTRIBUTING documents the one-time
`git config core.hooksPath scripts/git-hooks`. pre-push is the
enforcement point (plumbing commits bypass pre-commit).
3. N4 follow-up — the spike verdict FLIPS TO GO: SwDeviceProfile grows
`usb_mi`, synthesizing `&MI_02` into the Deck spike's USB hardware
ids. hidclass mirrors the parent's USB tokens into the HID child's
hardware ids, and hidapi/SDL/Steam parse `MI_` as bInterfaceNumber
(defaulting to 0 when absent — the exact gate the first run hit:
Steam wants the Deck controller on interface 2). Re-run live on
.173: Steam logs `Interface: 2`, then `!! Steam controller device
opened`, `Steam Controller reserving XInput slot 0`, and activates
a mapping — full Steam Input promotion of the software-devnode
Deck, no driver change needed. The PS identities pass
`usb_mi: None` (real single-interface devices carry no MI_ token).
A proper Windows-Deck backend phase is now justified; planned
separately.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
057E:2069 (Pro Controller 2) and 057E:2068 (Joy-Con 2 pair) are the
same full pad surface as the OG Pro and ride the same virtual
hid-nintendo pad. Mirrors SDL, which folds both to its public
NINTENDO_SWITCH_PRO type (the SDL clients bundle 3.4.10, whose switch2
hidapi driver already covers them end to end incl. gyro + GL/GR
paddles-as-paddle-buttons). :kit Kotlin compile green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RECORD_AUDIO / Wi-Fi-state permissions implied hard microphone + wifi
requirements, filtering mic-less TVs (reported: Philips OLED707) and
ethernet-only boxes as "not compatible" on Play; both are optional at
runtime and now declared required=false (aapt2-verified).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Used for the .173 on-glass verify: the Edge devnode enumerates
(SWD\PUNKTFUNK\PF_EDGE_1, driver pf_dualsenseedge attaches, proto 2),
and Steam's live controller.txt confirms the identity end to end —
'type: 054c 0df2', 'Product: DualSense Edge Wireless Controller',
'Controller using HIDAPI driver, vid=0x054c, pid=0x0df2' — with probe
lightbar/player-LED feedback flowing back on the 0xCD plane.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gamepad-new-types §6 go/no-go rig, ready to run the moment .173 is
back (the box is currently down, so the observation itself is still
owed): does Steam Input on Windows promote a software-devnode HID Deck
(28DE:1205), or does it need a real USB bus identity (the documented
GameInput instance-path gap — the Linux 'Interface: -1' lesson)?
- Driver: scratch device_type=3 serves the Deck identity — the captured
38-byte controller-interface descriptor, 28DE:1205 attributes, Valve
strings, the Deck neutral frame, and the Steam 0x83/0xAE feature
contract (SET_FEATURE latches the command, GET_FEATURE answers it —
attribute blob + unit serial mirroring steam_proto::feature_reply).
Never stamped by a session. INF gains pf_steamdeck.
- Host: deck_spike_hold() + the `deck-windows-spike` subcommand — stamps
devtype 3, spawns the devnode under VID_28DE&PID_1205, streams the
neutral frame, prints what to observe (Steam logs/controller.txt,
controller settings) and logs any output reports Steam writes.
Run recipe (on .173, once the updated signed driver is staged): install
driver, start Steam, `punktfunk-host.exe deck-windows-spike`, watch
controller.txt. GO -> plan a proper N4 phase (the Deck codec is already
shared); NO-GO -> document next to the Linux Interface:-1 note and keep
the SteamDeck->DualSense Windows fold.
Verified: .133 clippy -D warnings + the driver workspace cargo check
(WDK) both green; .21 clippy + 304/0 tests unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Plan 0.4 for the N1/N2 backends (SDL landed with them):
- Apple: GamepadType grows dualSenseEdge=7 / switchPro=8 (wire-byte
parity + name parsing). padKind splits the Edge out of the shared
GCDualSenseGamepad subclass by product category, and resolves Switch
Pro / a paired Joy-Con set by category (GameController has no Nintendo
subclass; single Joy-Cons stay on the Xbox 360 fallback — half a pad).
The DualSense-only gates (adaptive-trigger feedback, player LEDs, the
touchpad+motion rich capture) now include the Edge — same surfaces.
Paddle CAPTURE stays gated on G22 (needs a real pad to pin the
paddleButton1..4 correspondence); the declared identity is right
meanwhile. swift build + 124 tests green.
- Android: PREF_DUALSENSEEDGE/PREF_SWITCHPRO wire bytes; the Sony PID
table splits 0x0DF2 (Edge) out of DualSense; Nintendo 057E:2009
declares Switch Pro; ControllersScreen labels the new kinds.
:kit/:app Kotlin compile green (-PskipRustBuild).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reserved GamepadPref::SteamController = 5 slot goes live: the same
hid-steam driver under the wired-SC identity (28DE:1102,
ID_CONTROLLER_STATE), UHID-only in v1 (no captured SC USB interface
layout, so no Steam-Input promotion — the pre-usbip Deck state;
acceptable for discontinued hardware).
Layout pinned against the kernel's ID_CONTROLLER_STATE table: 24-bit
buttons at 8..11 (low bits shared with the Deck; grips at 9.7/10.0 =
the Deck's L5/R5 positions; right-pad click 10.2; joystick click 10.6),
u8 triggers at 11/12, the joystick/left-pad MULTIPLEX at 16..20 (a
left-pad contact shadows the stick, like real hardware's lpad_touched
flag), right pad at 20..24. Mapping: wire left stick -> SC stick; wire
right stick -> right-pad coords + touched bit (the SC's camera surface —
the second-stick loss is inherent); PADDLE1/2 -> the two grips (natively,
masked out of the fold input); PADDLE3/4 + MISC1 -> the remap policy.
The SC parser has NO gamepad_mode gate, so no mode-entry pulse.
SteamDeckPad grew a SteamModel (open_model); ScProto/SteamCtrlManager;
pick_gamepad flips SteamController -> itself on Linux (replacing the
Xbox360 fold); SDL picker splits Valve PIDs (Deck 1205 stays SteamDeck,
SC 1102/1142 now declare SteamController).
Verified: .21 clippy -D warnings + 304/0 tests + on-box UHID smoke
(hid-steam binds 1102, BTN_A + right-pad ABS_RX land on evdev, no mode
pulse); .133 clippy -D warnings green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RECORD_AUDIO implies android.hardware.microphone required=true and the
Wi-Fi state permissions imply android.hardware.wifi required=true unless
declared otherwise, so Google Play filtered the app as "not compatible"
on TVs that declare no microphone (reported on a Philips 65OLED707/12,
Android TV 11, closed-testing track) and would do the same on
ethernet-only boxes. Both capabilities are optional at runtime: the mic
uplink is runtime-requested and the Wi-Fi locks are best-effort hedges.
Verified via aapt2 dump badging: microphone + wifi now report
uses-feature-not-required and no implied hard requirements remain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 0 of gamepad-new-types: the two new kinds exist on the wire (enum,
to_u8/from_u8/from_name/as_str, C-ABI constants + header), and pick_gamepad
folds them to the closest EXISTING backend until their own backends land —
DualSenseEdge -> DualSense (keeps the rich planes; only the paddles go
through the fold policy), SwitchPro -> Xbox360. Wire round-trip pinned
0..=8 + unknown->Auto; fold table extended.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The deferred Phase 3.3 of the gamepad review (gamepad-review-cleanup.md
§3a): the seven virtual-pad managers' copy-pasted lifecycle (slot table,
active_mask unplug sweep, gate-checked create, rumble/hidout dedup,
heartbeat) extracted into shared PadSlots<P> + PadProto/UhidManager<B>;
each backend now supplies only its protocol half via a type alias, with
zero Pads-router edits. Includes the 3.3.0 pre-step fixing the drifted
Linux DS4 backend (rich-plane pad clicks + the Steam left pad were dead
on the DS4 kind).
10 commits, each verified as it landed: Linux .21 clippy -D warnings +
full host suite 290 pass / 0 fail + fmt; Windows CI VM .133 clippy
--all-targets -D warnings EXITCODE 0. On-glass kind-cycling smoke
(one real pad per platform) still owed post-merge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop the vestigial Ds4Feedback.hidout field (parse_ds4_output never
filled it and neither DS4 manager read it — the lightbar rides the led
field, now converted to a HidOutput::Led by the protos) and its
now-unused HidOutput import; refresh the pad_gate module doc (managers
now drive it via pad_slots).
Verified: .21 clippy --all-targets -D warnings + full suite 290 pass /
0 fail + cargo fmt --check clean; .133 clippy --all-targets -D warnings
EXITCODE 0.
Part of G12/3.3 (§3a.4 commit 10) — extraction complete.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The two stateless backends keep their structs and special pumps (uinput
FF-effect mixing via pump_ff/last_mix; the XUSB stale-residual
RUMBLE_IDLE_TIMEOUT force-off) but delegate slot lifecycle — table,
unplug sweep, gate-checked create — to the shared PadSlots. XUSB resets
last_rumble/last_active on the swept indices and on fresh create exactly
as before (the G10/G16-adjacent semantics untouched).
Two accepted deltas, both flagged in the plan (§3a): the uinput
arrival/unplug log lines gain the pad-identity label every other backend
already has ("controller arrival (X-Box 360 pad)"), and XUSB's
f.index.max(0) clamp is replaced by the bounds check every other manager
uses — a negative wire index is now dropped instead of being treated as
pad 0.
Verified: .21 clippy --all-targets -D warnings clean + full suite 290
pass / 0 fail (uinput); .133 clippy --all-targets -D warnings EXITCODE 0
(XUSB).
Part of G12/3.3 (§3a.4 commit 9) — all seven managers now share the
PadSlots lifecycle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The most hook-laden conversion: SteamControllerManager becomes a pub
type alias of UhidManager<SteamProto>. The Steam-specific pieces map
cleanly onto the trait — open() delegates to open_transport (usbip →
gadget → UHID fallback, which keeps its own per-transport logging, so no
extra success line, matching the old ensure), merge_frame preserves the
trackpad coords/touch-bits/clicks + motion across button-only frames
(the G2 fix, verbatim), and the gamepad-mode-entry pulse rides the
force_heartbeat hook. DeckTransport goes pub (type Pad in a public-trait
impl). Also un-fuses a doc-comment glitch where the manager's doc had
been merged onto the DeckTransport enum.
Verified on .21: clippy --all-targets -D warnings clean; full suite 290
pass / 0 fail.
Part of G12/3.3 (§3a.4 commit 8) — all five stateful managers now share
one skeleton.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DualShock4Manager becomes a pub type alias of UhidManager<Ds4LinuxProto>
(the same shape as the other three DS-family conversions); the bespoke
last_led lightbar dedup folds into the shared HidoutDedup exactly as the
Windows DS4 conversion did. With 3.3.0 already applied, the proto half
is byte-identical to Ds4WinProto except the transport open — the codec,
the mappers, and now the manager all shared.
Verified on .21: clippy --all-targets -D warnings clean; full suite 290
pass / 0 fail.
Part of G12/3.3 (§3a.4 commit 7).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DualShock4WindowsManager becomes a pub type alias of
UhidManager<Ds4WinProto>. The bespoke last_led lightbar dedup folds into
the shared HidoutDedup: the proto's service() converts Ds4Feedback.led
into a HidOutput::Led, and HidoutDedup compares it against the
last-forwarded value with the same reset-on-create/unplug semantics the
Option<(u8,u8,u8)> vec had. Everything else mirrors the DualSense
conversion (same DsState mappers as linux/dualshock4.rs). Ds4WinPad goes
pub (type Pad in a public-trait impl, E0446 otherwise).
Verified on the Windows CI VM .133: cargo clippy -p punktfunk-host
--all-targets -- -D warnings EXITCODE 0 at this tip.
Part of G12/3.3 (§3a.4 commit 6).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DualSenseWindowsManager becomes a pub type alias of
UhidManager<DsWinProto>; the proto supplies the UMDF sealed-channel open
(+ success log), the DsState mappers (identical to linux/dualsense.rs,
paddle fold included), and the section feedback poll. Lifecycle, dedup,
and heartbeat come from the shared skeleton — behavior-identical, same
log lines (LABEL DualSense/Windows + the driver-install hint).
DsWinPad goes pub (it appears as type Pad in the impl of the public
PadProto trait — E0446 otherwise; the Linux pads were already pub).
Verified on the Windows CI VM .133 (same pinned 1.96.0 MSVC toolchain +
Public-path FFmpeg/LLVM the runner uses): cargo clippy -p punktfunk-host
--all-targets -- -D warnings EXITCODE 0 at the DS4-conversion tip
(.173 was down; .133 carries the identical toolchain).
Part of G12/3.3 (§3a.4 commit 5).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first backend onto the shared skeleton: DualSenseManager becomes
pub type DualSenseManager = UhidManager<DsLinuxProto>, where DsLinuxProto
supplies only the protocol half (UHID open + success log, DsState
neutral/merge/apply_rich with the paddle fold, best-effort write, the
GET_REPORT-answering service pass). handle/apply_rich/heartbeat/pump and
the unplug sweep now come from uhid_manager — behavior-identical
(same log lines, same dedup + reset semantics), zero Pads-router edits.
Verified on .21: clippy --all-targets -D warnings clean; full suite 290
pass / 0 fail.
Part of G12/3.3 (§3a.4 commit 4).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shared skeleton of the five stateful UHID/UMDF managers (Linux
DualSense / DualShock 4 / Steam Deck, Windows DualSense / DualShock 4),
written once over PadSlots: event routing with the unplug sweep and
was-the-unplug early return, the merge-preserving frame fold, rich-input
application, the silence heartbeat (with a backend force hook for the
Steam mode-entry pulse), and the feedback pump with rumble dedup +
HidoutDedup. A backend supplies only its per-controller half via
PadProto: open / neutral / merge_frame / apply_rich / write_state /
service — exactly where the real protocol differences live.
Method surface (new/handle/apply_rich/pump/heartbeat) matches what the
punktfunk1.rs Pads router already drives, so each backend will convert
as a pub type alias with zero router edits.
Additive only — no backend converted yet. 8 mock-backend tests make the
manager lifecycle unit-testable for the first time; G2 (rich fields
survive a button-only frame) and G10 (Arrival eager-creates) are now
generic regression tests, plus removal-frame no-recreate, absent-pad
rich drop, create-backoff state tracking, rumble/hidout dedup + re-arm
on recreate, and heartbeat gap/force semantics.
Verified on .21: clippy --all-targets -D warnings clean; suite 293
pass / 0 fail (285 prior + 8 new).
Part of G12/3.3 (gamepad-review-cleanup.md §3a.3, commit 3 of the §3a.4
sequence).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Vec<Option<Pad>> slot table, active_mask unplug sweep, and PadGate-
checked create that all seven backend managers copy-paste, extracted into
one unit-tested inject/pad_slots.rs (cfg any(linux,windows), like
pad_gate). sweep() returns the swept indices as a bitmask and ensure()
returns fresh-create, so managers reset their per-index sibling state
(state / last_rumble / dedup / clocks) without closure gymnastics.
Lifecycle log lines are label/device/hint-parameterized to stay
byte-identical per backend; open() keeps the success line (it knows the
transport detail).
Additive only — no manager converted yet; first unit coverage for the
sweep/create lifecycle (5 tests: freshness, sweep-once semantics, gate
integration, recreate, pump iteration).
Verified on .21: clippy --all-targets -D warnings clean; suite 285
pass / 0 fail (280 prior + 5 new).
Part of G12/3.3 (gamepad-review-cleanup.md §3a.3, commit 2 of the §3a.4
sequence).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Linux DualShock 4 backend missed the G2-era shared-mapping work and
drifted from dualshock4_proto three ways, leaving two user-visible gaps
on the DS4 kind (Windows, written later against the proto, is correct):
- its serialize_state duplicated the proto's byte-for-byte EXCEPT byte 7:
raw st.buttons[2] instead of buttons2_with_click(), so a rich-plane pad
click never reached the report;
- its inline apply_rich never set touch_click and dropped the Steam LEFT
pad entirely (surface 1 skipped), where the shared
dualsense_proto::DsState::apply_rich splits the one touchpad left/right;
- handle() didn't preserve touch_click across button-only frames.
Net effect: Deck client -> Linux host on the DS4 kind = pad clicks and
the left pad dead.
Delete the local serialize_state/parse_ds4_output/Ds4Feedback/pack_touch
and touch-dim consts in favor of dualshock4_proto (dropping the proto's
keep-in-sync FIXME), route rich events through the shared
DsState::apply_rich, and preserve touch_click in the frame merge exactly
like the other three DS-family managers. The proto's serialize_offsets
test gains a touch_click case pinning byte 7 bit 1.
Verified on .21: cargo clippy -p punktfunk-host --all-targets -D warnings
clean; full suite 277 pass / 0 fail.
Pre-step 3.3.0 of the G12 skeleton extraction (gamepad-review-cleanup.md
§3a.2) — the behavior fix lands before the mechanical dedup.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wrap the long dispatch_finger call args, Abs struct literals, and Act::Button/
Scroll/MoveRel pushes per rustfmt (the CI fmt check on pf-presenter). No behavior
change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bumps [workspace.package] version 0.10.0 -> 0.10.1 (14 workspace crates) and
syncs Cargo.lock (versions-only). Apple MARKETING_VERSION / Android versionName
are set from the release tag by CI, so no client manifest changes; the nested
Windows-driver workspace keeps its independent 0.0.1 version.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bring the SDL presenter (Linux/Deck + Windows) to parity with the Android and
Apple clients: a persisted TouchMode selects how a touchscreen drives the host —
* Trackpad (default): relative cursor with pointer ballistics + the shared
gesture vocabulary (tap = left click, two-finger tap = right click,
two-finger drag = scroll, tap-then-drag = held left drag, three-finger tap =
cycle the stats overlay).
* Direct pointer: the cursor jumps to and follows the finger (absolute).
* Touch passthrough: every finger is a real host touchscreen contact.
Previously the presenter had no finger handling, so SDL synthesized mouse events
from touch and — under the stream's relative-mouse lock — walked the host cursor
into the corner (the reported Deck bug). SDL touch->mouse synthesis is now off;
DIRECT touchscreens route through a new incremental gesture engine (a port of
Android TouchInput.kt / Apple TouchMouse.swift), while INDIRECT trackpads keep
driving the mouse. Fingers map through the aspect-fit letterbox onto the content
rect.
TouchMode lives in the shared trust::Settings (default trackpad, so passthrough
is opt-in like the other clients); the GTK and WinUI settings screens both gained
a "Touch input" picker. Gesture engine, letterbox mapping, and settings
back-compat are unit-tested (28 tests green); clippy -D warnings clean; full
Linux client + session build verified on-host.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
48-finding cross-client + host gamepad audit (2026-07-13). Apple/Android/SDL-core
capture + feedback and the Linux/Windows host injectors: held-guide release, the
permanent broken-latch cliff (PadGate), Steam Deck trackpad clicks, DualSense mute,
Windows DS/DS4 paddle fold, uinput button re-sync, gamestream BTN_* dedup, the dead
Windows shell fork, legacy-Deck rumble ceiling, XUSB arrival, ARM64 fences, the
truncate-everywhere value convention, and more. See
punktfunk-planning/design/gamepad-review-cleanup.md.
The gamepad-UI navigation resolvers disagreed on which way a perfect 45-degree
stick push (|x| == |y|) resolves: the SDL core picked horizontal (`ax >= ay`)
while Apple (`abs(x) > abs(y)`) and Android (`abs(Y) >= abs(X)`) picked vertical.
Align Apple (`>` -> `>=`) and Android (`>=` -> `>`) to the SDL core so an exact
diagonal moves focus the same way on every client (horizontal wins). This is
client-local menu navigation only and never reaches the wire. Completes the last
deferred G25 sub-part.
Verified: Apple `swift build` + full suite (124 pass); Android `:app:compileDebugKotlin`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The XUSB `packet` publish and the XUSB `rumble_seq` / DualSense `out_seq` reads
used plain unaligned accesses with no fence, so a driver could observe a bumped
change-detect field over a torn body on a weakly-ordered core (ARM64). Publish
`packet` via a Release AtomicU32 store behind a Release fence, and Acquire-load
the seq fields, mirroring the gamepad_raii PadChannel seq-fence precedent. The
DualSense input report embeds its seq mid-report with no driver-gated
change-detect field, so it gets a Release fence after the copy and a documented
residual (a per-frame input generation is deferred). No-op on x86-TSO.
Verified: Windows .173 `cargo clippy -p punktfunk-host --all-targets -- -D warnings` (green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The XUSB manager's `handle` dropped `GamepadEvent::Arrival` via a `let else`, so
the GameStream path never created the pad until the first `State` and missed the
first XInput poll. Match on the event and `ensure` eagerly on Arrival, mirroring
the DualSense backend. Also refresh `last_active` on create and unplug so a
freshly-created pad's residual-rumble idle clock starts fresh rather than
inheriting a stale Instant (which could force off a legitimate rumble at once).
Verified: Windows .173 `cargo clippy -p punktfunk-host --all-targets -- -D warnings` (green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`sddl_sa` leaked the `LocalAlloc`'d PSECURITY_DESCRIPTOR that
ConvertStringSecurityDescriptorToSecurityDescriptorW returns, once per DATA
section and once per bootstrap mailbox create (amplifiable under pad-flap via
create_named's squat-retry loop). Wrap it in a `SecAttr` RAII owner that
`LocalFree`s on drop; it outlives every CreateFileMappingW (the section copies
the security info at create time), and create_named builds one and reuses it
across retries instead of re-allocating.
Verified: Windows .173 `cargo clippy -p punktfunk-host --all-targets -- -D warnings`
(green) -- confirms the LocalFree/HLOCAL signature at the pinned windows-rs rev.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The two-motor split assumes ids[0] = light/right and ids[1] = heavy/left, an
ordering `VibratorManager.getVibratorIds()` does not guarantee. Record the
assumption and its tactile-only failure mode (a heavy-first pad inverts the feel
but nothing silences or crashes) at the call site. No behavior change: a per-pad
fix needs on-glass verification, and a blanket count-based fallback is unsafe
(extra ids may be DualSense trigger actuators that must stay silent).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Against a legacy (no-TTL) host, a held Deck rumble droned forever if the stop
datagram was lost: the 40 ms keep-alive re-kicked the actuator indefinitely and
only the v2 lease `deadline` ever bounded it. Add a per-slot `updated_at` clock
bumped ONLY by real host datagrams (never by the keep-alive re-kick, unlike
`last_at`), and in the legacy branch (`ttl_ms == 0`) issue a single (0, 0) once
it is stale past LEGACY_RUMBLE_CEILING_MS (1000 ms = 2x the host's flat 500 ms
legacy refresh). A genuinely-held legacy rumble refreshes every 500 ms and never
trips; the v2 `deadline` path is untouched and stays authoritative.
Verified: Windows .173 `cargo clippy -p pf-client-core -- -D warnings` (green).
On-glass owed: real Deck with an induced legacy stop-frame drop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Apple's GamepadCapture rounded axis values (`(v * scale).rounded()`) while
SDL-core and Android truncate, so a half-pressed control emitted 128 on Apple
vs 127 elsewhere. Drop `.rounded()` so `Int32(Float)` truncates toward zero on
Apple too; rails are unchanged (full deflection stays 255 / ±32767).
Also clamp SDL-core's LeftX/RightX to a symmetric -32767 like the Y axes and
the other clients already do, instead of letting the raw i16 reach -32768.
Verified: Apple `swift build` + full PunktfunkKit suite (124 pass); SDL half
on Windows .173 `cargo clippy -p pf-client-core -- -D warnings` (green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Four Android gamepad fixes bringing the client to parity with SDL/Apple:
G4 — HAT batched history. Android batches joystick ACTION_MOVEs, so a
rapid d-pad tap (press+release within one batch) lived only in the event's
historical samples; onMotion read just the final getAxisValue and missed
it. Feed every historical HAT sample through the transition logic (new
`applyHat`) before the current one. Sticks/triggers stay latest-wins.
G9 — floor the rumble one-shot duration. A v2 lease can carry ttl_ms==0
with a nonzero amplitude (past the (0,0) stop guard); createOneShot throws
on a non-positive duration, and on the VibratorManager path the effect is
built outside the vibrate() runCatching, so the throw would kill the whole
rumble poll thread. `durationMs.coerceAtLeast(1)`.
G18 — evict feedback binds on disconnect. Rumble/light bindings were
cached by device id and freed only at session stop, so a controller
unplugged mid-session leaked its open LightsSession. Add
GamepadFeedback.onDeviceRemoved(deviceId) (closes the session, cancels
rumble), invoked from GamepadRouter's slot-close via a new onSlotClosed
callback wired in StreamScreen. The bind maps are now guarded by a lock
(the poll threads write them; eviction runs on the main thread).
G24 — held exit chord + releases. The emergency-exit chord (Select+Start+
L1+R1) quit the stream the instant it completed — an accidental brush
killed the session, and the four held buttons were never released
host-side. Now completing the chord ARMS a 1.5 s hold timer (matching
DISCONNECT_HOLD on SDL/Apple); onExitChord fires only if still held at
expiry, after releasing the held buttons + zeroing the axes on the
triggering pad(s). onButton no longer returns the exit bool (async now);
MainActivity + StreamScreen updated.
G25 (Android half): no change — Android's stick/trigger `.toInt()` already
truncates, the chosen cross-client convention. G23 (rich-input plane) stays
deferred to its own doc.
Verified on this Mac: :kit + :app compileDebugKotlin clean; kit lint
unchanged at its pre-existing baseline. On-glass on a real phone + pad
still owed (per the Android-regressions-only-show-on-hardware history):
watch batched d-pad taps, the 1.5 s exit hold, and a mid-session unplug.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The virtual Steam Deck pad only appears in the host's Game Mode (and is
navigable) when it arrives as a real USB device via the usbip/vhci_hcd
transport — Steam Input won't promote the UHID hid-steam fallback
(Interface: -1). The host runs as an unprivileged --user service, so it
cannot modprobe vhci_hcd itself; the module must be loaded at boot and the
vhci attach/detach sysfs files chgrp'd to the `input` group by the udev
rule.
Packaging ships modules-load.d/punktfunk.conf + 60-punktfunk.rules under
the sysext's /usr/lib, but a systemd-sysext image MERGES after
systemd-modules-load and early udev have already run, so on a plain reboot
of a sysext host (e.g. Bazzite) those files are read too late: vhci_hcd is
never loaded, usbip fails, and the pad silently degrades to non-promoted
UHID — the controller vanishes from Game Mode. (deb/arch/rpm are
unaffected: real /usr is present at early boot.)
Fix: sysext post_merge now mirrors BOTH files into real /etc (read at the
normal early-boot time, shadowing the /usr copies by filename; refreshed
every merge since neither is user config), then reloads udev, modprobes
vhci-hcd, and re-triggers the vhci platform device for the live session.
Also raise the UHID-fallback log INFO->WARN with an actionable hint.
Verified on the .41 sysext host: after the /etc mirror, unloading vhci_hcd
and restarting systemd-modules-load (the real reader of /etc/modules-load.d)
reloads the module; a udev coldplug trigger makes attach/detach root:input
0660; the unprivileged host user can then write attach — the exact working
precondition for the usbip transport, now durable across reboot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A game's DualSense output report bundles rumble + lightbar + player-LEDs
+ adaptive-triggers into one report, so a pad that is merely rumbling
re-sends its unchanged lightbar / LED / trigger state on every output
report. The managers already dedup rumble, but forwarded every rich
`HidOutput` event verbatim — flooding the 0xCD feedback plane to the
client during continuous rumble.
Add a shared `HidoutDedup` (dualsense_proto, used by both the Linux UHID
and Windows UMDF managers) that forwards Led/PlayerLeds/Trigger only on a
value change (per side for the two triggers) and always forwards one-shot
TrackpadHaptic pulses — mirroring the rumble dedup two lines above and the
DS4 backend's lightbar dedup. Reset per pad on create/unplug.
Verified on Linux .21 (clippy -D warnings clean, new HidoutDedup unit
test + full suite green); Windows .173 with the rest of Phase 3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `SwDeviceCreate` completion-callback context (`SwCreateCtx`, the
`sw_create_cb` extern callback, and the `instance_id()` accessor) was
copy-pasted byte-for-byte in the XUSB (`gamepad_windows.rs`) and
DualSense/DS4 (`dualsense_windows.rs`) backends. Hoist the one copy into
`gamepad_raii.rs` as `pub(super)`; both `create_swdevice` bodies now build
the shared type and pass the shared callback. Prunes the now-orphaned
HRESULT/SetEvent/HANDLE imports from the two siblings.
Pure move + dedup, no behavior change. Windows-verified with the rest of
Phase 3 (clippy --all-targets -D warnings).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`gamestream/gamepad.rs` hand-declared its own copy of the GameStream
buttonFlags/buttonFlags2 layout, which had drifted from the single source
of truth in `punktfunk_core::input::gamepad`: the click bits were named
`BTN_LS_CLK`/`BTN_RS_CLK` (vs core's `…_CLICK`). The two layouts are
bit-identical — GameStream/Limelight and the punktfunk/1 native wire are
one contract — so define the gamestream names as `pub const` aliases of
the core constants. Values now come solely from core (can't drift);
kept as `pub const` (not a `pub use` re-export) because on Windows the
only consumer — the Linux uinput button map — is cfg'd out, where an
unused re-export lints as an error but an unused pub const does not.
Rename the two injector call-sites (`inject/linux/gamepad.rs`) to the
canonical `BTN_LS_CLICK`/`BTN_RS_CLICK`.
G15 host half: replace the 3-bit gamestream-vs-core spot-check with an
exhaustive golden-value test (`gamepad_wire_bits_are_pinned`) that freezes
every button bit + axis id to its exact wire value, so renumbering a bit
in core — which would silently break every shipped client — fails a test
first. The host counterpart to the client-side C-ABI cross-checks.
Verified on Linux .21: clippy -D warnings clean, pin test + gamepad
suite green. (Windows verified together with the rest of Phase 3.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Windows DualSense and DualShock 4 managers passed the raw wire
buttons straight into `DsState::from_gamepad`, so a client's Steam back
grips (BTN_PADDLE1..4) were silently dropped and `PUNKTFUNK_STEAM_REMAP`
was ignored — the Linux DS/DS4 backends already fold them via
`steam_remap::fold_paddles`. Bring the Windows backends to parity: add a
`remap: steam_remap::RemapConfig` field (`::from_env()` in `new()`) to
both managers and fold the paddles before `from_gamepad`, exactly as
`linux/dualsense.rs` / `linux/dualshock4.rs`. Default policy stays Drop
(don't fire buttons the user didn't ask for); set the env to map the
grips onto stick-clicks or shoulders.
`steam_remap` was gated `target_os = "linux"`; widened to
`any(linux, windows)`. It's pure (only punktfunk_core + std::env); its
Linux-only Deck motion rescale is `pub` so it compiles clean on Windows
with no dead-code warning.
Verified: Linux .21 (clippy -D warnings clean, inject tests 32 pass / 0
fail — the gate widening is a no-op there); Windows .173 (clean-recheck
of punktfunk-host, cargo clippy --all-targets -D warnings EXITCODE 0,
steam_remap + both managers compiling on Windows for the first time).
On-glass with a real DualSense/DS4 + PUNKTFUNK_STEAM_REMAP still owed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The uinput gamepad backend emitted only XOR-changed button edges while
advancing `prev_buttons` unconditionally. Because `emit()` is best-effort
(a full kernel queue silently drops the write), a dropped EV_KEY edge was
never re-synced — the button stayed stuck (pressed-not-released, or vice
versa) until it next toggled. The axes never had this problem: they
re-emit their absolute value every frame.
Re-assert every mapped button's absolute state each frame, exactly like
the axes, and drop the now-unused `prev_buttons` field. Restating an
unchanged key is free downstream: the kernel input core discards an
EV_KEY whose value already matches the device's current state (no
duplicate event reaches consumers, and BTN_* keys don't autorepeat). The
`emit()` "next frame re-syncs state" comment is now honest for buttons
too.
Verified on the Linux host build (.21): cargo clippy -D warnings clean
(no dead-field warning), full punktfunk-host suite 277 passed / 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All seven virtual-pad managers (Linux uinput/uhid: gamepad, dualsense,
dualshock4, steam_controller; Windows XUSB/UMDF: gamepad, dualsense,
dualshock4) carried an identical copy-pasted `broken: bool` latch that
was set on the FIRST pad-creation error and never cleared — so a single
transient failure (a startup race on /dev/uinput, a momentary EBUSY, the
Windows companion driver not yet ready) permanently disabled EVERY
controller for the rest of the session, even after the cause cleared.
Extract that latch into one shared, unit-tested `PadGate`
(inject/pad_gate.rs) with the fix baked in: capped exponential backoff
(1s doubling to 30s) instead of a permanent kill. After a failure,
creation is blocked only until the backoff elapses — so the manager no
longer re-attempts (and re-logs) on every one of the 60–240 input
frames/sec — then a single retry is allowed; a success resets the
backoff. A genuinely broken setup therefore self-heals within one
backoff window of the fix (udev reload / driver install / next client
connect) with no host restart. The gate is manager-wide, matching the
old flag's semantics (these failures are systemic, not per-slot).
This folds G3 (broken latch) into G12 (dedup the manager skeleton): the
latch now lives in one place across all seven backends.
Verified on the Linux host build (.21): cargo clippy -D warnings clean,
full punktfunk-host suite 277 passed / 0 failed, 4 new PadGate tests
green. Windows managers verified separately on the x64 box.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DsState::from_gamepad mapped GUIDE→PS and TOUCHPAD→TOUCHPAD into buttons[2] but
never handled BTN_MISC1, so the mic-mute / capture button clients send was inert
on every PlayStation-family virtual pad (DualSense/DualShock4), and btn2::MUTE
was dead code. Map BTN_MISC1 → btn2::MUTE (rebuilt from the wire bit each frame
like PS/TOUCHPAD, so no persistence gap) and drop the #[allow(dead_code)].
Test extended (from_gamepad_maps_touchpad_click); green on Linux (.21).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SteamControllerManager::handle rebuilds `SteamState.buttons` from the gamepad
frame every tick via from_gamepad, preserving only the rich-plane TOUCH bits —
so a held trackpad CLICK (set on the rich plane by apply_rich, stored in
`buttons`) was wiped on the very next button/stick frame and only flickered
back on the next rich event. This is the exact trap the DualSense backend
already dodges by keeping click in a separate `touch_click` field.
Mirror that: add persisted `lpad_click`/`rpad_click` bools to SteamState set by
apply_rich (instead of pressing LPAD_CLICK/RPAD_CLICK into `buttons`), OR them
into the report's click bits in serialize_deck_state, and preserve them across
the rebuild in handle() like touch/coords/motion. RPAD_CLICK's other owner —
the DualSense touchpad-click wire button via from_gamepad — stays in `buttons`
and is OR'd at serialize, so the two sources release independently (a released
BTN_TOUCHPAD can't strand a rich click, and vice-versa).
Adds a regression test (rich_click_survives_a_buttons_rebuild). All 17
inject::{steam,dualsense,dualshock4}_proto tests pass on Linux (.21).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
clients/windows/src/gamepad.rs was a 629-line near-verbatim fork of
pf-client-core's SDL gamepad service, frozen at an old single-pad design.
Commit 9822fc3b removed its attach/detach entry points but left the machinery,
so `Worker.attached` was initialized None and never set — ~300-400 lines
(button/axis/touchpad/motion forwarding, Ds5Feedback, the rumble/HID feedback
loop) were logically unreachable, never flagged because the guards read a
runtime Option the compiler can't prove is always None. The live remainder
(pad enumeration + pin persistence) had drifted from core: it opened every
device for metadata (vs core's no-open id-getters), force-enabled the Valve
HIDAPI drivers unconditionally, lacked the steam_virtual skip (so it could pin
Steam Input's virtual pad and kill gyro), and derived the pin key from an
opened handle — risking a cross-process byte-mismatch with the session, which
resolves the same key from id-getters.
The shell's only live job is enumerating pads for the Settings list and
persisting the pin; the spawned punktfunk-session already runs the full
pf-client-core service and does all real forwarding (session/main.rs). So
delete the fork and point the shell at pf_client_core::gamepad::GamepadService
directly — its start()/pads()/set_pinned()/clone() + PadInfo{key,name,
kind_label()} are a strict superset of what the shell uses. Idle, core's
service is hands-off the hardware (id-getter metadata, no device open, HIDAPI
off), which is the intended behavior and fixes the drift class above.
- delete clients/windows/src/gamepad.rs (-629) and `mod gamepad;`
- main.rs / app/mod.rs: use pf_client_core::gamepad::GamepadService
- drop the now-unused direct sdl3 dep (pf-client-core pulls it on Windows with
the same build-from-source,hidapi features); sync Cargo.lock
Pre-checks (dev Mac): std mpsc Sender<T>: Sync confirmed on the pinned 1.96.0
(so core's GamepadService is Sync for the WinUI cross-thread sharing, no core
change needed); rustfmt clean; no dangling refs. Windows compile is deferred
to CI (windows-only crate, unbuildable on macOS).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
G5: buttonMask mapped the dedicated share/create/capture element onto BTN_BACK,
the same bit as View (buttonOptions). On an Xbox-Series pad those are two
distinct physical buttons, so Share was indistinguishable from View on the
host and never delivered the capture bit the host already decodes (DualSense
mute / Steam quick-access). Route it to BTN_MISC1 instead, matching the Rust
client's `Button::Misc1 => wire::BTN_MISC1`. Adds `misc1` to GamepadWire and
allButtons so a held capture button is released on flush like the others.
(On-glass verify owed on a real Xbox-Series pad; a clone pad that exposes one
button as both buttonOptions and Share now emits back+misc1 for it — harmless
on a plain xpad session and rare otherwise.)
G22 (partial): define paddle1..4 for wire completeness, but leave them out of
buttonMask/allButtons until the GameController paddleButton1..4 ↔ BTN_PADDLE
physical correspondence is confirmed on a real Elite pad.
G15: replace the 3-bit spot-check with an exhaustive assertion of every
GamepadWire button/axis constant against the generated C ABI header
(punktfunk_core.h), so any Swift-side drift from punktfunk_core::input::gamepad
fails CI.
swift build + full PunktfunkKit suite green (124 passed, 5 skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`sync()` XOR-diffs the full `GamepadWire.allButtons` set (which includes
guide) against `slot.buttons`, but `buttonMask` deliberately omits guide —
it's driven separately by the Home handler via `sendGuide`. So while guide
was physically held, the first stick/trigger/face-button move made `changed`
carry the guide bit and the diff loop emitted a spurious guide-UP (then the
real release was swallowed by `sendGuide`'s `guard now != slot.buttons`).
Effect: you could not hold PS/guide while doing anything else — e.g. holding
guide to keep the host's Steam overlay engaged released it the instant you
touched a stick. The Rust reference client folds guide through the same diff
as every other button and has no such split.
Fix: preserve the current held guide bit through the diff
(`buttonMask(g) | (slot.buttons & GamepadWire.guide)`) so guide is never seen
as "changed"; `sendGuide` stays the sole toggler and `flush`/`allButtons`
still release it on close/deactivation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
More builtin-removal fallout: trust.rs re-exported pf_client_core::trust::
touch_last_used, whose only consumer was the deleted in-process session pump. In
a binary crate an unused pub-use is a hard -D warnings error (it surfaced only
after the gamepad dead-code errors were cleared, which had suppressed the
unused_imports pass). Drop it; every other re-export still has a user.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Removing the builtin stream path (ef580825) left GamepadService's attach/detach/
active/auto_pref + the Ctl::Attach/Detach variants with no callers — the spawned
punktfunk-session binary owns pad forwarding now. The client still compiled, but
clippy -D warnings tripped on the dead code. Drop the forwarding hooks + the
active-pad mirror; the service keeps pads() (Settings list) and set_pinned()
(persist the forwarded-pad selection the session child reads).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cargo fmt --all --check flagged the reanchor gate wiring (decode.rs / session.rs /
abi.rs / reanchor.rs): wrapped signatures + comparisons, and two multi-line comments
that followed a trailing-comment line were restructured to their own lines so
rustfmt keeps them at normal indentation instead of deep-aligning them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Windows AMF encoder hard-rejected any runtime <1.4.36 — a Jan-2025
(Adrenalin 25.1.1) driver floor. Every AMD host on an older driver failed the
session with "update the AMD driver" after 8 retries, notably Boot Camp Macs
whose bundled amfrt64.dll lags far behind.
Split the single pin:
- AMF_MIN_VERSION (1.4.34): the ABI floor accepted at load. Every vtable slot
the FFI mirrors is a base-interface slot stable since well before 1.4.34; the
1.4.35/1.4.36-only features are string-keyed encoder properties already applied
via set_prop(required=false), which log-and-continue — so an older driver
degrades those features individually instead of failing.
- AMF_HEADER_VERSION (1.4.36): the header the mirror targets, now passed to
AMFInit capped at min(header, runtime) so claiming a version newer than the
runtime can't make AMFInit reject an otherwise-usable older driver.
No functionality removed: a >=1.4.36 runtime behaves exactly as before.
Also logs, once per process, the AMF runtime version AND the loaded amfrt64.dll's
full path + file-version resource (via GetModuleFileNameW + VerQueryValueW). This
surfaces the Boot Camp failure mode where the display driver reads 25.x but the
System32 amfrt64.dll is a stale build reporting an old AMF version; the too-old
decline now names the DLL path/build and points at reboot + DDU reinstall.
Not compile-verified: amf.rs is Windows-only and this Linux box can't cross-build
it (a dependency's C build fails for the msvc target). Needs cargo check/clippy on
the Windows build box / CI. rustfmt-clean; the windows-crate FFI signatures were
verified against the on-disk 0.62.2 bindings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bumps [workspace.package] version 0.9.2 -> 0.10.0 (14 workspace crates). Apple
MARKETING_VERSION / Android versionName are set from the release tag by CI, so no
client manifest changes; the nested Windows-driver workspace keeps its independent
0.0.1 version.
Also syncs Cargo.lock: the version bump for the 14 members, plus dropping the
now-orphaned crossbeam-channel entry the Windows builtin removal (ef580825) left
behind (it dropped the dep from the manifest but not the lock), so
`cargo build --locked` (ci.yml / deb.yml) stays green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- InputCapture / StreamViewIOS: iPad ⌃⌥⇧Q un-capture chord, recognized from the
GCKeyboard HID stream (no NSEvent monitor on iOS) for cross-client parity with
the macOS/Windows/Linux combo; and a click into the video re-engages capture —
the iPad analogue of macOS mouseDown → engageCapture(fromClick:), with the
engaging click suppressed toward the host.
- SessionPresenter: snap the aspect-fit sublayer frame to the backing pixel grid.
AVMakeRect centers the fit rect at fractional points, so the compositor
resampled the layer — a uniform "everything soft" blur even when the drawable
was pixel-exact 1:1. Rounding origin + size to device pixels makes the composite
a true 1:1 blit; idempotent when already aligned.
- MetalVideoPresenter: PUNKTFUNK_BILINEAR_LUMA=1 A/B lever — compiles the shader
with Catmull-Rom luma off (plain bilinear) to isolate bicubic overshoot from
upstream fringing.
- SettingsView / StreamView / StreamViewIOS: match-window reverted to opt-in
(default OFF) — the explicit mode is used and never auto-resized unless enabled.
The real Windows client is the spawned punktfunk-session Vulkan binary
(pf-client-core); the in-process builtin GUI stream — reachable only via
PUNKTFUNK_BUILTIN_STREAM=1 — was dead weight kept alive by nothing and a
recurring source of wasted effort. Remove it: delete present/render/input/
audio.rs and the builtin remainder of session/video.rs, rip all the builtin
wiring (app/mod, connect, stream), and make connect always spawn.
Preserve the two shipped keepers that happened to live in those files by
relocating them to a new probe.rs: run_speed_probe (the per-host network speed
test used by the Settings speed page and --headless --speed-test) and
decodable_codecs (the codec-capability advert on the probe connect). Trim gpu.rs
to just the Settings adapter picker (adapter_names + helpers). --headless now
supports only --speed-test — the in-process decode/frame-counter went with the
pump.
Drops the now-orphaned deps opus, wasapi, crossbeam-channel, anyhow; keeps
ffmpeg-next (probe::decodable_codecs still needs it). Net 4432 deletions.
Statically verified (module wiring, imports, orphaned symbols/deps all clean);
the type-level compile runs on the windows-amd64 CI runner, which has the
toolchain this non-Windows host lacks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After unrecoverable loss the host keeps sending delta frames that reference a
picture the client never received; hardware decoders conceal these as gray/
garbage with a success status. Linux already withheld them and held the last
good frame until a proven clean re-anchor — this brings that behavior to the
Android and Apple clients.
Extract the Linux pump's freeze state machine into a shared `ReanchorGate` in
punktfunk-core (reanchor.rs, 18 tests) exposed over the C ABI (ABI v6, additive —
no wire change) for the Swift clients. Migrate the Linux/Deck pump
(pf-client-core) onto it as the parity proof (no-op refactor). Then wire:
- Android (decode.rs, both sync + async loops): arm on the frame-index gap, a
pts-keyed flag map carries the wire flags to the output-buffer release, fold
the gate per drained output, gate.poll replaces the dropped-climb block.
- Apple Stage2Pipeline (default): arm on a gap (new noteFrameIndexGap), withhold
at the ring-submit seam (CAMetalLayer holds its last drawable), poll
framesDropped, fold VT decode errors through the no-output streak.
- Apple StreamPump (stage-1): fold at enqueue, withhold via
kCMSampleAttachmentKey_DoNotDisplay so the layer keeps decoding (reference
chain intact) but holds the last displayed frame.
- Apple VideoDecoder: thread the AU's wire flags to the async decode callback via
a retained FrameContext refcon (replaces the receivedNs bit-pattern scalar).
Lifts only on a proven re-anchor (IDR / RFI anchor / 2nd recovery mark) with a
500 ms backstop so a lost re-anchor can never freeze forever. Apple: swift build
clean, 123/123 tests pass (incl. VideoToolboxRoundTripTests). On-glass
loss-injection validation still owed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a second client got its own virtual display mid-stream, the FIRST
client's IDD-push stream froze (video only; `new_fps=0 repeat_fps=240`
forever). Adding/removing/resizing a sibling display re-commits the CCD
topology, which makes the OS unassign+reassign the first monitor's IddCx
swap chain. `unassign_swap_chain` dropped the SwapChainProcessor, dropping
`run_core`'s local FramePublisher and closing the sealed-channel handles.
The fresh worker then polled the frame-channel stash — but that stash is
consumed once at session open, and the host only re-delivers on a ring
recreate (a descriptor change). The first monitor's descriptor didn't
change and WUDFHost stayed alive, so no watchdog fired: the driver drained
the swap chain without publishing and the host repeated its last frame
indefinitely. Confirmed twice on the .173 box (host.log 21:12 & 21:15).
Preserve the live FramePublisher across the flap instead of dropping it:
the host-owned ring (header/event/textures) it holds stays valid — only
the swap chain died.
- frame_transport.rs: FramePublisher records its render-adapter LUID +
exposes render_adapter().
- monitor.rs: MonitorObject.preserved_publisher + preserve_publisher()
(mirrors set_frame_channel) + take_preserved_publisher() (mirrors
take_frame_channel). Monitor teardown drops the stashed publisher and
closes its ring handles, so nothing leaks.
- swap_chain_processor.rs run_core: after SetDevice OK, re-adopt a
preserved publisher ONLY when the new swap chain renders on the same
LUID (same pooled Direct3DDevice → its context + opened textures are
valid); on loop exit, stash the publisher back on the monitor.
Safe: the old worker is fully joined (drop-outside-lock discipline)
before the new one runs, so no concurrent context use; a stale re-adopted
publisher is superseded by the existing is_stale() + has_frame_channel()
newest-wins checks at the loop top.
Verified clippy -D warnings clean on rustc 1.96.0 via a faithful mock
crate (the real crate needs the WDK to compile). Needs a driver rebuild +
reinstall on the host to take effect; not yet hardware-validated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 review of the host encoders / client decoders / RFI plane.
NVENC (both), AMF-LTR, the session glue, and the client RfiTracker came
out clean; every fix lands in the Vulkan Video backend + dispatch:
1. HEVC: author each P-frame's short-term RPS to retain ALL resident DPB
pictures (minus the setup slot), not just its one reference. HEVC
8.3.2 evicts unlisted pictures, and clients keep FEEDING the decoder
while frozen — so with the old single-pic RPS, a conforming parser
(FFmpeg = the Linux VAAPI/Vulkan and Windows D3D11VA clients) had
already discarded the picture an RFI recovery anchor references
whenever a fed post-loss frame preceded it: generate_missing_ref, and
the "clean" anchor plus everything chained after it decodes as
garbage. Pure builder (`build_h265_rps_s0`) + unit tests; AV1 needs
nothing (slot-based retention). The smoke test now encodes a fed
post-loss frame between loss@4 and anchor@6 so an ffmpeg decode of
the dropped dump exercises exactly this (expect ONE POC-4 complaint,
never POC 3) — revalidate on the AMD box; this NVIDIA dev box fails
the backend earlier at HEVC header retrieval (pre-existing).
2. AV1: chain PhysicalDeviceVideoEncodeAV1FeaturesKHR (videoEncodeAV1 =
TRUE, stype 1000513004) into device creation — spec-required for the
ENCODE_AV1 codec op; RADV tolerated the omission, validation layers
and stricter drivers do not.
3. RFI decline no longer self-arms force_kf — that bypassed the session
glue's 750 ms IDR cooldown, turning a storm of hopeless RFI requests
into one full IDR each. Decline like NVENC/AMF and let the caller's
coalesced keyframe path own the fallback; add the missing
first>last guard for parity.
4. open_video_backend now returns the label of the branch that ACTUALLY
opened, so the mgmt API / web console reports "vulkan" instead of
"vaapi" for the default-on Vulkan sessions (the old dispatch-mirror
resolved_backend_label went stale when the backend gained its VAAPI
fallback; deleted).
Structure: the ~230-line inline HEVC coding block moves to
record_coding_h265 (symmetric with record_coding_av1) and the duplicated
pre-encode barriers dedupe into begin_encode_cmd.
Follow-up plan (separate, punktfunk-planning): bring the post-loss
freeze + RECOVERY_ANCHOR/POINT lift to the Android/Windows/Apple clients
via a shared ReanchorGate (design/client-reanchor-freeze-parity.md).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend the raw Vulkan Video backend to AV1 (`VK_KHR_video_encode_av1`)
alongside HEVC, so AMD/Intel Linux hosts get the same clean-P-frame loss
recovery for AV1 that HEVC already has — no full IDR on packet loss.
ash 0.38.0+1.3.281 predates the AV1-encode extension (finalized in Vulkan
1.3.290) and bumping ash breaks the SDL/Vulkan client (it drops the
lifetime on AllocationCallbacks, which sdl3-sys still generates). So the
AV1-encode structs/flags/enums are vendored host-only in
`encode/linux/vk_av1_encode.rs`, copied verbatim from ash-master's
generated code and chained into ash's generic video-encode calls via raw
p_next — the common StdVideoAV1* types (from AV1 decode) are reused from
ash 1.3.281.
`vulkan_video.rs` gains a parallel AV1 path: AV1 Main profile/caps/session
(+ max-level session-create), a bit-packed sequence-header OBU + per-frame
temporal-delimiter framing (Vulkan AV1 encode, unlike H26x, emits only the
frame OBU), and per-frame StdVideoEncodeAV1PictureInfo with the RFI
reference model — a normal P inherits CDF context from its reference for
compression, while an IDR or recovery anchor sets primary_ref_frame=NONE +
error_resilient_mode so it decodes independent of the (possibly lost)
frames since its reference. HEVC recording is unchanged; the shared CSC /
ring / DPB-barrier pipeline is reused as-is. Codec routing in
`open_video_backend` extends the HEVC arm to HEVC|AV1.
The seq header enables only order-hint (+128px superblocks per caps),
matching FFmpeg's proven Vulkan AV1 config — enabling CDEF/restoration made
VCN emit frame-header sections our seq header didn't match, desyncing every
inter frame.
Headless-validated on real RADV (780M): open + 6-frame encode (I P P P P P)
decodes 0-error on both dav1d and ffmpeg/cbs; the RFI recovery anchor at
frame 4 is a clean P (not IDR), and dropping the "lost" frame 3 still
decodes clean (re-anchored to frame 2). HEVC smoke unchanged (no
regression). `cargo clippy --features vulkan-encode -- -D warnings` and the
no-feature build both green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The relm4 (Linux) and native (Windows) shells spawn punktfunk-session, which
since the native-plane rework forwards ALL controllers by default — but the
"Forwarded controller" settings dropdowns still described the pre-rework world
("Automatic (most recent)", "Exactly one controller is forwarded to the host").
The dropdown already lists every detected pad and wires set_pinned(None)=all /
set_pinned(key)=single-player; this fixes only the misleading labels, subtitle,
tooltip, and stale leading comments to match: Automatic forwards every real
controller (each its own player); pick one to force single-player.
cargo check -p punktfunk-client-linux green; Windows is windows-gated (pure
string edits, CI windows.yml).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Roll the pf-client-core slot pattern to the Apple client (Swift):
- GamepadManager tracks all connected GCControllers, assigning each a stable
lowest-free wire pad index + concrete type, emitting GamepadArrival on
connect and GamepadRemove on disconnect (index freed for reuse on re-plug).
- GamepadCapture binds every controller with per-controller Slot state
(buttons/axes/fingers/motion), threading the pad index into flags on every
event; GamepadWire/InputEvents carry the pad + the two new events.
- GamepadFeedback + RumbleRenderer go per-pad (rumbleByPad, slots[pad]),
routing rumble/HID back to the correct controller by wire index.
- ContentView/Settings surface every forwarded controller.
pad 0 => flags 0, so single-controller wire is byte-identical. Cannot build
on the Linux dev box (no Swift toolchain / Apple frameworks); wire bytes
hand-checked against input.rs and GamepadWireTests extended for multi-pad.
CI apple.yml (swift build/test on macOS) is the compile gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Roll the pf-client-core slot pattern to the Android client (Kotlin + JNI):
- New kit/GamepadRouter.kt: the Android analogue of the client-core Slot
model — a deviceId→Slot map assigning each InputDevice a stable lowest-free
wire pad index held for its lifetime, GamepadArrival(pref) before a pad's
first input, GamepadRemove on onInputDeviceRemoved, per-slot AxisMapper +
held-bitmask so two pads never clobber each other. The isForwardable gate
(excludes DualSense/DS4 all-zero sensor sibling nodes) is centralized in
slotFor so no entry point can open a phantom slot.
- native/src/session/input.rs: JNI shims take a pad arg -> flags=pad
(nativeSendGamepadButton/Axis, plus nativeSendGamepadArrival/Remove).
- native/src/feedback.rs: pad carried in rumble bits 49..52 + a leading
hidout pad byte; GamepadFeedback.kt routes rumble/lightbar/LED back to the
originating device by pad via deviceForPad.
- MainActivity.kt routes key/motion events by device; ControllersScreen.kt
badges every forwarded pad (was hardcoded i==0), reading getControllerNumber.
A lone controller lands on wire index 0, so its per-transition datagrams stay
byte-identical to the old single-pad path. gradle :app:assembleDebug green
(Rust cross-compiled via cargo-ndk); JNI signatures hand-verified 1:1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Host was already built for 16 pads; the blocker was every client
hard-coding pad 0. This lands the host-side + reference-client contract:
- input.rs: new wire kinds GamepadArrival=14 (declares a pad's type:
code=GamepadPref byte, flags=pad) and GamepadRemove=13 (flags=seq<<24|pad,
shares the snapshot seq space via encode/decode_gamepad_remove).
- pf-client-core/gamepad.rs: reworked from a single `open` pad to a
slots: Vec<Slot> model — every forwarded controller gets a stable
lowest-free wire index held for its lifetime, per-slot held/axis/touch/
rumble state, GamepadArrival on open + GamepadRemove on close, and
feedback routed back per wire index. Automatic forwards all real pads;
a pin forces single-player.
- punktfunk1.rs: replaced the single-session PadBackend enum with a Pads
router — per-pad kinds[]/owner[] arrays, lazily-created per-kind managers,
pure route_decision keeping a live device in its manager across a kind
change (no ghost/dup). Input thread seq-gates GamepadRemove (clears the
pad_mask bit, resets rumble) and applies GamepadArrival kinds.
- inject linux/windows backends: add the two new no-op InputKind arms.
Native/session + default-Windows clients (both spawn punktfunk-session)
inherit this. 57 core + 33 client-core + 272 host tests green; clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On-glass validated 2026-07-12 on an AMD RADV 780M with a real Deck-class
client: the pipelined raw-Vulkan HEVC encoder ran a rock-solid 1080p@240
session and healed loss with clean P-frame recovery anchors (real RFI the
libav VAAPI path can't express). Ship it on by default, mirroring the NVENC
default-on.
- vulkan_encode_enabled() defaults ON; PUNKTFUNK_VULKAN_ENCODE=0 is the libav
VAAPI escape hatch. A failed open still falls back to VAAPI, so a device
without h265 Vulkan encode (or an untested Intel/ANV that misbehaves at open)
degrades gracefully instead of breaking the stream.
- Ring depth defaults to 2 (one frame of overlap, lowest added latency — the
on-glass-validated real-time setting); PUNKTFUNK_VULKAN_INFLIGHT still tunes it.
- Compile --features punktfunk-host/vulkan-encode into the arch/deb/rpm host
builds (pure-Rust ash, no new lib / no link-time dep), alongside nvenc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Linux raw-Vulkan HEVC backend blocked on two GPU fences per frame, so
CPU readback and the next capture could not overlap the GPU encode. Refactor
into a small ring of per-in-flight-frame resources (own command buffers,
CSC descriptor set + Y/UV/NV12 scratch, bitstream buffer, feedback query and
sync objects) so submit() records into a free slot and returns without
waiting, and poll() reads back the oldest slot once its fence signals. The
pump's non-blocking poll then overlaps a frame's CSC+encode with the next
capture — the throughput win — with no capturer/pump change (VAAPI untouched).
- New `Frame` struct + `make_frame`; encoder holds `frames`/`ring`/`in_flight`.
- `record_submit` (non-blocking) + `read_slot` (fence-gated readback) replace
the synchronous `encode_frame`; `enqueue` applies backpressure by draining
the oldest slot when the ring is full.
- DPB self-barrier between consecutive encodes: orders frame N's reconstruct
write before N+1's reference read now that they can be in flight together.
- flush() drains all in-flight slots in order; reset() waits idle + discards.
- Ring depth defaults to 3, overridable via PUNKTFUNK_VULKAN_INFLIGHT (2..=6).
- Smoke test drains via poll-loop + flush (async breaks one-AU-per-submit).
Headless-validated on real RADV 780M: cargo check (feature on/off) + clippy
-D warnings + rustfmt clean; smoke test at ring depth 2/3/6 all ffmpeg-decode
clean (I P P P P P) and drop-heal (I P P P P) with 0 errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two refinements after the initial on-glass validation on RADV (780M):
- Green padding bar at non-16-aligned heights (e.g. 1080 → coded 1088): the CSC
compute shader read past the edge of the shorter source dmabuf for the 8
alignment-padding rows, producing undefined/green garbage that showed on a
client rendering the coded frame. Clamp every source fetch to `textureSize-1`
so padding rows duplicate the last real row (invisible, and the SPS
conformance window still crops it for a compliant decoder). BT.709 conversion
is byte-identical for in-bounds pixels. 5120x1440 (exactly aligned) was never
affected.
- Per-frame dmabuf import churn: the backend created + imported + destroyed a
VkImage every frame (allocation jitter → stutter). PipeWire cycles a small
fixed pool, so import each underlying buffer ONCE (keyed by st_dev/st_ino —
each frame's fd is a fresh dup of the same buffer) and reuse it, matching the
CUDA-path VkBridge. First import acquires from the foreign producer; cached
re-reads keep queue ownership and use a plain visibility barrier. On-glass:
~3-6 imports per session then silent (was ~one per frame at 240 Hz), stutter
gone at resolutions with headroom.
Also adds a PF_SMOKE_W/H override to the headless smoke test to exercise the
conformance-window crop path (ffprobe confirms coded 1088 → displayed 1080).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add `VulkanVideoEncoder` (`VK_KHR_video_encode_h265` via ash) — the open-stack
twin of the direct-NVENC RFI path, giving AMD/Intel Linux hosts real
reference-frame invalidation loss recovery: a clean P-frame recovery anchor
that re-references a known-good older frame instead of a full IDR. The app owns
the DPB, so recovery = pointing the P-frame's single L0 reference at a resident
slot strictly older than the loss (never a concealed frame).
The backend owns its own ash instance/device with encode + compute queues,
authors VPS/SPS/PPS (Main, conformance-window crop for non-16-aligned heights
like 1080->1088), runs a DPB-ring reference-slot state machine with monotonic
POC and CBR rate control, and does an on-GPU RGB->NV12 BT.709 compute CSC
(embedded rgb2yuv.spv) since capture delivers packed-RGB dmabufs — importing
each frame's dmabuf (explicit DRM modifier) or uploading a CPU-RGB fallback,
CSC on the compute queue, then encode on the encode queue, ordered by a
semaphore.
Wired into `open_video_backend`: an AMD/Intel HEVC session opens this instead
of libav VAAPI when `PUNKTFUNK_VULKAN_ENCODE=1` (VAAPI fallback on any open
error, so it can only improve recovery, never break a stream); `PUNKTFUNK_
ENCODER=vulkan` forces it. Gated behind the new `vulkan-encode` Cargo feature,
which pulls no new dependency (reuses the `ash` bindings already carried for
the dmabuf zero-copy bridge). Opt-in until on-glass validated, mirroring how
the direct-NVENC path shipped.
Headless-validated on real RADV (RDNA3 780M, Mesa 26): open + multi-frame
encode + `invalidate_ref_frames` all run through the real struct and ffmpeg
decodes the output `I P P P P P` with 0 errors; the recovery frame is a clean
P-frame (not an IDR); and dropping the "lost" AU still decodes cleanly because
the recovery re-anchored to an older frame — the RFI heal, proven on real
hardware. `cargo check`/`clippy -D warnings` green with the feature on and off.
Design: design/linux-vulkan-video-encode.md. Harness: design/vkenc-probe-harness/.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On-glass validated 2026-07-12 on an RTX 5070 Ti host with a real Steam Deck
client (NV12 4:2:0, 1280x800@90): real nvEncInvalidateRefFrames landed 73/73
as clean P-frame recovery anchors (never IDR), losses consistently ~2 frames
deep — well inside the 5-frame DPB. That is the loss recovery the libav
hevc_nvenc path cannot express, so make the direct path the default on NVIDIA.
PUNKTFUNK_NVENC_DIRECT=0 (also false/no/off) is now the libav escape hatch.
Still gated on a CUDA capture payload — the `cuda` check in open_nvenc_probed
keeps AMD/Intel on VAAPI regardless, and the NVENC/CUDA entry points stay
dlopen'd at runtime (no new DT_NEEDED), so non-NVIDIA hosts are unaffected.
Packaging comments updated to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Phase 5.2 direct-SDK NVENC Linux backend (encode/linux/nvenc_cuda.rs) is
gated `#[cfg(all(target_os = "linux", feature = "nvenc"))]`, but no Linux
packager passed `--features nvenc` (it was historically a Windows-only feature
for the D3D11 NVENC path). So the module was compiled OUT of every arch/deb/rpm
canary regardless of commit — PUNKTFUNK_NVENC_DIRECT was a silent no-op on the
shipped Linux host. Add `--features punktfunk-host/nvenc` to all three package
builds so the code actually ships.
AMD/Intel-SAFE (verified): this is NOT the old Windows link-import crash. The
NVENC/CUDA entry points are dlopen'd at RUNTIME (libloading) — `objdump -p` shows
the feature build's DT_NEEDED is byte-identical to a plain build (no libcuda /
libnvidia-encode), so the binary starts fine driver-less. We use only the crate's
`sys::nvEncodeAPI` types (no code-running safe wrapper / lazy statics), cudarc
stays on `ci-check`/dynamic-loading (no CUDA toolkit at build), and the encoder is
only constructed for a CUDA capture frame + PUNKTFUNK_NVENC_DIRECT — never on a
VAAPI (AMD/Intel) host. The sysext images repackage these outputs, so they inherit
it; no other Linux host build compiles the binary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The punktfunk-web unit serves HTTP/1.1 over TLS with the host's own
self-signed identity cert, but several guides still told users to open
http://<host>:47992, which fails. Correct the scheme everywhere and note
the one-time browser cert warning in the canonical + SteamOS docs.
The RPM %post web hint was doubly wrong (http://<host-ip>:3000): wrong
scheme and wrong port — the service listens on :47992, not the :3000 dev
default. Also fixes scripts/web-init.sh, so the URL the SteamOS/Linux
installer prints at the end of setup is correct too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`cargo fmt` was not run on the Phase 5.2 additions (nvenc_cuda.rs + the encode.rs
dispatcher fork), failing the ci.yml rust fmt job. Whitespace/wrapping only — no
behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 5.2 of design/encoder-recovery-hardening.md (design/linux-direct-nvenc.md).
The Linux NVIDIA host encodes through libavcodec `hevc_nvenc`, which structurally
cannot express `nvEncInvalidateRefFrames` — so every FEC-unrecoverable loss is a
full IDR and, since the client freezes-until-reanchor, a per-loss freeze for
RTT+IDR-encode. This ports the Windows raw-NVENC backend to
NV_ENC_DEVICE_TYPE_CUDA over the shared CUcontext so Linux NVIDIA gets the same
real RFI + F2 recovery-anchor + reset() stall lever + HDR-SEI/Main10 plumbing.
New `encode/linux/nvenc_cuda.rs` (`NvencCudaEncoder`):
- runtime-loaded entry table via `dlopen libnvidia-encode.so.1` (never link-time,
mirroring the zerocopy::cuda libcuda loader) — one binary still starts on
AMD/Intel Linux boxes and falls through to VAAPI/software;
- session on the shared CUcontext (zerocopy::cuda::context());
- an encoder-owned ring of registered CUDADEVICEPTR input surfaces
(zerocopy::cuda::InputSurface + a contiguous-NV12 allocator), each captured
DeviceBuffer device→device copied in via the existing copy_* helpers — mirrors
the libav recycled-hwframe-pool copy, so zero regression vs today;
- config/RFI/anchor/reset ported from the Windows backend; sync-only (NVENC async
is Windows-only, so that whole subsystem is dropped);
- Main10/HDR-SEI wired but inert until a Linux P010 capture path (Phase 5.1).
Wired behind PUNKTFUNK_NVENC_DIRECT (default OFF) in open_nvenc_probed; the Windows
path is untouched (no shared extraction in v1). Two on-hardware `#[ignore]` smokes
added.
Validated on .21 (RTX 5070 Ti, driver 610.43.03): builds on Linux under ci-check,
clippy-clean, full host suite 272/0, NV12 smoke (8 AUs, real invalidate_ref_frames
+ recovery_anchor on a P-frame) and YUV444 FREXT smoke (6 AUs, chroma_444) green;
Windows compile unaffected. Owed: the client-in-the-loop matrix (RFI-survives-ABR,
reset() heal, A/B vs libav) and the default flip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Developer integration guide for building a punktfunk client on any platform
by linking punktfunk-core through its stable C ABI: what the core does vs.
what the embedder supplies, build/link/cross-compile, the full client
lifecycle (identity/pairing, connect ladder, video+recovery loop, audio,
input, feedback planes, teardown), plus worked blueprints for webOS, Xbox
(GDK), and Tizen.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The About settings card had no version at all — add an identity block (app name +
"Version <CARGO_PKG_VERSION>", the workspace version) at the top, the WinUI
Settings convention and matching the Apple client's "Version X" wording. Also
capitalize the brand name on the licenses screen (was lowercase "punktfunk").
Verified against the pinned windows-reactor source + cargo fmt --check; full
Windows link left to CI (Windows-only crate).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New clippy (rust-1.96) flags the summary line after the 'Gated OFF for' bullet
list as a lazy list-continuation under -D warnings. Add a blank /// line so it
reads as its own paragraph (clippy's suggested fix), matching intent. Comment
only; no behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"Gaming rig" actually meant a dedicated/headless box you only ever stream from,
which confused users — rename it to "Headless box" and rewrite all five preset
summaries to be scenario-first and shorter (the console cards already show the
mechanics as badges). Updated across the host API summaries (mgmt.rs), the web
console labels (en/de), and the docs table + prose. The internal preset id
`gaming-rig` is unchanged (stable API / stored-policy / test contract).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror the Apple client: the connect/wake overlay was showing the full-screen
aurora takeover in the default touch UI too. Make ConnectOverlay mode-aware —
gamepad/console keeps the aurora ConnectTakeover, the default UI now renders a
Material 3 AlertDialog over the host grid (inert scrim; Back/buttons cancel),
matching the app's other touch dialogs. Extract a shared connectCopy() so both
presentations read identically; ConnectTakeover is now console-only.
Screenshot scenes updated (touch phases -> modal over the host grid via
shootScreen; console stays a root capture); record-mode tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three client-UX changes that share the connect/present path (and settings files):
- Connect/wake overlay is now mode-aware: the console/gamepad UI keeps the
full-screen aurora takeover, while the default (touch/desktop) UI shows a
Liquid Glass modal over the host grid — the takeover looked out of place there.
- Add an auto-wake toggle (DefaultsKey.autoWake, default on) across macOS/iOS/tvOS
Settings + the gamepad settings view; gate startSession/prepareWake and the
gamepad "Wake & Connect" label on it. MAC-address learning stays always-on.
- Windowed sessions now stream at the window's native pixels (Match-window
default-on) so the picture is 1:1 pixel-exact instead of the presenter
resampling a fixed-mode frame; fullscreen reports full-display px, also 1:1.
Also lands the mid-resize aspect-fit tracking (decoded contentSize) that keeps
the picture undistorted after a resize.
swift build + swift test (121 tests) green; screenshot scenes verified.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Give instant feedback the moment a host is picked, and make the wake wait a
full-screen takeover instead of a modal card — unified into one ConnectOverlay
across every client:
- android: new ConnectOverlay (aurora backdrop; Connecting / Waking / timed-out
phases) replaces the tiny inline "Connecting…" row and the WakeOverlay card.
The dial phase is now cancelable and hands off to the wake wait in one frame.
- console (pf-console-ui): the connect/wake overlays become a full-screen aurora
takeover (draw_takeover) instead of a centered card over a dim scrim; the
Waking → Connecting handoff no longer blinks.
- apple: new ConnectOverlay mirrors it (macOS / iOS / tvOS), replacing the
per-tile connecting spinner + the WakeOverlay card; instant "Connecting…" from
model.phase, and the carousel is gated inactive during the dial.
Also enable Wake-on-LAN on iOS/tvOS now that the multicast entitlement is
approved: enable com.apple.developer.networking.multicast and flip
wakeOnLANAvailable to true on every platform (MACs were already learned from
mDNS, so wake works immediately).
Verified: Android compileDebugKotlin + screenshot renders; console clippy +
36 tests + rendered phases on Linux; Apple swift build + 121 tests + rendered
phases.
Co-Authored-By: Claude Opus 4.8 <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>
The mid-stream Match-window trigger + Resolution tri-state already shipped on BOTH
desktop clients via the session-always punktfunk-session binary (pf-presenter D1/D2,
C1). This ports the last Apple-parity piece — the resize-in-progress indicator
(clients/apple ResizeIndicator/ResizeIndicatorView) — into the SHARED pf-presenter
overlay, so one implementation covers both the Windows and GTK4 session windows.
- ResizeIndicator (run.rs): the Apple state machine in Rust — `steering` (a switch was
requested) shows it, `decoded` (a frame reached the target size) clears it, `tick`
times it out after 2.5 s for a switch the host rejected/capped. The live drag stays
sharp; only the host's 0.3-2 s virtual-display + encoder rebuild gap is covered. A
present-while-resizing path keeps the spinner animating through that frame-less gap.
- DecodedImage::dimensions() (pf-client-core): the END signal — a decoded frame at the
target size means the sharp new-mode picture is on glass (the accept ack alone lands
ahead of the host's rebuild). Mirrors is_keyframe()'s cfg arms.
- FrameCtx.resizing (pf-presenter/overlay.rs) + Skia draw (pf-console-ui): a full-screen
55% scrim + the shared rotating theme::spinner + "Resizing…" label. The overlay
composites its own RGBA quad and can't sample the video to blur it as SwiftUI does, so
a scrim stands in for the blur — same intent, one draw. resizing_since clocks the
spinner; Drawn.resize_step defeats the damage gate so it redraws each frame.
Verified on Linux: pf-presenter/pf-console-ui/session/linux-client build + clippy
-D warnings clean; 8 pf-presenter tests green incl. 2 new ResizeIndicator tests.
Windows session-binary compile (cfg-symmetric) + live on-glass both pending.
Co-Authored-By: Claude Opus 4.8 <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>
Lands the mid-stream resolution resize feature (client-driven Reconfigure so
the host's virtual display + encoder follow a resized client window without a
reconnect), all paths default OFF:
- host hardening H1-H5 + session-binary Match window (C1)
- Apple macOS/iPadOS Match-window trigger + settings (C3) and the resize
overlay (blur + spinner) client UX
- Windows on-glass fixes: corrective-ack actual resolution + pf-vdisplay
monitor re-arrival for out-of-list mid-stream modes
- Linux backend matrix + the live-reconfigure gate unit tests
Validated on-glass: Windows IDD-push (.173), Linux Mutter + KWin. Android
(C4) deferred; Apple full build pending on a Mac.
A component() page re-renders reliably only when its props change: root wraps
every screen in a stable animated border, so once the entrance tween settles the
reconciler skips that unchanged-props subtree and a page's own use_state writes
never force a re-render. Three text fields read their value at click time from
that stranded local state:
- PIN pairing sent an empty PIN, so pairing always failed with "wrong PIN, or not
armed?" — the reported bug. The CLI --pair path bypasses the reactor and worked.
- "Add host" Connect captured the empty mount-time address and silently did
nothing (you open the modal precisely when the host isn't being discovered, so
no discovery tick re-renders the page while you type).
- Rename round-tripped the draft through an always-deferred AsyncSetState into a
controlled text box, fighting the caret on fast typing and dropping the last
character when Save was clicked before the write landed.
Fix: hold each field's live value in a use_ref cell written on every keystroke
and read at commit time (uncontrolled input), instead of a render-time snapshot.
Rename is seeded when its target changes and no longer re-renders the whole page
per keystroke. Reviewed the rest of the app (settings, speed test, library,
stream, connect/request-access/waking, forget) — all driven by root-state props
and wired correctly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A full Windows CI pass writes ~50 GB of cargo target output into the shared
C:\t (x64) / C:\t-a64 (arm64) scratch dirs on the intentionally-small (100 GB)
windows-amd64 runner. Left to accumulate across runs, that overflowed the disk
and every build died with "no space on device" (os error 112) — bytemuck_derive,
cc, bindgen, windows, tracing-subscriber, fs4 all failing mid-compile, taking
down pf-vdisplay/host builds.
ensure-windows-toolchain.ps1 already runs first in every Windows job, so reclaim
disk there before provisioning/building: call the runner-baked reclaimer
(unom/infra installs C:\Users\Public\act-runner\clean-runner-disk.ps1 + a
scheduled task) in threshold mode so THIS job starts with headroom regardless of
when that task last ran, and keep incremental caches warm when there's room. A
small inline fallback covers a runner not yet re-baked with the reclaimer. The
whole step is best-effort — a cleanup hiccup never fails the build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:35:08 +02:00
816 changed files with 194623 additions and 19338 deletions
"description":"Video codec identifier. The wire token matches the codec's canonical name used across the\nstack (SDP/GameStream advertisement, the stats-capture `CaptureMeta.codec`, and the encoder's\n[`Codec::label`]) — notably `H.265` serializes as `\"hevc\"`, not `\"h265\"`, so the same codec\nreads identically on every console page.",
"enum":[
"h264",
"h265",
"av1"
"hevc",
"av1",
"pyrowave"
]
},
"ApiDisplayInfo":{
@@ -2811,6 +2812,7 @@
"app_version",
"gfe_version",
"codecs",
"gamestream",
"ports"
],
"properties":{
@@ -2831,6 +2833,10 @@
},
"description":"Codecs the host can encode (NVENC)."
},
"gamestream":{
"type":"boolean",
"description":"Whether the GameStream/Moonlight-compat planes are running (`--gamestream`). `false` on the\nsecure default (native punktfunk/1 only) — a console can hide Moonlight-only UI (e.g. the\nMoonlight PIN pairing card, which could never receive a PIN when this is `false`)."
},
"gfe_version":{
"type":"string",
"description":"GFE version advertised to Moonlight clients."
@@ -3393,9 +3399,16 @@
"video_streaming",
"audio_streaming",
"pin_pending",
"paired_clients"
"paired_clients",
"active_sessions"
],
"properties":{
"active_sessions":{
"type":"integer",
"format":"int32",
"description":"Number of live streaming sessions across BOTH planes (GameStream + native punktfunk/1). The\nnative server admits concurrent sessions, so this can exceed 1; `session`/`stream` below\ndescribe a single representative session for the detail card.",
"minimum":0
},
"audio_streaming":{
"type":"boolean",
"description":"True while the audio stream thread is running."
@@ -3417,7 +3430,7 @@
},
{
"$ref":"#/components/schemas/SessionInfo",
"description":"The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)."
"description":"A representative active session. GameStream's launch (Moonlight `/launch`) when present, else\nthe first live native session. `null` when nothing is streaming."
}
]
},
@@ -3428,7 +3441,7 @@
},
{
"$ref":"#/components/schemas/StreamInfo",
"description":"The RTSP-negotiated stream parameters (present once a client has completed ANNOUNCE)."
"description":"The active stream's parameters — RTSP-negotiated for GameStream, or the live native session's\nmode/codec/bitrate. `null` when nothing is streaming."
}
]
},
@@ -3599,6 +3612,7 @@
"armed",
"sample_count",
"started_unix_ms",
"elapsed_ms",
"kind"
],
"properties":{
@@ -3606,6 +3620,12 @@
"type":"boolean",
"description":"Capture currently running."
},
"elapsed_ms":{
"type":"integer",
"format":"int64",
"description":"Host-measured elapsed time of the in-progress capture, in ms (`0` if idle). Computed from the\nhost's MONOTONIC clock, so a console can show elapsed time without subtracting `started_unix_ms`\nfrom its own (possibly skewed) wall clock.",
"minimum":0
},
"kind":{
"type":"string",
"description":"Path of the in-progress capture (`\"\"` if idle)."
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.