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>