Commit Graph
126 Commits
Author SHA1 Message Date
enricobuehler 3cc8fa7ee0 feat(core): the host tells the client where its library is, so mDNS is no longer required
ci / rust (pull_request) Failing after 10m40s
ci / rust-arm64 (pull_request) Failing after 31s
android / android (pull_request) Failing after 1m9s
ci / docs-site (pull_request) Successful in 1m23s
apple / swift (pull_request) Successful in 2m4s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Failing after 1m47s
ci / bun-nix (pull_request) Successful in 2m23s
ci / web (pull_request) Successful in 5m45s
nix / flake (pull_request) Successful in 13m46s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Failing after 1m47s
ABI 19 -> 20. Wire protocol unchanged (still 2).

Persisting the mgmt port (fe2bfeca) made a moved port survive mDNS going away, but mDNS was still
the only SOURCE: a host that had never been seen on it — VPN-only, a routed subnet, or simply added
by address on a network where multicast has never worked — had nothing to learn from and fell back
to 47990. The `Welcome` now carries the port, so the client learns it over the connection it has
already authenticated and discovery stops being involved at all.

`Welcome.mgmt_port`, a trailing u16 after the cipher block, following the same additive discipline
as the eight fields before it (compositor, gamepad, bitrate_kbps, bit_depth, color, chroma_format,
audio_channels, codec): an older peer stops earlier and gets a documented default, in both
directions, so WIRE_VERSION does not move.

⚠ THE TRAP, and why emitting the port forces the `cipher` placeholder: `cipher` is emitted only
when non-default, so appending the port to an AES Welcome would land its LOW BYTE at offset 68 —
exactly where every shipped 0.28.x client reads `cipher`, whose decode is deliberately fail-closed
on an unknown id. 47991 is 0xBB57, so byte 68 would read 0x57 = 87, and EVERY current client would
fail the handshake against a host that had merely moved its mgmt port. `encode` therefore writes an
explicit cipher byte whenever a port rides along (the placeholder discipline `Hello::encode`
already uses); a current client reads AES, a pre-cipher client stops before 68 regardless. The test
pins the byte, both offsets (69 AES / 101 ChaCha), and that a host advertising no port still emits
exactly 68 bytes — this field costs the common case nothing.

Host: `mgmt::effective_port()` reads the same resolved bind `publish_endpoint` writes, so the wire,
the endpoint file and the mDNS TXT cannot disagree — one lookup, not a fourth place to compute a
port. `0` on the standalone punktfunk1-host binary, which has no management API: advertising 47990
from a host that is not serving it would be worse than saying nothing.

Clients persist it on connect, feeding the store plumbing fe2bfeca already built:
* Rust — `on_connected` grew the port alongside the fingerprint, plus `learn_mgmt_port_by_fp`
  (keyed by fingerprint alone, the identity a just-connected client is certain of).
* Apple — `PunktfunkConnection.hostMgmtPort` + `updateMgmtPort` at the existing markConnected site.
* Android — a new `nativeHostMgmtPort` JNI call, persisted where the session is constructed.

Verified: Linux (pf-lxcheck2, amd64) `cargo check --all-targets` clean across punktfunk-core,
pf-host-config, punktfunk-host, pf-client-core, pf-presenter, punktfunk-cli, punktfunk-client-linux
and punktfunk-client-session, each confirmed genuinely compiled (counting `Compiling` as well as
`Checking` — cargo prints the former for bin-only packages, which is what made an earlier gate look
vacuous when it was not). punktfunk-core quic tests 76/76. Android: :kit+:app Kotlin, ParseRecordTest
12/12, and cargoNdkClippy clean for aarch64-linux-android. Apple: xcframework rebuilt at ABI 20,
`swift build` complete. cargo fmt --all --check clean. NOT verified: the Windows client
(192.168.1.133 unreachable).
2026-08-14 19:44:19 +02:00
enricobuehler 99eb679c07 feat(clients): a moved mgmt port now outlives the advert that announced it
Moving the mgmt port off 47990 (the fix for sharing a box with a Sunshine fork, whose web UI owns
that port) only ever worked for as long as mDNS did. The real port lived in the advert and nowhere
else: every client read it live and threw it away, so on a VPN, a routed subnet, or any
multicast-dead network the library silently fell back to a port nothing was listening on.

