d026e50a4b78880ea2abcdf95f054cf9d78ac617
1357
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d6b9462092 |
fix(client): pad_audio reached for a module name Windows doesn't have
`audio_wasapi.rs` is mounted as `crate::audio` (lib.rs, `#[cfg(windows)] #[path = "audio_wasapi.rs"] pub mod audio;`), so there is no `crate::audio_wasapi` path to reach it by. `pad_audio.rs` used one anyway, which means pf-client-core has not compiled for Windows since |
||
|
|
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. |
||
|
|
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. |
||
|
|
92f617a989 |
Merge remote-tracking branch 'origin/main' into worktree-haptics-m12-dry
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m28s
ci / docs-site (pull_request) Successful in 2m30s
android / android (pull_request) Successful in 3m58s
ci / rust-arm64 (pull_request) Successful in 5m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m6s
ci / rust (pull_request) Successful in 8m47s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m10s
# Conflicts: # clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt # crates/pf-client-core/src/gamepad.rs |
||
|
|
62d35bc4b6 |
Merge pull request 'fix(core/wire): a truncated trigger datagram stops cancelling the effect it should carry' (#45) from worktree-haptics-m10-wire into main
android / android (push) Canceled after 7s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (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 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
arch / build-publish (push) Canceled after 2m57s
deb / build-publish (push) Canceled after 2m52s
deb / build-publish-host (push) Canceled after 2m24s
deb / build-publish-client-arm64 (push) Canceled after 1m38s
flatpak / build-publish (push) Canceled after 6s
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
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
|
||
|
|
fcf4076eb7 |
Merge remote-tracking branch 'origin/main' into worktree-haptics-m9-richfb
ci / docs-site (pull_request) Successful in 1m14s
ci / web (pull_request) Successful in 2m10s
apple / swift (pull_request) Successful in 1m29s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m45s
android / android (pull_request) Successful in 4m54s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m17s
ci / rust (pull_request) Successful in 7m59s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m0s
# Conflicts: # clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt # crates/pf-client-core/src/gamepad.rs |
||
|
|
53eb592c43 |
Merge pull request 'fix(host/pads): a centred stick reads centred, and a delayed effect waits its turn' (#43) from worktree-haptics-m8-proto into main
deb / build-publish-client-arm64 (push) Successful in 1m17s
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 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
android / android (push) Canceled after 0s
ci / rust-arm64 (push) Successful in 2m58s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
ci / web (push) Successful in 2m52s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 3m8s
ci / docs-site (push) Canceled after 2m56s
deb / build-publish (push) Canceled after 2m50s
deb / build-publish-host (push) Canceled after 2m48s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 13s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 18s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
|
||
|
|
956d8dd8ef |
Merge pull request 'fix(host/windows): two virtual pads stop tearing each other's reports' (#39) from worktree-haptics-m7-windows into main
deb / build-publish-host (push) Canceled after 4s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
android / android (push) Canceled after 10s
deb / build-publish-client-arm64 (push) Canceled after 4s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
ci / rust (push) Canceled after 10s
ci / rust-arm64 (push) Canceled after 7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
ci / web (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 14s
ci / docs-site (push) Canceled after 0s
deb / build-publish (push) Canceled after 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 14s
docker / builders-arm64cross (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 14s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-drivers / probe-and-proto (push) Successful in 33s
windows-drivers / driver-build (push) Successful in 1m58s
|
||
|
|
b2e716ad5f |
Merge pull request 'fix(client/desktop): the Deck keeps its trackpad, and a pad stops buzzing at exit' (#38) from worktree-haptics-m6-presenter into main
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
android / android (push) Canceled after 0s
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
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
apple / swift (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 27s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 4m25s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
|
||
|
|
ffd5a33598 |
Merge pull request 'fix(core/rumble): the Deck's keepalive stops being swallowed by its own renewals' (#30) from worktree-haptics-m3-rumble-engine into main
windows-host / package (push) Canceled after 1m3s
windows-host / canary-manifest (push) Canceled after 0s
deb / build-publish (push) Canceled after 58s
deb / build-publish-host (push) Canceled after 56s
deb / build-publish-client-arm64 (push) Canceled after 49s
windows-host / winget-source (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
apple / swift (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
arch / build-publish (push) Canceled after 56s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 49s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
android / android (push) Canceled after 7s
release / apple (push) Canceled after 5m31s
|
||
|
|
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. |
||
|
|
b31495bea5 |
fix(host): a leftover Sunshine folder is not a conflict, and a crashed host gives the screen back
ci / rust (pull_request) Successful in 9m53s
ci / rust-arm64 (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
apple / swift (pull_request) Successful in 1m20s
ci / docs-site (pull_request) Successful in 1m12s
android / android (pull_request) Successful in 2m56s
ci / web (pull_request) Successful in 2m0s
Three things a field report (Discord, upgrade from 0.1x) turned up, all on the
Windows host.
1. "It thinks I have Sunshine/Apollo running." It didn't — they were uninstalled.
Both uninstallers leave their config/log directory in Program Files behind, and
`detect.rs` counted a bare directory, or a service registered at ANY start type
(including `disabled`), as a live conflict. The installer's own probe was
narrowed to "service start type <= 2" after exactly this cried wolf on a
`winget install`, and the tray dropped its always-on warning for the same reason
in
|
||
|
|
1db7058a5d |
feat(clients/input): system buttons route around local overlays
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m16s
ci / rust-arm64 (pull_request) Successful in 3m20s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 59s
ci / web (pull_request) Successful in 1m36s
ci / docs-site (pull_request) Successful in 2m7s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
ci / rust (pull_request) Successful in 10m36s
Pressing guide/Steam/QAM collided with the client device's own shell: iOS 26 opens its Game Overlay for the Home press (no app opt-out until iOS 27 makes it a user setting), and a Gaming-Mode client opened BOTH Steam overlays for one press — the local one covering the stream. Two cross-client tier-P settings, zero wire changes: - system_buttons (auto|forward|local): raw guide+misc1 passthrough. Auto forwards everywhere EXCEPT under gamescope, where SteamOS reacts to the same physical press no matter what. - guide_gesture (auto|on|off): hold Select ALONE ~350ms sends the HOST's guide, down until release — held on, that's the host's long-press, which opens a Gaming-Mode host's QAM for regular pads. A Select tap is delivered on release with its up TAP_PRESS (50ms) behind, because per-transition sends fold into seq'd GamepadState snapshots and a back-to-back pair can coalesce into no press at all. A Select inside a combo (the escape chord) passes through untouched. Auto arms it only where the raw press can't reach the host cleanly: gamescope, iOS/iPadOS, tvOS. The same SelectGesture rules live in pf-client-core (pure state machine + unit tests), the Apple client (mask-diff adaptation in GamepadCapture), and Android's GamepadRouter. Settings rows on every surface (GTK, WinUI, console UI, Decky, Apple x2, Android x2) with profile plumbing throughout. punktfunk-session grows a control socket ($XDG_RUNTIME_DIR[/app/$FLATPAK_ID]/punktfunk-session-ctl.sock — the one runtime path a flatpak and the host see identically): 'guide'/'qam' verbs inject synthetic taps. The Decky panel gains a Host menus section (visible while the client runs) whose buttons press the host's Steam/QAM and close the local menu so the host's shows through. iOS 27's GCControllerHomeButtonSettingsManager deep-link is a TODO (the class needs the Xcode 27 SDK to compile). Docs: input, client-settings, steam-deck. Design: punktfunk-planning design/system-buttons-routing.md. Gates: docker clippy --all-targets --locked -D warnings + tests (pf-client-core 88 incl. 6 new gesture tests, pf-console-ui 47), cargo fmt --all --check, swift build (macOS), gradle kit+app compile, decky tsc --noEmit + py_compile. clients/windows not compiled (no box). |
||
|
|
0d407a866d |
fix: a host that changed DHCP lease could no longer be streamed from the panel
ci / rust (pull_request) Successful in 7m15s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m58s
ci / web (pull_request) Successful in 1m7s
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m54s
ci / rust-arm64 (pull_request) Successful in 3m5s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m16s
android / android (pull_request) Successful in 5m2s
An adversarial review of this branch found a regression I introduced, plus three smaller defects. All four are fixed here, each verified on .21. **The regression.** `mergeHosts` names a host by its record's stable id, and `hosts list --json` always emits one (`KnownHosts::load` mints ids for every record). So a launch always went out as `punktfunk launch <uuid>` → `ConnectPlan::for_host` → `HostTarget::from(&KnownHost)`, which copies the address stored ON THE RECORD. Meanwhile the panel deliberately renders the LIVE advert's address. Nothing on a Deck ever writes a moved address back — `discover` and `hosts list` are both reads, and only the desktop shells' hosts pages update one. So after any DHCP move the row read "online" at the new address and every press dialled the old one: a 15 s dead connect, or — if a MAC had ever been learned — a black Steam "game" for the full 90 s wake budget. Proven with a stub session binary: `launch abc-123` emitted `--connect 10.0.0.5:9777` for a host answering at `10.0.0.99`. This worked on origin/main, which dialled `toHost(v).host` — the advert's address. The fix restores that without giving up stable ids: `hosts add <new-addr> --fp <known-fp>` now MOVES the matching record instead of filing a second one (the fingerprint is the identity — this is the same rule that makes the verb idempotent), and the panel re-points a host it can see has moved before launching it. Verified: `moved 10.0.0.5:9777 to 10.0.0.99:9777`, one record still, and `launch abc-123` then emits `--connect 10.0.0.99:9777`. **"No hosts yet" was also how a missing client looked.** `_cli_argv()` returning None becomes `client-unavailable`, which the panel dropped on the floor — so a Deck with no client installed was told its network was empty, under a button that launches the client that isn't there. It now says which of the two it is. **The browse worker never exited on a quiet LAN.** `discover_for` drops the receiver and the doc claimed that stops the thread. It does not: the worker parks in `recv()`, and the arms that ignore an event (`SearchStarted`, `ServiceFound`, `SearchStopped`, a v6-only advert) never touch the sender, so on a LAN with no Punktfunk host nothing ever wakes it. Harmless today because the only caller is a short-lived CLI process, but the function invites in-process use, where it would leak a thread and an mDNS daemon per call. Now polled with a 250 ms tick and a check at the top of the loop. Verified: ten back-to-back browses settle back to the baseline thread count. **A `pair=optional` host was recorded as paired.** Every unsaved host now goes through the trust sheet (it has no pin, so it cannot stream without one), but the sheet's only non-PIN action ran `--request-access`, which persists `paired: true` on Ready. An optional host admits anyone who pins its identity — there is no operator decision, so nothing was approved and the same box read "paired" here and "trusted" in the desktop client. Such a host now gets **Connect** instead, which pins and streams without claiming an approval, and the "approve this Deck" toast is no longer shown to someone who has nobody to ask. Also: `PF_CLIENT_BIN` was the one launch-option value never validated — a client installed under a path with a space would split Steam's tokenizer. |
||
|
|
414380fc9e |
fix(cli): discover reads the host store without writing to it
`KnownHosts::load()` mints a stable id for any record that lacks one and SAVES it — which makes
it a write, and `discover` was calling it purely to annotate what the browse found with
saved/paired. It never hands those ids back to anyone.
That matters because the Decky panel issues `discover` and `hosts list` together, in parallel.
Against a store written before ids existed, both processes read it, both mint DIFFERENT ids for
the same record, and both save. Whichever loses the race has already handed its ids to its
caller — so the panel could draw a row whose host reference no longer resolves, and pressing it
would exit 5 ("no saved host matches") until the next refresh settled things.
`KnownHosts::read()` is `load` without the mint: the store exactly as it is on disk. `discover`
uses it; every caller that dials a host by id still uses `load`, so ids are still minted the
first time anything needs one.
Verified on a fixture store with no ids: `punktfunk discover` leaves it byte-identical, and a
following `punktfunk hosts list` mints as before.
|
||
|
|
2d43275fcb |
fix(core/abi)!: stop exporting 149 unprefixed macros into every embedder's namespace
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 1m15s
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m10s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m57s
ci / rust (pull_request) Successful in 6m25s
ci / rust-arm64 (pull_request) Failing after 15s
ci / web (pull_request) Successful in 1m6s
BREAKING (C header only): constants such as MAX_PADS, TAG_LEN, ABI_VERSION, INPUT_MAGIC and the whole BTN_/AXIS_ family are now PUNKTFUNK_-prefixed. cbindgen emits a bare #define per `pub const`, so those names landed in the namespace of every C program that includes the header. The rename table already said this was the rule and already carried the handful someone had noticed — and its own comment spells out why it matters: a clashing #define silently takes the last definition rather than failing to compile, so the failure mode is a wrong value, not a build error. This is the remaining 149. Associated constants are deliberately left alone. cbindgen already qualifies those with their type name, which is the very property whose absence makes a bare MAX_PADS dangerous — they are namespaced, just not by us. Nothing in this repository consumed the unprefixed spellings except one Swift test, which sat next to lines already using the prefixed form because its constant happened never to have been added to the table; it is updated here. The C harness links and runs against the regenerated header. Scheduled deliberately: the sweep flagged this for a release boundary, and 0.24.0 has shipped. External C embedders using the old spellings must add the prefix; there is no silent breakage, since the old names simply stop existing. |
||
|
|
77ddd05b13 |
fix(core/wire): a truncated trigger datagram stops cancelling the effect it should carry
Three wire and ABI faults. An out-of-range pad index reached one rumble consumer and not the other. It skipped the reorder gate — the per-pad seq cursor has no slot for it — and was handed to the legacy queue, while the policy engine discarded it on its own bounds check, so the comment promising both consumers are fed was false for exactly these. An embedder draining the queue could be handed an index it would use to subscript its own per-pad array. The host never emits one, so it is malformed or hostile either way; both consumers now agree by dropping it before either sees it. The adaptive-trigger effect was the only variable-length wire field bounded on neither side. Encode appended whatever it was handed and decode took the whole tail, while its sibling raw-report field had been bounded both ways all along; there is now one constant both sides clamp to. Worse than the missing bound was the empty case: a body with no effect bytes decoded as an EMPTY effect, and downstream an empty block is written as an all-zero trigger report, which is mode 0x00 — release. A truncated datagram could therefore silently cancel the trigger effect a game was holding. That shape is now rejected outright; a genuine release is a full-length zero block and still decodes. The C ABI history had a hole and a symbol nobody versioned. v11 shipped without its line, and the rumble policy engine's C surface was added while the version constant still read 7, with no bump at all — so every core since has exported those symbols while advertising a number that never promised them. A shipped binary says what it says, so that cannot be corrected backwards; v15 instead establishes the floor that guarantees the surface, and the v11 line is written down. No code changed for the bump and nothing moved on the wire. |
||
|
|
a9a514dea0 |
fix(feedback): the pad stops keeping a game's trigger effect after the stream ends
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m0s
ci / docs-site (pull_request) Successful in 1m24s
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m34s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m5s
ci / rust-arm64 (pull_request) Successful in 3m26s
android / android (pull_request) Successful in 4m9s
ci / rust (pull_request) Successful in 7m51s
Two faults in the rich-feedback plane — the lightbar, player LEDs and adaptive triggers — both of which leave a controller physically wrong with nothing to put it right. Nothing reset the pad on teardown. Rumble stops on its own the moment nothing renews it, but the rich planes are LATCHED in the controller's firmware: they outlive the stream, the app, and being unplugged. Ending a session while a game held a weapon's trigger resistance left the physical trigger stiff on the desktop afterwards, and its lightbar showing whatever the game last set, until another game happened to set one. The Apple client already reset on teardown; the desktop and Android halves now do too — triggers to mode 0x00, lightbar dark, player indicator cleared. Android writes them EP0-direct like its rumble stop, because the reader thread is stopping and the queue would never drain. A single lost datagram stranded the pad on the previous value. The plane is deduped AND rides unreliable datagrams, which is a bad pairing: a change is forwarded exactly once, so when that datagram is dropped nothing re-derives it — the game keeps sending the same value and the dedup swallows every copy. The pad then holds the last weapon's trigger effect, or the last lightbar colour, for as long as the game keeps that setting, which can be the rest of a level. The dedup already remembers the current state, so it can repair itself: it now re-emits what it has latched once a second. Slow on purpose — this is a repair mechanism, not a transport, and every value is idempotent, so a client that did receive the original simply re-applies it. A forward re-stamps the clock, so a plane the game is actively driving never pays for a renewal it does not need. One-shot pulses are deliberately excluded from that renewal: replaying a trackpad haptic would be a new pulse, not a repair. Raw passthrough reports are excluded too — the device's own refresh cadence already re-sends them verbatim. |
||
|
|
48bb1769b4 |
feat(cli): punktfunk discover — browse the LAN, annotated against what you've saved
The CLI could do everything with a host except FIND one, so every headless consumer grew its own mDNS: the Decky plugin parses ~120 lines of avahi TXT escaping in Python, which drifts from the host's advert every time a key is added and makes the plugin depend on Avahi being the resolver. `discovery::discover_for(timeout)` is the bounded collector beside the streaming `browse()` the UI uses — same service type, same TXT keys, folded to one row per host. A refreshed advert wins (it carries the newer address), a removal drops the row, and dropping the receiver on the way out stops the worker so a one-shot call can't leak a browse per invocation. The verb annotates each hit against the saved-hosts store rather than handing back two lists to join: `saved`/`paired` are answered by fingerprint first and address second — the same rule every other surface uses. That is what stops a host that moved DHCP lease from reading as new, and stops a different box that inherited the old address from reading as paired. punktfunk discover [--json] [--timeout SECS] Default 3 s, capped at 30 — this is called from a Quick Access panel, and a typo'd `--timeout 3000` would hang that panel with no way to cancel. An empty LAN exits 0: a caller branching on the code is asking whether the browse ran, and it did. |
||
|
|
454fa2e0cb |
Merge pull request 'feat(gamepad-ui): profiles integration — pinned cards, pin management, settings section on all three gamepad UIs' (#42) from worktree-gamepad-ui-profiles into main
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 13s
deb / build-publish-host (push) Successful in 4m30s
android / android (push) Successful in 7m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m50s
deb / build-publish (push) Successful in 5m31s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m5s
docker / builders-arm64cross (push) Successful in 5s
apple / screenshots (push) Successful in 5m57s
arch / build-publish (push) Successful in 8m37s
release / apple (push) Successful in 9m17s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m55s
ci / rust (push) Successful in 8m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m19s
docker / deploy-docs (push) Failing after 9m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m30s
apple / swift (push) Successful in 1m32s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 52s
ci / web (push) Successful in 1m8s
flatpak / build-publish (push) Successful in 27m16s
ci / rust-arm64 (push) Successful in 1m48s
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 18s
ci / docs-site (push) Successful in 1m51s
deb / build-publish-client-arm64 (push) Successful in 1m21s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
Reviewed-on: #42 |
||
|
|
6e001e54b4 |
fix(host/pads): a centred stick reads centred, and a delayed effect waits its turn
android / android (pull_request) Successful in 9m9s
apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 11m18s
ci / web (pull_request) Successful in 2m37s
ci / docs-site (pull_request) Successful in 2m8s
ci / rust-arm64 (pull_request) Successful in 4m2s
Four encoder faults, plus a note on a fifth that turned out not to be one. A centred stick did not encode as centre on the Y axes. The mapper inverted the already-quantised byte, and 0..255 has no exact midpoint: the forward map puts centre at 0x80, so mirroring the output lands on 0x7F — one below the 0x80 that DsState::neutral and the pad's own resting report use. Games idle-poll a centred stick constantly, so a DualSense, Edge or DS4 sat under a permanent sub-deadzone tilt. Inverting in i16 space instead maps centre to centre by construction and keeps both extremes exact; the only cost is i16::MIN and -32767 sharing a code, one LSB at the very end of the travel. Force-feedback ignored replay.delay. It was decoded on upload and never read: an effect started the moment it was played and ended replay.length later, so anything scheduling a delayed effect — DirectInput under Wine does this routinely — fired early AND finished early by the same amount. The delay now shifts the whole window, with length measured from the end of the delay rather than eaten into by it. Writing the test for that surfaced a second bug in the same path: a waiting effect was still a candidate for the abandoned-effect force-off, and since the play command is itself the last FF activity, an infinite effect with a delay longer than the idle window would be killed on its first contributing tick after sitting silent the whole time it waited. Being abandoned now requires the effect to have been audible for the window too. An empty serial panicked the service thread. The reply builder clamped the length to at least 1 and then sliced that many bytes out of the string, which asks a zero-byte slice for one byte. The kernel already has a graceful answer for a length it rejects, so report the true one and let it fall back. Deck triggers could not reach full pull. Scaling by 128 tops out at 32640 of a declared 32767, leaving the last 127 counts unreachable, so no game could ever see the axis bottom out. One multiply gets both ends exact. The idle watchdog is left alone. It does cut finite multi-second effects that the uinput path exempts, but only the uinput path is handed an explicit duration; the protocols behind the watchdog are level-triggered with no duration field anywhere in a report, so there is nothing at that layer to exempt. The choice is between cutting a long effect and letting an abandoned one drone forever, and only the latter has field evidence behind it. Recorded at the constant so the next reader sees the cost rather than rediscovering it. |
||
|
|
9f1f23eb40 |
Merge pull request 'feat(wire): mid-session shard-payload renegotiation — the black screen heals in seconds, jumbo behind an opt-in' (#41) from worktree-shard-payload-reneg into main
release / apple (push) Canceled after 2m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-host / canary-manifest (push) Successful in 16s
windows-host / package (push) Successful in 11m13s
windows-host / winget-source (push) Skipped
apple / swift (push) Successful in 1m26s
android / android (push) Canceled after 3m42s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 3m45s
ci / rust (push) Canceled after 3m28s
ci / rust-arm64 (push) Canceled after 1m59s
ci / web (push) Canceled after 1m54s
ci / docs-site (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
Reviewed-on: #41 |
||
|
|
d1c4cb18dd |
test(core/session): pin the low-MTU chunk-aligned guarantee at clamped shard sizes
apple / swift (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m2s
ci / rust-arm64 (pull_request) Successful in 1m44s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 2m3s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m51s
ci / rust (pull_request) Successful in 8m33s
android / android (pull_request) Successful in 5m21s
PyroWave sessions are gated out of mid-session renegotiation, so a constrained path serves them through the leg-1 SESSION-START clamp. This pins the consistency that guarantee rests on: everything chunk-aligned derives from the one Welcome::shard_payload number — the host packetizes at it, the client's C-ABI parse window reads it back, and partial delivery zero-fills exact windows of it — verified at the two clamp shapes a constrained path actually produces (1216, the WARP/Tailscale budget, and the 512 floor) over the sealed loopback wire with real loss. |
||
|
|
5e319f3b77 |
Merge pull request 'fix(client/abr): the decode-cap latch fires on the knee's real presentations' (#36) from worktree-abr-decode-cap-latch into main
windows-host / canary-manifest (push) Successful in 29s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m37s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 13m16s
apple / screenshots (push) Successful in 5m51s
flatpak / build-publish (push) Successful in 9m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 13m6s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 1m53s
deb / build-publish (push) Successful in 6m13s
arch / build-publish (push) Successful in 8m42s
ci / web (push) Successful in 1m3s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m9s
apple / swift (push) Successful in 1m24s
deb / build-publish-host (push) Successful in 7m20s
ci / docs-site (push) Successful in 1m43s
ci / rust-arm64 (push) Successful in 2m32s
release / apple (push) Successful in 8m58s
android / android (push) Successful in 5m50s
deb / build-publish-client-arm64 (push) Successful in 3m24s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
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 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
ci / rust (push) Canceled after 6m55s
windows-host / package (push) Successful in 12m11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 1m18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 27s
docker / builders-arm64cross (push) Canceled after 0s
windows-host / winget-source (push) Skipped
docker / deploy-docs (push) Canceled after 0s
Reviewed-on: #36 |
||
|
|
34ad3cc611 |
feat(host/wire): mid-session shard-payload renegotiation, driven by the MTU verdict
Phases 1-2 of design/shard-payload-reneg.md, on top of the Phase 0
per-frame geometry. The leg-1 watcher stops merely diagnosing the
constrained path and heals the CURRENT session; the same machinery,
inverted, takes a proven jumbo LAN up to ~8.9 KB shards.
- Messages: MSG_SHARD_PAYLOAD_CHANGED (0x08, host→client, {shard_payload
u16}) and MSG_SHARD_PAYLOAD_ACK (0x09, the echo). Asymmetric by
design: a shrink re-keys the packetizer at the next AU immediately
after sending (per-frame pinning makes ordering irrelevant; the ack is
telemetry), a grow emits nothing above the old size until the ack —
the ack is the gate even though client buffers are statically sized.
- Client: one dispatch arm in the shared pump control task (all client
families) — validate against the advertised receive bounds, ack;
out-of-bounds requests get SILENCE, not an ack, so a buggy host can
never read a granted grow out of garbage.
- Host driver: the wire_mtu watcher grows a ShardReneg arm — on a
below-ceiling verdict it still records the learned budget (session 2
starts right) and now also shrinks session 1 at the ~3-10 s verdict
mark; with the jumbo opt-in (PUNKTFUNK_JUMBO=1, or PUNKTFUNK_WIRE_MTU
> 1500 — one knob, derived) it sends the ack-gated grow after a
settled-at-sealed-jumbo proof and then stays alive as the revert
guard: quinn's blackhole detection lowering current_mtu shrinks the
wire back through the same path. The QUIC MTUD probe ceiling rises
from 1472 to the sealed jumbo size with the opt-in (per-ENDPOINT: a
few extra failed probes toward non-jumbo peers, zero cost otherwise).
- Apply point: Session::set_shard_payload drained in the send loop next
to the adaptive-FEC target, gated on no open streamed AU (a streamed
frame's shard-aligned tiling derives from the size it began with).
- Renegotiation is gated OFF for PyroWave sessions: their clients parse
chunk-aligned AUs in windows of the Welcome value pinned at session
start (read once over the C ABI), so a mid-stream re-key would corrupt
the parse — those sessions keep the leg-1 next-session clamp. This
also settles the plan's open question on the two wire_chunk consumers:
both are PyroWave-only, so the gate covers them entirely.
- Legacy peers are inert both ways: no Hello advertisement → the host
never constructs the driver; an old host never sends the message.
core: 296/296 --features quic + clippy -D warnings (macOS), fmt; the
regenerated header carries the new message ids (drift gate).
|
||
|
|
63a4f583b9 |
feat(console): profiles reach the gamepad UI — pinned cards, pin management, settings section
The Skia console now renders a pinned profile card after its host's primary tile (KnownHost::pinned_profiles resolved by the service thread), connects with that profile as a one-off via the existing effective_settings resolver, and shows the bound default profile on the primary tile. The settings screen gains a trailing Profiles section — one row per catalog profile with a live pin count — whose activation opens a pin-to-hosts screen; toggles ride the new ConsoleCmd::SetPin to the binary, which persists pinned_profiles (the same field the CLI resolves for Decky's host list). Profiles themselves stay desktop-authored (design client-settings-profiles.md §5.2a, §5.4). |
||
|
|
290d760ea4 |
feat(core/wire): per-frame shard geometry, jumbo ceiling, Hello advertisement
Phase 0 of mid-session shard-payload renegotiation (planning design/shard-payload-reneg.md), stacked on the leg-1 MTU resilience. All three legs are client-side and forward-compatible: deployed clients that carry them accept a mid-session shard change the moment a future host sends one, and nothing changes on the wire until then. - W0.1 — the reassembler's strict shard_bytes firewall becomes per-frame pinning: a frame's first-arriving packet pins that frame's shard size (bounds-checked to [min_shard_bytes, max_shard_bytes], even), later packets must match the pin, and the per-frame block ceiling derives from the pinned size (a session-level cap would reject legitimate post-shrink frames). The reorder race between an ordered control message and unordered video dies structurally: old-geometry frames in flight complete under their own pin while new frames arrive under the new one, and no cross-geometry splice can land in one buffer. The in-flight budget stays byte-based and exact. - W0.2 — MAX_DATAGRAM_BYTES 2048 → 9216: every receive path (transport RECV_BUF, the recvmmsg ring) now accepts sealed jumbo datagrams (9000-MTU LAN ≈ 8908-byte shards). Static buffers over resize-on-ack: the ring delta is 128 × ~7 KiB ≈ 896 KiB per client session, lazily allocated, hosts unaffected. Grep verdict: no embedder uses the constant directly, so no C ABI bump — the regenerated header rides along (drift gate). - W0.3 — trailing Hello field max_shard_payload: u16 (0/absent = legacy), the append-with-placeholder discipline of video_caps/ client_caps. One field is both the renegotiation capability flag and the jumbo ceiling; core's pump advertises it for all client families, the probe too. - Host seam for Phase 1, dead until wired: Packetizer::set_shard_payload (re-derives the block ceilings; construction delegates to it) + Session::set_shard_payload (host-only, Config::validate parity). Verification (the 0.23.0 lesson — geometry changes breed sizing bugs): the slice-wire suite re-runs at shard 512/1216/1408/8908 (exact-multiple sweep, lossy + reversed roundtrips, sentinel path, in-flight budget); mid-stream shrink→grow→revert delivery; the old-geometry reorder race; cross-geometry splice rejection; firewall bounds non-vacuous both ways; a 48-case mixed-geometry reorder-torture proptest asserting per-frame byte-identical DELIVERY and an exactly-zero final budget; and a sealed loopback session test (continuous crypto/replay) delivering frames across live re-keys — every test asserts delivered frames, never the absence of errors. core: 294/294 --features quic + clippy -D warnings (macOS), fmt. |
||
|
|
31b5f90b12 |
fix(host/windows): two virtual pads stop tearing each other's reports
apple / swift (pull_request) Successful in 1m16s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m53s
android / android (pull_request) Successful in 2m52s
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m20s
ci / rust (pull_request) Successful in 25m42s
windows-drivers / probe-and-proto (pull_request) Successful in 29s
windows-drivers / driver-build (pull_request) Successful in 1m37s
Three faults on the Windows pad path, two of them races that only bite when a game drives a pad hard enough for two callbacks to overlap. pf-gamepad's output ring could hand the host a torn report. Publishing is a read-modify-write — read the cursor, write the slot it names, advance it — and the framework dispatches output callbacks in parallel, so two could be inside it at once: both read the same head, both wrote the SAME slot, and both stored head+1, so the cursor moved once for two reports and the host read a single entry with two reports mixed into it. An atomic fetch_add does not fix this. It hands each writer its own slot but advances the cursor before the bytes exist, so the host is then invited to read a slot still being filled. Serializing the publish is what makes the cursor bump mean "the slot below is complete". The ring exists to stop a rumble STOP being coalesced away, and a torn slot can eat that STOP with no idle watchdog behind it. Both drivers also promised the host an ordering they never established. The host loads out_seq and rumble_seq with Acquire and says so in its own comments — "Acquire pairs with the driver's publish-then-bump store order" — but the drivers bumped both with plain writes, and an Acquire load pairs with a Release store and nothing else. On a weakly-ordered core the host could see a fresh seq against stale bytes. pf-xusb's rumble seq was racy in the same way as the ring: two SET_STATE calls could both read one value and both write back value+1, so the host saw one bump for two writes and skipped a level. A skipped stop is the one that hurts — the pad buzzes until the ~2.5 s idle force-off notices the game went quiet, which is what bounds the damage. Diagnosing an unattached driver stalled the session. The pad service thread — the one feeding input and rumble — waited up to two seconds for a pnputil enumeration, per unattached pad, at exactly the moment a session was already going wrong. The diagnosis now runs on its own thread. Off the hot path the wait no longer has to be a compromise, so it is generous enough to report what it actually found instead of giving up with "still enumerating" — which, given pnputil routinely takes longer than the old budget, is what it usually did. |
||
|
|
8abdd74a62 |
fix(client/desktop): the Deck keeps its trackpad, and a pad stops buzzing at exit
ci / rust-arm64 (pull_request) Successful in 1m55s
ci / web (pull_request) Successful in 1m14s
ci / docs-site (pull_request) Successful in 1m15s
android / android (pull_request) Successful in 8m52s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m2s
ci / rust (pull_request) Successful in 12m50s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m7s
apple / swift (pull_request) Successful in 1m23s
apple / screenshots (pull_request) Skipped
Three faults in the desktop session's gamepad path. The Steam Deck lost its built-in trackpad-mouse at the start of every session. SDL's Valve HIDAPI driver clears the pad's digital mappings during *enumeration*, which is part of bringing the gamepad subsystem up — so holding the drivers off from inside GamepadService::pumped could never work: receiving a GamepadSubsystem means the enumeration has already happened. The hint set there detached a driver that had already done the damage, and lizard mode only came back seconds later when the firmware watchdog restored it. The presenter now disables them with its other pre-SDL_Init hints. The threaded worker always had this right; only the caller-pumped path was wrong, and it could not fix itself, hence a separate entry point its callers can place correctly. Player LEDs did nothing at all on any pad that is not a DualSense. The match arm handled the DualSense raw-effects path and let everything else fall through a bare `_`, though SDL exposes set_player_index and owns the per-device pattern. The wire carries a positional bitmask rather than an index, and the bridge is the popcount: every convention that reaches this wire spells "player N" as N lit LEDs — the DualSense patterns 0x04/0x0A/0x15/0x1B/0x1F and the Switch/XInput run 0x01/0x03/0x07/0x0F alike — so counting them works for both, where reading a bit position would only ever suit one. No lit LED means no player, not player 0. The remaining unhandled variants are now named rather than swept up by `_`, so a new one cannot join them silently. A forwarded pad could be left buzzing when the session ended. detach() only posts Ctl::Detach; the close that flushes the pad, tells the host to remove it and explicitly zeroes the motors runs when the pump next drains that message. Single mode broke out of the loop immediately after detaching and Event::Quit never detached at all, so both skipped it entirely. The teardown now sits where every exit converges instead of on the individual breaks. That still leaves the several paths that leave by `?` on a fatal overlay or present error, so the pump also silences its slots on Drop — the explicit call stays, because a pad should go quiet before a long teardown rather than after it. Drop closes the slots directly rather than draining the queue that would have done it: same physical outcome, and it touches no lock, where draining reaches an unwrap on a Mutex that would abort the process if it panicked mid-unwind. |
||
|
|
69f1db5ea9 |
Merge pull request 'feat(host/wire): MTU resilience for the video data plane' (#37) from worktree-wire-mtu-resilience into main
apple / swift (push) Successful in 1m30s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m21s
docker / builders-arm64cross (push) Successful in 11s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m37s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m50s
windows-host / package (push) Successful in 16m8s
android / android (push) Successful in 5m39s
windows-host / winget-source (push) Skipped
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m36s
windows-host / canary-manifest (push) Successful in 30s
deb / build-publish (push) Successful in 21m51s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m31s
deb / build-publish-client-arm64 (push) Successful in 9m9s
ci / rust (push) Canceled after 27m31s
flatpak / build-publish (push) Successful in 8m11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
release / apple (push) Successful in 9m14s
arch / build-publish (push) Successful in 9m41s
docker / deploy-docs (push) Successful in 6m19s
apple / screenshots (push) Successful in 6m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m27s
ci / web (push) Successful in 1m3s
deb / build-publish-host (push) Failing after 30s
ci / docs-site (push) Successful in 1m15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 16s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 20s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 21s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
ci / rust-arm64 (push) Successful in 2m41s
Reviewed-on: #37 |
||
|
|
4bc7eecf05 |
feat(host/wire): MTU resilience for the video data plane
android / android (pull_request) Successful in 3m25s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m13s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m16s
ci / rust (pull_request) Successful in 22m23s
ci / docs-site (pull_request) Successful in 1m5s
ci / web (pull_request) Successful in 1m47s
apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m46s
Video datagrams are sealed at a shard payload sized for a clean 1500-byte MTU (1472-byte UDP payloads). A host whose route to the client crosses a smaller-MTU hop (a VPN/overlay adapter claiming the LAN route, a lowered NIC MTU) delivers every small flow — QUIC control, hole punch, input, audio — while 100% of video datagrams die: the client sits on a black screen reporting zero loss and the host streams into the void with every gauge green. Field-reported as 'connects fine, black screen forever'. Three legs, none of which changes a session on a healthy path: - PUNKTFUNK_WIRE_MTU operator override: shard payload derived from a given on-wire IP MTU. Wire-compatible — Welcome::shard_payload is already negotiated per session (the v4/v6 split ships two values today) and every client follows the negotiated value. - Detection: the QUIC MTU-discovery probe ceiling moves from quinn's stock 1452 to exactly the sealed video-datagram size (1472), so a control connection's settled MTU becomes a verdict on the path: settled at the ceiling proves it carries video, settled below proves it cannot. A per-session watcher samples after the search has settled (live-connection guard against mid-search false learns) and logs an actionable WARN naming the failure shape and the diagnosis commands. - Healing: the measured budget is recorded per peer IP; the next handshake clamps shard_payload to fit, so a reconnect self-heals. A later session that reaches the ceiling erases the record. Verified: core 286/286 --features quic + clippy -D warnings (macOS); host clippy -D warnings + native:: tests 44/44 (pf-lxcheck container). The regenerated C header picks up the new MIN_SHARD_PAYLOAD constant. |
||
|
|
5e19a4611f |
fix(client/abr): the decode-cap latch fires on the knee's real presentations
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m26s
ci / rust (pull_request) Successful in 10m22s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m4s
ci / rust-arm64 (pull_request) Successful in 1m55s
android / android (pull_request) Successful in 3m32s
ci / docs-site (pull_request) Successful in 1m37s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m10s
The client-decoder knee latch (decode_cap_kbps) was unreachable in production — zero "decode cap learned" lines across every field log, while its own doc named the exact sawtooth it exists to end (the 2026-08-03 1440p120 field trace: 220↔450 Mbps for nine minutes, five knee backoffs, no latch): - The ordinary two-bad-window backoff — the knee's most common presentation, a standing 15–45 ms decode rise below the severe tier — carried no decode evidence at decision time, because evidence was judged from the deciding window alone. Worse, the backoff the decode signal itself caused then RESET the knee streak. Now the streak carries its own attribution (streak_decode_windows): a backoff whose bad windows were all decode-flagged is decode evidence. - A cascade's second backoff can never agree with the first: a live host acks the ×0.7 request in ~100 ms, so the second sample always sits at the reduced rate — outside the ±1/8 similarity band by construction (0.7 < 7/8). The canonical test never acked between its backoffs, which is how the premise survived. Now a backoff only samples a rate the controller climbed back to (climb_since_backoff, armed by any ack that raises the rate); a drain-time backoff neither latches nor erases the reference the real knee set. - A keyframe-ask storm on a clean link (the Steam Deck presentation: the overdriven decoder wedges and begs instead of queueing — 14–19 asks at ~300 Mbps with loss_ppm=0 in the field traces) is decode evidence too; with real loss present the asks stay network-attributed. The reworked tests model the ack round-trip (choke → ack → re-climb → choke), including a regression test replaying the field trace's rates and decode figures, which must latch at its second knee encounter. |
||
|
|
2cfc82e96c |
fix(audio): budget the audio plane against the link, and close the review's gaps
Findings from the post-implementation review of design/audio-quality-and-latency.md. **The bandwidth gap (highest).** Tier `High` (256 kbps) and the redundant `0xD2` plane were added separately, each costed as "~1 % of the video budget", and nobody added them together: 256 kbps sent twice is 512 kbps — ~2.5 % of a 20 Mbps session but ~10 % of a 5 Mbps one. Audio rides QUIC datagrams, OUTSIDE the ABR loop, so ABR could neither see that nor reclaim it; a constrained link quietly handed a tenth of its bandwidth to audio while ABR carefully managed the rest. `plan_audio_budget` now makes tier and redundancy ONE decision against the session's resolved video bitrate, ordered by preference rather than cost — transparent audio beats redundant audio, since the field report was about quality and redundancy only pays under loss, so `High` alone outranks `Standard`+redundancy even though they cost the same. It can lower what the operator asked for, never raise it, and never goes below `Low`: a stream with unintelligible audio is worse than one spending a few percent more. **The Linux host kept the exact defect fixed on Windows.** `let _ = tx.try_send(samples)` — silent, uncounted data loss, where the encoder concatenates across the hole, so every drop is a click AND a permanent shift of everything after it. WP0.2 turned out to be Windows-only and had not said so. Linux now shares `capture_policy::CaptureStats`: drops counted and warned, plus per-window peak/RMS/delivered%. A Linux audio report was until now exactly as un-triageable as the Windows one was on 2026-08-03. **Apple's WP0.3 was half-done** — `bufferedMS` was added and wired to nothing. The drain thread now logs buffer/target/underruns/sheds like the other three, from one locked snapshot so the numbers in a line describe the same instant. Also: the Linux "audio format negotiated" line now says WHICH mode produced it, because that changes what it is worth — in stream-sink mode the host owns the sink so the mix cannot have been narrowed upstream, but in legacy monitor mode a 16 kHz Bluetooth sink would still be reported as a clean 48 kHz through PipeWire's resampler, the same way WASAPI's autoconvert hid it on Windows. Reading the monitored node's own rate needs a registry lookup this stream does not do; recorded as an open gap rather than implied to be covered. Two stale docs: `audio_wasapi.rs` cited `clients/windows/src/audio.rs` (deleted) and still described the pre-shared-policy "prime to ~3 quanta" behaviour. And the Apple ring's `prefill:` parameter, dead since the depth moved into the ring, is gone. Verified: clippy --all-targets -D warnings on Linux (docker) AND Windows (runner .133, forced clean rebuild of punktfunk-host + pf-client-core); core 167 tests; host 57 audio tests on Windows; Android clippy count identical to pristine (6, all documented arm64 artifacts); Apple ring re-simulated. The host suite's `gamestream::stream::tests::sender_delivers_batches` fails under qemu — the recorded environmental flake, unrelated to audio, green on the earlier less-loaded run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
3055e29ebb |
feat(host/audio): make audio observable, fix the endpoint choice, raise the encode quality
Phases 0-3 of design/audio-quality-and-latency.md, host side.
**WP2.1 — the 2026-08-03 root cause.** The client-only loopback preference took Steam's
Streaming *Microphone* render endpoint over real hardware unconditionally, because it is
silent on the host. But that endpoint exists to carry remote VOICE, and nothing checked
whether it could carry music: on the reporter's box it won all 31 loopback opens across 25
sessions while a clean AMD HD Audio endpoint sat idle, and the whole desktop mix went
through it before reaching Opus. A silent sink now has to EARN its preference — if its mix
format narrows the mix it drops below real hardware. It is still taken when nothing better
exists (narrow audio beats no audio), but flagged so the capture side says why.
`plan_with_formats` takes a probe rather than reading WASAPI, so all 26 wiring-plan tests
still run on every platform. An unknown format counts as fine, which is asserted:
`unknown_formats_reproduce_the_formatless_plan` proves a probe failure can never make the
plan worse than it was before formats existed.
**WP0.1 — log the endpoint's ACTUAL mix format.** Everything the old log printed ("48 kHz
f32 channels=2") was our REQUEST; with `autoconvert` WASAPI converts silently from whatever
the endpoint really runs. That is why a 3,600-line log filed over an audio-quality
complaint contained nothing that could diagnose it.
**WP0.2 — count what we drop.** The capture->encode handoff was a silent lossy `try_send`:
a stalled encode thread lost chunks, the encoder concatenated across the hole, and nothing
recorded it — a click plus a permanent shift of everything after. Now counted and warned,
alongside per-window peak/RMS/delivered% so a quiet host, a broken endpoint and a stream we
are damaging ourselves stop looking identical.
**WP2.4 — stop the default-device tug-of-war.** In Assert mode the capture is bound to the
planned endpoint EXPLICITLY, so a hijacked default changes only where apps render — the old
full reopen tore the capture down for nothing. The field log shows the cost: something
re-set the default every ~4 s and each round was a teardown, a wiring pass with
IPolicyConfig writes, and an audible dropout — seven in sixteen seconds, one ending in a
2 s error backoff. Now: put the default back, keep the stream, and after four rounds in
twenty seconds concede for a minute and say so once.
**WP1.1/1.2 — encode quality.** Constrained VBR (the hard-CBR comment justifies itself with
GameStream's audio FEC, which this plane does not have) and `AudioTier::High` by default:
stereo 128 -> 256 kbps, ~1 % of a 20 Mbps session. GameStream's encoder is deliberately
untouched — its FEC really does need fixed-size packets.
**WP3.1 — redundant `0xD2` plane**, sent when the client asked for it.
**WP2.2 — `audio.output_mode`** as a first-class setting (`client_only` / `host_and_client`
/ `follow_default`), superseding the two undocumented env vars, which stay honoured. The
enum lives in pf-host-config, which is deliberately dependency-free, so the tier table stays
in core where the codec knowledge is.
`capture_policy.rs` is split out for the same reason `wiring_plan.rs` is: both encode field
behaviour, so their tests must run on Linux CI, not only on a Windows box. That split
immediately earned itself — `capture_stats_separate_silence_from_signal` caught RMS being
divided by the FRAME count while summed over interleaved SAMPLES, which inflated it by
sqrt(channels) and made a sine report an RMS equal to its own peak.
WP4.5 (open the loopback at the minimum device period) is deliberately NOT done: in shared
mode `IAudioClient::Initialize` cannot change the engine period at all, so it would be a
no-op at best and a new failure path at worst. Recorded in the code. WP2.3 (force the parked
endpoint's volume) is deferred — `wasapi` keeps IMMDevice private, so it needs new raw COM
on a path this tree cannot compile, let alone test; its diagnostic half ships as the RMS
line above.
Verified: punktfunk-host + pf-host-config clippy --all-targets -D warnings and the audio
test suite under Linux/docker (gate proven non-vacuous with a planted type error); 26
wiring-plan tests standalone; fmt. The Windows-only halves of wasapi_cap.rs and
audio_control.rs are NOT compile-verified anywhere yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
7077b0a0df |
feat(core/audio): bitrate tiers, a shared de-jitter policy, and a redundant audio plane
Foundation for the audio quality + latency plan (design/audio-quality-and-latency.md). All three pieces are pure and unit-tested here so the four client rings and the Windows host glue that follow stay thin. **Bitrate tiers** (`AudioTier`). The layout table's `bitrate` becomes the `Standard` value, so that tier reproduces the pre-tier wire byte-for-byte — the tier machinery is provably non-regressive. `High` (stereo 256 kbps) is the default: 5 ms Opus frames are much less efficient than 20 ms ones, so the historical 128 kbps buys roughly what ~100 kbps buys at 20 ms, while the same session carries tens of Mbps of video. Purely a host-side encoder knob — libopus reads the bitrate out of the packet, so no client change and no negotiation. **`JitterPolicy`** — the ms-denominated de-jitter state machine every client will share. Two defects it exists to fix: (1) each ring computed its target as `3 x quantum`, a sane 15 ms at a 5 ms quantum and a silent 64 ms at a 20 ms one; (2) every ring primed *up* and clamped at a ceiling, and none walked the depth back *down*, so drift/bursts added latency permanently — Android, with no shed at all, converged on its 120 ms cap. Here a depth EWMA that sits above target for 2 s of consumed audio sheds ONE 5 ms frame with a crossfade. Driven by samples consumed rather than the wall clock: allocation- and syscall-free (safe in a realtime callback) and deterministic under test. `every_preset_sheds_before_it_trims` pins the invariant that makes this real rather than decorative. The first draft had `headroom_ms` <= the shed threshold on all four presets, so the ring was trimmed back before the average could ever reach the shed point: drift correction was dead code and the ratchet test passed for the wrong reason (the hard cap did the work). `a_transient_burst_does_not_shed` caught it. The shed point is now derived from `headroom_ms` so it cannot invert again. **`0xD2` redundant audio** — each datagram carries its frame plus a copy of the previous one, so a single lost packet is reconstructed instead of concealed. Opus in-band FEC cannot do this job: LBRR is a SILK feature and the desktop encoder is CELT-only (RESTRICTED_LOWDELAY, 5 ms), so `set_inband_fec` there is a no-op. Costs no latency — the copy rides the successor, which arrives inside de-jitter slack that already exists. Gated capable-and-agreed via CLIENT_CAP_AUDIO_RED/HOST_CAP_AUDIO_RED; every other session keeps the `0xC9` wire unchanged. 0xD1 is left free for the pad-audio program. cbindgen: prefix the four new exported constants. `FRAME_MS`/`SAMPLE_RATE_HZ` as bare C macros are the same hazard the BTN_* renames already document — a clashing #define takes the last definition silently rather than failing to compile. Verified: 300 core tests, clippy -D warnings, fmt. (`c_abi` fails identically on a pristine tree — this Mac has no system libopus for the C harness link.) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ec4bf75a6e |
fix(core/rumble): the Deck's keepalive stops being swallowed by its own renewals
ci / docs-site (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m54s
ci / rust-arm64 (pull_request) Successful in 4m8s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m46s
android / android (pull_request) Successful in 5m31s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 4m11s
ci / rust (pull_request) Successful in 9m39s
Three faults in the shared rumble policy engine, all answered by one change of shape: the free-running jitter phase becomes `last_emit` — the exact value last handed to an embedder — and every emit routes through one helper. That single field answers all three live questions: would re-sending this be a no-op device write, is this stop redundant, and would the nudge invent a stop. The Steam Deck declares a 40 ms keepalive with a 1-LSB nudge, because an SDL-class layer discards a write identical to the last one. But the nudge lived only in the keepalive branch, so every host renewal re-emitted the raw level, collided with the last jittered write, was discarded, AND re-anchored the keepalive timer. The gap between distinct device writes stretched to 80 ms at the 400 ms default TTL and 100 ms at the hatch floor — two to two and a half times the cadence the quirk exists to guarantee. Nudging on any repeat closes it: 40 ms throughout. Level (1, 0) turned that nudge into (0, 0) — the value the engine reserves for "stop now" — and handed it out with a non-zero backstop, under a live lease. It is the only such level: high must already be zero, and low ^ 1 == 0 implies low == 1. The nudge now steps the LSB up instead, so the phase still alternates and no stop is ever invented. A zero for a pad the engine already believes silent is now dropped. Under the legacy hatch the host re-sends zeros for every latched pad every 500 ms for the rest of the session, which cost Android an unconditional log line and a binder cancel() at 2 Hz per pad. The deliberate stop-burst heal is untouched, because a stop that was LOST leaves the pad buzzing, and that is exactly the guard's pass condition. The client also now bounds the lease it will honour. RUMBLE_TTL_CEIL_MS is sender-side only, so a modified or third-party host could stamp a long TTL and wedge its pump, leaving Apple — whose renderer deliberately keeps no staleness policy of its own — and a Deck slot buzzing for all of it. Every new test was proven to fail with its own fix reverted, including the two that guard against over-reach: a default-quirks pad must still get the level verbatim, or an off-by-one amplitude would land in Apple's identical-target comparison and Android's one-shots. One suspicion from the audit did NOT survive: a v2 envelope carrying ttl_ms 0 cannot take the legacy backstop, because the expiry check preempts the relay branch. No fix; pinned with a test so that ordering stays load-bearing. Verified: 17/17 rumble tests, clippy --all-targets --features quic -D warnings = 0, fmt clean, generated C header unchanged. (`c_abi_harness_round_trips` fails on this Mac with a linker error, identically on an unmodified tree.) From the 2026-08-03 force-feedback sweep (B12, B22, R9, T1). |
||
|
|
5582a6ea51 |
Merge branch 'main' into worktree-haptics-m1-rumble-seq
apple / swift (pull_request) Successful in 1m17s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m51s
ci / docs-site (pull_request) Successful in 2m5s
ci / rust-arm64 (pull_request) Successful in 2m22s
android / android (pull_request) Successful in 3m25s
ci / rust (pull_request) Successful in 8m4s
|
||
|
|
ab4cd06e86 |
Merge remote-tracking branch 'origin/main' into worktree-haptics-m2-pad-slots
ci / docs-site (pull_request) Successful in 3m0s
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m42s
ci / web (pull_request) Successful in 1m39s
ci / rust-arm64 (pull_request) Failing after 11m17s
ci / rust (pull_request) Successful in 7m36s
|
||
|
|
3eab1e41df |
Merge remote-tracking branch 'origin/main' into worktree-haptics-m1-rumble-seq
ci / web (pull_request) Successful in 1m42s
ci / docs-site (pull_request) Successful in 1m40s
ci / rust-arm64 (pull_request) Successful in 3m22s
apple / swift (pull_request) Successful in 1m23s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m8s
ci / rust (pull_request) Successful in 8m11s
|
||
|
|
9a52c279f1 |
Merge branch 'main' into feat/android-pad-audio
ci / web (pull_request) Successful in 1m24s
android / android (pull_request) Successful in 3m55s
apple / swift (pull_request) Canceled after 0s
apple / screenshots (pull_request) Canceled after 0s
ci / rust (pull_request) Canceled after 5m1s
ci / rust-arm64 (pull_request) Canceled after 3m22s
ci / docs-site (pull_request) Canceled after 1m35s
windows / build (aarch64-pc-windows-msvc) (pull_request) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 0s
|
||
|
|
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
|
||
|
|
1ae8b4d4ca |
fix(client/abr): let the ceiling follow a host-initiated re-target
apple / swift (pull_request) Successful in 1m15s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m8s
ci / web (pull_request) Successful in 2m1s
ci / rust-arm64 (pull_request) Successful in 2m25s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m14s
ci / docs-site (pull_request) Successful in 1m14s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m44s
ci / rust (pull_request) Successful in 9m9s
Interaction between two fixes in this series. The host now tells the client when a rebuild re-resolves an Automatic rate, and that rate can legitimately sit ABOVE the client's climb ceiling — the ceiling is the negotiated start rate until the capacity probe raises it, while the host's re-resolve answers "what do these pixels actually need" (a 1080p session mirroring a 4K panel resolves ~3× higher). Left alone, the client would learn the new rate, notice it was above a stale ceiling, and step the host straight back down off the rate it had just chosen for itself. So an ack raises the ceiling to meet it. `set_ceiling` only ever raises and still clamps to PUNKTFUNK_ABR_MAX_MBPS, which is the one limit that should bind here. No effect on ordinary acks: a climb is never requested above the effective ceiling to begin with. |
||
|
|
33ecd8e1a5 |
fix(client/abr): a granted climb disproves the learned cap
Completing the cap-escape fix. Backing the re-probe clock off to 12 s got the client asking again quickly, but each ask only LIFTED the cap by +12.5 % — so even a host that had fully recovered still granted the session its real ceiling one small step at a time, ~4 minutes from the 20 Mbps default to a 300 Mbps link. The crawl was never the point; re-learning was. A request granted IN FULL at or above the cap is the host's own word that the limit is gone. Drop the cap outright at that point instead of nudging it. A standing limit is unaffected — it answers the same re-probe with another short ack, which re-latches it and doubles its clock, exactly as before. Adds the end-to-end regression the sweep was really about: a session pinned at 20 Mbps by a transient cadence refusal, under a probe-measured 300 Mbps ceiling, now reaches 150 Mbps in 22 windows (~16 s) where it used to need ~17 minutes. |
||
|
|
48565c4e9e |
fix(host/abr): stop pinning Automatic sessions, and tell the client when the rate moves
Two host-side halves of the same sweep. **The cadence latch.** `cadence_degraded` — which makes the control task refuse bitrate CLIMBS — was latched true for as long as the session was escalated (adaptive capture depth or pipelined retrieve), independently of whether encode was still missing deadlines. The client cannot tell that refusal apart from an encoder's real ceiling: both arrive as a short `BitrateChanged`, and two identical ones latch a cap. Escalation needs ~20 net behind-frames, which a startup hitch supplies while the ABR is still in slow start at the 20 Mbps default — so one transient pinned the whole session there, long after the escalation had bought back the headroom it was for, and escaping cost +12.5 % per 60 s. An escalated session is still judged strictly (ANY net behind-frame keeps it flagged, where an unescalated one gets the full bucket), but being escalated no longer flags it by itself: escalating exists so cadence CAN be held, and once it is, refusing climbs refuses the thing that worked. The rule moves into `encode_behind_cadence` so it is stateable and testable. **The silent re-target.** `adopt_built_bitrate` publishes the rate a rebuilt pipeline actually opened at — `build_pipeline` re-resolves an Automatic rate whenever the source delivers a size the session did not negotiate, the mirrored-panel case — and the encoder's own clamp can land below what the control task already acked. Neither reached the client, whose controller keeps its own copy of that number as its climb base. A 1080p client mirroring a 4K panel therefore believed 20 Mbps while the host encoded 60, and its first climb computed from the stale base asked for 40: a re-target DOWNWARD, paying an encoder rebuild to get there. Both paths now push the applied rate to the control task, which sends `BitrateChanged` — the existing 9-byte message, which already means precisely this and which clients already handle arriving unprompted. No wire-format change, no capability negotiation, old clients unaffected. 2 host tests added. |
||
|
|
e9a7373c76 |
fix(client/abr): measure delivered throughput in media bytes, not wire bytes
The controller's two throughput-driven gates both compare "what the pipeline carried" against the ENCODER's target: the utilization gate asks whether a clean window actually tested that target (a calm menu proves nothing), and the never-decaying proven mark bounds how far every later climb may step. Both were fed `bytes_received`, which counts every accepted datagram — headers, FEC parity, probe filler, audio. So the figure rose with the redundancy the host adds in ANSWER to loss: at 25 % FEC the gate passed with the encoder emitting ~55 % of target, and the proven mark inherited the same inflation permanently. The signal was weakest exactly on the lossy links it exists for. Count data-shard payload separately at the reassembler's routing decision — the same place, and for the same reason, the probe counters are already stamped — and feed the ABR that. First time both gates are dimensionally honest: a media rate compared against a media target. |
||
|
|
f7a8c2013d |
fix(client/abr): the controller stops learning the wrong lessons from one window
Six defects found by a sweep of the Automatic-bitrate path, all of them the same shape: a single window, or a single refusal, taught the controller something it then treated as permanent. - Rolling baselines (OWD, client decode, host encode) armed off ONE sample. The baseline is a rolling minimum, so one window IS the floor — and `on_ack` deliberately clears the encode baseline after every decrease we ourselves asked for, re-opening that hole each time. A calm re-seed window followed by ordinary motion read as 4 ms of "congestion", backed off, cleared again, and ratcheted toward the floor on a link that was never the problem. All three now need BASELINE_MIN_WINDOWS of evidence before they may fire, via one shared `score_baseline` (the three copies had already drifted apart). - A mode switch rebased only the encode baseline. Decode and OWD are just as mode-scoped: 4K120 decodes slower and puts bigger frames on the wire than 1080p60, so the old floor was one the new mode cleared on its first window — ~30 s of every window scoring bad, i.e. a backoff every other window. A switch UP in mode cratered the rate instead of raising it. `proven_kbps` goes with them; throughput the old mode's decoder digested is not evidence about this one. - `proven_kbps` — never decayed, and permanent authority over how far every later climb may step — was raised by any window without a decode rise, including ones scored SEVERE. The windows that overstate delivered throughput are exactly the damaged ones: a stall's backlog draining at once, a flush's queue, the FEC surge answering a loss burst. Now only clean windows raise it. - A learned cap escaped at +12.5 % per ~60 s. The host cannot distinguish a durable encoder ceiling from a climb refused while it is transiently behind cadence, and the latter routinely latches during slow start at the 20 Mbps default — from which crossing the gap to a probe-measured ceiling took upwards of twenty minutes. Re-probe after 12 s instead, doubling the interval each time the lift is immediately re-learned: a transient is out in one interval, a real ceiling settles into a slow poll. - The decode cap latched AT the rate that choked, authorizing a climb straight back into the failure, and a bare jump-to-live flush could teach a "decoder knee" from what was a network event. It now latches just under the choke rate (inside the ±1/8 band the evidence already required) and only credits a flush where the decode signal is absent and cannot speak for itself. - PUNKTFUNK_ABR_MAX_MBPS bound only probe-learned ceilings, not the negotiated start rate — so the one knob an Automatic session gives the operator did nothing when the session already started above it. It now binds at construction, and a session sitting above its ceiling steps down to it (no congestion signal will ever find that: the link is fine, the cap is policy). Also: a SetBitrate dropped by a full control queue counted toward MAX_UNACKED, so three of them retired the controller for the session while logging that an "older host" was at fault. The pump now tells the controller what happened. Wire format and ABI untouched. 34 abr tests green (3 new). |
||
|
|
9979489b56 |
fix(host/pads): an unplugged controller actually disappears
ci / web (pull_request) Successful in 1m11s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m24s
ci / rust-arm64 (pull_request) Successful in 1m35s
android / android (pull_request) Successful in 3m11s
ci / rust (pull_request) Successful in 6m3s
Unplug a controller mid-session and the virtual pad it was driving outlives it: the game keeps seeing a connected, permanently idle device for the rest of the session. The single-controller session — the common case — hits this every time. `PadSlots::sweep` needs two passes to retire a pad. The first pass to see the mask bit clear only ARMS the 300 ms devnode-churn grace; the drop lands on a later pass. But sweep runs only from a state frame, and the producer emits exactly one frame per detach — `native/input.rs` guards the emit on the bit still being set — so for a pad with no still-changing sibling in the same manager, the second pass never comes. Nothing periodic reaches sweep: `heartbeat` and `pump` walk the slots without it. Split the two halves. `sweep` still folds a frame's mask into the grace clocks, and `reap` — new — drops whatever has run out, with no frame needed. Every manager now reaps on the periodic pump it already runs, so the teardown completes ~300 ms after the detach instead of never. `reap` deliberately cannot arm a clock: it only reads `inactive_since` and clears it, so a pad whose bit never went clear has nothing to run out and no amount of reaping can drop it. That is what makes it safe on a hot loop, and it keeps the anti-flap guarantee intact — a mask that blips clear and returns still never churns a devnode. The two existing tests hand-fed a SECOND removal frame, which production never sends; they passed while the real path leaked. Both now drive the unplug through a pump tick, and PadSlots gains three tests pinning the new invariants. Verified non-vacuous: with the reap neutered, both manager tests fail with "the pump tick never completed the unplug". Behaviour notes: this puts UI_DEV_DESTROY on the GameStream control thread's budget for the first time, and a mask glitch longer than the grace now really does flap — which is SWEEP_GRACE working as documented, so the constant stays. Found by the 2026-08-03 force-feedback sweep (B2 — see the backlog in punktfunk-planning design/haptics-sweep-2026-08-03.md). |
||
|
|
14502769e0 |
fix(host/input): rumble comes back when a controller does
android / android (pull_request) Failing after 18s
ci / docs-site (pull_request) Successful in 1m7s
ci / web (pull_request) Successful in 1m9s
apple / swift (pull_request) Successful in 1m15s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m45s
ci / rust (pull_request) Successful in 6m9s
Unplug a pad mid-session and plug it back in, and roughly half the time it never rumbles again for the rest of the session. The removal arm restarted the pad's rumble sequence counter. The client's reorder gate does not restart: `rumble_last_seq` lives for the whole QUIC connection and has no reset path, so it still holds whatever the pad reached before the unplug. Restarting the host counter therefore hands the client a seq it has already seen, and its wrapping half-space compare drops every envelope until the counter climbs back past the stored value — up to 128 sends. Since the counter only advances on a level change or a ~120 ms renewal while a level is non-zero, that spans many separate rumble events, so it reads as a flaky controller rather than a clean outage. Whether it bites is decided by how much the pad rumbled beforehand, which is why it looks intermittent: a pad that never rumbled before the re-plug has `None` on the client side and always heals. The counter now survives, matching the sibling pad-state gate — whose comment eleven lines above already explains that a re-plug must arrive with a still- newer seq to be accepted. The three clears that actually end the stale lease move into `clear_pad_feedback`, whose signature deliberately has no seq parameter so the arm cannot regress by editing. Covered by a regression test that drives the real wire encoder and the real client gate, and asserts the pre-fix behaviour is genuinely rejected across the whole forward window, so it cannot pass vacuously. Found by the 2026-08-03 force-feedback sweep (B1/T5 — see the backlog in punktfunk-planning design/haptics-sweep-2026-08-03.md). |
||
|
|
442ea12b96 |
test(mgmt): pin the two things that would silently break plugin logging
The runner holds the PLUGIN token and nothing else — on Windows its LocalService principal cannot read the admin one at all. `plugin_may_access` is an exclusion list, so `/plugins/logs` is reachable today only because it happens not to match `/ui-credential`. If that ever changed, plugin logs would go quiet in the console with no other symptom and no failing test. Now asserted on that lane directly. The second test covers ingest end to end through `GET /logs`: the `plugin:` target prefix the console's Host/Plugins filter keys on, the level coercion (an unranked level would sort as 0 and hide under every filter setting), a sourceless line being attributed to the runner rather than to nothing, the caller's timestamp surviving the trip, and an oversized batch being refused whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |