4e04c2bbf8f608593b82b8e504a0727150150cec
8
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
74270109dd |
ci(android): lint the Android target, which nothing had ever done
`ci.yml` runs `cargo clippy --workspace` on the HOST, where `clients/android/native` and every `#[cfg(target_os = "android")]` module elsewhere compile out, and `android.yml` only ever built. So the Android target was never linted at all — not once. Five lints were sitting in clients/android/native when this was noticed, in code no gate had ever read. The gate is a Gradle task rather than a YAML step because cargo-ndk needs a specific discovery environment (NDK sysroot, SDK cmake 3.22.1 for libopus, `LIBOPUS_STATIC`, Ninja) and duplicating it into the workflow would let the lint drift from the build — a lint that ran against a different toolchain is a lint about a different program. `registerCargoNdkClippy` reuses the build task's environment verbatim via the extracted `cargoNdkEnvironment`, so local and CI runs are the same invocation. It lints BOTH pointer widths, and that is load-bearing rather than thorough: arm64-v8a is 64-bit and armeabi-v7a is 32-bit, so a cast that is redundant on one can be required on the other. Linting only the primary ABI would license "fixes" that break the 32-bit build — the shipping ABI for the many 32-bit Google TV / Android TV boxes this client targets. x86_64 is skipped: it is emulator-only and shares its width with arm64, so it costs lint time for no signal the other two do not already carry. The five resident lints: * `audio.rs` / `mic.rs` `type_complexity` — the open-attempt closures now return named `OpenedPlayback` / `OpenedCapture` aliases. The two tuples are mirror images of each other (playback sends, capture receives), which the aliases now say out loud. * `vsync.rs` ×2 `unnecessary_cast` — **not** taken. `timespec`'s fields are 32-bit on armv7 and 64-bit on arm64, so the casts are REQUIRED on one shipping ABI and redundant on the other; following the suggestion would break the 32-bit build. `i64::from`/`.into()` do not escape it either, they trade `unnecessary_cast` for `useless_conversion` on the 64-bit side. Answered with a documented `#[allow]` at the expression instead of in whichever build breaks first. * `pad_audio.rs` `needless_range_loop` — iterator form, preserving the `channels < 2` no-op the range had. Verified: `:kit:cargoNdkClippy` green on both ABIs, host-lane clippy for the crate still clean, `cargo fmt --all --check` clean. The gate was proven non-vacuous by planting `1i32 as i32` in an android-only module and confirming it fails the task, then reverting. |
||
|
|
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. |
||
|
|
2032c48ffa |
fix(android/pad-audio): a game that only rumbles keeps rumbling
ci / web (pull_request) Successful in 1m19s
ci / docs-site (pull_request) Successful in 2m49s
ci / rust-arm64 (pull_request) Successful in 3m9s
android / android (pull_request) Failing after 4m23s
ci / rust (pull_request) Successful in 6m54s
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 2m22s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 55s
Three faults that between them silence a wired DualSense. The trade was committed without asking whether the host can send pad audio at all. Against every released host — no HOST_CAP_PAD_AUDIO — the renderer claimed the interface, took the pad off wire rumble, and then rendered nothing, with `pad_haptics` defaulting on and no UI to turn it off. The capability is now checked before `sink::open`, so nothing is claimed and nothing is traded. Arming was unconditional, so a speaker-only setup took the motors away too. The speaker pair is channels 0/1 and no rumble write can disturb it; only the haptics lane arms now. And the suppression itself was wrong for the case that matters most: a title driving classic rumble and no haptics audio. Suppressing on "a stream is open" assumed the game's rumble rides the haptics mix, which for such a title is false — it renders no haptics audio at all, so the host's -60 dBFS gate emits nothing on 0xD1 and the pad was left with neither. Ownership is now decided by evidence: the coils belong to haptics only while haptics frames are actually arriving, and to wire rumble otherwise. Frames are stamped on arrival rather than after decode, so a decoder hiccup cannot hand the coils back mid-effect, and concealment does not count as evidence. Liveness is dropped at every teardown, because wire indices are recycled and a stale stamp would let a fresh pad inherit the previous occupant's ownership. Arbitrating on evidence rather than on a prediction about the hardware is deliberate, and the module doc now says why. It used to assert that the coils and the rumble motors are the same physical actuators — "a firmware constraint, not a preference". Nothing establishes that: it traces to one reverse-engineered comment in SDL, whose own modern path sets HAPTICS_SELECT alone with amplitude on ucEnableBits3, which reads more like an independent mute than a shared- actuator interlock. The combination that would settle it — rumble with HAPTICS_SELECT cleared — is emitted by no code anywhere, and nothing here writes it either. The evidence rule is correct under either hypothesis. The liveness clock is 1-based so that 0 stays an unambiguous "never stamped": without it a frame arriving in the process's first millisecond read as never-arrived and handed the coils back mid-effect. Its test caught that. Verified: clippy -p punktfunk-client-android --all-targets --locked -D warnings = 0; 15 tests pass. Owed: the desktop twin of the arbiter, and the coil restore — the Android stop write still asserts HAPTICS_SELECT with zero amplitude, where SDL's all-zero stop restores the audio path. From the 2026-08-03 force-feedback sweep (B4, B5; B6 partly). |
||
|
|
6fed1510ba |
test(android): report renderer stats even when the plane is silent
ci / web (pull_request) Successful in 1m2s
apple / swift (pull_request) Successful in 1m16s
ci / docs-site (pull_request) Successful in 1m16s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m30s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m53s
android / android (pull_request) Successful in 4m7s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m30s
ci / rust (pull_request) Canceled after 4m44s
The renderer now reports once a second regardless of traffic — frames in, samples decoded, peak level, frames written, underruns, short bytes. The first version reported only after a frame arrived, which made the single most diagnostic state unreportable: an idle plane and a dead renderer looked identical (both silent). That cost a debugging round on real hardware, where the absence of any line had to be triangulated against usbfs interface claims and `dumpsys input` to work out which of the two it was. The peak is of the decoded PCM, and it is the discriminator that matters: frames arriving with peak=0 means the host's capture is hearing silence — a routing problem upstream — whereas a non-zero peak means real signal is reaching the pad and anything still wrong is downstream of the write. |
||
|
|
e32bd30c85 |
fix(android): give the renderer its own USB connection, and add a real-world self test
ci / docs-site (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m17s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m3s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m26s
ci / rust-arm64 (pull_request) Successful in 1m28s
android / android (pull_request) Successful in 4m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m21s
ci / rust (pull_request) Successful in 13m11s
**The bug.** The renderer was handed `HidUsbLink`'s file descriptor. That link's own comment states the hazard exactly — "only one thread may drive a connection's UsbRequests (requestWait() returns ANY completed request; a second waiter would steal the reader's completions)" — and it is just as true of the usbfs reap underneath: the isochronous ring and the HID reader were reaping each other's URB completions. The standalone harness works because it owns its descriptor by construction, which is precisely why it could never have caught this. `DsCapture` now opens a dedicated connection via `openAuxConnection()` and closes it only after the render thread is joined. **The test.** Nothing exercised the CLIENT path without a host, so the two things most likely to be wrong were invisible: whether the descriptor handed over is exclusively ours, and whether the claim succeeds on this kernel. Neither is unit-testable and a harness proves neither. `nativePadAudioSelfTest` drives the voice coils with a tone through the real path — the same aux connection, claim, sink and write loop the renderer uses — and is triggered by `adb shell setprop debug.punktfunk.pad_audio_selftest 3`, matching this repo's existing debug.punktfunk.* convention. It runs INSTEAD of the renderer for that capture, never alongside it: two engines on one descriptor is the fault being tested for, and I nearly shipped it into the test itself. Underruns are deliberately not a failure condition — that is producer pacing. The pass condition is data reaching the bus. |
||
|
|
2f1ef44191 |
fix(android): commit the tier-A trade only once the USB stream actually opens
ci / web (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m17s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m1s
ci / rust-arm64 (pull_request) Successful in 2m9s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m12s
android / android (pull_request) Successful in 3m36s
ci / rust (pull_request) Canceled after 4m11s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 55s
A real bug, and the worst shape one can take here: it costs the user ALL haptics rather than degrading. `pad_audio::start` returned success as soon as the render thread spawned, and `nativeStartPadAudio` then declared the pad's render capability and took it off wire rumble. But `sink::open` runs later, on that thread. On a kernel that refuses the interface claim — the OEM case documented as needing a clean tier-C fallback — the pad was already suppressed and the host already streaming 0xD1 at a renderer that never opened. No pad audio, and no rumble either. The declaration and the suppression now happen inside the renderer, immediately after a successful open, and are both withdrawn when it stops. A failed open declares nothing and suppresses nothing, so the session stays on ordinary rumble — which is what "degrades to tier C" was always supposed to mean. `PadAudio`'s Drop clears the tier-A bit too, so a thread that dies unexpectedly cannot leave a pad permanently mute. The general rule this violated: never give up a working fallback until the thing replacing it is known to work. Spawning a thread is not evidence that it will. |
||
|
|
a10bde39bb |
feat(android): declare pad-audio caps and take tier-A pads off wire rumble
The two things that decide whether WP9 does anything at all on a device, both failing silently rather than loudly if missed. **Capability bits.** The host emits 0xD1 only toward pads that declared they can render it (arrival flags 8/9). Without `set_pad_audio_caps` the renderer would sit on a permanently empty plane and look like a decode bug. Declared when the stream opens, withdrawn when it stops. **Rumble arbitration.** `valid_flag0` bit 1 (HAPTICS_SELECT) *disables* audio haptics and selects classic rumble, and `DsDevice` sets it on every rumble write — as Linux's hid-playstation and SDL both do. One replayed rumble command would mute the voice coils the 0xD1 stream is driving, for the rest of the session. Tier A and tier C are mutually exclusive in the pad's firmware, so the arbitration selects and never blends. Suppression sits at `nativeNextRumble`, the pull point, rather than in Kotlin: it keeps the rule next to the reason and covers every caller. The registry is an atomic bitmask because the reader is the rumble poll thread and must not block behind a start/stop on the JNI thread. Order matters on teardown: the capability is withdrawn before the pad returns to wire rumble, so the host has stopped sending 0xD1 before tier C resumes and the two never overlap. `nativeStartPadAudio`/`nativeStopPadAudio` now take the wire pad index, since both the capability and the arbitration are per-pad. Out-of-range indices are rejected rather than wrapped into another pad's slot. 12 host tests (2 new, including one pinning that an out-of-range index cannot shift the mask into undefined territory), 0 clippy findings, check clean on all three Android ABIs. |
||
|
|
b5f91d50bb |
feat(android): tier-A pad audio — the 0xD1 plane on the pad's USB endpoint (WP9)
The Android twin of `pf-client-core`'s pad_audio: drain the host's per-pad DualSense streams, Opus-decode haptics (kind 0) and speaker (kind 1), interleave into the pad's own 4-channel layout, and render on the pad itself. Every other client hands that stream to the platform's audio graph. Android cannot: AOSP's UsbAlsaManager denylists the DualSense's output by VID/PID, so the kernel enumerates the pad's playback node and the framework discards it — `hasOutput: false`, nothing for setPreferredDevice to target, /dev/snd closed by SELinux, and UsbRequest rejects non-bulk/interrupt endpoints. So this drives the pad's isochronous endpoint directly via uac-host on the descriptor Java owns. That is measured, not assumed. On a Nothing Phone (3): the claim succeeds unprivileged, the gamepad and the pad's microphone both keep working, and the underrun-free floor is 4 ms — holding under eight-core load with the SoC in severe thermal throttling. The renderer runs at 6 ms, one step of headroom, because the same measurement found transient events that are not depth-dependent. Structured to the crate's own convention: the mixer and PLC are ungated so they compile and unit-test in the host workspace (8 tests), while everything touching an Android-only dependency is cfg'd to android. Two details worth review: - The kinds arrive on different cadences (5 ms vs 10 ms), so each has its own write cursor and both shift together on overflow — a haptics-only session renders with a silent speaker pair instead of stalling on a kind that will never arrive, and the two can never skew. - An unrecognised kind is dropped rather than folded into the coil pair. A `min(1)` clamp would have rendered a future kind straight into the actuators. Lifecycle mirrors MicCapture: dropping the handle joins the thread, and nativeStopPadAudio returns only once it has, so Kotlin may close the UsbDeviceConnection as soon as it returns and not before. usbfs-iso/uac-host enter as git dependencies pinned by revision — a transport under a real-time deadline should move when we choose. They become version dependencies once published to crates.io. |