`KnownHost` gains `mgmt_port: Option<u16>` + `effective_mgmt_port()` + `learn_mgmt_port()`, exactly
the shape `mac` and `os` already use ("learned from the advert while online, persisted so it
survives the host going to sleep") — except this one is load-bearing rather than cosmetic, so
`upsert` states the preserve rule explicitly instead of relying on the does-not-mention-it accident
that `clipboard_sync` survives by, and `upsert_trusted` carries it across a re-key.

Wired through all four client families, each of which was wrong in its own way:

* CLI / Windows / Linux reached for `DEFAULT_MGMT_PORT` at the call site — the constant is the
  FALLBACK, not the answer. Windows also needed the port on `Target`, which the library screen has
  instead of a `KnownHost`.
* The session console read `advert.and_then(mgmt_port)` with NO saved fallback, two lines above an
  `os` that gets the three-rung treatment right. It now matches, and learns on every tick.
* Linux's `mgmt_port_for` consulted live adverts only; it now falls back to the store.
* Android never carried the port at all — its native discovery record stopped at 8 fields. Added
  `mgmt` as the 9th (the record's own documented "new fields append, never reorder" rule), then
  through `DiscoveredHost` -> `KnownHost` -> `LibraryScreen`.
* Apple LOOKED done and was not: `StoredHost.mgmtPort` and `effectiveMgmtPort` have existed all
  along, but nothing anywhere wrote the field and the `mgmt` TXT was never parsed — so it was
  permanently nil and every Apple client resolved to 47990 regardless. That is worse than the
  honest omissions above, because it reads as finished. Now parsed, carried on `DiscoveredHost`,
  and written by `HostStore.updateMgmtPort` at the same site that learns MACs and the OS chain.

Also `PUNKTFUNK_NATIVE_PORT` in host.env, finishing the pair with PUNKTFUNK_MGMT_BIND: `--native-port`
was likewise CLI-only and died on a package upgrade. A bad value is a startup ERROR rather than the
silent fall back to 9777 that `PUNKTFUNK_DATA_PORT` still does — the failure that reads as "I moved
the port and the client still can't reach me". The client side of the native port already worked
(`KnownHost.port` is persisted, `--connect HOST:PORT` names it).

Adding the field broke three `KnownHost` literals in tests, which is the `Default` impl's stated
purpose working ("adding a field here can't silently produce records that lack it"). All three now
carry 47991 — deliberately NOT the default, so the assertions cannot pass vacuously against a
hardcode. New coverage: forward-compat decode of a store predating the field, the resolver
fallback, re-key carry-forward, and on Android the 9th-field parse plus 0/non-numeric/out-of-range
all reading as unknown.

What this does NOT fix: a host that moved its mgmt port and has NEVER been seen over mDNS. Nothing
tells the client where to look, and the honest fix is for the host to announce it in-band — the
`Welcome` message has an established "append a trailing field, older peer decodes to the default"
pattern for exactly this, at the cost of a C ABI accessor and a bump. Left for a separate change.

Verified: Linux (punktfunk-rust-ci/pf-lxcheck2, amd64) `cargo check --all-targets` clean for
pf-host-config, punktfunk-host, pf-client-core, punktfunk-cli, punktfunk-client-linux and
punktfunk-client-session — the last confirmed non-vacuous by planting a compile_error! and watching
the gate fail (cargo prints "Compiling", not "Checking", for bin-only packages, so the usual marker
grep lies about it). Android: :kit + :app compileDebugKotlin clean, ParseRecordTest 12/12 with both
new cases named in the XML. Apple: xcframework built, `swift build` complete, SharedFoundationTests
pass. cargo fmt --all --check clean. NOT verified: the Windows client (192.168.1.133 unreachable).
2026-08-14 19:44:19 +02:00
enricobuehler 38631d43df fix(client/android): the audio plane trusted AAudio, so a TV that opened a dead stream was silent all session
ci / bun-nix (pull_request) Successful in 19s
ci / web (pull_request) Successful in 1m13s
android / android (pull_request) Canceled after 1m41s
ci / rust (pull_request) Canceled after 1m39s
ci / docs-site (pull_request) Canceled after 1m41s
ci / rust-arm64 (pull_request) Canceled after 1m43s
Field report: no audio at all on an NVIDIA Shield Android TV, stereo, same
host and settings that play fine on an Apple TV. Video unaffected. Turning
off low-latency mode — which gates the forced HDMI mode switch and the
usage=Game tagging, the two things that toggle controls — changed nothing.

This client opens AAudio directly, where the Apple one goes through
AVAudioEngine and gets route-change handling for free; that is why this was
Android-only. Opening AAudio is a negotiation with a vendor HAL and this
plane treated it as a formality: one Exclusive attempt, one Shared retry,
everything after the open taken on trust. Three separate failures all came
out as "the app has no sound" behind a perfectly ordinary log line:

  - a configuration that opens but routes nowhere — nothing ever checked
    that the device pulled a single sample, so the decode thread fed Opus
    into a dead stream indefinitely;
  - request_start failing — we gave up on the spot rather than trying
    anything else, so one unhappy config disabled audio for the session;
  - a disconnect — by AAudio's contract the stream is then DEAD and the
    only recovery is close + open a new one, but the error callback logged
    a warning and did nothing. On a TV that is not rare: this client drives
    an HDMI mode switch on the video plane, and the platform's own
    match-content-frame-rate setting drives more.

The open now walks a ladder, every rung must prove the device is pulling
before it is accepted, and a supervisor owns the plane for the session and
reopens it when the device goes away — with bounded retries across the
settling time of a route change, so a reopen landing mid-switch cannot
permanently disable audio. Granted rate/channels/format are checked rather
than assumed: the realtime callback casts AAudio's buffer to f32 and writes
num_frames * channels of them, so a HAL that disagreed was an out-of-bounds
write on the audio thread, not just a mistuning.

TV boxes now start at Shared. Exclusive is MMAP, the lowest-latency path
AAudio has and the one rung whose routing cannot be verified from inside
the process; the latency it buys was never banked, since the ring depths
are unchanged from the Shared-only era (AAUDIO still primes at 25 ms). On a
mains-powered HDMI box that trade is not worth betting the audio plane on.
Phones keep Exclusive first. If no rung proves itself the first one that
opened and started is used anyway — a watchdog must never be able to turn
working audio into no audio.

nativeStartAudio takes isTv (FEATURE_LEANBACK, the source the video plane
already used) because ro.build.characteristics is not answered by every TV.

debug.punktfunk.audio_sharing / audio_perf / audio_reopen bisect all of it
with setprop, for the device that reports silence and cannot be handed a
custom build. A stream that stops taking samples after it started now says
so at error level instead of looking exactly like an app with no sound.

Not verified on a Shield — no such device here.
2026-08-14 00:15:48 +02:00
enricobuehler 5cd4da4b46 Merge branch 'worktree-agent-a3a9cb8eda476b361' into worktree-dep-currency-wave
# Conflicts:
#	Cargo.lock
#	crates/punktfunk-host/Cargo.toml
2026-08-13 14:22:36 +02:00
enricobuehler 2718b7d4bd chore(deps): mdns-sd 0.20 -> 0.21 and if-addrs 0.13 -> 0.15, together
One commit because splitting them accomplishes nothing: mdns-sd 0.20 ALREADY
depends on if-addrs 0.15, so while our own five crates declared 0.13 the tree
carried both copies no matter which of the two moved first. Moving them together
is what collapses it:

    $ cargo tree -d | grep '^if-addrs'
    (no output)

Neither needed a source change. mdns-sd 0.21's public API is purely additive
over 0.20.3 — the sole new item is `ServiceDaemon::set_max_packet_size`, and
`ServiceInfo`'s surface is byte-identical — so `ServiceDaemon`/`ServiceInfo`/
`ServiceEvent`/`ResolvedService` behave as before at all six call sites
(host discovery + gamestream mdns, pf-client-core, and the Android, Windows and
probe clients). if-addrs 0.15 keeps 0.13's `Interface`/`IfAddr` shape, and we
only ever read those.

The one real change is a FEATURE, not a version. if-addrs has `link-local`, and
mdns-sd declares if-addrs with it on. Once our crates share that single copy,
unification turns it on for our calls too — meaning `get_if_addrs()` now also
reports fe80:: interfaces (and, on Windows, 169.254.x.x). Rather than inherit
that silently, punktfunk-core and punktfunk-host now NAME the feature. Two
reasons: it is what every real build gets anyway, so a standalone `cargo test -p
punktfunk-core` should not enumerate a different set of NICs than the host does;
and for the consumer here — Wake-on-LAN — it is the behaviour we want, since a
NIC is wake-capable whether or not it currently holds a routable address.

Verified on CachyOS (rustc 1.96.0):
  cargo clippy --workspace --all-targets --locked -- -D warnings   OK
  cargo test --workspace --locked                                  OK, 0 failed
  cargo test -p punktfunk-host --bins --locked      501 passed, 0 failed, 2 ignored
  cargo test ... gamestream::cert                   3 passed
  cargo fmt --all --check                           clean

(One `cargo test --workspace` attempt failed with E0463 "can't find crate for
pf_frame" in a doc-test. That is the target dir having only clippy's .rmeta for
a crate a doc-test wants to LINK, not anything in this change; a plain re-run
after cargo test built the rlibs was green.)
2026-08-13 14:10:10 +02:00
enricobuehler d73bdcdcc1 chore(deps): jni 0.21 → 0.22 for the Android JNI bridge
Version currency, but a real API migration rather than a version-number edit — and the
payoff is bigger than "one crate is newer", because jni 0.22 was ALREADY in this .so:
`rustls-platform-verifier` (via quinn-proto, for Android cert verification) depends on it,
so pinning 0.21 here compiled two complete jni copies into one library. Matching the
version collapses them.

Eliminated, measured as the delta in THIRD-PARTY-NOTICES.txt (582 → 571 crates, nothing
added): jni 0.21.1, its `cesu8`, and — because jni 0.21 was the SOLE consumer of
windows-sys 0.45.0, the oldest crate in the tree — that whole windows-rs 0.42 generation:
windows-sys 0.45.0, windows-targets 0.42.2 and its seven per-arch import libraries. Eleven
crates, carried for a `cfg(windows)` dependency of an Android-only bridge.

NOT eliminated, contrary to what the sweep expected — recorded in the manifest so the next
person does not re-derive it. thiserror 1.0 and the jni-sys 0.3/0.4 split both survive,
because jni was never their only source:

  thiserror 1.0.69  ← vendor/ndk 0.9.0 (+ asn1-rs/x509-parser, host side)
  jni-sys 0.3.1     ← vendor/ndk 0.9.0 AND crates.io ndk-sys 0.6

jni's share of both is gone; the remainder is the ndk stack. jni-sys 0.3.1 is itself a
facade crate over 0.4.1, so the split cannot close until ndk and ndk-sys move, and `ndk` is
vendored for a one-line visibility patch — changing its dependency versions would mean
rewriting the vendored snapshot instead of reading it as a diff against upstream. Left
alone deliberately.

The migration itself, across 66 native methods in 10 files:

  * `JNIEnv` split into `EnvUnowned` (the FFI-safe native-method argument) and `Env` (where
    the JNI calls live). The 41 methods that never touched the environment are a type
    rename; the 22 that do now acquire an `Env` inside `EnvUnowned::with_env` and map the
    outcome with an `ErrorPolicy`.
  * `LogErrorAndDefault` everywhere, chosen to PRESERVE behaviour: the old code swallowed
    JNI errors and returned a default, and this logs and returns the same default. The
    throwing policy would have been a behaviour change (new Java exceptions out of methods
    that previously failed quietly), which is not what a currency bump should do.
  * `with_env` also catches panics, which is exactly what the crate's own `jni_guard` did,
    so the guard is folded into it where the two would have nested. It stays on the methods
    that take no `Env`. ONE exception, marked at the call site: `nativeNextHidout` returns
    -1 as its failure sentinel, and every error policy resolves to `T::default()` — which
    for `jint` is 0, a *valid* byte count. That method keeps `jni_guard(-1, …)` outside and
    uses `with_env_no_catch` inside so the sentinel survives the panic path unchanged.
  * `jboolean` is `bool` in jni-sys 0.4, not `u8` — a type change, not a rename, and the
    reason for most of the mechanical diff (`down != 0` → `down`, `return 0` → `false`).
  * `Env::get_string` is deprecated in favour of `JString::try_to_string`, and CI runs
    clippy with `-D warnings`, so the call sites moved rather than being left to warn.
    Likewise `set_/get_*_array_region` → `JPrimitiveArray::set_region`/`get_region`.
  * `Env::get_native_interface()` is now `Env::get_raw()` — the raw pointer handed to
    `ndk::NativeWindow::from_surface`. The `as *mut _` cast next to it was already commented
    as bridging jni-sys skew between `jni` and `ndk`; that skew is now real (0.4 vs 0.3)
    rather than hypothetical, so the comment says so.
  * Return types moved from raw `jni::sys::jstring`/`jdoubleArray`/`jintArray` to
    `JString`/`JDoubleArray`/`JIntArray`, because `resolve()` requires `T: Default` and raw
    pointers have no `Default`. All three are `#[repr(transparent)]` over the same
    `jobject`, so the exported ABI is unchanged and `Default` IS the null reference the old
    code returned explicitly.

Kotlin and Gradle needed NO changes, checked rather than assumed: every affected
`external fun` in NativeBridge.kt already declares `Boolean` / `String?` / `IntArray?` /
`DoubleArray?`, which is what these signatures still present to the JVM, and the Gradle
side only shells out to cargo-ndk without naming a jni version.

Verified on NDK 30.0.14904198, both shipping ABIs, using the environment
clients/android/kit/build.gradle.kts hands cargo-ndk:

  cargo clippy -p punktfunk-client-android --all-targets -- -D warnings   → ok (host)
  cargo test   -p punktfunk-client-android                                → 20 passed
  cargo ndk -t arm64-v8a   --platform 28 clippy … -- -D warnings          → ok
  cargo ndk -t armeabi-v7a --platform 28 clippy … -- -D warnings          → ok
  cargo ndk -t arm64-v8a -t armeabi-v7a --platform 28 build               → both .so LINKED
  scripts/ci/check-android-jni-imports.sh … 28    → 2 ABI(s) clean at the API-28 floor

That last one matters for this change specifically: a cdylib links with dangling undefined
symbols, so the floor check is the only thing that would catch jni 0.22 hard-importing an
NDK entry point above minSdk 28 — the shape of the 0.9.0 `System.loadLibrary` regression.
It is clean.

`cargo tree -i ring` stays empty on host and on aarch64-linux-android.
2026-08-13 13:58:08 +02:00
enricobuehler 00245499e0 chore(deps): android_logger 0.14 → 0.15
Version currency for the Android client's only `log` backend. No code change: 0.15 is
almost entirely an internal refactor (the single `lib.rs` split into `config`/`arrays`/
`id`/`platform_log_writer`/`tests`), and the surface this crate uses — `init_once`,
`Config::default`, `with_max_level`, `with_tag` — is untouched. The lockfile delta is the
version and checksum alone: no dependency was added, removed or re-resolved, and the
third-party crate count stays at 582.

The one thing 0.15 adds that we must NOT take is recorded next to the dependency: the new
opt-in `android-api-30` feature filters levels through `__android_log_is_loggable_len` so
logcat's `setprop log.tag.*` overrides are honoured, but it HARD-LINKS that API-30 symbol.
Against our minSdk-28 floor that is a `System.loadLibrary` failure on Android 9/10 — the
identical shape of the ndk 0.9.0 `AMediaCodec_setOnFrameRenderedCallback` break the manifest
already warns about a few lines further down. Default features keep it off; the comment
explains why so nobody "completes" the upgrade by enabling it.

Verified with cargo-ndk (NDK 30.0.14904198), which is the only way to exercise this crate at
all — `android_logger` sits behind `cfg(target_os = "android")`, so the host workspace build
never compiles it:

  cargo ndk -t arm64-v8a   check -p punktfunk-client-android   → ok
  cargo ndk -t armeabi-v7a check -p punktfunk-client-android   → ok

Checked, not built: these are `cargo check` runs, not a linked `.so` and not an APK.
2026-08-13 13:34:19 +02:00
enricobuehler 79d755cd98 feat(crypto): move the rustls backend from ring to aws-lc-rs, with PQ key exchange
The workspace pinned `ring` everywhere because aws-lc-sys 0.41.0 failed to C-compile
on the Windows CI runner. Re-tested on that runner (.133) with aws-lc-sys 0.44.0: the
`params.c` cl.exe failure does not reproduce under MSVC 14.44, and rustls's `aws_lc_rs`
feature turns on `aws-lc-rs/prebuilt-nasm`, so no NASM is needed on the box either.

That unblocks post-quantum TLS: `prefer-post-quantum` offers X25519MLKEM768 first on
every TLS 1.3 handshake (mgmt API, native control plane, QUIC), which ring cannot do —
it has no ML-KEM. Classical curves stay in the list, so older clients still connect.

rustls, quinn, rcgen and tokio-rustls each select a backend independently, so all four
had to move together; a single dissenter pulls a second crypto stack in via feature
unification. The direct `ring` users (ed25519 in pf-update-check, SHA-256 in the Windows
updater) moved to aws-lc-rs, whose API is ring-compatible.

`ring` does NOT leave the tree: ureq 2 names `features = ["ring"]` in its own rustls
dependency line and cargo features are additive, so no dependent can switch it off. Two
backends compiled in means rustls refuses to infer one, and anything built via
`ClientConfig::builder()` panics instead of picking — which is what ureq's default agent
does on its first HTTPS request. `tls::install_default_provider()` makes the choice
explicit; it runs at each binary's entry point and defensively in pf-client-core, which
several binaries link. Dropping ring entirely needs the ureq 2 -> 3 upgrade (36 call
sites), deliberately left out of this change.

Verified on macOS: pf-update-check 32, punktfunk-core 385, c_abi 1 (the last with
LIBRARY_PATH=/opt/homebrew/opt/opus/lib) — aws-lc-sys links into the C ABI harness, so
the Swift/Kotlin embedders keep working. cargo fmt --all --check clean.
2026-08-13 12:41:47 +02:00
enricobuehler c68e0be688 Merge remote-tracking branch 'origin/main' into worktree-edition-2024
ci / bun-nix (pull_request) Successful in 24s
windows-drivers / probe-and-proto (pull_request) Successful in 34s
ci / docs-site (pull_request) Successful in 1m23s
apple / swift (pull_request) Successful in 1m45s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 3m4s
windows-drivers / driver-build (pull_request) Successful in 2m23s
ci / rust (pull_request) Failing after 4m6s
ci / rust-arm64 (pull_request) Failing after 5m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 1m54s
nix / flake (pull_request) Successful in 13m36s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 54s
android / android (pull_request) Successful in 15m6s
2026-08-12 17:27:27 +02:00
enricobuehler f373dffb5e chore: migrate the main workspace and pf-vkhdr-layer to edition 2024 (WP20)
The safety half of the rust-safety programme's §8.4: `std::env::set_var`/`remove_var` are
`unsafe fn` in edition 2024, converting the class of bug the programme found the hard way
(the 972af299 environ data race lived in a file with ZERO occurrences of the word
`unsafe`) from invisible to counted and compiler-enforced.

Manifests: [workspace.package] edition 2021→2024, rust-version 1.82→1.85 (the pinned
toolchain is 1.96.0, so no toolchain bump — only the declared floor rises); the 13 crates
pinning `edition = "2021"` literally now inherit it (Trap 1: the root bump alone reaches
only `edition.workspace = true` crates and would have left pf-encode/pf-capture/pf-inject
et al. on 2021 while reading as complete); pf-driver-proto's stale rust-version 1.82 pin
now inherits; pf-vkhdr-layer (a separate workspace, inherits nothing) bumped to 2024. The
four vendored crates (fec-rs, cros-codecs, usbip-sim, the patched ndk) stay on 2021
deliberately — upstream code stays pristine. The excluded usbip-poc standalone PoC is
untouched.

Mechanical, done textually across ALL cfg branches so no platform's half is left behind
(Trap 3 — 44% of the host's unsafe is Windows-only and a one-platform `cargo fix` misses
it): 148 `#[no_mangle]` → `#[unsafe(no_mangle)]` (83 in abi.rs); 12 bare extern blocks →
`unsafe extern`; `gen` is a reserved keyword, so pf-vdisplay's generation stamps
(registry.rs, windows/manager.rs) and the WinUI shell's animation counters rename
gen → generation (internal identifiers only, no serde/wire surface); two
match-ergonomics patterns take the compiler's suggested reference form.

env mutation: every `set_var`/`remove_var` site (20 files) now sits in an `unsafe` block
whose SAFETY comment states the real serialization argument (pf-vdisplay's ENV_LOCK,
CONFIG_DIR_TEST_LOCK, ART_ROOTS_LOCK, vkdecode's gpu_lock, the `--test-threads=1`
contracts of the hardware spikes, or single-threaded startup). Two genuine hazards
surfaced en route — exactly the WP3b-class finds this migration exists to make visible —
and are fixed here:
- windows/service.rs spawned the network-profile warner thread BEFORE `load_host_env()`,
  so a child-spawning thread (child spawn snapshots the env block) was live while
  `set_var` ran in a loop; the load now precedes the spawn.
- pf-console-ui's `fake_home()` re-set HOME outside its OnceLock on EVERY call, so two
  parallel tests could race the write; the set now happens exactly once inside
  `get_or_init`.

cbindgen (Trap 2): 0.29.4 parses `#[unsafe(no_mangle)]` — verified empirically; the
header regenerates byte-identical. The ci.yml drift check could never catch "failed to
regenerate" (build.rs demotes a cbindgen failure to a warning and writes nothing, leaving
the checked-in header untouched and the diff clean), so the step now first asserts the
"punktfunk-core: wrote" line and the absence of "cbindgen failed" (sh -e safe: no `!`
pipeline, no tee-masked exit).

rustfmt: style_edition pinned to 2021 at the root — edition 2024 would otherwise flip the
style edition and reformat ~370 untouched files inside this same commit, burying the
migration diff. The drivers workspace pins its already-current 2024 style. Adopting the
2024 style tree-wide is its own future one-line-plus-reformat commit.

Census: the primary metric moves UP BY DESIGN — 2435 → 2453 operations, unsafe blocks
1534 → 1577, and env_set_var is now a counted category (45 ops). The newly counted env
sites are a truer number, not a regression; baseline snapshot saved as punktfunk-planning
design/rust-safety-census-baseline-2026-08-12-edition-2024.txt. Gate C's env ratchet is
now compiler-enforced (the hygiene-script header says so); the two shrunk file counts
(nvenc_cuda 49→2 via the test helpers, shell/tests 2→1) are lowered in the same commit
per the gate's own rule.

Drop order (the semantic change most likely to bite this codebase): the migration lint
`-W tail-expr-drop-order` reports zero findings on the macOS-visible halves of
pf-encode / pf-zerocopy / pf-capture / pf-frame; the Linux and Windows halves run the
same lint on the gate boxes. The four #[ignore]d alloc/drop-cycle tests on the hardware
boxes remain owed, as before this change.
2026-08-12 16:12:35 +02:00
enricobuehler 2a62fe7857 fix(client): stop the double-arm race re-freezing RFI-healed streams
ci / bun-nix (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m22s
ci / rust-arm64 (pull_request) Successful in 1m42s
ci / web (pull_request) Successful in 1m51s
apple / swift (pull_request) Successful in 1m50s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m28s
android / android (pull_request) Successful in 6m39s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 13m7s
ci / rust (pull_request) Successful in 14m4s
Every unrecoverable loss armed the client's freeze gate twice: instantly at
frame-index-gap detection (which fires the RFI), and ~120 ms later when the
reassembler ages the lost frame into frames_dropped and poll() re-armed
unconditionally. An LTR-RFI recovery anchor lands in ~60 ms — between the two
signals — so the stale climb re-froze a bit-exact-healed stream, the host
swallowed the re-ask as an RFI echo, and the picture stayed frozen until the
overdue backstop extracted a full IDR: the field 'H265 freezes on every loss,
AV1 fine' signature on AMD hosts (AMF is the only LTR-RFI backend; the slower
IDR path usually lands after the climb and dodged the race).

The gap-arm now pre-credits the expected climb (ReanchorGate::arm_expecting_drops;
credit expires after DROP_CREDIT_WINDOW so a straggler-filled gap can't mask a
later real loss), and poll() consumes credited climbs instead of re-arming.
Plumbed through every embedder: pf-client-core's session pump, Android's
sync/async loops (note_frame_index now returns the gap width), and the Swift
client via new ABI exports punktfunk_connection_note_frame_index_ex +
punktfunk_reanchor_gate_arm_expecting_drops (additive; the bool ABI stays).
2026-08-12 08:11:35 +02:00
enricobuehler 2bfd1cd2d5 chore(safety): three unsafe-hygiene grep gates, blocking in ci.yml (WP2c gates)
scripts/ci/check-unsafe-hygiene.sh — textual gates for three classes no lint
covers:

A. unsafe fn markers carrying no contract. unsafe_op_in_unsafe_fn forces real
   ops into blocks, so an unsafe fn with no `unsafe` in its body is a marker
   with no contract (db659809 found two by hand). Contract-deferring fns
   (Vec::set_len shape) waive with `// unsafe-fn-no-op-ok: <reason>`; fenced
   files and `unsafe extern "ABI" fn` (signature-mandated markers) are
   skipped structurally.

B. unwrap/expect/panic! inside extern "C"/"system" bodies — an abort since
   Rust 1.81, not linted, not fuzzable (8b98d0b3). catch_unwind bodies are
   exempt; `// panic-in-extern-ok: <reason>` waives a deliberate abort.

C. Safe-but-process-global APIs (env::set_var/remove_var, sigaction,
   setlocale, set_current_dir) — the 972af299 environ race lived in a file
   with zero occurrences of the word `unsafe`. Per-file count ratchet with
   the baseline in the script; any increase or new file fails.

Making gate B clean on main surfaced 14 real instances of exactly its class —
`.lock().unwrap()` in unguarded extern fns, where a poisoned mutex aborts the
embedding process: six punktfunk-core abi.rs entry points (poll_frame,
next_au, next_audio, next_audio_pcm, next_cursor_shape, next_clipboard),
seven Android JNI entry points, and the Windows client's deeplink wnd_proc.
All fixed with poison-recovering locks (the slots are last-value caches,
valid whatever a poisoned writer left) and Option::insert for the
set-then-unwrap shape; punktfunk-core's 203 lib tests pass. Gate A's
findings were six genuine contract-deferring fns — waived with reasons, not
fixed, because the markers are correct.

Gate-of-the-gate: all three shown to FAIL on deliberately planted instances
(marker fn, panicking extern callback, env::set_var in an unlisted file) and
to run clean on the tree, before the ci.yml step made them blocking.
2026-08-11 23:36:17 +02:00
enricobuehler bfed711921 Merge remote-tracking branch 'origin/main' into audio/latency-overhaul
ci / bun-nix (pull_request) Successful in 33s
ci / web (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m21s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m13s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m36s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m9s
android / android (pull_request) Successful in 7m53s
ci / rust (pull_request) Successful in 12m23s
2026-08-08 00:16:02 +02:00
enricobuehler 74270109dd ci(android): lint the Android target, which nothing had ever done
`ci.yml` runs `cargo clippy --workspace` on the HOST, where
`clients/android/native` and every `#[cfg(target_os = "android")]` module
elsewhere compile out, and `android.yml` only ever built. So the Android target
was never linted at all — not once. Five lints were sitting in
clients/android/native when this was noticed, in code no gate had ever read.

The gate is a Gradle task rather than a YAML step because cargo-ndk needs a
specific discovery environment (NDK sysroot, SDK cmake 3.22.1 for libopus,
`LIBOPUS_STATIC`, Ninja) and duplicating it into the workflow would let the lint
drift from the build — a lint that ran against a different toolchain is a lint
about a different program. `registerCargoNdkClippy` reuses the build task's
environment verbatim via the extracted `cargoNdkEnvironment`, so local and CI
runs are the same invocation.

It lints BOTH pointer widths, and that is load-bearing rather than thorough:
arm64-v8a is 64-bit and armeabi-v7a is 32-bit, so a cast that is redundant on
one can be required on the other. Linting only the primary ABI would license
"fixes" that break the 32-bit build — the shipping ABI for the many 32-bit
Google TV / Android TV boxes this client targets. x86_64 is skipped: it is
emulator-only and shares its width with arm64, so it costs lint time for no
signal the other two do not already carry.

The five resident lints:

* `audio.rs` / `mic.rs` `type_complexity` — the open-attempt closures now return
  named `OpenedPlayback` / `OpenedCapture` aliases. The two tuples are mirror
  images of each other (playback sends, capture receives), which the aliases now
  say out loud.
* `vsync.rs` ×2 `unnecessary_cast` — **not** taken. `timespec`'s fields are
  32-bit on armv7 and 64-bit on arm64, so the casts are REQUIRED on one shipping
  ABI and redundant on the other; following the suggestion would break the
  32-bit build. `i64::from`/`.into()` do not escape it either, they trade
  `unnecessary_cast` for `useless_conversion` on the 64-bit side. Answered with
  a documented `#[allow]` at the expression instead of in whichever build breaks
  first.
* `pad_audio.rs` `needless_range_loop` — iterator form, preserving the
  `channels < 2` no-op the range had.

Verified: `:kit:cargoNdkClippy` green on both ABIs, host-lane clippy for the
crate still clean, `cargo fmt --all --check` clean. The gate was proven
non-vacuous by planting `1i32 as i32` in an android-only module and confirming
it fails the task, then reverting.
2026-08-08 00:01:39 +02:00
enricobuehler 70e6b80200 fix(client/android): place audio with the picture on Android too
The core, Linux, Windows and host halves of the audio latency overhaul landed
with Android deliberately left inert: `JitterPolicy`'s sync target defaults to
`None`, so this ring kept behaving exactly as it always had. What was missing
was not the loop but its REFERENCE — nothing here published where a frame
actually reached glass, and a controller with no reference is the mechanism you
can prove is present but that cannot act. This wires both halves.

The decode thread now reads the host capture `pts_ns` that every `AudioPacket`
has always carried and that this client, like every other, dropped on the floor.
Against the ring depth (published by the AAudio callback through the shared
`AudioSyncCell`) and the video plane's end-to-end figure it computes

  audio_e2e = (now + buffered_ahead + clock_offset) − pts_ns
  av_offset = audio_e2e − video_e2e        (> 0 ⇒ audio behind the picture)

and asks the ring for a depth that closes it. Only ASKS: `set_sync_target` is
clamped between the underrun-driven adaptive floor and the hard cap, so a link
whose jitter genuinely needs more buffer than the picture is away keeps its
buffer and the residual is reported instead of being taken out of the listener's
stream. Continuity outranks sync, on this ring as on the others.

The reference comes from `DisplayTracker`'s `OnFrameRendered` callback — the one
place in the client that knows a frame truly latched — and it is computed ABOVE
the HUD gate now. A sync loop that only ran while the overlay was up would be
off on exactly the devices that report latency; the stats LOCK stays gated,
which is what that early-return was really protecting. Both decode loops feed
it, so sync works with "Low-latency mode" off as well.

Two deliberate refusals:

* The figure is published RAW. The HUD shaves the OS present floor off its shown
  display/end-to-end numbers — metrics report what Punktfunk controls — but sound
  has to reach the ear when the light reaches the eye, and a floor-shaved
  reference would place audio a whole latch period early on every device.
* Below API 33 there is no render callback, so there is no confirmed present and
  the loop stays inert (target `None` ⇒ today's behaviour exactly). The release
  instant is NOT substituted for it: a release targets a FUTURE vsync and runs a
  whole latch period (8-21 ms measured) ahead of glass, well outside the loop's
  deadband — it would place audio early on every frame while looking like it was
  working.

The plane is also no longer invisible. Ring depth and the smoothed offset ride
the stats array at 33/34 and the Detailed HUD carries `audio buffer N ms · a/v
±N ms`, the same wording the desktop HUD uses — both numbers, because a deep ring
on a jittery link is correct behaviour and only the offset separates that from
audio simply held late. The 1 Hz logcat line gains `av_ms` beside its depth, and
the depth itself now has ONE publisher: the counter copy is gone in favour of the
sync cell both readers already share.

The escape hatch is two levers. `PUNKTFUNK_NO_AV_SYNC=1` keeps the contract the
desktop clients document, but an app launched from the launcher inherits no
environment, so the one a field tester can actually reach is
`adb shell setprop debug.punktfunk.no_av_sync 1` — no rebuild, exactly like
`debug.punktfunk.presenter`. A loop that steers playback has to be bisectable on
the device that reports the regression.

Verified: `cargo ndk -t arm64-v8a check` clean; `cargo clippy -p
punktfunk-client-android --all-targets -- -D warnings` clean on the host lane CI
lints, and the Android target introduces no new findings (5 pre-existing lints in
audio/mic/pad_audio/vsync are unchanged — the android-gated modules are never
linted by the host workspace); `cargo fmt --all --check` clean;
`./gradlew :app:testDebugUnitTest` green. The new HUD test was proven
non-vacuous by planting the defect first — dropping the render call fails its
three positive assertions and leaves the three absence assertions passing, which
is the shape a test that "passes for the wrong reason" would not have.

design/audio-latency-overhaul.md W4. Apple (W6) still keeps today's behaviour.
2026-08-07 23:51:15 +02:00
enricobuehler 7cab7ae6bc feat(client/android): say when a captured pad's gyro can't reach the session
G8's Android half, and the last of the three clients. Same failure as the other
two: a controller with a gyro, in a session whose virtual pad has no motion
plane, does nothing when tilted — silently, with no way from the couch to tell
that apart from a broken sensor. The fix is the Controller type setting, so the
notice names it.

Android read neither the requested nor the resolved backend, so this needed a
plumb. What it did NOT need was a third copy of the rule. `nativePadMotionReaches`
takes the kind a pad declared and answers off `pad_motion_reaches` in
punktfunk-core, where the argument and the tests already live. The rule is
subtler than it looks — the host builds each pad from its OWN declaration and
folds what it cannot build, so neither the declaration nor the session echo
answers it alone — and every way of getting it wrong is silent. A Kotlin
transcription would have been a third thing to keep in step with the host, which
is exactly how the SDL half got it wrong the first time.

Asked once per pad, at claim, in `openExternal` — where the pad's kind is already
being declared to the host — and the answer held for the pad's lifetime on the
`ExternalPad`. Not per sample: this runs at a DualSense's full report rate.

`hasGyro` gates only the NOTICE, and defaults to false. `DsCapture` passes true —
every pad it captures is a Sony one whose IMU is a headline feature, forwarded on
the rich plane. `Sc2Capture` keeps the default, because the Steam Controller 2's
motion rides inside the opaque passthrough report that `hidReport` carries, which
nothing here may second-guess: warning about motion for a pad that never calls
`motion()` would be a notice about a feature the player never lost. The
suppression itself is on `motion()` regardless, where it costs a dead pad nothing
and stops a live one paying to send samples the host will decode and discard.

The notice sits at the BOTTOM of the stream overlay, unlike the mic-chord
confirmation at the top. The two can coincide — a pad is claimed at roughly the
moment someone might be muting — and one landing on the other would cost the user
both. It holds 6 s rather than the mic chord's 1.6: that one confirms something
the user just did, this one explains something they did not, in a sentence they
have to read. Nulled at teardown beside `onExitArmed`/`onMicChord`, for the same
reason those are — a slot closing during release must not poke Compose state on
the way out.

Not covered by tests, and this is a limit of the module rather than a choice:
`GamepadRouter` needs Android plus a live JNI handle, there is no Robolectric
here, and the predicate it defers to is pure Rust that already has its table. So
the parts that carry the reasoning are argued in comments, as `DsCapture`'s
claim/teardown ordering already is. What IS mechanically verified is the piece
that a compiler cannot catch and a device would fail on: the JNI symbol
`Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionReaches` is present and
global in the built arm64-v8a `.so`, so the `external fun` resolves rather than
throwing `UnsatisfiedLinkError` at the first pad.

Gate: `:kit:compileDebugKotlin`, `:kit:testDebugUnitTest` (62 cases, 0 failed,
read out of the JUnit XML rather than inferred from a green build — unchanged
from this branch's previous count), `:app:compileDebugKotlin` and
`:app:testDebugUnitTest` (67 cases, 0 failed), with `:kit:cargoNdkRelease`
rebuilding the JNI crate clean across all three ABIs, plus `cargo fmt --check` on
it. On-glass verification is owed on the rig the earlier legs used, and is worth
doing as one pass with the two already owed there.
2026-08-07 17:02:04 +02:00
enricobuehler 72777119fd fix(client/android): stop reporting every disconnect as a lost connection
ci / web (pull_request) Successful in 1m6s
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m50s
android / android (pull_request) Successful in 3m43s
ci / rust-arm64 (pull_request) Successful in 4m26s
ci / rust (pull_request) Successful in 7m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 12m38s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 8m23s
The stream watchdog polled a bare "has the session ended" boolean, so it
had exactly one thing it could say and said it every time: "Connection
lost — the host may be asleep. Wake it to reconnect." That ran when the
player quit their game, when an operator ended the session from the
console, and when they pressed Back themselves — telling them to go wake
a host that was never asleep.

It now reads the end reason. Only a connection that actually died gets
that line, a host-side failure gets its own, and the three deliberate
endings say nothing at all: leaving the stream is already the feedback,
and a toast on top of it is just noise.

A game launched from a library also returns to that library instead of
host selection, which needs the intent hoisted out of the console shell:
the stream replaces that shell in the composition, discarding the
`remember`s holding its screen and host, so by the time the session ends
there is nothing left to navigate back with. The parent holds it across
the gap and the shell consumes it on the way in. The touch UI has no
library — only the console shell does — so there it is the toast fix
alone.
2026-08-06 14:30:59 +02:00
enricobuehler d27e62f7c9 fix(pad-audio): close the twelve findings the sweep left open on this branch
apple / swift (pull_request) Successful in 1m31s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 41s
ci / web (pull_request) Successful in 1m59s
ci / docs-site (pull_request) Successful in 1m59s
ci / rust-arm64 (pull_request) Successful in 4m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 2m37s
android / android (pull_request) Successful in 4m16s
ci / rust (pull_request) Failing after 10m50s
Everything the 2026-08-03 haptics sweep filed against the pad-audio branch (P2 + P3).
Four of them are the difference between a feature that works and one that fails silently.

**B6 — nothing ever un-muted the coils.** Every rumble report asserts `HAPTICS_SELECT`,
which is SDL's "disable audio haptics" bit: the firmware mutes the very voice coils the
0xD1 stream drives. No code anywhere cleared it again, so ONE rumble left tier-A haptics
silent for the rest of that pad's life — no error, nothing in a log, and the host happily
streaming into a muted actuator. `DsDevice.ds5AudioHapticsReport` is the documented undo
(flag0 with both bits clear); written EP0-direct when the stream starts and again after a
rumble stop while a stream is live, because the stop report re-mutes on its way past.

**B10 — the desktop mix could reach a controller's coils.** Pad endpoints were filtered out
inside `plan()` only. The watchdog, Follow mode and the parked default all go through
`judge_default`, which classifies by NAME — and a pad endpoint is deliberately stamped
"DualSense Wireless Controller" so games treat it as the pad's speaker. No name rule could
ever catch one. It now refuses them by identity.

**B27 — an out-of-range pad aliased onto a real slot.** The 0xCD plane's pad is the only u16
index and every consumer narrowed it with `as u8` on an assumption nothing enforced, so wire
pad 256 steered pad 0's speaker volumes. Rejected at the decoder, which makes the narrowings
lossless by construction. An existing test had pinned the bug in place, asserting that wire
pad 513 round-trips; corrected, plus a test for the 256→0 alias specifically.

**B7 — caps that arrived late were never announced.** The renderer commits the tier-A trade
only once its sink opens, which is well past the arrival burst's two 100 ms ticks, and
`set_pad_audio_caps` only stored an atomic. The client believed it had pad audio while the
host emitted nothing. The input task now compares the live registry against what the last
arrival actually carried and re-arms the burst itself — no new plumbing, and no extra traffic
when nothing changed.

The rest: `needs_aeb_kick` is finally ACTED on (R4) — a stored-but-not-served endpoint is
declined rather than opened, because `AUTOCONVERTPCM` makes it succeed and mis-route; a failed
provisioning no longer latches `PROVISIONED` for the process lifetime (R5), and `host_cap`
retries, so a host that started while the audio stack was busy recovers at the next connect
instead of the next reboot; the loopback init timeout reaps its thread instead of detaching one
per ~2 s reopen (R6); kind-change restarts are bounded (R3) since the trigger is a client-sent
arrival; the devtest uses the endpoint's real channel mask (B11) instead of letting wasapi
derive 0x0F against the endpoint's 0x33; the render loop asks `is_session_ended()` rather than
spinning at nice -16 (R12); short writes are counted and reported instead of dropping the tail
in silence (R13); and a frame addressed to another pad is dropped before it can seed the gap
tracker from a foreign sequence space (R14).

Verified: punktfunk-host clippy -D warnings **0 on a real Windows box**; Linux/amd64 clippy 0
with **589 tests** (pf-client-core 114, pf-inject 101, punktfunk-client-android 20,
punktfunk-core 345+1+8); Android :kit: tests + :app: compile green; fmt clean.

Six punktfunk-host tests fail on that Windows box. FIVE fail identically on a tree with no
pad-audio code at all (QUIC `Rejected(SetupFailed)` — the box's network environment); the
sixth passes 3/3 in isolation and only failed under the parallel run, on a locally-bound
ephemeral port. Neither is this change.

Still owed: on-glass. This is a hardware feature and none of it has been on a real DualSense
since the merge.
2026-08-04 23:55:47 +02:00
enricobuehler 0a72959ef7 Merge main into feat/android-pad-audio
86 commits of main, including the whole M1-M12 haptics sweep. Twelve conflicting files;
three of them were more than textual.

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

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

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

The rest: `wiring_plan::plan` now delegates to main's `plan_with_formats`, so the pad-endpoint
filter moved into that body and the predicate behind it is factored out as `is_pad_render`
(also what B10 needs); `Ds5Feedback::AUDIO` derives from main's `REPORT_ID_LEN` like its
siblings; `AudioCtl` joins the explicitly-listed unhandled variants so the guard-false case is
covered rather than swept up by a `_`; `include/punktfunk_core.h` regenerated rather than
hand-merged.
2026-08-04 23:27:06 +02:00
enricobuehler 42a0dd52be refactor(haptics): one copy of each thing every rumble path was transcribing
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / rust (pull_request) Successful in 9m44s
ci / rust-arm64 (pull_request) Successful in 2m0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m2s
android / android (pull_request) Successful in 5m3s
Twelve findings from the sweep's DRY/docs/dead-code tail. Most are small; three found
real defects hiding behind the duplication.

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

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

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

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

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

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

Verified: pf-inject clippy -D warnings 0 / 91 tests; pf-client-core + punktfunk-core
clippy 0 / 437 tests (amd64 container); punktfunk-client-android 7 tests; Android :kit:
6 tests; Apple swift build + 189 tests / 0 failures; cargo fmt --all --check clean. Each
new test probed by reverting its fix — the fixed SET_REPORT window fails 3, a broken pack
shift fails 3, dropping the amplitude floor fails 1, and a wrong DS5 offset either fails
the pin or refuses to compile.
2026-08-04 22:52:38 +02:00
enricobuehlerandClaude Opus 5 a12f1f092c feat(clients/audio): one de-jitter policy for all four rings, and lossless single-packet recovery
Phase 4 + WP3.2 of design/audio-quality-and-latency.md.

**The defect.** Every client ring primed *up* to a target and clamped at a ceiling, and none
walked the depth back *down*. Any transient — a Wi-Fi arrival burst, a host stall, or plain
host-DAC-vs-client-DAC skew of a few dozen ppm — therefore added latency permanently, until
an underrun happened to re-prime. Android, with no shed at all, converged on its 120 ms hard
cap and stayed there for the rest of the session; that is the "audio latency is too high"
report. Apple did shed, 40 ms in one go, which its own comment called "one audible blip".

All four now share `punktfunk_core::audio::JitterPolicy`: depths in MILLISECONDS rather than
device quanta (`3 x quantum` meant 15 ms at a 5 ms quantum and a silent 64 ms at a 20 ms
one), a crossfaded 5 ms shed once the depth average has sat above target for 2 s of consumed
audio, and de-prime hysteresis. Linux and Windows had never had that hysteresis — they still
carried the `if ring.is_empty()` instant re-prime that Android identified as self-inflicted
crackle, where one transient drain manufactured a whole target's worth of silence.

Android's floor drops 40 -> 25 ms: the policy grows the target on the devices that actually
underrun, instead of every device pre-paying for the worst one. The Windows ring moves from
raw bytes to interleaved f32 so it can share the policy and the crossfade helper at all.

Apple is the one client where the policy is hand-written in a second language, so it gets
its own XCTest (`AudioRingDriftTests`). Verified here by compiling `AudioRing.swift`
standalone against a simulation harness — +200 ppm for 5 minutes settles at 30 ms with zero
silent callbacks, where the old ring would have ridden its 80 ms high-water mark.

**WP3.2 — recovery lives in core, not in the clients.** The rebuilt frame is re-inserted into
the demux queue in order, so every embedder (including any C-ABI consumer) gets a complete
stream without knowing the `0xD2` plane exists, and their `AudioGapTracker` simply stops
seeing the gap. `recovery_and_the_gap_tracker_agree` pins exactly that. For the same reason
core advertises CLIENT_CAP_AUDIO_RED itself rather than making four embedders remember to.

Verified: clippy --all-targets -D warnings and the full test suites for punktfunk-core,
pf-client-core, punktfunk-host, pf-host-config under Linux/docker (163 + 61 tests);
punktfunk-client-android `cargo ndk check` for aarch64 with the gate proven non-vacuous by a
planted type error, and its 6 clippy findings confirmed IDENTICAL to the pristine file (all
are the documented arm64-only artifacts); AudioRing.swift type-checked and simulated on
macOS; fmt. The Windows client half (audio_wasapi.rs) is still not compile-verified anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 09:28:01 +02:00
enricobuehler 2032c48ffa fix(android/pad-audio): a game that only rumbles keeps rumbling
ci / web (pull_request) Successful in 1m19s
ci / docs-site (pull_request) Successful in 2m49s
ci / rust-arm64 (pull_request) Successful in 3m9s
android / android (pull_request) Failing after 4m23s
ci / rust (pull_request) Successful in 6m54s
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 2m22s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 55s
Three faults that between them silence a wired DualSense.

The trade was committed without asking whether the host can send pad audio at
all. Against every released host — no HOST_CAP_PAD_AUDIO — the renderer claimed
the interface, took the pad off wire rumble, and then rendered nothing, with
`pad_haptics` defaulting on and no UI to turn it off. The capability is now
checked before `sink::open`, so nothing is claimed and nothing is traded.

Arming was unconditional, so a speaker-only setup took the motors away too. The
speaker pair is channels 0/1 and no rumble write can disturb it; only the
haptics lane arms now.

And the suppression itself was wrong for the case that matters most: a title
driving classic rumble and no haptics audio. Suppressing on "a stream is open"
assumed the game's rumble rides the haptics mix, which for such a title is
false — it renders no haptics audio at all, so the host's -60 dBFS gate emits
nothing on 0xD1 and the pad was left with neither. Ownership is now decided by
evidence: the coils belong to haptics only while haptics frames are actually
arriving, and to wire rumble otherwise. Frames are stamped on arrival rather
than after decode, so a decoder hiccup cannot hand the coils back mid-effect,
and concealment does not count as evidence. Liveness is dropped at every
teardown, because wire indices are recycled and a stale stamp would let a fresh
pad inherit the previous occupant's ownership.

Arbitrating on evidence rather than on a prediction about the hardware is
deliberate, and the module doc now says why. It used to assert that the coils
and the rumble motors are the same physical actuators — "a firmware constraint,
not a preference". Nothing establishes that: it traces to one reverse-engineered
comment in SDL, whose own modern path sets HAPTICS_SELECT alone with amplitude
on ucEnableBits3, which reads more like an independent mute than a shared-
actuator interlock. The combination that would settle it — rumble with
HAPTICS_SELECT cleared — is emitted by no code anywhere, and nothing here writes
it either. The evidence rule is correct under either hypothesis.

The liveness clock is 1-based so that 0 stays an unambiguous "never stamped":
without it a frame arriving in the process's first millisecond read as
never-arrived and handed the coils back mid-effect. Its test caught that.

Verified: clippy -p punktfunk-client-android --all-targets --locked -D warnings
= 0; 15 tests pass.

Owed: the desktop twin of the arbiter, and the coil restore — the Android stop
write still asserts HAPTICS_SELECT with zero amplitude, where SDL's all-zero
stop restores the audio path.

From the 2026-08-03 force-feedback sweep (B4, B5; B6 partly).
2026-08-03 19:44:52 +02:00
enricobuehler 5be494f490 merge: bring main into the pad-audio branch
ci / web (pull_request) Successful in 1m0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 1m2s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m51s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 34s
ci / rust-arm64 (pull_request) Successful in 2m48s
android / android (pull_request) Successful in 3m49s
ci / rust (pull_request) Successful in 5m40s
Main had moved 34 commits past the merge-base and 13 files had diverged.
Resolving now rather than later, since the force-feedback sweep work is landing
in the same files.

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

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

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

NOT verified: audio_control.rs and wasapi_cap.rs are cfg(windows), so neither
the Linux container nor xcheck.sh compiles them. Those two resolutions have had
review only and need the Windows runner before this merges.
2026-08-03 19:11:26 +02:00
enricobuehlerandClaude Opus 5 0d5e5b436b fix(android/pad-audio): pin the uac-host that unmutes the pad
ci / web (pull_request) Successful in 59s
apple / swift (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m50s
android / android (pull_request) Successful in 5m55s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 59s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 54s
ci / rust-arm64 (pull_request) Successful in 9m27s
ci / rust (pull_request) Canceled after 14m9s
The pad rendered nothing — not its speaker, not its voice coils — because
`uac-host` streamed into a device it never unmuted. It set the sample rate and
nothing else; the UAC Feature Unit, where Mute and Volume live, was parsed by
nobody. Every counter stayed green throughout: URBs completed, 0 short bytes,
0 URB errors, 0 short writes here, decoded peak 19345. None of them can observe
mute, so a muted device is indistinguishable from a working one.

Bumps the pin to unom-io/usbfs-iso f3de1fd, which sends SET_CUR Mute=0 and
Volume=0 dB to the Feature Unit before the stream starts.

With this in, Spider-Man Remastered's haptics reach the physical DualSense
through the virtual pad, confirmed by feel on real hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:35:27 +02:00
enricobuehler 6fed1510ba test(android): report renderer stats even when the plane is silent
ci / web (pull_request) Successful in 1m2s
apple / swift (pull_request) Successful in 1m16s
ci / docs-site (pull_request) Successful in 1m16s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m30s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m53s
android / android (pull_request) Successful in 4m7s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m30s
ci / rust (pull_request) Canceled after 4m44s
The renderer now reports once a second regardless of traffic — frames in,
samples decoded, peak level, frames written, underruns, short bytes.

The first version reported only after a frame arrived, which made the single
most diagnostic state unreportable: an idle plane and a dead renderer looked
identical (both silent). That cost a debugging round on real hardware, where the
absence of any line had to be triangulated against usbfs interface claims and
`dumpsys input` to work out which of the two it was.

The peak is of the decoded PCM, and it is the discriminator that matters: frames
arriving with peak=0 means the host's capture is hearing silence — a routing
problem upstream — whereas a non-zero peak means real signal is reaching the pad
and anything still wrong is downstream of the write.
2026-08-03 10:01:05 +02:00
enricobuehler e32bd30c85 fix(android): give the renderer its own USB connection, and add a real-world self test
ci / docs-site (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m17s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m3s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m26s
ci / rust-arm64 (pull_request) Successful in 1m28s
android / android (pull_request) Successful in 4m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m21s
ci / rust (pull_request) Successful in 13m11s
**The bug.** The renderer was handed `HidUsbLink`'s file descriptor. That link's
own comment states the hazard exactly — "only one thread may drive a
connection's UsbRequests (requestWait() returns ANY completed request; a second
waiter would steal the reader's completions)" — and it is just as true of the
usbfs reap underneath: the isochronous ring and the HID reader were reaping each
other's URB completions. The standalone harness works because it owns its
descriptor by construction, which is precisely why it could never have caught
this. `DsCapture` now opens a dedicated connection via `openAuxConnection()` and
closes it only after the render thread is joined.

**The test.** Nothing exercised the CLIENT path without a host, so the two things
most likely to be wrong were invisible: whether the descriptor handed over is
exclusively ours, and whether the claim succeeds on this kernel. Neither is
unit-testable and a harness proves neither.

`nativePadAudioSelfTest` drives the voice coils with a tone through the real
path — the same aux connection, claim, sink and write loop the renderer uses —
and is triggered by `adb shell setprop debug.punktfunk.pad_audio_selftest 3`,
matching this repo's existing debug.punktfunk.* convention. It runs INSTEAD of
the renderer for that capture, never alongside it: two engines on one descriptor
is the fault being tested for, and I nearly shipped it into the test itself.

Underruns are deliberately not a failure condition — that is producer pacing.
The pass condition is data reaching the bus.
2026-08-03 00:38:58 +02:00
enricobuehler 2f1ef44191 fix(android): commit the tier-A trade only once the USB stream actually opens
ci / web (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m17s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m1s
ci / rust-arm64 (pull_request) Successful in 2m9s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m12s
android / android (pull_request) Successful in 3m36s
ci / rust (pull_request) Canceled after 4m11s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 55s
A real bug, and the worst shape one can take here: it costs the user ALL
haptics rather than degrading.

`pad_audio::start` returned success as soon as the render thread spawned, and
`nativeStartPadAudio` then declared the pad's render capability and took it off
wire rumble. But `sink::open` runs later, on that thread. On a kernel that
refuses the interface claim — the OEM case documented as needing a clean tier-C
fallback — the pad was already suppressed and the host already streaming 0xD1 at
a renderer that never opened. No pad audio, and no rumble either.

The declaration and the suppression now happen inside the renderer, immediately
after a successful open, and are both withdrawn when it stops. A failed open
declares nothing and suppresses nothing, so the session stays on ordinary rumble
— which is what "degrades to tier C" was always supposed to mean. `PadAudio`'s
Drop clears the tier-A bit too, so a thread that dies unexpectedly cannot leave a
pad permanently mute.

The general rule this violated: never give up a working fallback until the thing
replacing it is known to work. Spawning a thread is not evidence that it will.
2026-08-03 00:34:47 +02:00
enricobuehler 8ee224e5db fix(android): advertise CLIENT_CAP_PAD_AUDIO, without which nothing is ever sent
ci / web (pull_request) Successful in 1m6s
apple / swift (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m35s
ci / docs-site (pull_request) Successful in 1m14s
android / android (pull_request) Successful in 3m2s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m20s
ci / rust-arm64 (pull_request) Successful in 5m27s
ci / rust (pull_request) Successful in 12m30s
A gap in the previous commits, and the same silent-failure shape as the two they
fixed. There are TWO negotiations, not one: the per-pad render capabilities that
ride a gamepad arrival (bits 8/9), which those commits set, and the SESSION-level
CLIENT_CAP_PAD_AUDIO in the Hello, which they did not. Without the latter the
host never sets HOST_CAP_PAD_AUDIO and emits no 0xD1 at all — so the per-pad bits
would have had nothing to gate, and the renderer would have sat on a permanently
empty plane with every other piece looking correct.

Threaded as an explicit `padAudioOk` on nativeConnect rather than advertised
unconditionally: the cap makes a Windows host provision pad endpoints at startup,
and a user who has pad audio switched off should not pay for that.

Found by tracing what an on-glass run against a real host would actually need,
not by a test — there is no test that could have caught it, since both halves are
individually well-formed.
2026-08-03 00:04:14 +02:00
enricobuehler a10bde39bb feat(android): declare pad-audio caps and take tier-A pads off wire rumble
The two things that decide whether WP9 does anything at all on a device, both
failing silently rather than loudly if missed.

**Capability bits.** The host emits 0xD1 only toward pads that declared they can
render it (arrival flags 8/9). Without `set_pad_audio_caps` the renderer would
sit on a permanently empty plane and look like a decode bug. Declared when the
stream opens, withdrawn when it stops.

**Rumble arbitration.** `valid_flag0` bit 1 (HAPTICS_SELECT) *disables* audio
haptics and selects classic rumble, and `DsDevice` sets it on every rumble write
— as Linux's hid-playstation and SDL both do. One replayed rumble command would
mute the voice coils the 0xD1 stream is driving, for the rest of the session.
Tier A and tier C are mutually exclusive in the pad's firmware, so the
arbitration selects and never blends.

Suppression sits at `nativeNextRumble`, the pull point, rather than in Kotlin:
it keeps the rule next to the reason and covers every caller. The registry is an
atomic bitmask because the reader is the rumble poll thread and must not block
behind a start/stop on the JNI thread.

Order matters on teardown: the capability is withdrawn before the pad returns to
wire rumble, so the host has stopped sending 0xD1 before tier C resumes and the
two never overlap.

`nativeStartPadAudio`/`nativeStopPadAudio` now take the wire pad index, since
both the capability and the arbitration are per-pad. Out-of-range indices are
rejected rather than wrapped into another pad's slot.

12 host tests (2 new, including one pinning that an out-of-range index cannot
shift the mask into undefined territory), 0 clippy findings, check clean on all
three Android ABIs.
2026-08-02 23:44:51 +02:00
enricobuehler b5f91d50bb feat(android): tier-A pad audio — the 0xD1 plane on the pad's USB endpoint (WP9)
The Android twin of `pf-client-core`'s pad_audio: drain the host's per-pad
DualSense streams, Opus-decode haptics (kind 0) and speaker (kind 1), interleave
into the pad's own 4-channel layout, and render on the pad itself.

Every other client hands that stream to the platform's audio graph. Android
cannot: AOSP's UsbAlsaManager denylists the DualSense's output by VID/PID, so
the kernel enumerates the pad's playback node and the framework discards it —
`hasOutput: false`, nothing for setPreferredDevice to target, /dev/snd closed by
SELinux, and UsbRequest rejects non-bulk/interrupt endpoints. So this drives the
pad's isochronous endpoint directly via uac-host on the descriptor Java owns.

That is measured, not assumed. On a Nothing Phone (3): the claim succeeds
unprivileged, the gamepad and the pad's microphone both keep working, and the
underrun-free floor is 4 ms — holding under eight-core load with the SoC in
severe thermal throttling. The renderer runs at 6 ms, one step of headroom,
because the same measurement found transient events that are not depth-dependent.

Structured to the crate's own convention: the mixer and PLC are ungated so they
compile and unit-test in the host workspace (8 tests), while everything touching
an Android-only dependency is cfg'd to android. Two details worth review:

- The kinds arrive on different cadences (5 ms vs 10 ms), so each has its own
  write cursor and both shift together on overflow — a haptics-only session
  renders with a silent speaker pair instead of stalling on a kind that will
  never arrive, and the two can never skew.
- An unrecognised kind is dropped rather than folded into the coil pair. A
  `min(1)` clamp would have rendered a future kind straight into the actuators.

Lifecycle mirrors MicCapture: dropping the handle joins the thread, and
nativeStopPadAudio returns only once it has, so Kotlin may close the
UsbDeviceConnection as soon as it returns and not before.

usbfs-iso/uac-host enter as git dependencies pinned by revision — a transport
under a real-time deadline should move when we choose. They become version
dependencies once published to crates.io.
2026-08-02 23:39:25 +02:00
enricobuehlerandClaude Opus 5 c2a6d30d7b fix(android/decode): a codec input slot the feeder can't fill goes back, and so does the AU
deb / build-publish (push) Failing after 2s
deb / build-publish-host (push) Failing after 2s
deb / build-publish-client-arm64 (push) Failing after 3s
apple / swift (push) Successful in 1m19s
ci / rust (push) Successful in 5m52s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
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 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
arch / build-publish (push) Successful in 9m12s
release / apple (push) Successful in 9m14s
ci / rust-arm64 (push) Successful in 1m57s
android / android (push) Successful in 11m23s
windows-host / package (push) Successful in 11m26s
windows-host / winget-source (push) Skipped
ci / docs-site (push) Failing after 11m59s
ci / web (push) Failing after 12m2s
flatpak / build-publish (push) Successful in 6m25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m33s
docker / builders-arm64cross (push) Successful in 5s
docker / deploy-docs (push) Successful in 28s
apple / screenshots (push) Successful in 5m54s
windows-host / canary-manifest (push) Successful in 18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m53s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m1s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m26s
`AMediaCodec_getInputBuffer` returning null for an index the input-available
callback had just handed us dropped both the slot and the access unit on the
floor. Every sibling path in this loop recycles the slot — the orphan-part
discard and the oversize drop both say so in as many words — because nothing was
written and nothing was queued, so it is still ours. Forgetting it leaks one of
the codec's input buffers per occurrence: we never use it again and the codec
never frees what it never received, so the pipeline runs out of input slots,
`pending_aus` overflows into its drop-oldest arm, and the resulting keyframe storm
reads as a decode fault rather than a bookkeeping one.

The AU went with it, silently — no keyframe request, no freeze gate, unlike every
other loss path here — leaving a hole in the reference chain whose concealment
was free to reach the screen.

Both go back now. `break` rather than `continue`, because a codec that cannot
hand out an input buffer it has just advertised is in no state to be fed the rest
of the parked queue on this pass, and retrying the same index against every
parked AU would burn the whole backlog for nothing; the loop comes round again on
the housekeeping wake within 5 ms if it was transient.

Gates: cargo ndk check green on arm64 and armv7, fmt clean, Android clippy at the
same 4 pre-existing warnings as the base commit.

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:07:06 +02:00
enricobuehler 951bcec650 Merge remote-tracking branch 'origin/main' into audio/mic-latency-echo 2026-08-01 00:47:50 +02:00
enricobuehlerandClaude Fable 5 c767a904d2 feat(android): the decode stage answers where its time goes, HUD on or off
P3 decode science: every AU is stamped as its last piece enters the codec, so
the decode stage splits into feed (received→queued: hand-off + input-slot wait)
and codec (queued→decoded: the decoder alone — a slice head start would show
here). The split + an always-on capture→decoded e2e ride the 1 Hz pf-present
line, so a wireless HUD-off A/B reads everything from logcat; the HUD equation
gains the split (indices 30/31), the skipped counter tells benign newest-wins
pacing from parked-AU overflow (32), and a −2-refresh Apple-HUD-equivalent twin
makes iPhone comparisons honest (Apple shaves its OS floor; Android shows raw).

Connect now logs the per-mime decoder picks + FEATURE_PartialFrame verdicts
(tag pf.caps) — on the NP3 all three c2.qti low-latency decoders say no, so
parts delivery never arms and P2d is inert there; a debug.punktfunk.force_parts
sysprop overrides the probe for the on-glass question the API cannot answer.
Forced on glass: c2.qti accepts PARTIAL_FRAME pieces without erroring but only
assembles them — codec time unchanged, so the overlap is dead on SM8735 either
way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:40:58 +02:00
enricobuehlerandClaude Fable 5 8e877ad25f fix(android): a mute is a pause, not a hole the host tries to conceal
The Android uplink kept advancing `seq` while muted, so the first frame
after an unmute looked to the host like loss the width of the mute. The
de-jitter reads that as a gap: up to five concealment frames of stale
voice, and a seq gap counted in the uplink-health line. Past 600 ms the
pump's stale flush resets the chain first and hides it, which is why the
usual long mute looks fine — a quick toggle does not.

Freeze `seq` while muted, as the desktop uplink already does, so the
frame after an unmute continues the chain. `reset_stream` says it
plainly: a pause is not loss, and must not conceal or count a gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 23:15:03 +02:00
enricobuehlerandClaude Fable 5 253e0bbe7c feat(android): the mic has an off switch you can reach mid-stream
Wave 1 gave the Android client a mic worth using. It gave it no way to stop
talking: leaving the stream, or digging through Settings to turn the whole
feature off, were the only ways to stop the room being heard. Now a tap (or
Select + Y on a pad) mutes it, and the screen says so while it lasts.

How muting gates the capture, and why that way. The AAudio input stream is
never stopped: a stop/start would re-run the input-preset fallback ladder and
re-prime the buffers on every toggle — hundreds of milliseconds, and possibly
a landing on a different rung, silently losing the HAL echo canceller wave 1
went to some trouble to get. Instead the encode loop reads an AtomicBool per
10 ms frame and, while it is set, drains the frame out of its ring and drops
it there — the last point before it would have become an Opus packet. Nothing
is encoded, nothing is sent, and the realtime capture callback is untouched,
so its allocation-free discipline and the queue policy stay exactly as wave 1
verified them. A toggle costs one atomic store and takes effect on the next
10 ms boundary.

The frame counter keeps advancing across a mute, because it numbers the
captured 10 ms TIMELINE rather than the datagrams. The gap the host then sees
is exactly the audio that never came: its de-jitter conceals at most a few
frames of it before the pump's 600 ms stale-gap flush resets the chain
outright, which is the right reading of a mute. Encoding silence instead
would have kept a pointless uplink and a host-side ring alive for its whole
duration.

Mute is per session and nothing is persisted — a new stream always starts
unmuted, and no new setting exists. The flag lives on the session handle
rather than on the capture, so the mic stop/start a surface recreate performs
brings the user's choice back with it, with no window in which the fresh
capture could send an unmuted frame.

The control is offered on the evidence that a capture is actually running
(nativeMicActive), not on the setting: with the mic disabled, RECORD_AUDIO
denied, or every AAudio input rung refused, there is nothing on screen to
lie about. On touch it is a pill in the corner the stats HUD doesn't use —
the one in-stream control, so it sits above the gesture layer to take its own
taps — dim while live, a red "Muted" badge while it isn't. On TV that badge
is the indicator alone: Select + Y is the control there, and a focusable
button would fight the game for the D-pad. Y is deliberately not one of the
exit chord's buttons, so neither chord can be reached through the other.

One honest consequence of keeping the stream open: the platform's recording
indicator stays lit while muted, because the mic really is still open. What
stops is the encode and the send.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 22:41:09 +02:00
enricobuehlerandClaude Fable 5 a681de7e5c feat(android): the mic goes through the echo canceller, and the host stops hearing itself
The capture stream opened under AAudio's default VoiceRecognition input
preset, which deliberately bypasses the HAL's acoustic echo canceller —
so a phone playing the game audio out of its own speaker fed that audio
straight back to the host. Two layers fix it, both behind a new "Echo
cancellation" setting (default ON, next to the Microphone toggle in the
touch and console settings, per-profile like every tier-P setting):

- Native: the mic opens under the VoiceCommunication preset (HAL AEC/NS
  on the capture path) and allocates an audio session id. The open
  ladder is Exclusive+voice → Shared+voice → Exclusive → Shared — some
  HALs refuse the preset or a session id outright, and a mic without
  echo cancellation still beats no mic; the last rungs are exactly the
  preset-less open this always did.

- Kotlin backstop: nativeStartMic now returns the allocated session id
  (0 = none), and StreamScreen hangs the Java AcousticEchoCanceler +
  NoiseSuppressor off it (guarded by isAvailable), releasing them on
  every mic-stop path — the surface teardown and the final dispose — so
  a surface recreate re-attaches instead of leaking effect engines.

The playback stream is untouched: retagging it voice/communication
would route it through the phone-call chain and regress quality.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:53:01 +02:00
enricobuehlerandClaude Fable 5 a7d4213778 feat(android): the mic uplink drops to mono 10 ms frames, and stops hoarding stale audio
Latency, three ways, all inside mic.rs:

- 48 kHz stereo 20 ms becomes mono 10 ms: speech gains nothing from a
  second channel, the shorter frame shaves a buffering interval off the
  uplink, and the host already decodes any Opus frame <= 120 ms with its
  stereo decoder (mono packets upmix) — no protocol change. The encoder
  follows: 48 kbps, complexity 5, in-band FEC at an assumed 10% loss so
  a dropped datagram reconstructs from its successor instead of a hole.

- The latency ratchet is gone: the capture callback drops the NEWEST
  chunk when the hand-off channel fills, so an encode-side stall used to
  convert into standing mic delay that never drained. The encode loop
  now drains the whole backlog in one lump and, past ~60 ms, jumps to
  the newest ~20 ms (one audible blip, live again), counting what it
  shed in the periodic log line. The realtime callback stays exactly as
  allocation-free as it was.

- The encode thread registers with the client's hot-thread set, so the
  ADPF session keeps mic encode on a fast core alongside audio decode.

No .frames_per_data_callback() pin: AAudio's own docs say leaving it
unset is the lowest-latency path (the callback runs at the device's
optimal burst), and the encode side re-chunks to 10 ms frames anyway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:46:44 +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 c002ca8746 fix(clients): pen batches split at the wire cap and the heartbeat survives tracking mode
Three drawing-path defects on the pen clients:

- The Apple in-range heartbeat was re-armed on every emit (~120 run-loop
  mutations/s during a stroke) and lived in .default run-loop mode only — a
  tracking-mode excursion >200 ms with a stationary pen crossed the host's
  force-release failsafe and dropped the held stroke. Now one long-lived
  50 ms timer in .common mode, resending only after ≥50 ms of send silence.
- Both clients TRUNCATED an over-8-sample coalesced run instead of splitting
  it into consecutive batches (the send_pen contract): Apple suffix(8)
  dropped the oldest samples, the Android JNI clamped count. An over-cap run
  means the UI thread hitched — exactly when dropping stroke geometry hurts
  most. Apple splits in emit(); the Android JNI loops send_pen over ≤8-sample
  chunks (Kotlin's per-emit ceiling is now 64 = a >250 ms stall).
- The iOS letterbox mapper did a lock+FFI currentMode() read per coalesced
  sample on the main thread, contending the ABI lock the batch send takes
  next — now a 250 ms TTL cache (mid-stream requestMode still tracked).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:41:15 +02:00
enricobuehlerandClaude Fable 5 780791ed81 feat(android): the presenter's latch margin starts at zero and earns its width
The 2.5 ms submit margin ahead of expected present assumed
SurfaceFlinger latches well before the vsync. On-glass (NP3, 120 Hz)
that lead is not needed: with the margin at zero every release still
made its vsync (paced=0 across full sessions) - meaning the constant was
2.5 ms of pure display latency on every frame, and it was quantizing
away exactly the readiness gains the slice-overlap pipeline just bought.
e2e on the Linux-host pairing dropped from 22-24 ms to 14-18 ms.

The margin is now adaptive instead of assumed: it starts at 0 and only
widens (+500 us per 1 Hz window, capped at the old 2.5 ms) when the
paced counter shows real latch misses - one-way per stream, so a margin
that proved necessary is never re-gambled mid-session. A device that
needs lead converges within seconds; a device that doesn't keeps the
full win. debug.punktfunk.latch_margin_us (0..=8000) pins the margin
for rebuild-free sweeps, and the presenter logs the resolved mode at
stream start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:34:31 +02:00
enricobuehlerandClaude Fable 5 4240b76182 feat(android): slices feed the decoder as they arrive, not when the AU closes
The decode loop used to hold every access unit until its last wire
packet landed. With slice-progressive delivery (frame_parts) each newly
contiguous piece now goes straight into MediaCodec under
BUFFER_FLAG_PARTIAL_FRAME, the closing piece drops the flag, and the
decoder chews the front of the frame while its tail is still on the
wire.

Feeding partial input means owning its failure modes without a codec
flush: a broken sequence (dropped piece, orphan, oversize) closes the
dead AU with an empty non-partial buffer at its own pts, arms the
re-anchor freeze so the concealed output never reaches glass, and
requests a recovery keyframe - the same machinery ordinary loss already
rides. Per-AU accounting keeps its units: the RFI gap detector notes an
AU once, and the HUD stamps, host/network split and the phase-lock
arrival sensor ride only the completing delivery.

The opt-in is decoder truth and loop truth: every decoder this device
would use must pass FEATURE_PartialFrame, and only the async loop may
see parts - the legacy sync loop feeds whole AUs and stays that way.
Smoke-checked on-glass (NP3 vs the Windows host): byte-identical
degenerate path, presenter profile unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:34:31 +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 43868af1f5 feat(android): the client advertises multi-slice tolerance per-decoder
The P0 gating branch deliberately left Android off VIDEO_CAP_MULTI_SLICE
("embedder-set decoder truth") — this is that deferred item, and the P2
slice-pipeline prerequisite. VideoDecoders.multiSliceTolerant() probes the
pick for every advertised codec: tolerant unless any pick is an Amlogic
decoder (the 0.17.0 device-rebooting wedge) or uninspectable (a null pick =
platform default → conservatively single-slice). nativeConnect carries the
bit; the JNI ORs it into the Hello's video_caps beside the panel-truth
HDR bits. Phones (Qualcomm/Exynos) advertise it; the Amlogic TV boxes the
gate exists for never will.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:14:46 +02:00
enricobuehlerandClaude Fable 5 759aac255b feat(phase-lock): controller v3 — grid-locked submits, arrival sensing, antipode damping
ci / docs-site (push) Successful in 1m17s
ci / web (push) Successful in 2m14s
ci / rust-arm64 (push) Successful in 2m54s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 25s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
deb / build-publish-client-arm64 (push) Successful in 2m59s
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 10s
apple / swift (push) Successful in 4m48s
deb / build-publish-host (push) Successful in 4m51s
android / android (push) Successful in 5m31s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 31s
docker / builders-arm64cross (push) Successful in 12s
docker / deploy-docs (push) Successful in 35s
deb / build-publish (push) Successful in 6m0s
ci / rust (push) Successful in 9m53s
arch / build-publish (push) Successful in 10m32s
windows-host / package (push) Successful in 19m10s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m2s
apple / screenshots (push) Successful in 21m3s
Midday on-glass falsified v2's remaining assumptions: a per-frame additive
hold SATURATES the arrival-slaved loop once hold + work >= interval — submits
free-run at their own cadence and the commanded phase dissolves (measured:
+-2 ms hold steps, zero client response) — and the latch statistic hovers at
the target's ANTIPODE, where shortest-way errors sign-flip on noise (the
0<->2<->4 ms offset chatter).

v3, one change per falsified assumption:
- ACTUATOR: submits lock to an absolute grid (epoch + k x period + offset);
  the controller walks only the offset. A periodic grid cannot free-run —
  occupancy is one frame per period whatever the offset — so phase actuation
  is linear BY CONSTRUCTION. Disengaged = no grid sleeps = zero cost; every
  failure path DISENGAGES (never parks: v2's e2e-tax lesson).
- SENSOR: the client reports the circular mean + coherence of the ARRIVAL
  lead (per-AU reassembly stamps vs the panel latch grid) — the phase the
  host actually controls; latch measured downstream of the decoder pipeline,
  which absorbed the actuation. pf.phase logs "arrival lead circ= coh=".
- ANTIPODE DAMPING: errors within 1 ms of +-period/2 take half-steps until
  they commit to a side.

Harness rewritten around plants glass validated: a GRID plant (linear by
construction), the DECOUPLED plant (v2's saturation — must disengage, not
orbit or park), an antipode start (must converge within one period of
travel), incoherence (never engages), a regime change (re-engages after
backoff), and the actuator's own periodicity/offset-linearity. Also fixes
the harness's SIM_TARGET (a mis-derived max asserted 3.5 ms where the
controller's actual target is 2.5 — the controller was right, the ruler
was wrong; the Python cross-check inherited the same constant, a lesson in
replicating the CODE, not the author's belief about it).

Gates: v3 simulations 6/6; host bin suite 345/1 (the documented
environmental qemu UDP test); clippy --all-targets -D warnings clean;
cargo ndk arm64 check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 12:20:39 +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 fe552a50bb fix(android): the phase reporter actually sends the v2 circular statistic
ci / rust-arm64 (push) Successful in 2m45s
ci / web (push) Successful in 1m15s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
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 11s
ci / docs-site (push) Successful in 1m29s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 37s
docker / builders-arm64cross (push) Successful in 18s
android / android (push) Successful in 6m17s
ci / rust (push) Successful in 9m44s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 12m57s
docker / deploy-docs (push) Canceled after 0s
The v2 client edit missed its anchor against rustfmt's reshaping and the
reporter kept sending the v1 median — caught by the arm64 build the moment
the tuple signature landed. Sends (circular mean, coherence) now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:58:19 +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 a5896f0883 feat(android): the phase-lock ACK is logged — the closed loop reads out in logcat
ci / web (push) Successful in 1m6s
ci / docs-site (push) Successful in 2m8s
deb / build-publish (push) Successful in 4m17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
deb / build-publish-host (push) Successful in 4m25s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
ci / rust-arm64 (push) Successful in 5m46s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
deb / build-publish-client-arm64 (push) Successful in 3m40s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 31s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 30s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 50s
arch / build-publish (push) Successful in 7m59s
android / android (push) Successful in 8m20s
ci / rust (push) Successful in 9m33s
apple / swift (push) Successful in 5m3s
windows-host / package (push) Successful in 12m57s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 30s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m59s
apple / screenshots (push) Successful in 21m18s
The host's applied hold rides the 0xCF tail but nothing client-side showed
it; 'adb logcat -s pf.phase' now logs transitions. This line is what exposed
the dead-median controller orbit on the first on-glass run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:12:56 +02:00