Commit Graph
100 Commits
Author SHA1 Message Date
enricobuehler 0a72959ef7 Merge main into feat/android-pad-audio
86 commits of main, including the whole M1-M12 haptics sweep. Twelve conflicting files;
three of them were more than textual.

**The capability bits collided.** Both branches allocated the SAME wire bits for DIFFERENT
features: `client_caps 0x04` and `host_caps 0x20` are redundant desktop audio on main and
pad audio here. Merged naively, a peer would negotiate one and get the other. Pad audio
moves to the next free bits — `CLIENT_CAP_PAD_AUDIO = 0x08`, `HOST_CAP_PAD_AUDIO = 0x40` —
and the `abi.rs` mirrors move with them (their compile-time equality assertions caught the
mismatch, which is exactly what they are for).

**Both branches also claimed ABI v15.** Main's shipped (the rumble-policy floor), so the
pad-audio surface becomes **v16**.

**`native/input.rs` would have reintroduced a fixed bug.** This branch resets
`rumble_seq[idx]` on pad removal; M1 established that the client's reorder gate is
per-connection with no reset path, so restarting the host counter strands every later
envelope until it climbs back. Took main's seq-preserving `clear_pad_feedback` and kept only
the branch's `pad_streams.stop(idx)`.

The rest: `wiring_plan::plan` now delegates to main's `plan_with_formats`, so the pad-endpoint
filter moved into that body and the predicate behind it is factored out as `is_pad_render`
(also what B10 needs); `Ds5Feedback::AUDIO` derives from main's `REPORT_ID_LEN` like its
siblings; `AudioCtl` joins the explicitly-listed unhandled variants so the guard-false case is
covered rather than swept up by a `_`; `include/punktfunk_core.h` regenerated rather than
hand-merged.
2026-08-04 23:27:06 +02:00
enricobuehler 92f617a989 Merge remote-tracking branch 'origin/main' into worktree-haptics-m12-dry
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m28s
ci / docs-site (pull_request) Successful in 2m30s
android / android (pull_request) Successful in 3m58s
ci / rust-arm64 (pull_request) Successful in 5m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m6s
ci / rust (pull_request) Successful in 8m47s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m10s
# Conflicts:
#	clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt
#	crates/pf-client-core/src/gamepad.rs
2026-08-04 23:11:22 +02:00
enricobuehler 42a0dd52be refactor(haptics): one copy of each thing every rumble path was transcribing
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / rust (pull_request) Successful in 9m44s
ci / rust-arm64 (pull_request) Successful in 2m0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m2s
android / android (pull_request) Successful in 5m3s
Twelve findings from the sweep's DRY/docs/dead-code tail. Most are small; three found
real defects hiding behind the duplication.

**The UHID event ABI existed five times.** Every UHID gamepad backend — DualSense,
DualShock 4, Switch Pro, Steam Controller, Steam Controller 2 — carried its own verbatim
copy of the kernel's constants plus its own `put_cstr`, and they had already drifted:
`switch_pro` was missing the SET_REPORT pair entirely, and `steam_controller` read a
FIXED 16-byte SET_REPORT window instead of the event's own `size`. That last one is a
bug in both directions — a longer report was truncated, and a shorter one had the parser
reading whatever the reused event buffer still held past the payload, i.e. acting on
rumble values the game never wrote. Now one `uhid_abi` module owns the numbers plus the
two accessors that are easy to get subtly wrong, with tests on exactly that.

**A dead force-feedback id fallback.** ff-core's `input_ff_upload` picks a free effect
slot and writes it into the effect BEFORE uinput forwards the request, so the `id == -1`
branch could never run — and allocating from a local counter would have been the wrong
answer anyway, since the kernel owns that id space. Removed, with a `debug_assert` where
it stood.

**Apple's HID path silently dropped weak rumble.** `hidByte` took the top byte with no
non-zero floor, so every amplitude below 0x0100 rendered as exactly nothing. Android has
always floored it at 1; this was the odd one out. That converter also existed twice
byte-identically inside one Gradle module — now one `wireAmplitudeToByte`.

Also: the DS5 output-report layout gets named offsets (`dualsense_proto::out_report`)
documenting all three transport bases — USB 0, SDL payload −1, Bluetooth +2 — since the
differing bases are transport-forced, not drift. `pf-client-core` cannot import them (it
and `pf-inject` do not depend on each other, and a DualSense layout has no business in
`punktfunk-core`, their only shared crate), so its copy now DERIVES its offsets by
explicit subtraction and a test pins the relationship. `PUNKTFUNK_HID_EFFECT_MAX` sizes
the struct it describes instead of a second literal 11 — the header now emits
`uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX]`. The rumble policy engine's `min_pulse_ms`
and `keepalive_ms` docs stop naming cases nothing implements: no in-tree caller sets
`min_pulse_ms`, and the macOS DualSense-over-BT keepalive the doc cited CANNOT be served
by the quirk, because that renderer skips writes whose levels are unchanged and would
swallow the engine's re-emit — it keeps its own keepalive instead. `TrackpadHaptic` is
marked as staged scaffolding (the tag is on a shipped wire; removing the variant would
not reclaim it). Three ×257-vs-`<<8` doc comments corrected — the scaling itself is fine,
both round-trip to 255. `backstop_ms.max(160)` deleted as unreachable (the engine floors
at 500). New tests for `Ds5Feedback` and for the Android rumble JNI packing on BOTH sides,
with `MAX_PADS <= 16` now a compile-time assertion rather than a comment.

Closes S1-S9, S11, T2, T3 (design/haptics-sweep-2026-08-03.md M12).

S11's second half is NOT a defect and was left alone: `clients/session/src/main.rs`
calls `set_forwarding` unconditionally on every params-build (its own comment explains
why — browse mode reuses one service across launches), so `Ctl::Forwarding` routinely
arrives unchanged and that early-out is what stops a redundant `sync_open` + Valve-HIDAPI
cycle each launch.

Verified: pf-inject clippy -D warnings 0 / 91 tests; pf-client-core + punktfunk-core
clippy 0 / 437 tests (amd64 container); punktfunk-client-android 7 tests; Android :kit:
6 tests; Apple swift build + 189 tests / 0 failures; cargo fmt --all --check clean. Each
new test probed by reverting its fix — the fixed SET_REPORT window fails 3, a broken pack
shift fails 3, dropping the amplitude floor fails 1, and a wrong DS5 offset either fails
the pin or refuses to compile.
2026-08-04 22:52:38 +02:00
enricobuehler 2d43275fcb fix(core/abi)!: stop exporting 149 unprefixed macros into every embedder's namespace
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 1m15s
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m10s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m57s
ci / rust (pull_request) Successful in 6m25s
ci / rust-arm64 (pull_request) Failing after 15s
ci / web (pull_request) Successful in 1m6s
BREAKING (C header only): constants such as MAX_PADS, TAG_LEN, ABI_VERSION,
INPUT_MAGIC and the whole BTN_/AXIS_ family are now PUNKTFUNK_-prefixed.

cbindgen emits a bare #define per `pub const`, so those names landed in the
namespace of every C program that includes the header. The rename table already
said this was the rule and already carried the handful someone had noticed —
and its own comment spells out why it matters: a clashing #define silently
takes the last definition rather than failing to compile, so the failure mode
is a wrong value, not a build error. This is the remaining 149.

Associated constants are deliberately left alone. cbindgen already qualifies
those with their type name, which is the very property whose absence makes a
bare MAX_PADS dangerous — they are namespaced, just not by us.

Nothing in this repository consumed the unprefixed spellings except one Swift
test, which sat next to lines already using the prefixed form because its
constant happened never to have been added to the table; it is updated here.
The C harness links and runs against the regenerated header.

