Commit Graph
201 Commits
Author SHA1 Message Date
enricobuehlerandClaude Opus 5 20de58a78a fix(android/present): the panel grid can be wrong in both directions, and the margin listens to the latch
Three defects in the 0.23.0 timeline presenter, all found while root-causing the
field report that turned out to be the slice wire. None of them is that bug; all
three are real, and the first is the one that would still bite once it is fixed.

The panel-period learner could only ever narrow. It is seeded from the display
mode Kotlin asked for — and `preferredDisplayModeId` is a REQUEST the system may
refuse (Smooth Display off, battery saver, thermal, an OEM governor). Ask for
120 Hz on a panel that stays at 60 and the presenter pins an 8.33 ms grid on a
16.67 ms display with no way back, for the rest of the session: it then aims at
instants that never arrive and releases faster than the panel scans. The learner
moves both ways now, and lives in `punktfunk_core::phase::PanelGrid` where it is
host-testable and where the iOS and desktop presenters can share it. The
asymmetry is kept and made explicit — narrowing is immediate (a finer real grid
is always safe to subdivide onto, and it is the per-uid down-rate case the seed
most often gets wrong), widening needs eight consecutive agreeing observations
and then takes the narrowest of them, because one wide sample is a missed
callback and eight in a row is a display that really did slow down.

The glass budget was a prediction with nothing underneath it. `OnFrameRendered`
already reports what actually reached glass, but the budget never consulted it,
so a wrong grid could hand SurfaceFlinger frames indefinitely: BufferQueue fills,
MediaCodec runs out of output buffers, the decoder stalls, and the no-output
backstop starts begging for keyframes. Releases are now counted against their
confirms and the presenter holds back past six outstanding — loose on purpose,
since the callbacks are allowed to arrive batched and a held frame in the
newest-wins slot is a dropped one. It self-clears when the confirms catch up, and
writes the ledger off after the same 100 ms the stale reopen uses, so a platform
that stops confirming can never wedge the stream. `qWait` and `unconfirmed` join
the 1 Hz pf.present line, which is what would have made this visible from a log.

The adaptive latch margin widened on `paced_drops` — the newest-wins store's own
policy evictions, which happen whenever the stream out-runs the panel and say
nothing about SurfaceFlinger's latch lead. On a healthy device that walked the
margin to its 2.5 ms ceiling and re-imposed the display latency the P2e sweep had
just measured away. It now widens on the measured latch exceeding one panel
period plus the live margin, which is what a missed vsync actually looks like.

Also corrects two doc comments that named `display.refreshRate` as the panel_hz
source; it has been the mode table since the A024 down-rate fix.

Gates: 278 punktfunk-core lib tests (7 new PanelGrid cases incl. the refused-mode
regression), clippy -D warnings and fmt clean, cargo ndk check green on arm64 and
armv7. Android clippy reports the same 4 warnings as the base commit and no new
ones. NOT yet confirmed on glass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:15:45 +02:00
enricobuehlerandClaude Opus 5 97b2c01ac1 fix(core/packet): a slice-streamed frame costs its own size, not the whole frame ceiling
The 0.23.0 slice wire flushes a block every MIN_STREAM_BLOCK_SHARDS, so every
ordinary access unit is now opened by a SENTINEL — a header with no totals. The
reassembler sized those frames at `max_frame_bytes`, which the QUIC handshake
clamps to 8-64 MiB. That was survivable while sentinels were rare (the streamed
path emitted one only for an AU exceeding a whole FEC block, ~281 KB); it is not
survivable now that every frame is one.

Two consequences, both measured: each access unit allocated and ZEROED a
multi-megabyte buffer, and the in-flight budget (IN_FLIGHT_BUF_FACTOR x
max_frame_bytes) was spent after ~3 concurrent frames — with production geometry,
12 ordinary AUs in flight lost 9 of them outright, every packet dropped before it
could be placed. On a link with normal reorder that is a permanent loss storm:
frames never complete, the re-anchor gate freezes the picture, and the client begs
for keyframes. Only clients advertising VIDEO_CAP_MULTI_SLICE reach this path —
Android and the Linux/Windows session client; Apple and the Windows in-process
client never did, which is why it read as a platform-specific "video pipeline"
fault in the field.

