5be494f490bd6c4cf98917b28fb2d7f44d2fcb17
2347
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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
|
||
|
|
926e2ccbdd |
Merge pull request 'feat(decky): the settings tab covers the whole store, as a SteamOS-style sidebar' (#24) from worktree-decky-stats-overlay-toggle into main
ci / web (push) Successful in 1m7s
ci / rust-arm64 (push) Successful in 2m12s
decky / build-publish (push) Successful in 30s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
ci / docs-site (push) Successful in 1m15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 19s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m5s
docker / builders-arm64cross (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m26s
docker / deploy-docs (push) Failing after 6m7s
ci / rust (push) Successful in 8m27s
Reviewed-on: #24 |
||
|
|
b8b38d082e |
Merge pull request 'fix(plugins): plugin output reaches the console's log page, and /tmp is no longer hidden from the runner' (#27) from worktree-plugin-logs-and-vh-fixes into main
ci / rust (push) Canceled after 23s
ci / rust-arm64 (push) Canceled after 24s
ci / web (push) Canceled after 24s
ci / docs-site (push) Canceled after 24s
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
apple / swift (push) Successful in 1m16s
deb / build-publish-client-arm64 (push) Successful in 1m12s
deb / build-publish (push) Successful in 3m52s
deb / build-publish-host (push) Successful in 4m45s
android / android (push) Successful in 5m24s
apple / screenshots (push) Successful in 5m57s
arch / build-publish (push) Successful in 8m4s
windows-host / package (push) Successful in 17m44s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 22m42s
Reviewed-on: #27 |
||
|
|
db1faef9fb |
docs(plugins): don't name a release that doesn't exist yet
ci / web (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m45s
ci / rust-arm64 (pull_request) Successful in 2m39s
android / android (pull_request) Successful in 3m16s
ci / rust (pull_request) Successful in 7m58s
The /tmp troubleshooting note said PrivateTmp=yes shipped "until 0.23.1". 0.23.0 is the latest tag and the next number isn't decided, so that could be wrong on arrival. "In earlier releases" is true whichever number it gets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
1e56705b86 |
fix(sdk/log-ship): a busy plugin's lines survive a POST, and the shutdown tail is actually sent
Two bugs in the log shipper, both found by re-reading it rather than by a failing test, and both of the kind where the symptom is a missing log line — which is the one failure a logging path must not have. The recursion guard was held across the whole `await fetch`, and `enqueue` checked it. So every line logged while a POST was open was dropped, silently. That window is milliseconds when the host is healthy and much longer when it is not, and the lines lost are whatever a busy plugin happened to be saying — so the shipper was least reliable exactly when it was most needed. The flag now guards flush re-entry only (the interval can fire while a slow POST is still open, and two concurrent flushes would splice disjoint batches out of one queue and deliver them out of order). Nothing on the shipping path logs, so the recursion it was guarding cannot form; that is now a stated rule at the top of the file rather than a flag that costs real lines. An explicit `flush()` hit that same re-entry guard and returned having sent nothing. That is the shutdown path: the runner flushes once more after its units' finalizers have run, and those last lines are the ones that say whether the shutdown was clean. It now waits for an in-flight flush before starting its own. Both are covered by tests that fail against the previous code. The first needed a server that signals when it has the request — logging merely "after calling flush()" passes against the bug, because flush yields at its own awaits long before the fetch starts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
365caa23be |
fix(plugins): plugin output reaches the console's log page, and /tmp is no longer hidden from the runner
A user could not get the VirtualHere plugin to use their VirtualHere client and asked, reasonably, where the logs were. There was no good answer, and the reason they were stuck turned out to be ours. **The runner could not see /tmp.** `punktfunk-scripting.service` set PrivateTmp=yes, which hands the unit a private tmpfs. But integrating with things already running on the box is the entire job of a plugin, and on Linux those talk over /tmp: VirtualHere's client IPC is the FIFO pair /tmp/vhclient + /tmp/vhclient_response, X11 is /tmp/.X11-unix. So the plugin launched the vendor binary happily and could then never reach the daemon behind it — while the same command worked perfectly in the operator's own shell, because that shell has the real /tmp. No config change could fix it, which is exactly the loop the report described. PrivateTmp is now off, with /tmp added to ReadWritePaths (which ProtectSystem=strict would otherwise make read-only). **Plugin logs now land in the console.** Plugins are not host child processes — the runner is a separate bun process that import()s each plugin in-process — so nothing they print passed through the host's tracing, and the console's Logs page could not show a single plugin line. The fallback was journalctl on Linux; on Windows the runner's scheduled task writes no log file at all, so a failing plugin was diagnosable only by stopping the task and re-running the runner by hand. Both mean shell access on the host box, which is what the console exists to avoid — and it left the one question a stuck user asks with no answer. So the runner now tees its output to POST /api/v1/plugins/logs, and those lines join the host's own ring under one cursor, targeted plugin:<name>. The console grows a Host/Plugins switch beside the level filter; an empty Plugins view says the thing that is actually usually wrong (the runner isn't running) rather than "adjust the filter". The shipper keeps stdout authoritative — journald and foreground output are unchanged whatever the host is doing — and is built so that logging can never hurt the thing being logged: it never throws into a caller, holds a bounded queue that drops oldest and then says how many, backs off when the host is away (a restart is normal), and re-sends a batch the host failed to take. Lines logged while a POST is in flight are kept, which cost one round to get right: the first version held its recursion guard across the await and silently dropped exactly the lines a busy plugin produces. Runner lines that report a failure (a refused unit file, a crashed plugin, a give-up) now go out at warn/error instead of all arriving as INFO, so the console's level filter means something for them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0d5e5b436b |
fix(android/pad-audio): pin the uac-host that unmutes the pad
ci / web (pull_request) Successful in 59s
apple / swift (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m50s
android / android (pull_request) Successful in 5m55s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 59s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 54s
ci / rust-arm64 (pull_request) Successful in 9m27s
ci / rust (pull_request) Canceled after 14m9s
The pad rendered nothing — not its speaker, not its voice coils — because `uac-host` streamed into a device it never unmuted. It set the sample rate and nothing else; the UAC Feature Unit, where Mute and Volume live, was parsed by nobody. Every counter stayed green throughout: URBs completed, 0 short bytes, 0 URB errors, 0 short writes here, decoded peak 19345. None of them can observe mute, so a muted device is indistinguishable from a working one. Bumps the pin to unom-io/usbfs-iso f3de1fd, which sends SET_CUR Mute=0 and Volume=0 dB to the Feature Unit before the stream starts. With this in, Spider-Man Remastered's haptics reach the physical DualSense through the virtual pad, confirmed by feel on real hardware. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3a48cc2470 |
test(host/pad-audio): drive either channel pair, so the speaker leg can be proven too
`pad-endpoint tone` only ever drove the BACK pair, which meant the pad's speaker — the FRONT pair, the other half of the 4-channel split — had never carried a signal end to end. The capture probe's verdict was shaped the same way, and called a perfectly good front-pair run "silent". `--pair front|back|both` picks the pair, and the verdict now reports which pair it SAW rather than judging against an assumed one. Measured on .173, an exact mirror in both directions and no crosstalk either way: --pair back peak_front=0.0000 peak_back=0.5000 back only, channel-exact --pair front peak_front=0.5000 peak_back=0.0000 front only, channel-exact --pair both peak_front=0.5000 peak_back=0.5000 both So the host half of the speaker path is proven to the same standard the haptics path was. What is still unproven is the client rendering the front pair into the pad's own speaker; that needs the phone unlocked, which it no longer is. Host clippy clean; 360 tests pass, the one mgmt display failure reproduces on a clean tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
64a392634e |
test(host/pad-audio): prove the endpoint actually carries audio, channel-exact
`pad-endpoint tone` only ever proved a render client could open the endpoint. Whether anything came back out of the loopback — and in the right channel pair — was still taken on faith, which is exactly the gap that let a stamped-but- unservable endpoint look healthy while a client sat on an empty plane. `pad-endpoint capture [seconds]` opens the real PadLoopbackCapturer and reports frames plus per-pair peaks, so the two halves together exercise render -> engine -> loopback -> pair routing with no game and no client attached. Run against each other on .173: pad-endpoint capture: 157920 frames over 7s, peak_front=0.0000 peak_back=0.5000 VERDICT: PASS - back pair only, front pair silent (channel-exact). 0.5 is the tone's own amplitude and the front pair is dead silent, which is the signal the 0xD1 framer routes to the voice coils. Same figure the program notes recorded on 2026-08-01 and nothing has been able to reproduce since. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
35285afafc |
fix(host/pad-audio): retire the freed-string endpoint lookup everywhere, and make provisioning converge
Two loose ends from the pad-audio bring-up. `wasapi 0.23`'s `DeviceEnumerator::get_device` passes `GetDevice` a pointer into an `HSTRING` temporary that was already dropped, so it resolves whatever the allocator left behind and misses ids that are perfectly valid. Only the pad-audio path had been moved off it; the remaining four callers include desktop loopback capture and the default-endpoint judgement, where a spurious miss silently downgrades a capturable default to Unknown. The host now resolves through `open_wasapi_device` (raw COM, buffer kept alive). `pf-client-core` cannot share that helper — it pins a different `windows` revision than `wasapi` does, so the two `IMMDevice` types are incompatible — and instead scans the active collection by id, which touches only safe crate APIs. Provisioning also stopped latching a transient. A stamp lands, a check run immediately afterwards reports all seven keys served, and AudioEndpointBuilder then reverts the three format keys behind us, leaving 4/7 for good. Since `needs_aeb_kick` is what makes startup restart AudioEndpointBuilder + Audiosrv, that transient meant bouncing the machine's whole audio stack on every host start, forever, chasing stamps a re-pass lands. `ensure` now stamps, lets AEB settle, and only then checks — repeating up to five times. Before: fresh provisions landed 4/7 with kick=true on 3 of 4 runs. After: 4 of 4 runs settle 7/7 with kick=false in 2.8s, identity intact (Wireless Controller / DualSense Wireless Controller / PFDS container), 4ch mask 0x33, render and loopback capture both opening, and `pad-endpoint tone` clean. Host clippy clean; 360 tests pass, the one mgmt display failure reproduces on a clean tree. The client-side helper is type-checked against wasapi on Windows in isolation — pf-client-core itself will not build on .173 (no ffmpeg/SDL3/Vulkan toolchain there), so its module integration is unverified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0d0e7e6861 |
style(host/pad-audio): drop a redundant f32 cast in the tone devtest
clippy's `unnecessary_cast` fires on it, which fails CI's -D warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
143454590f |
test(host/pad-audio): let a stamp subset be re-provisioned, and confirm the endpoint really is 4ch
`PUNKTFUNK_PAD_AUDIO_STAMPS` narrows `ensure` to a named subset of the seven stamps (unset keeps all of them, so the shipping path is unchanged). The MMDevices Properties ACL denies even an elevated `reg delete`, so the only way to ask "which stamp breaks this endpoint" was to re-provision with subsets. Using it settled that nothing does. Once the heap corruption is out of the way and stamping completes in ONE pass, the full set yields an endpoint that is 4ch/48k/mask 0x33 with both directions open — render and the loopback capture that feeds the 0xD1 plane — and `pad-endpoint tone` renders without error. The intermediate reading, that the Steam driver was stereo-only and the feature needed a different carrier, was a confounded A/B: the "stamped" sample had accumulated its stamps across heap-corrupted runs. Asked properly — in EXCLUSIVE mode, which reaches the driver instead of the engine's mix format — that driver reports 2ch, 4ch and 8ch, the same shape a real DualSense reports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9409d0a04c |
fix(host/pad-audio): provisioning stops corrupting the heap, and the endpoint stops being resolved by a freed string
Two defects sat between the pad-audio endpoint and any sound. Neither was where the symptom pointed. `windows 0.62` implements `Drop for PROPVARIANT` as `PropVariantClear(self)`. Every variant `set_store_value` builds borrows memory Rust owns — a `Vec<u16>`, a `&GUID`, a `&'static [u8]` — so each stamp handed that pointer to `CoTaskMemFree`. The file said the opposite in a comment, which is why it looked safe. The damage surfaced late: `pad-endpoint ensure` died with STATUS_HEAP_CORRUPTION (0xC0000374) partway through stamping, leaving the endpoint with whatever subset had landed and `needs_aeb_kick` stuck true forever. With the variants held in `ManuallyDrop`, `ensure` exits 0 and all seven stamps read back served for the first time. `wasapi 0.23`'s `DeviceEnumerator::get_device` builds its argument as `PCWSTR::from_raw(HSTRING::from(id).as_ptr())`; the `HSTRING` is a temporary, so `GetDevice` reads freed memory. That is where the `IAudioClient: 0x80070002` came from — not from the endpoint, which activates fine. Resolving through `open_mmdevice`, which keeps its buffer alive, retires the error in both the tone devtest and the loopback capture. Also adds the instrument that separated these: the tone path now reports the raw `IMMDevice::Activate` result alongside the crate's, and `pad-endpoint tone --endpoint <id>` can drive any endpoint, so "this process cannot activate anything" and "this endpoint is broken" stop looking identical. Verified on .173: ensure exit=0, 7/7 stamps served, needs_aeb_kick=false, 0x80070002 gone. Host clippy clean; 360 tests pass (the one mgmt display failure reproduces on a clean tree). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
212bdc3b08 |
fix(devtest): resolve the pad endpoint by system lookup, not the service's cache
apple / swift (pull_request) Successful in 1m25s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m43s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m37s
ci / docs-site (pull_request) Successful in 3m8s
ci / web (pull_request) Successful in 3m31s
ci / rust (pull_request) Successful in 8m16s
android / android (pull_request) Successful in 8m43s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 14m18s
|
||
|
|
45cb525035 |
wip(host): pad-endpoint tone devtest
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m34s
ci / docs-site (pull_request) Successful in 2m48s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m28s
ci / web (pull_request) Successful in 3m53s
android / android (pull_request) Canceled after 4m2s
ci / rust (pull_request) Canceled after 4m9s
|
||
|
|
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. |
||
|
|
4fd240deab |
test(android): make the pad-audio self test reachable without a host
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m46s
ci / rust-arm64 (pull_request) Successful in 2m37s
android / android (pull_request) Successful in 3m58s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m13s
ci / rust (pull_request) Successful in 4m6s
The self test shipped in the previous commit was gated behind a capture, which needs a stream, which needs a host — so it depended on precisely the thing it exists to rule out. It could not have been run in the situation that motivated it. It is now a "Test haptics" button on the DualSense passthrough card in Settings → Controllers → Connected controllers, which is reachable with no session at all. It opens its OWN connection to the pad — the same rule the renderer follows, and the rule whose violation caused the fault this test looks for — runs the tone on a worker thread, and reports a plain-language result: which of open / write / no-data failed, or how many frames reached the pad. The debug-property trigger stays for the in-session case; this is the one that answers "can this phone drive this pad at all" before a host is even involved. |
||
|
|
f71bee917b |
Merge origin/main; the presenter rebuild's four settings join the sidebar
`#20` landed while this branch was open and added four settings the console screen groups under a new "Presentation" header: Prioritize, Smoothness buffer, V-Sync and Follow variable refresh. A branch whose whole claim is "everything the store holds is reachable" cannot merge past those, so they get a Presentation page of their own, in the console screen's position (after Video, before Audio) and with its wording. Smoothness buffer is indented under Prioritize and disabled until the intent is Smoothness — the same relationship the console's `enabled` gate draws. The docs conflict resolves to main's side plus this branch's correction: the 4:4:4 advertisement claim main rewrote is the current one and stays, while "Android, Decky and the console home don't offer it" was wrong about two of the three before this branch and about all three after it. The four new settings' paragraphs pick up the console home and Decky the same way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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. |
||
|
|
6de78213ee |
feat(decky): the settings tab covers the whole store, as a SteamOS-style sidebar
The stats overlay was the visible half of a general problem: nine of the client's settings had a row here and twenty didn't, so a Deck that never sees a desktop could not reach its own decoder, chroma, HDR, audio layout, echo cancellation, touch or mouse model, scroll direction, auto-wake, or either audio endpoint. Everything the store holds is here now — except the two things a plugin backend genuinely cannot answer, named in `backend.ts` so the next reader doesn't go looking: which physical pad is player 1 (SDL's live device list lives in the client process, and no CLI enumerates it) and the session's remembered window size, which is not a preference. Thirty rows is too many to scroll past on a thumbstick, so they are split across a `SidebarNavigation` — the left-rail-of-categories layout SteamOS's own Settings uses, and the one Deck users already know. Every page fits on screen without scrolling, which is the point: the rail is the index, so nothing is more than one hop away. The categories, their order and the wording of the rows are the console settings screen's — it is the other settings editor reachable without leaving Gaming Mode, and two different orders for one store is how people stop trusting either. It shows them as one steppable list because it has no pointer and no room for a rail; here they become the rail's pages. The six pages take one shared settings object rather than each holding state, so a change on one is visible on the others the moment you switch. Three more rules: - A dependent setting is INDENTED under what it depends on and DISABLED, never hidden: mic device and echo cancellation under the microphone, controller type under forwarding. The console dims those rows for the same reason, and a row that vanishes as you toggle the one above it is a moving target for a thumbstick. The device row at the foot of Audio is rendered even while it reads, for that reason. - A picker with nothing to pick doesn't appear: the GPU row shows up only where the enumeration found more than one adapter, so it is absent on a Deck and present on a Bazzite desktop with a dGPU. - A setting that behaves differently HERE says so in its own description rather than being dropped. Capture system shortcuts holds nothing back under gamescope; fullscreen-on-stream can't lose to a launch that always passes `--fullscreen`; the client's library toggle isn't this plugin's browser. Each says which. The device pickers are real, not stubs: `list_devices` reads `--list-adapters` and `--list-audio` off the SESSION binary, the same two enumerations the GTK shell shells out for because it links no Vulkan itself. It is cached for the life of the backend (that call inits Vulkan and PipeWire) with an explicit Refresh for the headset you just plugged in, and a failure — a client too old to ship the session binary — leaves the pickers on Automatic and says so instead of claiming you have no devices. `_parse_audio_endpoints` is split out and unit-tested with the malformed lines that must never reach a picker. Two smaller honesty fixes fall out of building it. A Dropdown can only display a value that is one of its options, and this store has four other writers — so a stored value the table doesn't list is carried as its own entry rather than rendering blank or, worse, showing a different value than the stream will use. And a stored audio endpoint that isn't currently connected keeps a "(not connected)" entry, the way the Linux picker keeps "(not detected)", instead of silently re-pointing the next stream at the default. The Settings tab's wrapper deliberately stops being a scroll area: a SidebarNavigation given an indefinite height to fill collapses its rail, so the pane hands it the full height and keeps its hands off the overflow, and the footer inset moves inside the pages. The docs claimed nine things about this plugin that are no longer true, and three about the console home that stopped being true when its own row set grew on 2026-07-31 (4:4:4, echo cancellation, auto-wake and the library toggle are all there in `screens/settings.rs`). Both corrected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
aa3bcfd0d0 |
Merge pull request 'feat(client/present): desktop presenter rebuild — intent engine, V-Sync/VRR, display-stat split, settings UI' (#20) from worktree-desktop-presenter into main
apple / swift (push) Successful in 1m18s
arch / build-publish (push) Failing after 1m17s
ci / web (push) Successful in 1m46s
ci / docs-site (push) Successful in 1m30s
deb / build-publish-host (push) Failing after 11s
ci / rust-arm64 (push) Successful in 3m51s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 18s
apple / screenshots (push) Successful in 5m54s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 26s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
deb / build-publish-client-arm64 (push) Successful in 1m4s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m42s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
android / android (push) Successful in 6m54s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m37s
docker / builders-arm64cross (push) Successful in 17s
deb / build-publish (push) Failing after 4m50s
ci / rust (push) Failing after 7m29s
docker / deploy-docs (push) Successful in 36s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m54s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m3s
flatpak / build-publish (push) Successful in 6m28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m21s
Reviewed-on: #20 |
||
|
|
8ee224e5db |
fix(android): advertise CLIENT_CAP_PAD_AUDIO, without which nothing is ever sent
ci / web (pull_request) Successful in 1m6s
apple / swift (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m35s
ci / docs-site (pull_request) Successful in 1m14s
android / android (pull_request) Successful in 3m2s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m20s
ci / rust-arm64 (pull_request) Successful in 5m27s
ci / rust (pull_request) Successful in 12m30s
A gap in the previous commits, and the same silent-failure shape as the two they fixed. There are TWO negotiations, not one: the per-pad render capabilities that ride a gamepad arrival (bits 8/9), which those commits set, and the SESSION-level CLIENT_CAP_PAD_AUDIO in the Hello, which they did not. Without the latter the host never sets HOST_CAP_PAD_AUDIO and emits no 0xD1 at all — so the per-pad bits would have had nothing to gate, and the renderer would have sat on a permanently empty plane with every other piece looking correct. Threaded as an explicit `padAudioOk` on nativeConnect rather than advertised unconditionally: the cap makes a Windows host provision pad endpoints at startup, and a user who has pad audio switched off should not pay for that. Found by tracing what an on-glass run against a real host would actually need, not by a test — there is no test that could have caught it, since both halves are individually well-formed. |
||
|
|
6b3c582eb1 |
feat(client/present): use the driver's queue-free vblank mode where it exists
apple / swift (pull_request) Successful in 1m14s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m30s
ci / rust-arm64 (pull_request) Successful in 2m51s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m3s
android / android (pull_request) Successful in 5m32s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m53s
ci / rust (pull_request) Successful in 7m13s
`VK_PRESENT_MODE_FIFO_LATEST_READY_EXT` is FIFO's tear-free vblank pacing that
presents the LATEST READY image at each refresh and retires the older ones,
instead of draining a queue. That is precisely what the software glass gate
emulates — so where the driver offers it, the driver does the job, and it does
it exactly where the gate matters most: a surface with no MAILBOX gets
newest-wins behaviour back without the app holding frames.
Found by asking the surface what it actually offers rather than trusting a
comment: the previous commit's `surface present modes` line read back
`[MAILBOX, 1000361000, FIFO]` on NVIDIA/Wayland, and 1000361000 is this mode.
The extension postdates the Vulkan headers ash 0.38 is generated from (1.3.281),
so there is no binding — hence the bare number in the log. It is hand-declared
here: mode value, extension name, and
`VkPhysicalDevicePresentModeFifoLatestReadyFeaturesEXT` spliced into the device
pNext chain. One trap worth naming: the SURFACE advertises the mode even with
the extension disabled, and using it on that basis is undefined — so the ladder
only offers it when the device feature actually came back true and we enabled it.
The gate/probe predicate had to split in two, and the distinction is the point:
* `needs_glass_gate()` — FIFO and FIFO_RELAXED only. NOT this mode: gating on
top of a driver that already retires stale images would hold frames back to
emulate something the presentation engine is doing, paying the serialisation
twice, which is the ~27 ms the last commit measured.
* `vblank_locked()` — the whole FIFO family INCLUDING this mode, because it
still presents on the refresh boundary, so the VRR cadence probe's premise
("with VRR off, a present waits for vblank") still holds.
Ranking: MAILBOX first (measured good at 1.4 ms), then LATEST_READY, then plain
FIFO — so a MAILBOX-less surface reaches newest-wins in the driver rather than
in our gate.
MEASURED ON GLASS (.21, NVIDIA 610.43.03, GNOME/Wayland): the extension probe,
feature enable and swapchain creation all succeed with a mode ash has no binding
for. Default ladder selects MAILBOX with `fifo_latest_ready=true`; the VRR ladder
selects `present_mode=1000361000` and measures `display 2.6 ms (pace 0.6 + latch
2.0)` — against 13-28 ms for plain FIFO + gate on the same box. The vblank-locked
path is now MAILBOX-class.
That changes the previous commit's reversal. The VRR ladder was reverted to
opt-in because it led with plain FIFO and cost ~27 ms; led with LATEST_READY it
costs 0.6 ms over MAILBOX. So `allow_vrr` is automatic again WHERE THE DEVICE
OFFERS THE MODE, and stays behind `PUNKTFUNK_VRR_FIFO=1` where it does not — on
those drivers the ladder would fall back to plain FIFO and the regression
returns. Both branches are pinned by tests. This also retires a dead switch: the
"Follow variable refresh rate" row did nothing at all after the reversal, and now
does something real on any driver with the extension.
⚠ Still unverified off this box: whether Windows and Intel drivers expose the
mode at all. Nothing measured here carries over — Windows Vulkan WSI goes through
DXGI, so exposing the enum and mapping it usefully onto flip-model semantics are
separate questions, and Intel is a different vendor stack again. Both facts are
logged unconditionally now (`surface present modes` + `fifo_latest_ready=`), so
one run on any box settles it. The code is safe either way: the mode is only
requested where the device feature enabled, and `allow_vrr` only goes automatic
there — everywhere else the shipped MAILBOX-first behaviour is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
e08474d96d |
fix(client/present): log the surface's actual present modes, and document the VRR opt-in
"AMD's Windows driver offers no MAILBOX" is the premise the FIFO glass gate is built on, and it has been carried in a code comment rather than measured. Present modes are a property of the (surface, device) pair — they vary by platform surface, driver version and fullscreen state — so the only way to settle it is to read them back from real machines. One unconditional log line makes every field log answer the question. First reading, .21 (NVIDIA 610.43.03, GNOME/Wayland): surface present modes available=[MAILBOX, 1000361000, FIFO] Two things fall out. No IMMEDIATE and no FIFO_RELAXED on this surface, which is why a PUNKTFUNK_PRESENT_MODE=immediate run reported mode=fifo — the pin was not offered and the ladder fell through; previously that looked like a puzzling result and is now evidence. And 1000361000 is VK_PRESENT_MODE_FIFO_LATEST_READY_EXT: FIFO's tear-free vblank pacing that presents the LATEST READY image instead of draining a queue — the driver-native version of what the glass gate emulates in software, and a candidate to replace it wherever the driver exposes it (needs VK_EXT_present_mode_fifo_latest_ready enabled at device creation, so a work package rather than a tweak). Also documents PUNKTFUNK_VRR_FIFO, which the previous commit introduced without a docs entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f422ae3e38 |
fix(client/present): what the first on-glass session found, including a reversed default
WP6 ran against .21 (CachyOS, RTX 5070 Ti, NVIDIA 610.43.03, GNOME/Wayland, 1080p60 HDMI, VRR provably disabled — `org.gnome.mutter experimental-features` is empty), host and client on the same box, `VK_KHR_present_wait` available. Five defects that unit tests and both CI gates had passed over: 1. The latch learner and the VRR probe observed NOTHING. Both derived spacings with `windows(2)` inside a single batch, but the run loop drains present-wait samples every pass, so a batch is normally ONE stamp. `period_us` read back exactly the mode fallback — correct by luck on a 60 Hz panel, wrong the moment a mode lies, which is the entire reason PanelGrid exists. The tests fed 40-stamp batches, a shape the live loop never produces. Spacings are now measured against the previous stamp across calls. 2. The VRR reference was circular. It compared spacings against the LEARNED period, but the grid cannot be learned from our own presents when the stream runs below panel rate — we only ever observe multiples ≥ our frame interval, so the learner adopts our own cadence and every delta is on-grid by construction. It learned 18-22 ms from a 40-50 fps stream and reported VRR on a display with VRR off. The reference is now the DISPLAY MODE's period, which is the vblank grid presents actually quantize to. 3. The probe is meaningless outside FIFO. MAILBOX deliberately decouples presents from scanout, so its stamps are never grid-quantized: same panel, same minute, FIFO read `no` (correct, period 16.4 ms) and MAILBOX read `yes` (wrong). Outside a FIFO-family mode the honest answer is Unknown, and that is now what it reports. 4. Round evaluation was per-CALL rather than per-sample, so the verdict depended on how the caller batched its stamps. Closed inside the sample loop now, with a test pinning bulk-vs-one-at-a-time equivalence — the same invariant (1) violated, in a second place. 5. `force_latency` was dead code without the `pyrowave` feature: a warning in the `--no-default-features` build CI actually ships (the Windows ARM64 leg). The gate only ever tested default features; it now tests both. DESIGN REVERSAL — the VRR FIFO-first ladder is opt-in (`PUNKTFUNK_VRR_FIFO=1`), no longer default. It shipped default-on for `allow_vrr` + fullscreen, which is the default configuration. Measured A/B, same box, back to back, reproduced across three runs: FIFO+engine `display 28.4 ms (pace 11.8 + latch 16.6)` versus MAILBOX `1.4 ms (0.2 + 1.2)`. Under a compositor the FIFO present's on-glass confirmation arrives a whole refresh later and the presenter serialises behind it. The VRR upside is real in principle but UNMEASURED — no VRR panel was available — and a default that is measurably ~27 ms worse on the hardware we could test, bought against an unproven win on hardware we could not, is the wrong way round. A test pins the default to MAILBOX; flip it back when a VRR panel confirms the win. NOT measured, and not claimed: the FIFO glass gate's own headline. The standing queue only forms when the stream rate approaches the panel rate, and an idle GNOME desktop is damage-driven at 40-50 fps on a 60 Hz panel, so `gated`/`forced` read 0 in every mode and the mechanism never engaged. The 11-13 ms figure is still the code's inherited documentation, not a fresh measurement. It needs its actual target: AMD-on-Windows (no MAILBOX, direct scanout) under load. Rig caveats recorded rather than smoothed over: host and client shared one GPU, so absolute latencies are contended and run-to-run variance was large, and it could not be visually confirmed what the physical screen showed. Mode selection, the fallback ladder, the VRR verdict and the counter plumbing are robust to that; absolute numbers are not. Gates: fmt, clippy -D warnings over the five client crates AND the `--no-default-features` build (added because defect 5 hid there), 160 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e38e3c44c9 |
feat(client/present): V-Sync and VRR become real settings, and VRR is measured
WP3 of design/desktop-presentation-rebuild.md. The `vsync` and `allow_vrr` settings have existed since WP1 but nothing consumed them — the swapchain picked MAILBOX-or-FIFO once, from an env var, and froze. This makes them mean something, which is also what unblocks their settings rows (deliberately withheld from WP5 rather than shipped as dead switches). Present-mode selection is now a preference ladder, not a constant: * V-Sync off — IMMEDIATE, then FIFO_RELAXED, then the tear-free modes. Asking to tear and silently getting vsync is a lie, so the mode that actually took is named in the stats line and a refused preference is logged requested-vs-active. * V-Sync on + VRR allowed + fullscreen — FIFO first. On a variable-refresh panel with direct scanout the FIFO present IS the flip, so the panel follows the stream's cadence instead of a fixed grid; MAILBOX would decouple presents from scanout and re-quantize to the compositor's clock. This is only safe because WP2's glass gate bounds the standing queue that historically made FIFO costly. * Otherwise — MAILBOX then FIFO, the shipped default, unchanged. `PUNKTFUNK_PRESENT_MODE` still pins a mode outright and now falls back to the settings (rather than to mailbox) when the name is unknown. VRR detection is MEASURED, never queried. No portable query exists — SDL exposes none, Wayland does not report adaptive-sync state, Windows surfaces nothing through Vulkan — and the platforms that do answer have been caught lying (see the Android per-uid refresh-rate finding). The discriminator is quantization: on a fixed-refresh panel every on-glass instant lands on the vblank grid, so the spacing between presents is ~k×period for whole k even when the stream runs slower than the panel (it just picks a larger k); under real VRR the panel refreshes when we present, so the spacing follows our own cadence and sits off the grid. `CadenceProbe` folds each delta to its distance from the nearest multiple of the learned period and takes the median. Tri-state: it stays Unknown below 24 deltas and after a display change, so `vrr` is reported only when it has been measured — never inferred from what the display claims. Also fixes the read-once refresh rate: `native.refresh_hz` was sampled at startup and never revisited, so dragging the window to another monitor left a 60 Hz-seeded clock pacing a 144 Hz panel. `WindowEvent::DisplayChanged` now relearns the latch grid, resets the cadence verdict, and clears the served-slot latch. Settings rows for both, on all three surfaces (GTK, WinUI, console). The console's V-Sync row is reachable in Gaming Mode, which is the only editor a Deck user has. Gates: punktfunk-rust-ci linux/amd64 — fmt, clippy -D warnings over pf-client-core, pf-presenter, pf-console-ui, the session binary and the GTK client, 160 tests (the two new ones cover every ladder and both cadence regimes, including the case that matters most: a stream slower than a FIXED panel must still read as fixed). WinUI leg on the Windows runner .133: clippy=0 tests=0, against a tree proven by content to contain the edit. ⚠ On-glass validation is still owed and is NOT claimed here: every box with a real display was powered off when this landed, so the VRR ladder and the detector have been exercised only against synthetic stamps in unit tests. Rebase follow-up: `20de58a7` landed the same "panel grid can be wrong in both directions" defect fix on Android and extracted the corrected learner into `punktfunk_core::phase::PanelGrid` for the iOS and desktop presenters to share. This clock had the identical bug — it capped the learned period at the display mode's refresh, and the mode is only a CLAIM, so a display really running slower than it advertises pinned a grid whose instants never arrive, for the session, with no way back. Adopted the shared learner rather than carrying a second, buggier copy; still fed the window's MIN spacing, which preserves the k×period resistance the cap was actually aimed at while the streak requirement lets a genuinely slower panel be discovered. New test: seed 120 Hz, real panel 60 Hz, the clock must climb back out. Took the same commit's third lesson too: the adaptive margin widened on a latch over 1.5×period (a number picked here), and now widens on the latch exceeding one period plus the lead already applied — the slot actually aimed at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b1ac4d02de |
feat(client/present): the display stat splits, and the intent reaches the settings UI
WP4 + WP5 of design/desktop-presentation-rebuild.md, on top of the WP1/WP2 engine. The engine shipped with no way to choose it and no way to see what it cost; this closes both. WP4 — the display stage splits into `pace` (decoded → present-submit, our own pipeline) + `latch` (submit → on-glass, the presentation queue and the vblank wait), off the `submitted_ns` stamp WP2 already carried. That split is what makes a high `display` self-diagnosing: latch dominating is the vsync floor or a standing queue, pace dominating is us. A `present:` line joins the Detailed tier naming the live swapchain mode — the answer to most "why is my latch a whole refresh" questions, since a MAILBOX request silently lands on FIFO wherever the driver has no mailbox — plus the engine's counters, rendered only when they are non-zero so a healthy latency session shows just the mode. Deviation from the plan: the planned `display_adj` twin is NOT here. It was specified as `display − latch_p50` for parity with the Apple HUD's shaved figure, but with a real per-sample `pace` percentile that twin is the same quantity derived worse (subtracting percentiles). `pace` IS the Apple-comparable number — Apple subtracts its OS present floor, the latch is ours — and the user docs now say exactly that. WP5 — Prioritize + Smoothness buffer on all three surfaces: the GTK dialog (a new Presentation group on the Display page), the WinUI settings page, and the console settings screen, which is the ONLY editor reachable in Gaming Mode and so the one that decides whether Deck users can reach this at all. The buffer control follows the intent the way echo cancellation follows the mic: hidden on the desktop shells, dimmed and inert on the console, where a row that vanished mid-list would shift everything under the cursor. The V-Sync and VRR rows are deliberately NOT here. Their settings exist and are profile-routed, but the swapchain does not honour them until WP3, and a toggle that does nothing is exactly how "Full chroma (4:4:4)" shipped inert on desktop for three releases after being announced. Buffer labels carry no millisecond hints (Apple/Android derive them from the session refresh): under a Native mode the shells do not know the refresh at settings time, so the captions state the cost as one refresh per frame rather than a confident wrong number. Docs: the stats page documents the split and the `present:` line, and stops claiming Linux/Windows measure to the present instant (untrue since present_wait); client-settings documents both new rows and drops the stale claim that the desktop 4:4:4 toggle has no effect (it was wired to VIDEO_CAP_444); configuration documents PUNKTFUNK_PRESENTER and PUNKTFUNK_PRESENT_DEBUG. Gates: punktfunk-rust-ci linux/amd64 — fmt, clippy -D warnings over pf-client-core, pf-presenter, pf-console-ui, the session binary and the GTK client, 158 tests. The WinUI leg cannot be reached by any Linux or macOS check, so it was compiled on the Windows runner .133: clippy -D warnings and tests both exit 0, against a tree proven by content to contain the edit. ⚠ The first run there reported a false pass — the script printed its done-marker while the log carried a test failure (a STATUS_DLL_NOT_FOUND launch failure, ffmpeg's DLLs missing from PATH); the harness now echoes each phase's exit code so the verdict is a fact in the log rather than an inference from a marker. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5f55fa874a |
feat(client/present): the desktop presenter gains the Apple/Android intent model
WP1+WP2 of design/desktop-presentation-rebuild.md. The shared Linux/Windows session client presented arrival-paced with no pacing layer at all: two depth-2 newest-wins hops into a drain-to-newest and an immediate present. That IS the lowest-latency intent, but it was unnamed, unselectable, and had no alternative — and on a surface without MAILBOX (AMD's Windows driver offers none, and any compositor holding images does the same) the swapchain's own FIFO becomes a standing queue worth a measured 11-13 ms at 60 Hz. WP1 — the settings cluster, under the keys the Apple client already writes into the shared profile catalog (present_priority / smooth_buffer / vsync / allow_vrr): mismatched names would ride SettingsOverlay::extra, carried but never applied. PresentPriority::resolve mirrors the Android reference exactly (anything but an explicit "smooth" is latency; a buffer outside 1..=3 becomes 2), so a profile authored on any client means the same thing on all of them. Only the first two are consumed here; vsync/allow_vrr land in WP3. WP2 — the engine (present_pace.rs, pure state + arithmetic, 6 tests): - FrameStore: newest-wins slot, or the smoothing FIFO with preroll-to-capacity, drop-oldest overflow, and an underflow that re-arms the preroll (repeat by omission) — the Apple/Android semantics, with qDrop/qDry counters. - LatchClock: the panel grid learned from VK_KHR_present_wait glass stamps, min positive spacing capped by the mode refresh (measured, never queried — VRR and Android's per-uid refresh lie both punish trusting a reported rate). It now also publishes the host-facing LatchGrid, so the phase-lock report and the local scheduler cannot disagree about the grid. - PresentGate: one undisplayed present in flight on FIFO surfaces, with the 100 ms stale force-open. This is the standing-queue killer, and it is inert on MAILBOX/IMMEDIATE and without present timing — where behaviour stays byte-for-byte the shipped arrival pacing. Wiring: glass samples drain every pass (a 1 Hz batch would starve clock and gate) and the waiter pushes an SDL wake, so a gate reopen never waits out the event timeout; smoothness serves one frame per latch slot and tightens the loop's wait to that deadline; the adaptive slot margin starts at 0 and widens +500 us per missed window toward 2.5 ms (a fixed lead was measured to be pure display tax). PUNKTFUNK_PRESENTER=arrival disables the whole engine for field A/B without a rebuild. PyroWave collapses smoothness to latency for the stream: its plane-ring retirement accounting assumes the depth-2 newest-wins hand-off, and all-intra frames make buffering moot anyway. Gates (punktfunk-rust-ci, linux/amd64, sources touched first so a warm target cannot print a vacuous Finished): clippy -D warnings across pf-client-core, pf-presenter and punktfunk-client-session; 80 + 32 tests pass; rustfmt clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8af6e2dd02 |
feat(decky): the stats overlay gets an off switch in Gaming Mode
Field report: "as of version 0.23 of this plugin, there is no setting to toggle off the stat overlay." Correct, and it never had one — no commit in `clients/decky` has ever touched a stats key. Every other client does: the GTK dialog, the Windows page, the Apple app, and the console's own settings screen all carry the four-tier picker. The tier defaults to on. `Settings::default` is `show_stats: true` and `stats_verbosity: None`, which `Settings::stats_verbosity` resolves to Normal — so a Deck that has only ever been configured through this panel streams with the overlay up and no way here to put it down. What escapes exist are not discoverable: Ctrl+Alt+Shift+S wants a keyboard, and the three-finger touchscreen tap is documented in `docs/stats`, not on the glass. The console's picker is reachable (X on console home), but that is a different shortcut than the one-tap stream this panel launches, and a user editing stream settings here has no reason to look there. So the row lands here, last in the section, matching the console's wording. It writes `stats_verbosity` AND the legacy `show_stats` in the same pairing `Settings::set_stats_verbosity` keeps, so a client too old for the tiers still honours an Off chosen here; it reads them back the way `Settings::stats_verbosity` does, so a pre-tier file — including every file this plugin wrote before today — shows the Normal the stream actually runs at. `set_settings` stops replacing the file and merges onto it instead. This JSON is shared with the desktop client and the console, and holds many more keys than this panel models (decoder, GPU, profiles, touch/mouse model). The panel reads it once when it mounts, so a wholesale write posts a snapshot that predates anything another editor stored while it sat open — silently reverting it. That was invisible until 0.23.0: `9c5af8d7` fixed the GTK shell handing the session a spec built from `Settings::default()`, and only since then does this file reach a stream at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e8499e6131 |
feat(android): wire tier-A pad audio through the capture lifecycle and settings
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m4s
android / android (pull_request) Successful in 5m27s
ci / rust (pull_request) Canceled after 3m50s
ci / rust-arm64 (pull_request) Canceled after 3m50s
ci / docs-site (pull_request) Canceled after 31s
windows / build (aarch64-pc-windows-msvc) (pull_request) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 0s
The Kotlin half. Turns out Android needs to claim nothing extra: `uac-host` claims the pad's audio interface itself through usbfs on the fd, and usbfs claims are per interface, so the HID claim `HidUsbLink` already holds is untouched. The link therefore surrenders only its file descriptor. Two orderings carry the whole design, and both are easy to get wrong: - **Start on the first report, not at claim time.** The wire pad index does not exist until the router opens a slot, and the host addresses the 0xD1 stream by that index — starting earlier would declare capabilities for a pad that has no index yet. - **Stop before the link closes.** `usb.stop()` closes the connection whose descriptor the render thread borrows, so `padAudio.stop()` runs first, at the top of `DsCapture.stop()`. `nativeStopPadAudio` does not return until the thread is joined, which is what makes the borrow sound rather than merely usually-fine. `DsCapture` decides WHEN (it owns the wire index and the link lifetime); `StreamScreen` decides WHETHER (it owns the session handle and the settings). The capture stays ignorant of sessions. Settings: `padHaptics` defaults on — it is the whole point, and this client's rumble already drives the same actuators, so tier A is a strict improvement. `padSpeaker` defaults OFF: it is a small loudspeaker in the user's hands playing audio they can already hear, and surprising someone with that is worse than making them opt in. Verified: APK builds, and both JNI entry points are exported in the shipped arm64 .so — a missing one would be an UnsatisfiedLinkError only at runtime. 12 Rust tests, 0 clippy findings, fmt clean. |
||
|
|
d839f4c2b6 |
fix(client/windows): settings stop going stale behind your back, and the log has a door
ci / web (push) Successful in 1m1s
ci / rust-arm64 (push) Successful in 2m35s
ci / docs-site (push) Successful in 2m35s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 33s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4s
deb / build-publish-client-arm64 (push) Successful in 1m16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
deb / build-publish (push) Successful in 3m52s
apple / swift (push) Successful in 1m18s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
deb / build-publish-host (push) Successful in 4m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m28s
ci / rust (push) Successful in 7m1s
android / android (push) Successful in 7m5s
arch / build-publish (push) Successful in 8m17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m55s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m0s
docker / builders-arm64cross (push) Successful in 8s
apple / screenshots (push) Successful in 5m42s
docker / deploy-docs (push) Successful in 26s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m25s
flatpak / build-publish (push) Canceled after 9m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 9m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9m11s
A field reporter's codec setting "changed by itself" between sessions. Nothing writes the negotiated codec back — what they saw was a stale snapshot. `AppCtx.settings` is loaded ONCE at process start and the page renders from it, but this process is not the file's only writer (the spawned session persists its match-window size, the console UI and Decky save too), so the page showed values another process had already replaced — until a row was touched and `commit`'s rebase pulled the file in, at which point the value visibly jumped. The 2026-07-31 rebase fix covered the whole-file writers and missed two spots: nothing re-based on page ENTRY, and the profile-scope commit arm cloned the snapshot without reloading, so overlay absorption diffed against stale globals. Both now re-base on the file. Two more ways a setting could vanish or cost time: * An older binary's whole-file save DROPPED a newer client's keys — `Settings` had no unknown-key passthrough, unlike `SettingsOverlay`, whose `extra` map already gives profiles exactly that contract. Extended to the globals: additive, empty on every existing store, and an empty map serializes to nothing so no file churns. (`save()` was already temp+rename, so the torn-file → silent-Default reset was closed.) * "Check the client log" never said WHERE. Settings ▸ About grows an Open log folder row (%LOCALAPPDATA%\punktfunk\logs, folder not file so the rotated .old generation is in reach), and the failed-spawn banner now names the path. The 4:4:4 caption said "HEVC only, and only where the host can encode it", which sends people hunting: the host gate is PyroWave or an NVENC backend. It says so now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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. |
||
|
|
0de161e29b |
Merge pull request 'feat(clients/input): controllers can stop being forwarded, for couches that hand the pad over another way' (#22) from worktree-gamepad-passthrough-toggle into main
ci / web (push) Successful in 1m10s
apple / swift (push) Successful in 1m22s
ci / docs-site (push) Successful in 2m0s
ci / rust-arm64 (push) Successful in 2m37s
decky / build-publish (push) Successful in 48s
deb / build-publish-client-arm64 (push) Successful in 1m27s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
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
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m33s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
deb / build-publish (push) Successful in 4m0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m3s
deb / build-publish-host (push) Successful in 3m51s
docker / builders-arm64cross (push) Successful in 14s
arch / build-publish (push) Failing after 5m22s
docker / deploy-docs (push) Successful in 33s
android / android (push) Successful in 7m22s
ci / rust (push) Failing after 9m12s
flatpak / build-publish (push) Failing after 5m29s
release / apple (push) Successful in 9m14s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m12s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m34s
apple / screenshots (push) Canceled after 4m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 10m21s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 11m8s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 1m31s
Reviewed-on: #22 |
||
|
|
b297542c4d |
feat(clients/input): controllers can stop being forwarded, for couches that hand the pad over another way
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m39s
ci / docs-site (pull_request) Successful in 2m6s
ci / rust-arm64 (pull_request) Successful in 2m50s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
android / android (pull_request) Successful in 3m27s
ci / rust (pull_request) Successful in 7m59s
A controller that reaches the host by USB passthrough — VirtualHere and friends, or simply a pad plugged into the host — arrived there twice: once as the real device, once as the virtual pad this client built from the same hands. Games read both, so a stick drifts against the centred second pad and menus take every input twice. New per-client setting, "Forward controllers", default on (today's behaviour). It is tier-P, so a profile can decline what another profile forwards. On Linux and Windows it is deliberately stronger than "send nothing". Opening a controller is what CLAIMS it — SDL's HIDAPI drivers take the device node — and a claimed device is one a passthrough tool cannot bind, so with this off the session opens no slot at all and never enables the Valve HIDAPI drivers. Menu navigation is untouched: the launcher still opens the active pad, and a session supersedes menu mode whether it forwards or not, so the pad is free for the whole time a stream is up. The consequence, documented at both the setting and the chord: the controller escape chord is read off forwarded pads, so it is unavailable there. The Apple and Android input stacks claim nothing, so those clients keep their slots and their chords and only gate the wire sends — losing tvOS's only controller way out of a stream would have been the worse bug. Android does stop its DualSense and Steam Controller 2 USB captures, which do claim the device. Surfaces: GTK, WinUI, the console settings screen, Apple's touch and gamepad settings, the Android touch and gamepad settings, and Decky (which also hides the rows that now have nothing to act on). Everywhere the "which pad" and "pad type" rows grey out while it is off. Verified: cargo clippy --all-targets -D warnings + 79 tests on pf-client-core, pf-console-ui, punktfunk-client-session and punktfunk-client-linux (linux/amd64 container, gate proven non-vacuous with a planted error); swift build for the Apple clients; gradle compile + 49 unit tests for Android (likewise proven); tsc for Decky. clients/windows is UNCOMPILED — both Windows boxes were offline; its edits were reviewed against the helper signatures by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
98e040fd01 |
fix(host/stream): the wire holds the session rate when the display outruns it
apple / swift (push) Successful in 1m20s
ci / rust (push) Successful in 5m0s
android / android (push) Successful in 6m30s
ci / rust-arm64 (push) Successful in 1m50s
apple / screenshots (push) Successful in 5m37s
arch / build-publish (push) Successful in 8m0s
ci / web (push) Successful in 1m28s
ci / docs-site (push) Successful in 1m36s
deb / build-publish-client-arm64 (push) Successful in 2m19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 18s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
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 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
windows-host / package (push) Successful in 13m17s
windows-host / winget-source (push) Skipped
deb / build-publish-host (push) Successful in 5m33s
deb / build-publish (push) Successful in 5m39s
docker / builders-arm64cross (push) Successful in 7s
docker / deploy-docs (push) Successful in 24s
windows-host / canary-manifest (push) Successful in 15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m56s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m18s
PUNKTFUNK_VDISPLAY_HZ_MULT promises extra display refreshes without one extra frame on the wire, but the frame-driven trigger enforced its pace only as a per-gap floor: sleep to 0.9×interval, then wake on arrival. A source that always has a frame pending — the overdriven display under uncapped content — settled at 0.9-interval spacing, 1.11× the negotiated rate. That is the field report's 132 fps on a 120 fps session: ten percent more bitrate, encode and decode for frames a 120 Hz panel can only drop. A credit bucket (PaceBudget) now pins the long-run average at the pacing rate: credit accrues at one frame per interval of real elapsed time, capped at 1.25 frames of post-stall burst, and every submitted frame spends one. A grab may run early only against banked credit, so the 0.9 floor keeps its per-gap jitter headroom while the average cannot exceed the rate — and a source at or below it banks faster than it spends and is never delayed. Anchoring to real elapsed time also keeps the synchronous-encode overlap the arrival-anchored floor bought (the owed fraction absorbs a constant encode tail instead of stacking on top of it), and it cannot fight the phase lock's submit grid: both agree the period is the interval. The charge lives under the same guard as the gate — the legacy fixed tick paces by its own grid, and charging it without ever accruing would bank unbounded debt that stalls the loop if a rebuild later flips the capturer to arrival-wait. Verified on .25: native::stream tests 15/15 (three new PaceBudget tests), punktfunk-host 369/369, clippy -D warnings clean, fmt clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5174a59832 |
fix(capture/kwin): a hidden cursor leaves the stream — KWin's id-0 meta is the hide
Since the 0.22.0 cursor work (the seat-pointer park + the metadata composite), a KWin capture-model stream always has a cursor — and it never went away again: not in game, not in Big Picture, not with a controller in hand (field report, 2026-08-01). The host blended the arrow forever because pf-capture deliberately ignores SPA_META_Cursor id 0, and once `visible` latched true nothing on Linux ever cleared it. Two producer contracts meet on id 0, and one flag now carries which one a stream follows. KWin rewrites the cursor meta on EVERY enqueued buffer and writes id 0 whenever Cursor::isOnOutput says the pointer is not in this stream — which covers both a globally hidden cursor and a client null-cursor surface (empty geometry intersects nothing). There id 0 IS the hide, and honoring it is what lets a game hide the pointer mid-stream. Mutter only rewrites a buffer's meta when the cursor changed, so recycled buffers carry stale id-0 regions between damage frames — honoring those flickered the cursor off between hovers (on-glass round 5), and that path keeps its last-known-state behavior. The flag rides from the backend that created the output (correct for registry-pooled reuse too — a kept display only ever matches its own backend) through capture_virtual_output into the parser's CursorState. The portal-monitor path stays on the stale-meta contract: the only thing routed through it today is Mutter's HDR mirror. Verified on .25: pf-capture 45/45, punktfunk-host 369/369, clippy -D warnings clean (pf-capture, punktfunk-host, cursor-probe), fmt clean. On-glass KDE validation still owed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c2a6d30d7b |
fix(android/decode): a codec input slot the feeder can't fill goes back, and so does the AU
deb / build-publish (push) Failing after 2s
deb / build-publish-host (push) Failing after 2s
deb / build-publish-client-arm64 (push) Failing after 3s
apple / swift (push) Successful in 1m19s
ci / rust (push) Successful in 5m52s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
arch / build-publish (push) Successful in 9m12s
release / apple (push) Successful in 9m14s
ci / rust-arm64 (push) Successful in 1m57s
android / android (push) Successful in 11m23s
windows-host / package (push) Successful in 11m26s
windows-host / winget-source (push) Skipped
ci / docs-site (push) Failing after 11m59s
ci / web (push) Failing after 12m2s
flatpak / build-publish (push) Successful in 6m25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m33s
docker / builders-arm64cross (push) Successful in 5s
docker / deploy-docs (push) Successful in 28s
apple / screenshots (push) Successful in 5m54s
windows-host / canary-manifest (push) Successful in 18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m53s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m1s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m26s
`AMediaCodec_getInputBuffer` returning null for an index the input-available callback had just handed us dropped both the slot and the access unit on the floor. Every sibling path in this loop recycles the slot — the orphan-part discard and the oversize drop both say so in as many words — because nothing was written and nothing was queued, so it is still ours. Forgetting it leaks one of the codec's input buffers per occurrence: we never use it again and the codec never frees what it never received, so the pipeline runs out of input slots, `pending_aus` overflows into its drop-oldest arm, and the resulting keyframe storm reads as a decode fault rather than a bookkeeping one. The AU went with it, silently — no keyframe request, no freeze gate, unlike every other loss path here — leaving a hole in the reference chain whose concealment was free to reach the screen. Both go back now. `break` rather than `continue`, because a codec that cannot hand out an input buffer it has just advertised is in no state to be fed the rest of the parked queue on this pass, and retrying the same index against every parked AU would burn the whole backlog for nothing; the loop comes round again on the housekeeping wake within 5 ms if it was transient. Gates: cargo ndk check green on arm64 and armv7, fmt clean, Android clippy at the same 4 pre-existing warnings as the base commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
20de58a78a |
fix(android/present): the panel grid can be wrong in both directions, and the margin listens to the latch
Three defects in the 0.23.0 timeline presenter, all found while root-causing the field report that turned out to be the slice wire. None of them is that bug; all three are real, and the first is the one that would still bite once it is fixed. The panel-period learner could only ever narrow. It is seeded from the display mode Kotlin asked for — and `preferredDisplayModeId` is a REQUEST the system may refuse (Smooth Display off, battery saver, thermal, an OEM governor). Ask for 120 Hz on a panel that stays at 60 and the presenter pins an 8.33 ms grid on a 16.67 ms display with no way back, for the rest of the session: it then aims at instants that never arrive and releases faster than the panel scans. The learner moves both ways now, and lives in `punktfunk_core::phase::PanelGrid` where it is host-testable and where the iOS and desktop presenters can share it. The asymmetry is kept and made explicit — narrowing is immediate (a finer real grid is always safe to subdivide onto, and it is the per-uid down-rate case the seed most often gets wrong), widening needs eight consecutive agreeing observations and then takes the narrowest of them, because one wide sample is a missed callback and eight in a row is a display that really did slow down. The glass budget was a prediction with nothing underneath it. `OnFrameRendered` already reports what actually reached glass, but the budget never consulted it, so a wrong grid could hand SurfaceFlinger frames indefinitely: BufferQueue fills, MediaCodec runs out of output buffers, the decoder stalls, and the no-output backstop starts begging for keyframes. Releases are now counted against their confirms and the presenter holds back past six outstanding — loose on purpose, since the callbacks are allowed to arrive batched and a held frame in the newest-wins slot is a dropped one. It self-clears when the confirms catch up, and writes the ledger off after the same 100 ms the stale reopen uses, so a platform that stops confirming can never wedge the stream. `qWait` and `unconfirmed` join the 1 Hz pf.present line, which is what would have made this visible from a log. The adaptive latch margin widened on `paced_drops` — the newest-wins store's own policy evictions, which happen whenever the stream out-runs the panel and say nothing about SurfaceFlinger's latch lead. On a healthy device that walked the margin to its 2.5 ms ceiling and re-imposed the display latency the P2e sweep had just measured away. It now widens on the measured latch exceeding one panel period plus the live margin, which is what a missed vsync actually looks like. Also corrects two doc comments that named `display.refreshRate` as the panel_hz source; it has been the mode table since the A024 down-rate fix. Gates: 278 punktfunk-core lib tests (7 new PanelGrid cases incl. the refused-mode regression), clippy -D warnings and fmt clean, cargo ndk check green on arm64 and armv7. Android clippy reports the same 4 warnings as the base commit and no new ones. NOT yet confirmed on glass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
97b2c01ac1 |
fix(core/packet): a slice-streamed frame costs its own size, not the whole frame ceiling
The 0.23.0 slice wire flushes a block every MIN_STREAM_BLOCK_SHARDS, so every ordinary access unit is now opened by a SENTINEL — a header with no totals. The reassembler sized those frames at `max_frame_bytes`, which the QUIC handshake clamps to 8-64 MiB. That was survivable while sentinels were rare (the streamed path emitted one only for an AU exceeding a whole FEC block, ~281 KB); it is not survivable now that every frame is one. Two consequences, both measured: each access unit allocated and ZEROED a multi-megabyte buffer, and the in-flight budget (IN_FLIGHT_BUF_FACTOR x max_frame_bytes) was spent after ~3 concurrent frames — with production geometry, 12 ordinary AUs in flight lost 9 of them outright, every packet dropped before it could be placed. On a link with normal reorder that is a permanent loss storm: frames never complete, the re-anchor gate freezes the picture, and the client begs for keyframes. Only clients advertising VIDEO_CAP_MULTI_SLICE reach this path — Android and the Linux/Windows session client; Apple and the Windows in-process client never did, which is why it read as a platform-specific "video pipeline" fault in the field. A sentinel carries no total but does pin its own block's extent: a slice sentinel by its wire base, a legacy one by its full-K position. Size the buffer to that and grow as later blocks (or the final block's totals) reveal more. The budget is re-checked on growth for the same reason it is checked at open. The same flush also drained `pending` to empty whenever the AU's length was an exact multiple of the shard payload, leaving `finish_streamed` to seal a final block of one zero-padded FILLER shard. Its derived base overlapped the block flushed a moment earlier, retro-validation correctly read that as a lying header, and the whole AU died — one frame in every 1408 on a 1500-MTU link, ~12 s apart at 120 fps, each costing a freeze and a recovery keyframe. A flush now keeps one whole shard back, restoring the invariant `StreamedAu::pending` already documented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
29473d6280 |
Merge pull request 'fix(client/ios): Escape keeps the pointer captured instead of handing it back to iPadOS' (#19) from worktree-ipad-esc-pointer-relock into main
ci / rust-arm64 (push) Failing after 2s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 24s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 27s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 34s
ci / docs-site (push) Successful in 1m9s
apple / swift (push) Successful in 1m21s
docker / builders-arm64cross (push) Successful in 45s
ci / rust (push) Successful in 4m0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 5m6s
docker / deploy-docs (push) Failing after 9s
ci / web (push) Successful in 6m50s
release / apple (push) Successful in 9m10s
apple / screenshots (push) Successful in 5m53s
Reviewed-on: #19 |
||
|
|
b6acbd096e |
fix(host/vdisplay): waking the PC stops failing the first session
android / android (push) Failing after 2s
apple / swift (push) Successful in 1m21s
ci / rust-arm64 (push) Successful in 1m49s
ci / web (push) Failing after 1s
ci / docs-site (push) Successful in 1m32s
ci / rust (push) Successful in 6m38s
apple / screenshots (push) Successful in 5m55s
arch / build-publish (push) Successful in 8m58s
deb / build-publish (push) Successful in 6m38s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3m19s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m9s
deb / build-publish-host (push) Successful in 4m57s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 3m43s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3m59s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
windows-host / package (push) Successful in 12m4s
windows-host / winget-source (push) Skipped
docker / deploy-docs (push) Successful in 55s
windows-host / canary-manifest (push) Successful in 1m30s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 10m36s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 10m55s
deb / build-publish-client-arm64 (push) Failing after 11m12s
docker / builders-arm64cross (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 11m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m27s
A woken Windows host refused every connection with "pf-vdisplay driver interface not found", on a box where the driver was installed and running. Resuming re-enters D0 and re-registers the IddCx control interface while the rest of the resume storm is still going. A client reconnecting a second after wake lands inside that gap. `ensure_available` probed exactly ONCE, so it read the gap as a dead driver and answered a device that was seconds from ready by disabling and re-enabling it — then gave the interface 4 s to come back, which a contended post-resume PnP does not meet. The session failed, and the log blamed a missing install. The recovery also could not tell whether it had recovered anything. It ran the whole cycle under `SilentlyContinue` and reported `(Get-PnpDevice).Status` — the DEVICE's status, not the cycle's outcome — so a disable that was REFUSED left the adapter untouched, started, and reading `OK`. That is the reporter's `cycled the adapter device … status=OK` line: a recovery that never happened, announcing success. And a refusal is the expected case here, not the exotic one: reset-pf-vdisplay.ps1 stops the host service first precisely because the host holds the driver's control device open, a step an in-process cycle structurally cannot take. - Distinguish a devnode MID-TRANSITION (interface registered, not started yet, or the open refused) from one genuinely ABSENT. Wait the first out; only the second earns a reload. `Probe` carries the counts. - Report what the reload DID, not what the device looks like afterwards: every failable step is `-ErrorAction Stop` in a `try`, and `pnputil /restart-device` is the fallback for the in-use device that `Disable-PnpDevice` refuses. Failure paths re-enable, so a half-cycle can never strand the adapter DISABLED. - Give the interface 15 s to arrive after a reload, not 4 — under a 30 s hard ceiling so a permanently wedged devnode still fails predictably. - Serialize recovery: N sessions racing in after a wake perform ONE reload, not N interleaved ones. The lock is taken only where no manager lock is held, so the order stays one-way. - Retire the manager's cached control handle when a reload runs, instead of letting the next session discover it via a failed IOCTL. - Surface the real reason. `ensure_available` returns `Result`, so the log names how long it waited, whether a reload ran, and how many interface instances were seen in what state — the detail that would have identified this from the field report's log alone. `VdisplayDriver::open` now shares the wait (brief, no reload) instead of carrying a second, drifted copy of it — that path is also reached by `hw_cursor_capable` mid-handshake, where a reload would be the wrong trade for one capability bool. Windows-gated, so verified with scripts/xcheck.sh (check + clippy -D warnings, --all-targets) and cargo fmt; on-glass wake test still owed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d63e913f52 |
fix(client/ios): Escape keeps the pointer captured instead of handing it back to iPadOS
ci / web (pull_request) Successful in 1m2s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m26s
ci / docs-site (pull_request) Successful in 3m46s
ci / rust (pull_request) Successful in 4m8s
iPadOS releases the scene's pointer lock by itself when Escape is pressed — the platform's built-in "let me out", mirroring the web Pointer Lock API's default unlock gesture. Nothing in our code does it: a bare Esc never touches `captured`, and it keeps forwarding to the host as the game key it is. But the lock going away flips the mouse onto the absolute UIKit path and un-hides the iPadOS cursor, so pressing Esc for an in-game menu silently cost the capture until the user clicked into the video to win it back. Esc is a GAME key in a stream, not a request to hand the pointer back to iPadOS, so an unwanted drop is now re-requested. `syncPointerLock` arms a short, bounded burst (3 attempts over ~0.6 s, no restart inside 2 s) whenever the lock is wanted, was previously HELD, and is now gone; the first attempt re-asserts `prefersPointerLocked`, later ones present a real false→true transition and re-anchor the PointerLockChain. Every deliberate release (⌘⎋, ⌃⌥⇧Q, the Stream menu, resigning active) clears `captured` first, so `wantsPointerLock` is already false when their drop is observed and none of them are fought. The "previously held" half of the condition keeps a scene that never qualifies (Stage Manager, Split View) from paying for a lock that isn't coming — there, a first grant is still driven by the chain engage in setCaptured/viewDidAppear exactly as before. While a re-lock is in flight the local cursor stays hidden and absolute pointer MOTION stays muted, so the couple of frames it takes read as "Esc did nothing to my mouse" rather than a cursor that blinks in and out and a host cursor that teleports to the pointer's absolute position. Buttons still forward (they carry no position), so a click mid-relock isn't swallowed. The burst clears itself on give-up, so the cursor can never stay hidden on a lock the system won't grant. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
362595b20f |
fix(vdisplay/kwin): the streamed output declares it mirrors nothing, so a stored replicationSource can't clone a panel into the stream
apple / swift (pull_request) Successful in 1m25s
ci / rust-arm64 (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m13s
ci / rust (pull_request) Successful in 4m22s
ci / docs-site (pull_request) Failing after 18m2s
ci / web (pull_request) Failing after 18m8s
ci / rust (push) Failing after 3s
ci / rust-arm64 (push) Failing after 5s
deb / build-publish-host (push) Failing after 3s
deb / build-publish-client-arm64 (push) Failing after 3s
apple / swift (push) Successful in 1m22s
ci / docs-site (push) Successful in 1m24s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 31s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 23s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 4m14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 4m13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 4m15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 2s
docker / builders-arm64cross (push) Successful in 21s
android / android (push) Successful in 6m2s
docker / deploy-docs (push) Successful in 1m14s
apple / screenshots (push) Successful in 6m3s
arch / build-publish (push) Successful in 8m11s
deb / build-publish (push) Successful in 5m4s
ci / web (push) Failing after 10m16s
windows-host / package (push) Successful in 11m15s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 17s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 10m25s
KWin stores output configuration per *setup* — the exact set of connected outputs, matched by EDID/connector — in `kwinoutputconfig.json`, and `replicationSource` is one of the fields it saves and restores (`OutputConfigurationStore::storeConfig` / `setupToConfig`). Our virtual output carries a STABLE name on purpose, so once any setup has an entry making `Virtual-punktfunk` a mirror of a physical head, KWin re-applies it to OUR output on every session that reproduces that same monitor set — and only that set, which is why the failure looks environment-dependent: a field report has the stream cloning the panel whenever exactly one monitor is live, and behaving normally the moment the others come back (a different setup key, a different stored entry). A mirroring output is not a desktop. KWin's `applyMirroring` overrides its scale and render offset to the source's, so the stream carries the physical screen's viewport at the physical screen's size instead of the mode the client negotiated. The protocol says the rest out loud on `priority`: "an output may not be in the output order if it's disabled or mirroring another screen" — so the primary assertion this module works so hard to verify silently stops meaning anything too. Nothing we sent ever contradicted the stored value. The topology config enabled our output, took priority 1 and disabled the others, but never stated the one property that decides whether the thing is its own screen. Now it does: `set_replication_source(ours, "")` rides along in the config we already build (free, idempotent — an empty source is exactly what KWin resolves to "mirrors nothing"), gated on management v13 where the request appeared, since wayland-rs does not range-check requests and an out-of-range opcode would kill the connection. `extend`/`auto` issue no topology calls by design — the streamed output is meant to join the desk without rearranging it — but a mirror is not an arrangement, it is a broken source under every topology. So they get `clear_replication_source`, which enumerates and applies ONLY when our output really is mirroring. The device's `replication_source` event is now read, so the state is visible: a mirrored streamed output names its source in a warn instead of leaving "the stream just shows my monitor" as something only the reporter can see. Verified on 192.168.1.25 (Ubuntu, cargo 1.96): `cargo test -p pf-vdisplay` 128 pass (7 in `kwin_output_mgmt`), `cargo clippy -p pf-vdisplay --all-targets --locked -D warnings` clean, `scripts/xcheck.sh linux` clean, fmt clean. NOT yet on-glass — no KDE box here reproduces a stored mirror; the reporter's setup is the real test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
caa47e28e6 |
fix(client/decode): AV1 hardware decode stops silently opening libdav1d
apple / swift (push) Successful in 1m27s
ci / rust (push) Successful in 5m28s
android / android (push) Successful in 5m59s
ci / rust-arm64 (push) Successful in 2m24s
ci / web (push) Successful in 1m55s
arch / build-publish (push) Successful in 9m23s
ci / docs-site (push) Successful in 1m45s
deb / build-publish-client-arm64 (push) Successful in 2m23s
windows-host / package (push) Successful in 12m12s
windows-host / winget-source (push) Skipped
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 37s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
release / apple (push) Successful in 12m6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
deb / build-publish (push) Successful in 5m54s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
deb / build-publish-host (push) Successful in 5m19s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m43s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m44s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
windows-host / canary-manifest (push) Successful in 24s
docker / deploy-docs (push) Successful in 45s
docker / builders-arm64cross (push) Successful in 8s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m7s
apple / screenshots (push) Successful in 5m51s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m17s
flatpak / build-publish (push) Successful in 5m38s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m7s
avcodec_find_decoder(id) returns the registry's FIRST decoder for the id, and
upstream orders the native av1 decoder LAST on purpose ("hwaccel hooks only,
so prefer external decoders" — allcodecs.c). All three hardware backends
selected by id, so every AV1 session opened libdav1d: a software decoder that
silently ignores hw_device_ctx and never calls get_format. Each frame then
failed the backend's hw-format guard and the session burned the demotion
ladder MID-STREAM — field-logged as 68 Vulkan fails → D3D11VA → 102 fails →
software, ~3 s of black — with "hardware decode active" already printed and
the D3D11 profile/pool probes all green. H.264/HEVC never hit this only
because their native decoders happen to be registered first.
Selection is now by capability: find_hw_decoder walks av_codec_iterate and
takes the first decoder whose avcodec_get_hw_config advertises the backend's
surface via HW_DEVICE_CTX, so a build without a usable hw decoder fails at
OPEN in milliseconds and the ladder runs there — the idiom the D3D11 probes
already follow. Registry order still wins among capable decoders, so
H.264/HEVC select exactly what they always did. The software path keeps the
id lookup on purpose: libdav1d is the fastest CPU AV1, and the native av1
decoder has no software path at all.
Every decode log now carries the selected decoder's name — decoder="av1" vs
decoder="libdav1d" is the whole diagnosis, and no log line said it. The
session log names the WIRE codec and drops the FFmpeg id for PyroWave
(ffmpeg_codec_id's fallthrough claimed codec_id=HEVC for wavelet sessions
that never touch FFmpeg).
The CPU lane also stops passing raw PQ off as a tone-map: software-decoded
frames deliberately never take the HDR10 swapchain, but a PQ stream there was
then shown UNtonemapped (washed out) with no warning — the pq-downgrade warn
keys off the swapchain answer — while the Detailed OSD badge claimed the
"HDR→SDR" tone-map that only the hardware lane's CSC runs. The presenter now
warns once when a PQ CpuFrame arrives, and the badge distinguishes
"HDR→SDR (raw)" (no tone-map pass) from the hardware lane's real "HDR→SDR".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
652abeb397 |
fix(host/audio): an unsatisfiable wiring plan waits for an endpoint change instead of hammering
Field case 2026-08: the display isolate invalidated the only real render endpoint; the mic held the Steam Streaming Microphone, the Speakers were blacklisted, and the capture loop re-ran the full wiring pass — three IPolicyConfig SetDefaultEndpoint writes included — every 2 s for 8+ minutes, retrying a verdict that could never change. - wiring_plan: a plan with no loopback is a typed structural verdict (Wiring::loopback_unsatisfiable + an endpoint-set fingerprint); the dead leftover() tier (byte-identical to real_hw()) becomes a real last resort that accepts ONLY the Steam Streaming Speakers, flagged loopback_last_resort — a known-silent-loopback QUALITY risk, never the cable/VoiceMeeter echo CORRECTNESS risks. excluded_from_loopback stays untouched (judge_default's mid-stream snap-back semantics). - wasapi_cap: an unsatisfiable plan errors ONCE per topology with the render inventory, per-endpoint rejection reasons and only the remedies not already taken, then parks on a cheap enumerate-and-hash poll and re-plans the instant the set changes; transient failures get a real capped exponential backoff (2 s → 60 s, reset on success or set change); a last-resort capture re-plans on any set change and promotes the 30 s zero-packet breadcrumb to warn. - audio_control: the recording default is asserted only when the plan changed or the default drifted — set_default_endpoint fires all three SetDefaultEndpoint roles unconditionally, so the 2 s loop silently stomped any operator recording-device change; the "attach one, or let the host install the Steam Streaming pair" warn (already satisfied in the field case) is replaced by the same diagnosis. Verified: 19/19 wiring_plan tests (native rustc --test and the Linux CI image via docker); both Windows files type-check and clippy clean against wasapi 0.23.0 / windows 0.62.2 for x86_64-pc-windows-msvc via an xcheck-style stub harness (the in-tree target check dies in openh264-sys2's build script on macOS, as scripts/xcheck.sh documents). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
48511d1267 |
fix(abr): probe throughput is measured over the client's receive interval
The capacity probe divided client-side bytes by the HOST's burst duration — a window wrong on both edges (base snapshotted before the burst reached the host, frozen only when the ProbeResult landed, while the host's clock stops the moment ITS send window closes, before the switch/kernel queue finishes draining toward the client). On a 1 GbE link a 2 Gbps burst target "measured" 1266 Mbps and set an 886 Mbps climb ceiling the link could never carry — permanent for the session, because set_ceiling never lowers. The reassembler now stamps probe-scoped counters (bytes, packets, first/last arrival, monotonic ns) at its FLAG_PROBE routing, so video around the burst contaminates neither numerator nor denominator; the throughput divisor is the client's first→last arrival interval, with the host duration kept as the fallback when fewer than two probe packets arrived. The user-facing speed test shares the corrected computation (ProbeOutcome/PunktfunkProbeResult layouts unchanged; elapsed_ms docs updated to the new semantics). Two guards ride along: - PUNKTFUNK_ABR_MAX_MBPS clamps inside set_ceiling — the one funnel every learned ceiling passes through — so a user cap binds no matter what any probe concludes. - The controller latches decode_cap_kbps when two CONSECUTIVE backoffs carry decode-severe evidence (deep decode excursion or jump-to-live flush) at a similar pre-backoff rate, mirroring host_cap_kbps for the client decoder: a knee below the link ceiling was a permanent 30-60 s sawtooth costing a flush + dropped-frame burst per cycle (1440p120 HEVC field case, knee ~490 Mbps). One spurious flush never latches; the cap re-probes on the CAP_REPROBE_WINDOWS clock, so it lifts when the decoder recovers. Also rights the three stale "3 Gbps" probe-clamp comments (the host constant has been 10 Gbps since MAX_PROBE_KBPS moved). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3e649d372e |
fix(capture/stall): the ETW witness testifies in QPC, so compose-silence stops convicting content
The stall classifier's present witness never worked: the consumer was opened
without PROCESS_TRACE_MODE_RAW_TIMESTAMP, so ProcessTrace converted every
event's TimeStamp to FILETIME regardless of the session's ClientContext=1 —
FILETIME ticks (100 ns since 1601) are ~4 orders of magnitude above QPC, so
every ts <= to_q comparison was false. summary() always printed etw=none,
window_counts() always returned presents=0/queue_adds=0 while present_history
was still true (satisfied by the unfiltered ring), and classify() therefore
convicted EVERY compose-silence hole as CONTENT-SILENCE; FRAME-GENERATION —
the class the program exists to catch — was unreachable. Two comments
asserted the wrong contract ("TimeStamp IS a QPC value"); both now state the
real one: ClientContext selects the session clock, RAW_TIMESTAMP is what
stops the FILETIME conversion on delivery.
Three adjacent defects fixed with it:
- summary() and window_counts() each took their own ring lock and their own
(Instant::now(), qpc_now()) anchor, with OpenProcess syscalls between the
two calls — the prose and the verdict could disagree about the same hole.
Merged into window_report(): one snapshot, one anchor, both halves; the
summary keeps its 300 ms lead-in, the counts keep the gap-only window, and
the etw=/etw_presents=/etw_queue_adds= log fields are unchanged.
- present_history/queue_history meant "an event EVER sat in the ring" —
satisfied by events arriving after the hole, or by a previous session's
leftovers in the never-cleared static RING. Both flags now mean witness
LIVENESS: at least one event inside a 5 s LOOKBACK ending at the hole's
start, i.e. the witness demonstrably worked before the hole opened. The
ring is cleared when a new session starts, so a dead session's events can
never pose as the next one's history.
- window_counts() accepted only BltQueueAddEntry (1071) as queue history
while summary() also took BltQueueCompleteIndirectPresent (1068); either
proves the queue witness works, so the merged reader takes both.
The windowing math is factored into a pure count_window() (plain i64 tick
arithmetic) with unit tests, and the classify() matrix gains the live-witness
zero-presents case. Conviction thresholds are untouched.
Verified: scripts/xcheck.sh windows clippy (-D warnings, --all-targets) green
for pf-frame/pf-win-display/pf-capture/pf-vdisplay; native cargo check green.
The new Windows-gated tests type-check but need a Windows box to run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|