Scheduled deliberately: the sweep flagged this for a release boundary, and
0.24.0 has shipped. External C embedders using the old spellings must add the
prefix; there is no silent breakage, since the old names simply stop existing.
2026-08-04 20:52:58 +02:00
enricobuehler 9f1f23eb40 Merge pull request 'feat(wire): mid-session shard-payload renegotiation — the black screen heals in seconds, jumbo behind an opt-in' (#41) from worktree-shard-payload-reneg into main
release / apple (push) Canceled after 2m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-host / canary-manifest (push) Successful in 16s
windows-host / package (push) Successful in 11m13s
windows-host / winget-source (push) Skipped
apple / swift (push) Successful in 1m26s
android / android (push) Canceled after 3m42s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 3m45s
ci / rust (push) Canceled after 3m28s
ci / rust-arm64 (push) Canceled after 1m59s
ci / web (push) Canceled after 1m54s
ci / docs-site (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
Reviewed-on: #41
2026-08-04 18:06:41 +00:00
enricobuehler 34ad3cc611 feat(host/wire): mid-session shard-payload renegotiation, driven by the MTU verdict
Phases 1-2 of design/shard-payload-reneg.md, on top of the Phase 0
per-frame geometry. The leg-1 watcher stops merely diagnosing the
constrained path and heals the CURRENT session; the same machinery,
inverted, takes a proven jumbo LAN up to ~8.9 KB shards.

- Messages: MSG_SHARD_PAYLOAD_CHANGED (0x08, host→client, {shard_payload
  u16}) and MSG_SHARD_PAYLOAD_ACK (0x09, the echo). Asymmetric by
  design: a shrink re-keys the packetizer at the next AU immediately
  after sending (per-frame pinning makes ordering irrelevant; the ack is
  telemetry), a grow emits nothing above the old size until the ack —
  the ack is the gate even though client buffers are statically sized.
- Client: one dispatch arm in the shared pump control task (all client
  families) — validate against the advertised receive bounds, ack;
  out-of-bounds requests get SILENCE, not an ack, so a buggy host can
  never read a granted grow out of garbage.
- Host driver: the wire_mtu watcher grows a ShardReneg arm — on a
  below-ceiling verdict it still records the learned budget (session 2
  starts right) and now also shrinks session 1 at the ~3-10 s verdict
  mark; with the jumbo opt-in (PUNKTFUNK_JUMBO=1, or PUNKTFUNK_WIRE_MTU
  > 1500 — one knob, derived) it sends the ack-gated grow after a
  settled-at-sealed-jumbo proof and then stays alive as the revert
  guard: quinn's blackhole detection lowering current_mtu shrinks the
  wire back through the same path. The QUIC MTUD probe ceiling rises
  from 1472 to the sealed jumbo size with the opt-in (per-ENDPOINT: a
  few extra failed probes toward non-jumbo peers, zero cost otherwise).
- Apply point: Session::set_shard_payload drained in the send loop next
  to the adaptive-FEC target, gated on no open streamed AU (a streamed
  frame's shard-aligned tiling derives from the size it began with).
- Renegotiation is gated OFF for PyroWave sessions: their clients parse
  chunk-aligned AUs in windows of the Welcome value pinned at session
  start (read once over the C ABI), so a mid-stream re-key would corrupt
  the parse — those sessions keep the leg-1 next-session clamp. This
  also settles the plan's open question on the two wire_chunk consumers:
  both are PyroWave-only, so the gate covers them entirely.
- Legacy peers are inert both ways: no Hello advertisement → the host
  never constructs the driver; an old host never sends the message.

core: 296/296 --features quic + clippy -D warnings (macOS), fmt; the
regenerated header carries the new message ids (drift gate).
2026-08-04 19:42:36 +02:00
enricobuehler 290d760ea4 feat(core/wire): per-frame shard geometry, jumbo ceiling, Hello advertisement
Phase 0 of mid-session shard-payload renegotiation (planning
design/shard-payload-reneg.md), stacked on the leg-1 MTU resilience. All
three legs are client-side and forward-compatible: deployed clients that
carry them accept a mid-session shard change the moment a future host
sends one, and nothing changes on the wire until then.

- W0.1 — the reassembler's strict shard_bytes firewall becomes per-frame
  pinning: a frame's first-arriving packet pins that frame's shard size
  (bounds-checked to [min_shard_bytes, max_shard_bytes], even), later
  packets must match the pin, and the per-frame block ceiling derives
  from the pinned size (a session-level cap would reject legitimate
  post-shrink frames). The reorder race between an ordered control
  message and unordered video dies structurally: old-geometry frames in
  flight complete under their own pin while new frames arrive under the
  new one, and no cross-geometry splice can land in one buffer. The
  in-flight budget stays byte-based and exact.
- W0.2 — MAX_DATAGRAM_BYTES 2048 → 9216: every receive path (transport
  RECV_BUF, the recvmmsg ring) now accepts sealed jumbo datagrams
  (9000-MTU LAN ≈ 8908-byte shards). Static buffers over resize-on-ack:
  the ring delta is 128 × ~7 KiB ≈ 896 KiB per client session, lazily
  allocated, hosts unaffected. Grep verdict: no embedder uses the
  constant directly, so no C ABI bump — the regenerated header rides
  along (drift gate).
- W0.3 — trailing Hello field max_shard_payload: u16 (0/absent =
  legacy), the append-with-placeholder discipline of video_caps/
  client_caps. One field is both the renegotiation capability flag and
  the jumbo ceiling; core's pump advertises it for all client families,
  the probe too.
- Host seam for Phase 1, dead until wired: Packetizer::set_shard_payload
  (re-derives the block ceilings; construction delegates to it) +
  Session::set_shard_payload (host-only, Config::validate parity).

Verification (the 0.23.0 lesson — geometry changes breed sizing bugs):
the slice-wire suite re-runs at shard 512/1216/1408/8908 (exact-multiple
sweep, lossy + reversed roundtrips, sentinel path, in-flight budget);
mid-stream shrink→grow→revert delivery; the old-geometry reorder race;
cross-geometry splice rejection; firewall bounds non-vacuous both ways;
a 48-case mixed-geometry reorder-torture proptest asserting per-frame
byte-identical DELIVERY and an exactly-zero final budget; and a sealed
loopback session test (continuous crypto/replay) delivering frames
across live re-keys — every test asserts delivered frames, never the
absence of errors.

core: 294/294 --features quic + clippy -D warnings (macOS), fmt.
2026-08-04 19:27:09 +02:00
enricobuehler 69f1db5ea9 Merge pull request 'feat(host/wire): MTU resilience for the video data plane' (#37) from worktree-wire-mtu-resilience into main
apple / swift (push) Successful in 1m30s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m21s
docker / builders-arm64cross (push) Successful in 11s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m37s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m50s
windows-host / package (push) Successful in 16m8s
android / android (push) Successful in 5m39s
windows-host / winget-source (push) Skipped
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m36s
windows-host / canary-manifest (push) Successful in 30s
deb / build-publish (push) Successful in 21m51s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m31s
deb / build-publish-client-arm64 (push) Successful in 9m9s
ci / rust (push) Canceled after 27m31s
flatpak / build-publish (push) Successful in 8m11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
release / apple (push) Successful in 9m14s
arch / build-publish (push) Successful in 9m41s
docker / deploy-docs (push) Successful in 6m19s
apple / screenshots (push) Successful in 6m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m27s
ci / web (push) Successful in 1m3s
deb / build-publish-host (push) Failing after 30s
ci / docs-site (push) Successful in 1m15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 16s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 20s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 21s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
ci / rust-arm64 (push) Successful in 2m41s
Reviewed-on: #37
2026-08-04 17:02:01 +00:00
enricobuehler 4bc7eecf05 feat(host/wire): MTU resilience for the video data plane
android / android (pull_request) Successful in 3m25s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m13s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m16s
ci / rust (pull_request) Successful in 22m23s
ci / docs-site (pull_request) Successful in 1m5s
ci / web (pull_request) Successful in 1m47s
apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m46s
Video datagrams are sealed at a shard payload sized for a clean 1500-byte
MTU (1472-byte UDP payloads). A host whose route to the client crosses a
smaller-MTU hop (a VPN/overlay adapter claiming the LAN route, a lowered
NIC MTU) delivers every small flow — QUIC control, hole punch, input,
audio — while 100% of video datagrams die: the client sits on a black
screen reporting zero loss and the host streams into the void with every
gauge green. Field-reported as 'connects fine, black screen forever'.

Three legs, none of which changes a session on a healthy path:

- PUNKTFUNK_WIRE_MTU operator override: shard payload derived from a
  given on-wire IP MTU. Wire-compatible — Welcome::shard_payload is
  already negotiated per session (the v4/v6 split ships two values
  today) and every client follows the negotiated value.
- Detection: the QUIC MTU-discovery probe ceiling moves from quinn's
  stock 1452 to exactly the sealed video-datagram size (1472), so a
  control connection's settled MTU becomes a verdict on the path:
  settled at the ceiling proves it carries video, settled below proves
  it cannot. A per-session watcher samples after the search has settled
  (live-connection guard against mid-search false learns) and logs an
  actionable WARN naming the failure shape and the diagnosis commands.
- Healing: the measured budget is recorded per peer IP; the next
  handshake clamps shard_payload to fit, so a reconnect self-heals. A
  later session that reaches the ceiling erases the record.

Verified: core 286/286 --features quic + clippy -D warnings (macOS);
host clippy -D warnings + native:: tests 44/44 (pf-lxcheck container).
The regenerated C header picks up the new MIN_SHARD_PAYLOAD constant.
2026-08-04 18:30:33 +02:00
enricobuehlerandClaude Opus 5 7077b0a0df feat(core/audio): bitrate tiers, a shared de-jitter policy, and a redundant audio plane
Foundation for the audio quality + latency plan (design/audio-quality-and-latency.md).
All three pieces are pure and unit-tested here so the four client rings and the Windows
host glue that follow stay thin.

**Bitrate tiers** (`AudioTier`). The layout table's `bitrate` becomes the `Standard`
value, so that tier reproduces the pre-tier wire byte-for-byte — the tier machinery is
provably non-regressive. `High` (stereo 256 kbps) is the default: 5 ms Opus frames are
much less efficient than 20 ms ones, so the historical 128 kbps buys roughly what
~100 kbps buys at 20 ms, while the same session carries tens of Mbps of video. Purely a
host-side encoder knob — libopus reads the bitrate out of the packet, so no client
change and no negotiation.

**`JitterPolicy`** — the ms-denominated de-jitter state machine every client will share.
Two defects it exists to fix: (1) each ring computed its target as `3 x quantum`, a sane
15 ms at a 5 ms quantum and a silent 64 ms at a 20 ms one; (2) every ring primed *up* and
clamped at a ceiling, and none walked the depth back *down*, so drift/bursts added latency
permanently — Android, with no shed at all, converged on its 120 ms cap. Here a depth EWMA
that sits above target for 2 s of consumed audio sheds ONE 5 ms frame with a crossfade.
Driven by samples consumed rather than the wall clock: allocation- and syscall-free (safe
in a realtime callback) and deterministic under test.

`every_preset_sheds_before_it_trims` pins the invariant that makes this real rather than
decorative. The first draft had `headroom_ms` <= the shed threshold on all four presets,
so the ring was trimmed back before the average could ever reach the shed point: drift
correction was dead code and the ratchet test passed for the wrong reason (the hard cap
did the work). `a_transient_burst_does_not_shed` caught it. The shed point is now derived
from `headroom_ms` so it cannot invert again.

**`0xD2` redundant audio** — each datagram carries its frame plus a copy of the previous
one, so a single lost packet is reconstructed instead of concealed. Opus in-band FEC
cannot do this job: LBRR is a SILK feature and the desktop encoder is CELT-only
(RESTRICTED_LOWDELAY, 5 ms), so `set_inband_fec` there is a no-op. Costs no latency —
the copy rides the successor, which arrives inside de-jitter slack that already exists.
Gated capable-and-agreed via CLIENT_CAP_AUDIO_RED/HOST_CAP_AUDIO_RED; every other session
keeps the `0xC9` wire unchanged. 0xD1 is left free for the pad-audio program.

cbindgen: prefix the four new exported constants. `FRAME_MS`/`SAMPLE_RATE_HZ` as bare C
macros are the same hazard the BTN_* renames already document — a clashing #define takes
the last definition silently rather than failing to compile.

Verified: 300 core tests, clippy -D warnings, fmt. (`c_abi` fails identically on a
pristine tree — this Mac has no system libopus for the C harness link.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 08:11:35 +02:00
enricobuehler 5be494f490 merge: bring main into the pad-audio branch
ci / web (pull_request) Successful in 1m0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 1m2s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m51s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 34s
ci / rust-arm64 (pull_request) Successful in 2m48s
android / android (pull_request) Successful in 3m49s
ci / rust (pull_request) Successful in 5m40s
Main had moved 34 commits past the merge-base and 13 files had diverged.
Resolving now rather than later, since the force-feedback sweep work is landing
in the same files.

Five conflicts needed hand resolution. Four were "each side added something
different" and keep both: the Forwarding and PadAudioPrefs control variants with
their handlers and setters (pf-client-core/gamepad.rs), both of the session's
pre-attach declarations (forwarding first, so slots still declare their
pad-audio caps at open time), main's WiredPlan/fingerprint alongside the
branch's pad_render_ids (audio_control.rs), and main's judge_default signature
(wasapi_cap.rs).

wiring_plan.rs was not mechanical. Main's 652abeb3 added a flagged last-resort
loopback tier; the branch had added a fifth `plan` parameter excluding pad
endpoints from every role. Taking either side alone loses the other, and
combining them carelessly is worse than both: the new last-resort tier would
happily select the pad's own speaker endpoint, which is stamped "DualSense
Wireless Controller" with no virtual marker precisely so games read it as the
pad's speaker — routing the entire desktop mix into the controller's voice
coils. The branch's exclusion shadows `renders` before any tier runs, so the
last resort inherits it; `a_pad_is_never_the_last_resort` pins that, including
that a pad-only candidate set stays honestly unsatisfiable rather than falling
back onto the coils.

Verified: clippy -p punktfunk-host -p pf-client-core --all-targets --locked
-D warnings = 0; pf-client-core 93/93; punktfunk-host 387 passed with only the
known-environmental gamestream sender_delivers_batches UDP-loopback flake;
wiring_plan 21/21; fmt clean.

NOT verified: audio_control.rs and wasapi_cap.rs are cfg(windows), so neither
the Linux container nor xcheck.sh compiles them. Those two resolutions have had
review only and need the Windows runner before this merges.
2026-08-03 19:11:26 +02:00
enricobuehlerandClaude Fable 5 48511d1267 fix(abr): probe throughput is measured over the client's receive interval
The capacity probe divided client-side bytes by the HOST's burst
duration — a window wrong on both edges (base snapshotted before the
burst reached the host, frozen only when the ProbeResult landed, while
the host's clock stops the moment ITS send window closes, before the
switch/kernel queue finishes draining toward the client). On a 1 GbE
link a 2 Gbps burst target "measured" 1266 Mbps and set an 886 Mbps
climb ceiling the link could never carry — permanent for the session,
because set_ceiling never lowers.

The reassembler now stamps probe-scoped counters (bytes, packets,
first/last arrival, monotonic ns) at its FLAG_PROBE routing, so video
around the burst contaminates neither numerator nor denominator; the
throughput divisor is the client's first→last arrival interval, with
the host duration kept as the fallback when fewer than two probe
packets arrived. The user-facing speed test shares the corrected
computation (ProbeOutcome/PunktfunkProbeResult layouts unchanged;
elapsed_ms docs updated to the new semantics).

Two guards ride along:
- PUNKTFUNK_ABR_MAX_MBPS clamps inside set_ceiling — the one funnel
  every learned ceiling passes through — so a user cap binds no matter
  what any probe concludes.
- The controller latches decode_cap_kbps when two CONSECUTIVE backoffs
  carry decode-severe evidence (deep decode excursion or jump-to-live
  flush) at a similar pre-backoff rate, mirroring host_cap_kbps for the
  client decoder: a knee below the link ceiling was a permanent 30-60 s
  sawtooth costing a flush + dropped-frame burst per cycle (1440p120
  HEVC field case, knee ~490 Mbps). One spurious flush never latches;
  the cap re-probes on the CAP_REPROBE_WINDOWS clock, so it lifts when
  the decoder recovers.

Also rights the three stale "3 Gbps" probe-clamp comments (the host
constant has been 10 Gbps since MAX_PROBE_KBPS moved).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 09:53:36 +02:00
enricobuehlerandClaude Fable 5 ed3d236ab8 feat(pad-audio): DualSense audio haptics + speaker, host->client end to end
The 0xD1 pad-audio plane streams a DualSense's voice-coil haptics (back
channel pair, 5 ms Opus frames) and speaker (front pair, 10 ms) per pad from
a Windows host to the SDL clients, which render them into a USB DualSense's
own 4-channel audio device.

Wire (punktfunk-core, ABI v15): PAD_AUDIO_MAGIC 0xD1 [pad][kind][seq][pts]
[opus]; CLIENT_CAP_PAD_AUDIO 0x04 / HOST_CAP_PAD_AUDIO 0x20; per-pad render
capability rides GamepadArrival flags bits 8/9, sent only toward a host that
advertised its cap so old hosts see byte-identical arrivals; silence is a
frozen seq (mic-mute discipline), loss is a seq gap concealed via
AudioGapTracker. HidOutput::AudioCtl (0xCD kind 0x06) forwards the 0x02
report's audio-control bytes 5..=10 change-only, value-deduped, with a
once-per-pad "title asserted haptics-select" diagnosis log.

Windows host endpoint provider (audio/windows/pad_endpoint.rs): per-pad
render endpoints are additional devnode instances of Valve's Steam Streaming
Speakers driver (SetupDiRegisterDeviceInfo, NOT the class installer - it
needs an interactive window station), stamped with DualSense identity: desc
"Wireless Controller", device name "DualSense Wireless Controller",
ContainerId = the virtual pad's PFDS GUID, 4ch/48k format triplet.
IPropertyStore route first, ACL-repaired registry fallback (the MMDevices
keys deny writes even to SYSTEM; the owner's implicit WRITE_DAC + an ACE for
S-1-5-18 resolved by SID is the way in). Provisioned at host startup
(PUNKTFUNK_PAD_AUDIO, PUNKTFUNK_PAD_AUDIO_SLOTS, default 1), idempotent via
a persisted PunktfunkPadIndex marker; pad endpoints are structurally
ineligible for the mic/loopback wiring plan and guarded against default-
device theft; capture is WASAPI loopback on the stamped endpoint. Devtest:
punktfunk-host pad-endpoint ensure|remove|status.

Host service (native/pad_audio.rs): per-(session,pad) thread, loopback 4ch
-> pair splitter -> per-kind stereo Opus (48k LowDelay CBR 64k) -> per-kind
silence gate (opens at peak>=1e-3, 250 ms hangover, gated = no send + frozen
seq) -> datagrams. Spawned from the native input pump when a DualSense/Edge
arrival carries audio bits and both caps negotiated; idempotent re-arrivals;
reaped on remove and teardown.

Client tier A (pf-client-core/pad_audio.rs): settings pad_haptics (default
on) and pad_speaker (default "pad"); tier A = wired USB DS5/Edge via SDL
connection state with an audio-sibling fallback; correlation maps the SDL
HID path to the pad's own render endpoint (Windows: ContainerId match +
4ch gate via registry; Linux: Sony sink signature); renderer decodes both
kinds into a quad interleave and plays it on the pad's endpoint (WASAPI
autoconvert / PipeWire target.object, 240-2400 frame ring floor,
dont-reconnect so an unplug never re-routes haptics to the desktop
speakers). SDL's DualSense driver sets "disable audio haptics" whenever it
drives rumble emulation, so tier-A pads suppress wire rumble and send one
cleared-enable-bits effects packet to keep the actuators live; AudioCtl
bytes fold back into the effects packet at report-minus-one offsets.

Verification: punktfunk-core 265 tests (macOS) + clippy -D warnings (mac +
Linux docker); pf-inject 85 tests (Linux docker); punktfunk-host cargo
check + clippy + 19 pad tests + 46 audio-module tests (Windows box);
pf-client-core 30 tests + clippy (Linux docker CI image) + cargo check
(Windows box); punktfunk-client-session clippy (Linux) + check (Windows);
cargo fmt --all --check clean on the final tree. NOT yet verified: any
on-glass run (host deploy + real title + physical pad), the stamp-route
split at runtime, exclusive-mode Initialize isolation, Linux-host emission
(the per-pad PipeWire sink is not in this change - Windows hosts only).
Scope excluded deliberately: tier B (Apple CoreHaptics) and tier C
(haptics->rumble derivation), pad_speaker="mix", Android leg, settings UI
surfaces (keys are serde-defaulted), GameStream-plane arrivals (audio_caps
always 0 there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:07:06 +02:00
enricobuehler 3c509d48c9 feat(core/abi): report_phase earns its version — C ABI 13 -> 14
`punktfunk_connection_report_phase` (fa822744, coherence tail 1d31e4c5) and the
`PUNKTFUNK_CLIENT_CAP_PHASE_LOCK` mirror const (7cf71dd2) grew the embeddable C
surface without moving ABI_VERSION. Every prior additive entry in that doc list
bumped it — v3's wake_on_lan, v5's next_rumble2, v8's clipboard block, v13's
send_pen — precisely so an embedder can ask punktfunk_abi_version() whether the
function it wants to link is there. Left at 13, the one number that answers that
question said "no report_phase" about a core that has one.

The header was already regenerated with both symbols, so it carried the new
surface under the old number; the regen here changes exactly the #define and its
doc block and nothing else, which also confirms the committed header was
otherwise current.

Additive and capability-gated: the host arms on report receipt, the wire grows
only PhaseReport (0x32) — a control message an old host never reads — and a
strict-prefix append on the 0xCF host-timing tail, so WIRE_VERSION stays 2. No
in-tree caller compares ABI_VERSION against a literal; mgmt/tests.rs asserts
against the symbol.

Verified: cargo build -p punktfunk-core regenerates include/punktfunk_core.h to
exactly this diff; punktfunk-core lib suite 134/134; rustfmt clean. The C ABI
harness cannot run on this Mac (`ld: library 'opus' not found`, the documented
pre-existing local linker gap) — CI's Linux leg is the gate for it.
2026-08-01 00:58:11 +02:00
enricobuehlerandClaude Fable 5 7cf71dd218 feat(clients): the Apple deadline presenter reports its latch phase
Phase-locked capture had no client half on Apple — the host's v3 controller
(grid-locked submits, coherence-gated engage) shipped with only the Android
reporter feeding it. Now the stage-4 link thread flushes the same v2
circular arrival-phase statistic at ~1 Hz:

- PhaseReporter (Stage2Pipeline): the decode callback deposits per-AU
  reassembly-completion stamps, the CAMetalDisplayLink update deposits the
  latch grid (period = window-min of update spacing), and the flush ports
  punktfunk_core::phase::circular_latch verbatim — a period-smeared Wi-Fi
  link reads coherence ≈ 0 and the host correctly never engages; a wired
  link opens the gate. Binds/unbinds per session like DecodeReport.
- PunktfunkConnection.reportPhase wraps the existing ABI entry point.
- Hello honesty: iOS/tvOS advertise CLIENT_CAP_PHASE_LOCK (macOS stays
  without — the stage-2 arrival presenter has no latch grid), Android now
  sets the bit its reporter already earned, and the ABI grows the
  PUNKTFUNK_CLIENT_CAP_PHASE_LOCK mirror const (header regenerated).
  Advisory in v1: the host arms on report receipt.
- Stage-3 doc comment no longer calls itself the tvOS default (stage-4 took
  that over in the 2026-07 rebuild).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:41:46 +02:00
enricobuehlerandClaude Fable 5 440cb8d1ec feat(core/wire): streamed AUs cut blocks at slice boundaries, not at 5.5 MB
The streamed-AU path only ever flushed a block when a full FEC block's
worth of bytes accumulated - at real bitrates a frame finished encoding
before that, so every AU degenerated to one tail-heavy send and the
overlap the sentinel wire was built for never happened.

Now a slice boundary flushes every whole shard (floored at
MIN_STREAM_BLOCK_SHARDS so per-block parity stays economical), which
means blocks are variable-size and can no longer be addressed by the
uniform block_index * K_max formula. USER_FLAG_SLICE_STREAM marks such
frames on every packet: sentinel blocks reuse frame_bytes as their
shard-aligned base offset, the final block's base derives from the
totals, and the receiver pins/validates positionally (sentinels must sit
strictly below the final block's base; a mixed-flag or out-of-range
header is dropped before it can place a byte).

The AU's own flag bit gates the wire shape, and the host sets it only
toward clients advertising STREAMED_AU + MULTI_SLICE, so shipped
receivers keep seeing the byte-identical legacy sentinel
(PUNKTFUNK_SLICE_STREAM=0 pins legacy for A/B). Both sides bound the
per-frame block count by the same floored formula, and the flush loop
cuts as many blocks as a chunk completes - a slice bigger than one FEC
block can't leave an oversized final block behind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:14:46 +02:00
enricobuehlerandClaude Fable 5 1d31e4c565 feat(phase-lock): controller v2 — circular phase, coherence gate, decay-to-zero
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m34s
ci / rust-arm64 (push) Successful in 1m39s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
android / android (push) Failing after 2m21s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
ci / rust (push) Canceled after 2m52s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / swift (push) Successful in 4m47s
deb / build-publish-client-arm64 (push) Successful in 3m59s
deb / build-publish-host (push) Successful in 4m29s
deb / build-publish (push) Successful in 5m59s
flatpak / build-publish (push) Successful in 4m58s
arch / build-publish (push) Successful in 10m18s
windows-host / package (push) Successful in 11m6s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 33s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m8s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m41s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m46s
apple / screenshots (push) Canceled after 0s
release / apple (push) Canceled after 19m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 21m16s
Round-1 on-glass falsified two v1 assumptions in one morning (NP3 ↔ .173):

- A median lead is immovable under period-spanning jitter (uniform mod P),
  so v1 orbited the period at 2 ms/s forever — and the dead-signal freeze
  hotfix never armed, because ±1.5 ms of window sampling noise kept
  resetting its 'error responded' check. Thresholds can't referee noise.
- A HELD hold is not free: it delays sampling after the capture stamp,
  taxing e2e by up to a period (user-measured: ~+4 ms during the orbit).
  The correct failure response is DECAY TO ZERO, never freeze-in-place.

v2, all three layers:
- Wire: PhaseReport grows a length-discriminated coherence tail (27-byte
  form; the u16::MAX sentinel encodes as the byte-identical 25-byte v1 —
  strict-prefix discipline, both forms pinned by tests).
- Client: the reporter sends the CIRCULAR vector-mean latch phase mod the
  panel period + its coherence (‰); pf.present logs circ=/coh=.
- Host: steps only while coherent (floor 300‰; a v1 report bypasses the
  gate), along the signed SHORTEST way around the period, under a
  cumulative 1.25-period travel budget that is noise-immune by construction
  (it integrates applied steps, not reported errors); incoherence or budget
  exhaustion decays the hold to zero and re-arms once flat. Deadband
  convergence resets the budget — tight regimes lock exactly as designed.

report_phase + the C ABI mirror gain the coherence parameter (canary-only
ABI, hours old, sole caller in-tree). Gates: docker amd64 clippy
--all-targets -D warnings (host+core, nvenc) clean; core suite 244/244;
cargo ndk arm64 check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:55:24 +02:00
enricobuehlerandClaude Fable 5 ccf5c922ee fix(core/abi): report_phase joins its siblings behind the quic cfg — the C header parses again
ci / docs-site (push) Successful in 1m6s
deb / build-publish-host (push) Failing after 14s
ci / web (push) Successful in 2m28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 1m16s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
ci / rust-arm64 (push) Successful in 4m30s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m13s
apple / swift (push) Successful in 4m46s
android / android (push) Successful in 5m32s
docker / builders-arm64cross (push) Successful in 11s
deb / build-publish (push) Successful in 6m8s
docker / deploy-docs (push) Successful in 35s
ci / rust (push) Successful in 7m7s
arch / build-publish (push) Failing after 9m7s
flatpak / build-publish (push) Successful in 7m3s
windows-host / package (push) Successful in 18m52s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m25s
apple / screenshots (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 16m55s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m23s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 2m50s
release / apple (push) Successful in 25m47s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m44s
The new punktfunk_connection_report_phase lacked the #[cfg(feature = "quic")]
its connection-fn siblings carry, so cbindgen emitted it OUTSIDE the guarded
region that declares the opaque PunktfunkConnection — the generated header
stopped compiling as C (CI's harness caught it; my local harness failure was
a pre-existing macOS linker issue that masked this distinct cause — full-suite
comparison attributed the wrong root).

Verified: the harness compiles and passes in the amd64 CI-image container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 00:38:31 +02:00
enricobuehlerandClaude Fable 5 fa822744ff feat(core/host/android): phase-locked capture — frames arrive on the client's latch schedule
ci / web (push) Successful in 1m59s
ci / docs-site (push) Successful in 2m13s
ci / rust-arm64 (push) Successful in 5m17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 4s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 4s
android / android (push) Successful in 5m52s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
deb / build-publish (push) Successful in 5m38s
ci / rust (push) Failing after 5m52s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
arch / build-publish (push) Failing after 6m7s
deb / build-publish-client-arm64 (push) Successful in 3m55s
docker / builders-arm64cross (push) Successful in 19s
deb / build-publish-host (push) Failing after 4m24s
apple / swift (push) Successful in 4m35s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m13s
docker / deploy-docs (push) Successful in 31s
windows-host / package (push) Failing after 6m22s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m35s
flatpak / build-publish (push) Successful in 6m33s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 10m8s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 2m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 11m43s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m1s
release / apple (push) Successful in 25m5s
apple / screenshots (push) Successful in 20m50s
The host's capture tick and a client's panel vsync are independent ~120 Hz
oscillators; their drifting phase sweeps every frame's wait-for-latch across
a full refresh period (measured on-glass: latch p50 oscillating 5.4-8.9 ms
with a fixed margin) — the beat is the residual judder and the fat p95, and
no client can fix it alone. Design: punktfunk-planning
design/phase-locked-capture.md.

Protocol (punktfunk-core):
- PhaseReport (control 0x32, next to the clock family): the client's next
  display latch ALREADY CONVERTED to host clock (the skew offset lives only
  client-side), panel period, uncertainty, and the measured median
  arrival-lead — the controller's error signal. ~1 Hz, latest-wins.
  CLIENT_CAP_PHASE_LOCK advertises it; CtrlRequest::Phase + report_phase()
  + the C ABI mirror carry it.
- The 0xCF host-timing tail grows a phase ACK (applied_phase_ns, 29-byte
  form) under the same strict-prefix append discipline — old readers parse
  the shorter forms; degradation pinned by tests.

Host engine (arrival-slaved loop — no backend can move the source vsync,
per the tick-ownership audit in the design doc):
- PhaseCtl bridges control task → encode loop (the fec_target pattern,
  multi-field). PhaseController walks a per-frame HOLD before submit toward
  the client's reported lead hitting target = max(2.5 ms, uncertainty+1ms):
  1 Hz adjust, 2 ms max step, 300 µs deadband, period-wrapping (the
  newest-wins capture slot makes a wrapped hold sample fresher content, not
  staler). A loop local, so every mid-stream rebuild keeps the lock; a new
  session re-acquires. PUNKTFUNK_PHASE_LOCK=0 disarms.

Android reporter: the presenter's 1 Hz pf.present flush returns the
window's measured latch p50; the async loop converts the vsync clock's next
timeline monotonic→realtime→host and reports. Inert toward old hosts.

Gates: docker amd64 clippy --all-targets -D warnings (host+core, nvenc)
clean; core suite incl. the new wire tests; cargo ndk arm64 check/clippy
clean. On-glass A/B vs the .173 host owed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:44:09 +02:00
enricobuehlerandClaude Fable 5 f87c1e6cec fix(encode): gate multi-slice frames on the client's decoder — the 0.17.0 Chromecast crash
ci / docs-site (push) Successful in 1m9s
apple / swift (push) Successful in 1m15s
ci / web (push) Successful in 3m16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
deb / build-publish-client-arm64 (push) Successful in 2m13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
ci / rust-arm64 (push) Successful in 3m34s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
deb / build-publish-host (push) Successful in 4m20s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Successful in 28s
deb / build-publish (push) Successful in 6m17s
arch / build-publish (push) Failing after 7m58s
flatpak / build-publish (push) Successful in 9m6s
ci / rust (push) Successful in 13m37s
release / apple (push) Successful in 16m32s
windows-host / package (push) Successful in 18m21s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m21s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m24s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m27s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 2m56s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m54s
apple / screenshots (push) Successful in 20m45s
android / android (push) Successful in 5m0s
Field report: since 0.17.0 a stream to a Chromecast with Google TV 4K
freezes on the first frame and ~80% of the time crashes + reboots the
DEVICE — with both the Punktfunk app and Moonlight, while an Xbox
Series S is fine. Root cause: LN1 Phase 3 (67b79810) defaulted Linux
direct-NVENC to 4 slices per frame for EVERY session. Amlogic HEVC
decoders wedge on multi-slice AUs — exactly why moonlight-android
requests slicesPerFrame=1 for every hardware decoder (4 only for
software slice-threading) — and our RTSP parser never read the request.
The Phase-3 commit recorded the untested leg ("a live Moonlight re-test
joins the standing owed Moonlight item"); this report is that re-test.

The slicing ceiling now belongs to the CLIENT, threaded as open_video's
new max_slices from both planes:
- GameStream: parse x-nv-video[0].videoEncoderSlicesPerFrame into
  StreamConfig and honor it; absent/out-of-range (pre-auth input) => 1.
- punktfunk/1: new Hello cap VIDEO_CAP_MULTI_SLICE (0x80 — the byte's
  LAST free bit; the next cap needs a second byte + ABI bump).
  SessionPlan.max_slices = 32 with the bit, 1 without, applied to every
  encoder the plan opens so rebuilds can't change the wire shape. The
  desktop session client advertises it (FFmpeg/D3D11VA/Vulkan decode
  stacks are fine); Android/Apple stay off until they can decide
  per-decoder like Moonlight does — the cap is embedder-set decoder
  truth, never OR'd in by the shared pump.
- Linux direct-NVENC clamps its Phase-3 default to the ceiling
  (resolve_slices(codec, 4.min(max_slices))) and logs slices/max_slices
  in the caps-probe line; PUNKTFUNK_NVENC_SLICES stays the explicit
  operator override in both directions. Windows keeps its single-slice
  default untouched.

Also repairs the nvenc_cuda #[ignore] hardware tests: d2c46eaf added
open()'s cursor_blend param without updating them, invisible because CI
never compiles tests with the nvenc feature.

Verified on .21 (RTX 5070 Ti): pf-encode + host check/clippy clean with
nvenc; host unit suite 263/0; rtsp announce tests 7/7 incl. the new
slicesPerFrame coverage; on-hardware smokes — default e2e still 4
chunks/frame, NEW single-slice client-ceiling test clamps + disarms
chunked poll with no env involved, env escape unchanged. rustfmt clean.
Windows leg (prepare_display param) is CI-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 22:23:42 +02:00
enricobuehlerandClaude Fable 5 53640b8754 fix(core/clock): re-sync survives loaded links — floor baseline, spaced rounds, bounded staleness
The mid-stream clock re-sync starved on high-bitrate LAN sessions (2026-07
PyroWave-sawtooth field report, RX 9070 XT -> 780M @ 550 Mb/s): every batch
was judged against the CONNECT-TIME RTT, measured before the video data
plane existed, with a 2 ms floor — mid-stream control RTTs on a loaded GbE
link sit above that almost permanently, so batches were rejected for
minutes while the wall clocks drifted apart and the OSD e2e figure ramped
19->150 ms before snapping back on a lucky batch.

Three changes:
- Rounds are spaced 7 ms apart (stamped at send time, so the spacing never
  lands in the RTT). An 8-round batch used to complete inside ONE ~6 ms
  video burst — all rounds sampled the same congestion state; spacing walks
  them across the frame cycle so the min-RTT round finds a quiet gap.
- ResyncGuard replaces the static baseline: the guard band follows the best
  RTT the session has evidenced (connect RTT, then min over every completed
  batch — rejected ones included).
- Rejection streaks are bounded: after 3 consecutive rejections the best
  (min-RTT) batch of the streak is applied anyway. Its queueing bias is at
  most ~half its RTT; unbounded wall-clock drift costs more per minute.

Rejections now log at warn with the streak and floor — the starvation
signature is grep-able in exactly the logs users send.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehlerandClaude Opus 4.8 c3c0115a82 fix(core/abi): bump ABI_VERSION to 13 for punktfunk_connection_send_pen
The pen P0 work added a new embeddable C entry point (punktfunk_connection_send_pen)
but left ABI_VERSION at 12 — the value 0.18.0 shipped. Per the project's convention
(0.18.0 bumped to 12 for the additive cursor channel), additive C-surface growth bumps
the ABI so embedders can gate on the new symbol. Regenerated include/punktfunk_core.h
via cbindgen (cargo build -p punktfunk-core); WIRE_VERSION is unchanged (pen is
capability-gated on HOST_CAP_PEN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:23:38 +02:00
enricobuehlerandClaude Fable 5 aca5aa9993 feat(host+inject): pen P1 — per-session uinput virtual tablet, HOST_CAP_PEN live
The Linux injection leg of design/pen-tablet-input.md: 0xCC/0x05 pen batches now
route through the per-session PenTracker into a lazily-created 'Punktfunk Pen'
uinput tablet (BTN_TOOL_PEN/RUBBER, pressure, tilt-from-polar, ABS_Z barrel
roll, hover distance, INPUT_PROP_DIRECT) — compositors pick it up via libinput
and hand apps zwp_tablet_v2 with full fidelity. The host now advertises
HOST_CAP_PEN when /dev/uinput is accessible (PUNKTFUNK_PEN=0 kill-switch);
transitions group into SYN frames so proximity-enter carries its position.
Stroke failsafe: clients heartbeat ≤100ms while in range (documented wire
contract — capture APIs are silent for a stationary pen); 200ms of silence
force-releases. 'punktfunk-host pen-test' draws a pressure-ramped sine stroke
through the real tracker→uinput chain, no client needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:18:31 +02:00
enricobuehlerandClaude Fable 5 4714235fe6 feat(core): stylus wire P0 — state-full RICH_PEN batches, PenTracker, HOST_CAP_PEN
apple / swift (push) Successful in 1m24s
release / apple (push) Successful in 10m3s
android / android (push) Successful in 13m31s
arch / build-publish (push) Successful in 13m39s
ci / web (push) Successful in 1m4s
ci / docs-site (push) Successful in 1m12s
windows-host / package (push) Successful in 16m40s
apple / screenshots (push) Successful in 6m38s
ci / bench (push) Successful in 5m26s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m9s
ci / rust (push) Successful in 20m46s
decky / build-publish (push) Successful in 30s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m15s
deb / build-publish (push) Successful in 8m50s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 45s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m31s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7m6s
deb / build-publish-host (push) Successful in 9m52s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6m44s
docker / deploy-docs (push) Successful in 28s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 7m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m45s
flatpak / build-publish (push) Failing after 8m6s
The pen plane from design/pen-tablet-input.md, protocol side only (the P1 uinput
tablet injector will consume it): 0xCC kind 0x05 carries batches of state-full
PenSamples (pressure, polar tilt + azimuth, barrel roll, hover distance, eraser
tool, barrel buttons); PenTracker diffs samples into injector transitions so a
lost datagram self-heals; HOST_CAP_PEN (0x10) gates sending and stays
unadvertised until a real backend exists. C ABI: PunktfunkPenSample +
punktfunk_connection_send_pen, documented in docs/embedding-the-c-abi.md §8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:58:31 +02:00
enricobuehlerandClaude Opus 4.8 92f38ec3dd chore(cleanup): drop TEMP cursor probes + clear KWin-leg clippy debt
audit / bun-audit (push) Failing after 14s
ci / web (push) Successful in 52s
windows-drivers / probe-and-proto (push) Successful in 54s
ci / docs-site (push) Successful in 1m6s
apple / swift (push) Successful in 1m21s
audit / cargo-audit (push) Successful in 2m49s
windows-drivers / driver-build (push) Successful in 2m0s
decky / build-publish (push) Successful in 22s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
ci / bench (push) Successful in 6m47s
ci / rust (push) Failing after 10m23s
release / apple (push) Successful in 9m24s
deb / build-publish-host (push) Successful in 9m51s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
android / android (push) Successful in 12m25s
flatpak / build-publish (push) Failing after 8m8s
deb / build-publish (push) Successful in 12m16s
ci / web (pull_request) Successful in 50s
ci / docs-site (pull_request) Successful in 51s
docker / deploy-docs (push) Successful in 26s
arch / build-publish (push) Successful in 14m34s
windows-host / package (push) Successful in 15m5s
apple / screenshots (push) Successful in 6m33s
ci / rust (pull_request) Failing after 6m56s
ci / bench (pull_request) Successful in 6m5s
android / android (pull_request) Successful in 9m56s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m14s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m42s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m42s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m34s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 4m47s
windows-drivers / probe-and-proto (pull_request) Successful in 15s
windows-drivers / driver-build (pull_request) Successful in 1m30s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m3s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m54s
The KWin/Phase-A/B commits were built but never clippy-checked (that distrobox
had no clippy component), so they left TEMP on-glass probes and lint debt in the
tree. With clippy now runnable (fedora rust 1.96.1 = CI parity):

- drop the `fec424ee`/`8cff30d5` TEMP probes: the `update_cursor_meta` SPA_META
  diagnostic logs (also un-detaches the `// SAFETY:` comment from its `unsafe`
  block → fixes `undocumented_unsafe_blocks`) and the KWin composite-arm probe in
  the encode loop.
- `#[allow(clippy::too_many_arguments)]` on `spawn_pipewire` (8 params since the
  KWin leg added `expect_exact_dims`; mirrors `from_virtual_output`).

clippy `-p pf-capture -p pf-vdisplay -p punktfunk-host --locked --features
nvenc,vulkan-encode -- -D warnings` is now green. (--all-targets additionally
trips a pre-existing env mismatch: the fedora libspa binding lacks
`SPA_VIDEO_TRANSFER_SMPTE2084`, referenced only by a `#[cfg(test)]` guard-test —
not a code issue; CI's pinned pipewire has it.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:54:56 +02:00
enricobuehlerandClaude Opus 4.8 92761813a9 feat(core+host+driver+client): mid-stream cursor-render flip — host composites in the capture model
The cursor channel excluded the host pointer from the video for the
whole session, but the client only draws it under the DESKTOP mouse
model — flipping ⌃⌥⇧M to capture mid-stream left the session
pointer-blind (user-found). The fix makes render ownership LIVE state:

- wire: CursorRenderMode 0x51 (client→host, control stream) —
  client_draws: true = desktop model (host excludes + forwards),
  false = capture model / released (host composites, full fidelity —
  DWM on Windows incl. real XOR inversion, encoder blend on Linux).
  Sessions start client_draws=true (the pre-message behavior).
- host: control task stores the flag; the encode loop edge-detects it —
  forwarding + frame.cursor strip while the client draws, quiet
  forwarder + overlay-into-blend while composited. SessionPlan now
  grants blend CAPABILITY wherever the capture has a pointer (dropping
  the !cursor_forward gate) so the composite side needs no encoder
  rebuild; per-tick frame.cursor decides what is drawn.
- Windows: Capturer::set_cursor_forward → retained
  IOCTL_SET_CURSOR_FORWARD sender (proto v6) → driver (un)declares the
  IddCx hardware cursor on the LIVE monitor. Un-declare re-issues the
  setup with EMPTY caps (candidate mechanism — the DDI has no
  documented un-setup); resetup-on-commit is skipped while off, so a
  mode commit's software-cursor default is the fallback path. Failure
  against a pre-v6 driver logs and keeps declared-at-ADD behavior.
- SDL client: one edge-detected reconciler at the cursor pump site —
  desktop-active = client draws; capture model or released = host
  composites. Covers the chord, the M3 auto-flip, and engage/release.
- C ABI v12: punktfunk_connection_set_cursor_render (additive; wire
  version unchanged — pre-§8 hosts ignore the unknown message type).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:43:23 +02:00
enricobuehlerandClaude Fable 5 ec8ca9a535 feat(apple): cursor channel on the Mac client — ABI v11 + NSCursor rendering
The Mac client now exercises the full cursor channel against capable
hosts: desktop-mode sessions advertise CLIENT_CAP_CURSOR, the host
stops compositing the pointer, and StreamView draws the forwarded
shapes as the real NSCursor — plus the M3 host-driven model flip.

- ABI v11: punktfunk_connect_ex9 (adds client_caps; ex7/ex8 pass 0 —
  Android untouched), PunktfunkCursorShape/State + the two
  next_cursor_* poll fns (audio-style borrow contract), and
  PUNKTFUNK_CLIENT_CAP_CURSOR. Fixed in passing: the first insertion
  split next_host_timing's cfg/no_mangle attributes off the fn, which
  silently dropped the symbol from the header AND dylib — caught by
  the Swift build, reattached with its docs.
- PunktfunkConnection: clientCaps connect param, hostSupportsCursor,
  CursorShapeEvent/CursorStateEvent + nextCursorShape/nextCursorState
  (one cursor thread drains both planes; cursorLock joins the close()
  ladder).
- SessionModel: desktop-mode sessions (DefaultsKey.mouseMode) connect
  with the cursor cap on macOS.
- StreamView: shape cache (serial → NSCursor, straight-alpha RGBA via
  CGImage), resetCursorRects wears the HOST shape while the desktop
  model is engaged (hidden host pointer ⇒ invisible), latest-wins
  state pump, and the M3 auto-flip — edge-triggered on relative_hint,
  ⌃⌥⇧M sets an override latch cleared by the next host edge, leaving
  relative warps the pointer to the host position via the inverse
  letterbox mapping (CGWarpMouseCursorPosition, CursorCapture's
  coordinate convention).

Verified: swift build + full test suites green (xcframework rebuilt at
ABI v11, signed); core 218 tests + clippy -D warnings on Linux (.21).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 23:43:23 +02:00
enricobuehlerandClaude Fable 5 4a01bc4463 feat(core+host+client): cursor channel — remote-desktop sweep M2a+M2b
The host cursor stops riding the video and becomes a real OS cursor on
the client (the Parsec/RDP model): pointer feel no longer pays the
capture→encode→network→decode→present round trip.

Wire (M2a):
- Hello grows a client_caps trailing byte (CLIENT_CAP_CURSOR) after the
  fixed display_hdr block — presence disambiguated by remaining length,
  which caps the post-HDR tail at 27 bytes (documented); Welcome answers
  HOST_CAP_CURSOR (capable-and-asked, the 444/clipboard precedent).
- CursorShape (0x50, control stream): serial + dims + hotspot + straight
  RGBA, ≤120px/side so the u16 frame always fits (128² would overshoot);
  client caches by serial — re-showing a known shape costs 14 bytes, not
  a bitmap (RDP pointer-cache for free).
- CursorState (0xD0 datagram): serial + visible/relative_hint flags +
  position, sent once per encode-loop tick — latest-wins, self-healing
  under loss, no refresh timer. relative_hint is reserved for M3.
- Client core: two new planes (control-task + datagram-task arms) →
  next_cursor_shape/next_cursor_state; connect() grows client_caps
  (C ABI passes 0 until the v11 cursor poll fns exist).

Host (M2b, Linux portal only):
- handshake::cursor_forward is THE predicate (client asked ∧ Linux ∧
  compositor ≠ gamescope) — Welcome bit and session wiring both read it.
- SessionPlan.cursor_blend goes false for a forwarding session; the
  encode loop ticks a CursorForwarder every iteration: shape-serial diff
  → control-task bridge (mirrors probe_result), state datagram → conn.
- CursorOverlay/capture CursorState carry the hotspot through
  (nearest-neighbor downscale backstop for XL cursors, unit-tested).

Presenter:
- CursorChannel drains both planes per loop iteration; shapes become
  SDL color cursors (from_surface + hotspot), applied while the desktop
  mouse model is engaged; visibility follows the host; capture/released
  hands back the system cursor. Sessions advertise the cap when they
  START in desktop mode.

Verified on .21: fmt + clippy -D warnings (7 crates) + tests green
(core 218 incl. new wire roundtrips, host 245 incl. e2e + forwarder
downscale tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 23:43:23 +02:00
enricobuehlerandClaude Fable 5 abe6228b42 feat(core+host+android): committed-text input plane (IME path) — InputKind::TextInput
The VK key-event vocabulary cannot express text an input method COMMITS
(autocorrect, gesture typing, non-Latin scripts, emoji). Add a first-class
text event and negotiate it:

- punktfunk-core: InputKind::TextInput (= 15) carries one Unicode scalar per
  event in `code`; HOST_CAP_TEXT_INPUT (0x04) in Welcome::host_caps.
- Host advertises the cap only where the session's inject backend can type
  text: Windows SendInput (KEYEVENTF_UNICODE, surrogate-pair aware) and the
  Linux wlroots backend — a dedicated second zwp_virtual_keyboard whose xkb
  keymap grows Unicode keysyms on demand (the wtype model), so keymap
  re-uploads never disturb the main device's layout/modifier state. The
  KWin-fake-input/libei/gamescope backends can only press layout keycodes, so
  those sessions don't set the bit and clients keep the VK fallback.
- GameStream plane: Moonlight's UTF-8 text packet (MAGIC_UTF8, previously
  recognized-and-dropped) now decodes to the same TextInput events.
- Android: KeyCaptureView picks a real editable InputConnection when the host
  has the cap — the IME runs its full machinery, mirrored to the host live via
  common-prefix diffs of the composition (backspaces + new suffix), with
  setComposingRegion adopting committed text so autocorrect-revert flows diff
  instead of retyping; newline→Enter, deleteSurroundingText→Backspace/Delete.
  Older hosts keep the TYPE_NULL raw-key path unchanged.
- keymap: media VKs (0xB0-0xB3) → evdev so the Android media keys land on
  Linux hosts too.

Verified: punktfunk-core + host gamestream + pf-inject tests green on Linux
(Ubuntu box), clippy clean; Android app+native builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 19:19:11 +02:00
enricobuehlerandClaude Fable 5 2562663fc6 fix(host/linux): survive and self-heal dead gamescope sessions on headless SteamOS boxes
Three fixes for the "most connects simply fail" trap on a SteamOS host
with no physical display (VM testbox, panel-less mini PC):

- restore path: skip the restore-to-physical-panel when no DRM connector
  reports a connected display — removing the headless drop-in and
  restarting gamescope-session.target on such a box just crash-loops
  gamescope and strands every later connect on "no usable compositor".
  Keep the headless session (and the takeover state) instead.
- connect path: when no live graphical session exists but the MANAGED
  gamescope infra is present (SteamOS gamescope-session / Bazzite
  session-plus), route to the gamescope takeover — which rebuilds the
  session at the client's mode — instead of failing the connect.
- error delivery: a session-setup failure used to just drop the QUIC
  connection, reaching the client as "control stream finished mid-frame"
  (indistinguishable from transport trouble). Close with a typed
  setup-failed code (0x68, RejectedSetupFailed = -29) carrying the error
  text, and give the client handshake/pairing error paths a short grace
  for the CONNECTION_CLOSE to beat the stream error it caused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 09:50:30 +02:00
enricobuehlerandClaude Fable 5 d36bec6e9d feat(core+host): negotiate ChaCha20-Poly1305 as the session cipher for soft-AES clients
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
arch / build-publish (push) Has been cancelled
audit / bun-audit (push) Failing after 13s
audit / cargo-audit (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (push) Successful in 10m1s
windows-host / package (push) Successful in 11m20s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m29s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 22m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m27s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m28s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m39s
Lifts the ~100 Mbps decrypt ceiling on clients without hardware AES — the
armv7 soft-AES targets (webOS TVs), where AES-128-GCM resolves to fixsliced
software AES + software GHASH (~50-100 cpb) while ChaCha20-Poly1305's ARX
construction runs ~10-17 cpb portable, a 4-7x lift that PyroWave-on-TV needs
(design/chacha20-session-cipher.md).

Phase 1 (core crypto, no wire change): SessionKey merges cipher choice and
key material (invalid combinations unrepresentable, zeroize + redacted-Debug
discipline kept); SessionCrypto dispatches both aead-0.5 ciphers per call —
the salt||seq nonce scheme, per-direction salts, seq-as-AAD and replay
window carry over verbatim (same 96-bit nonce / 16-byte tag, const-asserted).
Config.key becomes SessionKey; validate's zero-key rejection follows the
active variant. The C ABI keeps its fixed 16-byte key mapped to AES — no
ABI_VERSION bump.

Phase 2 (negotiation): VIDEO_CAP_CHACHA20 (0x40) — support-plus-request in
one bit, the VIDEO_CAP_444 precedent. Welcome grows cipher@68 +
key_chacha@69..101, emitted only when non-zero so an AES session's Welcome
stays byte-identical to the pre-cipher form; decode is fail-closed (short
key or unknown id -> Err, never a silent AES fallback). No WIRE_VERSION
bump; downgrade resistance inherited from the pinned-TLS control channel.

Phase 3 (host): grant only when the client advertised the bit and the
PUNKTFUNK_CHACHA20 kill-switch (default on, documented) allows; fresh
32-byte per-session key from the same RNG discipline, legacy key field
stays independently random; resolved cipher logged at session start.

Verification: seal/open suites parameterized over both ciphers + a
cross-cipher tamper case; Welcome roundtrip/truncation/fail-closed tests;
ChaCha lossy-loopback soak (loss/replay is cipher-independent); bench
gains _chacha20 series (AES ids unchanged for CI history) — host-side
sealing line-rate-trivial on both x86 (~640 MiB/s) and Apple Silicon
(~535 MiB/s). punktfunk-probe drives the interop matrix via
PUNKTFUNK_CLIENT_CHACHA20=1 and logs the negotiated cipher.

Phase 4 (pf-webos pin bump + unconditional cap bit) follows the next
core release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:50:00 +02:00
enricobuehlerandClaude Fable 5 c5402cb1f7 feat(core+host): LN1 phase-2 — VIDEO_CAP_STREAMED_AU streamed access units
The wire half of sub-frame slice output (latency §7 LN1, planning
design/nvenc-subframe-slice-output.md Phase 2): toward a client that
advertises the new cap bit (0x20), a chunked-poll encoder session ships each
AU's completed FEC blocks while the tail of the frame is still encoding —
the AU's last packet leaves the host as the encode finishes instead of
after it.

Wire semantics (negotiated; zero change for anyone else):
- Non-final blocks ride SENTINEL headers: block_count = 0 (a value no legacy
  sender emits) + frame_bytes = 0 + exactly max_data_per_block data shards,
  so the receiver's shard-offset formula needs no total.
- The final block's headers carry the real frame_bytes/block_count (+
  FLAG_EOF) and RETRO-VALIDATE the whole frame: totals under which a
  received sentinel block is out of range or not full-K kill the frame
  wholesale (no spliced delivery) and the index can't be resurrected.
- Firewall: sentinels are bounded by the negotiated limits (full-K exactly,
  never the last block the limits allow, no total to lie about); the exact
  derived-geometry check runs unchanged on every non-sentinel packet and
  retroactively at pinning. Sentinel opens commit a max_frame_bytes buffer,
  bounded by the existing IN_FLIGHT_BUF_FACTOR budget (amplification test).
- Order-agnostic like legacy: a reversed frame (final block first) opens
  legacy-shaped and still accepts its sentinels against the pinned totals.
- Small/empty streamed AUs degenerate to byte-identical legacy headers.

Host: Packetizer::{begin,push,finish}_streamed seal full-K blocks (data +
parity per block) as chunks arrive; Session::seal_streamed_* share the
pooled-wire + two-lane seal machinery via the new seal_run; the send thread
paces each flush under the frame's existing deadline (pace_sealed split out
of paced_submit) and runs the whole-AU accounting at the last chunk; the
encode pump forwards poll_chunk output as ChunkMsg when the client has the
cap AND the encoder chunks (re-queried per AU — an escalation falls back
seamlessly). Probes never run mid-AU. PUNKTFUNK_STREAMED_AU=0 = host escape
hatch. Client core ORs the cap into Hello (the shared reassembler carries
the support). Sampled first_slice_us vs encode_us PERF log measures the
overlap; the 0xCF stage-field extension stays a follow-up.

Core tests: streamed round-trip (clean/loss/reorder/duplicate, both orders),
sentinel firewall bounds, lying-final wholesale kill + no-resurrect,
open-amplification budget, header-shape pins. Gates still owed before
default-on: security review pass, loss-harness curve, GameStream smoke
(plane untouched structurally), bitrate A/B.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:53:53 +02:00
enricobuehlerandClaude Fable 5 de7b54d4e0 fix(core): remaining 15 sweep lows — client, clipboard, C ABI (v10), FEC, GSO, GTK
ci / docs-site (push) Successful in 58s
ci / web (push) Successful in 1m11s
apple / swift (push) Successful in 1m20s
decky / build-publish (push) Successful in 29s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
Second and final batch from the 2026-07-20 core-sweep lows:

client:
- connect() timeout now sets `quit` before shutdown, so a handshake that
  completes after the deadline closes with QUIT_CLOSE_CODE instead of
  leaving the host lingering (virtual display up) for a reconnect that
  never comes.
- probe_result: saturating_add on the wire-supplied wire_packets +
  send_dropped counters (debug-build overflow panic / release wrap).
- the standing-latency bleed no longer sets flush_in_window: that flag
  is the ABR's SEVERE (×0.7) verdict, and the bleed fires only on
  provably loss-free windows the controller itself scores as fine.

clipboard:
- fetch_cancels pruned on every new fetch (was: one dead oneshot per
  paste for the session).
- serve chunks gated on a parked waiter + capped at CLIP_FETCH_CAP with
  an Error event (was: unbounded silent accumulation under any req_id).
- serve_inbound park bounded by FETCH_STALL_SECS + send.stopped() (was:
  an unanswered FetchRequest parked the task, waiter, and bi-stream
  forever; ~100 of them exhaust the connection's bidi budget).

C ABI (ABI_VERSION 9 → 10, header regenerated, additive only):
- new punktfunk_connection_clock_offset_now_ns — the LIVE re-synced
  offset (Swift/Kotlin latency math read the frozen connect-time value
  ~40ms wrong after a wall-clock step).
- to_config: checked u64→usize narrowing of max_frame_bytes (32-bit
  armeabi truncated >4GiB to a plausible residue).
- host_poll_input: no &mut held across the embedder callback (re-entry
  aliased it — UB under noalias); mid-drain callback clears now stick.
- next_audio_pcm: DTX (empty) payloads skipped — decode synthesized
  120ms of concealment per 5ms slot and grew the playout ring forever.
- next_clipboard releases the parked payload on an empty poll (a one-off
  50MiB paste stayed resident all session).
- frames_dropped / wants_decode_latency write their documented 0/false
  defaults before the NULL-handle check.
- gamepad constant docs match pick_gamepad() reality (DualSenseEdge/
  SwitchPro landed; DualSense/DS4 honored on Windows UMDF too).

FEC:
- gf8 reconstruct/reconstruct_into reuse the (k,m) codec cache like
  encode_into (was: fresh 230×200 generator + decode inversion per
  lossy block on the pump thread).
- vendored fec-rs: the 8 safe wrap_mul_slice shims assert equal lengths
  (x86 SIMD callees bound stores on input.len() — safe-code OOB write);
  ReconstructShard's safety contract gains the len()==get().len()
  clause and reconstruct_internal sizes raw slices from the slice.

transport/GTK:
- Linux GSO super-buffer capped at the real UDP payload ceiling (65487)
  not 65535 — seg sizes ≥1024 could EMSGSIZE and latch GSO off
  process-wide, blamed on the network.
- GTK settings dialog no longer rewrites an unlisted-but-valid stored
  gamepad preference to "auto" on close.

Already fixed on main (verified stale, skipped): the reassembler FEC
ceiling, wants_decode_latency's third term, request_probe rollback +
the generalized probe watchdog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:26:43 +02:00
enricobuehlerandClaude Fable 5 a784682d4c fix(client/net): split the receipt stamp from the pull + bleed standing latency
ci / docs-site (push) Successful in 56s
apple / swift (push) Successful in 1m14s
ci / web (push) Successful in 2m30s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
ci / bench (push) Successful in 5m57s
release / apple (push) Successful in 9m8s
deb / build-publish (push) Successful in 9m33s
arch / build-publish (push) Successful in 13m14s
docker / deploy-docs (push) Successful in 23s
flatpak / build-publish (push) Failing after 8m1s
deb / build-publish-host (push) Successful in 10m24s
android / android (push) Successful in 14m56s
apple / screenshots (push) Successful in 6m43s
ci / rust (push) Successful in 19m29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m29s
windows-host / package (push) Successful in 15m13s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 5m48s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 7m45s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 8m51s
The two-pair investigation (wired Mac clients stuck at a rock-steady
~18-19 ms "network" that survived the load ending and cleared only on
reconnect) exposed two structural gaps, one of measurement and one of
recovery:

- Receipt was stamped at the hand-off PULL (Swift nextAU, pf-client-core,
  Android decode loops), not at reassembly completion — so any client-side
  standing state between the reassembler and the pull read as NETWORK
  latency, undiagnosable from the HUD. ABI v9: `PunktfunkFrame`/`Frame`
  grow `received_ns`, stamped by `Session::poll_frame` as the AU crosses
  the session boundary. Every embedder now uses the core stamp; the Apple
  client keeps the pull instant as `AccessUnit.pulledNs` and shows the
  receipt→pull wait as its own "client queue" term (detailed HUD tier from
  2 ms + a `queue_p50` stats-log field). Decode stages keep their pull
  anchor on all platforms, so no historical stage shifts meaning.

- The jump-to-live detectors deliberately ignore anything under 6 queued
  frames / 400 ms behind — so a small, constant, loss-free elevation (a
  sub-frame standing backlog, or a stale clock offset after a wall-clock
  step/slew) is carried for the rest of the session. New third detector
  (`StandingLatency`, unit-tested ladder): window-MIN one-way delay
  ≥ 10 ms above the session floor with zero loss for ~4.5 s escalates
  gently — a free clock re-sync first (an applied re-sync re-bases the
  floor), then at most 3 flush+keyframe bleeds sharing the jump-to-live
  cooldown, then a loud disarm naming what it means. Loss windows reset
  the run: congestion belongs to FEC/ABR, not this detector.

Also: mid-stream re-sync apply/discard logs debug→info — they are the
forensic trail for the stale-offset case and were invisible in the field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 23:38:11 +02:00
enricobuehlerandClaude Fable 5 aedee2a4e3 perf(latency): tier-0 attribution + tier-1 send-path levers from the latency plan
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 54s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
apple / swift (push) Successful in 1m23s
ci / rust (push) Failing after 4m15s
arch / build-publish (push) Failing after 4m35s
deb / build-publish (push) Failing after 4m4s
docker / deploy-docs (push) Successful in 24s
ci / bench (push) Successful in 5m35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 3m45s
windows-host / package (push) Failing after 9m8s
release / apple (push) Successful in 5m49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 5m25s
flatpak / build-publish (push) Failing after 8m3s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m58s
android / android (push) Successful in 14m9s
apple / screenshots (push) Successful in 6m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m0s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 9m28s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 12m26s
design/latency-reduction-2026-07.md T0.1/T0.2/T1.2/T1.3:

- T1.2 rate-capped front-loaded pacing: the paced overflow's budget is now
  min(0.9x slack, overflow wire time at ~3x the live encoder bitrate)
  (PUNKTFUNK_PACE_FACTOR, 0 = legacy deadline-only spread). A 300 KB-1 MB
  frame's tail leaves in ~2-5 ms instead of smearing across ~15 ms at 60 fps;
  GameStream schedule byte-identical (pins unchanged).
- T1.3 data-first wire order: packetize emits every block's data shards before
  any parity (per-block parity pools keep all blocks' parity alive for the
  second pass), so lossless completion stops waiting behind the parity tail.
  EOF = last emitted packet; receiver already order-agnostic.
- T0.1 staged 0xCF: HostTiming gains an append-extensible per-stage tail
  (queue/encode/pace us; seal+channel-wait derived as residual) - no cap bit
  needed, old peers read the 13-byte prefix. Joined client-side into
  Stats::host_{queue,encode,xfer,pace}_ms, the OSD detailed tier, and the
  probe's report.
- T0.2 true on-glass present timing: VK_KHR_present_id/present_wait enabled
  when supported; a PresentTimer waiter thread resolves each present id to
  real visibility, replacing the submit-time display stamp (which undercounts
  by up to a refresh and hides a silent-FIFO standing queue).

Validated on .21: core 185 + host 185 tests, pf-presenter 19, clippy
-D warnings across all five touched crates; loss-harness recovery curve
unchanged; C ABI harness round-trips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:13:08 +02:00
enricobuehlerandClaude Opus 4.8 2064c0780c merge(core): reconcile the W7/W8 client refactor with origin's shared-clipboard feature
origin/main landed the shared clipboard (design/clipboard-and-file-transfer.md) while
this branch split quic/msgs.rs -> quic/{caps,control,...} and client.rs ->
client/{mod,control,worker,pump,planes,...} (W7) and deleted the two monoliths. The
feature had modified both deleted files, so its delta is re-applied onto the split
instead of resurrecting the monoliths:

  - HOST_CAP_CLIPBOARD                         -> quic/caps.rs
  - MSG_CLIP_* / CLIP_* consts, the six Clip*
    structs, and their encode/decode impls     -> quic/control.rs (beside the clock codecs)
  - CtrlRequest::{ClipControl,ClipOffer} +
    Negotiated.host_caps                        -> client/control.rs
  - WorkerArgs.{clip_event_tx,clip_cmd_rx}      -> client/worker.rs
  - CLIP_EVENT_QUEUE                            -> client/planes.rs
  - NativeClient clip fields, the 7 clip_* /
    host_caps / next_clip methods, connect()
    channel wiring                              -> client/mod.rs
  - the control-task encode/decode arms and
    the clipboard-task spawn                     -> client/pump.rs

Cargo.lock reconciled (adds pf-clipboard), punktfunk-host/Cargo.toml unions the W6
pf-* subsystem deps with pf-clipboard, and include/punktfunk_core.h is the cbindgen
union (clipboard + rumble C-ABI). punktfunk-core builds --all-features and its 174
lib tests pass, including quic::tests::clip_loopback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:38:11 +02:00
enricobuehlerandClaude Fable 5 13b1f36d4a feat(core,clients): one rumble policy engine for every platform (rumble root fix D)
punktfunk-core client/rumble.rs: a per-connection policy engine consumes seq-gated wire
updates and emits EFFECTIVE actuator commands — re-emits on renewals (duration APIs stay
re-armed), self-silences at the v2 lease, a UNIFORM 1 s legacy-host staleness replacing the
per-platform zoo (Apple 1.6 s / Android 60 s / SDL 1.5 s / Deck 1 s), quirk-declared
actuator keepalives (Deck 40 ms + LSB dedupe-defeat jitter), and one stop per buzzing pad
on connection close. Per-pad mailbox semantics: a stalled embedder wakes to ONE current
command, and a stop can structurally never be the update an overflowing queue drops.

New API/ABI: NativeClient::{next_rumble_command,set_rumble_quirks} +
punktfunk_connection_next_rumble_cmd/_set_rumble_quirks (next_rumble/next_rumble2 stay for
un-migrated embedders; both consumers are fed). Migrations DELETE the platform forks:
pf-client-core loses RumbleState + the Deck keepalive loop + LEGACY_RUMBLE_CEILING_MS and
physically silences a slot at close; Android loses the 60 s legacy one-shot (backstop
repack, cancel-on-zero); Apple loses envelopeDeadline + sessionStaleSeconds + both tick
watchdogs (CoreHaptics realization untouched; mac xcframework rebuilt locally).

design/rumble-root-fix.md par. D. Engine 10/10 unit tests; core tests 176 Linux / 175
Windows + clippy -D warnings; swift build + RumbleTuningTests; Kotlin + android-native
compile green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:07:32 +02:00
4ef90d586d feat(clipboard): wire protocol + client-core task for shared clipboard (Phase 0)
The portable shared-clipboard plane in punktfunk-core, all behind the `quic`
feature (design/clipboard-and-file-transfer.md §3):

- Control messages 0x40–0x44 (ClipControl / ClipOffer / ClipFetch...) and the
  HOST_CAP_CLIPBOARD capability bit, negotiated in the Welcome caps.
- Per-transfer QUIC bi-streams ("PKFs" magic) for lazy fetch of offered content,
  with ClipFetchHdr status/size framing (quic::clipstream).
- The §3.5 portable wire-MIME vocabulary (text/plain;utf-8, text/html, text/rtf,
  image/png) shared by both ends.
- Client-side clipboard task (client.rs) + C ABI surface bumped to v8 (abi.rs,
  regenerated include/punktfunk_core.h).
- Loopback transport tests (quic::tests).

No OS clipboard integration yet — that is the host backends (Phase 1/3) and the
macOS client (Phase 1).

Ported from feat/shared-clipboard (af3a7d8c, pre-W6 base) onto current main;
three deliberate deviations from the original commit:
- ABI v6 → v8: main took v6 (reanchor gate) and v7 (typed connect rejection)
  in the meantime; the clipboard C surface re-lands as v8.
- CLIP_CANCELLED_CODE 0x60 → 0x70: main's pairing-rejection close codes claimed
  the 0x60–0x67 block; the vocabularies stay disjoint on purpose.
- Negotiated.host_caps coexists with main's 6-tuple host_caps plumbing: main
  needs the worker-local copy for gamepad snapshots, the clipboard path needs it
  across ready_tx to build the NativeClient handle (punktfunk_connection_host_caps).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:50:37 +02:00
enricobuehlerandClaude Fable 5 01266ff18d feat(gamepad): SC2 Puck-dongle passthrough with the native 28DE:1304 topology
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>
2026-07-15 20:47:52 +02:00
enricobuehlerandClaude Opus 4.8 ff38933312 feat(core,apple,session): report decode latency from the Apple + Windows/Linux clients too
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 1m4s
ci / rust (push) Failing after 6m46s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
ci / bench (push) Successful in 5m19s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
arch / build-publish (push) Successful in 11m12s
windows-host / package (push) Successful in 14m52s
flatpak / build-publish (push) Successful in 7m10s
android / android (push) Successful in 16m0s
docker / deploy-docs (push) Failing after 17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m8s
deb / build-publish (push) Successful in 14m3s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m41s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m8s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m18s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m54s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m31s
apple / swift (push) Successful in 5m19s
release / apple (push) Successful in 27m46s
apple / screenshots (push) Successful in 19m39s
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>
2026-07-15 17:43:54 +02:00
enricobuehlerandClaude Fable 5 739a5f76bf feat(apple): PyroWave Phase 5 — native Metal decode on Mac / Apple TV / iPad (§4.7)
ci / web (push) Successful in 1m3s
ci / docs-site (push) Successful in 58s
decky / build-publish (push) Successful in 16s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 43s
ci / bench (push) Successful in 5m21s
arch / build-publish (push) Successful in 10m58s
docker / deploy-docs (push) Successful in 28s
android / android (push) Successful in 13m23s
deb / build-publish (push) Successful in 12m58s
ci / rust (push) Successful in 18m5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 12m31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m56s
flatpak / build-publish (push) Successful in 5m53s
windows-host / package (push) Successful in 14m37s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m9s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m12s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m6s
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
release / apple (push) Has been cancelled
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>
2026-07-15 12:16:43 +02:00
enricobuehlerandClaude Fable 5 2621b6e6b1 feat(core,host,android): Steam Controller 2 as-is passthrough to Linux hosts
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m2s
ci / rust (push) Failing after 5m34s
decky / build-publish (push) Successful in 17s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 52s
ci / bench (push) Successful in 6m3s
docker / deploy-docs (push) Successful in 25s
android / android (push) Successful in 14m2s
arch / build-publish (push) Successful in 11m57s
deb / build-publish (push) Successful in 11m49s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m5s
flatpak / build-publish (push) Failing after 8m3s
windows-host / package (push) Failing after 8m32s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m6s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m18s
apple / swift (push) Successful in 5m4s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 5m14s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m43s
release / apple (push) Successful in 26m11s
apple / screenshots (push) Has been cancelled
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>
2026-07-15 11:22:16 +02:00
enricobuehlerandClaude Fable 5 705a8baddf feat(core,host,client): PyroWave datagram-aligned packets + partial-frame delivery (Phase 4, §4.4)
ci / web (push) Successful in 1m10s
ci / docs-site (push) Successful in 1m16s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
decky / build-publish (push) Successful in 27s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
apple / swift (push) Successful in 4m59s
ci / rust (push) Failing after 6m17s
ci / bench (push) Successful in 6m36s
docker / deploy-docs (push) Successful in 23s
flatpak / build-publish (push) Failing after 8m29s
arch / build-publish (push) Successful in 11m11s
android / android (push) Successful in 12m33s
deb / build-publish (push) Successful in 14m8s
windows-host / package (push) Successful in 14m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m4s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m29s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m6s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 5m15s
release / apple (push) Successful in 26m8s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m48s
apple / screenshots (push) Successful in 20m29s
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>
2026-07-15 11:14:24 +02:00
enricobuehlerandClaude Fable 5 1fc9ef0050 feat(core,host,clients): typed pairing rejections — every client says WHY, not "not accepted"
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 52s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 44s
ci / bench (push) Successful in 5m56s
flatpak / build-publish (push) Successful in 6m41s
docker / deploy-docs (push) Successful in 27s
apple / swift (push) Successful in 4m40s
deb / build-publish (push) Successful in 12m7s
arch / build-publish (push) Successful in 14m10s
android / android (push) Successful in 17m16s
ci / rust (push) Successful in 17m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m50s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m15s
windows-host / package (push) Successful in 14m33s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m55s
release / apple (push) Successful in 25m31s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m49s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m11s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m9s
apple / screenshots (push) Successful in 19m41s
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>
2026-07-15 09:58:43 +02:00
enricobuehlerandClaude Fable 5 ef862454b0 chore(core): regenerate the C ABI header + lockfile for PUNKTFUNK_CODEC_PYROWAVE
audit / bun-audit (push) Successful in 11s
ci / web (push) Successful in 54s
audit / cargo-audit (push) Successful in 2m22s
ci / docs-site (push) Successful in 1m6s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 5m17s
android / android (push) Successful in 11m29s
flatpak / build-publish (push) Successful in 5m58s
arch / build-publish (push) Successful in 13m47s
deb / build-publish (push) Successful in 16m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m58s
docker / deploy-docs (push) Successful in 9s
ci / rust (push) Successful in 23m46s
apple / swift (push) Successful in 4m50s
windows-host / package (push) Successful in 14m8s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m39s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m31s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m55s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m39s
release / apple (push) Successful in 25m19s
apple / screenshots (push) Successful in 22m8s
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>
2026-07-15 01:44:29 +02:00
enricobuehlerandClaude Fable 5 d8e8529cd7 feat(gamepad): Windows Steam Deck backend — Steam-Input-promoted UMDF virtual Deck
windows-drivers / probe-and-proto (push) Successful in 18s
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows-host / package (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
release / apple (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
decky / build-publish (push) Has been cancelled
deb / build-publish (push) Has been cancelled
ci / web (push) Successful in 51s
windows-drivers / driver-build (push) Successful in 1m51s
ci / docs-site (push) Successful in 54s
ci / bench (push) Successful in 5m20s
arch / build-publish (push) Successful in 13m32s
android / android (push) Successful in 15m7s
ci / rust (push) Successful in 21m11s
apple / swift (push) Successful in 4m59s
apple / screenshots (push) Successful in 20m53s
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>
2026-07-14 12:36:26 +02:00
enricobuehlerandClaude Fable 5 45bde370e2 feat(gamepad): GamepadPref wire bytes for DualSense Edge (7) + Switch Pro (8)
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>
2026-07-14 10:20:24 +02:00
enricobuehlerandClaude Opus 4.8 8a18e130a2 feat(client): freeze-until-reanchor loss recovery on Android + Apple via shared core gate
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>
2026-07-13 01:22:09 +02:00
enricobuehlerandClaude Opus 4.8 76be4c3e12 feat(gamepad): multi-controller support on the native plane
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>
2026-07-12 21:53:07 +02:00
enricobuehlerandClaude Opus 4.8 fdda7144ed fix(encode): harden loss-recovery correctness across host encoders (F1–F7)
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>
2026-07-12 11:17:19 +02:00