A sentinel carries no total but does pin its own block's extent: a slice sentinel
by its wire base, a legacy one by its full-K position. Size the buffer to that and
grow as later blocks (or the final block's totals) reveal more. The budget is
re-checked on growth for the same reason it is checked at open.

The same flush also drained `pending` to empty whenever the AU's length was an
exact multiple of the shard payload, leaving `finish_streamed` to seal a final
block of one zero-padded FILLER shard. Its derived base overlapped the block
flushed a moment earlier, retro-validation correctly read that as a lying header,
and the whole AU died — one frame in every 1408 on a 1500-MTU link, ~12 s apart at
120 fps, each costing a freeze and a recovery keyframe. A flush now keeps one
whole shard back, restoring the invariant `StreamedAu::pending` already documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:15:45 +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
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 a7143a6510 Merge origin/main into audio/mic-latency-echo
Main moved through the same surfaces while the audio work was in flight, so
three files needed hand-resolution:

- clients/windows/src/app/settings.rs — main gave Windows its speaker and
  microphone endpoint pickers, the gap this branch could only report. Both
  keep their rows: the pickers, then Echo cancellation, and the microphone
  description keeps the sentence naming the mute chord.
- crates/pf-console-ui/src/screens/settings.rs — both sides grew the couch
  row list. Main's seven new rows and Echo cancellation are all reachable
  in Gaming Mode; the count is 22 and the rationale comment names echo
  cancellation among the fields that would otherwise be unreachable there.
- crates/pf-presenter/src/run.rs — both sides added a stats test at the same
  line. Both are kept.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:04:34 +02:00
enricobuehlerandClaude Fable 5 15392bd707 fix(core/client): the mic queue stops hoarding a second of your voice
MIC_QUEUE claimed 64 was ~320 ms of 5 ms frames; the frames were 20 ms, so
it really allowed 1.28 s — and since a full tokio mpsc can only refuse the
FRESH frame, one worker stall turned the whole backlog into permanent
standing mic latency. The queue shrinks to 12 and the pump's mic task now
sheds oldest-first past a ~60 ms backlog, so a stall costs a short dropout
and heals itself. New per-stage counters (sent / dropped-full /
dropped-stale) surface through NativeClient::mic_stats for the stats HUDs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:46:02 +02:00
enricobuehlerandClaude Fable 5 74863c96b3 feat(client): 4:4:4 can stay on hardware, and the overlay says what you actually got
Two halves of the same honesty problem (client-gaps handoff items 1+2,
done together as the handoff asked).

The presenter learns full chroma: `vkframe_plane_views` accepts the
2-plane 4:4:4 pool formats (8-bit, and the 10-bit 3PACK16 sibling) —
what NVIDIA's Vulkan Video reports for HEVC RExt decode — with the
accepted set extracted into `vkframe_plane_formats` and pinned by a
decision-table test. The CSC shader needed nothing: its 4:2:0 siting
correction already self-disables when the plane widths match. The VAAPI
leg gets the same treatment (NV24 in `drm_fourcc_for` and the dmabuf
import, full-size chroma plane), and the Vulkan decoder's sw-format
gate admits NV24/P410. 3-plane 4:4:4 stays rejected — it needs a third
CSC binding — and demotes cleanly like every other unsupported format.

Design call (the handoff's fork, argued here as requested): (B)+(C),
not (A). No capability probe gates VIDEO_CAP_444 — software decode is
the guaranteed display floor on both OSes (swscale → RGBA), the decoder
ladder demotes on its own, and a probe-gated bit would turn the switch
inert on exactly the boxes that rely on the fallback. What (A) wanted
from a prediction, the overlay now delivers as ground truth:

The Detailed tier prints the encoder's target next to the measured
rate — `19.4 Mb/s · target 20 Mb/s (auto)` — and the resolved chroma,
`4:4:4→4:2:0` when the host declined the ask (mirroring `HDR→SDR`).
The target is live: `NativeClient::current_bitrate_kbps()` mirrors
every BitrateChanged ack, so an Automatic session's ABR re-targets are
visible as they move. This is the figure whose absence let the
settings-drop bug (9c5af8d7) ship four releases — measured goodput
alone cannot distinguish "the encoder is capped at 20" from "my
200 Mb/s grant met a cheap scene".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:21:10 +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 86d4b36bfa chore(core): the Apple recv path documents its unsafe blocks
clippy -D warnings only lints this cfg(target_vendor = "apple") file on a
Mac, which CI never does — the three recv/recvmsg_x blocks predate the
undocumented_unsafe_blocks sweep and kept every local Apple-side clippy run
red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:41:15 +02:00
enricobuehlerandClaude Fable 5 f4f83202cb feat(core/client): an AU's prefix reaches the decoder while its tail is on the wire
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
ci / web (push) Successful in 1m13s
apple / swift (push) Successful in 1m21s
ci / rust (push) Failing after 2m14s
android / android (push) Canceled after 2m26s
apple / screenshots (push) Canceled after 58s
arch / build-publish (push) Canceled after 2m42s
ci / docs-site (push) Successful in 2m36s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
ci / rust-arm64 (push) Canceled after 2m47s
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 8s
deb / build-publish (push) Canceled after 2m50s
deb / build-publish-host (push) Canceled after 1m38s
deb / build-publish-client-arm64 (push) Canceled after 41s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
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) Canceled after 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 10s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 25s
release / apple (push) Canceled after 0s
windows-host / package (push) Canceled after 3m36s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
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 7s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Delivery used to be all-or-nothing: the decoder saw byte 0 only after the
last packet of the AU landed, so the whole transmit time sat in front of
decode. With the slice-streamed wire (previous commit) blocks now arrive
addressable, and a client can opt in (connect's new frame_parts) to
receive each AU's newly-contiguous prefix as Frame::part pieces - offset
tiling, first/last marked, the completing push carrying only the suffix.
A PARTIAL_FRAME-capable decoder then chews slices concurrently with the
remaining network transfer.

The reassembler walks a per-frame cursor over successfully-completed
blocks (failed FEC reconstructs don't advance it), coalesces blocks that
finished out of order into one part, keeps probe filler whole, and stops
short of the final block so the zero-padded tail still trims at
completion. Whole-frame consumers see byte-identical behavior - parts
never flow without the opt-in, and never on PyroWave (its newest-wins
draining assumes whole AUs).

Per-AU accounting keeps its units: OWD/ABR feeds, the inter-arrival
series and the clock-based jump-to-live detector only count completing
deliveries, and FrameChannel::depth() counts AUs so a part-rich queue
can't trip jump-to-live at a fraction of the real backlog. The consumer
contract (gap or orphan part = AU lost: abandon, flush, resync on the
next first) is documented on FramePart; the C ABI keeps parts off until
PunktfunkFrame can express them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:14: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 4457356ee4 test(phase-lock): a closed-loop simulation harness — both on-glass failures become CI regressions
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 1m14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 23s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 27s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
ci / rust-arm64 (push) Successful in 2m48s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 22s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 25s
deb / build-publish-client-arm64 (push) Successful in 2m48s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m1s
android / android (push) Successful in 5m31s
deb / build-publish-host (push) Successful in 6m5s
docker / builders-arm64cross (push) Successful in 18s
apple / swift (push) Successful in 4m41s
docker / deploy-docs (push) Successful in 46s
ci / rust (push) Successful in 6m45s
deb / build-publish (push) Successful in 5m9s
arch / build-publish (push) Successful in 9m4s
flatpak / build-publish (push) Successful in 6m54s
windows-host / package (push) Successful in 11m3s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 25s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m10s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m35s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m27s
release / apple (push) Successful in 25m17s
apple / screenshots (push) Successful in 21m0s
The controller iterated twice on the user's gaming PC before this existed;
never again. punktfunk_core::phase::circular_latch is extracted from the
Android presenter (shared: iOS reports next, and the harness generates its
synthetic reports through the IDENTICAL statistic the clients ship), with
unit tests including the wrap-straddling cluster an arithmetic mean gets
maximally wrong.

The stream.rs harness models the plant — latch = (base − hold + noise)
mod P, deterministic LCG noise — and drives PhaseController::adjust through
five regimes:
- tight jitter locks into the deadband within ~5 adjusts and stops moving;
- a wrap-side start takes the SHORTEST way (travel < P/2 asserted);
- an incoherent phase never steps (zero hold, ever);
- a v1 pinned-median report trips the travel budget and DECAYS TO ZERO —
  the 2026-07-31 orbit and the parked-hold e2e tax, both as asserts;
- a post-decay regime tightening re-locks.

Gates: host bin suite 344 passed / 1 failed = the documented environmental
qemu UDP-loopback test (clean main fails identically in this container);
core phase tests 4/4; clippy --all-targets -D warnings clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 11:18:30 +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 d014cea489 feat(core/abr): a keyframe-begging decoder backs the bitrate off, loss or no loss
The RX-9070 field trace: 14 decode-recovery keyframe requests in 2 s at
~300 Mbps with loss_ppm=0 — and the controller held the rate, because no
loss/OWD/latency signal moved. Repeated keyframe asks ARE the decoder
saying it cannot keep up; now they are an ABR input.

The control task counts outbound CtrlRequest::Keyframe at its send choke
point (the one place every emitter funnels through); the pump drains the
count per 750 ms report window into on_window. Two asks in a window is
ordinary-bad (two-window confirmation, like an OWD rise); four or more is
severe (the emitters throttle at 100 ms, so 4+ means the window was spent
begging) and backs off ×0.7 after one window. RFI asks are deliberately
not counted — they are the routine loss-recovery path and loss_ppm
already prices them in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +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 Opus 5 940bd0b7ec feat(core/abr): the startup capacity probe's target is configurable
ci / docs-site (push) Successful in 1m20s
ci / web (push) Successful in 1m31s
ci / rust-arm64 (push) Successful in 1m44s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 30s
apple / swift (push) Successful in 4m43s
android / android (push) Successful in 6m18s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
deb / build-publish-host (push) Successful in 6m14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
deb / build-publish (push) Successful in 5m16s
docker / builders-arm64cross (push) Successful in 12s
docker / deploy-docs (push) Successful in 39s
arch / build-publish (push) Successful in 9m18s
apple / screenshots (push) Canceled after 4m51s
ci / rust (push) Successful in 9m43s
deb / build-publish-client-arm64 (push) Canceled after 8m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 8m7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 8m5s
windows-host / package (push) Canceled after 10m19s
windows-host / winget-source (push) Canceled after 0s
flatpak / build-publish (push) Successful in 7m26s
release / apple (push) Canceled after 9m48s
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 3m23s
The Automatic startup probe bursts at a fixed 2 Gbps two seconds into every
session, deliberately far above any plausible link so the burst measures the
link rather than itself. On links the burst DISTURBS, that backfires: on an
LG G5 (webOS 10.3), three back-to-back connects to the same Gamescope host
split two ways — the two where the probe finished in ~1-2 s had video within
2-4 s, while the one that hit the 6 s timeout showed no video for 14 s. Even
a "successful" probe on that link reported send_dropped=20211. The webOS
client already caps its own speed test at 320 Mbps because an unbounded
firehose starves a 2-3 core TV; core then bursts the same hardware at 2 Gbps.

PUNKTFUNK_ABR_PROBE_KBPS now sets that target, so an embedder that caps its
own speed test can cap ours to match. Unset, zero, or unparseable keeps the
2 Gbps default, so every existing session behaves exactly as before. The
existing opt-out (PUNKTFUNK_ABR_PROBE=0) is no substitute: it leaves the
climb ceiling pinned at the negotiated ~20 Mbps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:24:57 +02:00
enricobuehlerandClaude Fable 5 9b2404a580 feat(core): every connect introduces the device by name
The wire always had Hello.name and the host always honored it - but the
connect path hardcoded None (only the PIN-pairing ceremony sent a name),
so every no-PIN "request access" knock surfaced as the fingerprint
placeholder "device abcd1234", and approving one without retyping a
name persisted that placeholder into the trust store forever.

NativeClient::connect now takes the device name. The session workers
and the probe connects pass trust::device_name() (the hostname), the C
ABI defaults to the same without a signature change (an ex10 variant
can make it explicit if an embedder wants a custom label), and Android
threads Build.MODEL through nativeConnect - the same convention its
pairing dialogs already use for nativePair.

The host, in turn, resolves the streaming client's display name (trust
store first, so an approval-time rename wins; else the sanitized Hello
name) and exposes it as client_name in GET /api/v1/local/summary for
the tray's connect toast - a deliberate, documented loosening of that
route's "no device names" contract, in the local user's favor: it tells
them who is on their machine. A paired-but-idle device's name still
never appears, which the mgmt tests now pin explicitly. openapi.json,
its docs-site copy, and the SDK bindings regenerate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:32:50 +02:00
enricobuehler bcfb833ff7 fix(abi): the panic boundary was documented as universal and wasn't
`abi.rs`'s header states "panics never cross the boundary: every entry point is wrapped in
`catch_unwind`". Of its 78 `extern "C"` entry points, 16 were not. Ten of those are fine and were
always fine — `punktfunk_abi_version` returns a constant, and the nine `punktfunk_connect*` shims
forward every argument unchanged into a guarded implementation — but five ran real code bare:
`session_free`, `connection_close`, `connection_disconnect_quit`, `reanchor_gate_free`,
`reanchor_gate_arm`.

The three `*_free`/`close` ones are the point. They run `Drop` for an entire `Session` or
`Connection` — transports, threads, mutexes — and a `Drop` impl that unwraps a poisoned lock panics.
Since Rust 1.81 that unwind is a hard abort rather than undefined behaviour, so this is not a
soundness hole; it is worse-behaved than it looks. Aborting the CALLER'S process because one of our
teardown paths hit a poisoned mutex is not an acceptable failure mode for a library, and it would
present as "the app died in punktfunk_session_free" with no Rust backtrace to explain it.

Adds `guard_void`, the sibling of `guard` for entry points with no status to report through: it
catches, logs, and returns — right for teardown, where the object is going away regardless. The five
are wrapped in it.

The header now says what is actually true, including WHICH entry points are deliberately bare and
why, so the next reader can check the claim instead of trusting it. That is the same failure this
sweep found in `MappedView`'s `Sync` proof: a stated invariant that a reviewer would rely on and that
had quietly stopped holding.

Verified: Linux .21 fmt + both CI clippy steps rc=0, and the C ABI harness — which exercises the
`*_free`/`close` paths it touches — still passes, 4 frames byte-exact through lossy loopback.
2026-07-29 08:48:42 +02:00
enricobuehler aa070f2d55 feat(ffi): hand-mirrored C structs are now layout-checked at compile time
The sharpest memory-safety risk left in this codebase is not an `unsafe` block — it is a
hand-written `#[repr(C)]` mirror of an external C struct. Get a field offset wrong and nothing fails
to compile and nothing reliably crashes: the library reads a pointer, a length or a pitch out of the
wrong bytes. Eleven such structs across five files had NO check at all.

Guarded here, each next to the struct it protects:

* `AVCUDADeviceContext`, and `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext`. `ffmpeg-sys-next`
  binds none of them, so these mirrors are the only definitions — and we WRITE through them
  (`cuda_ctx`, `device`, `bind_flags`). ⚠ The D3D11VA pair is duplicated VERBATIM in two crates
  (pf-encode's `ffmpeg_win.rs`, pf-client-core's `video_d3d11.rs`) because neither can depend on the
  other; they must agree with libav and with each other, and now a drift in either is a build error.
* The six cuda.h structs. Three were already asserted — but only in `#[cfg(test)]`, so the check ran
  when someone ran the tests and never in a release build. They are `const` now. The other three,
  including `CUDA_MEMCPY2D` which is filled on EVERY zero-copy frame, had nothing.
* `MsghdrX`, Darwin's `msghdr_x`, which `libc` does not expose. Its layout is not reviewable by eye:
  the 32-bit fields force padding before each following pointer, so `msg_iov` sits at 16 and not 12.
  `sendmsg_x`/`recvmsg_x` take the pointer and length from it.
* `IPolicyConfigVtbl` — the sharpest of the set. It mirrors an UNDOCUMENTED COM interface, and
  `set_default_endpoint` is called by SLOT INDEX through a ten-entry `_reserved` gap that carries no
  names to anchor a review. A field added or resized above it does not break the build; it calls a
  different function pointer through a mismatched signature.

Every assertion is `const _: () = assert!(..)`, so it holds on every build including release and
cannot be skipped. The compiler verified the numbers — the sizes and offsets asserted here are the
ones the target actually produces, on each platform that compiles the struct.

Verified: Linux .21 fmt + both CI clippy steps rc=0 (CUDA + libav CUDA mirrors); Windows .47 full CI
clippy set rc=0 + pf-capture tests (D3D11VA pair, COM vtable); macOS `cargo check -p punktfunk-core`
(MsghdrX — the only platform that compiles it).
2026-07-29 08:48:42 +02:00
enricobuehler 65cd388a52 fix(presenter,core): close the last of the proof-lint hole — the Vulkan contract, stated once
pf-presenter's 120 sites are `ash` calls almost without exception, so 120 independent arguments
would have been 120 restatements of the signature — the exact noise this program exists to remove.
They get the `abi.rs` treatment instead: the Vulkan contract stated once in `lib.rs`, each site
naming which of three shapes it is.

The three are not equal, and separating them is the point. CREATE and RECORD carry no real
precondition — the device is owned, the builders are locals, nothing executes until submit. DESTROY
does: the GPU must not still be using the object, and that is established by the path (a fence wait,
a `queue_wait_idle`, a retired swapchain), not by the call. Those sites say so, because getting it
wrong is a use-after-free no type catches. The contract also tells the next person that a block
outside the three shapes needs a real proof, and that writing "as above" is the signal it doesn't
belong in them.

punktfunk-core's Windows half is finished here too: `qos_windows.rs`'s `GetLastError` reads (called
before anything can reset the thread's error slot) and `udp/windows.rs`'s control-message write,
whose argument is that `ctrl` is sized by `WSA_CMSG_SPACE(4)` — computed two lines up — so header
plus payload cannot run past it, and `write_unaligned` is used because `WSA_CMSG_DATA` offers no
alignment guarantee.

⚠️ THE WINDOWS BLIND SPOT BIT A THIRD TIME. A Linux measurement put this crate pair at 113; the
real number was 129 — `d3d11.rs`, `win32.rs`, `qos_windows.rs`, `udp/windows.rs` are all
`cfg`-hidden. Every crate in this sweep had to be finished on .47 after being "done" on .21. For a
cross-platform crate the Linux number is a lower bound, never the answer.

All three crates now deny `undocumented_unsafe_blocks`, which was the goal: it applied to 8 of 11
crates carrying unsafe, and the three exempt ones held 381 items between them — including the C ABI
surface and the presenter. Verified: Linux .21 fmt + both CI clippy steps rc=0; Windows .47 all four
closed crates clippy `-D warnings` rc=0 plus the full Windows CI clippy set and pf-capture's tests.
Only small crates remain unguarded (75 items total, largest 26).
2026-07-29 08:48:42 +02:00
enricobuehler dda9ed1aa2 fix(core): close the proof-lint hole in punktfunk-core — the ABI contract, stated once
146 sites, 141 of them in `abi.rs`, the `extern "C"` surface `cbindgen` turns into
`punktfunk_core.h`. Writing 141 independent arguments would have been the wrong answer and would
have read like it: they are instances of ONE contract in six shapes — opaque handles reached only
through `as_mut()`/`as_ref()` (so null becomes a status, never a dereference), caller-owned
out-params (null-checked exactly where the header calls them optional), NUL-terminated-or-null C
strings through `opt_cstr`, unchanged forwarding to a versioned entry point, fixed-length output
buffers, and `addr_of!` reads.

So the contract is stated once at the top of the file, the way `pf-win-display`'s CCD contract is,
and each site says which instance it is. Two properties that hold everywhere are stated there and
not repeated 141 times: no pointer is retained past the call that received it, and every entry point
runs inside `guard`'s `catch_unwind`, so a panic becomes a status code instead of unwinding into C.

The `addr_of!` sites got a real proof rather than a shape, because that one is load-bearing and
non-obvious: it forms a raw pointer WITHOUT creating a reference precisely because the caller's
struct may be an older, smaller version, so the field must be read by offset and not through a `&`.

`transport/udp/linux.rs`'s five are genuinely individual — two `zeroed()` POD initialisations, and
the `sendmmsg`/`recvmmsg`/`CMSG` trio, where the argument worth writing down is that
`msg_controllen` is set to `CMSG_SPACE(size_of::<u16>())` and the `CmsgBuf` is 64 bytes, so the
kernel cannot write past the control buffer.

punktfunk-core now denies `undocumented_unsafe_blocks`. Verified: Linux .21 fmt + both CI clippy
steps rc=0, and — since this is the C surface and comments alone should not change it — the C ABI
harness still passes, 4 frames round-tripped byte-exact through lossy loopback.
2026-07-29 08:48:42 +02:00
enricobuehlerandClaude Fable 5 d8d8c6c43d fix(core/abr): the capacity probe's own overload no longer reads as session congestion
The startup speed-test probe deliberately overloads the link (2 Gb/s for
800 ms) to measure capacity. The report tick is suppressed while it runs
and the window anchors are rebased when it ends — but two residues leak
past the rebase: probe frames still pending in the reassembler age out as
frames_dropped for another ~120 ms (the loss-window fuse), and the burst
can latch flush_in_window (a jump-to-live it caused itself). Either one
reads as a SEVERE window: the controller backs off ×0.7 and — worse —
slow start ends at the first congestion signal, permanently.

That is precisely the 2026-07 field report's Automatic session: 20→14
Mb/s exactly one report tick after the probe, then a purely additive
+6 %/4.5 s crawl that took the whole 4.5-minute match to reach 308 Mb/s
on a link the probe had just measured at ~1.2 Gb/s. Slow start (doubling
per clean window to the probed ceiling, 'seconds instead of minutes')
never got to run.

The first post-probe report window is now discarded outright: no
LossReport (probe residue would also spike the host's adaptive FEC), the
standing-latency detector closes it as not-loss-free (its clean-run just
resets), and the ABR is fed nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehlerandClaude Fable 5 b8b8ac336c feat(core/client): all-intra streams drain the frame channel to the newest AU
The pre-decode FrameChannel is strictly FIFO because H.26x reference
chains forbid mid-stream drops — falling behind is only recoverable via
the coarse jump-to-live thresholds (depth ≥6 sustained, or 400 ms behind
the capture clock) at the cost of a keyframe round-trip.

PyroWave has no reference chains: every AU decodes independently, so a
slow consumer can skip straight to the newest queued AU with zero
recovery cost. The pump now flags the channel all-intra for PyroWave
sessions and pop() drains to the newest, which caps any standing
pre-decode queue at ~1 frame structurally — the 2026-07 field report's
780M client could otherwise ratchet a real multi-frame backlog (and with
it the OSD latency) between those coarse thresholds.

Skips are counted separately from losses (they were delivered, just
superseded) and surfaced at debug on the report tick — the OSD 'lost'
line keeps meaning wire loss.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +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 5 36e566052f fix(apple/cursor): a pointer whose bitmap has not landed must not vanish
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m5s
ci / bench (push) Successful in 7m30s
apple / swift (push) Successful in 6m42s
ci / rust-arm64 (push) Successful in 10m2s
decky / build-publish (push) Successful in 27s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
deb / build-publish-host (push) Successful in 10m7s
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 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
android / android (push) Successful in 12m14s
deb / build-publish (push) Successful in 12m47s
windows-host / package (push) Successful in 16m3s
docker / build-push-arm64cross (push) Successful in 24s
docker / deploy-docs (push) Successful in 50s
arch / build-publish (push) Successful in 18m4s
deb / build-publish-client-arm64 (push) Successful in 10m45s
flatpak / build-publish (push) Successful in 7m12s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m25s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m7s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m17s
ci / rust (push) Successful in 28m53s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m45s
release / apple (push) Successful in 33m5s
apple / screenshots (push) Successful in 24m18s
`resetCursorRects` wore the host shape only when `hostCursors[st.serial]` hit,
and fell through to `invisibleCursor` on a miss. But a miss is the ROUTINE case,
not a degenerate one, and it is not a reason to hide the pointer:

  * State (0xD0) is a per-frame datagram and announces the new serial the moment
    the host QUEUES the bitmap on the reliable control stream, so on every single
    shape change the client knows a serial before it holds the pixels.
  * The shape ring drops the NEWEST under burst (CURSOR_SHAPE_QUEUE = 8) and the
    host never re-sends it — it only sends on a serial CHANGE — so a dropped
    serial stays un-backed until the pointer next changes shape. Crossing a
    toolbar flips arrow/I-beam/hand/resize several times a second, and each flip
    mints a fresh serial and a fresh bitmap, so bursts are ordinary.

Either way the pointer BLINKED OUT rather than lagging, and in the dropped case
it stayed gone for as long as the pointer held that shape — reported on glass as
"the I-beam never appears over text fields, every other cursor is fine".

Hold the last worn shape across the gap: only `st.visible == false` (the host
says the pointer is hidden) may hide it now, never a missing bitmap. Worst case
is a briefly stale pointer. This also makes two comments that already claimed
this behaviour true — the shape-rejected warning's "keeping the previous cursor"
and CURSOR_SHAPE_QUEUE's healing claim, both of which were fiction.

Host side is exonerated: on .221 the poller sees the I-beam handle (0x10005,
CURSOR_SHOWING set), and the monochrome AND-over-XOR conversion renders a correct
glyph — black beam, white outline — so the bitmap that goes on the wire is good.

swift build PunktfunkKit + cargo check punktfunk-core green; on-glass owed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:13:08 +02:00
enricobuehlerandClaude Opus 5 ab3884c763 fix(core/abi): cast identity PEM pointers with .cast(), not as *mut u8
`c_char` is i8 on x86_64 and u8 on aarch64, so `cert_pem_out as *mut u8`
is a REQUIRED conversion on one target and a no-op on the other — where
clippy::unnecessary_cast then denies it. `.cast::<u8>()` is correct and
lint-clean on both.

Caught by the new aarch64 clippy leg on its first run. A sweep of the
remaining `as *mut u8` / `as *const u8` sites in the client crates found no
other c_char-derived casts; the rest convert from c_void or u16.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 14:32:45 +02:00
enricobuehlerandClaude Fable 5 f3c3a9427b feat(client/abr): learn the host's rate cap from short acks, and read host encode time as a signal
Two client-side halves of the §ABR-overdrive fix (the 4K120 sessions that
climbed to 1 Gbps against a 794 Mbps encoder and a 8.33 ms budget):

- Short-ack cap learning: the host now resolves a climb it can't serve to what
  the encoder actually runs at (its codec-level ceiling, or the current rate
  while encode is behind cadence). Two consecutive identical short acks latch
  that value as a host cap the climb logic folds into its ceiling — one short
  ack stays a transient (a failed rebuild also acks short once). The cap is
  mode-scoped (cleared on an accepted mode switch, tracked via a mode
  generation counter the control task bumps) and re-probes one step after ~60 s
  parked clean, so a heavy-scene refusal can't quietly cap the whole session.

- Host-encode-latency down-driver: the per-AU 0xCF `encode_us` the host
  already ships (and the overlay already draws) now feeds the controller
  through its own window accumulator — the overlay channel is lossy and
  embedder-drained, so the ABR gets a dedicated mirror of the decode-latency
  path. Baseline-relative like the decode signal (an escalated host reports
  encode_us inflated by ~a frame of queue depth; an absolute budget threshold
  would read permanently red), with the baseline rebased after our own
  decreases so one backoff doesn't train-fire into the floor. This is the only
  signal that can push an already-too-high rate back under the encoder's
  compute knee — host climb refusal stops the climb, but nothing else descends
  on a clean LAN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:44:54 +02:00
enricobuehlerandClaude Fable 5 cc8cccbee6 fix(core/client): drop two redundant glob imports that warned on tvOS
apple / swift (push) Successful in 1m18s
decky / build-publish (push) Successful in 35s
release / apple (push) Successful in 9m19s
ci / web (push) Successful in 1m12s
ci / docs-site (push) Successful in 1m21s
android / android (push) Successful in 12m19s
arch / build-publish (push) Successful in 12m19s
ci / bench (push) Successful in 6m36s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
apple / screenshots (push) Successful in 6m35s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
windows-host / package (push) Successful in 9m44s
deb / build-publish (push) Failing after 4m47s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m52s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m25s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m14s
deb / build-publish-host (push) Successful in 9m39s
flatpak / build-publish (push) Failing after 8m25s
ci / rust (push) Successful in 23m58s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m36s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m37s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m13s
docker / deploy-docs (push) Successful in 12s
`punktfunk-core` emitted `unused import: super::super::*` for
client/pump/handshake.rs and client/pump/input_task.rs — but only when
built for a tvOS target, where the cfg set leaves the glob contributing
nothing. Nobody saw them because CI never builds tvOS: that needs
BUILD_TVOS=1 and the Tier-3 build-std nightly path, and the Apple
workflow only runs swift build/test (macOS).

Both files also `use super::*`, i.e. the parent `pump` module, which
itself globs `super::*` (`client`) unconditionally — so every name stays
reachable by that path and the inner glob was pure duplication. Their
sibling datagram_task.rs already imports only `use super::*`, so this
also makes the module consistent. control_task.rs and data.rs keep
theirs: those genuinely resolve names through it and never warned.

Verified warning-free AND compiling on every slice the xcframework
ships — aarch64/x86_64-apple-darwin, aarch64-apple-ios,
aarch64-apple-ios-sim, aarch64-apple-tvos, aarch64-apple-tvos-sim,
x86_64-apple-tvos — plus Linux with and without the `quic` feature. A
full xcframework rebuild now reports 0 warnings (was 6: two per tvOS
slice), and swift build, swift test (170 tests), and xcodebuild for the
macOS, iOS and tvOS schemes all pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 02:11:49 +02:00
enricobuehlerandClaude Fable 5 ac3dc4323f fix(native/session): make the stop flag enforceable so a session can't outlive its client
Native sessions could survive long after the client was gone, in two
independent ways.

HOST: `stop` was only advisory. The one thing that ends a session is the
stream thread returning — every teardown (conn.close, the joins, and the
RAII drops of the session permit, admission entry and stream marker)
sits after that await, and nothing forced the issue. The encode loop
checks `stop` between iterations, so any unbounded call INSIDE one never
reaches the check, and one stuck syscall became a permanent zombie: it
held its semaphore slot (four of those and the host stops accepting
QUIC entirely), its admission entry (a later client gets "host busy"
forever), and not even the console's Stop button could clear it — that
button sets this same flag.

  * Bound the wait: once the session has been told to stop, the thread
    gets STREAM_STOP_GRACE (90 s, well past the 40 s capture-rebuild
    budget) to return, then teardown runs anyway. The thread is detached,
    not killed — Rust can't cancel a blocking thread — so it keeps its
    capturer/encoder until the stuck call returns, but the session's slot
    and admission entry come back and the host keeps serving. It logs at
    ERROR as the host wedge it is.
  * Bound the audio/input joins too — the last unbounded await in
    teardown.
  * Take the session permit AFTER the QUIC handshake instead of before
    `accept()`, so a host at its concurrency cap still accepts and the
    waiting client sees a live path instead of a silent dial timeout.
  * Bound the compositor helpers that caused the wedge in the first
    place: new pf-vdisplay `proc::{status_within, output_within}` kill a
    child that outlives its budget. `kscreen-doctor` is a Wayland client
    of the very compositor it configures, so against a wedged KWin it
    never returned; same for systemctl/dbus against a stuck session bus.

CLIENTS: the connection was never closed, so the host was right to keep
the session — it still had a live, keep-alive-answering peer.

  * Android: backgrounding did no teardown at all, and Android doesn't
    suspend the process, so the worker kept answering keep-alives until
    the OS reclaimed it (on a TV box, never). End the session on ON_STOP,
    via the existing onDispose path; a plain close, not a quit, so the
    host lingers the display for a fast return.
  * Apple: the .background arm was iOS-only AND gated on an opt-in that
    defaults off, so backgrounding did nothing — while the `audio`
    background mode kept the app (and its connection) alive indefinitely.
    Act unconditionally, and cover tvOS.
  * Core: `conn.close()` only queues the frame, and run_pump is the body
    of a block_on whose runtime is dropped the instant it returns, so the
    driver could never put it on the wire — a deliberate quit reached the
    host as silence (8 s idle timeout, no quit code, and the linger meant
    for an unwanted disconnect). Carry the endpoint out of the handshake
    and flush with wait_idle(), the same discipline the pairing and probe
    paths already use.

Linux check/clippy/tests green: 262 host, 71 pf-vdisplay (incl. new
bounded-process tests), 231 core.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 02:11:49 +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 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 256aa5f7f2 fix(steamdeck): install the plugin runner + harden Deck pad typing + repair update.sh
- scripts/steamdeck/install.sh (§2b) + update.sh: build and install the
  scripting runner user-scoped (~/.local wrapper + pinned bun + bundle) —
  SteamOS's read-only /usr can't take the .deb layout, so the console's
  plugin store reported "the plugin runner isn't installed" on every
  SteamOS host. update.sh retrofits it onto existing installs.
- plugins.rs runner discovery: check the ~/.local layout after the /usr
  ones; mention the SteamOS path in the not-installed error.
- update.sh: define warn() — it was used but never defined, so under
  `set -e` the first warn aborted the update before services restarted.
- pf-client-core gamepad: a Steam Input virtual pad forwarded on a real
  Deck (the only-pad case) now declares the DECK kind in its per-pad
  arrival instead of the wrapper's Xbox 360 identity — the session-level
  auto_pref already resolved SteamDeck, but the arrival overrides it on
  current hosts.
- reject tests: cover SetupFailed; move the foreign-code probe off 0x68.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 10:01:54 +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 56d21f9445 fix(client/wol): retry the QUIC dial across the connect budget so wake-from-sleep launches survive
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m3s
apple / swift (push) Successful in 1m19s
decky / build-publish (push) Successful in 40s
ci / bench (push) Successful in 6m51s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 18s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7m6s
deb / build-publish (push) Successful in 9m54s
release / apple (push) Successful in 9m5s
deb / build-publish-host (push) Successful in 9m35s
flatpak / build-publish (push) Failing after 8m19s
android / android (push) Successful in 16m57s
apple / screenshots (push) Successful in 6m29s
arch / build-publish (push) Successful in 17m35s
windows-host / package (push) Successful in 18m46s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (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 / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
docker / deploy-docs (push) Successful in 26s
ci / rust (push) Successful in 25m24s
Launching from the Decky plugin against a sleeping host fired the
Wake-on-LAN packet and dialed immediately — but the dial was a single
quinn attempt that gave up after the transport's 8 s idle window, far
shorter than a suspend-to-RAM resume. The host woke to find the client
already gone (its retransmitted Initials show up host-side as
"QUIC accept failed error=timed out"), the stream shortcut exited, and
the launch looked cancelled.

- punktfunk-core: dial in a loop until the embedder's connect budget is
  spent — short 3 s attempts keep the Initial cadence dense, so the
  connect lands within ~a second of the host's network returning. Only
  silence is retried: pin mismatches, typed rejections, and any real
  answer surface immediately, and the shutdown flag stops the loop.
- decky: stretch the budget to 75 s when a magic packet actually went
  out (PF_CONNECT_TIMEOUT → --connect-timeout via the wrapper), so the
  retry window covers the resume; an awake host still connects in <1 s.
- host: a clean close before the control handshake is a reachability
  probe (hosts-page online pips), not a failed session — log it at
  debug instead of WARN "session ended with error", which buried the
  real failures in the reporter's log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:26:10 +02:00
enricobuehlerandClaude Fable 5 3736bbdc73 fix(core): streamed-AU hardening from the security-review pass
apple / swift (push) Successful in 1m19s
docker / deploy-docs (push) Has been cancelled
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
release / apple (push) Successful in 8m41s
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (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
decky / build-publish (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-noble.Dockerfile, punktfunk-rust-ci-noble) (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 / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-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
Review verdict: no exploitable memory-safety / amplification / splice /
resurrection issue; nonce order and two-lane seal byte-identical for the
legacy path. Actionable findings applied:
- explicit per-block data_shards cross-check next to the recovery_shards
  one (defense-in-depth — the geometry invariants make it unreachable
  today, but have_data indexing assumes it)
- partial delivery skips an UNPINNED streamed frame (frame_bytes still 0)
  instead of truncating its max-sized buffer to an empty "partial"
- regression tests for the two untested load-bearing guards: the
  final-first out-of-range-sentinel drop (the exact-sized-buffer guard)
  and second-final-with-different-totals rejection

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 23:13:59 +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 76ac3cf867 fix(core): five sweep lows — seal-lane fallback, flush partial, codec echo, idle clamp, pair-name UTF-8
From the 2026-07-20 punktfunk-core quality sweep's adjudicated lows
(~/punktfunk-sweeps/punktfunk-core-2026-07-20.json), each with a
regression test:

- session: the two-lane seal's dead-worker fallback dropped the frame's
  back half and returned Ok with half an access unit; the corpse lane
  was also never respawned. The send-failure arm now reclaims the tail
  (single-lane seals the WHOLE frame) and both failure arms drop the
  lane so the next large frame respawns it. A recv-side death now
  surfaces as an error instead of silent truncation.
- reassemble: Reassembler::reset() left pending_partial parked, so a
  pre-flush stale partial survived Session::flush_backlog and was
  delivered as the first "frame" after a jump-to-live.
- caps: resolve_codec echoed a non-conformant multi-bit preferred byte
  verbatim (downstream from_wire folds it to HEVC — possibly outside
  the shared set). It now isolates one bit of the intersection.
- endpoint: stream_transport_idle only floored the value; an absurd
  operator-supplied idle timeout blew past QUIC's VarInt ms ceiling and
  panicked host startup through the expect. Clamped to 1s..1h.
- pairing: PairRequest::encode cut the device name at a raw byte-64
  boundary, splitting multi-byte UTF-8 (host showed U+FFFD forever);
  it now shares Hello's char-boundary truncate_to.

The frozen-FEC-ceiling finding (reassemble.rs:109) was already fixed on
main by the sweep's high-severity commit — skipped as stale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:16:02 +02:00
enricobuehlerandClaude Fable 5 f4ec18d528 chore(core): fix build.rs + crate-doc drift left by the W7/W8 splits
- build.rs: drop the stale `rerun-if-changed=src/client.rs` (the file
  became client/ in W7; the path never fires).
- lib.rs: the crate doc's module list named 6 of ~19 modules — add the
  missing ones (config/error/stats/input/reject/reanchor/render_scale/
  audio/wol + the quic-gated control-plane group), without intra-doc
  links to feature-gated modules.
- quic/mod.rs: the module doc still described the deleted `msgs`
  module; list the actual post-split module set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:08:39 +02:00
enricobuehlerandClaude Fable 5 ce51a2ba74 refactor(core): decompose the client run_pump monolith into pump/ submodules
client/pump.rs was a single 1192-line async fn stacking six concerns.
It is now a 190-line orchestrator (same spawn order, same channel
wiring) over five focused modules:

- pump/handshake.rs   — connect + Hello/Welcome/Start + skew handshake +
                        hole punch + Session construction (HandshakeOut)
- pump/input_task.rs  — the gamepad snapshot/removal/arrival re-send
                        state machine + passthrough input forwarding
- pump/control_task.rs— the control-stream select loop (renegotiation,
                        probes, bitrate acks, clock re-sync, clip
                        metadata), bundled as ControlTask
- pump/datagram_task.rs — the datagram tag demux + rumble reorder gate
- pump/data.rs        — the blocking data-plane pump (loss reports, ABR
                        + capacity probe, jump-to-live detectors,
                        standing-latency bleed), bundled as DataPump

Every body moved verbatim (dedent + arg-struct destructures aliasing
the original binding names); no per-frame indirection added — the pump
loop, its locals, and the hot-path shape are byte-equivalent. The
Ctrl/DataPump arg structs exist only to stay under too_many_arguments.

196 lib tests pass; clippy clean on --features quic AND
--no-default-features (--all-targets); include/punktfunk_core.h
byte-identical; fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:07:14 +02:00
enricobuehlerandClaude Fable 5 eacaaa5cd8 refactor(core): peel session.rs anti-replay, perf telemetry, and seal lane into submodules
session.rs (1203) sharpens down to the two hot-path state machines +
lifecycle (887): ReplayWindow + seq_of + their tests -> session/replay.rs;
the PUNKTFUNK_PERF trio PumpPerf/SealPerf/TimedCoder -> session/perf.rs;
the Phase-1.5 lane machinery SealLane/SealJob/seal_wire_slice/
TWO_LANE_MIN_PACKETS -> session/seal.rs. Facade pattern (session.rs stays
the parent file); pub use keeps session::{PumpPerf,SealPerf} stable and
lib.rs re-exports are untouched. Pure code motion + pub(super) bumps —
seal_frame_inner/poll_frame/poll_input bodies unchanged; the
wire-equivalence tests stay co-located with the seal path they pin.

196 lib tests pass, clippy --features quic --all-targets clean, fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:56:26 +02:00
enricobuehlerandClaude Fable 5 e0a1edb9d4 refactor(core): co-locate the quic wire tests with their modules
quic/tests.rs (1813 lines, 43 tests) was the W7 split's leftover: the
source moved into handshake/caps/control/clock/pairing/pake/datagram/
endpoint/clipstream/io but every test stayed in one monolithic file.
Each test now lives in a #[cfg(test)] mod tests at the foot of the
module it exercises, verbatim. The two CompositorPref/GamepadPref
wire/name tests moved to config.rs (where those enums live), so they
now also run under --no-default-features. The clip_loopback and
ctrl_framing integration mods share connect_pair via a cfg(test)-only
quic/test_util.rs.

Test-only motion: 196 lib tests pass unchanged on macOS, clippy
--features quic --all-targets clean, include/punktfunk_core.h
byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:53:36 +02:00