Compare commits

...
Author SHA1 Message Date
enricobuehler 6ff62b087c feat(client): say which GPU can do Vulkan Video, and why not when it can't
Field report from an Intel Arc + NVIDIA laptop: pinning the Vulkan rung on the Arc
iGPU silently produced D3D11VA, and there was no way to tell whether the build had
tried at all. That ambiguity was ours, in three places.

The "unavailable" log printed three of the FIVE conjuncts that gate Vulkan Video.
A device with 1.3, the features and a decode queue family — but no codec extension
— logged dev_is_13=true features_ok=true decode_family=true next to the word
"unavailable" and named nothing actionable. It now prints all five, plus which
base extensions are missing, which codec extensions are present, the decode
family's own advertised codec operations, and the device name and vendor. It also
no longer says "VAAPI/software" on Windows, where the rung below is D3D11VA.

The native-vulkan PIN refusal logged `video_decode` alone. On a device that
decodes something but not THIS codec, that reads as a contradiction: refused, yet
video_decode=true. It now carries the caps mask and the codec bit that was wanted,
so "your GPU can't" is distinguishable from "we asked for the wrong thing" — only
the second is our bug.

And `--probe-decode` is new: per-adapter Vulkan Video capability with no session,
no surface and no logical device. For each GPU it answers usable yes/no, the
driver's own decode ops, the extensions, and — when the answer is no — which
conjunct failed, in words. Separate from --list-adapters, which the desktop shells
parse line-by-line for their GPU picker and which therefore keeps printing bare
names.

The listing is ordered like pick_device (discrete first) and marks entry 0 as the
default presenter, because that ordering is very likely the reporter's actual
answer: pick_device ranks DISCRETE_GPU above INTEGRATED_GPU, Vulkan Video decodes
on the PRESENTER's device by design (that is what makes it zero-copy), and
PUNKTFUNK_DECODER does not move the presenter. So on a hybrid laptop, pinning the
decoder while the dGPU presents probes the wrong GPU entirely —
PUNKTFUNK_VK_DEVICE=<index> is the knob that moves it, and the index printed is
that value.

To keep the probe honest, VIDEO_BASE and VIDEO_CODECS moved to module scope and
the five-way AND became video_decode_gate(), called by both the probe and device
creation. A probe holding its own copy of the rule is one that eventually reports
a capability the session then refuses — which reads to everyone as a decoder bug
rather than a probe bug.

Gates: fmt clean; clippy -D warnings over punktfunk-client-session and
pf-presenter. The Linux container was unavailable (the host's disk filled and took
the docker daemon with it), so this ran on the macOS host target only — the
container leg is owed, and CI covers it on the PR.
2026-08-07 12:35:55 +02:00
enricobuehler bbbcf321e5 Merge origin/main into worktree-native-decode-m0
ci / web (pull_request) Successful in 1m19s
apple / swift (pull_request) Successful in 1m32s
ci / docs-site (pull_request) Successful in 1m23s
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 25s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m8s
android / android (pull_request) Successful in 3m31s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m23s
ci / rust-arm64 (pull_request) Successful in 5m36s
nix / flake (pull_request) Failing after 11m59s
ci / rust (pull_request) Successful in 14m27s
main moved 93 commits while this branch ran. Two conflicts, both where main's new
work sat next to M10's excision:

packaging/flatpak/io.unom.Punktfunk.yml — main added the vendored gamescope WSI
layer (the only route to HDR on a Deck) and, before it, a vulkan-headers module.
Took both: this branch predates them and deletes neither. But the headers module's
stated consumer was pf-ffvk's bindgen over FFmpeg's hwcontext_vulkan.h, and M10
deleted pf-ffvk — so it now reads as dead weight to the next person. It is not:
the WSI layer IS a Vulkan layer, compiles against those headers, and builds after
it, so module order is the dependency. Rewrote the rationale to say so, including
why dropping it would be expensive to discover — flatpak.yml has no pull_request:
trigger, so a manifest break reaches main invisibly and a tag then ships no Linux
flatpak. Also recorded that the native decoder needs nothing from there: pf-vkdecode
reaches Vulkan through ash, which is pure Rust bindings, no bindgen, no C headers.

crates/pf-console-ui/src/screens/settings.rs — main restructured the gamepad
settings into TABS, which removed the per-row section headers; this branch had left
Some("Video") untouched from the merge base and added the pre-M10 decoder migration
next to it. Git could not tell those apart. Took main's structure (no header, its
deliberate change) with this branch's migration layered on: a stored `vulkan`,
`vaapi` or `d3d11va` names no preset in the tabbed list and would render as "—",
then silently rewrite the user's preference on the next save.

Gates on the merged tree, Linux container: fmt clean; cargo check --workspace
--all-targets clean; clippy --workspace --all-targets -D warnings clean; tests
green across pf-vkdecode (187), pf-client-core (163), pf-console-ui (58) and
punktfunk-host (447 of 448 — the one failure is the pre-existing
gamestream::stream::tests::sender_delivers_batches, a UDP-loopback EINTR under
qemu that fails identically on a pristine HEAD).
2026-08-07 10:50:32 +02:00
enricobuehler 3608de25ed Merge pull request 'fix(ci): runner hygiene stops eating its own jobs' (#84) from worktree-ci-runner-hygiene into main
ci / bun-nix (push) Successful in 27s
ci / docs-site (push) Successful in 1m20s
apple / swift (push) Successful in 1m25s
ci / web (push) Successful in 1m28s
deb / build-publish-client-arm64 (push) Successful in 1m36s
ci / rust-arm64 (push) Successful in 3m31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
decky / build-publish (push) Successful in 43s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
deb / build-publish (push) Successful in 3m53s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
deb / build-publish-host (push) Successful in 4m3s
docker / builders-arm64cross (push) Successful in 10s
android / android (push) Successful in 6m31s
apple / screenshots (push) Successful in 5m45s
windows-host / package (push) Failing after 7m11s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
arch / build-publish (push) Successful in 8m0s
ci / rust (push) Successful in 10m1s
docker / deploy-docs (push) Successful in 28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m33s
Reviewed-on: #84
2026-08-07 08:37:42 +00:00
enricobuehler 75dfab1d35 fix(host): a reconnecting session inherits its launch instead of starting it again
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 2m48s
ci / web (pull_request) Successful in 2m34s
android / android (pull_request) Successful in 5m51s
ci / docs-site (pull_request) Successful in 1m48s
ci / rust (pull_request) Canceled after 6m55s
ci / rust-arm64 (pull_request) Canceled after 6m35s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 3m6s
Two defects, found while tracing M8's codec-fallback reconnect and recorded
verbatim in d5e23146 as out of scope there.

A client retry re-sends Hello::launch verbatim, and the host launched
unconditionally. Steam and Epic URIs hide it — the launcher focuses the running
copy — but a gog:/custom: target really did start a SECOND COPY of the game. The
client cannot fix it by dropping the field: on Linux the per-session gamescope is
re-adopted through pf-vdisplay's display registry, whose reuse key includes the
launch command, so a retry without it orphans the running game.

And the retry minted a fresh launch_stamp, so procscan refused to adopt a game
started more than 2 s before it — the game was minutes old, so a reconnected
session had no game-exit detection for the rest of its life.

Both are now answered by a launch registry (launchreg.rs): one record per (client
fingerprint, library id), written at launch time and INDEPENDENT OF THE
TERMINATION POLICY. That independence is the point. The existing fingerprint-keyed
reclaim only exists under GameOnSessionEnd::Always — under the default Keep,
arm_grace is never called, so nothing was recorded at all in exactly the
configuration the defect was reported in.

The design correction that matters: at launch time the host knows NOTHING about
the game's processes — that is the premise of the whole lease design. So identity
flows BACKWARDS from the watcher, which publishes the concrete ProcRefs it
adopted, and the registry's liveness is Scanner::alive over that recorded set,
re-verified by (pid, start). Never a re-scan by spec: a later scan would find a
copy the player started since, and adopting that is what procscan's rule 1
forbids. The published set is never cleared on exit either — the last thing the
watcher saw is what makes a quit game read Gone rather than "no opinion", which
is how it becomes relaunchable at once instead of being suppressed for the window.

On rule 1: an adopting session inherits the older floor, so its own find() admits
what the ORIGINAL session's lease already admitted for its whole life. That is the
correct reading of "the same launch, continued" and not a new exposure — rule 1
forbids adopting processes that PREDATE the launch, and these postdate it.

The match rule is pure and total (covers()): liveness is authoritative where it
has an opinion, and only Unknown falls through to the tie-breakers — a live holder,
or a 90 s in-flight window for a re-dial while the launcher is still working. Gone
beats both, deliberately: a title that crashed on startup must relaunch at once.

Both race orders are handled and neither is relied on. Teardown-first takes the
Running arm; handshake-first (a fast re-dial on a half-open connection) takes the
holders>0 arm, and the old teardown then sees superseded() and does nothing —
without which, under Always, it would arm a grace the new session had already
passed its chance to reprieve, and the reaper would kill the new session's game.

Two tradeoffs taken deliberately: a custom: command with no detection hints stays
Unknown forever, so that reconnect trades game-exit detection for not
double-spawning; and IN_FLIGHT_WINDOW is a fixed 90 s rather than sharing
disconnect_grace_seconds, because the two have opposite failure costs — grace
being wrong leaves a game running, this being wrong silently swallows a launch the
player asked for.

Gates: fmt clean; clippy -p punktfunk-host --all-targets -D warnings green in the
Linux container; 418 passed, +9 exactly the new tests. One failure,
gamestream::stream::tests::sender_delivers_batches, is pre-existing and
environmental — a UDP-loopback EINTR under qemu at stream.rs:1697, outside every
hunk in this change (the last is at +448), and it fails identically on a pristine
HEAD. I reproduced both the failure and its location myself rather than taking it
on report.

⚠ OWED: the Windows leg is COMPILE-UNVERIFIED. cargo check --target
x86_64-pc-windows-msvc dies in ring's C build on macOS and xcheck.sh does not
cover punktfunk-host. The Windows edits are small restructures of existing
branches plus a bool assignment, reasoned through but seen by no compiler. Run it
on .133 before this merges.

I narrowed that exposure by inspection afterwards, and it is smaller than the
blanket warning suggests. The change presents exactly two things to a Windows
compiler that a Linux one did not already see. launchreg gates only alive_count
(lines 227/231), whose cfg(any(linux, windows)) arm calls
Scanner::system().alive(procs) — the identical call gamelease.rs:563 already makes
in code that compiles on Windows today. And the Windows launch arm at
native/stream.rs:1666 reads only ungated bindings the Linux arm type-checks thirty
lines below it (adopt_launch:1658, spawned_now:1663, launch_claim:1463) and calls
only the pre-existing library::launch_title. No new type, no new signature, no
Windows-only API.

That is an argument, not a compile. The run on .133 is still owed.
2026-08-07 10:14:21 +02:00
enricobuehler 138a1f1b2f fix(host/windows): the staging-dir SID checks document their unsafe blocks
ci / web (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m34s
ci / bun-nix (pull_request) Successful in 22s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 4m4s
android / android (pull_request) Successful in 4m28s
ci / rust (pull_request) Successful in 9m12s
clippy's undocumented_unsafe_blocks (deny) flagged the three blocks that
81039581 introduced: the SAFETY comment sat outside the closure, so
IsValidSid/EqualSid inside it read as undocumented, and from_raw_parts
shared a comment that only covered the GetLengthSid line above it. Windows
host clippy is the only leg that lints this cfg(windows) code, red since.
2026-08-07 10:11:04 +02:00
enricobuehler 39cfb7234c fix(ci/docker): a cache-hit builders job stops failing on a login it never uses
The LAN-registry docker login only serves the Push step (Reconcile and
Tag-for-release authenticate via curl -u), but it ran unguarded — so a
hit=true leg landing on a host with a misconfigured docker daemon failed at
login with nothing to push (run 16044/16013 f44 leg). Gate it like Build/Push.
2026-08-07 10:11:02 +02:00
enricobuehler c3cdee9bf5 fix(ci/prune): the 2-minute image prune stops deleting images mid-pull
docker image prune -af --filter until=2h keyed on image CREATION time, so a
base image built days ago that merely had no container at that instant was
"aged" — including one a job had just pulled and not yet created. Measured
2026-08-07: three job failures, each coinciding with a prune tick to the
second ("No such image: …punktfunk-rust-ci:latest", every step cancelled),
plus a 4-7 GB re-pull of every idle base image within minutes.

The routine tick now retires only what this host actually accretes — per-SHA
app tags older than 2h (their creation time IS the local build time) — then
sweeps dangling layers, which cannot touch a tagged image. The blanket -a
prune survives only in the near-ENOSPC burst guard, where one re-pull beats
every concurrent job dying.

docker-reclaim.{sh,service,timer} are the hourly leak reclaimer that so far
lived hand-installed on home-runner-1 only; home-runner-2 went without it and
re-accumulated 176 leaked volumes (~60 GB) until jobs died of ENOSPC on
2026-08-06/07. Checked in so both hosts install the same files from here.
2026-08-07 10:10:59 +02:00
enricobuehler dee97e893c fix(vkdecode): the address the driver keeps is now the address we keep
The AV1 use-after-free fix (cdd1f3ef) stabilised the wrong half. NVIDIA was
measured retaining pColorConfig, so StoredParamsAv1 boxed the colour and timing
blocks — but OwnedStdAv1SequenceHeader kept the Std struct ITSELF inline, so the
pStdSequenceHeader we handed vkCreateVideoSessionParametersKHR was a stack
address inside ensure_parameters, dead the moment it returned. The fix worked
because of WHICH pointer that driver happened to hold. A driver retaining the
outer one instead — no more of a spec violation than retaining pColorConfig was —
reproduces the original bug exactly: plausible pictures, wrong content, no error
and no counter moved.

The same shape was in the shipping codecs, one step further from evidence: the
H.264 and H.265 create paths pointed pStdSPSs/pStdPPSs/pStdVPSs at function-local
Vecs, and both Add paths handed over the wrapper's inline std field and then moved
the wrapper. Those are spec-legal — the object stores copies — and have never
misbehaved on the fleet. They are fixed anyway, because that is precisely what was
true of H.264/H.265 before the same class of bug was found in them, and a
correctness argument that reduces to which vendor we tested is not one.

So: the Std struct is boxed inside each owning wrapper (one level out from what
_color_backing already did), and the contiguous create-time arrays are now fields
of the stored parameters, assembled at their final address. Identical bytes at
identical offsets — only where they live changed.

The line drawn deliberately, in prose at session.rs:29: Std DATA is pinned; the
VkVideoSessionParametersCreateInfoKHR chain itself is not. Retention there would
be a different and far more extreme class of driver bug, and pinning it needs a
self-referential struct over lifetime-parameterised builders.

⚠ NOT hardware-verified. No GPU has run this — the fleet is unreachable and the
250/250 parity that proved this code bit-exact cannot be re-run. That is why the
change is constrained to address stability alone, and why it ships five CPU-only
tests instead: three capture the pointer handed to Vulkan, perform the real move,
and assert it survives — each verified FAILING first, with genuinely differing
addresses, not a tautology. Two more pin the create-array ownership; those fail
before the fix as compile errors rather than assertions, because the pre-fix bug
there is a dangling pointer and asserting on it is UB.

Also: caps.rs claimed the borrow checker pins a profile chain between wire() and
its last use. False at exactly one site — decoder.rs took a raw *const, ending the
borrow, leaving nothing but inspection to stop a future editor moving the chain
before create_query_pool. Correct today, guarded by prose, which is how the first
bug shipped. It is now compiler-enforced: the pointer write and the create call
live inside one helper that takes the profile by reference, so the borrow is held
across both by the signature. An audit cleared the chains otherwise — no entry
point we pass one to retains it.

Gates: fmt clean; clippy -D warnings over pf-vkdecode AND pf-client-core in the
Linux container (its only real consumer, which cannot build on macOS at all —
wol.rs uses deps its manifest gates to linux/windows, so workspace clippy has
never passed there and does not now); 187 lib tests green on Linux, up from 182.
2026-08-07 09:57:39 +02:00
enricobuehler 93b8528d09 Merge pull request 'fix(encode): NVENC split-frame encode never engaged for HDR — engage it, measured' (#83) from worktree-nvenc-s1-split-reconfigure into main
ci / bun-nix (push) Successful in 24s
ci / web (push) Successful in 1m4s
ci / docs-site (push) Successful in 1m16s
apple / swift (push) Successful in 1m35s
deb / build-publish-client-arm64 (push) Successful in 1m34s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 8s
windows-host / canary-manifest (push) Skipped
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 6s
ci / rust-arm64 (push) Successful in 3m30s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 6s
docker / builders-arm64cross (push) Skipped
deb / build-publish (push) Successful in 3m55s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m3s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m37s
deb / build-publish-host (push) Successful in 4m56s
android / android (push) Successful in 6m46s
apple / screenshots (push) Successful in 5m37s
windows-host / package (push) Failing after 5m23s
windows-host / winget-source (push) Skipped
arch / build-publish (push) Successful in 7m56s
ci / rust (push) Successful in 9m35s
docker / deploy-docs (push) Successful in 6m27s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m36s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m45s
Reviewed-on: #83
2026-08-07 07:57:29 +00:00
enricobuehler b5205fef52 Merge pull request 'fix(client/apple): audio stops crackling on lossy, bunching Wi-Fi' (#82) from worktree-audio-wifi-distortion into main
ci / rust-arm64 (push) Failing after 13s
arch / build-publish (push) Failing after 22s
ci / bun-nix (push) Successful in 21s
apple / swift (push) Successful in 1m32s
ci / web (push) Successful in 1m26s
ci / docs-site (push) Successful in 1m27s
deb / build-publish-host (push) Failing after 1m26s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 1m2s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 8s
docker / builders-arm64cross (push) Skipped
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m28s
deb / build-publish (push) Successful in 4m33s
android / android (push) Successful in 5m53s
docker / deploy-docs (push) Successful in 37s
deb / build-publish-client-arm64 (push) Successful in 4m31s
ci / rust (push) Successful in 6m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 4m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 5m47s
flatpak / build-publish (push) Failing after 4m28s
windows-host / package (push) Failing after 6m36s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
release / apple (push) Successful in 11m44s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m24s
apple / screenshots (push) Successful in 6m5s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m15s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m57s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 12m58s
Reviewed-on: #82
2026-08-07 07:37:19 +00:00
enricobuehler 515a3c2912 feat(pf-encode): wire split arbitration on Windows too
ci / rust (pull_request) Failing after 26s
ci / bun-nix (pull_request) Successful in 46s
ci / web (pull_request) Successful in 1m19s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m35s
ci / rust-arm64 (pull_request) Successful in 2m10s
android / android (pull_request) Successful in 4m22s
The last coverage gap, and only worth building once S1 proved it possible: the
Windows backend drives NV_ENC_DEVICE_TYPE_DIRECTX, and an in-place splitEncodeMode
change had never been tested there. It works (071358cb), so the arbiter is now
ungated from Linux-only to the union of both direct-SDK backends and wired into
windows/nvenc.rs: the submit stamp, the feed hook on AU completion,
apply_split_mode, split_key, arm_split_arbiter, and set_send_spread_us.

Same gates as Linux, and they are correctness conditions rather than preferences:
opt-in while it earns trust, an operator PUNKTFUNK_SPLIT_ENCODE pin always wins,
a cached verdict short-circuits, >=2 engines, never H.264, and the sub-frame
trade is only entered when the host has actually reported a send spread to price
it with. The one Windows-specific difference is that `async_rt` is a real
possibility here (opt-in two-thread retrieve) and the arbiter refuses it, because
under pipelined retrieve the submit->AU span includes queue depth and the
comparison would be noise.

⚠ Two more instances of the same item-level dead_code trap, caught by the Windows
run and not by reasoning -- that is now 4 and 5:
- `clear_split_verdicts` is called only by the Linux on-hw test, so it is dead on
  Windows; gated to `all(test, target_os = "linux")`.
- The arbiter methods first landed inside `impl Encoder` rather than the inherent
  impl (the anchor I used, supports_chunked_poll, is a trait method), which the
  compiler caught as "not a member of trait Encoder".

Verified .158 (RTX 4090 / Ada, driver 610.88, D3D11): clippy --features nvenc
--all-targets -D warnings clean, and 2 on-hardware NVENC tests green including S1
re-run with the arbitration code in place (engines=2 latched, DISABLE->TWO_FORCED
accepted, zero IDRs, reverse accepted). Verified .21: clippy clean with AND
without the nvenc feature, 65 unit tests, 25/25 NVENC on-hardware. fmt clean.
2026-08-07 09:28:34 +02:00
enricobuehler 8c994965d4 docs(licensing): say what the licence gate cannot see
The plan's M10 checklist named "the about.toml carve-out that puts FFmpeg
outside the automated licence gate". There is no such stanza — I looked, on this
branch and on origin/main. The carve-out is structural, which is worse: cargo-about
walks the CARGO graph, so a native library reached through a permissively-licensed
-sys crate is invisible to it. ffmpeg-sys-next is WTFPL and passes the gate
cleanly while the LGPL libavcodec it link-imports is never harvested at all.

So about.toml's own claim to be "exactly the regression guard we want against a
copyleft dependency silently entering the linked set" was overstated: it did not
catch FFmpeg entering and would not catch the next one. The comment now says so,
and says where the LGPL obligations are actually discharged instead.

The one genuinely good piece of news is recorded too: since M10 the client links
no FFmpeg, so for every client artifact the crate graph and the linked set
coincide and the gate finally means what it appears to mean. The gap is the
host's alone.

Gate: cargo about generate about.hbs --fail — passes.
2026-08-07 09:27:30 +02:00
enricobuehler b27135308f fix(scripts): the xcframework never absorbs a Homebrew libopus
ci / docs-site (pull_request) Failing after 2s
ci / web (pull_request) Failing after 5s
android / android (pull_request) Failing after 14s
ci / bun-nix (pull_request) Successful in 22s
apple / swift (pull_request) Successful in 1m41s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m52s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 5m45s
ci / rust (pull_request) Failing after 7m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m22s
On a Mac with brew's opus installed, audiopus_sys found it via pkg-config and
statically linked it into the aarch64 slice — a lib built for the RUNNING
macOS (minos 26.0, tripping the script's own version guard) and existing only
for the host arch, so the x86_64 slice silently fell back to the vendored
build and the two slices shipped different libopus builds. Force the vendored
CMake build for every slice (OPUS_NO_PKG_CONFIG=1), with the CMake policy
floor modern CMake (>=4) needs to accept libopus's old cmake_minimum_required.
2026-08-07 09:23:37 +02:00
enricobuehler 64c92da356 fix(client/apple): the jitter ring deepens on the sessions that actually starve
The shared JitterPolicy grew an adaptive target floor — clustered genuine
underruns raise the live target a step at a time up to max_target_ms, a long
quiet spell relaxes it back — and the three Rust rings all run it via
note_read. The Apple ring is the one hand-written mirror, and it mirrored the
shed half but not the growth half: its target was pinned at the 20 ms base
forever. On Wi-Fi that bunches arrivals (power-save is the classic; the field
MacBook report is the symptom), 20 ms is regularly shorter than one delivery
stall, so the ring re-primed through every stall for the whole session —
crackle that never got better, on exactly the client where a Moonlight with a
deeper buffer sounds fine on the same host and network.

The ring now carries the full mirror of note_read: 3 underruns inside a 5 s
window grow the target 10 ms (capped at COREAUDIO's 70), 30 s of quiet gives a
step back, and the write-side hard trim follows the grown target (including
the Rust policy's target+quantum guard, which the mirror also lacked). New
tests pin the mirror to the Rust suite's expectations — growth, relax, the
cap — plus the field scenario end to end: bunched 60 ms deliveries with every
fourth burst 30 ms late converge to a silence-free tail instead of crackling
forever.
2026-08-07 09:23:29 +02:00
enricobuehler 81d257c7fa fix(client/audio): the in-core decoder conceals lost packets like every other client
A field report: game audio on a MacBook (M1) crackles over Wi-Fi against a host
that plays clean to other clients. The Apple client is the one client whose
Opus decode lives in core (punktfunk_connection_next_audio_pcm — AudioToolbox
has no multistream path), and that decoder only ever decoded packets that
ARRIVED. The Linux, Windows and Android decode loops all feed an
AudioGapTracker and synthesize libopus packet-loss concealment for every
packet the wire lost; the in-core path had the tracker sitting unused in the
same crate. So on Apple every lost 5 ms datagram — at ~200 packets/s over
Wi-Fi, a steady trickle — landed in the playout ring as a hard time-domain
gap: a click per loss, sustained crackle under real loss. The redundant-plane
recovery (0xD2) hides single losses when the host grants it, which is exactly
why the survivors are the burstier gaps that need concealing most.

The decode now runs through the same accounting as everyone else: concealed
frames land in front of the arriving frame in one contiguous buffer (the
embedder just writes it to its ring), a DTX marker advances the accounting
without being decoded, and the output buffer is pre-sized for a full
concealment run so the borrow-until-next-call pointer can never dangle.
Unit-tested against real libopus: gaps, duplicates, DTX-after-loss, and the
50 ms cap.
2026-08-07 09:23:15 +02:00
enricobuehler 071358cbf7 test(pf-encode): S1 on WINDOWS/D3D11 — passes; Windows arbitration is buildable
Everything the split-encode programme rests on had been proven only on
Linux/CUDA. The Windows backend drives NV_ENC_DEVICE_TYPE_DIRECTX, so none of it
transferred by assumption -- and if the driver refused an in-place split change
there, Windows arbitration would simply not be buildable.

RESULT on the RTX Windows box (RTX 4090 / AD102, driver 610.88, D3D11):
  engines=2, latched by query_caps  (WP1.1's probe, validated on Windows
                                     hardware rather than inferred from Linux)
  DISABLE -> TWO_FORCED via nvEncReconfigureEncoder, resetEncoder=0: ACCEPTED,
  ZERO IDRs, and the reverse likewise.

So the foundation now holds across three platform x arch x driver combinations:
Linux/CUDA Blackwell 610.57.04, Linux/CUDA Ada 610.43.03, Windows/D3D11 Ada
610.88.

 UNBLOCKS ALL FUTURE WINDOWS ON-HARDWARE TESTING. pf-encode's nvenc test
binaries were believed unlinkable on Windows ("NvEncodeAPICreateInstance
unresolved", recorded as pre-existing and worked around by only ever running
clippy there). They link fine given the SDK import library:

  RUSTFLAGS='-L native=C:\Users\Public\nvenc -l nvencodeapi'

`-L` alone is not enough -- without a `-l` nothing pulls the archive in, which is
why the earlier attempt still failed. ⚠ This is TEST-BINARY-LOCAL and must stay
that way: production deliberately dlopens NVENC rather than link-loading it, and
an unconditional link-load is the known crash class on non-NVIDIA Windows hosts.

⚠ Box note: the RTX Windows box answers on .158, not the .173 in its memory
entry, and `Administrator@` there resets the connection right after
SSH2_MSG_SERVICE_ACCEPT in a way that reads like the host being down -- the
working login is "Enrico Bühler"@192.168.1.158.
2026-08-07 09:23:05 +02:00
enricobuehler 5a1ec6198e docs(ci): the Windows host's FFmpeg tree is no longer shared with the client
windows-host.yml called FFMPEG_DIR "the same BtbN lgpl-shared x64 tree the
Windows CLIENT links against". Since M10 the client links no libav* at all and
windows.yml sets no FFMPEG_DIR, so the sentence pointed a reader at a link that
does not exist. The provisioning script still fetches the tree — for the host
alone — which is the part worth saying out loud, because the next person to read
it will wonder why a client-provisioning step still mentions FFmpeg.
2026-08-07 09:16:32 +02:00
enricobuehler 0430d907bb fix(pf-encode): gate forced_split_width to Linux — WP4 broke the Windows build
The verification gap flagged in 01294e3a was real. `.133` came back up and the
WP4 commit failed Windows clippy: `forced_split_width` is used only by the libav
NVENC path (`enc/linux/mod.rs`), but it was added to `codec.rs`, which compiles
everywhere -- so it is dead code on Windows and `-D warnings` rejects it.

Third time this crate has hit the same item-level dead_code trap (see
`subframe_env_forced`, and the arbiter items in `nvenc_core`), and the third time
it was caught by actually running the Windows check rather than by reasoning
about it. The comment on the gate says so, since the pattern is clearly not
self-evident from the code.

Verified .21: clippy -D warnings clean both WITH and WITHOUT the nvenc feature,
65 unit tests. Verified .133: Windows clippy --features nvenc --all-targets
-D warnings clean, zero errors, zero dead_code. fmt clean.
2026-08-07 09:01:05 +02:00
enricobuehler 5c05246098 feat: M10 — FFmpeg is gone from the client
cargo tree -p punktfunk-client-session finds no ffmpeg. The host still does,
which is the whole point: pf-encode keeps libavcodec unconditionally and no
host workflow, packaging script or licence file was touched.

Deleted: crates/pf-ffvk, video_vulkan.rs, video_vaapi.rs, video_libav.rs, the
libavcodec half of video_d3d11.rs, the av_log machinery, ffmpeg::codec::Id as
the decoder's vocabulary (the quic CODEC_* wire constants now serve, which is
why the evidence table was keyed on them), DecodedImage::VkFrame and ::Dmabuf,
the presenter's AVVkFrame lane, and the ffmpeg-fallback feature with
everything behind it. DrmFrameGuard collapses from an enum to a newtype, which
removes an unsafe impl Send. Roughly 25,000 lines.

Then the CI, packaging, licensing and docs work the plan's §6 lists: the
Windows workflows lose FFMPEG_DIR, PF_FFVK_VULKAN_INCLUDE and their PATH
prepend; the MSIX loses its DLL wildcard; the client .deb stops emitting libav
sonames on its own because depends come from dpkg-shlibdeps; arch, flatpak and
nix drop the dependency; and the README's "FFmpeg 7 or 8" contract narrows to
the host.

Three defects reached users' machines in the first cut, and none was in the
deletion itself.

All three desktop Settings UIs offer vulkan, vaapi and d3d11va as stored
decoder values, so those strings sit in shipped settings files today. Refusing
them by name — which is the correct rule for a stale pin — would have bricked
every upgraded client whose owner ever touched that dropdown. They now migrate
onto the native rung for the same hardware family, at decoder construction AND
at each dialog's lookup, because a legacy value that matches no preset
displays as "Automatic" and silently rewrites the user's preference on the
next save.

M9's evidence filter was deleted on the argument that with no libavcodec twin
below, barring an unproven rung removes hardware decode rather than moving
down one rung. That is true on Windows and false on Linux for Intel and every
unknown vendor id, where prefer_vulkan_first is false and the order is
native-vaapi → native-vk: a rung that has decoded nothing anywhere sitting
above one that is 250/250 on three drivers. Every Intel Linux desktop would
have moved from libavcodec VAAPI, shipping for years, onto pf-vaadec by
default — and a rung that constructs and then produces wrong pixels leaves
only by the error-streak demotion, which this codebase already documents as
not tripping on the B580's strobing. The filter is restored as a narrow, pure,
testable rule: an unproven rung yields to a proven one, and to nothing else.
Windows deliberately passes no rung below, because that vendor family is the
one with a measured wrong-pixel report against Vulkan decode, and trading no
evidence for evidence of corruption is the wrong direction.

And the notices still said FFmpeg was bundled. The root file is what both
desktop clients include_str! and what the MSIX ships, three lines under the
new card saying no FFmpeg is bundled; Apple's Acknowledgements said it too, on
iOS, tvOS and macOS. The generator now emits four per-client files scoped by
transitive closure — 0 FFmpeg mentions in each, verified — while the root file
keeps it for the host. That also ends the standing false attribution of
ffmpeg-next, GTK4, windows-rs and the NVENC SDK to an iPhone.

Windows has no reachable box, so it was compiled instead: a cross clippy at
-D warnings on x86_64 and aarch64-pc-windows-msvc with the C toolchain stubbed
so build scripts run without linking. That gate immediately caught an
include_str! path one directory too deep, which nothing else could have.

Gates: container clippy -D warnings, 160 tests, workspace check, both Windows
targets clean, client ffmpeg count 0 and host 2. The four decode crates are
untouched, so the hardware rungs' 250/250 stands.

⚠ Owed and unrun: no GPU has executed any of this milestone. M8's on-glass
software check, M7's D3D11 and VAAPI AV1 hardware legs, and M9's field bake
all still want hardware, and the bake window and criteria remain the user's.
2026-08-07 08:58:47 +02:00
enricobuehler 01294e3a53 refactor(pf-encode): WP4 — one split policy, shared with the libav path
The libav NVENC path carried its own inline copy of the split decision and had
already drifted from the direct-SDK selector: it hard-coded a 2-way split
regardless of engine count, and had no depth rule at all. That is the drift the
shared resolver was extracted to prevent, and the copy quietly reintroduced it.

Routing it through `resolve_split_mode` needed the policy to MOVE. `nvenc_core`
is gated on `feature = "nvenc"`, but the libav path is precisely the build where
that feature is OFF (`PUNKTFUNK_NVENC_DIRECT=0`, and the featureless packages --
the packaging gap this project has been bitten by before). So
resolve_split_mode / max_forced_split_mode / clamp_to_engines, plus a new
`forced_split_width`, now live in `codec.rs`, which is always compiled and
already owned SPLIT_FORCE_PIXEL_RATE.

That means the NV_ENC_SPLIT_ENCODE_MODE values had to be hand-written as plain
constants, since the SDK enum does not exist without the feature. They are
therefore pinned: `nvenc_split_constants_match_the_sdk` (feature-gated, the only
place both are visible at once) asserts all five against the real enum, so the
copies cannot rot.

⚠ Only the FORCED outcomes are actionable on the libav side -- libavcodec's
`split_encode_mode` AVOption is its own vocabulary and our DISABLE is the NVENC
enum's 15, which would be meaningless there. DISABLE/AUTO both map to "leave the
option unset", which is exactly today's behaviour (unset = the driver's auto).
`engines = 0` ("not probed") maps to 2-way, preserving what that site always did;
a 3-NVENC part gets the wider split only on the direct-SDK path, which is the one
that actually probes.

⚠⚠ VERIFICATION GAP: .133 went down mid-change (no ping), so the WINDOWS leg is
UNVERIFIED. This matters more than usual -- the Windows backend imported
resolve_split_mode from nvenc_core and that import had to move too, which a grep
caught rather than a compiler. Re-run before trusting it:
  cargo clippy -p pf-encode --features nvenc --all-targets -- -D warnings

Verified .21: clippy -D warnings clean BOTH with and without the nvenc feature
(the featureless build is the whole point of the move) and with
nvenc,vulkan-encode; 65 unit tests incl. the new constant-parity test; 25/25
NVENC on-hardware; punktfunk-host clippy clean. fmt clean.
2026-08-07 08:16:35 +02:00
enricobuehler 38554c1c6e feat(client): M9's code half — native first, FFmpeg behind an off-by-default feature
`ffmpeg-fallback` on pf-client-core, default off on the crate. With it off the
libavcodec rungs are not compiled, pf-ffvk leaves the dependency graph, and no
ladder or demotion arm names them; with it on each sits exactly where it sits
today, directly below its native twin. That is the switch which makes M10 a
deletion rather than a redesign.

The bake window and the regression criteria are the user's, per the plan, and
nothing here claims the M9 gate is met.

The hard part was not the feature, it was honesty. Two of the four native
rungs have never decoded a frame on any hardware — native VAAPI at all, and
native D3D11VA's AV1 leg — and making those the default would assert evidence
that does not exist. So admission is per rung and per codec: a pair with
hardware evidence joins `auto` always; a pair without it joins only when
nothing proven is left below it (a build with no FFmpeg twin, where the
alternative is not a proven rung but the CPU) or when the user asks with
PUNKTFUNK_NATIVE_FIRST=1. Pins bypass it, so a lab run can still reach any
rung.

The shipping default therefore changes in exactly three ways, all
evidence-backed: AV1 `auto` takes native Vulkan (250/250 bit-identical on an
RTX 5070 Ti), Windows H.264/H.265 `auto` takes native D3D11VA above its FFmpeg
twin (parity on two GPUs plus a 30-minute soak), and a failing Vulkan rung on
Windows demotes to native D3D11VA first. Everything unproven is byte-for-byte
as it was.

The evidence state is written where it cannot rot: a table in video.rs's
module docs, the same facts in code as `native_evidence()`, a test asserting
them in both feature states, and a per-session log line carrying the rung, the
codec, whether hardware has verified that pair and the evidence string — at
WARN when it has not. A support engineer reading a log can now tell proven
from assumed without asking anyone.

Termination needed a new guarantee. With the FFmpeg twins gone, two native
rungs in opposite per-vendor orders could hand a session back and forth
forever, so a rung once entered is never re-entered and the walk is monotone
to software. The never-delivered fall-through still works: with the feature on
it is unchanged, and with it off it is redundant, because the next candidate
already IS the rung below.

⚠ ffmpeg-next remains a hard dependency of pf-client-core, deliberately. What
is left off-feature is three type-level residues — the codec-id vocabulary,
the AVVkFrame guard that is pf-presenter's public import, and a pixel-format
in one signature — every one of them an M10 §6 line item. Deleting them here
would mean deleting the presenter's FFmpeg lane, 55 call sites, in a milestone
whose gates cannot run a GPU. No libavcodec decoder is opened in a default
build.

⚠ video_d3d11.rs was gated item by item rather than wholesale, and nothing in
this tree compiles it — it needs a Windows check before anyone trusts it.

Gates: both feature states, container clippy -D warnings and 158/159 tests,
workspace check. The four decode crates are untouched, so the hardware rungs'
250/250 stands.
2026-08-07 07:00:29 +02:00
enricobuehler d5e23146c0 feat(client): M8 — the software rung is openh264 and rav1d, and swscale is gone
The ladder's last rung no longer runs FFmpeg. H.264 decodes through openh264,
AV1 through rav1d, and HEVC is refused outright: no permissively licensed
software HEVC decoder exists, so an HEVC session that exhausts its hardware
rungs now tears down and re-dials advertising HEVC-less caps, and the host
picks H.264. The plan calls that a first-class path; it is one.

swscale is deleted, and with it the BT.601 default that its correction code
existed to undo. Colour on the H.264 lane now comes from the same
pf-bitstream planner every hardware rung submits from — openh264 reports no
VUI at all — and AV1's comes per-picture from the sequence header. One colour
source, one CSC: the old default is unrepresentable rather than merely fixed.
Frames reach the presenter as three tightly-packed planes through the planar
CSC pass, which had to be un-gated from the pyrowave feature and its device
probe, since the last rung must exist on devices that failed that probe.

rav1d rather than the dav1d crate, deliberately and against the plan's
literal wording: dav1d-sys is system-deps-only, so it would add a system
library and a .pc file to every client package — in the milestone family
whose excision checklist exists to delete exactly that. rav1d is the same
decoder, same licence, statically linked. The cost is honest: no-asm builds
on both decoders, and software throughput is still unmeasured.

The colour test is the milestone's exit criterion, so it is built to fail.
Three fixtures, and a mutation check: hardcoding the swscale default turns the
red bar to [255,24,0], and swapping Cb/Cr turns red to blue — a silent error
no metadata assertion could catch. Review then disproved the range half of it
numerically: with eight saturated bars, decoding the full-range fixture with
the wrong range gives max error ZERO, because a mismatch only pushes values
outside [0,1] where the shader clamps. A mid-tone was added; the wrong range
now costs 11, well past the tolerance. The exit criterion I set was
satisfiable by a test that proved nothing.

Two blocking defects, both emergent rather than local.

Software AV1 on a 10-bit stream never reached its typed refusal: rav1d is
built 8-bit-only and returns ENOPROTOOPT, which the send loop turned into a
generic error, so the pump's typed downcast missed and every AU failed
identically — a permanent freeze on precisely the shipping case, since AV1 is
advertised only where hardware AV1 exists and hardware AV1 plus HDR is Main
10. The shape is now read from the sequence header before any byte reaches
the decoder, exactly as the H.264 leg reads the active SPS.

And the new Reconnecting phase was the first state that is not streaming, not
connecting, and still holding a live stream — which opened all three guards
that had made a second launch impossible. Pressing A assigned over `stream`
where every other site shuts down first, and StreamState has no Drop, so the
old pump was detached: a second live session still submitting to a Vulkan
device that gets destroyed underneath it. Nothing about the reconnect was
wrong in isolation; the defect lived between a new state and three guards
nobody re-examined. Start is now defensive and the retry raises the
connecting modal, so the UI matches the state and B can cancel.

Also closed: retry_caps was computed, tested and never applied, so a shape
refusal could end a session reporting no codec available while a working
retry existed; the retry inherited force_software sticky-true, landing an
HEVC→H.264 fallback on software H.264 with working hardware H.264; it
re-dialled with a stale mode; the CPU present arm had no survivable-failure
handling where the pyrowave arm — same pass — has it; HEVC is no longer
advertised when the decoder is pinned to software; and the software rung now
feeds the recovery-point SEI it already had in hand to the re-anchor gate.

⚠ Two host-side gaps found while tracing, neither in scope here: Hello::launch
is NOT idempotent (gog:/custom: targets spawn a second copy on a retry; the
field is kept verbatim because dropping it orphans the gamescope display whose
reuse key includes the command), and a reconnected session can never adopt a
game predating its own launch stamp, so it has no game-exit detection.

⚠ OWED: the on-glass software run. ~200 lines of new Vulkan on a path that
only runs because the GPU already failed, and no driver has seen it. The
review's minimum check is sync validation enabled, a non-multiple-of-16 mode,
a mid-session resize and demotion, and both colour matrices.

Gates: container clippy -D warnings over four crates, 236 tests, workspace
check. pf-vkdecode and pf-bitstream are byte-for-byte untouched, so the
hardware rungs' 250/250 stands.
2026-08-07 06:18:23 +02:00
enricobuehler a20cd44ed4 feat(client): native VAAPI AV1 — the third rung, and two failure-path defects
The libva AV1 layouts, the AuPlan conversion and the Linux rung's AV1 arm,
completing AV1 across all three hardware backends. Pin-only.

Layouts measured, not transcribed: the committed probe grew the AV1
structures and every size and offset it printed against libva 2.23.0 is a
compile-time assertion. Three that a hand-count gets wrong — the picture
buffer is align 8 because anchor_frames_list is a pointer, inserting seven
bytes of padding; seg_info and film_grain_info carry their own padding tails
inside the parent; and THREE of AV1's six bit-field unions are narrower than
a word (one uint8_t, two uint16_t), so a u32 packer over any of them writes
through its neighbour.

This is the fifth way this program has had to spell "which pictures does this
frame use", and it is unlike the other four: ref_frame_map is indexed by SLOT
and holds actual VASurfaceIDs rather than indices into anything, ref_frame_idx
is indexed by NAME and holds slots taken from the header — not from the
plan's refs, where a lost reference leaves a hole and a hole is not a slot —
global motion is picture-level, and there is no per-reference size field at
all. Established from va_dec_av1.h and libavcodec's vaapi_av1.c, and stated
in the module docs so the next reader does not re-derive it.

Review verified the whole happy path — every layout assertion re-measured,
every packer width and bit position, the reference convention, the
num_elements buffer shape — and found both defects on FAILURE paths, neither
reachable on the vendored vector.

A conversion refusal permanently desynced the ledger. The mutation block sat
after the tile walk, so any tile-shape refusal left the planner holding a
picture with no ledger slot — and the resulting UnresolvedReference fires
before that block too, so it never repaired. Every later access unit
hard-errored until a shown key frame: one lost packet costing a GOP. The
arm's own doc already warned that skipping conversion would desynchronise the
slot map; the refusal door did exactly what the skip door was written to
avoid. The block is hoisted, and a tile-shape refusal on an already-damaged
plan is now concealed rather than refused.

Fixing that exposed a sharper edge: the conversion can release a slot and
reassign it to the refused picture in one call, so the binding would still
hold the PREVIOUS picture's surface — a wrong reference rather than a missing
one, which nothing downstream could notice. The caller now clears the binding
unconditionally on the refusal path.

And a damaged frame's surface was never written yet was bound as a reference
and left in pending, so a later clean show_existing_frame would claim it with
damaged = false and ship uninitialised GPU memory to the presenter — on
several drivers another client's framebuffer. The justification quoted half
of va_dec_av1.h; its next sentence gives the remedy, which is to point the
problematic index at an alternative buffer. Damaged frames now submit as they
do on the other two arms, with live surfaces substituted for invalid entries
and reported as a bitmask — preferring a reference that really decoded over
the decode target, and keeping libavcodec's deliberate all-invalid map on a
shown key frame.

Film grain is refused rather than decoded wrong: libva wants two surfaces,
one ungrained for prediction and one grained for output, and libavcodec
allocates a second frame for exactly that. The gate now sits after the
mutation block so a grained frame costs itself rather than the GOP, and stays
per-AU rather than per-sequence because a stream that merely DECLARES the tool
decodes here perfectly.

⚠ Residual, flagged not fixed: a picture decoded from substituted references
can still be shown by a later show_existing_frame. It is decoded memory now
rather than uninitialised, and it is what the H.264/H.265 arms do, but
tracking "this was concealed" through to display needs new session state.

Gates: macOS fmt/clippy/125 tests/cargo-doc, container clippy -D warnings over
seven crates and 548 tests, workspace check. pf-bitstream's diff is
comment-only — verified — so the Vulkan rung's 250/250 stands untouched.

Nothing here has decoded a frame: no VAAPI hardware is reachable.
2026-08-07 04:22:23 +02:00
enricobuehler ef40890c80 feat(client): native D3D11VA AV1 — wired, and four defects it exposed
The AV1 arm of the native D3D11VA rung, parity-required because today's
FFmpeg d3d11va rung already decodes AV1 Profile 0 and the excision must not
silently drop it. Pin-only, as that rung is today.

decode() walks the temporal unit frame by frame; submit() splits into
decode_into and present, because AV1 decodes frames that are never shown. The
proven H.264/H.265 body is byte-for-byte unchanged — review diffed it against
HEAD mechanically and found only a rename plus one refusal arm — and the
VideoProcessorBlt hand-off is untouched. That mattered more than anything
else here: those two codecs are hardware-proven, .173 is powered off, and no
gate that runs could have caught a regression in them.

Every descriptor value comes from libavcodec's dxva2_av1.c read verbatim, not
from symmetry with the other codecs: three buffers and no qmatrix (AV1
transmits none), NumMBsInBuffer zero on all three, ConfigBitstreamRaw 1,
surface alignment 128, pool +8, and the session sized from the SEQUENCE
header's max frame size — sizing from the frame would rebuild the decoder and
drop every reference the first time a stream legally resized downward.

Two places where following the H.264/HEVC pattern would have been wrong.
libav pads the bitstream buffer and grows only its descriptor's DataSize,
never a tile's, because a tile's size is exact — charging padding to the last
record is corruption, not filler. And the committed tile records were one per
tile GROUP spanning the whole OBU, header and frame header included, where
libav emits one per TILE addressing the payload past its tile_size_minus_1;
the vendored vector is single-tile, so the old tests passed either way.

Review then found four more defects in the already-committed conversion, each
confirmed against libavcodec AND Chromium's D3D11 AV1 accelerator:

Tile widths and heights were the coded minus-1 where the field is a
superblock COUNT — every tile declared one superblock short, on every frame,
with a comment asserting the opposite of the truth.

StatusReportFeedbackNumber must be zero for AV1. Both reference
implementations disable it specifically for this codec — libav's note reads
"breaks decoding on some drivers (tested on NVIDIA 457.09)", Chromium's "it
crashes :|" — while both set it for H.264 and HEVC, which is why this rung's
proven codecs never showed it. It would likely have presented as a hang or a
rejected submission rather than bad pixels, sending the next session after
the tile records instead.

frame_refs[].Index is an index INTO RefFrameMapTextureIndex, not a surface
index; the neighbouring line already filled that map correctly. Measured:
1636 reference entries on the vendored vector where the two differ.

qm_y/u/v need the 0xFF "no matrix" sentinel — 0 is a valid matrix index, and
274 of 274 frames transmit no quantiser matrix, so every one was being
dequantized against matrix 0.

Also closed: the slot leak the Vulkan rung had already found and documented
(a frame refreshing no slot is never reported removed, so nine of them
exhaust the ledger); a tile-grid check that could not fire, replaced with
libav's own cols*rows guard; per-reference sizes now taken from the
reference's own header via RefState rather than the current frame's; and the
render size clamped against the decoded picture in both rungs, since AV1
permits a render size larger than the frame.

The parity leg was rewired through the real decode path — it previously
called the internals directly, so its hidden-frame assertion described the
harness's own counter rather than production withholding anything.

Gates: macOS fmt/clippy/383 tests, container clippy -D warnings over four
crates and 499 tests, and on Windows .133 (.173 is powered off) clean checks
plus 97 pf-dxvadec tests. All 8 Vulkan gpu_parity legs re-verified bit-exact
on the RTX 5070 Ti after the shared-code change.

No AV1 frame has been decoded through this rung anywhere: it needs .173 back.
2026-08-07 03:01:49 +02:00
enricobuehler 185332a866 fix(vkdecode): H.264 and H.265 session parameters own what the driver keeps
The same use-after-free the AV1 rung was just fixed for, closed in the two
rungs that ship. session.rs and session_h265.rs handed their Std parameter
sets to vkCreateVideoSessionParametersKHR and dropped the backings when the
call returned; NVIDIA 610.57.04 was measured retaining such a pointer to
decode-record time, which is what made AV1 diverge on 250 of 250 frames.

Nothing was known to be broken here — both rungs are bit-exact on four
drivers — but that was luck rather than correctness: the freed blocks happen
to still hold the right bytes in that window. The native Vulkan rung sits in
the auto ladder above FFmpeg-Vulkan on shipping clients, so this was live
code, and its failure mode is silent wrong pixels rather than a crash.

StoredParams and StoredParamsH265 hold the parameters object together with
every wrapper it points at, so an object whose backing is gone cannot be
built. create_parameters_object takes the wrappers by value; the Add arm
adopts them only after a successful update, so a failed update drops what it
never stored; the Recreate arm replaces, destroys the old object, then drops
its backings, written explicitly so the ordering survives later edits. The
Add-vs-Recreate decision table and the VPS ledger are untouched — only
ownership moved.

params.rs still carried the refuted claim as a type-level contract, that
Vulkan "copies all parameter data before returning" and keeping the wrapper
alive across the call "is the whole obligation". Corrected to the measured
truth.

The tests are what stop this returning, and each was verified by sabotage:
inlining the H.264 PPS box fails at pps pScalingLists, inlining the H.265 SPS
DPB box fails at sps pDecPicBufMgr, and making either adopt drop instead of
store fails both session tests. Two lessons are recorded in them. Pointer
equality cannot be the assertion, because the Std struct carries pointers by
value and a stale one compares equal — the read-back is the discriminator, so
the tests clobber the dead stack first to make a dangling read deterministic
rather than lucky. And the first H.265 draft read six of eight pointers and
let the sabotage through, so it now reads every one with a labelled assert.

⚠ One site of this class remains, deliberately: the VkVideoProfileInfoKHR
chains, where wire()'s borrow dies with its enclosing block while the object
created from it lives on — three session creates, an image, a buffer, and a
query pool built from a raw pointer into a stack chain. It spans six modules
and all three codecs, and a profile is enums a driver resolves at create time
with no per-frame deref, so the risk is materially lower. It wants its own
pass with its own hardware verification.

Gates: macOS fmt/clippy/196 tests, container clippy -D warnings, pf-vkdecode
182/182 and pf-client-core 140/140. On the RTX 5070 Ti, all 8 gpu_parity legs
re-verified green after the change — H.264, H.265, Main 10 and AV1 all still
bit-identical to libavcodec.
2026-08-07 01:04:17 +02:00
enricobuehler cdd1f3efce fix(vkdecode): AV1 is bit-exact — the bug was a use-after-free, not the driver
250/250 frames bit-identical to libavcodec on NVIDIA 610.57.04, and all four
other parity legs (H.264, H.265, Main 10, both four-byte-prefix twins) still
green.

session_av1 built the sequence header, handed pStdSequenceHeader to
vkCreateVideoSessionParametersKHR, and dropped the backing the instant the
call returned — on the documented assumption that Vulkan copies parameter
data before returning. NVIDIA does not. It keeps the pointer and dereferences
pColorConfig when a decode is RECORDED. The freed block became our own next
allocation, whose bytes read back as mono_chrome = 1, and a monochrome frame
skips exactly loop_filter_level[2..3] (AV1 7.14).

That is the whole fingerprint two earlier rounds chased: luma bit-exact,
chroma off by small amounts, and rewriting the chroma levels in the bitstream
changing nothing — the driver read them correctly and then discarded them,
because it believed the stream had no chroma. StoredParamsAv1 now holds the
parameters object and its Std backing in one value, so an object whose
backing is gone is unrepresentable.

The road there is worth recording, because two well-evidenced conclusions
were wrong before this one was right. A software oracle reproduced the
divergence exactly by disabling chroma deblocking, and a GPU probe showed
chroma levels [8,12] and [63,63] producing byte-identical output — which
looked conclusive and was not. libavcodec's own Vulkan AV1 hwaccel is
bit-exact on this same driver, which proved the hardware fine and the defect
ours. ffmpeg never hits it: with VK_KHR_video_maintenance2 it uses inline
session parameters and never creates a parameters object at all.

The proof is direct rather than inferred: a throwaway Vulkan capture layer
dumped both submissions and every byte of our AV1 picture info already
matched libavcodec's, including the loop filter block; only the session
parameters layer differed. Watching the block's address showed correct bytes
at create and our next allocation at decode.

Ruled out on hardware, so nobody re-tests them: filmGrainSupport,
maxCodedExtent, maxDpbSlots/maxActiveReferences, VkVideoDecodeUsageInfoKHR,
the tile-start sentinel, the setup slot's SavedOrderHints, a NULL
pTimingInfo, and heap luck.

Two earlier fixes are confirmed against libavcodec's captured wire bytes and
kept: CDEF secondary strengths carry the coded value rather than the spec's
in-place fixup, and LoopRestorationSize is log2-based. The refuted
driver-ignores-chroma-levels claim is corrected everywhere it was written
down, and that probe test now passes and points at the lifetime of everything
a submission points at before blaming a vendor.

⚠ Adjacent and NOT fixed: session.rs and session_h265.rs drop their Std
backings the same way, and those sets carry embedded pointers too. Both are
measured bit-exact on four drivers, so nothing is known to be wrong — but the
contract now rests on a driver behaviour measured FALSE for AV1 on a shipping
driver. The SAFETY comments asserting it have been corrected; the structure
is deliberately untouched pending its own pass.

Gates: macOS fmt/clippy/336 tests, container clippy -D warnings, all green;
8/8 gpu_parity and 3/3 gpu_smoke legs verified on the RTX 5070 Ti.
2026-08-07 00:39:34 +02:00
enricobuehler 1062aa780f test(pf-encode): measure the bits/frame curve — no crossover, split always wins
WP0's real deliverable, and the hole every previous measurement in this
programme had. All prior timings ran against driver-zeroed buffers, so rate
control had nothing to code (~300 B/AU against an 833 KB quota) and only the
PIXEL-proportional half of the encode cost was ever exercised -- while the 4K60
HDR field report was a BITS/FRAME problem at 6.8 Mbit/frame.

Adds `pf_zerocopy::cuda::write_plane_from_host`, the exact mirror of the existing
read_plane_to_host. No new loader entry was needed: cuMemcpy2DAsync_v2 was
already in the table and CUDA_MEMCPY2D just needed the reverse memory types.
Linux-only by construction (pf-zerocopy's `imp` is cfg'd to linux).

⚠ Two harness mistakes found and fixed by looking at bytes/AU rather than
trusting the knob:
- Pure per-pixel noise is INCOMPRESSIBLE, so a low bitrate target does not
  produce low bits/frame -- it OVERSHOOTS. At a nominal 50 Mbps the encoder
  emitted 719 KB/AU against a 104 KB quota, and the three lowest rows of the
  first sweep all sat at the same ~5.7 Mbit/frame. Sweeping nominal bitrate
  measures nothing.
- So the sweep moves CONTENT DETAIL (block size) instead, and the x-axis is the
  bits/frame the encoder ACTUALLY produced, never the one requested.

  4K60 HEVC 8-bit, real content, single-engine vs forced-2:

    bits/frame   Ada 4090            Blackwell 5070 Ti
    0.2-0.3 Mb   4567 -> 2381 1.92x  5549 -> 3552 1.56x
    ~1.1-1.2 Mb  5060 -> 2626 1.93x  5867 -> 4082 1.44x
    ~3.3 Mb      8478 -> 4455 1.90x  9286 -> 5862 1.58x
    ~9.6 Mb     16237 -> 8114 2.00x 16435 -> 9275 1.77x

RESULTS. (1) Encode time scales strongly with bits/frame -- 4.6 ms to 16.2 ms
across the range on Ada -- confirming the hypothesis' core claim. (2) There is NO
CROSSOVER: split wins at every point on both architectures (Ada ~1.9-2.0x and
notably flat, Blackwell 1.44-1.77x). So the arbitration's encode-side answer is
essentially always "split", which makes the sub-frame handicap the only decision
that actually matters -- exactly the part already built and unit-pinned.
(3) It corroborates the field capture: at ~6.8 Mbit/frame these curves put
single-engine 4K60 around 10-13 ms, and the field report was 10.3 ms on a 4090.
That reads as real ASIC time, not the retrieve-queue inflation it might have been.

⚠ Caveat the data itself shows: cost is NOT monotonic in bits/frame alone. The
1px row lands at the HIGHEST bits/frame yet encodes FASTER than the 4px row on
both boxes (Ada 10148 vs 16237 us) -- pure noise defeats motion estimation, which
gives up early, where semi-structured content makes it search hard. Content
structure is a real term, so "bits/frame" is a good axis but not a complete cost
model.

Verified .21: clippy -D warnings clean (pf-encode + pf-zerocopy), 64 unit tests,
25/25 NVENC on-hardware. Curves run on both Ada and Blackwell. fmt clean.
2026-08-07 00:30:09 +02:00
enricobuehler 50b3fd1012 fix(pf-encode): drop the 10-bit short circuit — measured wrong on Ada, twice
WP1.3, and the measurement that justifies it. `resolve_split_mode`'s 10-bit rule
sat ABOVE the pixel-rate arm and took no codec, so it (D1) vetoed 10-bit 4K120 --
the very case the pixel-rate arm exists for -- and (D2) applied an HEVC-Main10-on-
Ada result to AV1 10-bit, which has no such measurement. Both fixed: the
pixel-rate arm now comes first, and what remains is codec-scoped to HEVC and only
applies BELOW that bar, where a second engine buys nothing anyway.

The rule rested on one datapoint: 5120x1440@240 Main10 on Ada, forced-2 7.6 ms
vs 2.8 ms single-engine -- split 2.7x SLOWER. Dropping the short circuit flips
that exact configuration's behaviour, so it was re-measured on a 4090 (AD102,
driver 610.43.03), 400 Mbps, sub-frame pinned off, via a new mode-parameterizable
Main10 A/B test (PF_AB_MODE=WxHxFPS reproduces the original operating point).

  Ada 4090          single    forced-2   ratio
  3840x2160@60      4483 us   2178 us    2.06x split WINS
  5120x1440@240     3689 us   2813 us    1.31x split WINS  <- the veto's origin
  3840x2160@120     4148 us   2189 us    1.89x split WINS

  Blackwell 5070 Ti
  3840x2160@60      4216 us   2477 us    1.70x split WINS
  5120x1440@240     4651 us   3894 us    1.19x split WINS

Split wins for Main10 at every mode on BOTH architectures, including the config
the veto came from. The original number does not reproduce.

⚠ Caveats, unchanged from the rest of this work: content is trivial (297-300 B/AU
against an 833 KB CBR quota -- zeroed VRAM), so this is the pixel-proportional
term and the bits/frame regime is still unmeasured; debug build; and the driver
differs from whenever the original was taken.

Also validated on Ada in the same session -- the whole spike set reproduces on a
SECOND architecture and an OLDER driver (610.43.03 vs 610.57.04): S1a in-place
split switch accepted with zero IDRs both directions; S1b takes effect
(|C-B|=12 vs |C-A|=1921, the cleanest run yet); S1c pair flip passes; D5 confirmed
(AUTO+sub-frame 4424 vs DISABLE 4409, 15 us apart -- and AUTO without sub-frame
2310 ~= TWO_FORCED 2314, so the arm stays); engines=2 with THREE_FORCED correctly
clamped to mode 2; arbitration converged with exactly 1 keyframe.

Verified: .21 clippy -D warnings clean + 64 unit tests; .133 Windows clippy
-D warnings clean (the resolver signature grew a `codec` param, so both backends
moved); Ada + Blackwell on-hardware as above. fmt clean.
2026-08-06 23:58:24 +02:00
enricobuehler 2366c4fe31 feat(pf-encode,host): price the HEVC sub-frame trade so arbitration can cover it
The named next step after WP3's first increment. That increment deliberately
REFUSED to arbitrate HEVC-with-sub-frame -- the fleet default, and the reported
field case -- because engaging split there gives up sub-frame readback, whose
whole value is that the send overlaps the encode. An encoder measuring only
encode time would see split as ~2x faster, take it, and make end-to-end latency
worse while reporting a win. This supplies the missing number.

The real comparison is encode_1eng + send_of_last_slice against
encode_2eng + send_of_whole_AU, so the challenger owes roughly
spread x (slices-1)/slices. Split across the two sides that can each see half:

- Host: new `Encoder::set_send_spread_us` (defaulted, forwarded by
  TrackedEncoder -- same trap class as set_wire_chunking, and unforwarded it
  would fail SILENTLY IN THE SAFE DIRECTION, which is the hardest kind to
  notice). The send thread is the only place a paced send is observed and the
  encode loop the only place the encoder can be touched, so it goes over an
  AtomicU32 like encoder_ceiling_kbps, EWMA-smoothed 3:1 per completed AU: one
  content spike must not flip a verdict that then gets cached.
- Encoder: turns the raw spread into the handicap, because only it knows
  `slices`. SplitArbiter::with_handicap charges it to the challenger before the
  comparison. A unit test runs identical encode numbers with a cheap and an
  expensive send and asserts the verdict REVERSES -- with an expensive send the
  arm that looks twice as fast is a loss end to end, and the incumbent must
  hold. That is precisely the regression an encode-only arbiter ships.

Gate now opens for HEVC+sub-frame only when a spread has actually been reported
(and slices >= 2); with no hint it still refuses, so behaviour is unchanged until
the host feeds it.

Two mechanics this needed:
- apply_split_mode became a PAIR flip (split + sub-frame), routed through
  resolve_split_subframe and restoring from `subframe_opened_with` so a session
  that never had sub-frame can never gain it. It also recomputes
  `subframe_chunks`, which reconfigure_bitrate does NOT -- spike S1c's finding;
  leave it stale and supports_chunked_poll keeps saying yes while numSlices never
  advances, so poll_chunk busy-polls its whole budget every AU.
- The arbiter is now fed from BOTH completion points. A sub-frame session
  finishes through poll_chunk, so the incumbent arm of an HEVC experiment would
  otherwise never deliver a sample -- only the challenger, with sub-frame
  dropped, comes through poll.

Verified .21: clippy -D warnings clean for pf-encode AND punktfunk-host with
nvenc, 63 unit tests (1 new), 23/23 NVENC on-hardware green. Verified .133:
Windows clippy -D warnings clean, zero dead_code. fmt clean.
2026-08-06 23:41:40 +02:00
enricobuehler f34f311c69 Merge pull request 'fix(brand): capitalize "Punktfunk" in user-facing text; name the legal copyright holder' (#81) from worktree-docs-title-casing into main
apple / swift (push) Successful in 1m35s
ci / bun-nix (push) Successful in 24s
ci / docs-site (push) Successful in 1m17s
ci / rust-arm64 (push) Successful in 2m23s
ci / web (push) Successful in 1m43s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 17s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
arch / build-publish (push) Failing after 4m37s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m6s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m36s
deb / build-publish-host (push) Successful in 4m21s
apple / screenshots (push) Successful in 6m3s
deb / build-publish-client-arm64 (push) Successful in 5m7s
nix / flake (push) Failing after 3m5s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 10s
docker / builders-arm64cross (push) Skipped
decky / build-publish (push) Successful in 45s
ci / rust (push) Failing after 7m22s
deb / build-publish (push) Successful in 5m11s
android / android (push) Successful in 14m37s
docker / deploy-docs (push) Successful in 6m48s
release / apple (push) Successful in 9m59s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m56s
windows-drivers / probe-and-proto (push) Successful in 2m0s
windows-drivers / driver-build (push) Successful in 9m47s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 17m8s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 6m42s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 11m31s
windows-host / package (push) Failing after 49m58s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 15m1s
Reviewed-on: #81
2026-08-06 21:33:33 +00:00
enricobuehler 3b283dc26e feat(pf-encode): WP3 — live split arbitration, measured on the session, no IDR
The fix S1 unlocked. Rather than predict the right split mode at open — which
cannot work, because the decision depends on bits/frame and an Automatic client's
steady-state bitrate is unknown at open (ABR climbs in place afterwards) — the
encoder now measures both arms on the live session and keeps the winner. S1
proved nvEncReconfigureEncoder takes a changed splitEncodeMode with
resetEncoder=0, emits no IDR, and actually applies it, so the experiment is
invisible on the wire.

Deliberately measures instead of modelling: hard-coded per-arch constants are
exactly how the rule this replaces went wrong (one 5120x1440@240 Ada datapoint
generalised into a fleet-wide 10-bit veto). A measurement tracks driver updates
for free.

`SplitArbiter` (pure state machine, unit-tested without a GPU): measure incumbent
-> switch -> SETTLE -> measure challenger -> keep the winner, else switch back.
Verdicts cache per (gpu, codec, mode, depth, chroma) so later sessions open
straight into the winning arm; the key is CeilingKey minus split_mode, since the
split mode is the thing being decided.

⚠ SETTLE_FRAMES=16 is load-bearing, not padding: split-encode does not reach
steady state on the first frame (a FRESH TWO_FORCED session measured early-half
3280us vs late-half 1996), so judging an arm right after switching reads the
transient — intermittently, which would then be cached. A unit test feeds exactly
that transient and asserts the arbiter still sees the steady state.

Safety gates, all correctness conditions rather than preferences: opt-in
(PUNKTFUNK_NVENC_SPLIT_ARBITRATE=1) while it earns trust; an operator
PUNKTFUNK_SPLIT_ENCODE pin always wins; skip if a verdict is already cached; sync
depth-1 only (async_rt.is_none(), same gate chunked poll uses — under pipelined
retrieve the submit->AU span includes queue depth and the comparison is noise);
needs >=2 engines; never H.264.

⚠ And the one that bounds this increment: NO SUB-FRAME TRADE. For HEVC, forcing
split gives up sub-frame readback, which costs send/encode overlap the ENCODER
CANNOT SEE — it measures encode time only, so it would reliably prefer split and
silently make end-to-end latency worse. So arbitration runs only where nothing is
traded: sub-frame already off, or AV1 (both features legal). Pricing that trade
needs the host's send cost and is the next work package.

Challenger choice tests the question worth asking — anything not already the
widest forced split is challenged BY the widest ("are we leaving engines idle?").
The naive "challenge whatever we are not" spent the experiment re-proving that
splitting beats not-splitting, while parking the session on the slow arm to do
it, because 4K60 sits on the fallthrough AUTO.

⚠ Every new nvenc_core item is linux-gated: the arbiter is wired into the Linux
backend only for now and nvenc_core compiles on Windows too. Caught by the .133
check, not by reasoning — the first cut failed Windows clippy with 12 dead_code
errors, the exact item-level trap this file already carries a scar from.

Verified .21: clippy --features nvenc --all-targets -D warnings clean, 62 unit
tests (4 new arbiter tests), 23/23 NVENC on-hardware green including a new
end-to-end convergence test asserting ZERO extra IDRs and a cached verdict.
Verified .133: Windows clippy -D warnings clean, zero dead_code. fmt clean.
2026-08-06 23:26:47 +02:00
enricobuehler e6bcac8770 chore(license): name the copyright holder "unom - Enrico Bühler"
apple / swift (pull_request) Successful in 1m34s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m6s
ci / rust-arm64 (pull_request) Successful in 1m31s
ci / bun-nix (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 1m23s
android / android (pull_request) Successful in 3m58s
ci / rust (pull_request) Successful in 8m28s
nix / flake (pull_request) Successful in 13m20s
windows-drivers / probe-and-proto (pull_request) Successful in 2m56s
windows-drivers / driver-build (pull_request) Successful in 10m19s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 18m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 24m1s
"unom" alone is not the legal entity name. Updated the copyright line in
all nine first-party license files -- the root MIT/Apache pair and the
hand-maintained copies under clients/apple, clients/decky,
packaging/windows/drivers and packaging/windows/pf-vkhdr-layer (there is
no script that syncs these, so each is edited directly).

The Linux and Windows clients' About screens pick this up automatically:
both `include_str!` the root LICENSE-MIT / LICENSE-APACHE at compile time.

Deliberately untouched:
- Third-party license texts (Geist OFL, FFmpeg, VB-CABLE, the vendored
  pyrowave/Granite tree, KDE protocol XMLs, the os-icon licenses) -- those
  are other parties' copyrights.
- Publisher/author/maintainer metadata, which is identity rather than
  license text and is reported separately for a decision.
2026-08-06 23:16:16 +02:00
enricobuehler 9c7409ea40 Merge pull request 'fix(web/plugins): a plugin's interface loads again, instead of an empty panel' (#80) from worktree-plugin-ui-frame-ancestors into main
ci / bun-nix (push) Successful in 29s
ci / docs-site (push) Successful in 1m10s
ci / web (push) Successful in 1m10s
ci / rust-arm64 (push) Successful in 1m25s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 22s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 58s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m35s
deb / build-publish-client-arm64 (push) Successful in 3m33s
deb / build-publish (push) Failing after 2m55s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 10s
docker / builders-arm64cross (push) Skipped
deb / build-publish-host (push) Successful in 5m37s
ci / rust (push) Successful in 6m37s
arch / build-publish (push) Successful in 8m50s
docker / deploy-docs (push) Failing after 6m26s
nix / flake (push) Canceled after 15m6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 14m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 14m28s
windows-host / package (push) Canceled after 17m47s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #80
2026-08-06 21:15:58 +00:00
enricobuehler b0ec759fad fix(brand): capitalize "Punktfunk" in user-facing text
The docs site's page title read "punktfunk docs". Fixed that and swept the
rest of the tree for the same defect, capitalizing the brand wherever it is
shown to a human and leaving it lowercase where it is a technical identifier
(CLI/package names, `punktfunk://` scheme, PnP enumerator, TLS SNI, logcat
tag, config paths, CMS tenant id).

User-visible fixes:

- docs-site: page title -> "Punktfunk Docs"; API reference title, meta
  description and the branded bar's aria-label; the BrandMark/Wordmark SVG
  accessible names (the web console already had these capitalized -- the
  docs site had drifted from it).
- Android: six strings of live UI copy -- the local-network permission
  dialog (x2), the connect-screen error banner, and the no-controller
  explainer.
- Apple: the "No Hosts" empty-state text and the fallback display name for
  a host that advertises no instance name.
- Windows client: the `--discover` progress line.
- KWin fake-input: the application name passed to `authenticate()` (the
  grant is cached per-exe, so the string is display-only).
- THIRD-PARTY-NOTICES: fixed in both generators (about.hbs and
  gen-third-party-notices.py) and applied to the three checked-in outputs
  so they match what a regeneration now produces.

Every changed line differs from the original only by letter case, so line
lengths are unchanged and no formatter width rule is affected.
`cargo fmt --all --check` passes.
2026-08-06 23:10:25 +02:00
enricobuehler 96fc3eca10 test(vkdecode): the AV1 rung finally has pixels to answer to
A parity and smoke harness for AV1, mirroring the H.264 and H.265 legs that
proved those rungs bit-identical to libavcodec on four drivers before either
ran on glass. This was the milestone's largest test gap: the adversarial
review found four blocking defects in the AV1 conversion — flags unset on
274 frames of 274, a units error in LoopRestorationSize, per-reference info
describing the wrong picture, film-grain fields left zero — and every one of
them would have shown on frame 1 of a parity run, while clippy and 164 green
unit tests said nothing at all.

The golden is 250 per-frame SHA-256s in DISPLAY order, not 274. The vector
carries 274 coded frames in 250 temporal units; the 24 extras are hidden
ALTREFs, decoded and referenced but never shown, and the rung delivers what
dpb.outputs names. The count is re-derived from the planner rather than
assumed.

Cross-checked between ffmpeg 8.1.1 on macOS arm64 and 8.0.1 on Linux x86_64,
whose raw outputs are byte-identical — and then against a third party neither
build knows about: the vendored vector ships upstream's own per-frame MD5s,
and re-running those reproduces all 250. The golden agrees with a decode
nobody in this program performed. I reproduced both independently before
committing.

8-bit NV12, traced from the sequence header rather than presumed
(seq_profile 0, high_bitdepth 0, mono_chrome 0), so the P010 scar does not
apply here — and the header says which check to make if a Main 10 golden is
ever added. film_grain_params_present is 0, which is load-bearing: grain
synthesis is part of the Vulkan decode profile, so this golden is only
comparable against a grain-less profile key.

Anti-vacuity is the point of the exercise, so it is structural. The golden
guard asserts the exact count, that every line is a bare digest, and that all
entries are DISTINCT — 250 copies of one digest would let a decoder frozen on
a single frame pass parity. The parity body asserts the golden set and the
access-unit count before it touches hardware, so an IVF reader returning
nothing cannot become "0 frames compared, pass". The agent verified the
guards fire by mutating the golden three ways.

assert_bit_identical now names the FIRST divergent frame, which is what
localises a defect; that improves all six legs, not just AV1.

AV1 has no four-byte-start-code twin, deliberately: OBUs are
length-delimited, so there is no prefix for a driver to mis-skip. Documented
where a reader would otherwise see an omission.

Nothing here has run on a GPU. The harness exists precisely so the four
review defects can be answered by measurement instead of argument.
2026-08-06 22:38:04 +02:00
enricobuehler 185a69c806 fix(web/plugins): a plugin's interface loads again, instead of an empty panel
ci / bun-nix (pull_request) Successful in 20s
ci / docs-site (pull_request) Successful in 1m5s
ci / web (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 1m49s
ci / rust (pull_request) Successful in 6m23s
nix / flake (pull_request) Failing after 12m37s
Two separate things had to be wrong for this, and both were.

The frame's own policy locked it out. Plugin UIs moved to their own origin so a
plugin cannot act as the logged-in operator, and the plugin origin names the
console as the only page allowed to frame it. It built that name from the
incoming request — but Nitro hands the app a synthetic request with no TLS
socket, so an HTTPS console reads back as `http:`. The header said
`frame-ancestors http://host:47992` while the operator was on
`https://host:47992`, and the browser refused the frame outright
(ERR_BLOCKED_BY_RESPONSE). Nothing on screen said so; the reason was only in
devtools. The scheme now comes from the listener's own TLS state, stamped at
bind time, with x-forwarded-proto winning when something in front terminated
TLS for us — the one case where the browser's scheme is not ours.

And the port was shut. 47993 was added to the firewall definitions, but an
already-open firewall does not pick a new port up: ufw expands an app profile
into rules when you allow it and keeps those, so editing the profile on upgrade
changes nothing, and firewalld needs a reload. Every upgraded Linux host kept a
47992-only rule, silently. The packages now notice on upgrade and print the one
command that fixes it, without touching the running firewall. The NixOS module
and the container image never learned the port at all; both now open it.

Also: the console no longer mounts the frame while it is still checking whether
that origin is reachable. A firewalled port drops rather than refuses, so the
check hangs for the browser's whole connect timeout, and mounting meanwhile is
exactly the empty panel with no explanation. The card that follows now names
both causes it can be — an untrusted certificate for that port, or a closed one
— because from a browser the two are indistinguishable.

The rule is now a pure function with tests, since its failure mode is a
well-formed header that only a browser rejects.

Verified on glass against home-worker-5 (.21) and its ROM Manager plugin: the
frame was refused before, renders the plugin's UI after.
2026-08-06 22:33:57 +02:00
enricobuehler 9a1d8be4cc fix(pf-encode): AUTO split is conditional on sub-frame — do NOT retire the arm
Last change's docs concluded "AUTO never splits, retire the arm" from the
sub-frame-ON measurement alone. Measured the missing leg before implementing it,
and the conclusion was wrong.

On .21 at 4K, plain AUTO (env unset, the resolver's fallthrough):
  sub-frame ON  -> 5023/5157 us/frame ~= DISABLE 4979/5000   (does NOT split)
  sub-frame OFF -> 2401/2352 us/frame ~= TWO_FORCED 2319/2378 (DOES split)

So AUTO is CONDITIONAL, not dead. Retiring it would have silently cost every
sub-frame-off session its second engine -- a regression introduced while
"cleaning up" an arm that looked inert. Split and sub-frame are mutually
unsupported for HEVC, so the driver resolves AUTO to no-split only in that
combination.

Fix is disclosure, not removal:
- resolve_split_subframe debug-logs the inert HEVC + AUTO + sub-frame case, which
  is the fleet default shape: "split_mode=AUTO" has meant "no split" for every
  default session and nothing said so. Deliberately NOT rewritten to DISABLE --
  the mode we pass is what the driver was actually given, and the ceiling-cache
  key must keep describing that.
- New unit test `auto_survives_the_arbitration_in_both_subframe_states` pins the
  contract so the arm cannot be simplified away later.
- The resolver doc now records both measured legs instead of "AUTO is dead".

Also in this change:
- WP1.6: `resolve_subframe`'s doc said "Windows passes `false`". Stale since the
  2026-07-31 .173 A/B flipped Windows to caps-gated default-on. It mattered:
  it made the AUTO-plus-sub-frame dead combination look Linux-only when it is
  fleet-wide.
- Windows session-ready log parity: split_mode + engines + subframe. The Windows
  line had no split_mode at all, so a Windows field report could not answer "did
  this session actually split?" -- the question that started this whole thread.

Verified: fmt clean; .21 clippy -p pf-encode --features nvenc --all-targets
-D warnings clean, 58 unit tests (1 new), 22/22 NVENC on-hardware tests green;
.133 Windows clippy --features nvenc --all-targets -D warnings clean (15m cold,
zero errors or warnings) -- the Windows backend is cfg'd out on both macOS and
the Linux box, so that leg needed a real Windows host.
2026-08-06 22:25:10 +02:00
enricobuehler a404830456 feat(client): wire AV1 into the native Vulkan rung, pin-only
The third codec arm in video_vk_native, AV1 admitted to native_codec and to
native_vulkan_gate by pin only. It stays out of `auto` on the same rule M5's
D3D11VA rung follows: `auto` admission is earned with hardware evidence, and
this has decoded nothing on a device.

is_integrity_warning_av1 did not exist, so the client could not have
concealed AV1 damage at all. Added, exhaustive, no wildcard: all three AV1
warnings really are damage, because AV1 has no spec-legal-but-noisy signal
to mis-classify — no reorder envelope to announce, no MMCO to rebase — and
the exhaustive match is what stops a future variant defaulting to clean.

The blocking defect review found was two safety mechanisms cancelling each
other. After a failure the decoder skipped to the next key frame answering
Ok(None), and because AV1's planner has no flush its store kept planning
cleanly, so those AUs carried no warnings and the client read them as proof
the rung works — clearing the demotion streak and resetting its clock on
every one. The streak could then never reach the threshold, which made the
never-delivered fall-through to FFmpeg-Vulkan unreachable, which is the
documented backstop for exactly three things: a level above maxLevelIdc, a
sequence header disagreeing with the Welcome, and film grain. Film grain is
the probe's own admitted assumption, so a grain stream would have frozen the
screen for the session while DecodeHealth reported run 0 — recovered.

AV1 now answers the wait with an error, as H.264 and H.265 already do
through AwaitingIdr, so all three codecs are indistinguishable to the
demotion machinery. That matters more than the extra precision of a third
state: only the H.26x paths have hardware evidence, and they are proven WITH
that behaviour.

The obvious form of that fix would have wedged the decoder. A key frame can
sit behind a skipped frame inside the same temporal unit — the vendored
vector has 24 two-frame units — so erroring out of the per-plan loop would
never reach it and the wait would never end. Skips are therefore counted per
frame and the error raised only when the whole unit was skipped, with the
metadata-only unit staying a clean Ok(None).

Also closed: a refused temporal unit left an already-decoded frame in the
ready queue, which shipped on the next AU as a clean success — putting a
picture from a refused AU on screen, clearing the streak again, and latching
delivered so the fall-through was disabled for good. The error arm now
drains and releases unshown.

MAX_DELIVERABLE is derived rather than picked: HOLD_HEADROOM minus the
pipeline's own hold, pinned to pf-vkdecode's constant so a hardcoded depth
fails the build. At the previous 8 the queue plus the presenter's 4-7 stood
against a headroom of 8, so it capped memory without preventing the
exhaustion it named, and a frame waiting 8 AUs burned 16 of the 17 query
slots — where a re-armed slot reads as Failed and becomes a fabricated
driver-corruption verdict in the very counter the Ally X signal lives in.
The trim now runs after this AU's frame is taken, or at the derived depth it
would drop a two-output unit's first frame and invert display order inside
one AU.

Its justification was also wrong: the claim that a temporal unit may carry a
show_existing_frame alongside a shown frame is disproved by this repo's own
golden — 250 units, 250 shown, zero show_existing. The bound is kept as
defence in depth against a non-conformant or multi-operating-point stream,
and now says so.

Gates: macOS fmt/clippy/392 tests, container clippy -D warnings over six
crates, 851 tests, workspace check. No hardware: the rung is pin-only and
has still never decoded a frame on a device.
2026-08-06 22:19:22 +02:00
enricobuehler 88f29a9411 feat(pf-encode): use every NVENC engine the GPU has, not a hard-coded two
WP1.1 plus the engine-count fix. `resolve_split_mode` forced TWO_FORCED at high
pixel rate regardless of hardware, so a 3-NVENC part (GB202, AD102 workstation)
left a third of its encode silicon idle, and a 1-NVENC part paid a wasted session
open to discover it could not split.

Probes NV_ENC_CAPS_NUM_ENCODER_ENGINES in both direct-SDK backends' query_caps
(the cap is `= 49` in both linux_sys and windows_sys of the vendored SDK 0.4.0 --
the caps enum is cfg-selected per-OS, so that was checked) and latches it on a
backend field. NOT on EncoderCaps: nine backends construct that struct as
exhaustive literals, so a new field would be a 9-site change of which 7 are
unrelated codecs passing a meaningless value, and the only consumer is the
resolver.

New `max_forced_split_mode(engines)`: 1 -> DISABLE, 2 -> TWO, 3 -> THREE, and
>3 -> AUTO_FORCED, because NV_ENC_SPLIT_ENCODE_MODE cannot NAME more than three
(NVENCAPI 12.1; values 4..14 are unallocated, so a future API may extend it) and
AUTO_FORCED = "split, driver picks how many" is measurably a real split (2.01x vs
disabled on .21). 0 = unprobed keeps the historical two-engine assumption.

⚠ WHY THE CLAMP EXISTS, measured on .21 (RTX 5070 Ti, 2 NVENC, 4K HEVC):
requesting THREE_FORCED was HONOURED -- session opened in mode 3 -- and ran at
2303 us/frame, identical to TWO_FORCED's 2308. The driver does not reject an
over-ask; it silently encodes narrower. So the rejection fallback cannot find the
ceiling and PUNKTFUNK_SPLIT_ENCODE=3 on a 2-engine card would have logged a
3-way split over a 2-way encode. Operator overrides are now clamped with a warn.
The ordering trap is covered by a test: on a >3-engine part hw_max is
AUTO_FORCED (1), which is not "narrower than" TWO_FORCED (2) despite comparing
smaller, so a naive min() would collapse a legitimate 3-way request to AUTO.

Also adds `engines` and `subframe` to the Linux session-ready log: split_mode
alone is ambiguous between "used both engines" and "left a third idle", and
since the driver honours an over-wide request the mode cannot be read without
the ceiling it was chosen from. This is the line a field report needs.

--- and a correction to S1b, in the same change ---

Re-running S1b afterwards flipped its verdict to "the driver appears to have
IGNORED the in-place split change", contradicting the isolated runs that produced
the |C-B|=34 figure already written into the design docs. Investigated rather
than re-rolled.

The switched leg was landing MIDWAY between the arms (~3600 us against A~5050,
B~2300) and the nearest-neighbour verdict flipped on noise. Cause: split-encode
does not reach steady state on the first frame -- a FRESH TWO_FORCED session
shows it too (early-half 3280 us vs late-half 1996 in one run), so it is split
warmup generally, not something specific to reconfiguring in place. A single
median over the whole window cannot see that. The test now reports early-half vs
late-half and gives a switched leg SETTLE=16 frames before its window opens,
every leg the same length. With that, 4/4 runs agree: the switched leg reaches
~2030 us against a fresh-split ~2000 and a single-engine ~4900.

⚠ S1b's CONCLUSION stands (the switch does take effect) but the evidence behind
the committed number did not reproduce; the docs are corrected rather than left
implying a cleaner result than the harness could support.
⚠⚠ This is a WP3 REQUIREMENT, not just a test fix: a live-session arbitration
that switches arms and immediately measures will misjudge the arm it just chose,
because the encoder needs ~16 frames to settle. The settle window has to be part
of the arbitration, and it is now a measured number rather than a guess.

Verified on .21: clippy --features nvenc --all-targets -D warnings clean,
57 unit tests (3 new), all 23 NVENC on-hardware tests green, fmt clean. The 3
failing on-hw tests in a full --ignored run are VAAPI (no AMD/Intel GPU on that
box -- their own ignore reason says so), pre-existing and unrelated.
2026-08-06 21:54:00 +02:00
enricobuehler 50fa228bab Merge pull request 'feat(client/android): copy a host's punktfunk:// link' (#79) from worktree-android-copy-link into main
ci / web (push) Failing after 28s
ci / bun-nix (push) Successful in 23s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 13s
ci / docs-site (push) Successful in 1m15s
docker / builders-arm64cross (push) Skipped
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m13s
android / android (push) Successful in 5m17s
ci / rust-arm64 (push) Successful in 6m17s
ci / rust (push) Failing after 6m15s
docker / deploy-docs (push) Successful in 6m29s
Reviewed-on: #79
2026-08-06 19:32:31 +00:00
enricobuehler 70b81ac3d7 test(pf-encode): S1c + the D5 confirm — pair flips in place, AUTO really is dead
S1c `nvenc_cuda_split_subframe_pair_reconfigure`: the leg S1a/S1b excluded. Both
pinned sub-frame OFF to isolate the split variable, but a real HEVC arbitration
cannot -- split and sub-frame are mutually unsupported there, so engaging split
means flipping enableSubFrameWrite in the same breath, a second init param and
the one the reconfigure path deliberately pins. RESULT on .21: the PAIR moves in
place, accepted, ZERO IDRs, both directions.

It also pins the invariant that makes this safe to build on: `subframe_chunks` is
latched ONLY in the init path (~line 1625) and is NOT recomputed by
reconfigure_bitrate, so a caller flipping sub-frame in place must clear it too or
supports_chunked_poll keeps reporting true and poll_chunk busy-polls its whole
budget every AU against a numSlices that never advances. The test performs the
correct sequence and asserts the state stays coherent, so WP3 has a worked
example rather than a warning.

`nvenc_cuda_auto_split_with_subframe`: the D5 confirm -- the one claim in the
design's defect list that was only ever inferred. The driver reports no "mode I
actually chose", so it is settled by timing, at 4K where the gap is ~2x.
RESULT: AUTO (env unset) + sub-frame 4904 us/frame, DISABLE + sub-frame 5062,
TWO_FORCED without sub-frame 3464. AUTO sits 158 us from DISABLE and 1440 from
TWO_FORCED ⇒ D5 CONFIRMED: plain AUTO does not split while sub-frame is on, so
the resolver's AUTO fallthrough reads as "let the driver decide" and means
"never split".

⚠ TRAP, hit on this test's first run and now documented in it: the env knob
CANNOT express plain AUTO. `0` is DISABLE and `1` is AUTO_FORCED, and
resolve_split_subframe counts AUTO_FORCED as forced, so passing `1` silently
disarms sub-frame and measures a different configuration entirely -- which
produced a spurious "D5 REFUTED". Plain AUTO is only reachable as the resolver's
fallthrough with the env unset. The leg now asserts sub-frame resolved TRUE, so
the test can no longer answer the wrong question quietly.

Verified on .21: clippy --features nvenc --all-targets -D warnings clean, all 4
spikes green, the normal 54-test suite unaffected, cargo fmt --all --check clean.
2026-08-06 21:27:18 +02:00
enricobuehler f33e442b43 feat(client/android): copy a host's punktfunk:// link
ci / bun-nix (pull_request) Successful in 25s
ci / web (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 1m41s
android / android (pull_request) Successful in 3m22s
ci / rust (pull_request) Failing after 7m33s
Android was the one app that could open a punktfunk:// link but never hand
one out, so every Android link had to be typed by hand — and the host's
stable record id, which is the part that keeps a link working after the box
changes address, isn't shown anywhere in the UI to type.

Both homes now offer Copy link: the touch grid's card overflow menu, and the
controller home's host options (Up on a tile). A pinned card copies its own
profile with it, matching Linux and Apple; a host card copies none and so
keeps honouring the host's binding, exactly like tapping it does.

The URL is the shared self-emitted form (DeepLinks.forHost), already covered
by the cross-language vector tests, so the three emitters stay in step.

Android 13+ draws its own clipboard confirmation and we add nothing on top of
it; below that we say so ourselves, as a toast in the console home, which
renders neither banner.
2026-08-06 21:20:36 +02:00
enricobuehler cab3aa1726 feat(vkdecode): M7's Vulkan AV1 rung — GPU half, and the review that saved it
caps_av1 / session_av1 / decoder_av1, over the CPU half already committed,
sharing the picture pool, bitstream ring, op ring, DPB settling and frame
delivery with H.264 and H.265 rather than forking them. AV1 session
parameters carry exactly one sequence header — no PPS, no VPS — so the
parameters ledger is two-state: current, or recreate.

The GPU plumbing came through review clean. The damage was all in the
conversion committed two rounds ago, which nothing tested against a
reference, and none of it would have failed a gate: clippy was clean, the
tests were green, and the rung would have decoded its own conformance vector
wrong on essentially every frame on AMD, silently.

Four blocking defects, each measured on the vendored vector rather than
argued:

Nine StdVideoDecodeAV1PictureInfo flags were never set. Four change
reconstruction — allow_screen_content_tools on 274 frames of 274,
allow_warped_motion on 273, is_filter_switchable on 172, force_integer_mv on
1 — and RADV reads three of them directly. The block already set
allow_intrabc, which is only codeable when screen-content tools are on, so
it contradicted itself.

LoopRestorationSize sent the pixel size where the field is log2(size) - 5.
cros-codecs stores 64/128/256; RADV names its destination
log2_restoration_size_minus5 and reads 1/2/3. Nothing truncates, nothing
errors, and every frame with loop restoration reconstructs against a
nonsense unit size.

Per-reference Std info answered questions about the wrong picture: every
reference carried the CURRENT frame's type, and RefFrameSignBias was never
set at all. Sign bias is what tells a decoder a reference lies in the
future, and this vector is the hidden-ALTREF one, so all-zero meant every
reference was treated as past. Fixed at the source: pf-bitstream now records
a RefState when a picture is stored — its own frame type, sign-bias mask,
saved order hints — and carries it on the slot, so all three backends get
answers about the reference rather than about the frame reading it.

Film grain's six chroma-scaling fields were zero, which defeats the profile
machinery that exists to refuse devices unable to synthesise grain.

The reference-name compaction is fixed in the PLANNER, once. AuPlan::refs is
now name-indexed with holes preserved, so a lost reference can no longer
renumber every later AV1 reference name — a class that was live in both
conversions and armed for the VAAPI rung that does not exist yet. The DXVA
twin had a second name-versus-slot confusion: it read global motion by DPB
slot from an array the spec indexes by reference name, and slot 0's matrix
is all-zero rather than identity, so 273 references were given a zero warp.

Also closed: pTileOffsets/pTileSizes were sized to tileCount while RADV
reads AV1_MAX_NUM_TILES entries unconditionally — a 4-byte allocation read a
kilobyte deep — now fixed 256-entry arrays with zeroed tails. And the test
guarding the lost-reference refusal re-implemented the predicate inline, so
deleting the guard left it green; both now call one named function.

The bitstream layout now matches libavcodec: raw tile payloads only,
frameHeaderOffset 0. The review established the spec-literal layout was NOT
wrong — AV1 has no start-code scanning, so the 3-versus-4-byte and
slices-only scars do not transfer, and no driver in the fleet reads
frameHeaderOffset — but matching the validated reference deletes code,
uploads 5835 fewer bytes over the vector, and removes the untested-driver
tail.

Upstream, and the third of its kind: the vendored parser writes
ref_frame_sign_bias[i] in the same loop body where it writes
order_hints[LAST_FRAME + i], so its array is shifted one down and index 7 is
never written. Corrected in RefState::of with the shift documented, the
vendored tree untouched, and pinned by a test that recomputes the bias from
order_hints through the parser's own get_relative_dist.

Gates: macOS fmt/clippy/tests, container clippy -D warnings over six crates,
845 tests, workspace check. No hardware: nothing here has reached a driver.
2026-08-06 21:15:39 +02:00
enricobuehler 4b57d11dd8 test(pf-encode): S1 spike — splitEncodeMode CAN change in place, no IDR
Two on-hardware spikes answering the gate on the split-encode engagement
program (design/nvenc-split-encode-engagement-implementation-plan.md).

S1a `nvenc_cuda_split_reconfigure_in_place`: can splitEncodeMode change via
nvEncReconfigureEncoder with resetEncoder=0, without an IDR? Our "reconfigure
must present the SAME init params as the open" rule (windows/nvenc.rs:620) is
our own invariant and had never been tested against a driver. It reports rather
than asserts the verdict -- both outcomes are legitimate findings -- and only
asserts what would invalidate the measurement (session live, engines >= 2, the
arms actually differ). Sub-frame is pinned off so the driver can't reject for
the wrong reason (HEVC forced-split and sub-frame are mutually unsupported).

S1b `nvenc_cuda_split_reconfigure_takes_effect`: the other half -- a driver that
accepts the parameter and quietly ignores it looks identical to one that honours
it. Three legs at 4K (fresh DISABLE / fresh TWO_FORCED / DISABLE->TWO in place);
if C tracks B and not A, the switch is real.

RESULT on .21 (RTX 5070 Ti, GB203 Blackwell, driver 610.57.04):
  NV_ENC_CAPS_NUM_ENCODER_ENGINES = 2
  S1a: accepted, ZERO IDRs, both directions.
  S1b: A fresh DISABLE 5054 us/frame, B fresh TWO_FORCED 2453,
       C switched in place 2419 -- |C-B|=34 vs |C-A|=2635. It takes effect,
       and split is a clean ~2x at 4K.

Two limits, both recorded in the test docs rather than the commit only. The
frames come out at 427 B/AU against an 833 KB CBR quota: the driver hands back
zeroed VRAM, so the rotated buffers are identical and rate control skip-codes
everything. So this measures the PIXEL-proportional half of the cost only --
the bits/frame regime the field case lives in is untested here, and the test
prints an explicit INCONCLUSIVE-on-content line when it detects that. And this
is Blackwell 8-bit; the Ada Main10 question is untouched.

Verified on .21: clippy -p pf-encode --features nvenc --all-targets -D warnings
clean, both spikes green, cargo fmt --all --check clean.
2026-08-06 21:11:52 +02:00
enricobuehler 442558e503 Merge pull request 'fix(plugin-kit): the SQLite reader never opened anything on Linux' (#78) from worktree-plugin-kit-sqlite-uri-fix into main
ci / bun-nix (push) Successful in 25s
ci / rust-arm64 (push) Successful in 1m19s
ci / docs-site (push) Successful in 1m25s
ci / web (push) Successful in 1m36s
plugin-kit-publish / publish (push) Successful in 33s
ci / rust (push) Successful in 8m0s
nix / flake (push) Successful in 11m19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 26s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
Reviewed-on: #78
2026-08-06 18:24:03 +00:00
enricobuehler 4bcb3794c5 fix(plugin-kit): the SQLite reader never opened anything on Linux
nix / flake (pull_request) Failing after 1s
ci / bun-nix (pull_request) Successful in 22s
ci / web (pull_request) Successful in 58s
ci / docs-site (pull_request) Successful in 1m35s
ci / rust-arm64 (pull_request) Successful in 2m2s
ci / rust (pull_request) Successful in 6m15s
Found while running the lutris plugin's own release gate against a live host on
.21: `parity --compare` reported `1 missing` — the plugin produced NO entry for
the one game Lutris had, while the host's built-in scanner produced it fine.
`scan` agreed: "present: 0 games". `detect` still said "present", because detect
only stats the file.

`openReadOnly` builds a `file:<path>?immutable=1` URI — the right idea, since
`immutable=1` is what makes this a pure read that cannot lock a running
launcher's database or spawn WAL sidecars next to it. But it opened that name
with `{ readonly: true }`, and the options object does NOT enable SQLite's URI
filename parsing. Without SQLITE_OPEN_URI the name is taken literally, no such
file exists, and the open throws:

    SQLiteError: unable to open database file

Every path here then degrades that to silence by design: `openReadOnly` returns
`undefined` for "this launcher isn't installed", `withReadOnlyDb` passes the
`undefined` through, and callers write `withReadOnlyDb(...) ?? []`. So a total,
permanent failure to read ANY database was indistinguishable from an empty
library. The lutris plugin reported 0 games on every Linux box, always.

It is platform-split, which is why it survived review and local runs: macOS
links Apple's system SQLite, which is built with URI filenames enabled, so the
same call succeeds there. Linux uses Bun's bundled SQLite, which is not. MEASURED
both ways on bun 1.3.14.

Fixed by passing the flags explicitly — SQLITE_OPEN_READONLY | SQLITE_OPEN_URI.
(`{ readonly: true, uri: true }` is not a supported option shape; also measured.)

And the second half, which is why nothing caught it: `parity --compare` sets
`process.exitCode = 1` on a mismatch and then returns NORMALLY — a red parity is
a finished comparison, not a crashed command. `runPluginCli` then assigned
`process.exitCode = 0` unconditionally after the effect resolved, overwriting it.
So the one verb both plugin READMEs document as the release gate — "exits
non-zero on any difference", "do not publish a version whose parity run is red" —
always exited 0, and any scripted use of it passed. Now `??= 0`, so a code a
command set deliberately survives.

Tests: the sqlite helper had NO coverage at all, which is the whole reason a
total failure shipped looking like an empty library. Added five cases against a
REAL database file — reads rows back, handles a path needing URI escaping,
withReadOnlyDb round-trips, an absent file is `undefined` not a throw, and a bad
query still degrades to []. Verified they actually catch it: against the shipped
code on Linux, 4 of the 5 fail; with the fix, 21/21 in that file and 61/61 across
the suite, typecheck and build clean.

End to end on .21 with the fix: lutris `scan` goes 0 -> 1 games and
`parity --compare` reports "parity OK — 1 entries identical". A deliberately
doctored baseline now exits 1 instead of 0.

0.3.0 -> 0.3.1.
2026-08-06 20:14:04 +02:00
enricobuehler 214e846984 Merge pull request 'fix(ci/nix): the flake job could never start its container' (#77) from worktree-nix-flake-container-fix into main
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 13s
ci / bun-nix (push) Successful in 24s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
ci / web (push) Successful in 1m3s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 43s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 47s
ci / rust-arm64 (push) Successful in 1m23s
docker / builders-arm64cross (push) Skipped
ci / docs-site (push) Successful in 1m24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
docker / deploy-docs (push) Successful in 6m30s
ci / rust (push) Successful in 8m11s
nix / flake (push) Successful in 11m42s
Reviewed-on: #77
2026-08-06 17:45:35 +00:00
enricobuehler e78906fb06 fix(ci/nix): the flake job could never start its container
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m11s
ci / rust-arm64 (pull_request) Successful in 1m18s
ci / bun-nix (pull_request) Successful in 16s
ci / rust (pull_request) Failing after 6m12s
nix / flake (pull_request) Failing after 4m54s
The nix gate landed in fb707b49 on `nixos/nix:latest`, and it has never run a
single step. That image carries nix and essentially nothing else — including no
`/bin/sleep` — and Gitea's act_runner starts every job container with
`entrypoint=["/bin/sleep", "10800"]`:

  failed to create shim task: OCI runtime create failed: runc create failed:
  unable to start container process: exec: "/bin/sleep":
  stat /bin/sleep: no such file or directory

The expensive part is how it reports: with the container dead, every step is
marked `cancelled` rather than `failed`, which reads exactly like a run that was
superseded by a newer push. Run 15907 looked skipped, not broken.

Switch to `node:22-bookworm` and install Nix in a step:

  * full Debian, so the entrypoint exists and coreutils are present;
  * a real node, so `actions/checkout` works with no pre-checkout install dance
    (the reason flatpak.yml's fedora job installs node before its checkout);
  * audit.yml already pulls this image on this fleet, so it is known to resolve.

Nix comes from the Determinate installer with `--init none` — the container mode:
no systemd, no daemon. That distribution is also what the hand-verification Nix
box (.21) runs, so CI and it stay on the same Nix.

MEASURED in a real `node:22-bookworm` container rather than assumed, since the
last version of this file shipped on an untested assumption and cost a red run:

  * `/bin/sleep` present — the entrypoint failure is gone;
  * the installer completes and `nix --version` runs from the absolute path;
  * `nix build` of a trivial derivation SUCCEEDS, `nix store ping` reports
    `Store URL: local, Trusted: 1`, and flakes are enabled.

That last one is the trap this change also pins. `--init none` runs no daemon,
but the installer still writes a profile script exporting `NIX_REMOTE=daemon`;
anything sourcing it (any `-l` login shell) then dies on "cannot connect to
socket at '/nix/var/nix/daemon-socket/socket'". It is why the installer's own
self-test fails, harmlessly, in the middle of this step's log. The steps here
never source that profile — they invoke `$NIX` by absolute path — but `NIX_REMOTE`
is now pinned empty at job level so a later step cannot reintroduce it.

Also records `df -h` before the build: this fleet ran a runner out of disk today
(ci.yml's `web` job died with "no space left on device" mid-`bun install`), a Nix
build is the heaviest thing that would run here, and a future failure should be
attributable at a glance rather than guessed.

Still unproven, and only the first green run can settle it: whether `nix flake
check` evaluates this flake cleanly under CI, and whether the runner has the disk
to build punktfunk-web. Both now produce a real diagnostic instead of a container
that never started.
2026-08-06 19:31:16 +02:00
enricobuehler eecd04247f feat(client): AV1 is advertised on hardware, not on a decoder existing
The standing open item M7 was meant to close. `decodable_codecs` answered the
AV1 bit from `ffmpeg::decoder::find(AV1)`, which says yes on every build that
links libdav1d — a software decoder. So the client told the host "send me
AV1" on machines that would then decode a 4K stream on the CPU, and codec
negotiation happens once at Welcome, so there is nothing to fall back to
afterwards. A promise the client cannot keep is worse than not making it.

`av1_hardware_decodable` answers from device facts only: the presenter's
Vulkan device listing DECODE_AV1 among its decode queue family's codec
operations, or — on Windows — the D3D11 import path, which is the same gate
the D3D11VA rung sits behind and that rung decodes AV1 Profile 0 today.

VAAPI is deliberately not consulted: asking libva costs opening a display,
and this is called too early and too often for that. The Vulkan bit covers
the Mesa devices where VAAPI AV1 exists in practice, and a machine with
VAAPI AV1 but no Vulkan AV1 loses the advertisement, not a working path.

The test pins what the gate must not accept: a device that decodes H.264 and
H.265 but lists no AV1 operation, and a device whose caps word claims AV1
while it has no decode queue at all.

Gates: macOS fmt/clippy, container clippy -D warnings over six crates, 805
tests, workspace check.
2026-08-06 19:27:24 +02:00
enricobuehler d167e90ea1 Merge pull request 'A launcher tile's session stops depending on invisible state' (#76) from worktree-launcher-lease into main
arch / build-publish (push) Failing after 16s
ci / bun-nix (push) Successful in 42s
ci / web (push) Successful in 1m11s
ci / docs-site (push) Successful in 1m24s
apple / swift (push) Successful in 1m37s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
deb / build-publish-client-arm64 (push) Successful in 2m28s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 54s
android / android (push) Failing after 3m51s
ci / rust (push) Failing after 3m48s
docker / builders-arm64cross (push) Failing after 19s
deb / build-publish (push) Successful in 3m54s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m9s
ci / rust-arm64 (push) Successful in 5m32s
docker / deploy-docs (push) Successful in 32s
apple / screenshots (push) Successful in 5m58s
deb / build-publish-host (push) Successful in 7m57s
windows-host / package (push) Failing after 11m43s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 8m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m49s
Reviewed-on: #76
2026-08-06 17:25:44 +00:00
enricobuehler b7cd023643 feat(dxvadec): M7's DXVA AV1 conversion — a fourth way to name a reference
One AuPlan into DXVA_PicParams_AV1, over the layouts the SDK header measured.

AV1 on DXVA needs TWO reference arrays that mean different things at once,
and this program has now written down four spellings of the same question.
`frame_refs[7]` is indexed by reference NAME and each entry carries a
SURFACE index — where Vulkan's `referenceNameSlotIndices` carries a SLOT —
plus that reference's own global motion. `RefFrameMapTextureIndex[8]` is
indexed by SLOT and states the whole reference store, which is what
`RefFrameList` is for the other codecs and why a long-term reference no
frame names still has to appear in it.

The test asserts that difference is exercised rather than assumed: it fails
if the run never saw the store hold a picture the frame did not name, which
is precisely the distinction the Ally X class of bug lives in.

Three transpositions that would each have been silent:

Global motion is signalled per reference SLOT in the frame header and stored
per reference NAME in DXVA, so the conversion reads by one and writes by the
other. Carrying the Vulkan shape across would leave every warped reference
at identity.

CDEF strengths pack two fields to a byte, primary in the low six bits and
secondary in the top two, where the AV1 syntax keeps parallel arrays.

DXVA wants log2 of the loop-restoration unit size; the parser records the
size. And the superres denominator is the real one here — SUPERRES_NUM when
superres is off — where Vulkan's `coded_denom` is the denominator less nine.

Film grain rides only where the sequence enables it and the frame applies
it, its scaling points transposed into [value, scaling] pairs, and an
over-count refused rather than truncated: fewer points than the stream
declared is different grain, not less of it.

Gates: macOS fmt/clippy/349 tests, container clippy -D warnings over six
crates, 804 tests, workspace check.
2026-08-06 19:23:42 +02:00
enricobuehler e9da37aaf6 feat(host/gamelease): a launcher tile's session stops depending on invisible state
ci / web (pull_request) Failing after 2s
ci / rust-arm64 (pull_request) Failing after 3s
ci / bun-nix (pull_request) Successful in 1m15s
apple / swift (pull_request) Successful in 1m35s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m16s
android / android (pull_request) Successful in 4m56s
ci / rust (pull_request) Successful in 9m1s
A launcher entry (design D4) has no "the game exited" moment to detect, but the lease was
still deciding its lifetime from whatever happened to be true at launch — and the two
outcomes disagree:

  launcher NOT already running   the spawned child stays alive -> `Child` lease
                                 -> quitting the launcher ends the session
  launcher ALREADY running       the command forwards to the live instance and exits
                                 inside SHIM_WINDOW, with no detect signals behind it
                                 -> `Untracked` -> the session persists

Same tile, two lifetimes, chosen by something the user cannot see. Steam is the case that
settles which one is right: Big Picture is a *mode* of an already-running Steam client, not
a process — and on a Deck or SteamOS host Steam is always running — so no process signal can
express "the launcher's window closed". Heroic has the same shape for a different reason
(single-instance Electron: a second invocation forwards and exits).

So a launcher entry is now `LeaseKind::Untracked` unconditionally. The check sits AHEAD of
`nested`/`child`/`spec`, and that ordering is the fix rather than an implementation detail:
a launcher the host just started leaves a live child behind, and tracking that child is
precisely the inconsistency being removed.

`Untracked` already meant the right things downstream, so nothing else had to change: no
watcher thread, `terminate()` is a no-op ("nothing to end" — closing a session must not kill
the user's Steam), and no `GameExited` event, so a client does not bounce back to its library
when the launcher closes.

Threaded as `LaunchTarget::launcher` -> `LeaseRequest::launcher` from the entry's `role`.
Three call sites, one of which was the actual trap: the GameStream path does not build its
lease from a `LaunchTarget` at all, it goes through a `GsApp` intermediate that silently
dropped the field. An operator-typed `apps.json` command has no library entry behind it and
is never a launcher tile, so it passes `false` explicitly.

The test pins BOTH cases from the table above, plus the same request without the flag still
being `Matched` — so the assertions are the flag's doing and not an artifact of the fixture.

Gates: punktfunk-host 439 passed / 0 failed on .21 (+1).

Not covered here: nothing publishes launcher tiles until the plugins are released, so there
was no live exposure to fix — this is the semantics being made deliberate before the first
tile is ever clicked.
2026-08-06 18:35:09 +02:00
enricobuehler 5d1556132e feat(dxvadec): M7's DXVA AV1 layouts, measured against Microsoft's own header
The blocker on this rung was never the code — it was that `dxva.rs` is the
most safety-critical file in the backend and nothing in it is type-checked
against Windows, so M5's layouts only became trustworthy once a libavcodec
byte capture had verified them field by field. AV1 turns out not to need
that capture: `DXVA_PicParams_AV1` ships in the Windows SDK's OWN `dxva.h`
(10.0.26100.0 and 10.0.28000.0 on .173), which is the declaration the driver
was compiled against and therefore outranks any mirror.

So `layout-probe-av1.c` is committed beside pf-vaadec's probe, compiled with
MSVC against that header on .173, and every number below came out of it:
DXVA_PicParams_AV1 is 912 bytes with alignment 1, PicEntry 36, Tile 16, and
each offset is a compile-time assertion. The nested blocks are asserted
through their own types too, so a wrong internal layout cannot hide behind a
right outer one. Every assertion passed on the first build, which is the
result worth having: the transcription and the compiler agree.

The bit-field words are measured, not assumed. C bit-field allocation order
is ABI-defined rather than standardised, so the probe sets one member at a
time and prints the word, and the tests check each packer against what MSVC
produced — tx_mode at bits 22-23, reference_frame_update at 26, film grain's
sixteen-bit word with matrix_coeff_is_identity at 12, and so on.

Two places AV1 puts things where the other two codecs would not, both now
written down where a conversion will read them:

Global motion is per REFERENCE, inside DXVA_PicEntry_AV1 — where Vulkan
hangs one global-motion block off the picture info. A conversion carrying
the Vulkan shape across would leave every warped reference at identity.

CDEF strengths are packed two-to-a-byte, primary in the low six bits and
secondary in the top two, where both the AV1 syntax and Vulkan's Std block
keep parallel arrays.

A zeroed block names NO reference: 0 is a valid surface index, so a
memset-style default would quietly point every unused reference at surface
0, which decodes, and decodes wrong.

Gates: macOS fmt/clippy/347 tests, container clippy -D warnings over six
crates, 802 tests, workspace check.
2026-08-06 18:26:02 +02:00
enricobuehler 83cfabda89 feat(vkdecode): M7's Vulkan CPU half — AV1 into the Std structures
The sequence header and the picture info, converted for
VK_KHR_video_decode_av1. Same shape as the H.264 and H.265 conversions, and
the same ownership contract: boxed backing beside the Std struct that points
at it, movable wrapper, no mutation, not Clone.

AV1 puts almost the whole frame header in the PICTURE info rather than in a
parameter set, so StdVideoDecodeAV1PictureInfo carries eight pointers to
per-frame blocks — tile info, quantisation, segmentation, loop filter, CDEF,
loop restoration, global motion, film grain — and the tile info carries four
more arrays of its own. Session parameters, by contrast, hold exactly one
sequence header. That asymmetry is why params_av1 is the small module here
and pic_av1 the large one.

The plan now carries the parsed frame header whole. The client needs a
digest — size, depth, colour, keyframe — but a backend needs nearly all of
the header, so AuPlan carries it the way its H.264 and H.265 siblings carry
their activated parameter sets: a backend builds from exactly what was
parsed, never by re-reading the access unit.

referenceNameSlotIndices holds DPB SLOT indices, not positions in the
reference list, and that is the HEVC RPS defect's exact shape in a narrower
place. Measured rather than argued: over the vendored vector the two
readings disagree 566 times across 274 frames, and the test fails if they
ever stop disagreeing, because then it would no longer be able to tell the
conventions apart.

Two places where transcription would have been wrong, both caught by the
types and then by asking the spec:

The parser's film-grain point arrays are 16 entries where the Std ones are
14 (luma) and 10 (chroma) — the spec's own maxima. The counts are validated
against the Std capacity and the copy is bounded by them; a stream declaring
more is refused, because a decoder handed fewer scaling points than the
stream declared synthesises different grain.

`coded_denom` is the superres denominator less SUPERRES_DENOM_MIN and only
meaningful where superres is in use, and `UsesLr` is derived — no frame
header codes it — from whether any plane's restoration type is not NONE.

Film grain rides only where the sequence enables it AND the frame applies
it, with the apply_grain flag set from whether a block is attached, so the
flag and the pointer cannot disagree.

Gates: macOS fmt/clippy/345 tests, container clippy -D warnings over six
crates, 800 tests, workspace check.
2026-08-06 18:00:59 +02:00
enricobuehler 7f83ec6c2f feat(bitstream): M7 begins — the AV1 planner, and it plans frames the stream hides
The third planner in this crate, and the foundation every AV1 rung will
consume. Same contract as its H.264 and H.265 siblings: an access unit in, a
plan out, with the vendored cros-codecs parser reading the bitstream and this
module owning the reference ledger, the output bookkeeping and the
concealment posture.

AV1's reference model is simpler than H.264's and entirely explicit — eight
numbered slots, `ref_frame_idx` naming what a frame reads and
`refresh_frame_flags` naming what it writes — so the planner is bookkeeping
rather than derivation, and a frame naming an empty slot is a lost reference
with no spec process that might legitimately have emptied it.

Two things measurement changed, both before a line of backend code depends on
them.

`plan_au` returns a VECTOR. An AV1 temporal unit may carry several frames,
and the vendored vector does: 250 units, 274 frames, 24 units carrying two.
Measured, those 24 extras are not `show_existing_frame` (there are none in
this vector) but HIDDEN frames — decoded, never displayed, referenced later.
A planner that took the last header in each unit would have decoded 250
frames and silently dropped 24 REFERENCES, and the damage would have
surfaced as missing-reference concealment on frames that were never damaged.

A picture is not removed until its LAST slot goes. One picture routinely
occupies several slots at once — a key frame refreshes all eight — so a slot
being overwritten does not mean its picture is gone. Reporting it removed
would free a surface under a live reference, which is precisely the shape
this program exists to catch. Tested directly, and asserted to report once
rather than once per slot.

What this does not cover is written down rather than left to be assumed: the
vector uses `show_existing_frame` zero times, so the display-only path and
its key-frame slot reset are exercised by no test here, and the test asserts
that count is zero so the day it changes the claim gets revisited.

Per-backend conversions are deliberately absent. Vulkan, DXVA and libva
disagree about what a reference list indexes — the disagreement that made
HEVC unplayable on every driver — so each belongs beside its siblings in
pf-vkdecode / pf-dxvadec / pf-vaadec, where its own convention is written
down and tested.

Gates: macOS fmt/clippy/344 tests, container clippy -D warnings over six
crates, 799 tests, workspace check.
2026-08-06 17:18:34 +02:00
enricobuehler a6e51215fd feat(client): M6's rung is wired — libva, dlopen'd, no libavcodec
The native VAAPI decoder now runs end to end: pf-vaadec's plans go into
libva's buffers, the surface comes back as DRM-PRIME dmabufs, and the
presenter imports them exactly as it does the FFmpeg rung's. Pin-only —
`PUNKTFUNK_DECODER=native-vaapi` — for the reason M5's D3D11VA rung was:
`auto` admission is earned with hardware parity and a soak, and this rung
has decoded nothing yet.

libva is dlopen'd rather than linked, so the pf-lxcheck2 container compiles
and clippies the whole thing without libva-dev, and a machine without a
VAAPI runtime gets a clean refusal instead of a packaging dependency.

The surface pool is not the slot map. `SlotMap::assign` hands out the lowest
free slot, and a slot freed by an access unit's own removals is free by the
time that unit's picture takes it — measured at 225 of the vendored vector's
250 access units. A surface bound by slot index would therefore decode, on
nine frames in ten, into the surface still holding the picture on screen. So
`plan_to_va` now takes the decode target as a parameter, bound by the caller
at activation time the way pf-vkdecode binds a pool image, and a surface is
free only when no live picture is bound to it, no output is owed for it, and
no consumer holds it.

Measured rather than transcribed, as everywhere else here: layout-probe.c
grew the export descriptor (312 bytes, objects[4]/layers[4]), the buffer-type
enumerators — VASliceParameterBufferType is 4 and VASliceDataBufferType is 5,
not the 3 and 4 that counting off the header suggests — and the config,
attribute and generic-value layouts. All pinned as compile-time assertions,
which is how the 12-byte VAGenericValue in the first draft was caught: the C
union holds a pointer, so it is 8-aligned and 16 bytes.

The plane walk lives in pf-vaadec, pure and unit-tested on macOS, because it
is the one structure the DRIVER writes and we read: SEPARATE_LAYERS returns
NV12 as two layers, and taking layers[0] is the green screen this project has
already paid for. It also refuses what it cannot express rather than guessing
— a bogus object count, a plane naming an object that is not there, objects
disagreeing on tiling.

Own DecodedImage variant, same payload type. The physical hand-off is
identical to the FFmpeg rung's, so the presenter keeps ONE arm and one
demotion streak; the variant exists so the compiler asks which rung decoded
wherever that matters. Both D3D11VA rungs share a variant and `1573a987` had
to fix the consequence afterwards — a "native" soak that could silently have
been an FFmpeg soak. Here the four uncovered matches were compile errors.

Buffers are destroyed by us, not by vaEndPicture: va.h is explicit that the
user must call vaDestroyBuffer, and the libva 0.x behaviour is long gone.
Leaking two per picture at 60 fps exhausts the driver's store in minutes.

pf-vaadec's presenter headroom was 4, written against no consumer. The Vulkan
rung had already measured the client pipeline at four to seven held frames;
it is 8 now, pinned to that crate's constant so a re-measurement moves both.

Gates: macOS fmt/clippy/341 tests/cargo doc, and in the container clippy
-D warnings over six crates, 795 tests, workspace check.

Hardware legs are still owed — no AMD/Mesa or Intel box was reachable.
2026-08-06 16:56:38 +02:00
enricobuehler 453b9850fa Merge pull request 'Gamepad UI: multi-tone palettes, and a UI that takes its colours from them' (#74) from worktree-gamepad-ui-polish into main
ci / bun-nix (push) Successful in 19s
ci / docs-site (push) Successful in 1m19s
apple / swift (push) Successful in 1m25s
deb / build-publish (push) Successful in 3m43s
ci / web (push) Successful in 5m20s
deb / build-publish-host (push) Successful in 4m10s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
ci / rust-arm64 (push) Successful in 6m3s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 9s
docker / builders-arm64cross (push) Skipped
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 24s
deb / build-publish-client-arm64 (push) Successful in 2m14s
arch / build-publish (push) Successful in 7m7s
docker / deploy-docs (push) Failing after 9s
android / android (push) Failing after 10m28s
ci / rust (push) Failing after 10m7s
release / apple (push) Successful in 9m49s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m52s
apple / screenshots (push) Successful in 5m55s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m42s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m53s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m13s
flatpak / build-publish (push) Successful in 13m21s
Reviewed-on: #74
2026-08-06 14:48:24 +00:00
enricobuehler eb4b33ca49 Merge pull request 'An xbox launch kind a de-privileged plugin can publish' (#75) from worktree-xbox-launch-kind into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (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
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Failing after 11m41s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
Reviewed-on: #75
2026-08-06 14:48:03 +00:00
enricobuehler 8e02f3a8e1 Merge pull request 'ci(nix): a stale bun.nix can no longer reach main unnoticed' (#73) from worktree-nix-bun2nix-drift-gate into main
apple / swift (push) Successful in 1m26s
ci / bun-nix (push) Successful in 1m20s
ci / rust-arm64 (push) Successful in 1m27s
android / android (push) Canceled after 1m44s
deb / build-publish-host (push) Failing after 3s
apple / screenshots (push) Canceled after 10s
arch / build-publish (push) Canceled after 1m47s
ci / rust (push) Canceled after 1m49s
ci / web (push) Canceled after 1m45s
ci / docs-site (push) Canceled after 1m45s
deb / build-publish (push) Canceled after 26s
deb / build-publish-client-arm64 (push) Canceled after 9s
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
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
nix / flake (push) Failing after 18s
decky / build-publish (push) Successful in 50s
Reviewed-on: #73
2026-08-06 14:46:25 +00:00
enricobuehler 4a9a1c3ed4 feat(clients/gamepad-ui): multi-tone palettes, and a UI that takes its colours from them
ci / docs-site (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 1m18s
apple / swift (pull_request) Successful in 1m25s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m59s
android / android (pull_request) Successful in 5m53s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 6m36s
ci / rust (pull_request) Successful in 9m57s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m41s
The first pass built each palette by rotating ONE colour field's hue, and it
showed: every option was a single tone at several brightnesses, which reads flat
next to any real gradient. A palette is now an ordered ramp of DISTINCT hues.
The 4×4 mesh samples that ramp along the diagonal with a fixed per-cell offset
table, so neighbouring cells land on different parts of it and the colours pool
and swirl instead of banding; the control points' existing drift then moves the
pools around. Violet keeps its explicit sixteen colours, so the default is
untouched.

Twelve of them now, dark first then pale: Violet, Nebula, Abyss, Ember, Moss,
Graphite, then Holo, Sunset, Bloom, Dawn, Mint, Opal. Holo and Sunset are
straight takes on the two reference gradients — foil and poster.

`every_palette_is_multi_tone` measures the hue spread across all sixteen cells
and fails under 45° (20° for Graphite and Opal, which are meant to be
restrained). It caught Ember at 35°, all reds and oranges — the very flatness
this rework exists to remove — and Graphite at 3° despite a comment claiming it
drifted cool to warm. Both were rebuilt until the numbers matched the prose.

The UI follows the palette now, rather than wearing brand violet over whatever
happens to be behind it. Each palette carries an accent and a light flag, and an
Ink derived from those (foreground, accent, on-accent, glass, scrim and its
strength) is published to the whole tree — a thread-local in the console, an
environment value on Apple, a CompositionLocal on Android. Pale palettes flip
the ink: dark text on white frost, with the materials, tray scrims and every
wash that sits under text following suit.

Three things only the renders could have told us:

  - Additive blending blows out over a pale ground. Android's blobs and Apple's
    legacy field composite with Plus/plusLighter, which over near-white
    saturates every blob to white — Holo rendered as a grey wash. Pale palettes
    blend normally.
  - A white scrim at the dark field's strength BLEACHES the gradient. Mixing
    toward black at 0.4 reads as depth; toward white at 0.4 destroys the chroma
    it is drawn over. The scrim now carries a per-palette strength.
  - White glass over a bright field has far less separating it from its backdrop
    than dark glass over a dark one, and needed more body.

Verified: console build + clippy -D warnings + 173 tests, Apple build + 200
tests + an iOS-triple typecheck, Android compile + 62 tests, and eyeball passes
on real renders of both the vivid and the pale ends (console CPU rasters; a new
Roborazzi light-palette scene, which is what exposed the blend-mode bug).
2026-08-06 15:55:26 +02:00
enricobuehler 166e158afe feat(host/library): an xbox launch kind a de-privileged plugin can publish
apple / swift (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m22s
android / android (pull_request) Successful in 3m9s
ci / docs-site (pull_request) Successful in 1m9s
ci / web (pull_request) Successful in 3m20s
ci / rust (pull_request) Failing after 9m9s
The xbox scanner resolves each package's PackageFamilyName by enumerating
%ProgramData%\Microsoft\Windows\AppRepository\Packages. Probed on .173 (design doc S1),
that directory is:

  NT AUTHORITY\LocalService   UnauthorizedAccessException   <- the plugin runner
  LocalSystem                 count=348                     <- the host service

So an extracted xbox plugin cannot build an AUMID, and `aumid` — which takes a complete
`<PFN>!<AppId>` — is unpublishable by one.

The design doc offered two fallbacks: entries without `aumid` (they would list but not
launch), or a one-shot elevated PFN resolve at install time. This is neither. The plugin
sends `<Identity>!<AppId>`, both read straight out of MicrosoftGame.config — which IS
readable de-privileged — and the host resolves the publisher hash at LAUNCH time, where it
already owns command construction.

That keeps D1 intact (the plugin supplies a validated value, the host builds the command),
it is the same shape as the `playnite` kind, and resolving late means a package update that
changes the publisher hash cannot leave a stale unlaunchable tile behind — which the
install-time cache would have.

`xbox_pfn` therefore moves from the scan path to the launch path rather than being deleted,
and its doc records the privilege asymmetry so the next reader doesn't re-derive it.

The charset guard is factored out (`aumid_part` / `valid_aumid`) and shared with the `aumid`
kind. On `aumid` it was belt-and-braces — every value was host-derived. On `xbox` it is
load-bearing: the Identity arrives over the wire from a plugin, and it is interpolated into
a `shell:AppsFolder\…` argument. Validated inbound at reconcile too, so a malformed value is
a 400 the plugin author can act on rather than a tile that fails on click.

Gates: punktfunk-host 438 passed / 0 failed on .21, and 55/0 for the library tests on .173
(where these arms actually compile). Both counts +1 for the new guard test.
2026-08-06 15:43:26 +02:00
enricobuehler fb707b4956 ci(nix): a stale bun.nix can no longer reach main unnoticed
ci / rust-arm64 (pull_request) Successful in 1m24s
apple / swift (pull_request) Successful in 1m32s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Failing after 2m19s
ci / docs-site (pull_request) Successful in 1m17s
android / android (pull_request) Successful in 3m10s
ci / bun-nix (pull_request) Successful in 1m6s
nix / flake (pull_request) Failing after 1m6s
ci / rust (pull_request) Failing after 6m2s
Moving the bun packages to bun2nix (1db8f763) removed the aggregate deps hash
that used to go stale, but not the second, quieter way a generated file rots.
bun2nix is a pure function of bun.lock, and `bun.nix` is committed alongside it
— but it regenerates ONLY from a local `bun install` that runs lifecycle
scripts. It does not regenerate under `bun install --ignore-scripts`, which is
what every bun install in CI uses, and it cannot regenerate on a merge or
rebase, where git carries someone else's lockfile change past a `bun.nix`
generated before it and reports no conflict.

That is not theoretical. web/bun.nix went onto main holding brace-expansion@5.0.7
(plus two nested entries the `^5.0.8` override had already collapsed) while
web/bun.lock said 5.0.8: the override landed in ec9aa415, the bun2nix branch had
generated bun.nix off the pre-override lock, and the merge kept both. The Nix
build fetches node_modules strictly from bun.nix, so the derivation's offline
`bun install` was asked for a tarball its store cache did not contain and
`nix build .#punktfunk-web` failed. It stayed broken for 553 commits
(2026-07-27 -> 2026-08-05) and was closed by accident, when an unrelated
advisory bump in b79d90b4 happened to rerun a real `bun install`.

Nothing caught it because nothing could: the string "nix" appeared in exactly
one workflow file, and only in a comment about bun2nix breaking a Windows step.

  * scripts/ci/check-bun-nix.sh regenerates each bun.nix from its committed
    bun.lock and diffs, with `--fix` to rewrite them. It reads which packages to
    check out of packages.nix's own `bunNix = src + ...` lines rather than a
    hardcoded list, so a third bun package is covered the day it is added, and
    an empty list is a hard error — a gate that checks nothing passes exactly
    like a clean tree. It also enforces the bun2nix version pin that flake.nix
    and the README only stated: bun.nix has no schema stability across releases,
    so the flake input ref and both package.json devDependencies must agree, and
    generation always uses the pinned version rather than a floating `bunx`.

  * ci.yml gains a `bun-nix` job running it. Deliberately unfiltered and not in
    nix.yml: it needs no Nix, takes under a minute, and the whole point is that
    this drift arrives through commits that look unrelated to Nix.

  * nix.yml is new and is the first CI that evaluates the flake at all —
    `nix flake check --no-build` over every output, then a real build of
    punktfunk-web + punktfunk-scripting, which are the two derivations whose
    inputs churn and the only ones that do not compile Rust. That leg is the
    end-to-end proof the drift gate cannot give: it catches a tarball the
    registry no longer serves, or the codegen going quietly message-less. It is
    path-filtered, has a workflow_dispatch opt-in for the hour-long Rust
    packages, and keeps `pull_request` — flatpak.yml shipped push-only and let
    manifest breakage reach main invisibly for weeks.

The script is POSIX sh (shellcheck-clean, exercised under dash, which is the
CI container's /bin/sh) and avoids process substitution, the parse-time failure
that silently disabled ci.yml's shader SPIR-V gate for 35 commits.
2026-08-06 15:28:14 +02:00
enricobuehler 65996621d8 Merge pull request 'fix(encode/pyrowave): stop stamping GPU scheduling priority over pf-frame's auto gate' (#72) from worktree-pyrowave-gpu-priority into main
ci / web (push) Successful in 1m10s
ci / docs-site (push) Successful in 1m9s
android / android (push) Successful in 6m26s
deb / build-publish-host (push) Failing after 15s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 1m19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 17s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 10s
apple / swift (push) Successful in 1m27s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 12s
deb / build-publish-client-arm64 (push) Successful in 2m55s
docker / builders-arm64cross (push) Skipped
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
deb / build-publish (push) Successful in 4m9s
docker / deploy-docs (push) Successful in 42s
arch / build-publish (push) Successful in 10m31s
ci / rust-arm64 (push) Failing after 8m55s
apple / screenshots (push) Successful in 5m51s
windows-host / package (push) Failing after 6m15s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 9m13s
ci / rust (push) Successful in 20m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m50s
Reviewed-on: #72
2026-08-06 13:15:06 +00:00
enricobuehler 61b96c3837 feat(client): M6's HEVC conversion — the CPU half is complete
The H.265 twin of plan_to_va, and with it pf-vaadec covers both codecs end to
end from an AuPlan to the buffers a vaRenderPicture call carries. What remains
for the rung is the Linux-only plumbing.

HEVC differs from H.264 in four ways that each had to be got right rather than
assumed, and they are why this is a separate module instead of a parameter:

ReferenceFrames is 15 entries, not 16.

The reference sets are FLAGS, not arrays. There is no RefPicSetStCurrBefore
here: membership is ORed into each DPB entry's own flags. Vulkan wants slot
indices in identically named arrays, DXVA wants list positions in them, and
VAAPI wants neither — three spellings of one idea, and confusing the first two
is what made HEVC unplayable on every driver.

The per-slice lists are INDICES into ReferenceFrames, not pictures and not
surfaces. So the DPB array is built first and every list entry resolved
through it; a picture a slice names that is not in the marked DPB is a refusal
rather than something to paper over, because there is nothing to fall back to.

The offset is in BYTES. slice_data() is byte-aligned by byte_alignment(), so
header_bit_size / 8 is exact — and a header that is not a whole number of
bytes is an error rather than a rounded offset, which would decode garbage
from the first inter picture.

Two conversions that are NOT copies, and would have been silently wrong as
copies: libva takes the derived ChromaOffsetLX (equation 7-56) where the
parser stores the coded delta, so putting the delta there would tint every
weighted-predicted block; and only 32x32 matrixIds 0 and 3 exist, where the
parser keeps six slots. The IQ matrix is Optional and gated on
scaling_list_enabled_flag for the reason review round 13 found on the DXVA
side — a driver MUST apply what it is handed, so a table of parser defaults
dequantises every residual to zero.

The weight table is only filled where 7.3.6.1 says one is coded, and the
chroma denominator is clamped into a legal shift so a malformed stream cannot
panic a decode thread.

Tests walk both HEVC vectors — the 250-frame 8-bit one and the 50-frame
Main 10 one, so a depth field wired to a constant would show — asserting per
slice that the start code was trimmed, the byte offset is inside the slice,
and every used list index points at a DPB entry that is actually valid. Per
picture it asserts that exactly the three current sets carry RPS flags and
nothing else does, and the walk fails if it never saw an RPS flag or a
reference at all, so it cannot pass vacuously.
2026-08-06 15:15:04 +02:00
enricobuehler c48e60fbb7 Merge pull request 'Two July fixes that were never merged: rpm FFmpeg modules + the released-pointer double cursor' (#71) from worktree-july-rpm-and-cursor into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (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
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m56s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m48s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m14s
release / apple (push) Successful in 9m31s
flatpak / build-publish (push) Successful in 10m3s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m17s
Reviewed-on: #71
2026-08-06 13:13:33 +00:00
enricobuehler 6c379f8fef feat(client): M6's HEVC layouts, and a third way to spell a reference set
The HEVC twin of pf-vaadec's H.264 buffer layouts, measured the same way: the
committed probe extended to cover va_dec_hevc.h, every size and offset read
off real libva 2.23.0 headers and pinned as const assertions —
VAPictureHEVC 28, VAPictureParameterBufferHEVC 604,
VASliceParameterBufferHEVC 264, VAIQMatrixBufferHEVC 1016 — and every
bit-field position read back out of a real header rather than counted by eye.

The finding worth carrying: HEVC's reference plumbing is a THIRD convention,
and this program has now been bitten by confusing two of them.

  Vulkan takes DPB SLOT indices in RefPicSetStCurrBefore/After/LtCurr.
  Writing reference-list positions there is what made HEVC unplayable on every
  driver until it was root-caused.

  DXVA takes positions into RefPicList[] in identically named arrays.

  VAAPI takes neither. It marks set membership as FLAGS on the DPB entries
  themselves — VA_PICTURE_HEVC_RPS_ST_CURR_BEFORE / _AFTER / _LT_CURR — and
  its per-slice RefPicList[2][15] holds INDICES INTO ReferenceFrames, not
  pictures and not surfaces.

Three spellings of one idea, identical names on two of them, and different
referents on all three. The conversion will say which it is writing, every
time, and the docs now hold all three side by side.

Two more asymmetries with the H.264 side, recorded where they will be read:
ReferenceFrames is 15 entries here, not 16; and the offset is
slice_data_byte_offset — BYTES, where H.264 wants bits — over the same
definition. slice_data() is byte-aligned by byte_alignment(), so the parser's
header_bit_size / 8 is exact rather than rounded, which the conversion will
assert rather than assume.

Tests cover the probe's measured bit patterns plus a disjointness sweep over
every field of pic_fields and slice_parsing_fields — two probe vectors per
word would not catch a shift typo that overlapped two neighbours, and these
words are 20 and 14 fields wide.
2026-08-06 15:09:07 +02:00
enricobuehler 70684e5079 fix(encode/pyrowave): stop stamping GPU scheduling priority over pf-frame's auto gate
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m41s
ci / rust-arm64 (pull_request) Successful in 2m35s
ci / web (pull_request) Successful in 2m27s
ci / docs-site (pull_request) Successful in 3m35s
ci / rust (pull_request) Successful in 8m25s
`windows/pyrowave.rs` raised the process's WDDM scheduling class to HIGH itself, once per
process, at every session open. `pf-frame::dxgi::auto_priority_gate` already owns that policy
for the whole process and runs from `create_device` — the call the Windows capture path always
makes before any PyroWave texture exists. Two owners of one process-wide setting.

The audit filed this as "downgrades REALTIME to HIGH", which undersells it. pf-frame's default
`auto` mode starts at HIGH and then UPGRADES to REALTIME once it has established that is safe —
HAGS off, or HAGS on with VRAM headroom — and leaves a monitor running that drops back when VRAM
tightens, because REALTIME + NVIDIA + HAGS + near-full VRAM is a documented NVENC hang. Opening
a PyroWave session after that upgrade stamped HIGH back over the class AND orphaned the
monitor's decision, losing the ceiling-raise on exactly the GPU-saturated workload PyroWave
exists to survive: it encodes on the shader cores a game saturates, where the measured spike is
~2 ms to 15-18 ms.

Removed rather than reconciled. `PyroWaveEncoder::open` takes no device, so there was nothing
session-specific to preserve, and the surviving owner is strictly better informed — it knows the
adapter, HAGS state and VRAM headroom, none of which this call site had.

The duplicated knob goes with it: `PUNKTFUNK_GPU_PRIORITY` is retired in favour of
`PUNKTFUNK_GPU_PRIORITY_CLASS` (`off|normal|high|realtime|auto`, default `auto`), which is a
superset — the removed knob could not express the auto gate at all. No other reference to it
exists in the tree.

Verified on .173: clippy -D warnings at nvenc,amf-qsv,qsv (host + pf-encode --all-targets),
amf-qsv without qsv, qsv alone, no-features, cargo test --features qsv (34 passed), rustfmt —
7 legs green. Windows-only file, so the Linux legs do not compile it.
2026-08-06 15:06:49 +02:00
enricobuehler 3f8a70dd45 Merge pull request 'Launcher tiles reach the clients, and Playnite can publish again' (#70) from worktree-library-clients into main
apple / swift (push) Successful in 1m34s
ci / web (push) Successful in 1m7s
deb / build-publish-host (push) Failing after 4s
ci / docs-site (push) Successful in 1m23s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 22s
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 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
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
deb / build-publish-client-arm64 (push) Successful in 2m36s
ci / rust-arm64 (push) Successful in 5m47s
arch / build-publish (push) Successful in 7m36s
docker / builders-arm64cross (push) Successful in 7s
docker / deploy-docs (push) Successful in 25s
android / android (push) Canceled after 9m43s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 9m54s
deb / build-publish (push) Canceled after 8m14s
release / apple (push) Canceled after 8m21s
flatpak / build-publish (push) Canceled after 5m29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 5m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 4m13s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-host / package (push) Failing after 6m42s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
Reviewed-on: #70
2026-08-06 13:03:42 +00:00
enricobuehler 2a67c02f7e fix(clients/cursor): the host must not composite a pointer under a released client's own cursor
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 1m9s
android / android (pull_request) Successful in 4m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 1m36s
ci / web (pull_request) Successful in 4m31s
ci / docs-site (pull_request) Successful in 4m43s
ci / rust-arm64 (pull_request) Successful in 6m50s
ci / rust (pull_request) Failing after 12m8s
Streaming a KDE desktop showed two cursors: the one the user was moving, and a
second one sitting underneath it that never moved. It was not KDE's — KWin 6.7.3
in cursor-as-metadata mode calls `setRenderCursor(false)` on every recorded buffer
and hands the cursor item to an exclusive `ItemTreeView`, so `shouldRenderItem()`
skips it and no pointer is ever painted into that stream. It was ours.

Both clients declared the render model as `captured && desktop`, so ANY released
pointer handed compositing back to the host. But releasing does not remove the
local cursor — it restores the ordinary window arrow over the video. The host then
blends its own pointer in underneath, and since a released client forwards no
motion, nothing drives it: it stays frozen wherever the host pointer was last left.
Caught live on the host with the render-model diag:

    cursor diag: client_draws=false blended=true live=Some((-1, 622, true))

x = -1 — parked on the streamed output's left edge, unchanged sample after sample,
while the user moved their own cursor around freely. Engaging capture flipped it to
`client_draws=true blended=false` and the duplicate vanished, which is why it only
looked "stuck when not dragging": dragging means engaged, and engaged was the one
state that behaved.

The host may composite ONLY while the client holds a grabbed, hidden pointer — the
capture model, engaged — which is the single state with no local cursor on screen.
Released now counts as "the client draws it": the host stops compositing and keeps
forwarding shape/state over the channel (the forwarder ticks on this side of the
flip), so re-engaging is seamless and the client's cached shape stays warm.
2026-08-06 15:03:11 +02:00
enricobuehler 79ee308a7a build(rpm): declare all seven FFmpeg pkg-config modules, not three
`ffmpeg-next` is pulled with default features, so `ffmpeg-sys-next`'s build script
pkg-config-probes codec/device/filter/format/util/resampling/scaling and panics on
the first one missing. The spec named three.

RPM Fusion's `ffmpeg-devel` ships all seven in one package, which hid it. On a host
where those three instead resolve to Fedora's split `libav*-free-devel` packages,
`dnf builddep` installs exactly three and the build dies in a build script:

    The system library `libavfilter` required by crate `ffmpeg-sys-next` was not found.
2026-08-06 15:03:05 +02:00
enricobuehler 58ee74cb58 fix(clients/windows): clippy's manual_is_multiple_of on the rescan tick
android / android (pull_request) Successful in 6m8s
ci / web (pull_request) Successful in 4m50s
ci / docs-site (pull_request) Successful in 1m14s
ci / rust-arm64 (pull_request) Successful in 5m50s
apple / swift (pull_request) Successful in 1m32s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 12m37s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m18s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m13s
`cargo clippy -p punktfunk-client-windows -- -D warnings` fails on main with the
pinned 1.96.0 toolchain: `ticks % 5 == 0` trips `manual_is_multiple_of`. Pre-existing
and not from this branch — found while gating the launcher work on .173, because
neither macOS nor Linux ever compiles this crate.

Clippy's own suggestion, applied verbatim.
2026-08-06 14:50:18 +02:00
enricobuehler 6ae2ea6708 Merge remote-tracking branch 'origin/main' into worktree-library-clients 2026-08-06 14:45:36 +02:00
enricobuehler 00d4026054 Merge pull request 'Worktree field kleisty triage' (#69) from worktree-field-kleisty-triage into main
arch / build-publish (push) Failing after 40s
apple / swift (push) Successful in 1m26s
ci / web (push) Successful in 1m10s
ci / docs-site (push) Successful in 2m30s
deb / build-publish (push) Successful in 3m43s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
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 9s
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 8s
deb / build-publish-client-arm64 (push) Successful in 2m23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
ci / rust-arm64 (push) Successful in 6m51s
docker / builders-arm64cross (push) Failing after 25s
docker / deploy-docs (push) Failing after 1m57s
release / apple (push) Successful in 9m17s
deb / build-publish-host (push) Successful in 7m58s
android / android (push) Successful in 12m19s
ci / rust (push) Successful in 12m1s
flatpak / build-publish (push) Successful in 9m40s
apple / screenshots (push) Successful in 5m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 15m55s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 15m50s
windows-host / package (push) Canceled after 2m58s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 1s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Reviewed-on: #69
2026-08-06 12:41:28 +00:00
enricobuehler dd20a17edb test(host/library): the art tests build a file:// URL Windows can read
`local_art_bytes_is_confined_and_image_only` and `posix_local_art_is_classified_and_proxied`
built their `file://` values as `format!("file://{path}")`. On Windows that yields
`file://C:\covers\cover.png`, whose authority is `C:` — a UNC reference, not a local
file — so the read half failed on the box and the host suite was red there.

The parser is right and the tests were wrong: `@punktfunk/plugin-kit/library`'s `fileUrl`
emits `file:///C:/covers/cover.png` (three slashes, forward separators) and
`file_url_to_path` documents exactly that. A shared `file_url` helper now builds the value
the way the kit does, so both tests exercise the real plugin contract on both platforms
rather than a shape no plugin ever sends.

Found while gating the Playnite launch kinds on .173 — Linux CI never compiles these arms,
so the failure had gone unnoticed. Test-only: no product code changes.
2026-08-06 14:36:04 +02:00
enricobuehler 8ff2c2e1c6 feat(host/library): Playnite can publish again, and gets a fullscreen tile
The Playnite plugin emits `kind: "command"` for every game (a `start "" "playnite://…"`
shell line). The 2026-08-05 review made `command` operator-only, and `privileged_field`
refuses a PROVIDER reconcile carrying one — so on this branch the published
`@punktfunk/plugin-playnite@0.3.0` cannot publish anything at all. Not a launcher tile:
not one game. That is a regression against a shipped plugin, and it is the same hole
`launcher_ui` was created to close, one kind further along.

Two kinds, both host-owned so D1 holds — the plugin supplies a validated VALUE and
never a command line:

  playnite     valued by the game's GUID; resolves to
               explorer.exe "playnite://playnite/start/<guid>", the same
               protocol-via-a-concrete-EXE shape the `epic` kind uses. GUID-validated
               on the way in (so a bad value is a 400 the plugin author can act on)
               and again at launch.

  launcher_ui  now accepts "playnite" on Windows, resolving to
               Playnite.FullscreenApp.exe with Playnite's own install dir as the
               working directory.

Fullscreen, not Desktop, is the whole point of a couch tile — and it is also why this
one cannot ride the URI the games use: probed on .173, Playnite's registered
`playnite://` handler is bound to Playnite.DesktopApp.exe, so no URI opens fullscreen
mode. The exe is spawned directly, with the install dir read from Playnite's own
uninstall entry (HKCU, then HKLM for a machine-wide install), falling back to
%LOCALAPPDATA%\Playnite.

`valid_launcher_ui("playnite")` is answered by RESOLUTION rather than by a static list:
a host without Playnite installed refuses the entry instead of publishing a tile that
does nothing when a user clicks it. That is the same instinct that left Epic, GOG
Galaxy and the Xbox app off the list — each still needs its own verified activation,
and a guess would ship exactly that dead tile.

Gates: punktfunk-host 436 passed / 0 failed on .21 (the Linux arms), and the Windows
arms compiled and their library tests run on .173.
2026-08-06 14:35:48 +02:00
enricobuehler 883c317872 feat(clients/library): a launcher tile looks like one, on every client
The host has been able to describe a launcher entry since M2 — `role: "launcher"`,
the `steam_ui` and `launcher_ui` kinds — and the web console has grouped them into
their own rail since M4. No other client ever looked. `pf-client-core` decoded
`role` into an `is_launcher()` helper with zero call sites, and the shared console
model dropped the field entirely on its way to the renderer.

So a launcher tile arrived everywhere else as an ordinary game with no cover art:
indistinguishable from a title whose poster failed to load, sorted into the middle
of the alphabet, and captioned "Play".

One contract, implemented in each client's own idiom:

  * launchers never interleave with titles — they lead, and each group keeps the
    host's title order
  * grid surfaces get a labelled section; a coverflow keeps its single carousel and
    names the group the cursor is in, changing as it crosses the boundary. A second
    focus rail would mean a new up/down nav model in three renderers for two or
    three tiles
  * an art-less launcher gets an accent face naming its launcher, not a title
    monogram on the neutral one — "opens Steam", not "a cover that didn't load"
  * anything that is not `"launcher"` is a game, and a host that omits the field
    renders exactly as before (design D4's intended degradation)
  * launching is unchanged: the client sends an id, the host resolves the recipe

The grouping is enforced once per client stack rather than per screen. In the
console UI it is an invariant of `LibraryShared::set_games`, so the cursor
arithmetic, the art pump and every future consumer inherit it; on Apple and Android
it is applied where the library is fetched/parsed.

Fixed in passing: the Apple and Android store badges were hard-coded
`isCustom ? "Custom" : "Steam"`, so every Lutris, GOG, Heroic, Epic and Xbox title
was labelled "Steam". Both now carry the same store table the Rust clients use.

The CLI's `--library` gains a fourth column (`game`/`launcher`), appended rather
than folded into an existing one so anything reading the first three is untouched.

Gates: punktfunk-host 436 passed / 0 failed and pf-console-ui 49 passed / 0 failed
on .21 (three new tests), workspace clippy -D warnings and cargo fmt --check clean
there; `swift build` of the full PunktfunkClient and `:app:compileDebugKotlin` clean
on macOS; `cargo check` + `clippy -D warnings` for the Windows client on .173.

Still unproven on hardware: no launcher tile has been clicked on a real host — that
needs the plugins published, which needs this branch's base merged first.
2026-08-06 14:35:24 +02:00
enricobuehler 72777119fd fix(client/android): stop reporting every disconnect as a lost connection
ci / web (pull_request) Successful in 1m6s
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m50s
android / android (pull_request) Successful in 3m43s
ci / rust-arm64 (pull_request) Successful in 4m26s
ci / rust (pull_request) Successful in 7m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 12m38s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 8m23s
The stream watchdog polled a bare "has the session ended" boolean, so it
had exactly one thing it could say and said it every time: "Connection
lost — the host may be asleep. Wake it to reconnect." That ran when the
player quit their game, when an operator ended the session from the
console, and when they pressed Back themselves — telling them to go wake
a host that was never asleep.

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

A game launched from a library also returns to that library instead of
host selection, which needs the intent hoisted out of the console shell:
the stream replaces that shell in the composition, discarding the
`remember`s holding its screen and host, so by the time the session ends
there is nothing left to navigate back with. The parent holds it across
the gap and the shell consumes it on the way in. The touch UI has no
library — only the console shell does — so there it is the toast fix
alone.
2026-08-06 14:30:59 +02:00
enricobuehler 81b4f76c4d fix(client): a session ending on purpose stops reading as a failure
The desktop clients turned every host-side close into "Host ended the
session", and a reason string means "abnormal" to everything downstream:
the GTK and Windows shells raised a banner, the console overlay drew a
status strip. Quitting a game you launched yourself produced all of
that. Now only a host error or a lost connection carries a message; the
deliberate endings return the silence those shells already give a clean
exit, which is also what puts the console back in its library with
nothing in the way.

The Apple client gains the same distinction. It had one line for every
ending — "Session ended by <host>." — which is fine for an operator
stopping the session and wrong for a link that died, so each now says
what happened. A game exiting stays silent and returns to the library it
was launched from.

Both read the reason while the connection is still up, because tearing
it down is what makes it unreadable, and both fall back to their previous
wording when there is no verdict — an older core, or a close that raced
the read — rather than inventing a new one for a case they cannot see.
2026-08-06 14:30:46 +02:00
enricobuehler ec44496285 feat(client): tell clients WHY a session ended, not just that it did
A session ending was a single bit. A player quitting their game, an
operator ending the session from the console, a stop the client itself
asked for, a host crashing and a Wi-Fi drop all arrived as the same
"closed" — so every client had to write one message covering all of
them, and every client picked an error. That is how quitting your own
game came to be reported as trouble on all three.

The information was already there and thrown away: the host closes with
APP_EXITED when a launched game exits, with 0 when it ends the session
cleanly and 1 when it fails, and a link that simply dies never closes at
all. The connection watcher now classifies that into a
PunktfunkEndReason — local, game exited, host ended, host error, lost —
and latches it before the shutdown flag, since the two are read by
different threads and the reason must never arrive second.

Exposed as punktfunk_connection_end_reason. This replaces the
game-exited flag added a moment ago rather than joining it: that
question is one row of this table, and it was never released. Still
additive to any embedder that ignores it, and the host sends the same
bytes either way, so the wire is untouched.

`is_normal()` is the question nearly every caller actually has, so both
the Rust and C surfaces answer it directly rather than making each
client re-derive which of five values are worth alarming a user about.
2026-08-06 14:30:33 +02:00
enricobuehler 7a31f1089e feat(client): M6's conversion half — one AuPlan into libva's buffers
The second half of pf-vaadec: picture parameters, inverse-quantization
matrices and one slice-parameter record per slice, over the same transaction
discipline pf-dxvadec uses — validate, resolve references against the
PRE-removal slot map, then apply removals and assign the setup slot last. A
half-applied DPB update is the shape of a corrupt reference, so nothing
mutates until every fallible step has passed.

Three things VAAPI wants that neither other backend does, all of which the
existing plan already carries:

A bit offset. slice_data_bit_offset is where slice_data() begins, counted from
and including the NAL header byte with emulation-prevention bytes removed —
DXVA takes a byte offset, Vulkan takes nothing. It costs no new parsing: the
vendored parser records exactly that as SliceHeader::header_bit_size, because
cros-codecs' own production backend is VAAPI.

The slice data without its start code, since that offset is relative to the
NAL header byte. SlicePlan::data is start-code-inclusive and the prefix is
three OR four bytes — the host emits four on every access unit — so it is
measured per slice rather than assumed. Assuming it is the defect that made
HEVC unplayable on every driver.

The per-slice reference lists. DXVA's short-format slice control expresses no
lists at all; VAAPI wants RefPicList0/1 in 8.2.4.2 order, which is what the
plan's derived lists already are.

And the distinction that cost M5 a defect, now written down in a third place:
reference_frames is documented "in DPB", the same statement DXVA's
RefFrameList makes and the opposite of Vulkan's pReferenceSlots. It is filled
from the marked-DPB snapshot; the per-slice lists come from the slice's own.
Getting that backwards loses a long-term reference no slice happens to name.

Weight tables follow 7.3.3's presence rule rather than being copied
unconditionally: flagged only where the PPS actually enables explicit
weighting for that slice type and list. Flagging them otherwise hands the
driver defaults as though the stream had coded them. The vendored
PredWeightTable stores luma_offset_l0 as [i8; 32] but luma_offset_l1 as
[i16; 32] — an upstream inconsistency, not a semantic one — so the narrow side
widens.

Envelope refusals are errors, never silent narrowings: slice groups, separate
colour planes, a capacity mismatch, a reference holding no slot, lists past
their array bounds, a slice range outside its access unit.

Tests: 15. The one that matters walks all 250 access units of the vendored
conformance vector through H264Planner and this conversion, asserting per
slice that the range lies inside its access unit, that the declared size
matches it, that the start code really was trimmed, and that the header
neither is zero bits nor outruns the slice — plus that reference_frames
carries exactly as many valid entries as the marked DPB and every entry past
it is invalidated. It also asserts it saw a multi-slice picture and a
non-empty reference set, so a splitter bug cannot make it vacuous. Gates:
rustfmt, clippy, cargo doc with no unresolved links, and the container's
clippy -D warnings, tests and workspace check.
2026-08-06 14:22:33 +02:00
enricobuehler d74639de70 Merge pull request 'A safe-area resolution that keeps the picture out of the notch' (#68) from worktree-launchers-safearea-exclusions into main
apple / swift (push) Successful in 1m28s
ci / rust-arm64 (push) Successful in 1m53s
android / android (push) Successful in 5m48s
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 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
ci / web (push) Successful in 1m4s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / docs-site (push) Successful in 1m12s
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 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
docker / builders-arm64cross (push) Successful in 7s
docker / deploy-docs (push) Successful in 34s
ci / rust (push) Failing after 9m39s
release / apple (push) Successful in 9m11s
apple / screenshots (push) Successful in 5m42s
Reviewed-on: #68
2026-08-06 11:59:31 +00:00
enricobuehler d4dd5f7a3d feat(client): a game exiting takes you back to its library
Quit a game you launched from a host's library and the stream ended with
"Session ended by <host>." on the host-selection screen — an error
report for something you had just done on purpose, and several taps away
from starting the next title.

The host has always said what happened: it closes the connection with
APP_EXITED when the game it launched for a session exits, and that
code's own documentation describes this feature. Nothing ever read it —
a search across every client found zero consumers. (It also could not
reach anyone until the previous commit, since the close only happens
once the lease declares the game gone.)

The core now records the reason as it observes the close, latched before
the shutdown flag because different threads watch the two, and exposes
it as punktfunk_connection_game_exited. Purely additive: a client that
never asks behaves exactly as before, the host sends identical bytes,
and the wire version is untouched — ABI 17.

The Apple client asks while the connection is still up, then treats a
game exit as the normal finish it is: no error banner, and if the
session began as a library launch it reopens that library so the next
title is one tap away. Any other ending — a stop, the host going away,
network loss — is unchanged. The other clients keep their existing
end-of-session behaviour; the call is there when they want it.
2026-08-06 13:58:57 +02:00
enricobuehler ea762b849d fix(client/ios): Escape stays in the game instead of freeing the pointer
Pressing Escape mid-stream on an iPad handed the mouse back to iPadOS:
the captured cursor was swapped for the system one and the game stopped
receiving relative motion, so aiming died until you clicked back in.
Two previous attempts treated that release as unavoidable and built
recovery around it — a re-lock burst, then a click that re-asks. Both
came back from the field unchanged, because both fought the release
after it had already happened, inside the cooldown the platform applies
straight after its own "let me out" gesture.

The release was never unavoidable. This app had no UIKit key handling at
all: every key arrives on the GameController path, which is a parallel
HID feed that does not consume the UIKit event, and the only thing that
ever became first responder was the video view, and only to summon the
soft keyboard. So every hardware Escape reached UIKit unclaimed — and an
unclaimed key press is precisely what lets the system apply its own
default for that key. Apps that read a hardware keyboard the ordinary
way consume the event as a side effect and never see this.

So claim it. The stream controller becomes first responder while capture
is engaged and takes Escape in pressesBegan/pressesEnded, passing every
other press to super untouched. Escape still reaches the host on the
GameController path, so in-game menus open exactly as before; only the
system's own interpretation is suppressed. Scoped to captured input, so
Escape keeps dismissing sheets and leaving full screen whenever the
stream doesn't own the keyboard, and the deliberate ways out are
untouched — Cmd-Escape and Ctrl-Opt-Shift-Q are read off the same
GameController path and clear capture themselves.

The recovery path stays as a backstop and is retimed to match what was
measured: the old burst spent its entire budget within ~0.6 s of the
drop, i.e. wholly inside the cooldown, where the answer can only be no.
Retries now continue at 1.2 s and 2.4 s, and quietly — they don't hide
the cursor or mute pointer motion the way the burst does, so a longer
recovery costs nothing when it fails.
2026-08-06 13:58:41 +02:00
enricobuehler 76e8bd1b98 fix(host/gamelease): a game that exited stops counting as running
When a launched game's processes are all gone, the watcher asks one last
out-of-band question before ending the session: does the launcher still
think the game is up? On Windows that reads Steam's per-app `Running`
registry flag. It was only ever meant to be a tie-breaker for a scan that
momentarily can't see the game — a launcher re-execing, an engine
relaunching itself into a new pid.

It had no bound. Honouring the flag reset the confirm window every pass,
so a flag Steam left set — it does that whenever it doesn't cleanly
observe the exit: it crashed, it was closed first, the game re-parented —
pinned the lease in `running` for the life of the host. The console kept
showing the game, `session_on_game_exit` never fired, and the only way to
get the stream back was a manual "End". Reported from the field on
Windows 0.24.0. `steam_running_hint` also believes the FIRST hive that
says so, so a stale flag in any loaded profile was enough.

The absence timer now keeps running instead of being reset, and that is
what bounds it: past `VETO_LIMIT` (30 s) with nothing of the game on the
box, the launcher's opinion is stale rather than early and the session
ends anyway, logged at WARN so it is visible. Ending a moment early is
the cheaper failure — the stream drops while the game lives, the user
reconnects, and nothing is ever killed. Ending never was the bug.

The rule is now a pure `exit_confirmed(gone_for, hint_running)` with a
test. The watch loop polls a live process table and can't be unit-tested,
which is exactly how an unbounded veto shipped unnoticed.
2026-08-06 13:58:24 +02:00
enricobuehler 509843f0d4 test(client): the D3D11VA rung's ten-bit path, measured too
The companion to the Vulkan ten-bit leg, over the same vector and the same
P010 goldens — one golden file serves both rungs because a D3D11 P010 surface
and Vulkan's 3PACK16 family hold the ten bits in the same place.

This is the rung where the gap mattered most. D3D11VA exposes no per-picture
status query at all, so its HDR evidence was a session that built a Main10
decoder and streamed without complaint — which is precisely what a Main10
stream decoding to garbage would also produce. Now there is a number.

It exercises geometry the eight-bit legs cannot reach: P010 samples are two
bytes, so a row is width * 2 rather than width, and HEVC's 128-line granule
pads a 240-line picture to a 256-line surface — so the chroma plane starts a
long way from where the display height alone would put it. Getting either
wrong is the smeared-rows failure this project has already paid for once, and
it would have looked like a decoder fault.

The run body now takes the stream format and the expected access-unit count
rather than assuming the eight-bit envelope and 250 frames.

A CPU guard pins the vector at ten bits — 4:2:0, both depths minus8 == 2,
320x240, 50 access units. A regenerated eight-bit vector would otherwise turn
this into a second run of the eight-bit path under a ten-bit name, passing,
because its goldens would have been regenerated with it.

Hardware: HEVC Main 10 50/50 bit-identical on the RTX 4090 and on the AMD
Radeon iGPU, alongside the unchanged eight-bit legs at 250/250 on both. With
the Vulkan leg's two drivers that is four independent drivers across two
rungs for the ten-bit path, where yesterday there were none.
2026-08-06 13:53:23 +02:00
enricobuehler fbdad8d917 Merge pull request 'fix(clients): host discovery heals itself, and every client can rescan' (#67) from worktree-host-discovery-refresh into main
ci / web (push) Successful in 1m14s
apple / swift (push) Successful in 1m26s
ci / docs-site (push) Successful in 1m20s
deb / build-publish (push) Successful in 3m53s
deb / build-publish-host (push) Successful in 4m14s
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 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m54s
ci / rust-arm64 (push) Successful in 6m58s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 17s
docker / builders-arm64cross (push) Skipped
deb / build-publish-client-arm64 (push) Successful in 2m33s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
android / android (push) Canceled after 8m10s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Successful in 8m22s
ci / rust (push) Canceled after 8m34s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 1m13s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 7m29s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 1m13s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m37s
flatpak / build-publish (push) Failing after 11m29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 13m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 15m37s
Reviewed-on: #67
2026-08-06 11:51:30 +00:00
enricobuehler 78ad675507 feat(clients): a safe-area resolution that keeps the picture out of the notch
ci / rust-arm64 (pull_request) Successful in 1m31s
ci / docs-site (pull_request) Successful in 1m24s
ci / web (pull_request) Successful in 2m2s
apple / swift (pull_request) Successful in 1m29s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m55s
ci / rust (pull_request) Successful in 7m19s
Picking the device's native mode on a phone hands the host the panel's own
aspect ratio, so the aspect-fit presenter fills every pixel — including the ones
behind the sensor housing and under the four rounded corners. That is why the
corners look cut off at max resolution while 1080p has always been fine: a 16:9
mode on a 20:9 phone pillarboxes, and those black bars land exactly on the
unsafe regions.

So the fix is entirely a sizing one — no layout change, no input change. Ask the
host for a mode narrowed by the unsafe inset and the existing aspect-fit centres
it inside the safe region; pointer mapping follows for free, because both
clients derive the picture rect from the live host mode rather than assuming
full-bleed.

Apple: `SafeDisplay` (PunktfunkShared, pure + unit-tested) and a "This device
(safe area)" row beside the native one, using Moonlight's formula — full native
height, width less the left+right safe insets. The stream is always landscape
but the settings screen may be portrait, where the same housing is reported on
`top` and the horizontal insets read zero; the portrait top inset stands in,
gated so an iPad's status bar never fabricates an inset.

Android: the same shape via `SafeArea` + a `SAFE_AREA_MODE` sentinel resolved at
connect like the existing `0`=native one. The cutout insets get the same
portrait fallback, and the rounded corners are added on top — Android does not
count them as cutout, and a full-height picture needs exactly the corner radius
of horizontal clearance.

Both even-floor and clamp, since `validate_dimensions` rejects odd dimensions
and an inset subtraction lands odd about half the time. Where a display has
neither cutout nor rounded corners the safe mode equals the native one, which on
Apple lets the existing dedup drop the duplicate row.
2026-08-06 13:44:01 +02:00
enricobuehler c91a482b4e test(vkdecode): the ten-bit path finally has pixels
Every golden set in this program was eight-bit. So the strongest thing anyone
could say about ten-bit decode was that a Main10 session BUILDS and streams
clean — which is not the same claim, and is exactly the shape of claim this
program has been burned by. A Main10 stream decoding to garbage logs just as
cleanly: HEVC Main10 on D3D11VA has no per-picture status query at all, and on
the Vulkan side the devices that matter report queryResultStatusSupport=false.
The HDR legs were measuring that the pipe ran, not that the pixels were right.

So: a Main10 vector and its goldens, and a ten-bit leg that runs them.

The vector is 50 frames of 320x240 HEVC Main 10 4:2:0 from libx265 — 48 KB,
generated by a command recorded in the golden file's header along with
everything else needed to regenerate it. The goldens come from libavcodec's
software decoder and were cross-checked between two independent builds on two
architectures (ffmpeg 8.1.1 Homebrew/macOS-arm64 and 8.0.1 Ubuntu/x86_64),
which agreed on all 50.

The goldens are P010, NOT yuv420p10le, and that distinction is the whole
reason this could have quietly gone wrong: P010 puts the ten bits in the HIGH
bits of each little-endian 16-bit word with the low six zeroed, which is what
a D3D11 P010 surface and Vulkan's G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16
both contain. Hashing LSB-aligned samples against MSB-aligned ones would fail
on every frame on every driver, for a reason that has nothing to do with
decoding. One golden file therefore serves both native rungs.

The readback is now depth-aware. Its only eight-bit assumption was the second
region's buffer_offset, which is a BYTE offset where the extents around it are
TEXELS — that plus the buffer size are the whole change, because
bufferRowLength = 0 already packs rows at the copy extent. The expected pool
format moved onto the readback so the sizing and the per-frame assertion come
from one source; a readback sized for eight bits that then accepted a ten-bit
frame would hash half a picture and blame the decoder.

A CPU guard asserts the vector really is ten-bit — 4:2:0, both depths
minus8 == 2, 320x240, 50 access units, 50 planned outputs. Without it a
regenerated eight-bit vector would turn the ten-bit leg into a second run of
the eight-bit path wearing a ten-bit name, and it would PASS, because its
goldens would have been regenerated alongside it. That guard is not ignored,
so it runs on macOS and in the container rather than only on the fleet.

Hardware: HEVC Main 10 50/50 bit-identical on NVIDIA 610.43.03 (Linux) and on
the Steam Deck's RADV/VanGogh — first run on both, which also confirms the
P010/3PACK16 layout match rather than assuming it. The four eight-bit legs are
unchanged and still green on both boxes.
2026-08-06 13:32:31 +02:00
enricobuehler b25e6eda91 fix(clients): host discovery heals itself, and every client can rescan
ci / web (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m32s
ci / docs-site (pull_request) Successful in 4m16s
android / android (pull_request) Successful in 6m25s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 7m14s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 3m30s
ci / rust (pull_request) Successful in 15m13s
A field report from an iPad: the host is not found on first run, and
restarting the client finds it. Pull-to-refresh appeared to do nothing.

Both were real. The Apple client's discovery had three ways to go
permanently deaf, each needing an app relaunch to clear:

- A failed resolve was never retried. `browseResultsChangedHandler`
  only fires when the result SET changes, and a host whose resolve
  failed is still in the set — so nothing ever re-offered it.
- A stuck resolve never ended. `NWConnection` has no timeout, so the
  throwaway UDP flow used to resolve an address could sit in
  `.preparing`/`.waiting` forever, and a service with a connection in
  flight was skipped.
- `NWBrowser` parking in `.waiting` was ignored (only `.failed`
  re-armed). On iOS that is where the local-network privacy prompt
  lands on first launch after install: the browse starts, the system
  asks, and the browser waits. Granting does not revive that browser —
  only a new one sees the grant. That is the reported first-run bug.

HostDiscovery now runs a 1 Hz sweep that times out stuck resolves,
retries failed ones on a 1→30 s backoff, and re-arms a browser that
stopped working; the advert's TXT is re-read on every browse report, so
a host that re-keys or flips its pairing policy is followed. Returning
to the foreground re-arms the browse (iOS/tvOS: `onAppear` does not
fire across background/foreground, and a suspended browse stays dead).

Pull-to-refresh did nothing because there was no `.refreshable` in the
client at all. Added, plus the explicit control the report asked for:
a toolbar Refresh on iOS/macOS, an action-row button on tvOS, a Rescan
tile in the gamepad launcher, Scan Again on the empty state, a
header-bar button in the GTK client, a hosts-page button on Windows,
and Scan again on Android. Decky already had one.

The desktop/Android browses needed a rescan trigger to make those
buttons mean anything: mdns-sd re-queries on a doubling backoff capped
at ONE HOUR, so a long-lived browse is effectively passive and a host
that appears later can stay invisible. `discovery::Rescan` forces a
fresh query; the wake-and-wait loops use it too, so a host that just
booted is noticed in seconds rather than at the next backoff tick.

Also fixed, found on the way: clients/windows/src/discovery.rs is a
second copy of the browse that d0fa8bd3 ("pin mDNS discovery to IPv4 on
every client") missed. It took an arbitrary first address, so when a
host's OS responder answered AAAA the Windows GUI rendered a card that
failed on every click. It also never noticed a dropped receiver, leaking
a thread and a :5353 socket per wake-and-wait.

Gates: Apple macOS + iOS (arm64-apple-ios17.0, proven non-vacuous) build
clean, 195 tests pass incl. a new one asserting a rescan re-finds a
still-advertising host. On .21: fmt, clippy --all-targets -D warnings
and build clean for pf-client-core + client-linux + client-session,
117 tests pass. Android :kit: and :app: compileDebugKotlin clean.
The Windows client is UNGATED — its CI runner was unreachable.
2026-08-06 13:30:10 +02:00
enricobuehler c79d9397fe Merge pull request 'fix(flatpak): the WSI layer module builds again — vkroots was declared twice' (#65) from worktree-flatpak-vkroots into main
ci / rust-arm64 (push) Successful in 1m22s
ci / web (push) Successful in 1m21s
ci / docs-site (push) Successful in 1m44s
flatpak / build-publish (push) Successful in 6m22s
ci / rust (push) Successful in 7m50s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 51s
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 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
docker / deploy-docs (push) Failing after 10s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders-arm64cross (push) Skipped
Reviewed-on: #65
2026-08-06 11:12:50 +00:00
enricobuehler d3e000768d feat(client): M6 begins — the libva layouts, measured rather than transcribed
The VAAPI rung's crate, in the shape the other two native rungs established:
everything that can be a pure decision or a pure conversion lives in a
cross-platform crate the ordinary gates run, and only the parts that genuinely
need a device stay behind a platform cfg. This lands the first half of that —
the buffer layouts and the decoder-creation decisions — with the conversion to
follow.

Route: minimal FFI rather than cros-libva. The plan of record permits either
("cros-libva (or minimal FFI)"), and hand-declaring keeps the crate building
and testing on macOS and in the Linux container, which is the property that
made pf-dxvadec's defects findable on a laptop instead of on a box.

The layouts are not eyeballed. A C probe compiled against real libva 2.23.0
headers printed sizeof/alignof/offsetof for every field and set individual
bit-fields to read the resulting word back; those numbers are pinned as const
assertions, so a transcription slip is a compile error rather than a driver
reading the wrong byte. The probe is committed beside them, with the command
that runs it, because evidence that cannot be re-run is a claim.

What the probe settled that a reader would otherwise get wrong: VAPictureH264
is 36 bytes and is embedded 81 times across the two buffers, so its size is
load-bearing for every later offset; the three DEPRECATED FMO fields still
occupy bytes 624..628, and dropping them would shift everything after; and C
bit-fields allocate from the least significant bit on this ABI — proven, since
that is ABI-defined rather than standardised.

Groundwork for the conversion, established here so the next work package
starts from facts:

slice_data_bit_offset needs no new parsing. VAAPI is the only backend that
wants a bit position — DXVA takes a byte offset, Vulkan takes none — and the
vendored parser already records exactly it as SliceHeader::header_bit_size,
computed as (nalu.size - epb) * 8 - bits_left: from and including the NAL
header byte, emulation-prevention bytes removed. That is the field's
definition verbatim, and it is there because cros-codecs' own production
backend is VAAPI.

The slice data buffer starts at the NAL header byte, so the start code is
skipped — SlicePlan::data is start-code-inclusive and the prefix is three OR
four bytes, the host emitting four on 100% of access units.

reference_frames is the marked DPB, the same statement DXVA's RefFrameList
makes, so it comes from the dpb_refs snapshot; Vulkan's pReferenceSlots is the
opposite and takes the access unit's own set. All three conventions now have a
written home, which is the distinction that cost M5 a defect.

Unlike DXVA short-format, VAAPI wants the per-slice reference lists and the
full prediction weight tables inline — hence the 3128-byte slice record. One
wrinkle recorded rather than left to be discovered: the vendored
PredWeightTable stores luma_offset_l0 as [i8; 32] but luma_offset_l1 as
[i16; 32], and libva wants i16 for both.

Profile selection resolves H.264 to High for every 8-bit 4:2:0 stream instead
of reading profile_idc, because High is a superset for the tools our hosts
emit and picking Main for a stream that turns out to use 8x8 transforms is a
mid-stream failure where picking High is not. 4:4:4 and 10-bit H.264 are
refused rather than narrowed to an 8-bit profile — that class of silent
narrowing decodes to garbage instead of failing.

11 tests: the probe's bit patterns, a disjointness check per bit-field word
(two probe vectors alone would not catch a shift typo that overlapped two
fields), and the envelope refusals. Gates: rustfmt, clippy, cargo doc with no
unresolved links, and the Linux container's clippy -D warnings, tests and
workspace check.
2026-08-06 13:04:07 +02:00
enricobuehler 3edb01f1b8 Merge pull request 'Gamepad UI: section tabs, background palettes, and a backdrop that moves everywhere' (#66) from worktree-gamepad-ui-polish into main
ci / web (push) Successful in 1m14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 20s
apple / swift (push) Successful in 1m37s
ci / docs-site (push) Successful in 1m43s
ci / rust-arm64 (push) Successful in 1m55s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 29s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m16s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m51s
android / android (push) Successful in 7m2s
docker / deploy-docs (push) Failing after 39s
ci / rust (push) Successful in 7m14s
deb / build-publish (push) Successful in 5m14s
deb / build-publish-host (push) Successful in 5m56s
flatpak / build-publish (push) Failing after 7m13s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m21s
deb / build-publish-client-arm64 (push) Failing after 11m14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 11m56s
arch / build-publish (push) Failing after 13m25s
docker / builders-arm64cross (push) Skipped
release / apple (push) Successful in 12m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m41s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m21s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m25s
apple / screenshots (push) Successful in 6m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m52s
Reviewed-on: #66
2026-08-06 10:50:25 +00:00
enricobuehler 5a7f7f0fc5 feat(clients/gamepad-ui): section tabs, background palettes, and a backdrop that moves everywhere
ci / web (pull_request) Successful in 1m17s
ci / docs-site (pull_request) Successful in 1m42s
ci / rust-arm64 (pull_request) Successful in 2m36s
android / android (pull_request) Successful in 3m33s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 8m37s
ci / rust (pull_request) Successful in 8m58s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m47s
apple / swift (pull_request) Successful in 1m29s
apple / screenshots (pull_request) Skipped
The console settings were one 30-row scroll, which on a Deck meant thumbing past
Video and Audio to reach the pad settings. They are now split across sections —
Stream · Video · Audio · Controller · Interface · Profiles, plus Input on the
desktop console, which alone carries the touch/mouse rows. L1/R1 walks them,
each section remembers where its cursor was, and the names are the same word on
every client so a setting is where you looked for it last.

Shoulders are not the only route, because a D-pad remote hasn't got any: on
Android, Up from the first row moves onto the strip (left/right walks sections
there, A drops back in), and on tvOS the pills are focusable, so the focus
engine handles it — a Siri Remote has no extended gamepad profile and never
reaches the input poll at all. The desktop console needs neither; PageUp and
PageDown already map to the same events.

New "Background" row, six palettes: Violet (the brand default), Tide, Forest,
Ember, Rose, Graphite. A palette is a hue rotation plus a saturation scale over
the ONE colour field each client already draws, so every palette inherits its
structure and Violet is the identity transform — existing installs see exactly
what they see today. The maths is ported three times (Rust/Swift/Kotlin) under
one shared `ui_palette` key, with the same assertions pinned in each language.
It is presentation only, so it is a device preference and never part of a
profile.

The form screens no longer have a backdrop of their own. Settings, add-host and
pair used to sit on a still gradient; they now wear the same living field at a
calm mix — pools dimmed onto the palette's own corner colour, vignette halved so
rows that run to the edges don't get crushed. On the desktop console that
collapsed the old aurora-over-static crossfade into one shader pass with a
chased uniform. Motion speed is identical in both modes on purpose: changing it
would make the field jump mid-transition. Nothing in the gamepad UI is backed by
a static image now, and Reduce Motion (Apple) / "remove animations" (Android)
still freeze it.

Also: the settings screen had no raster coverage at all — the eyeball dump is
`#[ignore]`d — so a new test draws every tab, and the Android screenshot set
gains a console-settings scene. Both earned their keep immediately: the renders
showed the extra hint pushing "Done" off a 360 dp phone (the legend scrolls now,
and the Section cell only appears where shoulders exist) and the form backdrop
crushing its own edges.
2026-08-06 12:39:30 +02:00
enricobuehler 1573a9876b fix(client): a d3d11va soak could not tell which rung it soaked
The `stats:` line's decode-path tag is derived from the DecodedImage variant,
and both D3D11VA rungs deliver DecodedImage::D3d11 — they share the hand-off
ring on purpose. So a native-d3d11va session and an FFmpeg-d3d11va session
emitted a byte-identical tag, and nothing downstream could tell them apart.
The native Vulkan rung never had this: it carries its own variant, hence its
own `native-vulkan` tag.

That is not cosmetic, and it was found the only way it could be — by running
the rung on glass and having to grep the log to prove which one had answered.
A native pin that fails to initialise falls through to the FFmpeg rung by
design; the line it then emits is exactly the line the native rung would have
emitted. M5's owed soak and M9's vendor-matrix bake both rest on attributing
a session to a rung, and until now the machine-readable half of that evidence
could not do it. This project has already shipped one measurement that could
not tell "clean" from "unmeasured"; this is the same shape.

D3d11Frame now records which rung wrote the surface, keyed off the pin
constant itself rather than a second field the two rungs could set
inconsistently — the native rung passes DECODER_PIN into the hand-off and
nothing else does.

The stats line stays additive for every shipping session: the only value that
changes belongs to a rung that is pin-only and deliberately absent from the
automatic ladder, and the Windows shell passes the line through opaquely
rather than matching on the tag.

Verified on glass on .173, both directions: pinned native-d3d11va gives 85
windows tagged `native-d3d11va` and 0 plain, pinned d3d11va gives 64 plain and
0 native, zero errors either way. Gates: clippy -D warnings on Windows, the
Linux container's clippy/tests/workspace check, rustfmt.
2026-08-06 12:34:31 +02:00
enricobuehler 3041d9bb43 test(client): M5's decoded pixels now answer to libavcodec's
The native D3D11VA rung had no pixel evidence at all. Its DXVA bytes were
checked against libavcodec's own captured bytes, and its Intel bring-up proved
the driver accepts the submission — but nothing had ever compared what came
out. This is that comparison, against the same goldens and the same reference
the Vulkan rung was held to: libavcodec's SOFTWARE decode, which is ground
truth rather than a peer implementation, so the two rungs' verdicts are now
directly comparable numbers.

It reads back the DECODE surface, before the VideoProcessorBlt, so what is
hashed is the half this rung is responsible for; the hand-off is the shared,
field-proven half and is deliberately not in the measurement.

Finding, recorded rather than papered over: this rung presents in DECODE
order. It never consults AuPlan::dpb.outputs — submit blits setup_slot and
returns. The native Vulkan rung keeps a display-order queue for exactly that
reason, and libavcodec's D3D11VA rung reorders internally, so this rung
differs from both. It cannot bite on punktfunk streams, which are zero-reorder
and carry no B pictures, but that is a convention of our hosts rather than a
structural guarantee, and a stream that did reorder would present out of order
with nothing to say so.

Both vendored vectors DO reorder — the H.265 one's first B picture at AU 3 is
what localised the RPS slot defect — so a harness hashing in decode order
would report a permutation against display-order goldens and read like a
decoder fault. Instead each decoded surface is hashed against the PicId the
planner gave it and the hashes are emitted in the planner's own output order.
The reordering is the test's, done by the planner the rung already trusts, and
`both_vendored_vectors_really_do_reorder` asserts the reason so the docs
cannot go stale silently.

The crop reads the chroma plane at RowPitch * texture height, not display
height: the decode pool is aligned to the codec's granule and is taller than
the picture. That is the 1088-row smear this project has already paid for.

Two CPU guards run in ordinary CI. This file needs its own Annex-B splitter
(pf-client-core does not depend on the vendored parser), and a splitter that
disagreed with pf-bitstream's would fail on hardware as a frame-count mismatch
that reads like a decoder defect; instead it fails on CPU, saying so.

PF_DXVA_ADAPTER pins a GPU by description substring and every run prints the
adapters it saw — .173 enumerates its AMD iGPU alongside the 4090, and which
one answered is a fact worth printing rather than inferring.

Hardware: H.264 and H.265 both 250/250 bit-identical on NVIDIA GeForce RTX
4090 and on the AMD Radeon iGPU, Windows. Gates: clippy -D warnings and the
lib tests on Windows, the Linux container's clippy/tests/workspace check, and
rustfmt.
2026-08-06 12:17:24 +02:00
enricobuehler 5d0b269e58 test(vkdecode): parity over the start-code form the host actually emits
Both vendored vectors carry three-byte Annex-B start codes throughout. The
real host emits four-byte ones on 100% of access units in both codecs —
1514/1514 H.264 and 1133/1133 HEVC, measured off the M0 NVENC corpus through
the capture hook's own .idx offsets. So every parity verdict this program has
recorded was taken on a prefix form that never ships, and the one form that
does ship was exercised by nothing.

That gap is not hypothetical. Submitting four-byte start codes to
vkCmdDecodeVideoKHR unchanged is exactly what made HEVC unplayable on every
driver tested: drivers are validated on the three-byte form, and a fixed
+3 + 2 skip into a four-byte-prefixed slice reads a nonsense pps_id — the
115 and 119 both NVIDIAs printed. H.264 was never safe here by structure,
only by its vendored encoder's convention, which is why the cure lives in the
shared ring layer and why this coverage is generic over both codecs.

Each codec's parity body now takes its access units as a parameter and runs
twice: once over the vector as it sits, once over the same vector rewritten
to four-byte prefixes. Prefix width carries no information, so both runs must
reproduce the same goldens — sharing one body is what makes that an equality
rather than two assertions that can drift.

The rewrite copies nalu.data[nalu.offset..], the same nal_size bytes the
parser hands the planner, so trailing_zero_8bits are dropped exactly where
the production parser drops them: the only difference between the two streams
is the width of every prefix.

Two CPU guards keep the new legs from passing vacuously, which is the failure
mode they are most exposed to — a rewrite that quietly returned its input
would make them trivially green and nothing on the fleet would notice. They
assert the original really does carry three-byte prefixes, that the rewritten
stream carries none, that the NAL count is preserved exactly, and that the
planner still yields 250 pictures.

Hardware: all four legs 250/250 bit-identical to libavcodec on two
independent driver stacks — AMD VanGogh on RADV/Mesa 26.0-devel (the Steam
Deck) and NVIDIA 610.43.03 on Linux. NVIDIA is the family that rejected the
four-byte form outright, so it is the meaningful witness for this regression.
2026-08-06 12:02:03 +02:00
enricobuehler db15c2615d fix(vkdecode): HEVC decoded from the wrong references, and told drivers the
wrong slice offsets

Two independent defects, both in this crate. pf-bitstream is untouched — its
HEVC plans were sound all along, which the D3D11VA rung proves by rendering
correctly from the same AuPlans.

The corrupter: StdVideoDecodeH265PictureInfo's RefPicSetStCurrBefore,
StCurrAfter and LtCurr carry DPB SLOT indices. We wrote positions in the
reference list. libavcodec's Vulkan HEVC hwaccel — the implementation every
driver is validated against — writes the index into its own DPB array and
passes that same value as slotIndex, while packing pReferenceSlots densely
over the used entries; the two numberings are provably different there, and
the RPS arrays follow the slot.

The two readings coincide on a freshly anchored stream, because the references
then occupy slots 0..n in reference-list order. They first diverge at the
vendored vector's first B picture, AU 3, where refs are slots 0, 2, 1 — so we
named slots 1 and 2 where the picture wanted 2 and 1, and every later access
unit inherited the error through its own references. That is why this shipped
and why review could not see it: correct for the opening pictures, wrong from
the first reordering onward.

It also accounts for the measurements exactly. Display order maps to decode
order as display 0 from AU 0, display 3 from AU 1, display 2 from AU 2,
display 1 from AU 3 — so the three frames that matched on AMD are precisely
the three access units where positions and slots agree, and 250 - 3 = 247 is
the divergence count that was measured. From AU 6 the named slots stop being
merely wrong and become unbindable by that operation, which is where NVIDIA
stopped reporting a verdict at all.

The diagnostic: every slice offset must point at a THREE-byte start code.
libavcodec discards the stream's prefix and writes 00 00 01, so that is the
only pattern drivers are validated on, and pf-dxvadec's packer already
normalised for exactly this reason and said so in its docs. This path uploaded
the prefix verbatim. 249 of 250 HEVC slice segments in the vendored vector
carry a four-byte prefix; all 500 H.264 slices carry three.

That derives the driver's own complaint bit for bit. A decoder reaching the
slice header by a fixed skip lands on the NAL header's second byte, reads
first_slice_segment_in_pic_flag as 0, and then takes six bits of the real
slice header as the tail of a long ue(v): 0xd0 gives 115, 0xe0 gives 119. A
P-slice header and a B-slice header — which is why exactly two bogus pps_id
values ever appeared.

⚠ H.264 was NOT protected structurally, only by its encoder's convention, and
the real host does not share that convention: every one of 1514 H.264 access
units and 1133 HEVC access units captured from an NVENC host prefixes its
slices with FOUR bytes. The vendored H.264 vector is therefore not
representative of what ships, and its bit-exactness was passing on a prefix
form the field never sends. The normalisation lives in the shared ring layer
and covers both codecs for that reason.

rebased_offsets is replaced by pack_slices, which trims the leading zero byte
and computes the offsets from the trimmed lengths in one call, so the bytes and
the offsets cannot drift apart; upload and the CPU test go through the same
pack_into.

Hardware, after the fix — H.264 AND H.265 both 250/250 bit-identical to
libavcodec, all four smoke legs green:

  NVIDIA RTX 4090      610.88     Windows   coincide
  AMD Adrenalin        25.10.30.02 Windows  distinct
  NVIDIA RTX 5070 Ti   610.43.03  Linux     coincide

On glass on the 4090 against a real NVENC host, 2800x1260 HEVC through the
auto ladder: 73 one-second windows all native-vulkan, fps avg 59.3 of 60,
decode 1.1 ms, e2e 4.5 ms p50, and ZERO driver-reported status failures where
the same session before the fix logged 1489 in 181 seconds and had dragged ABR
down to a 5 Mb/s target. No refusals, demotions, PlanWarnings, concealment,
DEVICE_LOSTs or panics.

Both defects now have CPU tests that were confirmed FAILING before the fix:
one walks every access unit of both vendored vectors and asserts each declared
offset opens on a three-byte start code, its own NAL header and a
first_slice_segment_in_pic_flag consistent with the segment index; the other
resolves every RPS entry by slot and asserts that 247 access units disagree
with the positional reading, so it cannot go vacuous on a stream where the two
happen to agree.
2026-08-06 09:56:25 +02:00
enricobuehler 5c6b09a5c5 test(vkdecode): the HEVC GPU legs, which find M3 broken on every driver
M3 was recorded as code complete. Its exit criteria named the HEVC gpu_smoke
and gpu_parity legs, and the goldens for them were committed — 250 per-frame
NV12 hashes, cross-checked between two independent FFmpeg builds, with a
header saying they are "consumed the same way by the HEVC parity test". No
such test existed. Both GPU files were H.264 only, with zero references to
h265, so nothing had ever decoded a single HEVC frame through this crate on
hardware.

They exist now, and the first run answered. On AMD Adrenalin 25.10.30.02
(distinct mode, queryResultStatusSupport=false) 247 of 250 frames diverge
from libavcodec, and that device's smoke leg PASSES — because smoke only
reads the driver's verdict and that driver reports none. That is the Ally X
class, reproduced in-house on demand: output that is wrong everywhere the
picture is looked at and clean everywhere the decoder is asked. Both NVIDIA
drivers reject the stream outright and name the cause themselves,
"Invalid PPS/SPS id in slice header (pps_id=119 / 115)" — the identical two
values, and the smoke leg dies at the identical AU 9, on a 4090 under 610.88
on Windows and on an RTX 5070 Ti under 610.43.03 on Linux. Same wrong values,
same access unit, two GPU generations, two operating systems: deterministic,
and therefore ours rather than any driver's.

It is not an ordering fault. Five of the divergent hashes appear nowhere in
the 250 goldens, so the pixels are wrong rather than correct-but-reordered.
Parity dies at frame 1 while smoke dies at AU 9 only because smoke holds four
frames before it looks; the first inter-predicted picture is already corrupt.

The legs are committed ahead of the fix deliberately. They are the regression
test for the defect, they are #[ignore]d so no CI leg changes colour, and the
evidence above is worth recording in the order it was obtained.

Adding a third and fourth copy of ~150 lines of unsafe Vulkan bring-up was
not acceptable, so it moved to tests/common. The two behavioural differences
between the callers are now named parameters rather than accidents: the parity
legs read back on a graphics queue and require one, while the smoke legs
accept a decode-only device and fall back to the decode family — which also
decides whether pool images are EXCLUSIVE or CONCURRENT, so it is load-bearing
rather than cosmetic. H.264 came through the refactor unchanged, verified two
ways: argument-by-argument against the previous file, and on hardware, still
250/250 bit-identical on NVIDIA Windows, AMD Windows and now NVIDIA Linux.

The loader is deliberately leaked at teardown. ash::Entry owns the Arc<Library>,
so dropping it unloads the Vulkan loader with every ICD and implicit layer;
harmless while each binary held one GPU leg, but each now holds two, and the
second would re-open a loader the first had torn down.

Three guards run without a GPU, because everything above is #[ignore]d: the
golden file's count and digest shape, the HEVC access-unit split agreeing with
what the CPU planner emits (with iraps == 1 pinning "no CRA anywhere", so a
re-synced vector that opens with one fails here rather than as a frame-count
mismatch on the fleet), the vector staying Main 4:2:0 8-bit since both legs
hard-code that probe, and a refusal to run the smoke legs with
PF_VKD_TEST_READBACK set, which would quietly grow the pool a usage flag
production never carries.
2026-08-06 09:47:30 +02:00
enricobuehler 9c10b97e31 fix(client): M5's DXVA bytes now answer to libavcodec's own
The native D3D11VA rung was committed with zero hardware evidence and, more
to the point, zero evidence of any kind: every claim it made about the DXVA
structures rested on reading the specification and reading libavcodec, and
reading is exactly the method that produced the four defects the last review
found. Three of those lived where a smoke test cannot look — in the buffer
descriptors — so a passing session would have proved nothing about them.

So this compares our submission against libavcodec's, byte for byte, on the
same access units of the same two vendored vectors. The reference side comes
from an FFmpeg n8.1 cross-built for Windows with the DXVA paths instrumented
(the recipe is in the harness docs); the comparison covers picture parameters,
quantisation matrices AND the buffer descriptors, 250 AUs per codec:

    H.264 / HEVC picture parameters    250 AUs, no undocumented divergence
    H.264 / HEVC quantisation matrices 250 AUs, no undocumented divergence
    H.264 / HEVC buffer descriptors    250 AUs, no undocumented divergence

It found a real defect immediately. The DXVA short slice record is TEN bytes
— dxva.h packs these bitstream-layout structs to a byte — and this crate
declared it `repr(C)`, which pads {u32,u32,u16} to twelve. libavcodec's own
descriptors say so twice over: 20 bytes of slice control for a two-slice
H.264 picture, 10 for a one-slice HEVC one. Record 0 survives either way
(its fields sit at 0/4/8 regardless), so the mistake is invisible on a
single-slice stream and displaces every later record by two bytes on a
multi-slice one — which punktfunk hosts emit. Both records are now
`repr(C, packed)`, and the HEVC slice-control test grew a second record
because one record is the shape that hid this.

The audit that followed matters more than the fix. Per-field offset asserts
cannot see TAIL padding, which is what this was, so all six hand-declared
structs now also assert that their size equals the last field's offset plus
that field's own size. Under that rule the slice records were the only place
packed and natural alignment disagree — 1040, 232, 224 and 1000 were right
all along, and now provably rather than luckily. The module docs claimed
`repr(C)` "reproduces MSVC's default packing exactly for that shape"; that
was a guess wearing a proof's clothes, and it is gone.

Two differences are documented rather than fixed, each with the argument for
why it is inert. libavcodec seeds prev_poc_msb = 1 << 16 at every IDR, so its
POCs are the specification's plus 65536 uniformly; every use a driver makes
of those fields is a difference, and references match on FrameNumList, so the
harness compares POCs relative to that constant and requires it to hold on
every AU rather than importing a magic number into a derivation the Vulkan
rung shares. And HEVC's loop_filter_across_tiles_enabled_flag is inferred 1
by 7.4.3.3.1 when the PPS codes no tiles while libav leaves it 0, with tiles
disabled either way. Both ride a channel that always prints, and both are
guarded by tests that synthesise the differences an allowance must NOT
absorb — a documented divergence that swallows a real defect would be worse
than no harness at all.

Everything checkable without a capture is now a non-ignored test: the buffer
set and order per codec, NumMBsInBuffer's codec asymmetry (mb_width*mb_height
on H.264's bitstream and slice-control buffers, zero everywhere for HEVC),
the three 7.4.5 scaling-list cases, contiguous slice records tiling DataSize,
the 128-byte padding charged to the last record and no other. That is the
part which would have caught the last round's defects with no hardware at all.
2026-08-06 09:46:53 +02:00
enricobuehler 31087697a9 feat(client): M5 — native D3D11VA decode, pin-only pending hardware
The Windows fallback rung, and auto's first choice on Intel, now has a
native implementation driven by pf-bitstream's plans instead of
libavcodec. New crate pf-dxvadec holds everything that can be a pure
function — the DXVA structure layouts, both codec conversions, bitstream
packing, config selection — deliberately CROSS-PLATFORM, because a
cfg(windows) module is verified by a remote cargo check and nothing
else, and this milestone's riskiest code is exactly the part no local
test can see. Only the FFI lives in video_d3d11_native.rs.

windows-rs does not generate dxva.h at the pinned rev, so the DXVA
structures are hand-declared: compile-time assertions on every struct
size AND every field offset, packed bitfield words as plain integers
with named builders and the bit positions written beside the C
declaration, and a const zeroed() per struct so construction needs no
unsafe at all. The crate's only unsafe is a sealed byte view over those
PODs. Review round 13 checked all seven layouts field by field in
declaration order — sizes, widths, array lengths, the PicEntry
index/flag packing, and every named bit's position and width.

The decode pool reproduces libavcodec's rather than inventing one:
ONE texture with ArraySize = pool size, BIND_DECODER and nothing else,
MiscFlags 0, aligned 16 for H.264 and 128 for HEVC. That is deliberate.
This rung's predecessor records that a hand-built pool which validated
on NVIDIA was rejected by Intel at the first SubmitDecoderBuffers — and
Intel is the vendor this rung exists for. The VideoProcessorBlt into
shareable RGBA is untouched: importing a multiplanar NV12 D3D11 texture
into Vulkan device-losts on NVIDIA, so that hand-off is load-bearing
field-proven code. It was extracted into a shared HandoffRing so both
rungs fill one implementation; the review diffed the blit statement by
statement, including the keyed-mutex pairing.

Review round 13's four defects are fixed. The blocking one: the HEVC
quantisation matrix was submitted unconditionally, and the vendored
parser leaves it ALL ZEROS unless the stream codes one — unlike FFmpeg,
which seeds the spec defaults. On a stream saying 'use the default
matrices' the driver is obliged to apply what it is handed, so every
residual would dequantise to zero and the picture would drift to flat
prediction. It is now gated on scaling_list_enabled_flag exactly as
libav gates it, with the Table 7-5/7-6 defaults supplied when enabled
but uncoded.

Second: NumMBsInBuffer was 0 where libav's H.264 path sets
mb_width * mb_height. This module's whole method is verbatim
reproduction on precisely the call that once failed for Intel, so an
omitted descriptor field is the same class of bug as the pool.

Third, and the one to watch on hardware: RefFrameList carried the
frame's reference set rather than the pictures marked used for
reference. Vulkan defines pReferenceSlots as the slots this operation
uses, so a subset is correct there; DXVA defines RefFrameList as a
statement about the DPB. The list DERIVATION survives a subset — which
is exactly why a smoke test would have passed — but a long-term
reference held across frames that none of them name would vanish and
reappear, and a driver keeping per-reference state is entitled to
discard it in between. That is the Ally X symptom shape. pf-bitstream
now exposes a per-AU DPB snapshot for both codecs and the converters
build the array from it, frame references first, marked tail appended.
121 of the 250 vendored AUs carry a marked picture the frame never
names, so this is exercised, not theoretical.

Fourth: the session identity omitted bit depth and chroma, while the
Windows host flips an HDR desktop to PQ in-band with a new SPS — a
depth change at unchanged size would have decoded 10-bit samples into
an NV12 pool. Identity now derives from the SPS per AU and rebuilds.

Wired PIN-ONLY (PUNKTFUNK_DECODER=native-d3d11va), absent from every
auto arm. Nothing has decoded a frame yet, and M2's discipline was that
auto admission comes only after hardware parity. A runtime streak
demotes to the FFmpeg D3D11VA rung first, then software.

Also scaffolded: a byte-diff harness against libavcodec's own DXVA
picture parameters, with the FFmpeg patch and capture recipe in its
docs. Nothing here is checked against libav's actual bytes the way M3
was checked against its pixels, and that is the cheap way to buy the
confidence before hardware.

Gates: fmt clean; container clippy -D warnings zero across
pf-client-core + pf-presenter + pf-vkdecode + pf-dxvadec +
punktfunk-core; tests 73/131/63/129/354 green; cargo check --workspace
clean; Windows cargo check and clippy -D warnings clean on .173.
2026-08-06 06:11:21 +02:00
enricobuehler 2a57ee36f8 feat(client): M4 — the decoder's own verdict reaches the session
This program exists because a field corruption was architecturally
undetectable through FFmpeg: no decode-status read, no corrupt-frame
flag, errors only as scraped log lines, and no recovery-point signal so
intra-refresh healing was invisible. The native decoder has all of those.
M4 is where they stop being internal.

DecodeHealth counts, per session and without allocating per frame, what
the three answers actually are: damaged (the stream arrived incomplete),
refused (the rung would not decode it at all) and driver-failed (the
hardware says it could not decode what arrived), plus the current and
worst concealment run — the figures that separate one bad AU from a
stream that never came back. They ride the stats line additively, so an
FFmpeg session and a healthy native session emit byte-identical output
to today. The status-query capability is reported too: without it a
clean report cannot be told from an unmeasured one, which is the whole
nb_queries=0 lesson.

The headline is local recovery. Until now the pump could only learn that
intra-refresh healing finished from wire flags the host sends; absent
those it froze until the 500 ms backstop forced an IDR. The parsed
recovery-point SEI now feeds the re-anchor gate directly, so a session
lifts on the picture that is actually clean. Wire semantics are
untouched for every client that never calls it.

Detection now asks for recovery instead of erroring — an integrity
warning ticking the error streak would demote the native rung on exactly
the lossy links it exists to diagnose, where an FFmpeg rung conceals
silently and keeps its job.

Review round 12 found that trade had removed the escape hatch entirely.
Concealment returning Ok(None) reset the demotion streak, and worse: the
driver-verdict ledger is only populated when a frame ships, so under
continuous concealment no verdict was ever read and the erroring arm
could not fire at all. A host framing regression of the 0.23.0
slice-wire class — which does not self-heal, and which a keyframe does
not clear — would have frozen indefinitely with no demotion and a clean
integrity line, where before it demoted to FFmpeg-Vulkan and showed a
picture. Now only an answer that proves the rung works clears the
streak: a shipped frame, or a clean no-frame. Concealment neither ticks
nor clears, so a lossy link still cannot demote a healthy rung while a
driver failure interleaved with concealment reaches the threshold again.

Two more honesty defects from the same round. A rung refusing every AU
reported no integrity line at all — the founding failure mode, wearing
the shape of a clean bill of health; refusals are now counted. And
driver-failed could be non-zero on a device that cannot produce driver
verdicts, because a degraded timeline read looked the same as one; the
attribution is now withheld inside the counter rather than at call
sites, so the self-contradictory line is unrepresentable.

Local recovery also no longer trusts any recovery-point SEI: only one
whose target advances past an outstanding wave counts as a new wave, so
an encoder re-announcing the current wave with a decreasing count — legal,
and what x264 intra-refresh does — cannot lift the freeze early onto a
partially stale picture. Frames buffered across an arm are dropped by
decode order for the same reason.

Fault injection is a first-class tool now (PUNKTFUNK_AU_FAULT, inert
unless set, env read once). Its test replays the vendored vectors
through the real planners and asserts a negative the plan assumed away:
truncation and bit flips are PROVABLY invisible to the parser — Annex-B
carries no NALU length, so a cut slice is just a shorter slice and a
flipped payload byte is syntactically perfect. Only dropped AUs are
parser-detectable; the rest need the driver verdict, which is why the
status query matters. The H.265 leg found a second: three of that
vector's faulted AUs are sub-layer non-reference pictures, so dropping
them damages nothing and silence is correct — the test asserts both
verdicts and guards that neither half goes vacuous.

Per-frame decode latency was deliberately NOT built. Polling answers
only 'complete by now', and the pump polls once per AU, so every sample
would quantise up by as much as a frame interval — 8.3 ms at 120 Hz
against decodes of 0.1-2 ms. Sampling faster needs a spin or a second
thread on a decoder that is deliberately not Sync. A blocking per-frame
wait is the field scar that once capped a stream at 51 fps. The honest
sampled stat stands.

Also fixed, pre-existing: the re-anchor gate re-armed on every damaged
AU, so sustained damage permanently zeroed the mark count — meaning the
wire's two-mark rule could never complete on exactly the lossy links it
was written for.

Field note recorded while wiring this: intra_refresh_recovery is set by
exactly one encoder backend (Linux libav-NVENC under
PUNKTFUNK_INTRA_REFRESH). AMF and QSV run a wave with no wire mark, and
AMF emits no recovery-point SEI either, so AMD/Windows intra-refresh
sessions still have no clean recovery point by either route.

Gates: fmt clean; container clippy -D warnings zero across
pf-client-core + pf-presenter + pf-vkdecode + punktfunk-core; tests
69/131/129/354/41 plus 5 fault-detection green; cargo check --workspace
clean.
2026-08-06 04:30:40 +02:00
enricobuehler e4d8573475 feat(client): the native rung now decodes HEVC as well as H.264
The last piece of M3 WP-2 — VkH265Decoder was built and hardware-gated
but nothing drove it. video_vk_native.rs holds a two-arm codec enum and
forwards to it; the ledger, release tokens, status-query settling and
timeline waits are byte-for-byte what they were, since they were always
codec-agnostic over one DecodedVkFrame contract. The forwarders are
written out per arm rather than macro'd so the unchanged H.264 arm is
visible to a reviewer.

The picture's own format now reaches the presenter, which picks bit
depth and MSB packing from it instead of assuming the H.264 envelope.
That incidentally fixes a live bug on the SHIPPING FFmpeg-Vulkan path:
it derived ten-bit-ness by comparing against the 10-bit 4:2:0 format
alone, so a 10-bit two-plane 4:4:4 surface — which its own format table
accepts, and which NVIDIA reports for HEVC RExt — got 8-bit range and
transfer maths. Reachable today with Full chroma plus 10-bit: decoded
correctly, displayed wrong.

Review round 11 caught a regression this WP would otherwise have
shipped. pf-vkdecode refuses a stream whose (chroma, depth) pair has no
picture format on the device, but the session is built lazily from the
first SPS, so the refusal arrived AFTER construction — past the point
where a native init failure falls through to FFmpeg-Vulkan. It burned
the error streak instead and demoted to VAAPI/D3D11VA, which on
NVIDIA/Linux means software. Turning on Full chroma on any non-NVIDIA
GPU was enough: a 4K HEVC session that ran on FFmpeg-Vulkan before this
branch would have landed on software decode.

Both halves are fixed. The negotiated chroma and bit depth — already at
the call site, the PyroWave arm four lines up uses them — are threaded
into the backend, which probes the same caps path ensure_state would
run, so the whole class refuses at CONSTRUCTION where the fall-through
already exists. For the legs no negotiation can carry (a level above
maxLevelIdc, an SPS that disagrees with the Welcome) the decoder latches
'never delivered a frame' and routes that first streak to FFmpeg-Vulkan
rather than down the hardware ladder. H.264 is deliberately not probed:
its envelope is fixed, so a probe would only add a profile guess on the
bit-exact path; it gets the latch as its backstop.

Two more from the round. Planner warnings are typed again rather than
Debug strings — pf-vkdecode simply lacked the h265 re-export its h264
twin already had — which restores the H.264 log rendering exactly and
unblocks M4, whose job is counting concealment by kind. And concealment
is now the integrity set only: NonZeroReorder is documented spec-legal
and fully planned, but the client treated every warning as damage, so
the opening IDR and every ABR renegotiation's IDR were released unshown
and re-anchored — a visible hitch on a healthy stream.

Also: a raw-format newtype so a neighbouring i32 field cannot be passed
to the colour maths, the presenter's depth table now pinned against
pf-vkdecode's actual output vocabulary rather than the FFmpeg lane's,
a per-format warn latch, and four stale docs.

Gates: fmt clean; container clippy -D warnings zero across
pf-client-core + pf-presenter + pf-vkdecode; tests 69/125/108/40 green;
cargo check --workspace clean.
2026-08-06 03:02:09 +02:00
enricobuehler 6d8f3b45b5 feat(pf-vkdecode): the GPU half of HEVC decode — session, pools, recording
M3 WP-2 complete. caps_h265.rs builds the profile the stream actually
needs (profile idc + chroma + bit depths, all three stated on every
Vulkan object) and resolves its picture format — Main to NV12, Main 10
to P010, RExt 4:4:4 to the two-plane 4:4:4 formats — validating it
against the format list of every role the chosen arrangement creates
images in. A Main 10 stream on an 8-bit-only device is refused BEFORE a
session exists, never narrowed: decoding 10-bit into an 8-bit surface is
the silent-wrongness class this crate exists to refuse. session_h265.rs
adds the three-array parameters ledger; decoder_h265.rs adds
VkH265Decoder, mirroring VkH264Decoder method-for-method so the client
wiring is a two-arm dispatch away.

H.264 and H.265 now SHARE the machinery instead of duplicating it:
derive_arrangement (one coincide/distinct/layered decision table),
ring::rebased_offsets (the slices-only rebase — non-VCL NALUs in the
decode range hang VCN firmware), session::bind_session_memory, and a
parameterised build_frame. A DecodeProfile enum replaces the bare
profile idc that images.rs and ring.rs used to take: both codecs' idc
types are c_uint, so handing an H.265 idc to the H.264 path COMPILED
SILENTLY and built a mismatched profile chain. That is now
unrepresentable.

The VPS leg is the ledger's real work. The vendored parser attaches a
VPS to an SPS only when it saw the NALU, and clients join live streams,
so VpsSource is Parsed-or-FromSps and is stored BY VALUE: re-activating
a VPS-less SPS is Current (no churn), but the real VPS arriving under
the same id is a content change and RECREATES onto it, because Vulkan
cannot replace a stored parameter set.

Review round 10 (adversarial) confirmed the hardware-proven H.264 path
is NOT regressed — derive_arrangement's check order and error identity
are byte-for-byte the original, build_frame's call sites still pass the
granularity-aligned extent (the 1088-row scar stays shut), and
rebased_offsets reproduces the deleted inline loop for every input while
moving the sum to u64 so overflow errors instead of wrapping. Also
verified: the refs-order contract on every path, the RESULT_STATUS caps
gate (each of reset/begin/end individually gated, no pool created when
unsupported — recording one on RADV hangs its VCN), pNext lifetimes, and
that no panic is reachable on stream input.

Its 10 findings are fixed. The two that mattered:

- A failed decode stranded a DPB slot. Once plan_to_vk_h265 had mutated
  the slot map, five later failure paths returned without restoring it,
  so planner and slot map both believed a picture was resident while no
  image held it — and every later AU referencing it failed, where H.264
  soft-degrades and keeps delivering. Fail-closed is kept (substituting
  a reference silently is the corruption-hiding this program exists to
  end) but made RECOVERABLE: a latch flushes the planner to AwaitingIdr
  and resets the bindings on the next decode, which composes with the
  client already requesting a keyframe on every decode error. The fix
  deliberately covers pre-mutation failures too — those strand the
  picture the other way round and wedge identically.
- DecodedVkFrame carried no picture format, so a Main 10 frame would
  decode correctly and be rendered with 8-bit transfer/range math. It
  now carries one, stamped from the pool so it is truthful for both
  decoders by construction. The presenter comment says depth 8 is
  because only H.264 is WIRED, not a decoder limit.

Plus: bind_session_memory freed allocations before the session that may
hold them was destroyed (an ordering regression from the extraction,
with a SAFETY comment asserting the opposite) — the bind-stage exit now
hands them back so Drop destroys first; max_level_idc is codec-tagged
rather than an H.264 type carrying H.265 code points; and the decode
family's videoCodecOperations is now checked, turning 'create an H.265
session on a device without the extension' from UB into a clean ladder
demote.

Deferred by design: no HEVC gpu_smoke/gpu_parity yet (its goldens are
already in tests/data/test-25fps-h265.nv12.sha256), and no codec
dispatch in the client — both later legs.

Gates: fmt clean; mac clippy zero warnings, pf-vkdecode 106 +
pf-bitstream 69 green; container clippy -D warnings zero for
pf-client-core + pf-presenter + pf-vkdecode, tests 69/121/106 green.

HARDWARE (.173, after the refactor — review saying the proven path is
safe is not the GPU saying it): gpu_parity '250 frames bit-identical to
libavcodec software decode' on BOTH the NVIDIA 4090 (610.88, coincide
mode) and the AMD iGPU (Adrenalin 25.10.30.02, distinct mode), gpu_smoke
green on both. Two independent drivers, both DPB modes, still bit-exact.
The smoke trace also shows the new videoCodecOperations capture reading
DECODE_H264 | DECODE_H265 | DECODE_AV1 off the real decode family.
2026-08-06 01:59:39 +02:00
enricobuehler c985438db1 test(pf-bitstream): replay real host captures through the planners + HEVC goldens
M0's capture hook has been in since 119ec0dd with nothing consuming its
output. corpus_replay.rs is that consumer: point PF_CORPUS at an
au-<stamp>.<codec> capture and every AU walks back through the H.264 or
H.265 planner, asserting no errors and no warnings — a clean capture of
a healthy session must plan whole. Ignored by default (captures are
hundreds of MB and live outside the repo).

It earns its keep immediately. Captured on .173 against the live host
(NVENC, 2800x1260, ~30 s each, client-side codec pin only — no host
config touched):

  h265  1133/1133 AUs planned, 0 errors, 0 warnings
  h264  1514/1514 AUs planned, 0 errors, 0 warnings

The HEVC number is the point: it is the FIRST validation of the WP-1
h265 planner against real host output rather than the vendored
conformance vectors, and it lands before the client's HEVC rung exists
to produce on-glass evidence.

Two real-capture facts the harness had to learn, both from this run:
ending a capture means killing the client, so the final .idx line is
routinely half-written and the final AU's bytes may not all have landed.
Both are tolerated at the TAIL only — a malformed line anywhere else, or
a gap the data cannot cover mid-file, still fails loudly rather than
silently replaying a subset.

Also adds tests/data/test-25fps-h265.nv12.sha256: 250 per-frame NV12
hashes of the vendored HEVC vector from libavcodec's software decoder,
cross-checked frame-for-frame between two independent FFmpeg builds
(8.0.1 in pf-lxcheck2, 8.1.1 from Homebrew) — the sibling of the H.264
goldens, ready for WP-2's parity leg.

Gates: fmt clean; pf-bitstream clippy clean, 69 tests green (the replay
stays ignored in normal runs).
2026-08-06 01:08:50 +02:00
enricobuehler 25b08916b6 fix(flatpak): the WSI layer module builds again — vkroots was declared twice
ci / web (pull_request) Successful in 57s
ci / docs-site (pull_request) Successful in 1m45s
ci / rust-arm64 (pull_request) Successful in 2m19s
ci / rust (pull_request) Successful in 6m22s
The flatpak has not built since 35ba64ca. Every push to main fails at "Build the
flatpak", before a single build command runs:

  cp: cannot overwrite non-directory
    '.../build/gamescope-wsi-layer-1/subprojects/vkroots/.git'
    with directory '.../git/https_github.com_Joshua-Ashton_vkroots.git'
  Error: module gamescope-wsi-layer: Child process exited with code 1

vkroots was declared twice. flatpak-builder clones git sources WITH SUBMODULES by
default, and `subprojects/vkroots` is a real gamescope submodule — `git ls-tree
8c676c39 subprojects/` shows it as mode 160000 at 5106d8a0, which is byte-for-byte
the commit the explicit source pinned. So the submodule checkout already produced
the right tree and left `subprojects/vkroots/.git` as a gitlink FILE; the second,
redundant source then tried to copy the bare mirror onto that path as a DIRECTORY,
and cp refused. Source extraction died there — `buildsystem: simple` and the
hand-applied glm/stb patch_directory copies were never reached, so neither is at
fault.

Removing the redundant source is therefore a no-op on the resulting tree: the
submodule supplies that exact rev. glm and stb are NOT submodules — `subprojects/
glm.wrap` and `stb.wrap` are plain blobs at that rev — so nothing else populates
them and their explicit sources have to stay. That asymmetry is the whole trap,
and it is now written down in the manifest next to the sources, along with the
disable-submodules escape hatch for anyone who later needs to pin a subproject
away from the gamescope rev.

Why this reached main: flatpak.yml has no `pull_request:` trigger — only `push` on
main with path filters, `tags: ['v*']`, and workflow_dispatch. PR #64's checks were
green because the flatpak was never built on the PR; run 15775 was the first time
this module had ever been built in CI. Adding a PR trigger (or a manifest lint) is
the durable follow-up, deliberately not bundled here.

This blocks the release, not just main. flatpak.yml runs on `tags: ['v*']`, and the
failing step gates the bundle export, the generic-registry publish, the OSTree push
to flatpak.unom.io and the release-asset attach — all of which stay skipped. A
v0.25.0 tag cut today would ship with NO Linux/Steam Deck flatpak at all, on the
release whose headline Linux change is Deck HDR working out of the box.

NOT VALIDATED LOCALLY: this cannot be built on macOS. The reasoning is confirmed
against the upstream tree (the ls-tree above) but the green run is still owed —
dispatch flatpak.yml on this branch before merging.
2026-08-06 00:49:25 +02:00
enricobuehler 55bc664eca docs(pf-bitstream): the two upstream cros-codecs bugs are now reported
PROVENANCE deviations #6 and #7 carried 'report upstream' — done:
- chromeos/cros-codecs#99: h264 PictureData display_resolution
  double-counts the left/top crop and underflow-panics on
  parser-valid crop offsets (answers their open #81).
- chromeos/cros-codecs#100: h265 parse_slice_header index-OOB panic
  when num_long_term_sps + num_long_term_pics > 16 — a hostile-input
  panic on the LTR path (an instance of their #78).

Both reports offer the downstream patch for the AOSP tree.
2026-08-06 00:44:18 +02:00
enricobuehler a34f4051fc feat(pf-vkdecode): the CPU half of HEVC decode — StdVideo H265 conversion + slot map
M3 WP-2, first half. params_h265.rs: VPS/SPS/PPS -> StdVideoH265* with
owned pointer-backing (the params.rs contract), scaling lists incl. the
32x32 two-matrix quirk and +8 DC convention, short-term RPS re-encoded
from the parser's RESOLVED DeltaPoc arrays back into delta_poc_sX_minus1
syntax under monotonicity checks, fallback_vps_from_sps for streams
whose VPS NALU was lost. pic_h265.rs: plan_to_vk_h265 — h265 AuPlan ->
StdVideoDecodeH265PictureInfo + per-reference infos; the binding set is
the union of the three current RPS sets with the Std index arrays
indexing into refs (0xFF unused; the GPU half must lay pReferenceSlots
out in refs order); NumDeltaPocsOfRefRpsIdx from the predicted-from
candidate; transactional SlotMap lifecycle identical to pic.rs. SlotMap
reused unmodified — HEVC's ceiling equals H.264's 16+1.

Envelope fails closed: Main/Main10/MainStill/RExt only, 4:2:0-8/10 +
4:4:4 only (separate_colour_plane_flag rejected — ChromaArrayType 0 in
disguise), SCC palette predictors out, >64 ST RPS sets / >16 per side /
>32 LT SPS candidates out, checked narrowing on every narrower Std
field. No panics on untrusted input.

Review round 9 (adversarial): RPS re-encode math, Std field-by-field
conformance, transactionality and slot ceiling verified clean; 6
findings fixed pre-commit. Headline (BLOCKING): long_term_ref_pics_
present_flag=1 with num=0 left pLongTermRefPicsSps NULL — the header
demands a valid pointer whenever the flag is set, and flag=1/num=0 is
exactly the punktfunk LTR/RFI recovery stream shape; the all-zero
backing now rides whenever the flag is set. Also: the slice_offsets doc
in BOTH pic modules claimed submit-as-planned while decoder.rs packs
slices-only and rebases (non-VCL NALUs in the decode range hang VCN
firmware) — reworded so the HEVC GPU half cannot implement the hang; a
concealment-produced ST/LT duplicate now ORs the long-term flag across
occurrences; NumDeltaPocs clamps became a typed error; dead
UnmappableLevelIdc variant dropped.

Deferred to the GPU half: HEVC caps/profile chain, session parameters
(VPS leg in the ledger), P010/4:4:4 pool selection, recording, and the
pReferenceSlots-in-refs-order contract consumption.

Gates: fmt clean; mac pf-vkdecode 80 + pf-bitstream 69 green, clippy
clean; container clippy -D warnings zero (pf-client-core, pf-presenter,
pf-vkdecode) + tests green (69/121/80).
2026-08-06 00:41:23 +02:00
enricobuehler 35ba64ca0f Merge pull request 'fix(flatpak): Deck HDR works on a plain install' (#64) from worktree-deck-hdr-wsi-env into main
ci / rust-arm64 (push) Failing after 4s
ci / rust (push) Failing after 4s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 19s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
docker / builders-arm64cross (push) Skipped
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 23s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m8s
docker / deploy-docs (push) Successful in 28s
flatpak / build-publish (push) Failing after 3m9s
Reviewed-on: #64
2026-08-05 22:21:51 +00:00
enricobuehler 5f71aeb024 feat(flatpak): vendor the gamescope WSI layer so Deck HDR works on a plain install
ci / web (pull_request) Successful in 56s
ci / docs-site (pull_request) Successful in 1m6s
ci / rust-arm64 (pull_request) Successful in 2m10s
ci / rust (pull_request) Successful in 7m59s
HDR on a Deck needed a manual second step nobody took:
  flatpak install --user flathub org.freedesktop.Platform.VulkanLayer.gamescope//25.08
documented only in a comment in this file. Build the layer ourselves
instead, so a plain `flatpak install` is all it takes.

The layer is genuinely required, not legacy. Measured on SteamOS 3.8.16
(gamescope 3.16.23.4): the gamescope-0 socket advertises
gamescope_swapchain_factory_v2 but NOT wp_color_manager_v1, with HDR both
off and on — so Mesa's Wayland WSI has no colour-management protocol to
negotiate HDR10 through, and this layer is the only thing that can append
the ST.2084 surface formats. Removing the extension gives zero
[Gamescope WSI] lines and hdr10_format=None.

Vendored rather than declared via add-extensions autodownload: the
extension is 94 MB of whole-gamescope for one 4 MB .so, its layer JSON
hardcodes a /usr library_path that an app-scoped extension mounted under
/app would not satisfy, and it would make flathub a hard install-time
dependency of an app we self-host on flatpak.unom.io.

enable_gamescope=false skips subdir('src') and every compositor
dependency, so only protocol/ and layer/ build. buildsystem is simple
rather than meson because glm and stb ship no meson.build of their own -
the wraps' patch_directory supplies it, and without that copy configure
dies with "Subproject exists but has no meson.build file".

meson generates the layer JSON from prefix+libdir, so it self-writes
library_path=/app/lib/... into /app/share/vulkan/implicit_layer.d, which
XDG_DATA_DIRS already covers. VK_ADD_IMPLICIT_LAYER_PATH is therefore
dropped - keeping it would also risk double-loading two same-named layers
for anyone who still has the flathub extension installed.

Pinned to the same gamescope rev as packaging/gamescope/PKGBUILD so the
client's layer and the host's punktfunk-gamescope come from one tree.

Verified on a Deck OLED: builds offline (--wrap-mode=nodownload) in
org.gnome.Sdk//50, and the resulting .so drives the Deck's system
gamescope to "hdr formats exposed to client: true" with
hdr10_format=Some(A2B10G10R10_UNORM_PACK32, HDR10_ST2084_EXT).

Still user-side, and not fixable in packaging: gamescope's hdr_enabled
convar (Steam's HDR display setting) must be on.
2026-08-06 00:15:16 +02:00
enricobuehler 370b0ab494 feat(client): native Vulkan decode joins the automatic ladder, above FFmpeg-Vulkan
Closes M2. The 2026-08-05 ladder decision: WP-D shut with bit-exact
parity vs libavcodec (250/250 AUs on RADV, AMD-proprietary and NVIDIA)
and a 92-minute clean soak, and the program's goal is dropping FFmpeg
from the client — so on H.264 sessions where caps pass, auto now tries
pf-vkdecode FIRST, exactly where the ladder would reach FFmpeg-Vulkan.
No bake period.

native_vulkan_gate widens from by-name-only to the auto family
(auto/""/hardware); the vendor-first rungs are untouched (Linux
Intel/unknown still VAAPI-first, Windows Intel/unknown still
D3D11VA-first — NVIDIA and ALL AMD go native first). A native INIT
failure or caps refusal logs and falls through to FFmpeg-Vulkan, so
admission can't cost a session its decoder at start; runtime error
streaks ride the existing demotion machinery unchanged (past
FFmpeg-Vulkan to VAAPI/D3D11VA/software — a native→FFmpeg-Vulkan
runtime rung is deliberately absent, FFmpeg is on its way out).
PUNKTFUNK_DECODER=native-vulkan stays as the explicit pin; vulkan
keeps naming the FFmpeg backend specifically. A native_tried guard
keeps a failed pin init from re-attempting construction in auto.

Review round 8 (adversarial): no blocking code defect — no demote
bounce-back (Decoder::new is session-start-only; demotion mutates in
place), no double attempt, no cfg imbalance. 5 findings fixed: two doc
overclaims ("nothing regresses" now scoped to init; the ladder
enumerations no longer claim desktop-AMD Linux is VAAPI-first —
prefer_vulkan_first is vendor-wide), stale opt-in claims in Cargo.toml,
stale user-facing ladder text (console-ui row, trust.rs decoder field,
session README incl. the env-knob list), and the gate test now pins the
H264 codec-op bit to the literal 0x1 so a typo'd constant can't make
native silently never engage.

Gates: fmt clean; container clippy -D warnings zero for pf-client-core +
pf-presenter + pf-vkdecode; container tests green (pf-client-core lib +
pf-vkdecode + pf-bitstream); pf-console-ui check clean; mac
pf-vkdecode/pf-bitstream/cros-codecs 167 tests green.

On-glass sanity CLOSED 2026-08-05 ~22:10 UTC on .173 (4090, coincide
mode), decoder=auto and NO env var: the ladder picked native on its
own ("pf-vkdecode auto rung" log line), 525/526 stats windows on
native-vulkan over ~8m46s / 31550 frames, fps 0/59.4/61 with 6
windows <55 incl. startup zeros, bad-signature grep over the whole
log EMPTY, zero TDR events, host service Running after teardown.
2026-08-06 00:12:35 +02:00
enricobuehler e1adc5d6d7 fix(flatpak): export GAMESCOPE_WAYLAND_DISPLAY so the Deck actually gets HDR
The gamescope WSI layer decides whether to engage from one signal:
isRunningUnderGamescope() reads $GAMESCOPE_WAYLAND_DISPLAY and nothing
else. flatpak does not forward host env into the sandbox, so it arrived
unset and the layer's CreateInstance early-returned before creating a
GamescopeInstance — no gamescope surface, so the HDR10/ST.2084 formats
were never appended and the surface stayed SDR.

The layer still loads and still logs its generic bits in that state, so
it reads as working. It is not: the three settings already here (layer
search path, ENABLE_GAMESCOPE_WSI, the socket bind) all sit downstream
of this gate and buy nothing without it.

Measured on a Deck OLED (Galileo, SteamOS 3.8.16), client --browse,
reading "swapchain config":
  unset              -> no [Gamescope WSI] Surface state block, None
  set, hdr_enabled=0 -> server hdr output enabled: false, None
  set, hdr_enabled=1 -> hdr formats exposed to client: true,
                        Some(A2B10G10R10_UNORM_PACK32, HDR10_ST2084_EXT)

Matches the field report of "HDR->SDR" in the stats overlay on a
correct HDR host. DXVK_HDR was ruled out by measurement. The remaining
gate (gamescope's hdr_enabled convar = Steam's HDR display setting) is
a user-side step, not a packaging one.
2026-08-05 23:59:45 +02:00
enricobuehler 76a271b97a Merge pull request 'Worktree decky brand name' (#63) from worktree-decky-brand-name into main
ci / docs-site (push) Successful in 1m21s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 46s
ci / web (push) Successful in 1m26s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 12s
ci / rust-arm64 (push) Successful in 2m1s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 13s
docker / builders-arm64cross (push) Skipped
decky / build-publish (push) Successful in 39s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m13s
ci / rust (push) Successful in 6m40s
docker / deploy-docs (push) Successful in 6m32s
Reviewed-on: #63
2026-08-05 21:41:17 +00:00
enricobuehler b53568c99f fix(decky): a host saved under its own IP now shows the name it advertises
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m30s
ci / rust-arm64 (pull_request) Successful in 2m35s
ci / rust (pull_request) Successful in 6m51s
The panel captioned most rows with an IP address. The saved records were
the source: `hosts add` falls back to the address when the pairing path
knew nothing better, so `name` is literally "192.168.1.21" — and
`mergeHosts` took `s.name || s.addr` unconditionally. The fallback only
ever fired for an EMPTY name, so a name that was already a copy of the
address sailed through as if it were meaningful, and the row printed the
address twice: once as its title, once as its subtitle.

The friendly name was in hand the whole time. The row is built by joining
the saved record to the live advert, and that advert carries the host's
actual hostname — the join was already trusted for address, port, online
and OS, and only the name was read from the saved side alone.

So treat a name equal to the record's own address as the placeholder it is
and yield to the advert. A real saved name still wins, even when stale: it
may be one the user chose, and an advert must never silently overwrite it.
The comparison is against the SAVED address, so a host that moved DHCP
lease still recognises its old address as a placeholder rather than
mistaking it for a chosen name.

Checked against the Deck that reported this, over its actual store and
browse: three online rows turn into home-worker-5, ENRICOS-DESKTOP and
steamdeck, the four offline ones keep their address (nothing is
advertising a better name for them yet), and a user-chosen name survives a
conflicting advert.
2026-08-05 23:37:18 +02:00
enricobuehler db0637928b fix(decky): the shortcut liveness guard answered "alive" for every appId
`shortcutStillExists()` extracted the store method before calling it:

    const get = appStore?.GetAppOverviewByAppID;
    return get(appId) != null;

`GetAppOverviewByAppID` reads the store's own state (`this.m_mapApps`), so
the unbound call throws on the lost `this` — and the function's own
`catch { return true }` swallowed it. The guard therefore returned "still
exists" for EVERY appId. Not a stale-data bug: it never once answered no.

Everything downstream of it was consequently inert. A dangling appId — the
documented hazard this guard exists to catch, since the id outlives the
shortcut in Steam's CEF localStorage across a plugin reinstall — was never
dropped, so `ensureGamepadUiShortcut` always took the reuse branch and
`SetShortcut*`'d a dead id (silent no-ops). The visible library entry never
came back, `recreateShortcuts` reported success having done nothing (its
toast only checks for a non-null appId, and the dead one is non-null), and
"Open Punktfunk" ran `RunGame` on the dead id — Steam answers that with
"Game configuration unavailable".

Call it as a method so `this` survives, and guard the global with `typeof`
first: `appStore` is Steam-injected, and a bare reference to a missing one
is a ReferenceError that optional chaining does not prevent — which would
have landed in the same catch.

Verified against the live Deck that hit this: evaluated both versions over
its actual appIds, and where the old guard says alive/alive, the fixed one
says alive for the live stream shortcut and dead for the dangling UI id —
so the stale key now drops and the entry is recreated on the next mount.
2026-08-05 23:33:25 +02:00
enricobuehler cf5db2d485 feat(pf-bitstream): H.265 DecodePlan layer — M3's AU-to-hardware contract
H265Planner mirrors the H.264 layer's contract exactly: plan_au -> AuPlan
{ picture, slices with ref lists by stable PicId, DpbUpdate, warnings },
same concealment posture (warnings never abort, in-place reference
substitution preserving ref_idx positions, outputs survive failed AUs,
flush gates on AwaitingIdr, any IRAP resumes). Ported logic: RPS 8.3.2
(short-term AND long-term incl. PocLsbLt/MSB-cycle - the hosts' RFI
recovery rides long-term refs), ref lists 8.3.3/8.3.4, DPB C.5.2.2/C.5.2.3
via the vendored dpb; POC 8.3.1 from the vendored PictureData. Written
fresh: the plan surface, AU walk, envelope gates (multilayer, interlaced,
SCC self-reference, DPB>16, conf-window overflow - checked at EVERY
activation, not just parse), HEVC recovery-point SEI (prefix NALU 39,
se(v) recovery_poc_cnt), VUI colour with E.3.1 inference, and a test-only
HEVC bitstream synthesizer (upstream has none).

Upstream deviations worth naming (all in-code with spec anchors): the
empty-RPS inter slice cannot infinite-loop (upstream bug); RASL behind a
joined CRA refuses BEFORE any state change (PlanError::RaslSkipped - the
WP-2 wiring must map it to skip, not reanchor; module docs carry the
contract note); MaxPicOrderCntLsb reads from the ACTIVATING SPS (upstream
latches at parse - a latent multi-SPS bug); C.5.2.2's exemption is
picture 0 of the BITSTREAM (EobNut), never first-after-EOS.

Vendored parser gained PROVENANCE deviation 7 (report upstream): hostile
slice headers with num_long_term_sps+num_long_term_pics > 16 indexed out
of bounds of SliceHeader's [_;16] arrays - a production panic on exactly
the long-term-reference path, now a parse error.

Port review round 7: 10 findings (3 blocking: the vendor panic, an
EOS-boundary output interleave, an envelope bypass through PPS-only SPS
rebind reaching wrapping crop arithmetic) - 9 fixed with a regression
test each, 1 documented as the WP-2 contract note. Known follow-up: the
h264 AU-tail truncation detector shares h265's dead-arm shape (its arm
also cuts reserved NALU types, so the fix is not identical - deferred).

Tests: 29 h265 planner + 2 HEVC SEI + full test-25fps.h265/bear/bbb clip
walks with real invariants (every stored id output exactly once,
ascending POC per IRAP period). Gates: fmt clean; clippy -D warnings zero
(mac + pf-lxcheck2 incl. pf-client-core/pf-presenter); tests 45+69 mac,
69+121+53 container.
2026-08-05 23:29:45 +02:00
enricobuehler 22bc81238d fix(decky): Decky's plugin list says "Punktfunk", not "punktfunk"
The label Decky shows for an installed plugin is plugin.json "name", which
we had set to the lowercase directory name — so the one place every user
sees the plugin listed was the one place it was off-brand, while the panel
header (titleView) already read "Punktfunk".

The two were conflated because the name looked load-bearing: the zip's
top-level dir becomes ~/homebrew/plugins/<dir>, and the scripts derived
that dir FROM plugin.json "name". They are in fact independent — Decky
extracts the zip as-is and locates an installed plugin by MATCHING
plugin.json "name", never by folder name (that is how a plugin can live in
DeckWebBrowser/ and list itself as "Web Browser").

So brand-case the label and pin the on-disk dir to the literal `punktfunk`
in package.sh/deploy.sh/CI instead of deriving it. Pinning is the part that
matters: had the dir followed the label, this rename would have installed a
second `Punktfunk/` folder beside the existing `punktfunk/` and the plugin
would have shown up twice.

The self-update call passes the name Decky uninstalls before extracting, so
it moves to "Punktfunk" with it. The upgrade INTO this build still passes
"punktfunk" (the installed build's own value), which matches that build's
plugin.json — so the old folder is removed and the new zip lands in the
same lowercase dir either way. Decky's per-plugin settings dir is unused
(all state lives in ~/.config/punktfunk), so nothing is stranded.
2026-08-05 23:20:28 +02:00
enricobuehler de6b9e94ec Merge pull request 'fix(client/windows): settings persist when the app isn't installed on C:' (#62) from worktree-client-msix-persist into main
ci / web (push) Successful in 1m13s
ci / docs-site (push) Successful in 1m22s
apple / swift (push) Successful in 1m25s
ci / rust-arm64 (push) Successful in 1m39s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
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 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 25s
deb / build-publish-client-arm64 (push) Successful in 2m40s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
flatpak / build-publish (push) Failing after 4s
deb / build-publish-host (push) Successful in 4m43s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 33s
ci / rust (push) Failing after 9m30s
apple / screenshots (push) Successful in 10m16s
android / android (push) Successful in 13m9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 13m28s
deb / build-publish (push) Successful in 14m47s
arch / build-publish (push) Successful in 15m13s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m26s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m4s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m12s
Reviewed-on: #62
2026-08-05 20:53:38 +00:00
enricobuehler 5ebe840320 fix(client/windows): settings persist when the app isn't installed on C:
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 22s
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m34s
ci / web (pull_request) Successful in 1m28s
ci / docs-site (pull_request) Successful in 1m23s
android / android (pull_request) Successful in 3m9s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 6m42s
ci / rust (pull_request) Successful in 7m46s
Reported from the field (2026-08-05): a fresh Windows 11 box with a data
partition, "New apps will save to: D:", and the client installed there. It
launches, finds hosts and streams — but no setting and no profile survives a
restart. Reinstalling to C: fixes it completely. The reporter's read was "it's
in read-only mode", and that is almost exactly right.

The one clue that localises it: the client creates its mTLS identity with a
plain `fs::write` on first run and hard-exits if that fails. Their app started,
so ordinary file creation in the config directory works. Only the config stores
were being lost — and those are the three files that go through `write_atomic`,
which writes a sibling temp and renames it over the target.

The rename is what breaks. The client ships as a full-trust MSIX package, so
its `%APPDATA%` writes are redirected into the package container. When the
package lives on a secondary drive, Windows keeps that redirected state on the
package's own volume: `C:\Users\<u>\AppData\Local\Packages\<pfn>\` stays a real
directory on C:, but its children (LocalCache, RoamingState, …) are junctions to
`D:\WpSystem\<SID>\…`. Both sides of our rename still spell `C:\Users\…`, so
nothing looks unusual, but they can resolve across that junction boundary — and
`std::fs::rename` is `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING` and *not*
`MOVEFILE_COPY_ALLOWED`, so a cross-volume move fails outright rather than
degrading to a copy. Creating files still works, which is why everything else
about the install looks healthy.

So the fix is not to make the rename work — it is to stop treating it as the
only way to persist. `write_atomic` now falls back to writing the target in
place when the atomic route fails. That is the same operation the identity files
already use, and those demonstrably round-trip on the affected installs, so the
fallback lands on a path we know resolves. It trades crash-atomicity for exactly
the writes that would otherwise be lost, and nowhere else: temp+rename stays the
normal route everywhere it works.

Writing into a redirected location cannot desync from reading it — Microsoft
documents one private-location-first resolution order for both, so whichever
layer a write lands in is the layer the next read finds. The fallback verifies
anyway, by reading the bytes straight back: a write that reports success and
disappears is precisely the bug being fixed, so this path does not get to claim
success on an `Ok(())` alone. It costs nothing normally — it only runs on an
install that has already shown it does something unusual.

Two things this uncovered on the way:

The temp file was a single shared `<name>.json.tmp`, but these stores have five
whole-file writers (WinUI shell, session, console UI, CLI, Decky). Two saving at
once collide on it — on Windows the second write hits a sharing violation, and
worse, one process can rename the other's half-written bytes over the target.
The scratch path now carries the pid.

And none of this was visible to anyone. Every save on this page is
fire-and-forget by design (a failed settings write must never take a stream
down), so ~15 call sites discard the error and the UI cheerfully shows the
toggle you just moved. The reporter had no log file to send either, because
"Open log folder" was handing out a phantom path — a separate bug, already fixed
in f3c0ee47 but not in the 0.24.0 they were running. `store_health` records the
last persistence failure centrally, and Settings shows an error bar naming the
path when the store is refusing writes, so a client that cannot save says so
instead of pretending.

`update.rs` had hand-rolled the same temp+rename inline, so it neither cleaned
up its temp on a failed rename nor picks up the fallback; it now goes through
the one writer. The update floor silently never rising is how a declined update
comes back forever.

Deliberately NOT done: disabling MSIX AppData virtualization in the manifest
(`desktop6:FileSystemWriteVirtualization`). It would stop the redirection at the
source, but every existing packaged install's settings, profiles and pairings
live inside the container today — turning it off points the client at an empty
real `%APPDATA%` and silently resets all of them. That needs a migration, not a
manifest flag.

Also considered and not taken: resolving the destination directory with
`GetFinalPathNameByHandleW` and creating the temp inside the resolved path, to
keep atomicity. It does not reliably close this hole — when the target file
exists only in the unvirtualized layer while its directory resolves to the
private one, the rename still straddles the boundary — and it would rest on
canonicalisation behaving through the redirection, which we have never verified
on a packaged run.

Verified on the RTX box (.173, Windows 11 26200), which is the platform that
actually has these rename semantics: `cargo fmt --all --check`, the full
`pf-client-core` lib suite (109 passed), and clippy `-D warnings --all-targets`
on both `pf-client-core` and `punktfunk-client-windows` — all clean. Also green
under linux/amd64 (116 passed). Three new tests: the pid-scoped scratch path,
the fallback actually persisting and reading back when the atomic route is
blocked, and a genuinely unwritable store surfacing its error instead of
swallowing it.

The mechanism above is established from documentation and third-party reports,
not from a reproduction on a second-drive install — that box does not exist
here. The fix does not depend on the diagnosis being exactly right: it repairs
any install where the rename fails but a direct write succeeds.
2026-08-05 22:33:36 +02:00
enricobuehler 4b1ce6b905 Merge pull request 'fix(android/hud): stop charging the compositor's wait to the stream' (#61) from worktree-android-hud-os-floor into main
ci / web (push) Successful in 1m7s
ci / rust-arm64 (push) Successful in 3m14s
ci / docs-site (push) Successful in 1m20s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 15s
docker / builders-arm64cross (push) Skipped
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m19s
docker / deploy-docs (push) Successful in 1m10s
ci / rust (push) Successful in 8m28s
android / android (push) Successful in 9m24s
Reviewed-on: #61
2026-08-05 20:31:46 +00:00
enricobuehler a11c672bea fix(android/hud): stop charging the compositor's wait to the stream
ci / web (pull_request) Successful in 1m24s
ci / docs-site (pull_request) Successful in 3m21s
ci / rust-arm64 (pull_request) Successful in 3m30s
android / android (pull_request) Successful in 9m40s
ci / rust (pull_request) Successful in 16m25s
The Android HUD headlined `capture→displayed` with SurfaceFlinger's latch
and scanout inside it — pipeline depth no client can pace under. The usual
Android streaming overlays stop measuring at decode-complete, so users
comparing overlays read our honesty as latency: on a 60 Hz panel that floor
alone clears 30 ms, more than everything those overlays display put together.

Exclude it, the way the Apple clients have since the presentation rebuild
(8a40e467): shave the measured floor off the shown display and end-to-end at
every tier, and name what came off in Detailed as `os present +N excluded
(display pipeline minimum)`. The equation still tiles the headline, because
the `display` term is shaved by the same amount.

The floor is the `latch` p50 we already measure (release→OnFrameRendered),
not a modelled 2/refresh: it moves with the panel rate, tunnelled playback
and the vendor's low-latency mode, and it exists on every render path (the
release stamp is parked on all three), so it does not depend on the timeline
presenter being active. Unmeasured reads 0.0 and nothing is shaved — we
exclude only what we actually measured. With the floor out, the `display`
term is already just `pace`, so the `(pace + latch)` split now renders only
on a window where no latch sample paired, and the hardcoded 2-refresh
Apple-equivalence twin is gone with it.

Raw numbers are untouched in the 1 Hz `pf.present` logcat line, so HUD-off
A/Bs and cross-session comparisons still read unshaved values.
2026-08-05 22:28:43 +02:00
enricobuehler dc0766b2f3 fix(client): the native rung now follows the stream's colour and reports true decode latency
The round-4 residuals, closed after the WP-D hardware verdict:

- VUI colour plumbing (the one silent-wrong): the picture's ACTIVE SPS's
  colour signalling (H.273 code points + range, with E.2.1's 'unspecified'
  inference where the VUI is silent — the vendored parser's defaults ARE the
  inferred values, verified) rides PicturePlan -> DecodedVkFrame ->
  NativeVkFrame per frame, never latched: the Windows host switches an HDR
  desktop to PQ/BT.2020 IN-BAND while the Welcome still says SDR. Before
  this, the native path would have painted PQ washed out, silently.
- Native decode-latency stat: the deliberately-deferred NativeVk arm of the
  pump's sampled once-per-stats-window decode measurement now feeds - the
  frame's (semaphore, semaphore_value) is the decode-done signal, resolved
  through the shipped ledger before a bounded, pure-measurement
  vkWaitSemaphores (VkH264Decoder::wait_decoded).
- The renegotiation-teardown window is settled as NO HOLE: rebuild_state now
  documents the full safety argument (graveyarded pools stay intact under
  presenter holds, tokens route strictly by generation, session objects die
  only post-drain with the generation gate INSIDE read_status), and the two
  backend comments that wrongly claimed stale pools were 'gone' are fixed.
- VK_KHR_unified_image_layouts stays deferred (fleet drivers lack it).

Adversarial review round 6: 3 minor findings (2 doc fixes applied; the
SPS-replaced-without-PPS-resend divergence stays a documented envelope
assumption - hosts re-send both at every keyframe, and a hardening
PlanWarning could cost real frames on a false positive).

Gates: fmt clean; clippy -D warnings zero (mac + pf-lxcheck2 container,
incl. pf-client-core/pf-presenter); tests 45+30+53 mac, 30+121+53 container.
2026-08-05 22:22:03 +02:00
enricobuehler cbd0e9664d Merge pull request 'fix(ci): builder-image pushes authenticate, and :latest stops being a tag anyone can move' (#60) from worktree-security-h6-registry-auth into main
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 54s
ci / web (push) Successful in 2m24s
ci / docs-site (push) Successful in 2m29s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 12s
docker / builders-arm64cross (push) Skipped
ci / rust-arm64 (push) Successful in 3m2s
ci / rust (push) Successful in 6m38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6m49s
docker / deploy-docs (push) Successful in 35s
Reviewed-on: #60
2026-08-05 20:11:17 +00:00
enricobuehler 66df1624b6 Merge pull request 'Library scanners become plugins — the bridge half (host, wire, kit, console, packaging)' (#59) from worktree-library-plugins into main
apple / swift (push) Successful in 1m29s
ci / web (push) Successful in 1m56s
ci / rust-arm64 (push) Successful in 2m3s
ci / docs-site (push) Successful in 2m16s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
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 20s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 19s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m1s
deb / build-publish-client-arm64 (push) Successful in 3m4s
android / android (push) Successful in 6m34s
deb / build-publish (push) Successful in 6m33s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 35s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 22s
apple / screenshots (push) Successful in 5m51s
docker / builders-arm64cross (push) Successful in 20s
deb / build-publish-host (push) Successful in 6m3s
docker / deploy-docs (push) Successful in 1m13s
arch / build-publish (push) Successful in 9m1s
ci / rust (push) Successful in 9m42s
windows-host / package (push) Failing after 11m36s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m57s
flatpak / build-publish (push) Successful in 9m10s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m44s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m4s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m14s
Reviewed-on: #59
2026-08-05 19:58:44 +00:00
enricobuehler 6f07bd94d3 feat(library): launcher tiles a plugin can actually publish
ci / docs-site (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 2m28s
android / android (pull_request) Successful in 4m10s
ci / rust (pull_request) Successful in 6m11s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 6m56s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m35s
Design D4 promised entries that open the LAUNCHER — Steam Big Picture, Heroic,
Lutris — and the plumbing for it landed in M2/M4: the `role` field, the
`steam_ui` kind, the console's Launchers rail. But nothing could flow through it
for anything except Steam.

D4 said the other launchers would ride the `command` kind. The 2026-08-05 review
then made `launch.kind = "command"` operator-only (it is handed to a shell), so a
plugin publishing one is refused with a 403. The two changes are individually
right and jointly leave a hole: `steam_ui` was the only launcher kind a plugin
could publish, so a Heroic or Lutris tile was unreachable.

New `launcher_ui` kind, valued by store id. One kind rather than one per store
because every launcher except Steam has exactly a single UI to open; Steam keeps
its own kind because it genuinely has two. D1 is preserved — the plugin names a
launcher, the host builds the command, and no shell string crosses the wire:

  heroic -> the same native-or-Flatpak resolution the `heroic` game kind uses,
            minus --no-gui and minus the URI, so the window itself opens
  lutris -> bare `lutris`, which opens the window (the URI form is `lutris_id`)

Platform-gated to what this host can actually resolve, and validated INBOUND: a
value naming a launcher this OS cannot open is a 400 the plugin author can act
on, not a tile that silently does nothing when a user clicks it. Windows
launchers (Epic, GOG Galaxy, Xbox app) are deliberately absent — each needs its
own verified activation and a guess would ship exactly that dead tile.

Also closes a WP4.3 item I under-delivered and did not flag: the console's
add/edit form had no way to mark an entry as a launcher, so even hand-adding one
was impossible. It now has the checkbox — and `formFrom` round-trips it, without
which editing a launcher entry would silently demote it to a game, which is the
precise bug that file's own comment warns about.

Gates on .21: punktfunk-host 435 passed / 0 failed (two new), workspace clippy
-D warnings clean, cargo fmt --all --check clean, OpenAPI drift green. Console:
orval + paraglide regen, tsc clean, check-i18n at 604 messages for en + de.

Still unproven on hardware: no launcher tile has been clicked on a real host.
The steam plugin (the first to emit one) is not built yet.
2026-08-05 21:12:19 +02:00
enricobuehler e6d6498a49 test(pf-vkdecode): frame-hash parity vs libavcodec — bit-exact on the whole fleet
WP-D parity A/B. gpu_parity (ignored) decodes the conformance vector,
reads every frame back through the presenter's exact contract (wait,
layout round-trip, signal-back, release), crops at the copy so pitch
can never leak, and compares SHA-256s in display order against goldens
from ffmpeg software decode — cross-checked bit-identical between
ffmpeg 8.0.1 (linux) and 8.1.1 (macOS), so the reference is the spec,
not one build. PF_VKD_TEST_READBACK=1 is the one test-only hook (ORs
TRANSFER_SRC into pool usage; production pools stay zero-copy-tight).

Fleet verdict: 250/250 frames bit-identical to libavcodec on RADV
(Mesa 26.0.3, distinct), AMD proprietary Windows (25.10.30.02,
distinct) and NVIDIA Windows (610.88, coincide) — H.264 decode is
exactly specified, and the native path meets the spec on every driver
and both DPB arrangements.
2026-08-05 20:23:18 +02:00
enricobuehler ca92dab6fd fix(pf-vkdecode): prefer DEVICE_LOCAL, never require it — NVIDIA runs; both DPB modes hardware-green
Session-memory and image allocation now honor each binding's
memoryTypeBits with DEVICE_LOCAL preferred, not required: NVIDIA 610.88
legally places a video-session binding in host-visible-only memory and
the hard requirement refused the whole device. The bitstream ring keeps
its hard HOST_VISIBLE|COHERENT need. Smoke test gains
PF_VKD_SMOKE_VENDOR device pinning + attribution and a final-state
print (DPB mode now observed, not inferred).

On-glass matrix after this fix (.173, vendor-pinned): NVIDIA 4090
PASSES in COINCIDE mode — the first end-to-end run of the RESULT_STATUS
query path, ~44 per-frame driver verdicts on the recording pattern that
hangs RADV's VCN — and Adrenalin re-passes in distinct mode unchanged.
With RADV's distinct pass, both DPB arrangements and three of four
desktop drivers are now hardware-validated; Intel remains a clean caps
refusal (no SAMPLED on decode outputs — its rung stays D3D11VA).

Gates: fmt clean, clippy -D warnings zero, 45+27+53 green both
platforms.
2026-08-05 20:03:25 +02:00
enricobuehler d2085879da Merge main: plugin art rides THROUGH the H-2 confinement, not around it
ci / web (pull_request) Successful in 58s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m1s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m0s
ci / rust-arm64 (pull_request) Successful in 3m45s
ci / rust (pull_request) Successful in 9m22s
PR #58 hardened the art proxy in the same three files this branch rewrote, and
the two changes pull in opposite directions: #58 narrowed what the host will read
from disk, while WP1.2 widened what counts as a local art path so an extracted
scanner's covers can be served at all. Resolved so the widening goes through the
gate rather than beside it.

Kept from #58, unchanged: art_path_is_confined (UNC refusal, canonicalize-or-
refuse, config-dir exclusion, roots check), the image-extension whitelist,
sniff_image_type, validate_art_paths as write-time validation, the AuthLane
privileged-field check on every entry in a reconcile payload, and the launch
redaction in GET /library.

Three reconciliations:

  * `local_art_bytes` converts a `file://` value to a path BEFORE calling
    art_path_is_servable, so the confinement check and the read see the same
    path. Ordering is the point: percent-decoding happens before
    canonicalization, so a `%2e%2e` escape cannot hide from the traversal check.
    Pinned by a test.

  * `art_roots()` gains $HOME on POSIX. This is the one that would have bitten
    silently: the list was empty on non-Windows, which was correct while
    is_local_art_path was Windows-shaped (Playnite is Windows-only, so nothing on
    a POSIX host was ever classified as local art and the confinement had nothing
    to confine). Once WP1.2 classifies POSIX paths as local, an empty root list
    is not "secure by default" — it serves NO plugin art on Linux, which is every
    cover the lutris and steam plugins emit. $HOME is the exact analogue of the
    Windows users base #58 already ships, and covers Steam's librarycache and
    grid overrides, Lutris's coverart/banners (both copies), Heroic's caches and
    all the Flatpak variants. It is not the load-bearing control: a value still
    needs an image extension, must canonicalize to a real regular file inside a
    root and outside the config dir, and must CONTAIN image bytes.

  * The two tests that both wanted to mutate PUNKTFUNK_LIBRARY_ART_ROOTS became
    one. Cargo runs tests as parallel threads of a single process, so two tests
    setting the same env var race. The `file://` and confinement assertions moved
    into #58's existing confined test; what remains of the WP1.2 test is the
    pure classification/rewrite half, which touches neither env nor filesystem.

Also: `steam_ui` was missing from the list of host-resolved launch kinds in
privileged_field's doc comment and in the 403 a plugin sees. Prose only — the
check is a denylist (prep, launch.kind = "command"), so steam_ui was never
actually refused — but a plugin author reading that error would have concluded
otherwise.

Gates on .21: punktfunk-host 433 passed / 0 failed (including #58's H-2 tests and
the new file:// ones), full workspace tests clean, workspace clippy -D warnings
clean, cargo fmt --all --check clean, OpenAPI drift test green.
2026-08-05 19:59:11 +02:00
enricobuehler 19f637ea6e fix(ci): builder-image pushes authenticate, and :latest stops being a tag anyone can move
ci / docs-site (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 1m25s
ci / rust-arm64 (pull_request) Successful in 1m40s
ci / rust (pull_request) Successful in 6m8s
Second half of security-review-2026-08-05 H-6. The infra half (unom/infra,
runners/ci-core/) split the LAN registry in two: :5010 serves GET/HEAD only and
refuses everything else with 405, :5011 demands basic auth on every request
including the /v2/ ping. Both fronts sit on one store, and a registry keys by
repository name rather than by the host:port the client used, so an image
pushed to :5011 is the identical image every consumer pulls from :5010.

So: builds tag the write port, a docker login precedes the push, and the
release-tag manifest PUTs authenticate. Consumers are untouched — every
`container:` in every other workflow still pulls anonymously from :5010, and
ci/rust-ci-arm64cross.Dockerfile's `FROM 192.168.1.58:5010/...` still resolves.

Not doing the digest pinning the review asked for, deliberately, and the header
says why at length. Once pushes are authenticated, the people who can overwrite
a tag are exactly the people who can push to main and edit a pinned digest in
this file — a pin defends against nobody it did not already trust, and costs a
two-commit dance on every ci/ change (~3x a month) during which consumers run a
builder image predating the change they are testing.

What does close the residual gap is making :latest a checked function of the
tree. reconcile-latest.sh asserts on every run that :latest and :ck-$KEY are the
same digest, re-points it when they are not, and warns loudly. An out-of-band
overwrite is caught on the next push to main with no churn, and it fixes a
pre-existing bug on the side: reverting ci/ used to leave :latest on the newer
build forever, because the older key is a cache hit and nothing re-pointed it.
Repair rather than fail, because a legitimate revert must not red-line main.

Verified against the live registry from a runner host with the real docker
client: unauthenticated push denied, push to :5010 refused 405, authenticated
push to :5011 accepted, that same image pulled back anonymously from :5010.
reconcile-latest.sh exercised over all three cases (diverged -> repaired,
already equal -> no-op, missing key -> exit 1). All seven builder images are
consistent with their content keys today, so the new step is a silent no-op on
its first real run.
2026-08-05 19:52:19 +02:00
enricobuehler 4a0d0ce587 Merge pull request 'The plugin lane stops being a way in — 37 of the 38 security-review findings' (#58) from worktree-security-review-0805-fixes into main
apple / swift (push) Successful in 1m24s
ci / web (push) Successful in 1m48s
ci / rust-arm64 (push) Successful in 2m2s
ci / docs-site (push) Successful in 2m2s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
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 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 43s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 29s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
deb / build-publish-client-arm64 (push) Successful in 2m34s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 49s
deb / build-publish (push) Successful in 5m50s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m23s
docker / builders-arm64cross (push) Successful in 6s
android / android (push) Successful in 6m19s
docker / deploy-docs (push) Successful in 32s
apple / screenshots (push) Successful in 5m45s
deb / build-publish-host (push) Successful in 6m11s
arch / build-publish (push) Successful in 8m38s
ci / rust (push) Successful in 10m48s
windows-host / package (push) Failing after 11m42s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m44s
Reviewed-on: #58
2026-08-05 17:39:02 +00:00
enricobuehler 0d94ef0dbe fix(host/mgmt): the field gate returns the refusal, not an error carrying it
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m49s
ci / docs-site (pull_request) Successful in 2m17s
ci / rust-arm64 (pull_request) Successful in 2m43s
android / android (pull_request) Successful in 3m3s
ci / rust (pull_request) Successful in 8m31s
`check_entry_fields` returned `Result<(), Response>`, which trips
`clippy::result_large_err` under CI's `-D warnings`: an axum `Response` is 128
bytes and it was riding in the `Err` variant.

`Option<Response>` is the shape this always wanted. There is no error value to
propagate here — the "error" IS the response the handler sends back — so `None`
means "the payload may proceed" and `Some(r)` is the refusal to return. The call
sites read the same, one word different.

Caught by CI, not by me: I ran `cargo check` and not `cargo clippy -D warnings`.
2026-08-05 19:14:19 +02:00
enricobuehler 6331ae7fd9 fix(pf-vkdecode): zero-copy pool model + the two faults the first hardware run found
WP-D leg 1 (.25 RADV, distinct mode) root causes, both real:
1. Output starvation: the fixed 4-deep ring lost to a stream that keeps
   max_dpb_frames+1 = 8 pictures pending. Zero-copy fix (user
   requirement, no copies): one picture pool of required_slots +
   HOLD_HEADROOM(8) images decoupled from DPB slots — a re-activated
   slot binds a fresh free image, so a delivered picture is never a
   decode target; the WP-B pin layer became dead and is deleted.
   Per-image timeline semaphores carry the AVVkFrame contract: decode
   signals value+1, the presenter waits and signals back, later decodes
   wait the image's latest value — layout traffic ordered against
   reference reads with no copy anywhere.
2. RESULT_STATUS queries HANG RADV's VCN firmware (ring timeout,
   DEVICE_LOST): queryResultStatusSupport=false on the decode family.
   Queries are now caps-gated; without them poll/wait degrade to
   timeline-completion verdicts (FFmpeg parity — and the likely reason
   upstream never wired nb_queries). The Ally-X-class detection runs
   where drivers advertise the query; .173 probes NVIDIA/Windows-AMD.

Also: slice-only bitstream feeding (the field-proven consumer shape),
graveyarded pool retirement keyed by release tokens + generation,
decode-current-AU-before-status attribution, take_ready drained,
H264-bit gating, teardown short-circuit on disconnected channel.

On-glass: 48 AUs green on .25 holding 4 frames like the real client.
Gates: fmt clean, container clippy -D warnings zero, 27+121+52 green
both platforms.
2026-08-05 19:12:20 +02:00
enricobuehler a1b8627e70 feat(plugin-kit): the lutris pilot as a worked example, and the export gap it found
plugin-kit-publish / publish (push) Successful in 29s
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m28s
ci / rust-arm64 (pull_request) Successful in 2m50s
android / android (pull_request) Successful in 4m28s
ci / docs-site (pull_request) Successful in 1m23s
ci / rust (pull_request) Successful in 7m11s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 2m58s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 3m52s
Writing a real scanner against the kit before six repos get cut from it, rather
than after. It is the lutris pilot (M5/WP5.1) — the smallest of the six and the
one that exercises the POSIX local-art path end to end.

It earned its keep immediately: withReadOnlyDb / openReadOnly were never exported
from the parsers barrel, so the single most distinctive thing the lutris plugin
needs was unreachable from @punktfunk/plugin-kit/library. Nothing caught that,
because nothing had consumed the public surface yet.

It also caught a vacuous green in this package: tsconfig's include was
["src","test"], so anything under examples/ type-checked as a no-op. `examples`
is now in the check scope; tsconfig.build.json still narrows to src and
package.json still ships only dist + README, so nothing new is published (verified
against the built dist).

The example carries two deliberate departures from the Rust original, both
documented inline: art is emitted as file:// URLs instead of inlined data: URLs
(the host proxies the bytes, so the payload stays small — inlining covers is what
blew the 2 MB body limit at 49 titles during the playnite work, and is exactly
why the POSIX art path exists), and the untrusted-slug guard is carried over
verbatim, since the slug comes from Lutris's own database and is interpolated
into a path the host will later be asked to serve.

What it demonstrates, which is the reason one-repo-per-plugin is safe: everything
below `scan` is store-specific parsing, and everything else — store claim, sync
engine, launcher entries, __config, console registration, and the CLI verbs
including the parity gate — comes from defineLibraryPlugin.

plugin-kit: tsc clean (now including examples), 56 tests pass, build clean.
2026-08-05 18:54:47 +02:00
enricobuehler 91fa32fbb6 feat(plugin-kit): the parity gate moves into the kit, so plugins can be one repo each
One plugin = one repo, matching the house pattern (playnite, rom-manager and
virtualhere are already each their own repo with their own biome/bunfig/tsconfig
/CI). The implementation plan's WP5.0 had proposed a single workspace repo for
all six library scanners; this is the piece that makes the split cost nothing.

Everything the six scanners share is already published rather than adjacent: the
parsers and defineLibraryPlugin live in @punktfunk/plugin-kit/library, so repo
boundaries are irrelevant to them. Fixtures are not shared in practice either —
the Rust scanners build theirs inline in code, there are no fixture files, and
the one genuinely cross-plugin builder (binary shortcuts.vdf) is already in this
package's own tests. A pga.db fixture is useless to the epic plugin.

The parity harness was the exception: generic across all six, and parked in the
shared repo the plan assumed. It moves here.

What it is: the acceptance gate for an extracted scanner. Ported unit tests pin
the PARSERS; they do not prove the plugin reproduces the scanner it replaces. A
plugin that parses perfectly and emits steam:440.0 instead of steam:440 breaks
every Moonlight pin on the host and no parser test notices.

  punktfunk-plugin-steam parity --snapshot before.json   # host on its built-in
  punktfunk-plugin-steam parity --compare  before.json   # offline; exits non-zero

--compare runs the plugin's own scan rather than requiring it to be installed
first, so a mismatch is visible before anything is published and the run is
repeatable while you fix it.

Three judgement calls in the diff, each pinned by a test:
  * art is compared by PRESENCE, not value. The representation legitimately
    changes on extraction (a host-relative proxy path or inlined data: URL
    becomes a file:// path or a CDN URL), so comparing values would fail every
    run for no reason. Losing an art kind fails; gaining one does not.
  * launcher entries (role: "launcher") are reported separately instead of as
    unexpected extras — the built-in scanner had no concept of them, so they can
    never be in a baseline. An ORDINARY title the scanner never had still fails,
    which is what catches a bad tool filter.
  * absent and empty are the same thing in metadata: the host omits empty lists
    and nulls, so a plugin sending genres: [] has not changed anything.

plugin-kit: tsc clean, 56 tests pass (10 new).
2026-08-05 18:52:38 +02:00
enricobuehler d0659d2b61 feat(client): wire the native Vulkan decoder in behind PUNKTFUNK_DECODER=native-vulkan
M2 WP-C. video_vk_native.rs adapts the presenter's VulkanDecodeDevice to
pf-vkdecode (queue lock shared only when the families actually collide —
the one case the 2026-07-09 DEVICE_LOST race proved matters), and the
presenter consumes DecodedImage::NativeVk on its own device: no handle
import, no AVVkFrame co-authoring — wait the timeline, barrier to
sampled, existing crop-aware CSC, barrier back, release after the fence.

Frame lifetime is a token: presented, retired, displaced or dropped
mid-demotion, the guard's drop sends it exactly once; the backend
releases the decoder slot only after the status query resolves, so a
recycled slot can never report a false Failed. Driver-reported decode
failures and plan warnings ride the existing streak/reanchor machinery —
the Ally X corruption class is now a visible error, not a silent frame.

Opt-in only until WP-D's on-glass parity verdict; H.264 sessions only;
failures demote to the existing ladder. Known WP-D items recorded in
code: coincide-mode cross-queue reference overlap, renegotiation
teardown window, VUI colour plumbing.

Gates: fmt clean; container clippy -D warnings zero for pf-client-core +
pf-presenter + pf-vkdecode; 121+53+27 tests green.
2026-08-05 17:57:12 +02:00
enricobuehler defdfbdb58 fix(security): plugin UIs get their own origin
ci / web (pull_request) Successful in 1m2s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m4s
ci / docs-site (pull_request) Successful in 2m13s
android / android (pull_request) Successful in 3m16s
ci / rust (pull_request) Failing after 3m36s
Closes H-3 of the 2026-08-05 review, the last of its six highs. A plugin's
interface was reverse-proxied onto the console's own origin and framed with
`allow-same-origin`, so plugin JS ran as first-party code on that origin: one
`fetch('/api/**', {credentials:'same-origin'})` and the BFF attached the
operator's ADMIN bearer. That reached everything `plugin_may_access` withholds
— arm pairing, read the host PIN, approve a device, read `/hooks`. The "open
in new tab" link was the same escalation with no iframe involved at all.

The fix is not a sandbox attribute, and it is worth writing down why, because
the obvious change is the one that does not work. Dropping `allow-same-origin`
gives the frame an OPAQUE origin; its subresource requests are then cross-site;
the `SameSite=Lax` session cookie stops being sent; every plugin asset 302s to
/login and the frame is blank. Nothing about the new-tab link is helped either.

So the origin moves instead. A second listener on its own port (default
PORT + 1) serves plugin UIs and nothing else:

  different ORIGIN — scheme+host+PORT — so the same-origin policy separates the
                     plugin from the console: it cannot read the console's DOM,
                     its cross-origin fetch of /api/** is unreadable (no CORS)
                     and cannot mutate (Sec-Fetch-Site sees same-site).
  same SITE        — cookie scope ignores the port and SameSite is computed on
                     the site, so the session cookie still reaches the plugin
                     listener and plugin pages keep working.

Enforcement is two refusals and both are load-bearing: the console origin
refuses /plugin-ui/**, and the plugin origin refuses everything ELSE — above
all /api/**, which would otherwise hand the admin bearer right back to plugin
JS that is now same-origin with that listener. Both are unconditional: if the
plugin port cannot be bound, plugin UIs are DISABLED and the console says so,
rather than falling back to the arrangement this exists to remove.

Two consequences that would otherwise bite in the field:

  The port has to be open. Done for the Windows netsh rule, the firewalld
  service and the ufw profile.

  A browser stores a self-signed-certificate exception per ORIGIN, including
  the port — and a certificate interstitial can never be shown inside an
  iframe, so the frame would just sit blank with nothing on screen explaining
  why. A `no-cors` probe distinguishes it (a TLS failure rejects; any HTTP
  answer, even 401, resolves) and the console renders a card linking the
  operator to open the port once in a real tab.

Also here: the health probe moved server-side to the console origin (it used
to rely on being same-origin with the plugin), the postMessage listener now
verifies `event.origin` — a real check rather than a tautology — and
plugin-kit's `postMessage(..., "*")` is documented as load-bearing, since
narrowing it to `location.origin` would now target the plugin's own origin and
silently drop every message.

Verified against a running console with a fake mgmt API and a fake plugin:
console /plugin-ui/** → 404; plugin-origin /api/v1/hooks, /, /login,
/_auth/logout → 404; plugin page loads 200 through its own origin;
unauthenticated plugin origin → 401 (not a redirect to a /login it does not
serve); a forged x-pf-listener header changes nothing on either listener; the
plugin's own Clear-Site-Data / Access-Control-Allow-Origin / Set-Cookie are
dropped by the proxy allowlist; the plugin origin's CSP names the console as
its only frame-ancestors source; and with the port squatted, ui-config reports
`unavailable`, the console still refuses /plugin-ui/**, and the console itself
keeps working.

Still wants on-glass confirmation in a real browser — the cookie and framing
behaviour is reasoned from spec, not observed.

cargo fmt --all --check clean; cargo check -p punktfunk-host --all-targets
green on Windows; web console builds and typechecks.
2026-08-05 17:50:04 +02:00
enricobuehler 8103958169 fix(security): the plugin lane stops being a way in
Acts on the 2026-08-05 host security review. 36 of its 38 findings; the two
exceptions are recorded below and in the review doc.

The review's headline is that `plugin_may_access` was the one authorization
gate in the system that was allow-by-default — a hand-maintained denylist of
route prefixes, where every sibling gate is deny-by-default. Its own doc
comment names the two capabilities it exists to withhold, and both were
reachable one route over, because ~1450 commits of new routes were added and
the list was never one of the things anyone remembered to update.

So the gate is now an allowlist, and a test walks the live route table and
fails the build for any route that has not been deliberately classified for
both non-admin lanes. That test is the actual fix: it is what stops the next
route from arriving pre-authorized.

Route reachability and field authority turned out to be different questions.
A provider plugin has to be able to reconcile its own library entries — that
is what a scanner plugin IS — but `prep` and a `command` launch inside that
payload are handed to `/bin/sh -c` as the host user, and every execution site
documents them as operator-typed. Requests now carry the lane that authorized
them, and those two fields are refused to everyone but the operator's own
token.

The art proxy read any absolute path off disk in the host process, which on
Windows is LocalSystem, from a path the plugin lane could write and then read
back — so it yielded `mgmt-token`, which is full admin. It now serves only
real images (extension AND magic bytes, so a renamed secret fails), only from
inside an allowed root, only after canonicalization, and never over UNC; and
a path it would refuse to serve can no longer be persisted in the first place.

On Windows, the config-dir hardening was skipped exactly when it was needed —
it ran only in the branch that CREATES host.env, so the case it was written
for (a local user pre-created the directory and planted one) was the one case
it never ran in. It is now unconditional and first, an existing host.env is
re-owned, and the inheritable OWNER RIGHTS ACE that kept an attacker's files
theirs after the directory was re-owned is gone. The identity and token
readers were hardening the directory only on the path that GENERATED a new
secret, so a planted cert/key or token was adopted verbatim and permanently;
they harden before the first read now.

`ensure_admin_only_source` is implemented. The 2026-07-05 audit recorded it as
FIXED and it was in no commit in this repository's history — the local EoP it
described was live, and it is the payload half of the config-dir chain above.

Also: the three input planes are bounded and lossy like the mic plane on the
same loop already was; Android's library client no longer accepts any
publicly-trusted certificate for the pinned host; the usbip vhci nodes get
their own group instead of riding on `input`, which every packaging scriptlet
tells users to join; a registry URL can no longer inject a TOML table into
bunfig.toml; the pairing cooldown is charged before the arming state is read,
so armed/disarmed is no longer a free oracle; and the whole Low tier, of which
the two worth naming are a clipboard MIME NUL that panicked the host on one
control message, and an unauthenticated global logout that let any LAN peer
sign the operator out on a loop.

NOT fixed, deliberately:

  H-3 (plugin UIs framed allow-same-origin). Dropping allow-same-origin does
  not work: the document's origin goes opaque, its subresource requests are
  then cross-site, the SameSite=Lax session cookie is not sent, and every
  plugin asset 302s to /login. The "open in new tab" link is the same
  escalation with no iframe at all, so the sandbox attribute is not where this
  gets fixed either. It needs a second listener — a distinct origin that is
  still the same site — which changes the console's deploy model and wants
  on-glass validation. The mechanism and the dead end are written down at the
  iframe.

  H-6 registry authentication, whose other half lives in unom/infra. The
  in-repo halves are done: workflow_dispatch inputs no longer interpolate into
  run: blocks (one of them in the step holding UPDATE_MANIFEST_KEY), and the
  syft installer is pinned to its tag instead of main. Digest pinning is left
  until the registry is authenticated, because a tag — content-keyed or not —
  can simply be overwritten while anonymous pushes are accepted.

M-5 is half done: the oracle is closed, but binding the arming window needs
the console to learn the fingerprint first, which is a knock-then-bind flow
rather than an edit.

Verified: cargo fmt --all --check clean; cargo check --all-targets green on
Linux and on Windows (confirmed non-vacuous — a planted type error in
windows/install.rs fails the build); scripts/xcheck.sh windows check green;
cargo test -p punktfunk-host --bins 416 passed, the single failure being
gamestream::stream::tests::sender_delivers_batches, the known qemu-environmental
UDP-loopback flake that fails identically on clean main in the same container;
cargo test -p pf-clipboard 13 passed; web console typechecks.
2026-08-05 17:12:12 +02:00
enricobuehler 540c0d3027 feat(pf-vkdecode): the GPU half — session, DPB pools, decode recording, status queries
M2 WP-B. VkVideoSessionKHR lifecycle with drain-before-destroy on
parameters recreation, DPB pools in both coincide and distinct modes
(caps-derived, usage/flags validated against the driver's format
properties), an aligned bitstream ring, vkCmdDecodeVideoKHR recording
with one-shot RESET re-armed on failed submits, timeline-semaphore
completion, and the per-op RESULT_STATUS query ring — the signal
FFmpeg's hwaccel never reads and the reason this program exists.

Frame lifetime is two-phase by construction: release_frame pins a
delivered frame's slot against reuse, closing the coincide-mode
overwrite the adversarial review round proved (a full DPB handed a
just-returned frame's image back as the same call's decode target).
Nine review findings fixed pre-commit; a counterfactual test pins the
collision. Generation-stamped frames, memory-type misses as errors,
granularity-aligned extents, level gate.

AuPlan now carries its activated SPS/PPS (Rc) so backends never
re-parse. GPU smoke test (ignored) decodes 48 AUs past DPB-full with
releases — the fleet runs it in WP-D.

Gates: fmt clean, clippy -D warnings zero, 45+27+53 tests green on
macOS and the linux/amd64 container.
2026-08-05 16:44:44 +02:00
enricobuehler d24f7fc6ac feat(pf-vkdecode): the CPU half of native Vulkan decode — StdVideo conversion + slot map
M2 WP-A (design/client-native-decode.md §3.2). AuPlan -> StdVideo
parameter sets (owned pointer backings), per-AU decode info with slice
start-code offsets, and a PicId->slot map that never evicts on its own.
Deliberate rejections over silent claims: FMO, separate colour planes,
DPBs deeper than 16 frames (unbounded VUI ue(v)) all fail closed.

pf-bitstream API grew what the review proved necessary: RefPic carries
the true top/bottom field order counts (a single poc fabricated
BottomFieldOrderCnt whenever the PPS signals pic-order deltas), the
>16-frame DPB envelope gate, and an MMCO5 rebase warning.

Adversarial review round two: 8 findings fixed pre-commit, including
transactional slot mutation (an error path could permanently desync
the map) and count/pointer coherence on type-1 POC offsets.

Gates: fmt clean, clippy -D warnings zero, 45+26+21 tests green on
macOS and the linux/amd64 container.
2026-08-05 15:03:39 +02:00
enricobuehler 0a359525a7 feat(pf-bitstream): H.264 DecodePlan layer — the AU-to-hardware contract
Adapted from cros-codecs decoder/stateless/h264.rs (POC 8.2.1, ref lists
8.2.4 incl. modification, sliding-window + MMCO/LTR marking 8.2.5,
frame_num-gap handling 8.2.5.2), minus the fd-coupled backend trait.
H264Planner::plan_au maps one wire AU to picture params, per-slice byte
ranges + ref lists keyed by stable PicIds, and a DPB update; recovery-
point SEI parsing is new code (upstream reads no SEI payloads).

Concealment posture, deliberately different from upstream's aborts:
frame_num gaps, failed RPLM/MMCO and mis-split AUs degrade to warnings
the session turns into recovery asks, gap placeholders substitute
in-place so ref_idx mapping never shifts, and DPB outputs queued during
a failed AU survive to the next plan.

An adversarial review round fixed 11 findings before this commit;
one was an upstream cros-codecs bug our conformance-window test exposed
(display_resolution double-subtracts the crop offset and underflow-
panics — PROVENANCE deviation #6, worth reporting upstream).

Gates: fmt clean, clippy -D warnings zero, 45+24 tests green on macOS
and the linux/amd64 container.
2026-08-05 13:13:00 +02:00
enricobuehler 896bb47235 refactor(pf-bitstream): the parser layer is now compiler-enforced unsafe-free
#![forbid(unsafe_code)] on both crates. Upstream's codec module was one
production unsafe away: build_ref_pic_lists turned DPB borrows into
indices via pointer offset_from — same pointer-identity mapping now
expressed as position(ptr::eq) over the <=16-entry DPB (PROVENANCE #5).
Three test-only mem::zeroed() asserts became Default::default(), an
identical value for the all-integer PredWeightTable.

Honest coverage note: build_ref_pic_lists has no callers inside the
vendored subset (its consumer was the non-vendored stateless layer), so
the rewrite is equivalence-by-construction until the DecodePlan layer
exercises it against goldens.
2026-08-05 11:25:41 +02:00
enricobuehler b5e54aea6e feat(client): vendor the cros-codecs parser layer + pf-bitstream skeleton
M1 of design/client-native-decode.md. The vendored snapshot (AOSP mirror
main @ 5ff6d693ffae, BSD-3, PROVENANCE.md) is the codec module only —
H.264/H.265/AV1/VP9 parsers, DPBs and their test vectors, which now run
as 45 conformance tests in our CI. pf-bitstream sits where upstream's
Linux-only decoder::stateless half would and starts with vendor-pinning
smoke tests: a re-sync that shifts parser behavior trips in-tree, not in
a decode session.
2026-08-05 11:19:51 +02:00
enricobuehler 119ec0dd83 feat(client): PUNKTFUNK_DUMP_VIDEO captures the exact decoder input
Fixture-corpus enabler for the native-decode program (M0,
design/client-native-decode.md): every AU exactly as the pump hands it
to decode_frame — the raw concatenation plus a sidecar .idx carrying
the AU boundaries and wire flags a byte stream cannot. Best-effort by
design: any I/O error disables the capture, never the stream.
2026-08-05 11:02:37 +02:00
enricobuehler ce8f3e9eaf feat(packaging): the plugin runner becomes a default component
WP6.1 of design/library-scanner-plugins-implementation-plan.md.

The library is a flagship surface and cannot depend on an opt-in subsystem
(design D9, closing G9): once the scanners are plugins, a host whose runner is
off comes up with an empty library and no obvious reason why. The security
posture for on-by-default was already built and shipped — LocalService on
Windows, a sandboxed systemd --user unit on Linux, the scoped plugin-token lane.

Windows (.iss): the PunktfunkScripting task is registered ENABLED and started on
a FRESH install, and left to the existing restore path on an upgrade. The
distinction is a new TaskExists probe taken before StopBunRuntimes disables
anything — TaskEnabled alone cannot tell a fresh install from an operator who
deliberately turned the runner off, and defaulting to "on" would silently switch
it back on for them.

deb/rpm: `systemctl --global enable` from the postinst/%post, guarded to first
install only so an upgrade never undoes a mask. `--global` because a maintainer
script has no user session to act on, and it is the only mechanism that makes a
--user unit on-by-default for everyone.

sysext: RPM scriptlets never run from a sysext image, so the enablement symlink
is baked in directly (/usr/lib/systemd/user/default.target.wants/). Without it
the runner would ship present-but-off on exactly the platform where an operator
is least likely to go looking for it.

Opt-out throughout is `systemctl --user mask punktfunk-scripting` — `mask`, not
`disable`, since a plain disable cannot remove a symlink under /etc or /usr. The
unit comment, both package descriptions, and the docs-site plugins page all say
so; the page also gains the Windows equivalent.

Not gated on hardware: none of this is verifiable from a Mac. The .iss change
needs an installer run (fresh + upgrade, and an upgrade with the task
deliberately disabled), and the deb/rpm/sysext changes need a package build.
2026-08-05 10:08:11 +02:00
enricobuehler bd383f1820 feat(web): one Game sources surface, launcher rail, and the migration nudge
M4 of design/library-scanner-plugins-implementation-plan.md, plus WP6.2.

WP4.1 — SourceToggles and ProvidersCard merge into Library/Sources.tsx. They
were two cards because they were two different things: scanners were compiled
into the host, plugins were an afterthought. After the extraction they are the
same thing — the host reports ONE list of sources whose ids match whether they
came from a built-in scanner or the plugin replacing it — so one surface is both
simpler and the only honest presentation. Each row carries its toggle, a
running/stopped badge for plugin sources, an entry count, filter, settings and
an uninstall that offers to remove the games too. An "Add a source" rail lists
uncatalogued library plugins with a "Detected" badge; `detected` is deliberately
tri-state, so only a POSITIVE probe badges — an entry with no probes for this
platform is unknown, and calling that "not installed" would be a lie.

The settings drawer (SourceSettings.tsx) renders a generic form from the
plugin's own JSON Schema over GET/PUT /__config, through the existing
session-gated /plugin-ui/<id>/ proxy — zero new host surface, and the browser
never learns the plugin's port or secret. It flattens allOf branches (effect
nests a checked schema's annotations there, so a form reading only the top level
silently loses every title and default) and falls back to a JSON editor when any
field is a shape it cannot express — partial rendering would be worse than none,
because a field missing from the form is a setting the operator cannot change.

WP4.2 — uiPlugins() now excludes category "library", which covers both the
sidebar and the mobile overflow since they share the selector. The
/plugins/$pluginId/$ route still resolves, so existing deep links keep working;
library plugins are just not advertised.

WP4.3 — LibraryGrid groups role:"launcher" entries into a rail above the grid,
and the empty state points at the sources surface rather than leaving a bare
grid (after extraction, "no games" is the expected first-run state).

WP6.2 — a migration banner offering one install per still-built-in scanner whose
plugin is catalogued. One button per scanner, never a single "migrate
everything" and never a silent auto-install: installing code stays an explicit
operator act, and per-scanner is what makes it safe to repeat (the claim
suppresses the built-in idempotently, so a half-finished migration is a valid
state).

WP4.4 — i18n en+de (kept under the existing "Game sources" label rather than
minting a third "Plugins"), Storybook stories for the sources card in three
states, the launcher rail and the banner. Gates: orval regen, tsc clean, vite
build clean, check-i18n green at 595 messages for both locales.

Still owed: the browser click-through (the store's Tabs-theme bug shipped
through green types and lint), and an AppShell nav story — that one needs the
plugins query mocked, which does not exist in this Storybook setup yet.
2026-08-05 10:03:24 +02:00
enricobuehler 8728d90e01 feat(plugin-kit): the library-plugin framework — parsers, __config, defineLibraryPlugin
M3 of design/library-scanner-plugins-implementation-plan.md. Target shape: a
first-party scanner plugin is its parsers plus a scan function.

WP3.1 — a parsers module under the new ./library subpath, porting what the six
in-host scanners hand-rolled: text VDF/ACF, the BINARY shortcuts.vdf KeyValues
walker with its CRC-32 appid derivation and the 64-bit rungameid composition,
read-only SQLite (bun:sqlite, immutable=1 so a scan can never take a lock or
spawn WAL sidecars next to a launcher's live database), a reg.exe wrapper,
capped readers, the path-confinement join that keeps a crafted goggame-*.info
from pointing a launch at an arbitrary program, Steam root/library discovery,
art location helpers, and a fetch helper carrying the host's no-redirect
anti-SSRF posture. Every parser is total: a missing launcher or a truncated file
degrades to "no titles", never to a throw.

Two deliberate departures from the Rust originals, both about the Windows
runner's account: steam root discovery now also reads HKLM Valve\Steam
InstallPath (a non-default install dir was previously uncovered), and the
registry wrapper refuses HKCU outright — as LocalService that is not the
operator's hive, so reading it would silently look like "not installed".

WP3.2 — GET/PUT /__config on the kit's UI server, so a plugin with settings does
not ship an SPA (closes G8). GET answers {schema, value}: the derived JSON Schema
and the raw operator-authored config. PUT validates by decoding and only then
persists RAW, so defaults are never baked into the file. The handler is split out
as makeConfigHandler and driven directly in tests.

WP3.3 — defineLibraryPlugin wires SyncEngine (poll + fs-watch + debounce), the
store-claiming reconcile, launcher entries appended to every sync, a UI server
serving only __config under category "library" (which keeps six installed
scanners out of the console nav), and the standard detect/scan/uninstall CLI
verbs. It warns ONCE when a pre-M2 host silently ignores the store claim — that
degradation is otherwise invisible except as duplicated titles.

M0/S2 is recorded here as a committed fixture rather than prose. Two findings the
original spike missed because deriving a schema does not exercise it:
withDecodingDefaultKey takes an Effect, not a thunk — a thunk type-checks, derives
fine, and dies at decode time; and a checked schema (Schema.Int) nests its
annotations under allOf, so a form must merge those branches. Both are pinned.

plugin-kit: version 0.3.0, tsc clean, 46 tests pass (16 ported parser tests, 10
config/derivation). Publishing (WP3.4) is deferred — it needs a tag and a push.
2026-08-05 09:53:58 +02:00
enricobuehler 3d4a659959 feat(host,sdk,kit): store claims, launcher entries, and plugin sources on the wire
M2 of design/library-scanner-plugins-implementation-plan.md. Everything a
library scanner plugin needs is now expressible over the API; all additive.

WP2.1/2.2 — store claims (D2). library.json gains a v2 shape ({entries, claims})
that loads the v1 bare array unchanged and is written on the first mutation.
PUT /library/provider/{p}?store=<s> claims a store for a provider: its entries
then surface with deterministic <store>:<external_id> ids and the store's own
badge instead of opaque custom:<id> ones. That identity is the whole point —
entry ids, GameStream FNV app ids, client art caches and Moonlight pins all
survive a title moving from an in-host scanner to a plugin. One provider per
store (409 otherwise); DELETE releases; an empty reconcile does NOT (a store can
legitimately have zero titles). While a claim is held, all_games() skips the
matching built-in scanner, so the two never double-list during the bridge.

WP2.3 — DetectHint gains steam_appid and env_marker, the two store-derived
signals the host used to read for itself. Without them a steam plugin's lease
tracking would drop from reaper-exact to dir-prefix, and Heroic-under-Proton
would lose the only signal that works. Malformed markers are dropped, not
honoured — this feeds a path that can end processes.

WP2.4/2.5 — role: game|launcher on the entry shapes (serde-default, skipped when
default), and a steam_ui launch kind valued bigpicture|desktop that opens the
Steam client itself. Validated inbound as well as at launch.

WP2.6 — GET/PUT /library/scanners generalizes to SOURCES: built-in scanners
minus claimed ones, plus claimed stores, plus any provider with entries. The
same library-scanners.json disabled-set backs all of them and the ids match by
construction, so a user's disabled state carries over verbatim through the whole
migration. A disabled plugin source has its entries filtered at read time,
exactly like a disabled scanner.

WP2.7/2.8 — plugin registration gains a category field (the console keeps
library plugins out of the nav); index entries gain categories and per-platform
detect probes, evaluated existence-only into CatalogEntry.detected so the host
never re-grows per-store knowledge. Index SCHEMA stays 1 — additive.

WP2.9 — OpenAPI + SDK regenerated on Linux; kit wire widened (LaunchSpec.kind is
now a plain string documented against the host's vocabulary — closes G3), and
ProviderClient.reconcile takes an optional store and returns the host's echoed
entries so a caller can detect a pre-M2 host silently ignoring the claim.

Also fixes a bug the S3 spike turned up: is_steam_launch gated on a steam:// URI,
so a steam_ui launcher entry would have skipped BOTH gamescope's --steam mode and
the B1 single-instance free — on a box autologged into game mode, the nested
second Steam would see the first and exit, crashing the spawn. It now tests the
first token.

Gates on .21: workspace tests green (punktfunk-host 425 passed), workspace
clippy -D warnings clean, cargo fmt --all --check clean, OpenAPI drift test
green. plugin-kit: tsc clean, 20 tests pass.
2026-08-05 09:39:31 +02:00
enricobuehler a418d2852a refactor(host/library): launch helpers into launch.rs, art proxy resolves any id
M1 of design/library-scanner-plugins-implementation-plan.md — behavior-frozen
groundwork for lifting the six scanners out into plugins.

WP1.1: heroic_command/heroic_launch_prefix, epic_launch_uri, gog_spawn,
valid_steam_appid and shortcut_gameid move into library/launch.rs with their
unit tests. The scanner modules beside it now do enumeration only, so they can
be deleted wholesale later without taking launch logic with them (D1).

WP1.2: is_local_art_path accepts file:// (the plugin contract) and POSIX
absolute paths, excluding the two /-leading shapes the host itself emits (its
own /api/ proxy path and protocol-relative CDN URLs). local_art_bytes
percent-decodes and converts a file:// value first. The art proxy and
fetch_box_art resolve ANY id against library.json before the legacy steam:
branch, so a plugin's entries serve art without the host knowing its store.

No API change; no user-visible change.
2026-08-05 09:09:19 +02:00
enricobuehler 110ac9b663 Merge pull request 'fix(stall): T2 amplification kill — resume-edge pacing + ABR starved-window guard' (#53) from worktree-stall-ride-through into main
apple / swift (push) Successful in 1m26s
ci / docs-site (push) Successful in 1m15s
ci / web (push) Successful in 1m36s
ci / rust-arm64 (push) Successful in 3m4s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 22s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
deb / build-publish (push) Successful in 3m43s
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 8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 26s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
deb / build-publish-client-arm64 (push) Successful in 4m11s
deb / build-publish-host (push) Successful in 4m27s
docker / builders-arm64cross (push) Successful in 5s
docker / deploy-docs (push) Successful in 33s
arch / build-publish (push) Successful in 7m29s
android / android (push) Successful in 8m0s
ci / rust (push) Successful in 9m20s
flatpak / build-publish (push) Successful in 5m36s
release / apple (push) Successful in 11m4s
windows-host / package (push) Successful in 12m31s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 16s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m59s
apple / screenshots (push) Successful in 5m52s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m19s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 2m41s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m1s
2026-08-05 06:35:30 +00:00
enricobuehler 1d6f4760f3 Merge branch 'main' into worktree-stall-ride-through
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m8s
apple / swift (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m7s
android / android (pull_request) Successful in 3m10s
ci / web (pull_request) Successful in 1m9s
ci / rust-arm64 (pull_request) Successful in 1m38s
ci / docs-site (pull_request) Successful in 1m25s
ci / rust (pull_request) Successful in 6m32s
2026-08-05 06:22:41 +00:00
enricobuehler 9dfbc2f895 Merge pull request 'fix(client-core): pad-audio references the WASAPI module by its mounted name' (#57) from fix/pad-audio-wasapi-module-path into main
ci / web (push) Successful in 1m7s
ci / rust-arm64 (push) Successful in 1m22s
apple / swift (push) Successful in 1m27s
ci / docs-site (push) Successful in 1m24s
deb / build-publish-client-arm64 (push) Successful in 2m46s
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 7s
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 4s
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 4s
deb / build-publish-host (push) Successful in 4m8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 49s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m12s
deb / build-publish (push) Successful in 6m26s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m43s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 16s
docker / builders-arm64cross (push) Successful in 19s
apple / screenshots (push) Successful in 5m53s
android / android (push) Successful in 8m51s
arch / build-publish (push) Successful in 9m37s
ci / rust (push) Successful in 10m25s
docker / deploy-docs (push) Failing after 3m52s
flatpak / build-publish (push) Canceled after 5m43s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 5m20s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 5m45s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-05 06:22:31 +00:00
enricobuehler 56adb47026 fix(client-core): pad-audio references the WASAPI module by its mounted name
ci / web (pull_request) Successful in 56s
apple / swift (pull_request) Successful in 1m25s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m40s
ci / docs-site (pull_request) Successful in 2m33s
ci / rust-arm64 (pull_request) Successful in 2m43s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m17s
android / android (pull_request) Successful in 4m12s
ci / rust (pull_request) Successful in 6m21s
The Windows build of pf-client-core has been red on main since the
pad-audio merge (#23): pad_audio.rs calls
`crate::audio_wasapi::device_by_id`, but lib.rs mounts audio_wasapi.rs AS
`crate::audio` via the #[path] per-OS swap — the `audio_wasapi` module
name never exists. Windows-gated call site, so every Linux leg stayed
green while both `windows / build` targets failed E0433.

One-line rename to the mounted path (+ the comment that pointed readers
at the phantom name). Verification is the PR's own windows leg — the
crate builds on no other platform this path compiles on.
2026-08-05 08:15:02 +02:00
enricobuehler 52a9d02355 Merge pull request 'fix(deps): close the undici, fast-uri, postcss and brace-expansion advisories' (#55) from worktree-audit-undici into main
audit / cargo-audit (push) Successful in 43s
audit / bun-audit (plugin-kit) (push) Successful in 16s
audit / bun-audit (sdk) (push) Successful in 17s
audit / bun-audit (web) (push) Successful in 21s
audit / docs-site-audit (push) Successful in 18s
audit / pnpm-audit (push) Successful in 9s
ci / web (push) Successful in 1m14s
ci / docs-site (push) Successful in 1m23s
ci / rust-arm64 (push) Successful in 2m20s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
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 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 4s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
deb / build-publish-client-arm64 (push) Successful in 2m45s
deb / build-publish (push) Successful in 5m12s
audit / license-gate (push) Successful in 5m44s
deb / build-publish-host (push) Successful in 4m56s
arch / build-publish (push) Successful in 11m54s
docker / builders-arm64cross (push) Successful in 49s
ci / rust (push) Successful in 10m46s
docker / deploy-docs (push) Failing after 3m45s
windows-host / package (push) Successful in 17m5s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m10s
Reviewed-on: #55
2026-08-05 05:51:06 +00:00
enricobuehler e5ca213339 fix(core/abr): a starved window is never a decode-knee sample
apple / swift (pull_request) Successful in 1m25s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 6m2s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 2m57s
ci / rust-arm64 (pull_request) Successful in 1m24s
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m47s
android / android (pull_request) Successful in 5m30s
ci / rust (pull_request) Successful in 9m50s
Stall program T2 (amplification kill), the phantom-latch half. A deciding
window that delivered under a quarter of the target rate (a host-side
capture stall, an outage, a mid-window pause) carries starvation-shaped
distress — a jump-to-live flush, a keyframe-ask burst — that the decode-cap
latch read as decoder evidence: under a periodic capture stall (the RDNA4
standby-sink field cases, one stall every ~5 s) every edge offers another
'backoff' at the SAME rate, and one pair latches a phantom decoder knee at
whatever rate the display driver happened to interrupt. The session then
fights the cap's re-probe ladder (+12.5% per 16-128 clean windows) for
minutes on a decoder that was never the problem.

Starved windows still back off (real damage deserves the safe response) but
take the same 'not a knee sample either way' arm as a draining backoff:
they neither latch a decode cap nor erase the reference a genuine choke
set, so a real knee's pair still finds itself around the interruption. The
¼ bar sits deliberately far under the ×¾ utilization bar climbs require.

Gates: 44 abr tests green (2 new: the stall-cycle no-latch scenario and the
reference-preservation scenario), full core lib suite 346 green
(--features quic), fmt + clippy clean.
2026-08-05 00:28:25 +02:00
enricobuehler e5416646f9 fix(host/send): a stall-resume frame paces at the proven rate instead of blasting
Stall program T2 (amplification kill), the resume-burst half. The native
pace budget was min(0.9 × time-to-deadline, overflow at ~3× stream rate) —
for steady-state frames the rate term is smaller and decides, but for an
OVERSIZED frame (a capture-stall resume carrying seconds of scene delta, a
cold IDR) the deadline term clamped a multi-interval overflow into the
remainder of ONE: an instantaneous many-×-stream-rate blast that overruns
the socket tx-buffer and loses the very frame that would have ended the
freeze. Field fingerprint across three RDNA4 standby-sink cases:
WSAENOBUFS(10055) + loss_ppm spikes at stall edges, then a recovery-IDR
round trip per retry while the client shows 'current bitrate 0.1'.

The budget is now the overflow's wire time at the pace rate itself
(send_pacing::native_budget, pure + unit-tested), bounded by an absolute
100 ms ceiling so a pathological frame can't park the send thread; the
deadline stays a target, never a license to blast. Steady-state frames
produce byte-identical schedules (the rate term already decided);
PUNKTFUNK_PACE_FACTOR=0 keeps the legacy deadline-only spread; the
GameStream plane's Moonlight-pinned schedule is untouched.

Gates: host clippy --all-targets -D warnings + 9 send_pacing tests green
(linux/amd64 container), fmt clean.
2026-08-05 00:28:13 +02:00
enricobuehler b79d90b463 fix(deps): close the undici, fast-uri, postcss and brace-expansion advisories
ci / web (pull_request) Successful in 1m8s
ci / rust-arm64 (pull_request) Successful in 1m33s
ci / docs-site (pull_request) Successful in 1m21s
ci / rust (pull_request) Failing after 7m49s
audit.yml's three blocking bun-audit legs (web, sdk, plugin-kit) were all red on
main. Ten findings in sdk and plugin-kit, eight in web; every one of them a
transitive dependency, none reachable by bumping a direct dep.

web already carried the right mechanism — an `overrides` block whose `undici` and
`fast-uri` pins had simply gone stale — so it needed four bumps, not a new idea:
undici 7.28.0 -> ^7.29.0 and fast-uri 3.1.4 -> ^3.1.5 for the reported advisories,
plus postcss ^8.5.10 -> ^8.5.25 and brace-expansion ^5.0.8 -> ^5.0.9 for two more
that were published after the failing run and would have gone red on the next
audit anyway. All four stay inside their current major.

sdk and plugin-kit were harder and the fix deserves an explanation. Their single
finding is undici 8.7.0/8.8.0 pulled in by @effect/platform-node, a devDependency
pinned at 4.0.0-beta.98. That dependency already declares `undici: ^8.7.0`, which
permits the fixed 8.10.0 — the vulnerable version survives purely as a stale
lockfile resolution. Nothing bumps it in place: `bun update` only walks direct
dependencies, `bun install --force` preserves a resolution that still satisfies
its range, and every platform-node release through beta.103 declares the same
`^8.7.0`, so moving the dep changes nothing. Bun rejects the scoped form outright
("Bun currently does not support nested resolutions"), so a flat `overrides` entry
is the only mechanism available, and it necessarily also moves sdk's top-level
undici from 7.x to 8.x.

That is safe here, and was verified rather than assumed. The only source use is
sdk/src/config.ts, which does `new Agent({ connect: { ca } })` behind a dynamic
import and a try/catch with a documented plain-fetch fallback; `Agent` and its
`connect` option are unchanged between undici 7 and 8. sdk typechecks and its 72
tests pass against 8.10.0; plugin-kit typechecks and its 20 tests pass. Both trees
now dedupe to a single undici 8.10.0.

Consumers are deliberately untouched: `overrides` apply only at the root of the
tree that declares them and are not honored when the package is installed as a
dependency, so sdk's published `optionalDependencies: { undici: "^7.0.0" }` is
left alone — a consumer resolves the latest 7.x, which is the fixed 7.29.0. The
override governs this repo's own tree, which is exactly what audit.yml checks.
Worth knowing: sdk's dev tree therefore exercises undici 8 while consumers get 7.

One trap found on the way. Running `bun install` over plugin-kit's existing
lockfile emitted a lockfile with two byte-identical `@punktfunk/host` entries —
its `file:../sdk` dependency crossed with the new override — and bun then refuses
its own output with "Error loading lockfile: InvalidPackageKey". That reads as a
tooling error rather than a finding, so it would have taken the audit gate down
while looking like something else entirely. Regenerating the lockfile from scratch
produces a valid single entry; all three lockfiles are checked for duplicate keys.

Also worth recording, because it nearly shipped: deleting the pinned nested entry
from a lockfile makes `bun audit` report "No vulnerabilities found" while the
vulnerable copy is still installed on disk. bun audit reads the lockfile, not
node_modules. That is a vacuous green, not a fix, and was rejected.

Verified: `bun audit` clean in all three trees; web builds and typechecks (its
typecheck needs the build first, which generates routeTree.gen); sdk 72/72 and
plugin-kit 20/20 tests pass.
2026-08-05 00:22:25 +02:00
enricobuehler 8983ec04b9 Merge pull request 'feat(pad-audio): DualSense voice-coil haptics + speaker, host to client' (#23) from feat/android-pad-audio into main
audit / bun-audit (plugin-kit) (push) Failing after 30s
audit / cargo-audit (push) Successful in 35s
apple / swift (push) Successful in 1m20s
audit / bun-audit (sdk) (push) Failing after 23s
audit / bun-audit (web) (push) Failing after 19s
audit / pnpm-audit (push) Successful in 12s
audit / docs-site-audit (push) Successful in 22s
ci / web (push) Successful in 1m7s
ci / rust-arm64 (push) Successful in 1m25s
ci / docs-site (push) Successful in 1m9s
android / android (push) Successful in 6m30s
audit / license-gate (push) Successful in 6m28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 28s
deb / build-publish-client-arm64 (push) Successful in 3m8s
deb / build-publish (push) Successful in 4m48s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
arch / build-publish (push) Failing after 10m5s
ci / rust (push) Failing after 7m23s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 24s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 31s
docker / builders-arm64cross (push) Successful in 12s
deb / build-publish-host (push) Successful in 5m34s
release / apple (push) Successful in 9m30s
apple / screenshots (push) Successful in 5m56s
windows-host / package (push) Successful in 18m17s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 16s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 1m53s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 1m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m10s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 2m16s
flatpak / build-publish (push) Successful in 18m26s
docker / deploy-docs (push) Successful in 18m42s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 2m10s
2026-08-04 21:56:37 +00:00
enricobuehler d27e62f7c9 fix(pad-audio): close the twelve findings the sweep left open on this branch
apple / swift (pull_request) Successful in 1m31s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 41s
ci / web (pull_request) Successful in 1m59s
ci / docs-site (pull_request) Successful in 1m59s
ci / rust-arm64 (pull_request) Successful in 4m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 2m37s
android / android (pull_request) Successful in 4m16s
ci / rust (pull_request) Failing after 10m50s
Everything the 2026-08-03 haptics sweep filed against the pad-audio branch (P2 + P3).
Four of them are the difference between a feature that works and one that fails silently.

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

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

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

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

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

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

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

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

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

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

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

The rest: `wiring_plan::plan` now delegates to main's `plan_with_formats`, so the pad-endpoint
filter moved into that body and the predicate behind it is factored out as `is_pad_render`
(also what B10 needs); `Ds5Feedback::AUDIO` derives from main's `REPORT_ID_LEN` like its
siblings; `AudioCtl` joins the explicitly-listed unhandled variants so the guard-false case is
covered rather than swept up by a `_`; `include/punktfunk_core.h` regenerated rather than
hand-merged.
2026-08-04 23:27:06 +02:00
enricobuehler 2d223274fc Merge pull request 'refactor(haptics): one copy of each thing every rumble path was transcribing' (#51) from worktree-haptics-m12-dry into main
apple / swift (push) Successful in 1m22s
ci / web (push) Successful in 1m16s
ci / rust-arm64 (push) Successful in 2m31s
ci / docs-site (push) Successful in 1m59s
android / android (push) Successful in 7m52s
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 8s
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 6s
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 46s
deb / build-publish (push) Successful in 4m59s
deb / build-publish-client-arm64 (push) Successful in 3m7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m12s
docker / builders-arm64cross (push) Successful in 5s
deb / build-publish-host (push) Successful in 4m38s
docker / deploy-docs (push) Successful in 29s
release / apple (push) Successful in 8m57s
ci / rust (push) Successful in 10m43s
arch / build-publish (push) Successful in 10m49s
apple / screenshots (push) Successful in 5m55s
flatpak / build-publish (push) Successful in 8m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m39s
windows-host / package (push) Successful in 17m21s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m44s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m16s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m49s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 1m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m59s
2026-08-04 21:11:49 +00:00
enricobuehler 92f617a989 Merge remote-tracking branch 'origin/main' into worktree-haptics-m12-dry
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m28s
ci / docs-site (pull_request) Successful in 2m30s
android / android (pull_request) Successful in 3m58s
ci / rust-arm64 (pull_request) Successful in 5m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m6s
ci / rust (pull_request) Successful in 8m47s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m10s
# Conflicts:
#	clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt
#	crates/pf-client-core/src/gamepad.rs
2026-08-04 23:11:22 +02:00
enricobuehler 2f071a9a93 Merge pull request 'fix(clients/settings): controller settings that can't do anything no longer look live' (#50) from worktree-haptics-m11-settings into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 59s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-04 21:08:15 +00:00
enricobuehler 62d35bc4b6 Merge pull request 'fix(core/wire): a truncated trigger datagram stops cancelling the effect it should carry' (#45) from worktree-haptics-m10-wire into main
android / android (push) Canceled after 7s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
arch / build-publish (push) Canceled after 2m57s
deb / build-publish (push) Canceled after 2m52s
deb / build-publish-host (push) Canceled after 2m24s
deb / build-publish-client-arm64 (push) Canceled after 1m38s
flatpak / build-publish (push) Canceled after 6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-04 21:07:54 +00:00
enricobuehler 5d06ef26ac Merge pull request 'fix(feedback): the pad stops keeping a game's trigger effect after the stream ends' (#44) from worktree-haptics-m9-richfb into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
deb / build-publish (push) Canceled after 4s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 2s
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
flatpak / build-publish (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-04 21:07:35 +00:00
enricobuehler fcf4076eb7 Merge remote-tracking branch 'origin/main' into worktree-haptics-m9-richfb
ci / docs-site (pull_request) Successful in 1m14s
ci / web (pull_request) Successful in 2m10s
apple / swift (pull_request) Successful in 1m29s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m45s
android / android (pull_request) Successful in 4m54s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m17s
ci / rust (pull_request) Successful in 7m59s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m0s
# Conflicts:
#	clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt
#	crates/pf-client-core/src/gamepad.rs
2026-08-04 23:07:18 +02:00
enricobuehler 53eb592c43 Merge pull request 'fix(host/pads): a centred stick reads centred, and a delayed effect waits its turn' (#43) from worktree-haptics-m8-proto into main
deb / build-publish-client-arm64 (push) Successful in 1m17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
android / android (push) Canceled after 0s
ci / rust-arm64 (push) Successful in 2m58s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
ci / web (push) Successful in 2m52s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 3m8s
ci / docs-site (push) Canceled after 2m56s
deb / build-publish (push) Canceled after 2m50s
deb / build-publish-host (push) Canceled after 2m48s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 13s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 18s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-04 21:03:33 +00:00
enricobuehler 956d8dd8ef Merge pull request 'fix(host/windows): two virtual pads stop tearing each other's reports' (#39) from worktree-haptics-m7-windows into main
deb / build-publish-host (push) Canceled after 4s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
android / android (push) Canceled after 10s
deb / build-publish-client-arm64 (push) Canceled after 4s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
ci / rust (push) Canceled after 10s
ci / rust-arm64 (push) Canceled after 7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
ci / web (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 14s
ci / docs-site (push) Canceled after 0s
deb / build-publish (push) Canceled after 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 14s
docker / builders-arm64cross (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 14s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-drivers / probe-and-proto (push) Successful in 33s
windows-drivers / driver-build (push) Successful in 1m58s
2026-08-04 21:03:13 +00:00
enricobuehler b2e716ad5f Merge pull request 'fix(client/desktop): the Deck keeps its trackpad, and a pad stops buzzing at exit' (#38) from worktree-haptics-m6-presenter into main
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
android / android (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
apple / swift (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 27s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 4m25s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-04 21:02:36 +00:00
enricobuehler ec288d64d3 Merge pull request 'fix(client/android): rumble survives a vibrator fault, and an unplug stops leaking' (#35) from worktree-haptics-m5-android into main
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
android / android (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
ci / rust (push) Canceled after 4s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
2026-08-04 21:02:15 +00:00
enricobuehler 68353a5d57 Merge pull request 'fix(client/apple): two DualSenses stop fighting over one device, and a failed stop stops lying' (#32) from worktree-haptics-m4-apple into main
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
apple / swift (push) Canceled after 53s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
2026-08-04 21:01:51 +00:00
enricobuehler ffd5a33598 Merge pull request 'fix(core/rumble): the Deck's keepalive stops being swallowed by its own renewals' (#30) from worktree-haptics-m3-rumble-engine into main
windows-host / package (push) Canceled after 1m3s
windows-host / canary-manifest (push) Canceled after 0s
deb / build-publish (push) Canceled after 58s
deb / build-publish-host (push) Canceled after 56s
deb / build-publish-client-arm64 (push) Canceled after 49s
windows-host / winget-source (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
apple / swift (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
arch / build-publish (push) Canceled after 56s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 49s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
android / android (push) Canceled after 7s
release / apple (push) Canceled after 5m31s
2026-08-04 21:01:32 +00:00
enricobuehler 4af8b02be1 Merge pull request 'fix(host): a leftover Sunshine folder is not a conflict, and a crashed host gives the screen back' (#52) from worktree-conflict-detect-and-isolate-recovery into main
deb / build-publish (push) Canceled after 1s
deb / build-publish-client-arm64 (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 13s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-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
android / android (push) Canceled after 25s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
windows-host / package (push) Canceled after 1m11s
docker / builders-arm64cross (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
apple / swift (push) Canceled after 32s
windows-host / winget-source (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
arch / build-publish (push) Canceled after 37s
ci / rust (push) Canceled after 46s
ci / web (push) Canceled after 47s
ci / rust-arm64 (push) Canceled after 48s
ci / docs-site (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
Reviewed-on: #52
2026-08-04 21:00:56 +00:00
enricobuehler 42a0dd52be refactor(haptics): one copy of each thing every rumble path was transcribing
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / rust (pull_request) Successful in 9m44s
ci / rust-arm64 (pull_request) Successful in 2m0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m2s
android / android (pull_request) Successful in 5m3s
Twelve findings from the sweep's DRY/docs/dead-code tail. Most are small; three found
real defects hiding behind the duplication.

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

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

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

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

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

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

Verified: pf-inject clippy -D warnings 0 / 91 tests; pf-client-core + punktfunk-core
clippy 0 / 437 tests (amd64 container); punktfunk-client-android 7 tests; Android :kit:
6 tests; Apple swift build + 189 tests / 0 failures; cargo fmt --all --check clean. Each
new test probed by reverting its fix — the fixed SET_REPORT window fails 3, a broken pack
shift fails 3, dropping the amplitude floor fails 1, and a wrong DS5 offset either fails
the pin or refuses to compile.
2026-08-04 22:52:38 +02:00
enricobuehler b31495bea5 fix(host): a leftover Sunshine folder is not a conflict, and a crashed host gives the screen back
ci / rust (pull_request) Successful in 9m53s
ci / rust-arm64 (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
apple / swift (pull_request) Successful in 1m20s
ci / docs-site (pull_request) Successful in 1m12s
android / android (pull_request) Successful in 2m56s
ci / web (pull_request) Successful in 2m0s
Three things a field report (Discord, upgrade from 0.1x) turned up, all on the
Windows host.

1. "It thinks I have Sunshine/Apollo running." It didn't — they were uninstalled.
   Both uninstallers leave their config/log directory in Program Files behind, and
   `detect.rs` counted a bare directory, or a service registered at ANY start type
   (including `disabled`), as a live conflict. The installer's own probe was
   narrowed to "service start type <= 2" after exactly this cried wolf on a
   `winget install`, and the tray dropped its always-on warning for the same reason
   in 3e782852 — the runtime probe never got the same treatment, so the one surface
   the user actually looks at kept shouting. `Evidence::is_active` now draws the
   line (running, or set to start on its own) and only active detections reach the
   startup warning, the `detect-conflicts` exit code, and `/local/summary`. Dormant
   findings still print in the full report, under a heading that says they need no
   action — that report is where "why does it think I have Apollo?" gets answered.

2. The console's conflicts card hardcoded "Another game-streaming server is
   **running** on this machine" regardless of what was found, so a dormant leftover
   was announced as a running server. It now says "active", and each entry names
   the observation — `Sunshine (running)`, `Apollo (starts automatically)`.

3. "The exclusive screen never times out going back to re-enabling the display."
   `isolate_displays_ccd` deactivates the operator's panels and hands the
   pre-isolate topology to the caller, which restores it at teardown — but that
   snapshot is PROCESS MEMORY, and Windows deliberately never saves the isolated
   topology to the CCD database. So a host that crashed, was killed, or was stopped
   mid-session left the desk dark with nothing in the product to undo it. There was
   one startup recovery leg already, but only for the EXPERIMENTAL
   `pnp_disable_monitors` axis, which is off by default — the default Exclusive path
   had none. `isolate_journal` now marks what an isolate is about to switch off
   (before the apply, so dying mid-apply is covered), clears the mark on restore,
   and force-EXTENDs at host startup if a mark survived. EXTEND rather than
   replaying the saved blob: the blob pins the virtual display's target id, which
   dies with the crashed host, so a replay would mostly fail BAD_CONFIGURATION into
   the very same backstop `restore_displays_ccd` already keeps — and EXTEND stays
   correct across a reboot, where saved ids would be stale.
2026-08-04 22:34:33 +02:00
enricobuehler 9fb41affba fix(clients/settings): a controller setting you can't use no longer looks like one you can
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 2m41s
ci / web (pull_request) Successful in 3m30s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m16s
android / android (pull_request) Successful in 5m37s
ci / rust (pull_request) Successful in 8m4s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m30s
Turn "Forward controllers" off and four rows below it stop meaning anything — nothing is
forwarded, so there is no pad type to pick and no guide button to route. GTK desensitised
them, the touch settings on both mobile clients dimmed them and the console UI refused the
step; the Windows client and BOTH controller-navigable screens left them fully live, so you
could sit there changing settings that did nothing.

Windows: `.enabled(s.gamepad_forwarding)` on the forwarded-controller picker, pad type,
guide button and hold-Select rows — the same builder the echo-cancellation row already used
to follow the mic switch.

Apple's gamepad settings had no way to say it: `Row` carried `adjustable` (which only hides
the chevrons) and nothing else. Added `Row.enabled`, dimmed the row CONTENTS only so the
glass still reads as a focusable row, and enforced the inertness centrally in `adjust(id:)`
/ `activate(id:)` rather than in each builder's closure. The hint bar drops "Adjust"/"Change"
on a dimmed row, because advertising them was the same lie the live row told.

Android's gamepad settings already had `GpRow.enabled` — documented as "dimmed + inert" —
but it only faded the label: every dimmed row still stepped and still wrote its setting. The
"No profiles yet" placeholder looked inert only because its own closures were empty. Made it
real in one named place (`liveRow`), covering all three input paths (left/right, A, and a tap
on the already-focused row), then gated the pad rows on it.

Also on that screen: the DualSense / DualShock passthrough toggle, which the touch settings
have carried beside its SC2 twin all along. It was missing exactly where it matters most —
a TV box has no touch interface to fall back to, so there was no way to reach it at all.

Apple capture, separately: with forwarding off, opening a slot still claimed EVERY element's
system gesture and powered the controller's IMU. Neither reaches the host, so the first only
took the user's screenshot/Home gestures away for nothing and the second drained the pad's
battery streaming gyro over Bluetooth. Narrowed rather than skipped — the escape chord is
read off the same slot and on tvOS is the ONLY controller way out of a stream, so the chord's
own four buttons keep their claim. A test pins the alias list against the chord mask; if they
drift the symptom is a session nobody can leave, with nothing logged.

Closes R17, R18, R19 (design/haptics-sweep-2026-08-03.md M11). R17 as filed named Windows and
"Apple"; Apple's TOUCH settings were already correct and Android's controller-navigable screen
was not — both corrected here.

Verified: Windows clippy -D warnings exit 0 on a real Windows box; Apple swift build clean +
full suite 192 tests / 0 failures (3 new); Android :app: + :kit: green (5 new); cargo fmt
--all --check clean. Each fix probed by reverting it — every probe failed the tests it should.
2026-08-04 22:25:51 +02:00
enricobuehler ee0b179618 Merge pull request 'docs(apple/store): App Store copy for iOS, macOS and tvOS, counted against Apple's limits' (#49) from worktree-appstore-copy into main
ci / rust-arm64 (push) Successful in 2m29s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
ci / web (push) Successful in 2m49s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
ci / docs-site (push) Successful in 2m38s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 18s
docker / builders-arm64cross (push) Successful in 7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m42s
release / apple (push) Successful in 9m23s
apple / swift (push) Successful in 1m34s
ci / rust (push) Successful in 7m27s
docker / deploy-docs (push) Failing after 6m11s
apple / screenshots (push) Successful in 6m13s
Reviewed-on: #49
2026-08-04 20:03:34 +00:00
enricobuehler d7e22c3db2 Merge pull request 'fix(apple/shots): the store screenshots show the app as it actually is' (#48) from worktree-apple-store-screenshots into main
apple / swift (push) Canceled after 15s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
Reviewed-on: #48
2026-08-04 20:02:37 +00:00
enricobuehler c1231fa2e6 Merge pull request 'feat(clients/input): system buttons route around local overlays' (#47) from worktree-system-buttons-routing into main
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
arch / build-publish (push) Successful in 8m52s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m1s
release / apple (push) Canceled after 0s
decky / build-publish (push) Successful in 41s
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 3m28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m11s
android / android (push) Successful in 5m19s
deb / build-publish (push) Successful in 5m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m32s
deb / build-publish-host (push) Successful in 5m51s
apple / swift (push) Canceled after 9s
apple / screenshots (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m57s
ci / rust (push) Canceled after 12s
flatpak / build-publish (push) Successful in 6m23s
ci / rust-arm64 (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
Reviewed-on: #47
2026-08-04 20:02:22 +00:00
enricobuehler 1db7058a5d feat(clients/input): system buttons route around local overlays
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m16s
ci / rust-arm64 (pull_request) Successful in 3m20s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 59s
ci / web (pull_request) Successful in 1m36s
ci / docs-site (pull_request) Successful in 2m7s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
ci / rust (pull_request) Successful in 10m36s
Pressing guide/Steam/QAM collided with the client device's own shell: iOS 26
opens its Game Overlay for the Home press (no app opt-out until iOS 27 makes
it a user setting), and a Gaming-Mode client opened BOTH Steam overlays for
one press — the local one covering the stream.

Two cross-client tier-P settings, zero wire changes:

- system_buttons (auto|forward|local): raw guide+misc1 passthrough. Auto
  forwards everywhere EXCEPT under gamescope, where SteamOS reacts to the
  same physical press no matter what.
- guide_gesture (auto|on|off): hold Select ALONE ~350ms sends the HOST's
  guide, down until release — held on, that's the host's long-press, which
  opens a Gaming-Mode host's QAM for regular pads. A Select tap is delivered
  on release with its up TAP_PRESS (50ms) behind, because per-transition
  sends fold into seq'd GamepadState snapshots and a back-to-back pair can
  coalesce into no press at all. A Select inside a combo (the escape chord)
  passes through untouched. Auto arms it only where the raw press can't
  reach the host cleanly: gamescope, iOS/iPadOS, tvOS.

The same SelectGesture rules live in pf-client-core (pure state machine +
unit tests), the Apple client (mask-diff adaptation in GamepadCapture), and
Android's GamepadRouter. Settings rows on every surface (GTK, WinUI,
console UI, Decky, Apple x2, Android x2) with profile plumbing throughout.

punktfunk-session grows a control socket
($XDG_RUNTIME_DIR[/app/$FLATPAK_ID]/punktfunk-session-ctl.sock — the one
runtime path a flatpak and the host see identically): 'guide'/'qam' verbs
inject synthetic taps. The Decky panel gains a Host menus section (visible
while the client runs) whose buttons press the host's Steam/QAM and close
the local menu so the host's shows through.

iOS 27's GCControllerHomeButtonSettingsManager deep-link is a TODO (the
class needs the Xcode 27 SDK to compile). Docs: input, client-settings,
steam-deck. Design: punktfunk-planning design/system-buttons-routing.md.

Gates: docker clippy --all-targets --locked -D warnings + tests
(pf-client-core 88 incl. 6 new gesture tests, pf-console-ui 47),
cargo fmt --all --check, swift build (macOS), gradle kit+app compile,
decky tsc --noEmit + py_compile. clients/windows not compiled (no box).
2026-08-04 21:46:27 +02:00
enricobuehler 83a12c7413 Merge pull request 'Decky, slimmed: a thin Gaming-Mode wrapper around the client' (#46) from worktree-decky-slim-rework into main
ci / web (push) Successful in 1m19s
ci / docs-site (push) Successful in 1m40s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m58s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
deb / build-publish-client-arm64 (push) Successful in 2m25s
android / android (push) Successful in 7m27s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m56s
decky / build-publish (push) Successful in 19s
apple / screenshots (push) Successful in 6m31s
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-arm64cross (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / deploy-docs (push) Successful in 34s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
flatpak / build-publish (push) Canceled after 10m10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
ci / rust (push) Successful in 7m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 10m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9m55s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
arch / build-publish (push) Successful in 8m21s
deb / build-publish-host (push) Successful in 4m33s
apple / swift (push) Successful in 1m18s
deb / build-publish (push) Successful in 5m28s
ci / rust-arm64 (push) Successful in 2m28s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m7s
Reviewed-on: #46
2026-08-04 19:41:32 +00:00
enricobuehler 7b1554af4b fix(apple/shots): fill the grid, open Settings on Display, note the iPad orientation limit
ci / rust-arm64 (pull_request) Successful in 1m41s
ci / web (pull_request) Successful in 1m42s
ci / docs-site (pull_request) Successful in 2m34s
ci / rust (pull_request) Successful in 7m6s
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
- Six mock hosts rather than three. An iPad-13 portrait grid is three columns
  wide and 2752 px tall; three cards left ~60% of the capture as black.
- Settings opens on Display, not General. Resolution, frame rate, bitrate,
  HDR and codec are what someone reads a streaming app's settings shot for.
- The wake scene is the modal-over-grid variant. The gamepad-UI one is a
  full-screen takeover over a bare gradient — correct, but four lines of text
  on an empty aurora; the modal shows the same overlay over the host grid.
- `requestGeometryUpdate` now reports a refusal instead of failing silently.
  It does not help on the simulator (an app's stdout doesn't reach the driver
  through `simctl launch`) but it will on macOS and on a device.
- Documented that `.landscape` does not rotate on iPad: a multitasking-capable
  iPad app is resizable, so iPadOS ignores the request and simctl cannot
  rotate a simulated device. The iPad set is portrait throughout.
2026-08-04 21:34:18 +02:00
enricobuehler 8f35155c14 fix(apple/shots): the store screenshots show the app as it actually is
Uploading the 0.24.0 set surfaced a pair screen that reads as broken, and a
hero that was never the orientation it claimed.

The capture harness:

- Landscape scenes were captured in PORTRAIT. `IOSOrientationConfigurator`
  asked for the geometry update from `updateUIViewController`, where
  `view.window` is still nil — SwiftUI makes one update pass for a
  `.background` representable, before the hierarchy is in a window, so the
  guard fell through and nothing ever asked again. Both `.landscape` scenes
  (the stream hero, the trust card) shipped as portrait. Now a real
  UIViewController asks from `viewDidAppear` and pins
  `supportedInterfaceOrientations`.

- The shot host applied `.ignoresSafeArea()` to the whole scene, so the
  hero's HUD — resolution, bitrate, the latency breakdown, the entire point
  of that screenshot — sat under the Dynamic Island. Only the black backing
  ignores it now; scenes that want full bleed already ignore it themselves.

- `03-pair` was hand-composed into a ZStack rather than presented. PairSheet
  is a bottom sheet on iOS: its detents and the system's Liquid Glass only
  exist inside a real `.sheet`. Composed, the grouped Form stretched to full
  screen height and the capture was a strip of content over a black void,
  with a DISABLED "Pair & Connect" (empty PIN) and the capture simulator's
  own name — `pf-shot-iphone-6.9` — rendered in as the device name.

- Sheets do not inherit `.environment(\.colorScheme, .dark)` across the
  presentation boundary; they follow the DEVICE. The pairing sheet came out
  light grey over the dark app. The simulator is now set to dark appearance.

- Discovery browsed the live LAN mid-capture, so a bystanding machine's
  hostname went out on the listing and no two runs matched. `HostDiscovery`
  gains a `debugSet` seam (the counterpart to `HostWaker.debugSet`); the
  mock hosts advertise, so cards read ONLINE through the real `advertises`
  path and the reachability probe never touches the network.

- Created simulators were named `pf-shot-<prefix>`, which the reuse regex
  never matches: every run created another simulator and none was reused.
  They are named after the device now — reusable, and not user-visible junk.

Two bugs found on the way, neither screenshot-only:

- HostStore/ProfileStore PERSISTED the harness's mock data. On a dev Mac
  that is the same App-Group suite the real app reads, so running the
  script could replace the tester's saved hosts with "Battlestation" & co.
- GamepadHomeView drew the controller chip as a trailing `.overlay`, which
  reserves no width — on a portrait phone it sat on top of the centred
  "Select a Host". Laid out as a row with a hidden leading mirror.
- The pairing sheet's field prompt said "How the host lists this Mac" on
  iPhone and iPad.

Coverage: the listing set is six scenes in listing order, and is now the
stream, the machines it found, the couch/controller mode, waking a sleeping
host, the quality controls and pairing — the console and wake screens
already existed in `ShotScenes.all` and were simply never captured. Mock
hosts carry OS marks, Wake-on-LAN MACs and profile chips so the grid is
full rather than three offline rows over an empty half-screen. `SCENES=`
overrides the set for the dev scenes.
2026-08-04 21:30:18 +02:00
enricobuehler 0d407a866d fix: a host that changed DHCP lease could no longer be streamed from the panel
ci / rust (pull_request) Successful in 7m15s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m58s
ci / web (pull_request) Successful in 1m7s
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m54s
ci / rust-arm64 (pull_request) Successful in 3m5s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m16s
android / android (pull_request) Successful in 5m2s
An adversarial review of this branch found a regression I introduced, plus three smaller
defects. All four are fixed here, each verified on .21.

**The regression.** `mergeHosts` names a host by its record's stable id, and `hosts list --json`
always emits one (`KnownHosts::load` mints ids for every record). So a launch always went out as
`punktfunk launch <uuid>` → `ConnectPlan::for_host` → `HostTarget::from(&KnownHost)`, which
copies the address stored ON THE RECORD. Meanwhile the panel deliberately renders the LIVE
advert's address. Nothing on a Deck ever writes a moved address back — `discover` and
`hosts list` are both reads, and only the desktop shells' hosts pages update one.

So after any DHCP move the row read "online" at the new address and every press dialled the old
one: a 15 s dead connect, or — if a MAC had ever been learned — a black Steam "game" for the
full 90 s wake budget. Proven with a stub session binary: `launch abc-123` emitted
`--connect 10.0.0.5:9777` for a host answering at `10.0.0.99`.

This worked on origin/main, which dialled `toHost(v).host` — the advert's address. The fix
restores that without giving up stable ids: `hosts add <new-addr> --fp <known-fp>` now MOVES the
matching record instead of filing a second one (the fingerprint is the identity — this is the
same rule that makes the verb idempotent), and the panel re-points a host it can see has moved
before launching it. Verified: `moved 10.0.0.5:9777 to 10.0.0.99:9777`, one record still, and
`launch abc-123` then emits `--connect 10.0.0.99:9777`.

**"No hosts yet" was also how a missing client looked.** `_cli_argv()` returning None becomes
`client-unavailable`, which the panel dropped on the floor — so a Deck with no client installed
was told its network was empty, under a button that launches the client that isn't there. It now
says which of the two it is.

**The browse worker never exited on a quiet LAN.** `discover_for` drops the receiver and the
doc claimed that stops the thread. It does not: the worker parks in `recv()`, and the arms that
ignore an event (`SearchStarted`, `ServiceFound`, `SearchStopped`, a v6-only advert) never touch
the sender, so on a LAN with no Punktfunk host nothing ever wakes it. Harmless today because the
only caller is a short-lived CLI process, but the function invites in-process use, where it would
leak a thread and an mDNS daemon per call. Now polled with a 250 ms tick and a check at the top
of the loop. Verified: ten back-to-back browses settle back to the baseline thread count.

**A `pair=optional` host was recorded as paired.** Every unsaved host now goes through the trust
sheet (it has no pin, so it cannot stream without one), but the sheet's only non-PIN action ran
`--request-access`, which persists `paired: true` on Ready. An optional host admits anyone who
pins its identity — there is no operator decision, so nothing was approved and the same box read
"paired" here and "trusted" in the desktop client. Such a host now gets **Connect** instead,
which pins and streams without claiming an approval, and the "approve this Deck" toast is no
longer shown to someone who has nobody to ask.

Also: `PF_CLIENT_BIN` was the one launch-option value never validated — a client installed under
a path with a space would split Steam's tokenizer.
2026-08-04 21:26:09 +02:00
enricobuehler bf2d8505cf docs(decky): the gamepad-UI shortcut comment still named PF_HOST
PF_HOST is gone; the browse branch is keyed on PF_BROWSE alone and runs the SESSION binary,
which is the one path this rework deliberately did not repoint. Comment only.
2026-08-04 21:08:52 +02:00
enricobuehler 414380fc9e fix(cli): discover reads the host store without writing to it
`KnownHosts::load()` mints a stable id for any record that lacks one and SAVES it — which makes
it a write, and `discover` was calling it purely to annotate what the browse found with
saved/paired. It never hands those ids back to anyone.

That matters because the Decky panel issues `discover` and `hosts list` together, in parallel.
Against a store written before ids existed, both processes read it, both mint DIFFERENT ids for
the same record, and both save. Whichever loses the race has already handed its ids to its
caller — so the panel could draw a row whose host reference no longer resolves, and pressing it
would exit 5 ("no saved host matches") until the next refresh settled things.

`KnownHosts::read()` is `load` without the mint: the store exactly as it is on disk. `discover`
uses it; every caller that dials a host by id still uses `load`, so ids are still minted the
first time anything needs one.

Verified on a fixture store with no ids: `punktfunk discover` leaves it byte-identical, and a
following `punktfunk hosts list` mints as before.
2026-08-04 21:07:37 +02:00
enricobuehler 6267dcdcd3 fix(decky): let a CLI payload's own key never override this layer's ok
`{"ok": True, **data}` let a future payload carrying its own `ok` report failure through the
field the shell layer owns. Spread first, set `ok` last.
2026-08-04 21:04:16 +02:00
enricobuehler 8042a2fd52 fix(decky): a saved host's pin is what it PINNED, never what it's advertising
`mergeHosts` filled a row's fingerprint as `s.fp_hex || advert?.fp || ""`, so a host saved by
address — nothing pinned on disk — borrowed the fingerprint of whatever was advertising at that
address and rendered as ready to stream. The launch then refused for want of a pin, from a row
that had just shown "Stream" and "trusted".

Under the old rule the mistake was mostly hidden, because `needsPair` asked a different
question for saved and unsaved rows. This rework makes a pinned fingerprint the ONLY rule, so
the same conflation would now decide the whole thing.

The two are different facts and are now separate fields. `fp` is what the RECORD pins — the
thing the session binary requires. `advertisedFp` is what the host is offering right now, which
is what request access would pin, and moving one to the other is a trust decision the user
makes in the sheet rather than something the merge does behind them.

The trust sheet gates on and pins `advertisedFp` accordingly: a saved placeholder that happens
to be advertising can now be let in with request access, and one that isn't still gets the PIN
path with the reason.
2026-08-04 21:01:59 +02:00
enricobuehler 7e40098bc6 test(decky): tear the CLI fixture dir down before building it
The "a native install with no sibling CLI resolves to None" check created
/tmp/pf-test-native/bin/punktfunk and never removed it, so the assertion that the sibling is
ABSENT held only on the first run on a given machine and failed on every rerun. Caught by
running the suite twice.
2026-08-04 21:00:15 +02:00
enricobuehler ac5299d4ce docs: the Deck plugin is a launcher now, not a second client
The plugin's settings tab, fullscreen page, host editor and games picker are gone, and the
docs described all four in detail. Sweeps clients/decky/README.md and the docs site.

steam-deck.md gains a **Request access** section — the no-PIN path where the host's operator
approves the Deck, which is the one genuinely new thing a user gets — and says plainly where
the settings went: **Open Punktfunk → Settings**, the same rows over the same store, one tap
from the same panel. A removal that reads as a regression is worth a sentence, not a silence.
The troubleshooting table drops the rows for surfaces that no longer exist and gains the two
questions the new path will actually raise ("request access isn't offered", "the stream just
sits there").

client-settings.md claimed ~18 settings were "offered by … and Decky". None are; the console
home offers them. Its intro now names the console home's real sections (Stream, Video,
Presentation, Audio, Controller, Touchscreen, Interface, Profiles) instead of describing the
deleted sidebar.

Three claims in that file turned out to be wrong ALREADY, independent of this rework, and are
fixed here because verifying against crates/pf-console-ui/src/screens/settings.rs is what
found them:

  • "Render scale — offered everywhere except the console home's list". RowId::RenderScale has
    been in the console's ROWS since 2026-07-31.
  • wake-on-lan.md: "Punktfunk Console has no auto-wake setting of its own". It does —
    RowId::AutoWake, "Wake hosts automatically". Its Wake & Connect BUTTON is independent of
    the setting, which is the true half that sentence was built on.
  • The console home's Library button was documented as gated on the "Show game library"
    toggle. It isn't — `library_enabled` appears nowhere in pf-console-ui outside the toggle
    row itself; home.rs offers Library on any paired, saved host.

Also updated: support-matrix (Decky's Profiles and Game library go /⚠️ — the panel shows
pinned profile cards but creates none, and the library lives in the console home),
wake-on-lan (the plugin no longer fires its own packet or stretches the connect budget — the
CLI runs the real wake-and-wait), pairing, game-library, profiles-and-links, input, clipboard
and install-client.
2026-08-04 20:58:11 +02:00
enricobuehler 2d43275fcb fix(core/abi)!: stop exporting 149 unprefixed macros into every embedder's namespace
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 1m15s
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m10s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m57s
ci / rust (pull_request) Successful in 6m25s
ci / rust-arm64 (pull_request) Failing after 15s
ci / web (pull_request) Successful in 1m6s
BREAKING (C header only): constants such as MAX_PADS, TAG_LEN, ABI_VERSION,
INPUT_MAGIC and the whole BTN_/AXIS_ family are now PUNKTFUNK_-prefixed.

cbindgen emits a bare #define per `pub const`, so those names landed in the
namespace of every C program that includes the header. The rename table already
said this was the rule and already carried the handful someone had noticed —
and its own comment spells out why it matters: a clashing #define silently
takes the last definition rather than failing to compile, so the failure mode
is a wrong value, not a build error. This is the remaining 149.

Associated constants are deliberately left alone. cbindgen already qualifies
those with their type name, which is the very property whose absence makes a
bare MAX_PADS dangerous — they are namespaced, just not by us.

Nothing in this repository consumed the unprefixed spellings except one Swift
test, which sat next to lines already using the prefixed form because its
constant happened never to have been added to the table; it is updated here.
The C harness links and runs against the regenerated header.

Scheduled deliberately: the sweep flagged this for a release boundary, and
0.24.0 has shipped. External C embedders using the old spellings must add the
prefix; there is no silent breakage, since the old names simply stop existing.
2026-08-04 20:52:58 +02:00
enricobuehler 77ddd05b13 fix(core/wire): a truncated trigger datagram stops cancelling the effect it should carry
Three wire and ABI faults.

An out-of-range pad index reached one rumble consumer and not the other. It
skipped the reorder gate — the per-pad seq cursor has no slot for it — and was
handed to the legacy queue, while the policy engine discarded it on its own
bounds check, so the comment promising both consumers are fed was false for
exactly these. An embedder draining the queue could be handed an index it would
use to subscript its own per-pad array. The host never emits one, so it is
malformed or hostile either way; both consumers now agree by dropping it before
either sees it.

The adaptive-trigger effect was the only variable-length wire field bounded on
neither side. Encode appended whatever it was handed and decode took the whole
tail, while its sibling raw-report field had been bounded both ways all along;
there is now one constant both sides clamp to. Worse than the missing bound was
the empty case: a body with no effect bytes decoded as an EMPTY effect, and
downstream an empty block is written as an all-zero trigger report, which is
mode 0x00 — release. A truncated datagram could therefore silently cancel the
trigger effect a game was holding. That shape is now rejected outright; a
genuine release is a full-length zero block and still decodes.

The C ABI history had a hole and a symbol nobody versioned. v11 shipped without
its line, and the rumble policy engine's C surface was added while the version
constant still read 7, with no bump at all — so every core since has exported
those symbols while advertising a number that never promised them. A shipped
binary says what it says, so that cannot be corrected backwards; v15 instead
establishes the floor that guarantees the surface, and the v11 line is written
down. No code changed for the bump and nothing moved on the wire.
2026-08-04 20:52:44 +02:00
enricobuehler 017c37b78a feat(decky): rebuild the panel as a launcher — nested cards and request access
What is left of the plugin is what only a Decky plugin can do: start a stream through Steam so
gamescope focuses it, and stand in front of the trust decision that gates it. One Quick Access
panel, four sections, no route.

HOSTS. One `useHosts()` calls discover and hosts-list together and merges them by fingerprint
first, address second — so a host that moved DHCP lease still matches its record, and a
different box that inherited the old address does not inherit its pairing. The CLI annotates
`saved`/`paired` by that same rule, so the two surfaces cannot disagree. Rows sort online
first, then most recently used, then by name: the host you streamed last night is the first
thing under your thumb, and a host that is off right now never is.

`needsPair` is now ONE rule: no pinned fingerprint. The session binary refuses a pinless
connect, so a row without one can offer nothing but a button that fails. The old rule also
consulted the advertised policy for unsaved hosts, which made the same box read differently
before and after being saved.

PINNED CARDS render NESTED under their host as `▸ <Profile name>`, not in a section of their
own — a card IS a (host, profile) pair, and a row floating free of its host is exactly the "a
pinned tile reads as a duplicate host" problem the desktop shells still have. The host's own
BOUND profile is deliberately not drawn as a card: it applies silently on the plain row, and
showing it twice would suggest the two do different things. This plugin creates, edits and
deletes no profile and no card — pin creation belongs where profiles are edited.

TRUST SHEET (new, trust.tsx). Request access (default) / Use a PIN instead… / Cancel, in the
GTK dialog's order and wording. Request access is not a second ceremony — it saves the host
with the fingerprint it ADVERTISED, then launches; the host parks that connect until its
operator approves this Deck, admits it, and the stream starts by itself.

No fingerprint, no request access. A host typed in by address advertises none, so the sheet
offers the PIN path only and says why, rather than showing a button that could only fail. The
sheet never TOFUs past a missing fingerprint: that pin is the only thing standing between a
185 s wait and an impostor answering for the host.

The sheet is a `showModal` portal, so it captures its callbacks once and never re-renders from
panel state — everything it acts on later is read through a ref. Reading a captured value is
precisely what made pinning a second game compute from a stale base and clobber the first.

LAUNCH PATH. The wrapper's contract becomes PF_REF / PF_PROFILE / PF_REQUEST_ACCESS /
PF_BROWSE; PF_HOST, PF_LAUNCH, PF_MGMT and PF_CONNECT_TIMEOUT are gone. A stream is now
`punktfunk launch <ref> [--profile <id>] --exec --fullscreen`, and a reference is all that ever
rides Steam's launch options — no resolution, bitrate or codec, the same rule the deep-link
grammar enforces.

Request-access launches run SUPERVISED, without `--exec`: under --exec the CLI becomes the
session, so no process survives to see the stream come up and record the approval. Safe for
gamescope because focus follows reaper's descendant tree, not a single process, and
flatpak-run/bwrap already sit in that tree on every other path.

Wake-on-LAN comes out entirely. The plugin used to fire a magic packet itself and then stretch
the connect budget to 75 s to cover the host's resume — a workaround for the CLI-less era.
`punktfunk launch` runs the real wake-and-wait loop and only dials once the host answers, which
is strictly better and deletes a backend method, a frontend call and a shell branch.

The console-home branch of the wrapper is untouched on purpose: the shell binary already execs
the session for `--browse`, so there is nothing to repoint and no reason to spend a diff there.

Everything else in steam.ts — two shortcuts sharing one name (and so one Steam Input configset
key), artwork versioning, appId verification, controller config, stopStream — is unchanged.
2026-08-04 20:41:30 +02:00
enricobuehler 2fd303e22f refactor(decky): delete the second client
The Decky plugin was a second client. It had its own mDNS discovery, its own host-store
editor, its own settings UI over the entire client settings store, its own per-game pin store
and picker, and its own fullscreen route with three tabs — about 3,000 lines of TypeScript and
Python mirroring, in two other languages, things the Rust client already does. Every one of
them drifted from the original: the TXT parser fell behind each key the host advert added, the
settings screen modelled a subset of a store that kept growing.

They existed because when this plugin was written there was nothing headless to ask. There has
been since v0.22.0, so this deletes them.

GONE, frontend: page.tsx (the fullscreen route), settings.tsx (a seven-page sidebar over the
whole store), hostmgmt.tsx (add/edit/forget), library.tsx (the games picker), ui.tsx (row
primitives only the page used).

GONE, backend: get/set_settings, list/refresh_devices, library, get/set_pins, list_hosts,
add/edit/forget_host, probe_host, reset_config, wake, the avahi browse and its TXT parser, and
the direct reads of client-known-hosts.json.

WHAT REPLACES THE BACKEND is four shells, each about fifteen lines of build-argv-run-parse:

  discover()    -> punktfunk discover --json
  hosts()       -> punktfunk hosts list --probe --json
  pair()        -> punktfunk pair <addr:port> --pin N --name LABEL
  trust_host()  -> punktfunk hosts add <addr:port> --fp HEX --name LABEL

trust_host is the ONLY write this backend makes to the client's store, and it goes through the
CLI — which writes temp+rename into a user-owned directory, so a root backend driving it
cannot lock the desktop client out of its own files. Nothing here opens client-known-hosts.json
or client-profiles.json any more; `hosts list --json` returns profile bindings and pinned cards
already resolved against the catalog.

_cli_argv mirrors the deleted _session_argv exactly, pointed at `punktfunk`: the flatpak app id
stays LAST, because flatpak treats everything after it as the app's own argv. The
LD_LIBRARY_PATH repair applies unchanged — Decky's PyInstaller leak breaks the flatpak's
libcurl whichever binary inside the sandbox is being started.

A client too old for a verb now announces itself DETERMINISTICALLY: exit 5 plus
`unknown command "<verb>"`, mapped to `client-outdated`, which the panel renders as one
explanatory row plus the update button that fixes it. That replaces guessing from GTK-init
noise, which survives only where the update check still drives `punktfunk-client` directly.

KEPT unchanged in mechanism, because only a Decky plugin can do them: runner_info,
shortcut_art, apply_controller_config, check_update/update_client, kill_stream.

The settings screen is not lost, it moved: console home -> Settings has the same rows over the
same store, is gamepad-navigable, and is one tap from this same panel. Per-game pins have no
shared equivalent yet — decky-pinned.json is deliberately left ON DISK, untouched, so a later
migration can read it.

test-backend.py is rewritten against what is left — argv shape, the exit-code mapping, and the
Steam configset editor, which was untested until now and is the riskiest thing that survived:
it edits a file holding hundreds of other games' bindings, in place.
2026-08-04 20:41:07 +02:00
enricobuehler a9a514dea0 fix(feedback): the pad stops keeping a game's trigger effect after the stream ends
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m0s
ci / docs-site (pull_request) Successful in 1m24s
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m34s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m5s
ci / rust-arm64 (pull_request) Successful in 3m26s
android / android (pull_request) Successful in 4m9s
ci / rust (pull_request) Successful in 7m51s
Two faults in the rich-feedback plane — the lightbar, player LEDs and adaptive
triggers — both of which leave a controller physically wrong with nothing to
put it right.

Nothing reset the pad on teardown. Rumble stops on its own the moment nothing
renews it, but the rich planes are LATCHED in the controller's firmware: they
outlive the stream, the app, and being unplugged. Ending a session while a game
held a weapon's trigger resistance left the physical trigger stiff on the
desktop afterwards, and its lightbar showing whatever the game last set, until
another game happened to set one. The Apple client already reset on teardown;
the desktop and Android halves now do too — triggers to mode 0x00, lightbar
dark, player indicator cleared. Android writes them EP0-direct like its rumble
stop, because the reader thread is stopping and the queue would never drain.

A single lost datagram stranded the pad on the previous value. The plane is
deduped AND rides unreliable datagrams, which is a bad pairing: a change is
forwarded exactly once, so when that datagram is dropped nothing re-derives it
— the game keeps sending the same value and the dedup swallows every copy. The
pad then holds the last weapon's trigger effect, or the last lightbar colour,
for as long as the game keeps that setting, which can be the rest of a level.
The dedup already remembers the current state, so it can repair itself: it now
re-emits what it has latched once a second. Slow on purpose — this is a repair
mechanism, not a transport, and every value is idempotent, so a client that did
receive the original simply re-applies it. A forward re-stamps the clock, so a
plane the game is actively driving never pays for a renewal it does not need.

One-shot pulses are deliberately excluded from that renewal: replaying a
trackpad haptic would be a new pulse, not a repair. Raw passthrough reports are
excluded too — the device's own refresh cadence already re-sends them verbatim.
2026-08-04 20:37:25 +02:00
enricobuehler f84c5b8114 feat(cli): launch --request-access — let the host's operator admit this device
Request access is not a second pairing ceremony, it is a LAUNCH: an ordinary identified
connect with the advertised fingerprint pinned and the handshake budget stretched past
the host's approval window. The host parks the connection until somebody approves the
device in its console or web UI, then admits the same connection and the stream starts
by itself. The desktop shells and the console home have had this for a while
(`SpawnOpts::persist_paired`, `screens/pair.rs`); headless callers had no door to it.

  punktfunk launch <host-ref> --request-access

Two behaviours, both small:

* `connect_timeout_secs = 185`, matching the host's PENDING_APPROVAL_WAIT. Anything
  shorter gives up while the approval prompt is still on the operator's screen.
* `run_plan` records the host as paired on SessionEvent::Ready. That event IS the
  approval arriving, and it records the pin the session actually connected WITH rather
  than re-reading the store — the handshake completed against that identity, which is
  what makes the record true. Every other launch still records nothing: a plain connect
  proves reachability, not a new trust decision.

Refused under `--exec` (exit 5) rather than silently downgraded. Under --exec the CLI
BECOMES the session, so no process survives to observe Ready — a quiet downgrade would
leave hosts reading "trusted" forever with nobody able to explain why.
2026-08-04 20:28:34 +02:00
enricobuehler aec02b9d26 fix(cli): hosts add --fp fills in an empty fingerprint instead of dropping it
`punktfunk hosts add <addr> --fp <hex>` against an address already in the store printed
"is already saved" and exited 0 — having done nothing at all. The --fp was silently
discarded, so a host saved by address stayed pinless and every later connect refused
for want of a fingerprint, with no line anywhere saying why.

Three outcomes now, and the difference between them is a trust decision:

  • no fingerprint on the record, one offered  → fill it in, print `updated <addr>:<port>`
  • the same fingerprint offered again        → no-op, exit 0 (a panel may retry a step
                                                whose state is already correct without
                                                having to invent an error to show)
  • a DIFFERENT fingerprint                    → refuse, exit 3

The refusal is the important one. A changed identity is a decision for a person at a
surface that can show them both — the rule `upsert_trusted` exists to enforce — and
quietly overwriting a pin here would be a back door through the pinning the rest of the
client is built on.

A record still named after its own address takes an offered --name; a label the user
chose is theirs and an advert's name must not overwrite it.
2026-08-04 20:28:11 +02:00
enricobuehler 48bb1769b4 feat(cli): punktfunk discover — browse the LAN, annotated against what you've saved
The CLI could do everything with a host except FIND one, so every headless consumer
grew its own mDNS: the Decky plugin parses ~120 lines of avahi TXT escaping in Python,
which drifts from the host's advert every time a key is added and makes the plugin
depend on Avahi being the resolver.

`discovery::discover_for(timeout)` is the bounded collector beside the streaming
`browse()` the UI uses — same service type, same TXT keys, folded to one row per host.
A refreshed advert wins (it carries the newer address), a removal drops the row, and
dropping the receiver on the way out stops the worker so a one-shot call can't leak a
browse per invocation.

The verb annotates each hit against the saved-hosts store rather than handing back two
lists to join: `saved`/`paired` are answered by fingerprint first and address second —
the same rule every other surface uses. That is what stops a host that moved DHCP lease
from reading as new, and stops a different box that inherited the old address from
reading as paired.

  punktfunk discover [--json] [--timeout SECS]

Default 3 s, capped at 30 — this is called from a Quick Access panel, and a typo'd
`--timeout 3000` would hang that panel with no way to cancel. An empty LAN exits 0: a
caller branching on the code is asking whether the browse ran, and it did.
2026-08-04 20:26:59 +02:00
enricobuehler 173be61213 fix(android/pad-audio): an unplugged pad comes back whole, and an idle one arrives at all
android / android (pull_request) Successful in 4m24s
ci / web (pull_request) Successful in 2m29s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 36s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 44s
ci / rust-arm64 (pull_request) Successful in 3m33s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m45s
ci / rust (pull_request) Successful in 6m52s
Three faults on the default capture path, all of them silent.

Unplug tore nothing down. onLinkClosed() is the real unplug signal — silence
never is, an idle pad simply stops streaming — but it skipped the pad-audio
teardown that stop() performs, so the render thread went on writing to a
descriptor whose device was gone, the renderer's own UsbDeviceConnection leaked,
and because the started flag stayed set and the native tier-A registry stayed
armed for that wire index, the pad came back with neither pad audio nor wire
rumble: the next occupant of the index inherited a suppression nothing would
lift. The teardown is now one shared step and runs on both paths, before the
slot is released, since the renderer is addressed by the index the release
forgets.

The wire slot was claimed on the first parsed report. A captured pad that
reports nothing then gave the host no arrival, so no virtual pad, no pad-audio
capability, no 0xD1 — a renderer sitting at zero frames, which is exactly what a
broken pipeline looks like, and it took a physical replug to clear. A pad that
reports nothing is still a pad, so the slot is claimed when the capture engages;
the first report stays as the fallback for a claim that found no free index.
This also puts the common claim on the main thread, which is the contract
GamepadRouter.openExternal documents and the link thread was quietly breaking.

And the two settings had no UI. The model and its persistence existed but no
toggle did, so pad_speaker could only be set by hand-editing shared_prefs, and
pad_haptics — which decides whether the pad trades wire rumble at all — could
not be turned off by anyone who hit trouble with it. Both are now rows under the
DualSense passthrough toggle, gated on it, since neither does anything to an
uncaptured pad.

The padHaptics doc no longer describes the arbitration as a selection forced by
a firmware-level mutual exclusion. It is decided on evidence — the coils belong
to haptics only while haptics frames arrive — which is what 2032c48f changed it
to and why a rumble-only title keeps rumbling.
2026-08-04 20:13:45 +02:00
enricobuehler 6e001e54b4 fix(host/pads): a centred stick reads centred, and a delayed effect waits its turn
android / android (pull_request) Successful in 9m9s
apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 11m18s
ci / web (pull_request) Successful in 2m37s
ci / docs-site (pull_request) Successful in 2m8s
ci / rust-arm64 (pull_request) Successful in 4m2s
Four encoder faults, plus a note on a fifth that turned out not to be one.

A centred stick did not encode as centre on the Y axes. The mapper inverted the
already-quantised byte, and 0..255 has no exact midpoint: the forward map puts
centre at 0x80, so mirroring the output lands on 0x7F — one below the 0x80 that
DsState::neutral and the pad's own resting report use. Games idle-poll a
centred stick constantly, so a DualSense, Edge or DS4 sat under a permanent
sub-deadzone tilt. Inverting in i16 space instead maps centre to centre by
construction and keeps both extremes exact; the only cost is i16::MIN and
-32767 sharing a code, one LSB at the very end of the travel.

Force-feedback ignored replay.delay. It was decoded on upload and never read:
an effect started the moment it was played and ended replay.length later, so
anything scheduling a delayed effect — DirectInput under Wine does this
routinely — fired early AND finished early by the same amount. The delay now
shifts the whole window, with length measured from the end of the delay rather
than eaten into by it. Writing the test for that surfaced a second bug in the
same path: a waiting effect was still a candidate for the abandoned-effect
force-off, and since the play command is itself the last FF activity, an
infinite effect with a delay longer than the idle window would be killed on its
first contributing tick after sitting silent the whole time it waited. Being
abandoned now requires the effect to have been audible for the window too.

An empty serial panicked the service thread. The reply builder clamped the
length to at least 1 and then sliced that many bytes out of the string, which
asks a zero-byte slice for one byte. The kernel already has a graceful answer
for a length it rejects, so report the true one and let it fall back.

Deck triggers could not reach full pull. Scaling by 128 tops out at 32640 of a
declared 32767, leaving the last 127 counts unreachable, so no game could ever
see the axis bottom out. One multiply gets both ends exact.

The idle watchdog is left alone. It does cut finite multi-second effects that
the uinput path exempts, but only the uinput path is handed an explicit
duration; the protocols behind the watchdog are level-triggered with no
duration field anywhere in a report, so there is nothing at that layer to
exempt. The choice is between cutting a long effect and letting an abandoned
one drone forever, and only the latter has field evidence behind it. Recorded
at the constant so the next reader sees the cost rather than rediscovering it.
2026-08-04 20:08:56 +02:00
enricobuehler 31b5f90b12 fix(host/windows): two virtual pads stop tearing each other's reports
apple / swift (pull_request) Successful in 1m16s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m53s
android / android (pull_request) Successful in 2m52s
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m20s
ci / rust (pull_request) Successful in 25m42s
windows-drivers / probe-and-proto (pull_request) Successful in 29s
windows-drivers / driver-build (pull_request) Successful in 1m37s
Three faults on the Windows pad path, two of them races that only bite when a
game drives a pad hard enough for two callbacks to overlap.

pf-gamepad's output ring could hand the host a torn report. Publishing is a
read-modify-write — read the cursor, write the slot it names, advance it — and
the framework dispatches output callbacks in parallel, so two could be inside
it at once: both read the same head, both wrote the SAME slot, and both stored
head+1, so the cursor moved once for two reports and the host read a single
entry with two reports mixed into it. An atomic fetch_add does not fix this. It
hands each writer its own slot but advances the cursor before the bytes exist,
so the host is then invited to read a slot still being filled. Serializing the
publish is what makes the cursor bump mean "the slot below is complete". The
ring exists to stop a rumble STOP being coalesced away, and a torn slot can eat
that STOP with no idle watchdog behind it.

Both drivers also promised the host an ordering they never established. The
host loads out_seq and rumble_seq with Acquire and says so in its own comments
— "Acquire pairs with the driver's publish-then-bump store order" — but the
drivers bumped both with plain writes, and an Acquire load pairs with a Release
store and nothing else. On a weakly-ordered core the host could see a fresh seq
against stale bytes. pf-xusb's rumble seq was racy in the same way as the ring:
two SET_STATE calls could both read one value and both write back value+1, so
the host saw one bump for two writes and skipped a level. A skipped stop is the
one that hurts — the pad buzzes until the ~2.5 s idle force-off notices the
game went quiet, which is what bounds the damage.

Diagnosing an unattached driver stalled the session. The pad service thread —
the one feeding input and rumble — waited up to two seconds for a pnputil
enumeration, per unattached pad, at exactly the moment a session was already
going wrong. The diagnosis now runs on its own thread. Off the hot path the
wait no longer has to be a compromise, so it is generous enough to report what
it actually found instead of giving up with "still enumerating" — which, given
pnputil routinely takes longer than the old budget, is what it usually did.
2026-08-04 19:20:01 +02:00
enricobuehler 8abdd74a62 fix(client/desktop): the Deck keeps its trackpad, and a pad stops buzzing at exit
ci / rust-arm64 (pull_request) Successful in 1m55s
ci / web (pull_request) Successful in 1m14s
ci / docs-site (pull_request) Successful in 1m15s
android / android (pull_request) Successful in 8m52s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m2s
ci / rust (pull_request) Successful in 12m50s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m7s
apple / swift (pull_request) Successful in 1m23s
apple / screenshots (pull_request) Skipped
Three faults in the desktop session's gamepad path.

The Steam Deck lost its built-in trackpad-mouse at the start of every session.
SDL's Valve HIDAPI driver clears the pad's digital mappings during
*enumeration*, which is part of bringing the gamepad subsystem up — so holding
the drivers off from inside GamepadService::pumped could never work: receiving
a GamepadSubsystem means the enumeration has already happened. The hint set
there detached a driver that had already done the damage, and lizard mode only
came back seconds later when the firmware watchdog restored it. The presenter
now disables them with its other pre-SDL_Init hints. The threaded worker always
had this right; only the caller-pumped path was wrong, and it could not fix
itself, hence a separate entry point its callers can place correctly.

Player LEDs did nothing at all on any pad that is not a DualSense. The match
arm handled the DualSense raw-effects path and let everything else fall through
a bare `_`, though SDL exposes set_player_index and owns the per-device
pattern. The wire carries a positional bitmask rather than an index, and the
bridge is the popcount: every convention that reaches this wire spells "player
N" as N lit LEDs — the DualSense patterns 0x04/0x0A/0x15/0x1B/0x1F and the
Switch/XInput run 0x01/0x03/0x07/0x0F alike — so counting them works for both,
where reading a bit position would only ever suit one. No lit LED means no
player, not player 0. The remaining unhandled variants are now named rather
than swept up by `_`, so a new one cannot join them silently.

A forwarded pad could be left buzzing when the session ended. detach() only
posts Ctl::Detach; the close that flushes the pad, tells the host to remove it
and explicitly zeroes the motors runs when the pump next drains that message.
Single mode broke out of the loop immediately after detaching and Event::Quit
never detached at all, so both skipped it entirely. The teardown now sits where
every exit converges instead of on the individual breaks. That still leaves the
several paths that leave by `?` on a fatal overlay or present error, so the
pump also silences its slots on Drop — the explicit call stays, because a pad
should go quiet before a long teardown rather than after it. Drop closes the
slots directly rather than draining the queue that would have done it: same
physical outcome, and it touches no lock, where draining reaches an unwrap on a
Mutex that would abort the process if it panicked mid-unwind.
2026-08-04 19:10:45 +02:00
enricobuehler 66a28d5abb ci(android): run the kit's unit tests
ci / web (pull_request) Successful in 1m3s
ci / rust-arm64 (pull_request) Successful in 2m24s
ci / docs-site (pull_request) Successful in 1m42s
android / android (pull_request) Successful in 3m17s
ci / rust (pull_request) Failing after 13m58s
They were running nowhere. This workflow only assembled, and the screenshot
workflow runs the :app module's tests, so nothing enforced :kit's — the pure
parsers, migrations and feedback policies could go red without anyone
noticing. A couple of seconds against a module the build already produces.
2026-08-04 18:14:13 +02:00
enricobuehler e2faecfd42 fix(client/android): rumble survives a vibrator fault, and an unplug stops leaking
Four faults in the Android feedback path, all of them silent.

Rumble stopped for the rest of the session if one vibrator call threw. The
poll thread called cancel() unguarded while every call around it was already
wrapped, so an unchecked throw — DeadSystemRuntimeException, or the
RuntimeException a dying service wraps a RemoteException in — unwound the
thread. `running` stayed true, so nothing noticed it was gone and nothing
restarted it. Guarding the two bare cancels is not enough on its own: the
binder calls that bind a vibrator can throw just the same, so the loop itself
now survives a failed render, and the same guard covers the hidout thread.

A rumble stop that was never written was treated as one that landed. The
DualSense capture disarmed its backstop timer *before* the write, on a queue
that discarded failed submits without saying so, so a dropped stop left the
motors running with nothing scheduled to try again — and a USB pad holds its
last level until told zero. Writes now report whether they were accepted, the
backstop is disarmed only once the stop is actually on its way, and the
backstop re-arms rather than giving up if its own write is refused.

A full write queue dropped lightbar colours, player-LED masks and trigger
effects. Its overflow rule was "drop the oldest", which is right for rumble —
re-sent continuously, so a lost frame returns milliseconds later — and wrong
for everything else, which the host sends once on change and never repeats.
Eviction is now driven by an explicit key from the caller rather than by
inspecting the bytes: rumble supersedes the pending rumble in place, and a
one-shot is discarded only if the queue holds nothing but one-shots. The key
cannot be recovered from the report itself, which is why this is not keyed by
report id — every DualSense output report carries the *same* id and differs
only in its valid_flag bytes, so an id-keyed rule would let a rumble
supersede a lightbar, which is this bug again by another route.

An unplug leaked the USB connection and the detach receiver. The link only
signalled the drop; neither capture released anything, so the interfaces
stayed claimed (the pad could not return to Android's own input stack) and a
re-plug overwrote the field holding the receiver, stranding one live for the
rest of the process. The captures now release the transport, stop() is safe
to call from the callback it arrives on — the reader thread must not join
itself — and a close is reported exactly once however many detectors see it.
A reader that could not queue a single request now reports itself down too,
instead of leaving the owner waiting on a capture that never streams.
2026-08-04 18:14:07 +02:00
enricobuehler 76832a5b86 fix(client/apple): two DualSenses stop fighting over one device, and a failed stop stops lying
apple / swift (pull_request) Successful in 1m25s
ci / web (pull_request) Successful in 1m23s
ci / docs-site (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m48s
ci / rust (pull_request) Successful in 7m11s
Five faults in the Apple client's feedback path.

With two DualSenses attached, each pad's renderer opened "the first connected
DualSense" — taken from an unordered Set, so the choice could differ between
two calls in one process. Both renderers could land on the same device, one
pad's rumble coming out of the other while their per-instance write dedupes
fought over it, or they could split by luck. Each renderer now asks for the
device its own controller is, correlating GameController's stable ordering with
IOKit's location ids; the selection rule is a pure function so it can be tested
without an IOHIDDevice, which cannot be constructed. Without a preference the
lowest location id wins — still arbitrary, but stable, which Set.first was not.

A failed HID write was logged and swallowed, so a write that never reached the
device still counted as a successful render. That matters most for a stop,
which has nothing behind it: the renderer stamped its write clock even on
failure, the keepalive only re-writes non-zero levels, the ticker is cancelled
once the target is zero, and on USB there is no firmware timeout. A swallowed
stop therefore left the motors running with nothing scheduled to try again.
The write result now reaches the caller, which drops the handle and falls back
to CoreHaptics rather than claiming success.

A half-failed split-handle setup reported HEALTHY. Only the all-nil case
counted as failure, so one surviving handle passed silently while rendering
something wrong in a direction that depended on which handle died: lose the
right one and render falls to the combined branch, playing max(low, high) on
the LEFT handle; lose the left and the split branch discards the heavy motor
outright. A half-open split now tears the survivor down and takes the combined
path, which at least renders both motors somewhere.

Session end never put the lightbar out. This class is what turned it on, and
every DS write is valid-flag-selective, so a game's last colour stayed lit in
firmware after the stream ended — a DS4 was cleared incidentally because its
player indicator IS the lightbar, a DualSense was not.

And the renderer's stop() ran on the main actor. It is a queue.sync whose body
is a per-motor CHHapticEngine.stop() — an XPC round trip the renderer's own
notes record as able to hang — plus a blocking HID write to a device that has
just departed, and it queues behind any in-flight setup(). It runs on every
unplug and every pin change, and the main thread drives the presenter's
CADisplayLink, so it hitched the picture mid-stream. It is detached now; the
renderer is already off routing by then, so nothing observes it.

Verified: swift build clean, 188 tests pass (185 before), and the three new
device-selection tests fail if the deterministic fallback is reverted.

Note for anyone rebuilding here: the checked-in xcframework was stale (it
predates punktfunk_connection_report_phase) and build-xcframework.sh still dies
on this Mac at its macOS-floor guard. A macos-arm64 slice assembled by hand
from `cargo build --target aarch64-apple-darwin` is enough to typecheck.

From the 2026-08-03 force-feedback sweep (B14, B15, B18, B19, B20).
2026-08-04 08:20:12 +02:00
enricobuehler ec4bf75a6e fix(core/rumble): the Deck's keepalive stops being swallowed by its own renewals
ci / docs-site (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m54s
ci / rust-arm64 (pull_request) Successful in 4m8s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m46s
android / android (pull_request) Successful in 5m31s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 4m11s
ci / rust (pull_request) Successful in 9m39s
Three faults in the shared rumble policy engine, all answered by one change of
shape: the free-running jitter phase becomes `last_emit` — the exact value last
handed to an embedder — and every emit routes through one helper. That single
field answers all three live questions: would re-sending this be a no-op device
write, is this stop redundant, and would the nudge invent a stop.

The Steam Deck declares a 40 ms keepalive with a 1-LSB nudge, because an
SDL-class layer discards a write identical to the last one. But the nudge lived
only in the keepalive branch, so every host renewal re-emitted the raw level,
collided with the last jittered write, was discarded, AND re-anchored the
keepalive timer. The gap between distinct device writes stretched to 80 ms at
the 400 ms default TTL and 100 ms at the hatch floor — two to two and a half
times the cadence the quirk exists to guarantee. Nudging on any repeat closes
it: 40 ms throughout.

Level (1, 0) turned that nudge into (0, 0) — the value the engine reserves for
"stop now" — and handed it out with a non-zero backstop, under a live lease.
It is the only such level: high must already be zero, and low ^ 1 == 0 implies
low == 1. The nudge now steps the LSB up instead, so the phase still alternates
and no stop is ever invented.

A zero for a pad the engine already believes silent is now dropped. Under the
legacy hatch the host re-sends zeros for every latched pad every 500 ms for the
rest of the session, which cost Android an unconditional log line and a binder
cancel() at 2 Hz per pad. The deliberate stop-burst heal is untouched, because
a stop that was LOST leaves the pad buzzing, and that is exactly the guard's
pass condition.

The client also now bounds the lease it will honour. RUMBLE_TTL_CEIL_MS is
sender-side only, so a modified or third-party host could stamp a long TTL and
wedge its pump, leaving Apple — whose renderer deliberately keeps no staleness
policy of its own — and a Deck slot buzzing for all of it.

Every new test was proven to fail with its own fix reverted, including the two
that guard against over-reach: a default-quirks pad must still get the level
verbatim, or an off-by-one amplitude would land in Apple's identical-target
comparison and Android's one-shots.

One suspicion from the audit did NOT survive: a v2 envelope carrying ttl_ms 0
cannot take the legacy backstop, because the expiry check preempts the relay
branch. No fix; pinned with a test so that ordering stays load-bearing.

Verified: 17/17 rumble tests, clippy --all-targets --features quic -D warnings
= 0, fmt clean, generated C header unchanged. (`c_abi_harness_round_trips`
fails on this Mac with a linker error, identically on an unmodified tree.)

From the 2026-08-03 force-feedback sweep (B12, B22, R9, T1).
2026-08-04 07:40:19 +02:00
enricobuehler 2032c48ffa fix(android/pad-audio): a game that only rumbles keeps rumbling
ci / web (pull_request) Successful in 1m19s
ci / docs-site (pull_request) Successful in 2m49s
ci / rust-arm64 (pull_request) Successful in 3m9s
android / android (pull_request) Failing after 4m23s
ci / rust (pull_request) Successful in 6m54s
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 2m22s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 55s
Three faults that between them silence a wired DualSense.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

usbfs-iso/uac-host enter as git dependencies pinned by revision — a transport
under a real-time deadline should move when we choose. They become version
dependencies once published to crates.io.
2026-08-02 23:39:25 +02:00
enricobuehlerandClaude Fable 5 ed3d236ab8 feat(pad-audio): DualSense audio haptics + speaker, host->client end to end
The 0xD1 pad-audio plane streams a DualSense's voice-coil haptics (back
channel pair, 5 ms Opus frames) and speaker (front pair, 10 ms) per pad from
a Windows host to the SDL clients, which render them into a USB DualSense's
own 4-channel audio device.

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:07:06 +02:00
640 changed files with 154243 additions and 23208 deletions
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Assert that a builder image's :latest is the SAME manifest as its content key, and
# re-point it when it isn't.
#
# This is what we do instead of pinning consumers by @sha256: digest
# (security-review-2026-08-05, H-6 — see the reasoning at the top of docker.yml). The
# content key is a hash of the ci/ tree, so "which image should :latest be?" has an
# answer derivable from the commit alone. Checking it on every run turns :latest from a
# tag someone remembered to move into a function of the tree.
#
# Two different things make them diverge and neither is distinguishable from here:
#
# - Someone overwrote :latest out of band. Post-fix that needs the push credential,
# but it is exactly the H-6 attack and it must not pass silently.
# - ci/ was reverted. The older key is already a cache hit, so nothing rebuilds and
# nothing re-points :latest — it stays on the newer build forever while every
# consumer pulls a builder that does not match the tree it is building. That bug
# predates this script.
#
# Both are repaired identically, so: repair, and shout. Failing the build instead would
# turn a legitimate revert into a red main with no way forward.
#
# Reads go to the anonymous port, the single write to the authenticated one.
set -euo pipefail
IMAGE="${1:?usage: reconcile-latest.sh <image> <content-key>}"
KEY="${2:?usage: reconcile-latest.sh <image> <content-key>}"
: "${CI_REGISTRY:?CI_REGISTRY not set}"
: "${CI_REGISTRY_PUSH:?CI_REGISTRY_PUSH not set}"
: "${CI_REGISTRY_PASSWORD:?CI_REGISTRY_PASSWORD not set}"
ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
# Digest of a tag, or empty if the tag does not exist. Never fails the script itself —
# "missing" is a state this has to reason about, not an error to abort on.
digest_of() {
curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$1" 2>/dev/null \
| tr -d '\r' | sed -n 's/^[Dd]ocker-[Cc]ontent-[Dd]igest: //p' || true
}
key_digest=$(digest_of "$KEY")
latest_digest=$(digest_of latest)
if [ -z "$key_digest" ]; then
echo "::error::$IMAGE:$KEY has no manifest — the build or push above did not land"
exit 1
fi
if [ "$key_digest" = "$latest_digest" ]; then
echo "$IMAGE:latest == :$KEY ($key_digest)"
exit 0
fi
echo "::warning::$IMAGE:latest did not match its content key :$KEY — re-pointing it. If ci/ was not just reverted, someone overwrote this tag out of band: check the registry access log on home-ci-core."
echo " was: ${latest_digest:-<no :latest tag>}"
echo " wanted: $key_digest (:$KEY)"
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
media_type=$(curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY" \
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
curl -sf -H "$ACCEPT" -o "$tmp" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $media_type" \
--data-binary @"$tmp" "http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/latest"
now=$(digest_of latest)
[ "$now" = "$key_digest" ] || { echo "::error::re-point failed: :latest is $now"; exit 1; }
echo "$IMAGE:latest re-pointed to $key_digest"
+8
View File
@@ -160,6 +160,14 @@ jobs:
key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-
# The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were
# running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app
# module's tests, so nothing enforced :kit's. Cheap (a couple of seconds against an already
# built module) and it is the only automated cover those behaviours have.
- name: kit unit tests
working-directory: clients/android
run: ./gradlew :kit:testDebugUnitTest --stacktrace
- name: assembleDebug (cargo-ndk → jniLibs → APK)
working-directory: clients/android
env:
+19 -2
View File
@@ -41,9 +41,23 @@ jobs:
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
# Through the ENVIRONMENT, never interpolated into the script body. A `${{ }}` expansion
# is a raw textual substitution performed BEFORE the shell sees the line, so a
# workflow_dispatch input containing shell syntax executes as this step — and this is the
# step holding UPDATE_MANIFEST_KEY, the Ed25519 key every host pins to decide whether an
# update is real (2026-08-05 review H-6). As `$INPUT_TAG` it is only ever data.
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
TAG="${{ inputs.tag }}"
TAG="$INPUT_TAG"
# Shape-check before the value reaches a URL or a filename: tags are `vX.Y.Z[-suffix]`.
case "$TAG" in
v[0-9]*) ;;
*) echo "refusing to publish for a tag that is not vX.Y.Z: $TAG" >&2; exit 1 ;;
esac
case "$TAG" in
*[!A-Za-z0-9.+_-]*) echo "tag has characters no release tag has: $TAG" >&2; exit 1 ;;
esac
case "$TAG" in
*-*) echo "pre-release tag $TAG — not publishing to the stable update feed"; exit 0 ;;
esac
@@ -67,4 +81,7 @@ jobs:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
ALLOW_PRERELEASE: ${{ inputs.allow_prerelease }}
run: bash scripts/ci/discord-announce.sh "${{ inputs.tag }}"
# Same reasoning as the publish step above: the input is data in the environment, never
# text spliced into the command line.
INPUT_TAG: ${{ inputs.tag }}
run: bash scripts/ci/discord-announce.sh "$INPUT_TAG"
+6 -1
View File
@@ -29,4 +29,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Tier-3 GPU stream benchmark
run: bash scripts/bench/gpu-stream.sh "${{ inputs.mode || '1920x1080x120' }}" 12
# Through the environment, not interpolated into the command line: a `${{ }}` expansion is
# substituted before the shell parses the line, so an input carrying shell syntax would run
# as this step (2026-08-05 review H-6).
env:
BENCH_MODE: ${{ inputs.mode || '1920x1080x120' }}
run: bash scripts/bench/gpu-stream.sh "$BENCH_MODE" 12
+33
View File
@@ -252,6 +252,11 @@ jobs:
run: bun run build
- name: Typecheck
run: bun run lint
# Scoped to server/: the console's browser code has no test runner, but the gate that keeps a
# plugin's origin apart from the console's does — and its failure mode is a well-formed header
# that only a browser rejects, which nothing else here would catch.
- name: Test
run: bun run test
docs-site:
runs-on: ubuntu-24.04
@@ -275,3 +280,31 @@ jobs:
run: bun run build
- name: Typecheck
run: bun run lint
# web/bun.nix and sdk/bun.nix are GENERATED from their bun.lock (bun2nix) and committed; the Nix
# build fetches node_modules from nothing else. They regenerate only on a local `bun install` that
# runs lifecycle scripts — never under CI's `--ignore-scripts`, and never on a merge or rebase,
# which happily carries a lockfile change past a bun.nix generated before it. That is not
# theoretical: web/bun.nix sat stale on main for 553 commits (2026-07-27 → 2026-08-05) with
# `nix build .#punktfunk-web` broken, and was repaired only by accident when an advisory bump
# happened to rerun a real `bun install`.
#
# Deliberately UNFILTERED and in ci.yml rather than nix.yml: it needs no Nix, takes well under a
# minute, and the whole point is that the drift arrives through commits that look unrelated to
# Nix. The Nix-toolchain gates (flake eval + building the bun packages) live in nix.yml.
bun-nix:
runs-on: ubuntu-24.04
container:
image: oven/bun:1
timeout-minutes: 15
steps:
# oven/bun ships neither git nor a real node, and the slim base has no CA bundle —
# actions/checkout needs all three (see the web job).
- name: Install git + node + CA certs
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
# Regenerates each bun.nix from its committed bun.lock and diffs, and checks that the
# bun2nix version pin agrees across flake.nix and both package.json files (bun.nix has no
# schema stability across bun2nix releases). Fix with: scripts/ci/check-bun-nix.sh --fix
- name: bun.nix drift gate
run: sh scripts/ci/check-bun-nix.sh
+8 -3
View File
@@ -119,10 +119,15 @@ jobs:
run: |
apt-get update
# python3 is used by scripts/ci/gitea-release.sh for the stable-tag release attach.
# libvulkan-dev: /usr/include/vulkan/vulkan.h for the client's pf-ffvk bindgen
# (FFmpeg's hwcontext_vulkan.h includes it).
# No libvulkan-dev: nothing here compiles or links against Vulkan (ash dlopens
# libvulkan and pf-vkdecode binds nothing at build time), so neither the compile nor
# dpkg-shlibdeps — which resolves DT_NEEDED sonames only — ever asks for it. The
# client's `Depends: libvulkan1` is added by hand in packaging/debian/build-client-deb.sh
# precisely because a dlopen is invisible to shlibdeps.
# No libav*-dev: the client links no FFmpeg since M10 (§6 of
# design/client-native-decode.md).
apt-get install -y --no-install-recommends dpkg-dev python3 \
libgtk-4-dev libadwaita-1-dev libsdl3-dev libvulkan-dev
libgtk-4-dev libadwaita-1-dev libsdl3-dev
# Share ci.yml's cache keys so the release build reuses its registry + target artifacts.
- name: Cache keys
+4 -1
View File
@@ -46,7 +46,10 @@ env:
REGISTRY: git.unom.io
OWNER: unom
PACKAGE: punktfunk-decky # generic-registry package name
PLUGIN: punktfunk # plugin.json "name" == zip top-level dir
# The plugin's ON-DISK dir == the zip's top-level dir. Deliberately NOT plugin.json "name"
# (that is the brand-cased label Decky lists, and it locates a plugin by matching it, not by
# the folder) — see clients/decky/scripts/package.sh.
PLUGIN: punktfunk
jobs:
build-publish:
+114 -21
View File
@@ -3,13 +3,18 @@
# Two very different image families now:
#
# BUILDER images (punktfunk-rust-ci{,-noble,-arm64cross}, punktfunk-fedora{,44}-rpm)
# live on the LAN registry (home-ci-core, 192.168.1.58:5010 — unom/infra
# runners/ci-core/) and are CONTENT-KEYED: the tag is a hash of what they are built
# from (the ci/ tree, + rust-toolchain.toml for the cross image), and a build only
# happens when that key has no manifest yet. A push that doesn't touch ci/ costs one
# curl per image (~seconds), pushes nothing over the WAN, and mints no per-SHA tag
# debris on the runners — the failure mode that filled the fleet's disks. `:latest`
# is re-pushed alongside every new key and is what the consuming workflows pin.
# live on the LAN registry (home-ci-core — unom/infra runners/ci-core/) and are
# CONTENT-KEYED: the tag is a hash of what they are built from (the ci/ tree, +
# rust-toolchain.toml for the cross image), and a build only happens when that key
# has no manifest yet. A push that doesn't touch ci/ costs one curl per image
# (~seconds), pushes nothing over the WAN, and mints no per-SHA tag debris on the
# runners — the failure mode that filled the fleet's disks. `:latest` is re-pushed
# alongside every new key and is what the consuming workflows pin.
#
# READS come from :5010 and need no credential. WRITES go to :5011 and need
# CI_REGISTRY_PASSWORD. Same store behind both — a registry keys by repository name,
# not by the host:port the client used — so an image pushed to :5011 is the same
# image every consumer pulls from :5010.
#
# APP images (punktfunk-web, punktfunk-docs) are deployables: they keep going to the
# Gitea registry (git.unom.io) with :latest + :sha-<8> (+ :vX.Y.Z on tags), because
@@ -17,8 +22,38 @@
#
# Host and clients are intentionally NOT containerized (see CLAUDE.md "What's left").
#
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images only —
# the LAN registry is unauthenticated inside the LAN).
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images).
# CI_REGISTRY_PASSWORD: repo Actions secret, the LAN registry's push credential for user
# `ci`. Generated on ci-core into /srv/ci/stack/registry-secret; rotate in both places.
#
# --- security-review-2026-08-05 H-6, FIXED 2026-08-05 -------------------------------
# The registry used to accept anonymous pushes from any LAN peer, and every
# secret-bearing job in this repo runs INSIDE an image pulled from it. Attacker
# position #1 of the project's own threat model did not need to break any signing
# logic: push one tag, and the next android.yml run executes their code in the same job
# that does `echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > release.jks`. Same shape for
# rpm.yml (RPM_GPG_PRIVATE_KEY) and android-promote.yml (SERVICE_ACCOUNT_JSON).
#
# The infra half is done (unom/infra runners/ci-core/): :5010 serves GET/HEAD only and
# refuses everything else with 405, :5011 demands basic auth on every request. The half
# in this file is done below: pushes and release-tag manifest PUTs authenticate.
#
# ⚠ On the second half as the review originally worded it — "pin consumers by @sha256:
# digest". We deliberately do something else, because after authentication the digest
# pin no longer buys what it was meant to buy. The set of people who can overwrite a tag
# is now exactly the set who can push to main and edit a pinned digest in this very
# file: a pin defends against nobody it did not already trust, while costing a
# two-commit dance on every ci/ change (~3x a month) during which consumers silently run
# a builder image that predates the ci/ change they are testing.
#
# What actually closes the residual gap — a tag quietly overwritten out of band — is
# making :latest a CHECKED function of the tree instead of a tag someone remembered to
# move. The "Reconcile :latest" step below asserts on every run that :latest and
# :ck-$KEY are the same digest, repairs it when they are not, and says so loudly. That
# catches an out-of-band overwrite on the next push to main, needs no churn, and fixes
# a real pre-existing bug on the side: reverting ci/ used to leave :latest pointing at
# the newer build forever. Revisit inline digest pins if the push credential ever leaves
# the maintainer trust set.
#
# Bootstrap note: consuming workflows pull <LAN>/punktfunk-rust-ci:latest, so the LAN
# registry must hold a seeded :latest once (done 2026-07-29 from the last Gitea-registry
@@ -42,7 +77,10 @@ on:
env:
REGISTRY: git.unom.io
OWNER: unom
# Read port (anonymous, GET/HEAD only) and write port (basic auth). Two doors onto
# one store; see the header.
CI_REGISTRY: 192.168.1.58:5010
CI_REGISTRY_PUSH: 192.168.1.58:5011
jobs:
builders:
@@ -98,21 +136,45 @@ jobs:
echo "hit=false" >> "$GITHUB_OUTPUT"
fi
# Tagged for the WRITE port: :5010 refuses a push outright, so a tag that names it
# can only fail. Consumers still pull the identical image from :5010.
- name: Build
if: steps.exists.outputs.hit == 'false'
# --pull is cheap now: base images come through the ci-core pull-through mirror.
run: |
docker build --pull ${{ matrix.buildargs }} \
-f "${{ matrix.dockerfile }}" \
-t "$CI_REGISTRY/${{ matrix.image }}:$KEY" \
-t "$CI_REGISTRY/${{ matrix.image }}:latest" \
-t "$CI_REGISTRY_PUSH/${{ matrix.image }}:$KEY" \
-t "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest" \
ci
# Gated like Build/Push: only the docker CLI needs this login (Reconcile and Tag-for-release
# authenticate via curl -u), so a cache-hit job with nothing to push must not be able to fail
# on a login it never uses — proven on run 16013, where a host with a misconfigured daemon
# failed exactly here on a hit=true leg.
- name: Log in to the LAN registry
if: steps.exists.outputs.hit == 'false'
run: |
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Push
if: steps.exists.outputs.hit == 'false'
run: |
docker push "$CI_REGISTRY/${{ matrix.image }}:$KEY"
docker push "$CI_REGISTRY/${{ matrix.image }}:latest"
docker push "$CI_REGISTRY_PUSH/${{ matrix.image }}:$KEY"
docker push "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest"
# :latest must be whatever ci/ says it is, on every run — not only on the runs that
# happened to build. Two things break that: an out-of-band overwrite (the H-6
# attack, now only reachable by someone holding the push credential), and a plain
# revert of ci/, which leaves :latest on the newer build because the older key is
# already a cache hit and nothing re-points it. Both look identical from here and
# both are repaired the same way, so repair and shout rather than fail the build.
- name: Reconcile :latest with the content key
run: .gitea/scripts/reconcile-latest.sh "${{ matrix.image }}" "$KEY"
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
# A release pins reproducible builder images without any rebuild: copy the key's
# manifest to a vX.Y.Z tag via the registry API (no image bytes move).
@@ -124,8 +186,19 @@ jobs:
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
curl -sf -H "$ACCEPT" -o /tmp/manifest.json \
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$KEY"
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME"
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $MT" \
--data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY_PUSH/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME"
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
# Today the job container is ephemeral (the ubuntu-24.04 label is a docker://
# image), so the credential docker login wrote would die with it anyway. Don't
# make that a load-bearing assumption about a runner label somebody may change to
# a host runner later.
- name: Log out of the LAN registry
if: always()
run: docker logout "$CI_REGISTRY_PUSH" || true
# The aarch64 CROSS builder — a SEPARATE job because it is `FROM punktfunk-rust-ci:latest`
# (the LAN copy) and so must not race the matrix entry that publishes that base. Consumed
@@ -164,15 +237,28 @@ jobs:
run: |
docker build --pull \
-f ci/rust-ci-arm64cross.Dockerfile \
-t "$CI_REGISTRY/$IMAGE:$KEY" \
-t "$CI_REGISTRY/$IMAGE:latest" \
-t "$CI_REGISTRY_PUSH/$IMAGE:$KEY" \
-t "$CI_REGISTRY_PUSH/$IMAGE:latest" \
.
# Same gate as the builders job above: the login only serves Push.
- name: Log in to the LAN registry
if: steps.exists.outputs.hit == 'false'
run: |
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Push
if: steps.exists.outputs.hit == 'false'
run: |
docker push "$CI_REGISTRY/$IMAGE:$KEY"
docker push "$CI_REGISTRY/$IMAGE:latest"
docker push "$CI_REGISTRY_PUSH/$IMAGE:$KEY"
docker push "$CI_REGISTRY_PUSH/$IMAGE:latest"
- name: Reconcile :latest with the content key
run: .gitea/scripts/reconcile-latest.sh "$IMAGE" "$KEY"
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Tag for release
if: startsWith(github.ref, 'refs/tags/v')
@@ -182,8 +268,15 @@ jobs:
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
curl -sf -H "$ACCEPT" -o /tmp/manifest.json \
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$GITHUB_REF_NAME"
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $MT" \
--data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/$GITHUB_REF_NAME"
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Log out of the LAN registry
if: always()
run: docker logout "$CI_REGISTRY_PUSH" || true
# Deployable app images — unchanged flow, Gitea registry, per-SHA + release tags.
apps:
+11 -4
View File
@@ -34,7 +34,10 @@ on:
# The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every
# design/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too.
# The bundle ships BOTH client binaries (shell + Vulkan session), so every crate in either
# binary's dependency closure must be listed here.
# binary's dependency closure must be listed here — including the native decode rungs, or a
# commit that only touches the decoder never rebuilds the bundle and the Deck canary quietly
# stops tracking it. pf-dxvadec is absent on purpose: it is `cfg(windows)` in pf-client-core
# and never enters the Linux closure (windows.yml / windows-msix.yml carry it instead).
paths:
- 'clients/linux/**'
- 'clients/session/**'
@@ -42,6 +45,9 @@ on:
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-bitstream/**'
- 'crates/pf-vkdecode/**'
- 'crates/pf-vaadec/**'
- 'packaging/flatpak/**'
- 'Cargo.lock'
- '.gitea/workflows/flatpak.yml'
@@ -128,7 +134,7 @@ jobs:
# authselect trigger fires — so this line alone was never the fix for the failures
# below. See the retry.sh bump for the real cause.
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
# Flathub provides the GNOME runtime/SDK + the rust-stable + ffmpeg-full extensions.
# Flathub provides the GNOME runtime/SDK + the rust-stable and llvm20 extensions.
#
# ROOT CAUSE (confirmed 2026-07-11 by watching a live run on home-runner-1): this is
# NOT a deterministic nsswitch/DNS-config bug. gitea-runner-fleet on home-runner-1 is
@@ -147,7 +153,7 @@ jobs:
git config --global --add safe.directory "$PWD"
# This job was the fleet's single heaviest network consumer: every run re-downloaded
# the GNOME runtime + SDK + llvm/rust/ffmpeg extensions (multi-GB from Flathub) and
# the GNOME runtime + SDK + llvm/rust extensions (multi-GB from Flathub) and
# every crate source. Both live in well-defined directories, both are idempotently
# verified/extended by the steps below, and the central cache server restores them
# at LAN speed — so cache them. Keyed on what actually pins them: the manifest tree
@@ -251,7 +257,8 @@ jobs:
# or TCP dial costs a backoff-retry instead of the whole (long) compile:
# 1) --install-deps-only pulls everything the manifest declares from Flathub: the
# GNOME 50 runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK
# extensions, plus the runtime's auto codecs-extra (HEVC libavcodec).
# extensions. (No codec extension: the client links no FFmpeg — see the
# manifest header.)
# 2) --download-only fetches every source (all crates in cargo-sources.json) into
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
# after a partial failure is safe and cheap.
+167
View File
@@ -0,0 +1,167 @@
# Nix packaging gate. Until this existed, NOTHING in CI ever evaluated flake.nix: the word "nix"
# appeared in exactly one workflow file, and only in a comment about bun2nix breaking a Windows
# step. Every Nix regression therefore reached main invisibly and was found by hand on a Nix box —
# `nix build .#punktfunk-web` was broken for 553 commits before anyone noticed (see the bun-nix job
# in ci.yml for that story).
#
# Two tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
# run for an hour on every push:
#
# * eval — `nix flake check --no-build`: instantiates every package, app, check, devShell and
# the NixOS module without building them. Catches the failures that actually happen to
# this flake — a renamed file, a callPackage argument that no longer exists, a syntax
# error, a package attribute dropped from packages.nix.
# * bun — actually BUILDS punktfunk-web + punktfunk-scripting. These are the two derivations
# whose inputs churn constantly (every dependency bump moves a lockfile) and they cost
# minutes, not hours, because neither compiles Rust. This is the end-to-end proof that
# the generated bun.nix really does materialise a working node_modules offline — it
# covers what the ci.yml drift gate cannot, e.g. a tarball the registry no longer
# serves, or the codegen going quietly message-less (see packages.nix's inlang note).
#
# The Rust packages (punktfunk-host, punktfunk-client) and punktfunk-gamescope are NOT built here.
# They are the expensive ones and their inputs are already gated by the `rust` job in ci.yml; build
# them by hand on a Nix box, or with the `build-rust` dispatch input below.
#
# ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest
# breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it.
# ⚠ The two path lists are duplicated on purpose: a YAML anchor would be tidier, but Gitea's
# workflow parser is not a place to bet on anchor support. Keep them in step by hand.
name: nix
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
paths:
- "flake.nix"
- "flake.lock"
- "packaging/nix/**"
- "**/bun.lock"
- "**/bun.nix"
- "**/package.json"
- "Cargo.lock"
- "Cargo.toml"
- "rust-toolchain.toml"
- ".gitea/workflows/nix.yml"
- "scripts/ci/check-bun-nix.sh"
pull_request:
paths:
- "flake.nix"
- "flake.lock"
- "packaging/nix/**"
- "**/bun.lock"
- "**/bun.nix"
- "**/package.json"
- "Cargo.lock"
- "Cargo.toml"
- "rust-toolchain.toml"
- ".gitea/workflows/nix.yml"
- "scripts/ci/check-bun-nix.sh"
workflow_dispatch:
inputs:
build-rust:
description: "Also build punktfunk-host + punktfunk-client (slow: full Rust workspace)"
type: boolean
default: false
jobs:
flake:
runs-on: ubuntu-24.04
container:
# NOT nixos/nix. That image contains nix and essentially nothing else — in particular no
# /bin/sleep, and Gitea's act_runner starts every job container with
# `entrypoint=["/bin/sleep","10800"]`. The container therefore never starts:
# failed to create shim task: OCI runtime create failed: unable to start container
# process: exec: "/bin/sleep": stat /bin/sleep: no such file or directory
# and — the part that makes this expensive to debug — every step is then reported as
# `cancelled` rather than failed, which reads exactly like a superseded run.
#
# node:22-bookworm instead: a full Debian with coreutils (so the entrypoint exists) and a
# real node (so actions/checkout works with no pre-checkout install dance), and audit.yml
# already pulls it on this fleet, so it is proven to resolve here. Nix is installed below.
image: node:22-bookworm
timeout-minutes: 90
env:
# The flake needs both experimental features. Also baked into the installer's --extra-conf
# below; this covers any step that shells out before that config is read.
NIX_CONFIG: "experimental-features = nix-command flakes"
# Absolute path rather than $GITHUB_PATH: one less runner behaviour to assume.
NIX: /nix/var/nix/profiles/default/bin/nix
# `--init none` installs Nix with NO daemon running, but the installer still writes a profile
# script that exports NIX_REMOTE=daemon. Anything that sources it (any `-l` login shell) then
# dies on `cannot connect to socket at '/nix/var/nix/daemon-socket/socket'` — which is exactly
# how the installer's own self-test fails during this step, harmlessly, and would be a
# confusing first thing to read in the log. The steps below never source that profile, but pin
# the empty value so a future step cannot reintroduce it. Empty = talk to the local store
# directly, which works because the job runs as root (MEASURED: "Store URL: local, Trusted: 1",
# and a real `nix build` of a trivial derivation succeeds).
NIX_REMOTE: ""
steps:
- uses: actions/checkout@v4
# The Determinate installer needs curl + xz; git so nix can read the flake from the checkout.
# (node:22-bookworm is the full image and already has all three — this is belt-and-braces
# against a future slim-image swap, and costs one cached apt call.)
- name: Installer prerequisites
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils git
# `--init none` is the container mode: no systemd, no daemon. Running as root, nix then talks
# to the store directly. Determinate Nix is also what the Nix box (.21) runs, so CI and the
# hand-verification box stay on the same distribution.
- name: Install Nix
run: |
curl -fsSL https://install.determinate.systems/nix -o /tmp/nix-installer.sh
sh /tmp/nix-installer.sh install linux --init none --no-confirm \
--extra-conf "experimental-features = nix-command flakes"
"$NIX" --version
# Nix reads the flake through libgit2 and refuses a checkout owned by another uid
# ("detected dubious ownership"), which is the normal case for a container job.
- name: Trust the checkout
run: git config --global --add safe.directory "$PWD"
# Diagnostics. This fleet ran a runner out of disk on 2026-08-06 (the ci.yml `web` job died
# with "no space left on device" mid-`bun install`), and a Nix build is the heaviest thing
# here — so record the headroom, or a future failure is a guess.
- name: Environment
run: df -h / /nix /tmp || true
# Evaluates + instantiates every flake output without building any of it.
- name: nix flake check (eval only)
run: |
"$NIX" flake check --no-build --show-trace
# The bun packages, built for real. This is the leg that would have caught the stale
# web/bun.nix end to end: the derivation's offline `bun install` runs against a store cache
# built strictly from bun.nix, so a lockfile that cache does not cover fails here.
# Path-filtered, so it runs only when the packaging or a lockfile actually moves. If it ever
# starts going red on runner disk rather than on real defects, demote it to the dispatch
# opt-in below rather than leaving an infra-red gate on the board.
- name: Build the bun packages
run: |
"$NIX" build --print-build-logs .#punktfunk-web .#punktfunk-scripting
# Both launchers exec pkgs.bun from the store; confirm they were produced and are real entry
# points rather than dangling wrappers.
- name: Smoke the built launchers
run: |
set -eu
web=$("$NIX" path-info .#punktfunk-web)
scripting=$("$NIX" path-info .#punktfunk-scripting)
test -x "$web/bin/punktfunk-web-server" || { echo "no punktfunk-web-server in $web" >&2; exit 1; }
test -x "$scripting/bin/punktfunk-scripting" || { echo "no punktfunk-scripting in $scripting" >&2; exit 1; }
# The console must be the bun bundle, not a node one — the same assertion packages.nix
# makes at build time, re-checked on the installed output.
grep -q 'Bun\.serve' "$web/share/punktfunk-web/.output/server/index.mjs" \
|| { echo "installed console is not a bun bundle" >&2; exit 1; }
echo "bun packages OK: $web $scripting"
# Opt-in only: the full Rust workspace through crane, which is the hour-long leg.
# `github.event.inputs.*` (string) rather than `inputs.*` — the portable spelling.
- name: Build the Rust packages (dispatch opt-in)
if: ${{ github.event.inputs.build-rust == 'true' }}
run: |
"$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client
+6 -3
View File
@@ -96,9 +96,12 @@ jobs:
- name: Prep
run: |
git config --global --add safe.directory "$PWD"
# vulkan-headers: the client's pf-ffvk crate runs bindgen over FFmpeg's
# libavutil/hwcontext_vulkan.h (#include <vulkan/vulkan.h>).
dnf -y install gtk4-devel libadwaita-devel SDL3-devel vulkan-headers
# No vulkan-headers: nothing in the workspace compiles against the system Vulkan headers.
# The host's Vulkan encode hand-rolls its structs, pyrowave-sys bindgens its own vendored
# copy, and both host and client reach Vulkan through ash, which dlopens the loader. (The
# HDR gamescope leg further down does need them, and pulls them itself via `dnf builddep
# gamescope`.) Matches packaging/rpm/punktfunk.spec, which dropped its BuildRequires too.
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
# Fedora's own gamescope, for its RUNTIME libraries only — never shipped, never run. The
+10 -2
View File
@@ -38,10 +38,18 @@ jobs:
with:
fetch-depth: 0
# Pinned syft (keep in sync with the version validated against this repo; bump deliberately).
#
# The BINARY version was pinned; the INSTALLER was not — it was fetched from `main` and piped
# into a shell, so whatever that branch happened to say at job time ran here, with the job's
# environment (2026-08-05 review H-6). Pinning the script to the same tag as the binary makes
# the whole step reproducible: bump the tag in both places together.
- name: Install syft
env:
SYFT_VERSION: v1.49.0
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
| sh -s -- -b /usr/local/bin v1.49.0
set -euo pipefail
curl -sSfL "https://raw.githubusercontent.com/anchore/syft/${SYFT_VERSION}/install.sh" \
| sh -s -- -b /usr/local/bin "$SYFT_VERSION"
- name: Generate SBOM
run: |
git config --global --add safe.directory "$PWD"
+4 -2
View File
@@ -141,8 +141,10 @@ jobs:
# observed on a clean build on this very runner (2026-07-17). No-op for compliant
# projects (libvpl-sys pins 3.13+).
"CMAKE_POLICY_VERSION_MINIMUM=3.5" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# FFMPEG_DIR: the same BtbN lgpl-shared x64 tree the Windows CLIENT links against (provisioned
# by scripts/ci/provision-windows-punktfunk-extras.ps1). The host's AMD/Intel AMF/QSV encode backend
# FFMPEG_DIR: the BtbN lgpl-shared x64 tree, provisioned by
# scripts/ci/provision-windows-punktfunk-extras.ps1. The CLIENT used to link it too; since M10
# it links no libav* at all (windows.yml sets no FFMPEG_DIR), so this tree is the HOST's alone
# and the provisioning step keeps fetching it for that reason. The host's AMD/Intel AMF/QSV encode backend
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
# then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
if (-not $env:FFMPEG_DIR) {
+13 -12
View File
@@ -1,13 +1,17 @@
# Build the punktfunk Windows client as signed MSIX packages (x64 + ARM64) and publish them to
# Gitea's generic package registry, so Windows boxes can download + install a real package (Start
# tile, clean install/uninstall) instead of a loose exe. Runs on a self-hosted windows-amd64
# runner (host mode; the MSVC/WinUI toolchain comes from unom/infra's windows-runner/, FFmpeg
# runner (host mode; the MSVC/WinUI toolchain comes from unom/infra's windows-runner/, the rest
# self-provisions via the "Ensure Windows toolchain" step below, same as windows.yml) — the
# Windows SDK's makeappx/signtool are baked into the runner's daemon env.
#
# Both arches come off the ONE x64 runner: x86_64 natively, aarch64 cross-compiled (the x64 MSVC
# toolset has the ARM64 cross compiler; the matrix points FFMPEG_DIR at the ARM64 FFmpeg tree). See
# windows.yml for the cross-build rationale + the BOM/MAX_PATH runner gotchas.
# toolset has the ARM64 cross compiler). See windows.yml for the cross-build rationale + the
# BOM/MAX_PATH runner gotchas.
#
# NO FFmpeg since M10 (design/client-native-decode.md §6): the client decodes natively, so the
# package carries no libav* DLLs and this workflow sets no FFMPEG_DIR. The host installer
# (windows-host.yml) is unchanged.
#
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
# Packaging internals: clients/windows/packaging/README.md.
@@ -49,7 +53,9 @@ on:
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-ffvk/**'
- 'crates/pf-bitstream/**'
- 'crates/pf-vkdecode/**'
- 'crates/pf-dxvadec/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows-msix.yml'
@@ -80,12 +86,10 @@ jobs:
include:
- arch: x64
target: x86_64-pc-windows-msvc
ffmpeg: C:\Users\Public\ffmpeg
td: C:\t
session_flags: ''
- arch: arm64
target: aarch64-pc-windows-msvc
ffmpeg: C:\Users\Public\ffmpeg-arm64
td: C:\t-a64
# No skia-binaries prebuilt for aarch64-pc-windows-msvc: the session ships
# without the Skia console UI on ARM64 (streaming unaffected) — flip when
@@ -94,7 +98,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
shell: pwsh
run: ./scripts/ci/ensure-windows-toolchain.ps1
@@ -102,12 +106,9 @@ jobs:
shell: pwsh
run: |
# CARGO_TARGET_DIR (per-arch, short) dodges the MAX_PATH wall in the CMake-from-source
# crates (see windows.yml). FFMPEG_DIR selects the arch's import libs + is read by
# pack-msix.ps1 for the runtime DLLs. All via GITHUB_ENV.
# crates (see windows.yml). No FFMPEG_DIR: nothing in this package links libav* (M10),
# and pack-msix.ps1 no longer copies runtime DLLs from one.
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# pf-ffvk's bindgen needs Vulkan headers (arch-independent; provisioned alongside FFmpeg).
"PF_FFVK_VULKAN_INCLUDE=C:\Users\Public\vulkan-headers\include" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
rustup target add ${{ matrix.target }}
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
+32 -31
View File
@@ -1,26 +1,29 @@
# Windows client CI — runs on a self-hosted windows-amd64 runner (host mode; the generic runner +
# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - FFmpeg,
# Vulkan-Headers, WDK, Inno Setup, the ARM64 rustup target - self-provision via the "Ensure
# Windows toolchain" step below, a fast no-op once already present, so any runner with that label
# works with no manual dispatch step first). Build + clippy + fmt + test BOTH client binaries:
# the WinUI 3 shell (windows-reactor + WASAPI + SDL3) and the punktfunk-session Vulkan client
# (pf-presenter/pf-client-core/pf-console-ui/pf-ffvk — every stream runs in it, spawned by the
# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - WDK, Inno Setup,
# the ARM64 rustup target - self-provision via the "Ensure Windows toolchain" step below, a fast
# no-op once already present, so any runner with that label works with no manual dispatch step
# first). Build + clippy + fmt + test BOTH client binaries: the WinUI 3 shell
# (windows-reactor + WASAPI + SDL3) and the punktfunk-session Vulkan client
# (pf-presenter/pf-client-core/pf-console-ui — every stream runs in it, spawned by the
# shell). ARM64 note: rust-skia publishes no aarch64-pc-windows-msvc prebuilt binaries, so the
# session builds --no-default-features there (no Skia console UI; streaming is unaffected) —
# flip when skia-binaries adds the target.
#
# NO FFmpeg here since M10 (design/client-native-decode.md §6): the client decodes with
# pf-vkdecode / pf-dxvadec / openh264+rav1d and links no libav* at all, so this workflow sets
# no FFMPEG_DIR, no PF_FFVK_VULKAN_INCLUDE and prepends nothing to PATH. The provisioning
# script still fetches the FFmpeg trees because the HOST keeps FFmpeg — windows-host.yml's
# `amf-qsv` leg link-imports them.
#
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and
# aarch64-pc-windows-msvc by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler
# (VC\Tools\MSVC\<ver>\bin\Hostx64\arm64\cl.exe) and aarch64-pc-windows-msvc is a tier-2 Rust
# target with host tools, so no ARM64 runner is needed — the cc/cmake crates pick the ARM64
# compiler from the target triple (SDL3 + libopus build-from-source cross-compile fine). The one
# arch-specific external dep is FFmpeg's import libs: the runner keeps an x64 tree at
# C:\Users\Public\ffmpeg and an ARM64 tree at C:\Users\Public\ffmpeg-arm64 (both FFmpeg 7.x /
# avcodec-61); the matrix points FFMPEG_DIR at the right one. aarch64 can't *run* on the x64 host,
# so fmt + test run only for x64.
# thing the aarch64 build can't do is *run* on the x64 host, so fmt + test run only for x64.
#
# The MSVC/WinUI/FFmpeg toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, the x64 FFmpeg,
# CARGO_HOME, CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout
# The MSVC/WinUI toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, CARGO_HOME,
# CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout
# / per-arch vars are set in a step:
# - CARGO_TARGET_DIR=C:\t… the runner's host workdir is buried deep under
# C:\Windows\System32\config\systemprofile\.cache\act\<hash>\hostexecutor\,
@@ -29,7 +32,6 @@
# can't create its .tlog (DirectoryNotFoundException -> MSB6003). A short
# root keeps every nested path well under the limit (per-arch so the two
# matrix legs don't share a target dir).
# - FFMPEG_DIR per-arch FFmpeg import libs (x64 vs arm64 tree).
#
# Steps use `shell: pwsh` (PowerShell 7) deliberately: Windows PowerShell 5.1's
# `Out-File -Encoding utf8` prepends a UTF-8 BOM that corrupts the first GITHUB_ENV line (that
@@ -55,7 +57,9 @@ on:
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-ffvk/**'
- 'crates/pf-bitstream/**'
- 'crates/pf-vkdecode/**'
- 'crates/pf-dxvadec/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows.yml'
@@ -67,7 +71,9 @@ on:
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-ffvk/**'
- 'crates/pf-bitstream/**'
- 'crates/pf-vkdecode/**'
- 'crates/pf-dxvadec/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows.yml'
@@ -110,7 +116,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
shell: pwsh
run: ./scripts/ci/ensure-windows-toolchain.ps1
@@ -120,21 +126,13 @@ jobs:
# Per-arch short target root (dodges MAX_PATH; keeps the two legs from sharing target\).
$td = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\t-a64' } else { 'C:\t' }
"CARGO_TARGET_DIR=$td" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# Per-arch FFmpeg import libs (provision-windows-punktfunk-extras.ps1 fetches both).
$ff = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\Users\Public\ffmpeg-arm64' } else { 'C:\Users\Public\ffmpeg' }
"FFMPEG_DIR=$ff" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# pf-ffvk's bindgen needs Vulkan headers (arch-independent; provisioned alongside FFmpeg).
"PF_FFVK_VULKAN_INCLUDE=C:\Users\Public\vulkan-headers\include" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# $ff\bin on PATH too (not just FFMPEG_DIR, which only satisfies the linker): the test
# binary needs the actual DLLs to load at runtime. Set here rather than relying on the
# daemon's own env (project-env.ps1) - on a freshly cloned/registered runner the daemon
# starts before this job's "Ensure Windows toolchain" step ever writes that file, so its
# PATH doesn't include this yet on a first run (confirmed live: STATUS_DLL_NOT_FOUND).
"$ff\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
# No FFMPEG_DIR / PF_FFVK_VULKAN_INCLUDE / PATH prepend: the client links no libav*
# since M10 (see this file's header), so nothing here needs import libs or runtime DLLs.
# The HOST still does — windows-host.yml sets them for its amf-qsv leg.
rustup target add ${{ matrix.target }}
rustc --version
cargo --version
Write-Output "target ${{ matrix.target }} target-dir $td ffmpeg $ff"
Write-Output "target ${{ matrix.target }} target-dir $td"
# Both client binaries. ARM64: no skia-binaries prebuilt for the target, so the session
# drops its `ui` feature there (pf-console-ui excluded; --no-default-features is a no-op
@@ -152,7 +150,10 @@ jobs:
- name: Clippy (-D warnings)
shell: pwsh
run: |
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk')
# Every crate in the `paths:` trigger above is named here: `cargo clippy -p X` BUILDS a
# dependency but only LINTS the packages it is given, so a decode crate that starts the
# run but is missing from this list would be gated by nothing.
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-bitstream','-p','pf-vkdecode','-p','pf-dxvadec')
$sf = @()
if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') }
cargo clippy @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings
@@ -160,9 +161,9 @@ jobs:
- name: Rustfmt check
if: matrix.target == 'x86_64-pc-windows-msvc'
shell: pwsh
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk -- --check
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec -- --check
- name: Test
if: matrix.target == 'x86_64-pc-windows-msvc'
shell: pwsh
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk --target ${{ matrix.target }}
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec --target ${{ matrix.target }}
+5 -3
View File
@@ -46,9 +46,11 @@ sudo apt install build-essential clang libclang-dev pkg-config cmake \
libvulkan-dev
```
(The last two groups are the Linux client shell and `pf-ffvk`; skip them only if you never build
those crates. `scripts/bootstrap-ubuntu.sh` sets up an Ubuntu **capture-test host** — NVIDIA, Sway,
PipeWire — and is not a substitute for the list above.)
(The last two groups are the Linux client shell and the Vulkan session presenter; skip them only
if you never build those crates. `libvulkan-dev` is for the LOADER's pkg-config/soname — ash
dlopens it, and the client links no FFmpeg at all, so no libav*-dev appears here.
`scripts/bootstrap-ubuntu.sh` sets up an Ubuntu **capture-test host** — NVIDIA, Sway, PipeWire —
and is not a substitute for the list above.)
## Before you push
Generated
+326 -41
View File
@@ -65,7 +65,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b07e8e73d720a1f2e4b6014766e6039fd2e96a4fa44e2a78d0e1fa2ff49826"
dependencies = [
"android_log-sys",
"env_filter",
"env_filter 0.1.4",
"log",
]
@@ -204,6 +204,12 @@ dependencies = [
"syn",
]
[[package]]
name = "assert_matches"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9"
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -341,6 +347,26 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "atomig"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd0f41f4bb89f5c6450325e283fb78c4a3d042181b54f3855ee2f872919f9863"
dependencies = [
"atomig-macro",
]
[[package]]
name = "atomig-macro"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49c98dba06b920588de7d63f6acc23f1e6a9fade5fd6198e564506334fb5a4f5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "audiopus_sys"
version = "0.2.2"
@@ -446,7 +472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"annotate-snippets",
"bitflags",
"bitflags 2.13.0",
"cexpr",
"clang-sys",
"itertools 0.13.0",
@@ -475,6 +501,12 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.13.0"
@@ -538,6 +570,12 @@ dependencies = [
"syn",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
@@ -556,7 +594,7 @@ version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"cairo-sys-rs",
"glib",
"libc",
@@ -885,6 +923,15 @@ dependencies = [
"itertools 0.10.5",
]
[[package]]
name = "cros-codecs"
version = "0.0.5"
dependencies = [
"env_logger",
"log",
"serde_json",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@@ -991,6 +1038,37 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "defmt"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
dependencies = [
"defmt-parser",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "der"
version = "0.7.10"
@@ -1101,6 +1179,29 @@ dependencies = [
"regex",
]
[[package]]
name = "env_filter"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
dependencies = [
"anstream",
"anstyle",
"env_filter 2.0.0",
"jiff",
"log",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1193,7 +1294,7 @@ version = "8.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c4bd5ab1ac61f29c634df1175d350ded29cf74c3c6d4f7030431a5ae3c7d5d"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"ffmpeg-sys-next",
"libc",
]
@@ -1584,7 +1685,7 @@ version = "0.22.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"futures-channel",
"futures-core",
"futures-executor",
@@ -1777,7 +1878,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
"zerocopy 0.8.52",
]
[[package]]
@@ -2127,6 +2228,42 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
dependencies = [
"defmt",
"jiff-core",
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-core"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
dependencies = [
"defmt",
]
[[package]]
name = "jiff-static"
version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
dependencies = [
"jiff-core",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "jni"
version = "0.21.1"
@@ -2291,7 +2428,7 @@ version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6b8cfa2a7656627b4c92c6b9ef929433acd673d5ab3708cda1b18478ac00df4"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"cc",
"convert_case",
"cookie-factory",
@@ -2486,6 +2623,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149"
dependencies = [
"jobserver",
"log",
]
@@ -2493,7 +2631,7 @@ dependencies = [
name = "ndk"
version = "0.9.0"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"jni-sys 0.3.1",
"log",
"ndk-sys",
@@ -2517,7 +2655,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"cfg-if",
"cfg_aliases",
"libc",
@@ -2530,7 +2668,7 @@ version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"cfg-if",
"cfg_aliases",
"libc",
@@ -2848,6 +2986,14 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-bitstream"
version = "0.24.0"
dependencies = [
"cros-codecs",
"tracing",
]
[[package]]
name = "pf-capture"
version = "0.24.0"
@@ -2876,23 +3022,31 @@ dependencies = [
"anyhow",
"ash",
"async-channel",
"ffmpeg-next",
"libc",
"libloading",
"mdns-sd",
"openh264",
"opus",
"pf-ffvk",
"pf-bitstream",
"pf-dxvadec",
"pf-update-check",
"pf-vaadec",
"pf-vkdecode",
"pipewire",
"punktfunk-core",
"pyrowave-sys",
"rand 0.9.4",
"rav1d",
"rustls",
"sdl3",
"serde",
"serde_json",
"sha2",
"tracing",
"ureq",
"wasapi",
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"winreg",
]
[[package]]
@@ -2934,6 +3088,16 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "pf-dxvadec"
version = "0.24.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
"pf-vkdecode",
"tracing",
]
[[package]]
name = "pf-encode"
version = "0.24.0"
@@ -2958,15 +3122,6 @@ dependencies = [
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "pf-ffvk"
version = "0.24.0"
dependencies = [
"ash",
"bindgen",
"pkg-config",
]
[[package]]
name = "pf-frame"
version = "0.24.0"
@@ -3041,7 +3196,6 @@ dependencies = [
"ash",
"async-channel",
"pf-client-core",
"pf-ffvk",
"punktfunk-core",
"sdl3",
"tracing",
@@ -3068,13 +3222,22 @@ dependencies = [
"ureq",
]
[[package]]
name = "pf-vaadec"
version = "0.24.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
"pf-vkdecode",
]
[[package]]
name = "pf-vdisplay"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
"bitflags",
"bitflags 2.13.0",
"bytemuck",
"futures-util",
"hex",
@@ -3101,6 +3264,17 @@ dependencies = [
"x11rb",
]
[[package]]
name = "pf-vkdecode"
version = "0.24.0"
dependencies = [
"ash",
"cros-codecs",
"pf-bitstream",
"sha2",
"tracing",
]
[[package]]
name = "pf-win-display"
version = "0.24.0"
@@ -3152,7 +3326,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9688b89abf11d756499f7c6190711d6dbe5a3acdb30c8fbf001d6596d06a8d44"
dependencies = [
"anyhow",
"bitflags",
"bitflags 2.13.0",
"libc",
"libspa",
"libspa-sys",
@@ -3206,7 +3380,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"crc32fast",
"fdeflate",
"flate2",
@@ -3250,6 +3424,21 @@ dependencies = [
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
[[package]]
name = "portable-atomic-util"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [
"portable-atomic",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -3271,7 +3460,7 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
"zerocopy 0.8.52",
]
[[package]]
@@ -3310,7 +3499,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
"bit-set",
"bit-vec",
"bitflags",
"bitflags 2.13.0",
"num-traits",
"rand 0.9.4",
"rand_chacha 0.9.0",
@@ -3346,6 +3535,8 @@ dependencies = [
"opus",
"punktfunk-core",
"tracing",
"uac-host",
"usbfs-iso",
]
[[package]]
@@ -3385,7 +3576,6 @@ name = "punktfunk-client-windows"
version = "0.24.0"
dependencies = [
"async-channel",
"ffmpeg-next",
"mdns-sd",
"pf-client-core",
"punktfunk-core",
@@ -3428,7 +3618,7 @@ dependencies = [
"tokio",
"tracing",
"windows-sys 0.59.0",
"zerocopy",
"zerocopy 0.8.52",
"zeroize",
]
@@ -3723,6 +3913,36 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rav1d"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1932f060d5e7bd49dc9f8b272c1dc5e9ce0ffe141c28be900265d3989b36c9ed"
dependencies = [
"assert_matches",
"atomig",
"bitflags 2.13.0",
"cc",
"cfg-if",
"libc",
"nasm-rs",
"parking_lot",
"paste",
"raw-cpuid",
"strum",
"to_method",
"zerocopy 0.7.35",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.13.0",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -3774,7 +3994,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
"bitflags 2.13.0",
]
[[package]]
@@ -3931,7 +4151,7 @@ version = "0.40.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
@@ -3970,7 +4190,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"errno",
"libc",
"linux-raw-sys",
@@ -4124,7 +4344,7 @@ version = "0.18.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25bd22eb1bbc9137e914022b4994ed35591eea0884e9e3e98e6d9895cad6e1d2"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"libc",
"sdl3-image-sys",
"sdl3-mixer-sys",
@@ -4219,7 +4439,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"core-foundation",
"core-foundation-sys",
"libc",
@@ -4425,7 +4645,7 @@ version = "0.87.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f7d94f3e7537c71ad4cf132eb26e3be8c8a886ed3649c4525c089041fc312b2"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"lazy_static",
"skia-bindings",
]
@@ -4518,6 +4738,28 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -4721,6 +4963,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "to_method"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7c4ceeeca15c8384bbc3e011dbd8fccb7f068a440b752b7d9b32ceb0ca0e2e8"
[[package]]
name = "tokio"
version = "1.52.3"
@@ -4985,6 +5233,14 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uac-host"
version = "0.1.0"
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
dependencies = [
"usbfs-iso",
]
[[package]]
name = "uds_windows"
version = "1.2.1"
@@ -5064,6 +5320,14 @@ dependencies = [
"serde",
]
[[package]]
name = "usbfs-iso"
version = "0.1.0"
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
dependencies = [
"libc",
]
[[package]]
name = "usbip-sim"
version = "0.8.0"
@@ -5282,7 +5546,7 @@ version = "0.31.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"rustix",
"wayland-backend",
"wayland-scanner",
@@ -5294,7 +5558,7 @@ version = "0.32.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"wayland-backend",
"wayland-client",
"wayland-scanner",
@@ -5306,7 +5570,7 @@ version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"wayland-backend",
"wayland-client",
"wayland-protocols",
@@ -5319,7 +5583,7 @@ version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"wayland-backend",
"wayland-client",
"wayland-protocols",
@@ -5671,7 +5935,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"widestring",
"windows-sys 0.52.0",
]
@@ -6163,13 +6427,34 @@ dependencies = [
"zvariant",
]
[[package]]
name = "zerocopy"
version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
dependencies = [
"byteorder",
"zerocopy-derive 0.7.35",
]
[[package]]
name = "zerocopy"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
dependencies = [
"zerocopy-derive",
"zerocopy-derive 0.8.52",
]
[[package]]
name = "zerocopy-derive"
version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
+5 -1
View File
@@ -5,11 +5,12 @@ members = [
"crates/punktfunk-host",
"crates/punktfunk-host/vendor/usbip-sim",
"crates/punktfunk-tray",
"crates/pf-bitstream",
"crates/pf-bitstream/vendor/cros-codecs",
"crates/pf-client-core",
"crates/pf-clipboard",
"crates/pf-presenter",
"crates/pf-console-ui",
"crates/pf-ffvk",
"crates/pf-driver-proto",
"crates/pf-paths",
"crates/pf-update",
@@ -23,6 +24,9 @@ members = [
"crates/pf-capture",
"crates/pf-inject",
"crates/pf-vdisplay",
"crates/pf-vkdecode",
"crates/pf-dxvadec",
"crates/pf-vaadec",
"crates/pyrowave-sys",
"crates/libvpl-sys",
"clients/probe",
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 unom
Copyright 2026 unom - Enrico Bühler
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 unom
Copyright (c) 2026 unom - Enrico Bühler
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+12 -6
View File
@@ -84,7 +84,9 @@ mid-stream mode renegotiation and a wall-clock skew handshake so latency stays v
Both run from **one process**: bare `punktfunk-host serve` is the **secure native-only default**
(`punktfunk/1` + the management API/web console), and `serve --gamestream` additionally enables the
GameStream/Moonlight-compat planes (opt-in, trusted-LAN only — GameStream has inherent on-path
weaknesses). The host is managed through a REST API and web console. Builds against FFmpeg 7 or 8.
weaknesses). The host is managed through a REST API and web console. The **host** builds against
FFmpeg 7 or 8; the **clients** link no FFmpeg at all — they decode natively (Vulkan Video, DXVA,
VAAPI, VideoToolbox, MediaCodec, openh264 + rav1d).
What works where: **[the support matrix](https://docs.punktfunk.unom.io/docs/support-matrix)** ·
where it's heading: **[the roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
@@ -187,10 +189,13 @@ and the [docs site](https://docs.punktfunk.unom.io).
crates/
punktfunk-core/ protocol · FEC · pacing · crypto · QUIC control plane — the C ABI (lib + cdylib + staticlib)
punktfunk-host/ the host (Linux + Windows): virtual displays · capture · encode · input · GameStream · punktfunk/1 · mgmt
pf-client-core/ shared client plumbing (Linux + Windows): session pump · FFmpeg decode · audio · SDL3 gamepads · trust · discovery
pf-client-core/ shared client plumbing (Linux + Windows): session pump · native decode ladder · audio · SDL3 gamepads · trust · discovery
pf-presenter/ Vulkan session presenter: SDL3 window · ash swapchain · frame present · input capture
pf-console-ui/ Skia console UI for the session client: gamepad shell · stats OSD · pairing · on-screen keyboard
pf-ffvk/ FFmpeg Vulkan hwcontext bindings (AVVkFrame) for Vulkan Video decode on the presenter's device
pf-bitstream/ H.264 / H.265 / AV1 bitstream parsing + per-AU decode plans — the one parser every native rung submits from
pf-vkdecode/ native Vulkan Video decode (H.264 / H.265 / AV1) on the presenter's own device
pf-dxvadec/ native DXVA buffer layouts + AuPlan → picparams conversion (the Windows D3D11VA rung)
pf-vaadec/ native libva buffer layouts + AuPlan → picparams conversion (the Linux VAAPI rung)
pf-driver-proto/ host ↔ pf-vdisplay driver contract: control IOCTLs + IDD-push frame transport (no_std)
punktfunk-tray/ host tray icon (Windows notification area / Linux StatusNotifierItem)
clients/
@@ -245,9 +250,10 @@ additional terms or conditions. See [CONTRIBUTING.md](CONTRIBUTING.md).
Punktfunk's own source is MIT/Apache-2.0. Shipped binaries additionally link third-party components
under their own (permissive) licenses — see [`THIRD-PARTY-NOTICES.txt`](THIRD-PARTY-NOTICES.txt)
(regenerate with `scripts/gen-third-party-notices.sh`). The Windows host and client builds also
bundle FFmpeg under the **LGPL v2.1+** (dynamically linked, replaceable DLLs; the license text and
notice ship in the installed `licenses/` folder).
(regenerate with `scripts/gen-third-party-notices.sh`). The Windows **host** build also
bundles FFmpeg under the **LGPL v2.1+** (dynamically linked, replaceable DLLs; the license text and
notice ship in the installed `licenses/` folder). The **clients** bundle no FFmpeg — they link
none.
### Trademarks
+566 -223
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
THIRD-PARTY SOFTWARE NOTICES
============================================================================
punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
Punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
The binaries it ships statically/dynamically link the third-party Rust crates below.
Each is distributed under its own permissive license; full texts follow.
Generated by `cargo about generate about.hbs` (see about.toml) — do not edit by hand.
+14 -2
View File
@@ -4,10 +4,22 @@
# cargo about generate about.hbs > THIRD-PARTY-NOTICES.txt # (or use scripts/gen-third-party-notices.sh)
#
# `accepted` is the allow-list of SPDX licenses permitted in the dependency tree. CI fails if a crate
# carries anything not listed here — which is exactly the regression guard we want against a copyleft
# dependency silently entering the linked set. All entries
# carries anything not listed here — the regression guard against a copyleft dependency silently
# entering the linked set. All entries
# below are permissive / attribution-only; deliberately NO GPL/LGPL/AGPL/MPL-link/SSPL/EPL.
#
# ⚠ KNOW THE LIMIT OF THIS GATE. cargo-about walks the CARGO graph, so it sees CRATES. A native
# library linked through a permissively-licensed `-sys` crate is INVISIBLE to it, licence and all.
# FFmpeg is precisely that shape: `ffmpeg-sys-next` is WTFPL and passes cleanly, while the LGPL
# libavcodec/libavutil/swscale it link-imports — and which the Windows host installer bundles as
# DLLs — never appear in the harvest at all. This gate did not catch FFmpeg entering the tree and
# would not catch the next such library. Copyleft arriving as C behind a -sys crate is a REVIEW
# question, not a CI one; the LGPL obligations we do carry are discharged by hand (the notice files
# and the replaceable-DLL linkage, see packaging/windows/punktfunk-host.iss).
#
# Since M10 this is a HOST-only concern: the client links no FFmpeg, so for every client artifact
# the crate graph and the linked set finally coincide and the gate means what it appears to mean.
#
# The dependency-free fallback is scripts/gen-third-party-notices.py (reads the cargo registry cache),
# which is what produced the committed baseline when cargo-about is unavailable offline.
+157 -9
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.23.0"
"version": "0.24.0"
},
"paths": {
"/api/v1/clients": {
@@ -1052,7 +1052,7 @@
"library"
],
"summary": "Fetch one cover-art image for a library entry",
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —\nit's what the user's Steam client already shows for it), the public Steam CDN's flat URL\nconvention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host\ncan't predict, in which case this 404s and the client falls through to its next art candidate).\nOnly Steam ids are backed today; any other store 404s.",
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file. A Steam title falls back\nto the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the\nuser's Steam client already shows for it), the public Steam CDN's flat URL convention second\n(newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case\nthis 404s and the client falls through to its next art candidate).",
"operationId": "getLibraryArt",
"parameters": [
{
@@ -1307,7 +1307,7 @@
"library"
],
"summary": "Replace a provider's library entries (declarative reconcile)",
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.",
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches. One provider\nper store; a second claimant gets 409. While a claim is held the matching built-in scanner is\nsuppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty\nreconcile (a store can legitimately have zero installed titles).",
"operationId": "reconcileProviderEntries",
"parameters": [
{
@@ -1318,6 +1318,15 @@
"schema": {
"type": "string"
}
},
{
"name": "store",
"in": "query",
"description": "Claim this store for the provider ([a-z0-9_-], `custom`/`manual` reserved)",
"required": false,
"schema": {
"type": "string"
}
}
],
"requestBody": {
@@ -1348,7 +1357,7 @@
}
},
"400": {
"description": "Invalid provider id or payload",
"description": "Invalid provider id, store id, or payload",
"content": {
"application/json": {
"schema": {
@@ -1367,6 +1376,16 @@
}
}
},
"409": {
"description": "That store is already claimed by another provider",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not persist the catalog",
"content": {
@@ -4159,7 +4178,8 @@
"tier",
"platforms",
"compatible",
"update_available"
"update_available",
"categories"
],
"properties": {
"author": {
@@ -4172,6 +4192,13 @@
],
"description": "A revocation covering the catalogued version — do not offer this without shouting."
},
"categories": {
"type": "array",
"items": {
"type": "string"
},
"description": "What kind of plugin this is — the console filters Browse by these, and the Game sources\nsurface's \"Add a source\" rail shows exactly the `library` ones (design D5/D6)."
},
"compatible": {
"type": "boolean",
"description": "Can this host install it?"
@@ -4179,6 +4206,13 @@
"description": {
"type": "string"
},
"detected": {
"type": [
"boolean",
"null"
],
"description": "Whether the launcher this plugin scans looks **installed on this host** (design D8), from the\nindex's own existence probes. `null` = the entry declares no probes for this platform, which\nthe console renders as \"unknown\" rather than \"not installed\"."
},
"homepage": {
"type": [
"string",
@@ -4365,6 +4399,17 @@
],
"description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)."
},
"role": {
"$ref": "#/components/schemas/GameRole",
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]."
},
"store": {
"type": [
"string",
"null"
],
"description": "The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.\n`None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.\n\nMaterialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an\nentry is self-describing: its id and its `store` badge derive from the entry alone, and stay\ncorrect even while the claim map is being rewritten."
},
"title": {
"type": "string"
}
@@ -4409,6 +4454,10 @@
},
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
},
"role": {
"$ref": "#/components/schemas/GameRole",
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher\nentry is legal (an operator may want a \"Steam\" tile without installing the steam plugin)."
},
"title": {
"type": "string"
}
@@ -4467,6 +4516,17 @@
"type": "object",
"description": "What an operator (or a provider plugin) can tell the host about recognizing a title — the wire\nhalf of [`DetectSpec`], and the only part of it that is ever accepted from outside.\n\nDeliberately a **subset**: the store-derived signals (a Steam appid, a launcher's environment\nmarker) are things the host discovers for itself and would be meaningless — or dangerous — to take\non someone's word. What is left is what a provider genuinely knows and the host cannot guess: where\nthe title is installed, which executable is the game, what the process is called. All three are\noptional; supplying none is the same as supplying no hint at all.\n\nNever returned by the catalog API — see the module docs on why detect data does not cross the wire\noutbound.",
"properties": {
"env_marker": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/EnvMarker",
"description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]."
}
]
},
"exe": {
"type": [
"string",
@@ -4487,6 +4547,15 @@
"null"
],
"description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]."
},
"steam_appid": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "The Steam appid, for a title Steam itself installed (D3). On Linux this is the **sharpest**\nsignal that exists — Steam wraps every launch, native or Proton, in\n`reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's — so without it a\nsteam plugin's lease tracking would degrade from reaper-exact to install-dir prefix matching.",
"minimum": 0
}
}
},
@@ -4715,6 +4784,27 @@
}
}
},
"EnvMarker": {
"type": "object",
"description": "An environment variable a launcher stamps onto the game's process, identifying it.\n\nSerializable because it is now half of the inbound [`DetectHint`] too (D3) — a library plugin\nthat knows its launcher's marker (Heroic's `HEROIC_APP_NAME`, load-bearing under Proton) has to\nbe able to say so, since after extraction the host no longer reads that launcher's files itself.",
"required": [
"key"
],
"properties": {
"key": {
"type": "string",
"description": "The variable name (e.g. `HEROIC_GAME_ID`).",
"example": "HEROIC_APP_NAME"
},
"value": {
"type": [
"string",
"null"
],
"description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time."
}
}
},
"EventKind": {
"oneOf": [
{
@@ -5165,6 +5255,10 @@
],
"description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
},
"role": {
"$ref": "#/components/schemas/GameRole",
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]."
},
"store": {
"type": "string",
"description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.",
@@ -5296,6 +5390,14 @@
}
}
},
"GameRole": {
"type": "string",
"description": "What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big\nPicture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,\nleases and lists exactly like a game (design D4), and clients that don't know the field render it\nas a plain tile. Serde-default `game` and skip-serialized when default, so the wire is unchanged\nfor every entry that doesn't opt in.",
"enum": [
"game",
"launcher"
]
},
"GameSession": {
"type": "string",
"description": "How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).",
@@ -6334,6 +6436,13 @@
"title"
],
"properties": {
"category": {
"type": [
"string",
"null"
],
"description": "What KIND of plugin this is (`^[a-z][a-z0-9-]{0,31}$`), top-level rather than under `ui`\nbecause it describes the plugin, not its surface. The console knows one value today —\n`library` — which it filters **out of the nav**: six installed scanner plugins would otherwise\nflood the sidebar, and their real entry point is the Game sources surface (design D5). A\nlibrary plugin that genuinely wants its own page (rom-manager, which is much more than a\nscanner) simply omits the category."
},
"title": {
"type": "string",
"description": "Human-readable title for the console nav entry (164 chars; control chars stripped)."
@@ -6366,6 +6475,13 @@
"title"
],
"properties": {
"category": {
"type": [
"string",
"null"
],
"description": "The plugin's kind — see [`PluginRegistration::category`]."
},
"id": {
"type": "string"
},
@@ -6604,6 +6720,10 @@
},
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
},
"role": {
"$ref": "#/components/schemas/GameRole",
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin\nemits its `launchers(cfg)` entries with `role: \"launcher\"`."
},
"title": {
"type": "string"
}
@@ -6780,26 +6900,46 @@
},
"ScannerInfo": {
"type": "object",
"description": "One installed-store scanner this host build supports, with its enable state — the unit the\nconsole renders a toggle for. The list is platform-gated at compile time (the scanners are),\nso the console never shows a toggle that cannot do anything on this host.",
"description": "One **game source** on this host, with its enable state — the unit the console renders a toggle\nfor. A source is either a scanner compiled into this build or a plugin that reconciles entries in\n(WP2.6); the console treats them identically, which is what makes the extraction invisible.",
"required": [
"id",
"label",
"enabled"
"enabled",
"origin"
],
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether this host runs the scanner (default true)."
"description": "Whether this host runs the source (default true)."
},
"entries": {
"type": [
"integer",
"null"
],
"description": "How many entries this source currently contributes. `None` for a built-in scanner, whose\ncount would mean walking every launcher's files just to render a toggle.",
"minimum": 0
},
"id": {
"type": "string",
"description": "Stable scanner id — the same string the scanner's entries carry in their `store` field.",
"description": "Stable source id — the same string this source's entries carry in their `store` field. For a\nplugin source it is also its provider id and its store claim: one string, by construction, so\na user's disabled state survives a built-in scanner being replaced by its plugin.",
"example": "steam"
},
"label": {
"type": "string",
"description": "Human-facing name for the console toggle.",
"example": "Steam"
},
"origin": {
"$ref": "#/components/schemas/SourceOrigin",
"description": "Where the source comes from: `builtin` (a scanner in this host build) or `plugin`."
},
"provider": {
"type": [
"string",
"null"
],
"description": "The provider id backing a `plugin` source — absent for a built-in scanner."
}
}
},
@@ -6962,6 +7102,14 @@
}
}
},
"SourceOrigin": {
"type": "string",
"description": "Where a [`ScannerInfo`] comes from.",
"enum": [
"builtin",
"plugin"
]
},
"SourceView": {
"type": "object",
"description": "A configured catalog source and how its last refresh went.",
+5
View File
@@ -15,6 +15,11 @@ FROM docker.io/library/archlinux:base-devel
# One transaction: the main build/runtime deps (first list) + the gamescope companion's
# deps (second list) — both copied verbatim from what arch.yml installed in-job, where
# they now no-op as `--needed` guards.
# vulkan-headers rides the first list only because arch.yml's copy does; the package it actually
# serves is the gamescope companion (packaging/gamescope/PKGBUILD makedepends). punktfunk itself
# needs no system Vulkan headers — pyrowave-sys bindgens its own vendored copy and ash dlopens the
# loader — but arch.yml builds gamescope with `makepkg -d`, so an absent makedepend would not be
# reported as a missing dependency, only as a compile failure. Keep it.
RUN pacman -Syu --noconfirm --needed \
git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
+4 -2
View File
@@ -27,8 +27,10 @@ RUN dnf -y install \
mesa-libGL-devel mesa-libgbm-devel \
# punktfunk-client link deps (GTK4 shell + SDL3 gamepads)
gtk4-devel libadwaita-devel SDL3-devel \
# pf-ffvk bindgen over libavutil/hwcontext_vulkan.h needs <vulkan/vulkan.h>
vulkan-headers \
# No vulkan-headers: nothing in the workspace compiles against the system Vulkan headers
# (pyrowave-sys bindgens its own vendored copy; host and client both reach Vulkan through
# ash, which dlopens the loader), and packaging/rpm/punktfunk.spec BuildRequires none.
# rpm.yml's HDR gamescope leg needs them and pulls them with `dnf builddep gamescope`.
&& dnf clean all
# bun — both the BUILD tool and the RUNTIME for the punktfunk-web console (`bun run build` -> the
+4 -3
View File
@@ -29,15 +29,16 @@ RUN sed -i 's|^Types: deb$|Types: deb\nArchitectures: amd64|' /etc/apt/sources.l
&& dpkg --add-architecture arm64
# 2. The cross toolchain + every arm64 dev lib the client links. Mirrors the client half of
# rust-ci.Dockerfile's list (FFmpeg, PipeWire, Opus, SDL3, GTK4/libadwaita, xkbcommon,
# Vulkan headers for pf-ffvk's bindgen over hwcontext_vulkan.h).
# rust-ci.Dockerfile's list (FFmpeg, PipeWire, Opus, SDL3, GTK4/libadwaita, xkbcommon). No
# Vulkan dev package: nothing compiles or links against Vulkan — ash dlopens the loader, and
# pyrowave-sys bindgens its own vendored headers.
RUN apt-get update && apt-get install -y --no-install-recommends \
crossbuild-essential-arm64 \
libavcodec-dev:arm64 libavformat-dev:arm64 libavutil-dev:arm64 libswscale-dev:arm64 \
libavfilter-dev:arm64 libavdevice-dev:arm64 \
libpipewire-0.3-dev:arm64 libopus-dev:arm64 \
libsdl3-dev:arm64 libgtk-4-dev:arm64 libadwaita-1-dev:arm64 \
libwayland-dev:arm64 libxkbcommon-dev:arm64 libvulkan-dev:arm64 \
libwayland-dev:arm64 libxkbcommon-dev:arm64 \
&& rm -rf /var/lib/apt/lists/*
# 3. The Rust target — installed against the toolchain the WORKSPACE pins, not the image's
+3 -2
View File
@@ -22,8 +22,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libgl-dev libegl-dev libgbm-dev \
# punktfunk-client-linux (GTK4/libadwaita shell, SDL3 gamepads)
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
# pf-ffvk (bindgen over libavutil/hwcontext_vulkan.h needs <vulkan/vulkan.h>)
libvulkan-dev \
# No libvulkan-dev: nothing in the workspace compiles or links against Vulkan (pyrowave-sys
# bindgens its own vendored headers, and both host and client reach Vulkan through ash, which
# dlopens the loader), so neither the build nor deb.yml's dpkg-shlibdeps ever asks for it.
&& rm -rf /var/lib/apt/lists/*
# bun — builds the punktfunk-web console in deb.yml (which runs the web build in THIS image).
File diff suppressed because it is too large Load Diff
@@ -31,6 +31,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -47,6 +48,7 @@ import android.widget.Toast
import io.unom.punktfunk.kit.link.DeepLinkResult
import io.unom.punktfunk.kit.link.DeepLinks
import io.unom.punktfunk.kit.link.HostResolution
import io.unom.punktfunk.kit.SessionEndReason
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.models.ActiveSession
import io.unom.punktfunk.models.Tab
@@ -61,6 +63,11 @@ fun App(forceGamepadUi: Boolean = false) {
// so the stream screen never re-reads the store behind its own connect's back.
var session by remember { mutableStateOf<ActiveSession?>(null) }
var tab by remember { mutableStateOf(Tab.Connect) }
// Set when a session ends because its game exited and it began as a library launch: the host
// whose library the console shell should come back to. Held HERE because the shell's own
// navigation state does not outlive the stream. Cleared once the shell has consumed it, so a
// later manual Back out of the library is not undone by a stale value.
var reopenLibraryHostId by remember { mutableStateOf<String?>(null) }
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
// a TV OR the dev force flag). Flips live as controllers connect/disconnect.
@@ -98,6 +105,15 @@ fun App(forceGamepadUi: Boolean = false) {
}
}
// The console backdrop's colour family, published once from the live settings rather than
// threaded through every screen that draws a backdrop. Because it is read from the SAME
// `settings` state the gamepad settings screen writes, stepping the Background row recolours
// the field behind that very row.
val palette = GamepadPalette.named(settings.uiPalette)
CompositionLocalProvider(
LocalGamepadPalette provides palette,
LocalGamepadInk provides GamepadInk.of(palette),
) {
AnimatedContent(
targetState = session,
transitionSpec = {
@@ -107,7 +123,20 @@ fun App(forceGamepadUi: Boolean = false) {
) { active ->
if (active != null) {
// Immersive: the stream takes the whole screen, no bottom bar.
StreamScreen(active, onDisconnect = { session = null })
StreamScreen(active) { reason ->
// A game launched from a library exiting is a normal finish, and the player is
// almost certainly after the next title — so send them back to that library rather
// than all the way out to host selection. The console shell's own screen state does
// not survive the stream (StreamScreen replaces it in the composition, discarding
// its `remember`s), so the intent is hoisted here and handed back on the way in.
reopenLibraryHostId =
if (reason == SessionEndReason.GAME_EXITED && active.launchedFromLibrary) {
active.hostId
} else {
null
}
session = null
}
} else if (gamepadUi) {
GamepadShell(
settings = settings,
@@ -115,6 +144,8 @@ fun App(forceGamepadUi: Boolean = false) {
onConnected = { session = it },
deepLink = pendingLink,
onDeepLinkHandled = { activity?.pendingDeepLink = null },
reopenLibraryHostId = reopenLibraryHostId,
onReopenLibraryHandled = { reopenLibraryHostId = null },
)
} else {
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
@@ -201,8 +232,16 @@ fun App(forceGamepadUi: Boolean = false) {
}
}
}
}
}
/**
* The console backdrop's colour family for everything under [App] provided from the live
* settings so a change on the gamepad settings screen recolours every backdrop at once. Defaults
* to the brand violet, which is also what a preview or a test composition gets.
*/
val LocalGamepadPalette = compositionLocalOf { GamepadPalette.named("violet") }
/** Which console screen the gamepad shell is showing. */
private enum class GamepadScreen { Home, Settings, Library }
@@ -218,11 +257,32 @@ fun GamepadShell(
onConnected: (ActiveSession) -> Unit,
deepLink: String? = null,
onDeepLinkHandled: () -> Unit = {},
/**
* Open this saved host's library instead of Home on the way in set when a game launched from
* it has just exited. Null (the default) starts on Home exactly as before.
*/
reopenLibraryHostId: String? = null,
onReopenLibraryHandled: () -> Unit = {},
) {
val context = LocalContext.current
var screen by remember { mutableStateOf(GamepadScreen.Home) }
var libraryHost by remember { mutableStateOf<io.unom.punktfunk.kit.security.KnownHost?>(null) }
// Consume the "come back to this library" intent once, on entry. Keyed on the id so a second
// game exit re-fires it; the parent clears it immediately, so a manual Back stays backed out.
// A host that has since been forgotten simply leaves us on Home rather than failing.
LaunchedEffect(reopenLibraryHostId) {
val id = reopenLibraryHostId ?: return@LaunchedEffect
// Navigate BEFORE acknowledging: acknowledging clears the parent's state, which re-keys
// this effect and cancels the coroutine running it. Nothing suspends in between today, so
// either order happens to work — but this one cannot be broken by a later edit that adds a
// suspending call. A host that has since been forgotten just leaves us on Home.
KnownHostStore(context).all()
.firstOrNull { it.id == id }
?.let { libraryHost = it; screen = GamepadScreen.Library }
onReopenLibraryHandled()
}
// On a TV, shrink the 10-foot UI so its elements aren't oversized. Density-aware: expand the
// effective dp footprint to at least CONSOLE_TV_MIN_WIDTH_DP (→ smaller elements) ONLY when the
// panel reports fewer dp than that; a low-density TV that's already spacious, and every phone /
@@ -168,9 +168,9 @@ internal fun LocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onD
title = { Text("Allow local network access") },
text = {
Text(
"Android blocks punktfunk from talking to devices on your network, so it can't " +
"Android blocks Punktfunk from talking to devices on your network, so it can't " +
"find or reach any host until you allow it. If no prompt appears when you tap " +
"Allow, enable “Nearby devices” for punktfunk in system settings.",
"Allow, enable “Nearby devices” for Punktfunk in system settings.",
)
},
confirmButton = {
@@ -191,6 +191,7 @@ internal fun ConnectTakeover(
onCancel: () -> Unit,
onRetry: () -> Unit,
) {
val ink = LocalGamepadInk.current
val copy = connectCopy(phase)
val timedOut = phase is ConnectPhase.WakeTimedOut
@@ -212,7 +213,7 @@ internal fun ConnectTakeover(
Icon(
Icons.Filled.Bedtime,
contentDescription = null,
tint = Color.White.copy(alpha = 0.9f),
tint = ink.fg(0.9f),
modifier = Modifier.size(46.dp),
)
}
@@ -221,14 +222,14 @@ internal fun ConnectTakeover(
}
Text(
copy.title,
color = Color.White,
color = ink.fg,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
textAlign = TextAlign.Center,
)
Text(
copy.subtitle,
color = Color.White.copy(alpha = 0.65f),
color = ink.fg(0.65f),
fontSize = 14.sp,
textAlign = TextAlign.Center,
fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default,
@@ -249,6 +250,7 @@ internal fun ConnectTakeover(
*/
@Composable
private fun PulsingSpinner() {
val ink = LocalGamepadInk.current
val transition = rememberInfiniteTransition(label = "connectPulse")
val pulse by transition.animateFloat(
initialValue = 0f,
@@ -262,14 +264,14 @@ private fun PulsingSpinner() {
for (i in 0..1) {
val p = (pulse + i * 0.5f) % 1f
drawCircle(
color = Color(0xFF8678F5).copy(alpha = (1f - p) * 0.35f),
color = ink.accent.copy(alpha = (1f - p) * 0.35f),
radius = maxR * (0.42f + p * 0.58f),
style = Stroke(width = 2.dp.toPx()),
)
}
}
CircularProgressIndicator(
color = Color.White,
color = ink.fg,
strokeWidth = 3.dp,
modifier = Modifier.size(54.dp),
)
@@ -1,11 +1,14 @@
package io.unom.punktfunk
import android.Manifest
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
@@ -168,8 +171,7 @@ fun ConnectScreen(
lnpPrompt = false
// The browse started while blocked (its sockets failed or received nothing) — restart it
// now that the grant makes them work.
discovery.stop()
discovery.start()
discovery.restart()
} else {
lnpPrompt = true // rationale + "Open settings" (a permanently-denied request returns instantly)
}
@@ -191,12 +193,27 @@ fun ConnectScreen(
// or otherwise notify the app — this observer is what turns the grant into a live discovery.
DisposableEffect(Unit) {
val lifecycle = (context as? LifecycleOwner)?.lifecycle
// Whether we've actually been away. ON_RESUME also fires on first entry, right after the
// effect below starts the browse — restarting it there would be pure churn.
var wasPaused = false
val obs = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && !lnpGranted && hasLocalNetworkPermission(context)) {
lnpGranted = true
lnpPrompt = false
discovery.stop()
discovery.start()
when (event) {
Lifecycle.Event.ON_PAUSE -> wasPaused = true
Lifecycle.Event.ON_RESUME -> {
if (!lnpGranted && hasLocalNetworkPermission(context)) {
lnpGranted = true
lnpPrompt = false
discovery.restart()
} else if (wasPaused) {
// Coming back from the background: the browse may have been sitting idle
// (or had its multicast socket torn out from under it) while we were away,
// and its own re-query interval has kept doubling. Re-arm and ask again,
// so returning to the screen is enough — no app restart.
discovery.restart()
}
wasPaused = false
}
else -> {}
}
}
lifecycle?.addObserver(obs)
@@ -608,6 +625,32 @@ fun ConnectScreen(
savedHosts = knownHostStore.all()
}
// "Copy link" — the self-emitted form every other client already hands out
// (design/client-deep-links.md §4): the host's STABLE id first, with `host=` and `fp=` alongside,
// so a link written today still lands on the right box after the host changes address or this
// client is reinstalled. A PINNED card copies its own profile with it, because that combination
// is the thing being copied; a host card copies no profile at all and so keeps honouring the
// host's binding, exactly like a tap on it does.
fun copyLink(kh: KnownHost, pin: StreamProfile?) {
val url = DeepLinks.forHost(kh, profile = pin?.id).toUrl()
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
val copied = clipboard != null && runCatching {
clipboard.setPrimaryClip(ClipData.newPlainText("Punktfunk link", url))
}.isSuccess
// Android 13 draws its own clipboard confirmation, and stacking a second one on top of it is
// the platform's own documented anti-pattern. Below it nothing visible happens at all unless
// we say so — a silent menu item reads as a broken one.
if (copied && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
val message = if (copied) "Link copied." else "Couldn't copy the link to the clipboard."
// The console home renders neither the notice nor the status banner, so there it has to be a
// toast; the touch grid has both, and a success dressed as an error banner is a small lie.
when {
gamepadUi -> Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
copied -> notice = message
else -> status = message
}
}
// The profile rows a card's overflow menu grows. With no profiles at all it stays empty — a
// user who never wants this feature sees no new clutter anywhere but the settings scope chips.
// "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding
@@ -616,6 +659,7 @@ fun ConnectScreen(
if (pin == null) {
add(HostMenuItem("Network speed test") { startSpeedTest(HostCardEntry(kh, null)) })
}
add(HostMenuItem("Copy link") { copyLink(kh, pin) })
if (profiles.isEmpty()) return@buildList
if (pin != null) {
add(HostMenuItem("Unpin card", startsSection = true) { togglePin(kh, pin) })
@@ -897,7 +941,7 @@ fun ConnectScreen(
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
"Android blocks punktfunk from finding or reaching hosts until you allow it.",
"Android blocks Punktfunk from finding or reaching hosts until you allow it.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
textAlign = TextAlign.Center,
@@ -1009,20 +1053,28 @@ fun ConnectScreen(
// rather than looking idle/empty. Suppressed while local network access is denied —
// a spinner would be a lie there (the browse can't receive anything); the banner above
// owns that state.
if (lnpGranted && !connecting && discovered.isEmpty()) {
// Scan again is offered whether or not anything turned up: the case that sends people
// here is ONE expected host missing, not an empty list, and a browse that quietly went
// deaf (blocked when it started, or backed off to its hour-long re-query) looks
// exactly like a network without that host on it.
if (lnpGranted && !connecting) {
item(span = { GridItemSpan(maxLineSpan) }) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
Text(
"Searching the local network…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (discovered.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
Text(
"Searching the local network…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
}
TextButton(onClick = { discovery.restart() }) { Text("Scan again") }
}
}
}
@@ -1140,6 +1192,7 @@ fun ConnectScreen(
} else {
null
},
onCopyLink = { optionsTarget = null; copyLink(kh, pin) },
onEdit = { optionsTarget = null; editTarget = kh },
onForget = {
knownHostStore.remove(kh)
@@ -191,7 +191,7 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
dsUsb?.let { DsRow(it) }
if (pads.isEmpty() && !sc2Present) {
Text(
"No controller detected. punktfunk can only forward devices Android " +
"No controller detected. Punktfunk can only forward devices Android " +
"classifies as a gamepad or joystick — a pad connected through an adapter " +
"or hub may show up under \"Other input devices\" below with the adapter's " +
"identity, or not at all.",
@@ -410,17 +410,68 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
Text("Grant USB access")
}
}
else -> Text(
if (model == DsDevice.Model.DUALSHOCK4) {
"Ready — captured at stream start: rumble, lightbar and gyro are " +
"driven directly."
} else {
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
"and gyro are driven directly."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> {
Text(
if (model == DsDevice.Model.DUALSHOCK4) {
"Ready — captured at stream start: rumble, lightbar and gyro are " +
"driven directly."
} else {
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
"and gyro are driven directly."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Pad-audio self test. Deliberately reachable WITHOUT a stream: it exists to
// answer "can this phone drive this pad's audio endpoint at all", and gating
// that behind a live session would make it depend on the very thing one wants
// to rule out when a session misbehaves. DualSense only — the DS4 has no
// 4-channel haptics device.
if (model != DsDevice.Model.DUALSHOCK4) {
var testing by remember { mutableStateOf(false) }
var result by remember { mutableStateOf<String?>(null) }
result?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedButton(
enabled = !testing,
onClick = {
testing = true
result = null
Thread({
// Its OWN connection: the renderer's descriptor must never be
// shared with another transfer engine, and that applies to
// this test as much as to the real path.
val conn = runCatching { usbManager.openDevice(usbDev) }.getOrNull()
val fd = conn?.fileDescriptor ?: -1
val r = if (fd >= 0) {
io.unom.punktfunk.kit.NativeBridge.nativePadAudioSelfTest(fd, 3, 60)
} else {
-1
}
conn?.close()
val msg = when {
r > 0 -> "Haptics test passed — $r frames to the pad."
r == -1 -> "Could not open the pad's audio interface. " +
"Some kernels refuse it; the pad still works normally."
r == -2 -> "The audio stream stopped part-way."
else -> "The stream opened but no audio reached the pad."
}
android.os.Handler(android.os.Looper.getMainLooper()).post {
result = msg
testing = false
}
}, "pf-pad-selftest-ui").start()
},
) {
Text(if (testing) "Testing…" else "Test haptics")
}
}
}
}
}
}
@@ -79,6 +79,7 @@ fun GamepadAddHostScreen(
suggestedMacs: List<String> = emptyList(),
onSave: ((KnownHost) -> Unit)? = null,
) {
val ink = LocalGamepadInk.current
val context = LocalContext.current
val isTv = remember { isTvDevice(context) }
val isEdit = editHost != null
@@ -245,7 +246,7 @@ fun GamepadAddHostScreen(
Text(
"Hosts on this network appear automatically — add one by address for everything else.",
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.55f),
color = ink.fg(0.55f),
modifier = Modifier.widthIn(max = 520.dp).padding(bottom = 8.dp),
)
}
@@ -306,6 +307,7 @@ private fun TvAddHostForm(
onAdd: () -> Unit,
onDismiss: () -> Unit,
) {
val ink = LocalGamepadInk.current
BackHandler(onBack = onDismiss)
val firstFocus = remember { FocusRequester() }
Box(Modifier.fillMaxSize()) {
@@ -319,11 +321,11 @@ private fun TvAddHostForm(
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = Color.White)
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = ink.fg)
Text(
"Hosts on this network appear automatically — add one by address for everything else.",
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.55f),
color = ink.fg(0.55f),
)
OutlinedTextField(
value = name, onValueChange = onName, singleLine = true,
@@ -362,6 +364,7 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row]
@Composable
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused || editing, editing = editing)
val shape = RoundedCornerShape(14.dp)
Row(
@@ -375,25 +378,26 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = Color.White)
Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = ink.fg)
Spacer(Modifier.weight(1f))
Text(
f.value.ifEmpty { f.placeholder },
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = if (f.value.isEmpty()) Color.White.copy(alpha = 0.35f) else Color.White,
color = if (f.value.isEmpty()) ink.fg(0.35f) else ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (editing) Text(" |", color = Color(0xFF8678F5))
if (editing) Text(" |", color = ink.accent)
}
}
@Composable
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp)
val labelColor by animateColorAsState(
if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
if (enabled) ink.accent else ink.fg(0.35f),
tween(160),
label = "addLabel",
)
@@ -425,6 +429,7 @@ private fun KeyboardGrid(
bottomInset: Dp = 0.dp, // empty frame at the bottom of the glass for the floating legend to sit over
onKey: (Int, Int) -> Unit,
) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(20.dp)
val gap = if (compact) 5.dp else 7.dp
Column(
@@ -433,7 +438,7 @@ private fun KeyboardGrid(
.widthIn(max = 640.dp)
.clip(shape)
.background(Color(0x1FFFFFFF))
.border(1.dp, Color.White.copy(alpha = 0.12f), shape)
.border(1.dp, ink.fg(0.12f), shape)
.padding(start = 12.dp, end = 12.dp, top = if (compact) 8.dp else 12.dp, bottom = 12.dp + bottomInset),
verticalArrangement = Arrangement.spacedBy(gap),
) {
@@ -454,14 +459,15 @@ private fun KeyboardGrid(
@Composable
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
// Fast tweens: the keyboard cursor hops many keys per second under hold-to-repeat, so the
// trailing key must have faded before the cursor is two keys away — quick, but no longer a snap.
val bg by animateColorAsState(
if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF),
if (focused) ink.accent else ink.glass,
tween(90),
label = "keyBg",
)
val fg by animateColorAsState(if (focused) Color.Black else Color.White, tween(90), label = "keyFg")
val fg by animateColorAsState(if (focused) Color.Black else ink.fg, tween(90), label = "keyFg")
Box(
modifier = modifier
.height(if (compact) 34.dp else 44.dp)
@@ -14,6 +14,8 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
@@ -23,6 +25,9 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -31,7 +36,9 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -65,9 +72,12 @@ import kotlin.math.sin
// connected-controller status chip. One look across every screen is what makes the console UI read
// as a coherent mode rather than a set of themed pages.
/** One drifting colour blob of the aurora field. Integer [sx]/[sy] keep the loop seamless at wrap. */
/**
* One drifting blob of the aurora field: where it sits, how far it wanders, and how fast. Integer
* [sx]/[sy] keep the loop seamless at wrap. The COLOUR is the palette's, taken from its ramp at
* draw time, so the field always shows several of that palette's tones at once.
*/
private class AuroraBlob(
val color: Color,
val baseX: Float,
val baseY: Float,
val driftX: Float,
@@ -80,50 +90,80 @@ private class AuroraBlob(
)
private val auroraBlobs = listOf(
AuroraBlob(Color(0xFF877AF5), 0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f), // brand violet
AuroraBlob(Color(0xFF3E33B8), 0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f), // deep indigo
AuroraBlob(Color(0xFF9E4CCC), 0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f), // plum
AuroraBlob(Color(0xFF3862DB), 0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), // cool blue
AuroraBlob(0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f),
AuroraBlob(0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f),
AuroraBlob(0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f),
AuroraBlob(0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f),
)
/**
* The living console backdrop: soft violet-family blobs drifting over black on slow, seamless loops,
* finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose approximation
* of the Apple client's MeshGradient aurora same brand family, same "ambience, never content" role.
* The living console backdrop: soft blobs from the palette's ramp drifting over its ground on
* slow, seamless loops, finished with a centre-pooling vignette and top/bottom legibility scrims.
* A Compose approximation of the Apple client's MeshGradient aurora same colour families, same
* "ambience, never content" role, and the same [GamepadPalette] setting recolours both.
*
* [calm] is what the FORM screens wear: the pools dim onto the ground so the glass rows keep real
* colour and luminance without the launcher's contrast. Motion is identical either way on purpose
* only the contrast differs, so moving between screens can't make the field jump.
*
* Honours the system's "remove animations" accessibility setting by freezing at a fixed phase, the
* same courtesy the Apple client pays Reduce Motion.
*/
@Composable
fun GamepadAuroraBackground(modifier: Modifier = Modifier) {
fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false) {
val ink = LocalGamepadInk.current
val palette = LocalGamepadPalette.current
val animated = animationsEnabled()
val transition = rememberInfiniteTransition(label = "aurora")
// A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the wrap
// so the field never visibly jumps when the animation restarts.
val angle by transition.animateFloat(
// A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the
// wrap so the field never visibly jumps when the animation restarts.
val swept by transition.animateFloat(
initialValue = 0f,
targetValue = (2 * PI).toFloat(),
animationSpec = infiniteRepeatable(tween(96_000, easing = LinearEasing), RepeatMode.Restart),
label = "angle",
)
val angle = if (animated) swept else 0f
val tones = palette.blobColors
val ground = palette.groundColor
// Where the scrims tend, and how hard. Mixing a PALE field toward white at the dark field's
// strength bleaches the chroma straight out of the gradient, so a pale palette gets under
// half — the same scrim strength the desktop console's shader carries.
val scrim = if (palette.light) ink.fg else Color.Black
val strength = if (palette.light) 0.45f else 1f
Canvas(modifier) {
drawRect(Color.Black)
drawRect(ground)
val span = max(size.width, size.height)
for (b in auroraBlobs) {
for ((i, b) in auroraBlobs.withIndex()) {
val cx = (b.baseX + b.driftX * sin(angle * b.sx + b.phase)) * size.width
val cy = (b.baseY + b.driftY * cos(angle * b.sy + b.phase)) * size.height
val r = span * b.radiusFrac
// Calm scales each blob's contribution rather than dimming the whole canvas: the
// ground stays put and only the pools come down to meet it, which is the same "lower
// the contrast, keep the colour" the desktop console's `calm` uniform does.
val alpha = if (calm) b.alpha * 0.62f else b.alpha
drawCircle(
brush = Brush.radialGradient(
colors = listOf(b.color.copy(alpha = b.alpha), Color.Transparent),
colors = listOf(tones[i].copy(alpha = alpha), Color.Transparent),
center = Offset(cx, cy),
radius = r,
),
center = Offset(cx, cy),
radius = r,
blendMode = BlendMode.Plus,
// Additive only works over a DARK ground; over a pale one every blob
// saturates to white and the field turns grey. Pale palettes tint instead.
blendMode = if (palette.light) BlendMode.SrcOver else BlendMode.Plus,
)
}
// Cinematic vignette: pool light centre, sink the corners.
// Cinematic vignette: pool light centre, settle the corners toward the scrim. Halved under
// calm: a launcher's cards sit in the pooled centre, but a form screen's rows run out
// toward the edges, where crushing them just eats the list.
drawRect(
Brush.radialGradient(
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.44f)),
colors = listOf(
Color.Transparent,
scrim.copy(alpha = (if (calm) 0.22f else 0.44f) * strength),
),
center = Offset(size.width / 2, size.height / 2),
radius = span * 0.92f,
),
@@ -131,43 +171,108 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier) {
// Top/bottom legibility scrim for the pinned title + hint bar.
drawRect(
Brush.verticalGradient(
0.0f to Color.Black.copy(alpha = 0.40f),
0.30f to Color.Black.copy(alpha = 0.05f),
0.70f to Color.Black.copy(alpha = 0.06f),
1.0f to Color.Black.copy(alpha = 0.42f),
0.0f to scrim.copy(alpha = 0.40f * strength),
0.30f to scrim.copy(alpha = 0.05f * strength),
0.70f to scrim.copy(alpha = 0.06f * strength),
1.0f to scrim.copy(alpha = 0.42f * strength),
),
)
}
}
/**
* The calm backdrop for the console FORM screens (settings, add-host) deliberately still and quiet
* (unlike the launcher's drifting aurora), a deep indigo base with two soft brand glows so the glass
* rows have some colour + luminance to sit on. Mirrors the Apple client's GamepadFormBackground.
* `false` when the user has turned animations off system-wide (Developer options' animator duration
* scale, or the accessibility "Remove animations" switch, which sets the same global). Read once
* per composition it needs a settings trip to the system, and it changes about never.
*/
@Composable
private fun animationsEnabled(): Boolean {
val context = LocalContext.current
return remember {
runCatching {
android.provider.Settings.Global.getFloat(
context.contentResolver,
android.provider.Settings.Global.ANIMATOR_DURATION_SCALE,
1f,
) != 0f
}.getOrDefault(true)
}
}
/**
* The backdrop for the console FORM screens (settings, add-host). It used to be a STILL deep-indigo
* base with two soft glows; it is now the launcher's own living field at `calm`, which keeps that
* colour and luminance under the glass rows, honours the palette setting on every screen rather
* than only the launcher, and leaves nothing in the console UI backed by a static image. Mirrors
* the Apple client's GamepadFormBackground, which made the same substitution.
*/
@Composable
fun GamepadFormBackground(modifier: Modifier = Modifier) {
Canvas(modifier) {
val span = max(size.width, size.height)
drawRect(Color(0xFF131126))
drawCircle(
brush = Brush.radialGradient(
colors = listOf(Color(0xE6635AAE), Color.Transparent),
center = Offset(size.width * 0.24f, size.height * 0.12f),
radius = span * 0.7f,
),
center = Offset(size.width * 0.24f, size.height * 0.12f),
radius = span * 0.7f,
)
drawCircle(
brush = Brush.radialGradient(
colors = listOf(Color(0xBF343E96), Color.Transparent),
center = Offset(size.width * 0.82f, size.height * 0.9f),
radius = span * 0.7f,
),
center = Offset(size.width * 0.82f, size.height * 0.9f),
radius = span * 0.7f,
)
GamepadAuroraBackground(modifier, calm = true)
}
/**
* The horizontal section switcher above a console list. Purely presentational the SCREEN owns
* which tab is selected and what the shoulders do. Scrollable so a narrow phone in landscape never
* has to squeeze the pills, and the selected one is always brought into view whether it was reached
* by shoulder button or tap.
*/
@Composable
fun ConsoleTabStrip(
titles: List<String>,
selected: Int,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier,
/**
* The strip itself holds the cursor (the caller moved focus UP out of its list). Draws a ring
* on the selected pill so it's clear left/right now walks sections rather than values the
* route a D-pad remote, which has no shoulder buttons, needs.
*/
focused: Boolean = false,
) {
val ink = LocalGamepadInk.current
val listState = rememberLazyListState()
LaunchedEffect(selected) {
runCatching { listState.animateScrollToItem(selected.coerceAtLeast(0)) }
}
LazyRow(
state = listState,
modifier = modifier,
contentPadding = PaddingValues(horizontal = ConsoleEdgeInset),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
itemsIndexed(titles) { i, title ->
val active = i == selected
val background by animateColorAsState(
if (active) ink.accent(0.85f) else ink.glass,
tween(180),
label = "tabBg",
)
// Not `ink` — that name is the palette's, and shadowing it here cost a compile.
val labelColor by animateColorAsState(
if (active) ink.onAccent else ink.fg(0.55f),
tween(180),
label = "tabInk",
)
val ring by animateColorAsState(
ink.fg(if (active && focused) 0.85f else 0f),
tween(180),
label = "tabRing",
)
Text(
title,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = labelColor,
maxLines = 1,
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(background)
.border(1.5.dp, ring, RoundedCornerShape(50))
.clickable { onSelect(i) }
.padding(horizontal = 14.dp, vertical = 7.dp),
)
}
}
}
@@ -176,7 +281,7 @@ fun GamepadFormBackground(modifier: Modifier = Modifier) {
* sits in the SAME spot across Home / Settings / Add-Host and appears pinned while the content behind
* it cross-fades between screens.
*/
val ConsoleLegendInset = PaddingValues(start = 24.dp, bottom = 24.dp)
val ConsoleLegendInset = PaddingValues(start = 24.dp, end = 24.dp, bottom = 24.dp)
/** The shared horizontal inset for a console screen's heading (matches the legend's left edge). */
val ConsoleEdgeInset = 24.dp
@@ -187,6 +292,7 @@ val ConsoleEdgeInset = 24.dp
*/
@Composable
fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset: Boolean = true) {
val ink = LocalGamepadInk.current
// `horizontalInset = false` when the caller's container already pads to ConsoleEdgeInset (e.g. a
// LazyColumn contentPadding) — so the heading lands at the SAME 24dp on every screen either way.
val h = if (horizontalInset) ConsoleEdgeInset else 0.dp
@@ -194,7 +300,7 @@ fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset:
title,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = Color.White,
color = ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier.padding(start = h, end = h, top = 18.dp, bottom = 10.dp),
@@ -251,21 +357,22 @@ class ConsoleFocusVisuals(val scale: Float, val background: Color, val border: C
*/
@Composable
fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocusVisuals {
val ink = LocalGamepadInk.current
val scale by animateFloatAsState(
targetValue = if (active) 1f else 0.98f,
animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
label = "consoleScale",
)
val background by animateColorAsState(
if (active) Color(0x336656F2) else Color(0x14FFFFFF),
if (active) ink.accent(0.20f) else ink.glass,
tween(160),
label = "consoleBg",
)
val border by animateColorAsState(
when {
editing -> Color(0xB38678F5)
active -> Color.White.copy(alpha = 0.28f)
else -> Color.White.copy(alpha = 0.06f)
editing -> ink.accent(0.70f)
active -> ink.fg(0.28f)
else -> ink.fg(0.06f)
},
tween(160),
label = "consoleBorder",
@@ -280,18 +387,19 @@ fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocus
*/
@Composable
fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) {
val ink = LocalGamepadInk.current
val travel by animateFloatAsState(
targetValue = if (on) 1f else 0f,
animationSpec = spring(dampingRatio = 0.8f, stiffness = 600f),
label = "switchKnob",
)
val track by animateColorAsState(
if (on) Color(0xFF6656F2) else Color(0x26FFFFFF),
if (on) ink.accent else Color(0x26FFFFFF),
tween(200),
label = "switchTrack",
)
val outline by animateColorAsState(
Color.White.copy(alpha = if (focused) 0.45f else 0.15f),
ink.fg(if (focused) 0.45f else 0.15f),
tween(160),
label = "switchOutline",
)
@@ -313,7 +421,7 @@ fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier)
.offset { IntOffset(((trackW - knob - pad * 2).toPx() * travel).roundToInt(), 0) }
.size(knob)
.clip(CircleShape)
.background(Color.White),
.background(ink.fg),
)
}
}
@@ -321,6 +429,7 @@ fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier)
/** A round face-button badge: a coloured disc with the button letter, like a controller's face. */
@Composable
fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) {
val ink = LocalGamepadInk.current
Box(
modifier = Modifier
.size(size)
@@ -330,7 +439,7 @@ fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit
) {
Text(
glyph.toString(),
color = Color.White,
color = ink.fg,
fontWeight = FontWeight.Bold,
fontSize = (size.value * 0.52f).sp,
textAlign = TextAlign.Center,
@@ -341,11 +450,12 @@ fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit
/** The D-pad-centre "select" button — a green (confirm) disc with a ring; the TV-remote glyph for A. */
@Composable
private fun SelectGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
val ink = LocalGamepadInk.current
Box(
modifier = Modifier.size(size).clip(CircleShape).background(PadGlyph.A),
contentAlignment = Alignment.Center,
) {
Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, Color.White, CircleShape))
Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, ink.fg, CircleShape))
}
}
@@ -410,6 +520,7 @@ internal fun PsFaceGlyph(glyph: Char, size: androidx.compose.ui.unit.Dp = 26.dp)
*/
@Composable
internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.ui.unit.Dp = 26.dp) {
val ink = LocalGamepadInk.current
Box(
Modifier.size(size).clip(CircleShape).background(PadButtonFace),
contentAlignment = Alignment.Center,
@@ -421,17 +532,17 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u
val corner = RoundedCornerShape(2.dp)
Box(
Modifier.size(size * 0.32f).align(Alignment.TopEnd)
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
.border(1.4.dp, ink.fg(0.9f), corner),
)
Box(
Modifier.size(size * 0.32f).align(Alignment.BottomStart)
.clip(corner).background(PadButtonFace)
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
.border(1.4.dp, ink.fg(0.9f), corner),
)
}
Gamepad.PadStyle.NINTENDO -> Text(
"",
color = Color.White,
color = ink.fg,
fontWeight = FontWeight.Bold,
fontSize = (size.value * 0.62f).sp,
textAlign = TextAlign.Center,
@@ -440,7 +551,7 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u
Modifier
.size(width = size * 0.58f, height = size * 0.30f)
.clip(RoundedCornerShape(50))
.border(1.6.dp, Color.White.copy(alpha = 0.9f), RoundedCornerShape(50)),
.border(1.6.dp, ink.fg(0.9f), RoundedCornerShape(50)),
)
}
}
@@ -452,6 +563,7 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u
*/
@Composable
fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, hazeState: HazeState? = null) {
val ink = LocalGamepadInk.current
// On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses:
// A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the
// home's Up/Down handle themselves. A real pad instead picks its glyph FAMILY (Xbox letters /
@@ -464,14 +576,19 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
// With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent
// scrim below) + a light tint; otherwise fall back to a solid frosted fill.
val frosted = if (hazeState != null) {
modifier.clip(shape).hazeEffect(hazeState).background(Color(0x4014122A))
modifier.clip(shape).hazeEffect(hazeState).background(ink.shade(0.25f))
} else {
modifier.clip(shape).background(Color(0x8C14122A))
modifier.clip(shape).background(ink.shade(0.55f))
}
Row(
modifier = frosted
.border(1.dp, Color.White.copy(alpha = 0.14f), shape)
.padding(horizontal = 16.dp, vertical = 10.dp),
.border(1.dp, ink.fg(0.14f), shape)
.padding(horizontal = 16.dp, vertical = 10.dp)
// The pill still hugs its content when it fits; when it doesn't (a narrow phone, or a
// screen whose legend grew a cell) it scrolls rather than running off the edge and
// silently eating the last hint — which is exactly what the settings screen's new
// Section cell did on a 360 dp phone.
.horizontalScroll(rememberScrollState()),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(11.dp),
) {
@@ -497,7 +614,7 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
Text(
h.text,
style = MaterialTheme.typography.labelLarge,
color = Color.White.copy(alpha = 0.9f),
color = ink.fg(0.9f),
maxLines = 1,
softWrap = false, // never char-wrap a label when several hints crowd a narrow pill
)
@@ -509,24 +626,25 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
/** "Which pad is driving this UI" — a quiet chip in the console top bar with the controller's name. */
@Composable
fun ControllerStatusChip(name: String, modifier: Modifier = Modifier) {
val ink = LocalGamepadInk.current
Row(
modifier = modifier
.clip(RoundedCornerShape(50))
.background(Color.White.copy(alpha = 0.08f))
.background(ink.fg(0.08f))
.padding(horizontal = 12.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.SportsEsports,
contentDescription = null,
tint = Color.White.copy(alpha = 0.75f),
tint = ink.fg(0.75f),
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(7.dp))
Text(
name,
style = MaterialTheme.typography.labelMedium,
color = Color.White.copy(alpha = 0.75f),
color = ink.fg(0.75f),
maxLines = 1,
)
}
@@ -85,6 +85,7 @@ fun GamepadDialog(
actions: List<DialogAction>,
body: @Composable ColumnScope.() -> Unit,
) {
val ink = LocalGamepadInk.current
// Focus the primary action; buttons are stacked full-width, navigated up/down (fits long labels
// like "Request access" without the cramped-row wrapping a horizontal layout caused).
var focus by remember { mutableIntStateOf(actions.indexOfFirst { it.primary }.coerceAtLeast(0)) }
@@ -117,11 +118,11 @@ fun GamepadDialog(
.heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
.border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg)
Column(
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
@@ -139,6 +140,7 @@ fun GamepadDialog(
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val scale by animateFloatAsState(
if (focused) 1.02f else 1f,
spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
@@ -152,19 +154,19 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab
// Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping.
val bg by animateColorAsState(
when {
focused -> Color(0xFF6656F2)
primary -> Color(0x336656F2)
else -> Color(0x14FFFFFF)
focused -> ink.accent
primary -> ink.accent(0.20f)
else -> ink.glass
},
tween(160),
label = "btnBg",
)
val fg by animateColorAsState(
when {
!enabled -> Color.White.copy(alpha = 0.35f)
focused -> Color.White
primary -> Color(0xFF8678F5)
else -> Color.White.copy(alpha = 0.85f)
!enabled -> ink.fg(0.35f)
focused -> ink.fg
primary -> ink.accent
else -> ink.fg(0.85f)
},
tween(160),
label = "btnFg",
@@ -198,13 +200,14 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab
/** Body text helper — a dimmed paragraph. */
@Composable
private fun DialogText(text: String) {
Text(text, style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f))
val ink = LocalGamepadInk.current
Text(text, style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f))
}
/**
* Console host options for a saved tile Wake (offered only when offline + a MAC is known), Edit,
* Forget. Reached by pressing Up on a focused saved host in the carousel; the console counterpart of
* the touch host card's overflow menu.
* Console host options for a saved tile Wake (offered only when offline + a MAC is known), Copy
* link, Edit, Forget. Reached by pressing Up on a focused saved host in the carousel; the console
* counterpart of the touch host card's overflow menu.
*/
@Composable
fun GamepadHostOptionsDialog(
@@ -214,6 +217,12 @@ fun GamepadHostOptionsDialog(
onLibrary: (() -> Unit)?, // non-null when the game library is enabled → reachable without Y
onEdit: () -> Unit,
onForget: () -> Unit,
/**
* Copy this tile's `punktfunk://` link. Offered on a pinned tile too — unlike the host's other
* actions it says nothing about the host, it hands out the shortcut this very tile already is
* (profile included), which is exactly what a pin is for.
*/
onCopyLink: () -> Unit,
onDismiss: () -> Unit,
onSpeedTest: (() -> Unit)? = null,
/**
@@ -230,12 +239,14 @@ fun GamepadHostOptionsDialog(
actions = buildList {
if (onUnpin != null) {
add(DialogAction("Unpin card", primary = true, onClick = onUnpin))
add(DialogAction("Copy link", onClick = onCopyLink))
add(DialogAction("Cancel", onClick = onDismiss))
return@buildList
}
if (onLibrary != null) add(DialogAction("Library", primary = true, onClick = onLibrary))
if (canWake) add(DialogAction("Wake host", onClick = onWake))
if (onSpeedTest != null) add(DialogAction("Network speed test", onClick = onSpeedTest))
add(DialogAction("Copy link", onClick = onCopyLink))
add(DialogAction("Edit…", primary = onLibrary == null, onClick = onEdit))
add(DialogAction("Forget", onClick = onForget))
add(DialogAction("Cancel", onClick = onDismiss))
@@ -271,6 +282,7 @@ fun GamepadPinHostsDialog(
onToggle: (KnownHost) -> Unit,
onDismiss: () -> Unit,
) {
val ink = LocalGamepadInk.current
// 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS
// Done, so it starts focused).
var focus by remember { mutableIntStateOf(0) }
@@ -304,7 +316,7 @@ fun GamepadPinHostsDialog(
.heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
.border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
@@ -312,7 +324,7 @@ fun GamepadPinHostsDialog(
"Pin “$profileName",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = Color.White,
color = ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -350,6 +362,7 @@ fun GamepadPinHostsDialog(
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused)
// Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short
// landscape window pulls itself into view.
@@ -376,7 +389,7 @@ private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: ()
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = Color.White,
color = ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -450,11 +463,11 @@ fun GamepadLocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDis
),
) {
DialogText(
"Android blocks punktfunk from talking to devices on your network, so it can't find " +
"Android blocks Punktfunk from talking to devices on your network, so it can't find " +
"or reach any host until you allow it.",
)
DialogText(
"If no prompt appears after Allow, enable “Nearby devices” for punktfunk in " +
"If no prompt appears after Allow, enable “Nearby devices” for Punktfunk in " +
"system settings.",
)
}
@@ -518,6 +531,7 @@ fun GamepadRequestAccessDialog(pt: PendingTrust, onRequestAccess: () -> Unit, on
@Composable
fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
val ink = LocalGamepadInk.current
GamepadDialog(
title = "Waiting for approval",
onDismiss = onCancel,
@@ -525,8 +539,8 @@ fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
) {
val deviceName = Build.MODEL ?: "this device"
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.White)
Text("Approve this device on $hostLabel.", color = Color.White)
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = ink.fg)
Text("Approve this device on $hostLabel.", color = ink.fg)
}
DialogText(
"Open the host's console (or web UI) and approve “$deviceName”. It connects automatically " +
@@ -542,6 +556,7 @@ fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
*/
@Composable
fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: (String) -> Unit, onDismiss: () -> Unit) {
val ink = LocalGamepadInk.current
val scope = rememberCoroutineScope()
val digits = remember(pt) { mutableStateListOf(0, 0, 0, 0) }
var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button
@@ -587,16 +602,16 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
Column(
Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730)).border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
.background(Color(0xF01A1730)).border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
.verticalScroll(rememberScrollState())
.padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg)
Text(
"Enter the 4-digit PIN shown on the host — D-pad ↑↓ sets a digit, ←→ moves.",
style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f), textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f), textAlign = TextAlign.Center,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) }
@@ -615,13 +630,14 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
@Composable
private fun PinSlot(value: Int, focused: Boolean) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(12.dp)
Box(
Modifier.size(54.dp, 66.dp).clip(shape)
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
.border(if (focused) 2.dp else 1.dp, if (focused) Color(0xFF8678F5) else Color.White.copy(alpha = 0.1f), shape),
.background(if (focused) ink.accent(0.20f) else ink.glass)
.border(if (focused) 2.dp else 1.dp, if (focused) ink.accent else ink.fg(0.1f), shape),
contentAlignment = Alignment.Center,
) {
Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = Color.White, fontFamily = FontFamily.Monospace)
Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = ink.fg, fontFamily = FontFamily.Monospace)
}
}
@@ -247,9 +247,10 @@ fun GamepadHome(
/** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */
@Composable
private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(26.dp)
val wash = if (tile.filled) {
Brush.verticalGradient(listOf(Color(0x336656F2), Color(0x14100C2A)))
Brush.verticalGradient(listOf(ink.accent(0.20f), Color(0x14100C2A)))
} else {
Brush.verticalGradient(listOf(Color(0x1AFFFFFF), Color(0x0DFFFFFF)))
}
@@ -258,7 +259,7 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
.fillMaxWidth()
.clip(shape)
.background(wash)
.border(1.dp, Color.White.copy(alpha = 0.16f), shape)
.border(1.dp, ink.fg(0.16f), shape)
.padding(22.dp),
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
@@ -269,7 +270,7 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
Icon(
Icons.Filled.Lock,
contentDescription = "Paired",
tint = Color.White.copy(alpha = 0.7f),
tint = ink.fg(0.7f),
modifier = Modifier.padding(end = 6.dp).size(15.dp),
)
}
@@ -286,14 +287,14 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
tile.title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = Color.White,
color = ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
tile.subtitle,
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.55f),
color = ink.fg(0.55f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -302,9 +303,10 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
@Composable
private fun MonogramBadge(tile: HomeTile) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(15.dp)
val fill = if (tile.filled) {
Brush.verticalGradient(listOf(Color(0xFF6656F2), Color(0xFF8678F5)))
Brush.verticalGradient(listOf(ink.accent, ink.accent))
} else {
Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2)))
}
@@ -316,18 +318,18 @@ private fun MonogramBadge(tile: HomeTile) {
tile.connecting -> CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = Color.White,
color = ink.fg,
)
tile.isAdd -> Icon(
Icons.Filled.Add,
contentDescription = null,
tint = if (tile.filled) Color.White else Color(0xFF8678F5),
tint = if (tile.filled) ink.fg else ink.accent,
)
else -> Text(
tile.title.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = if (tile.filled) Color.White else Color(0xFF8678F5),
color = if (tile.filled) ink.fg else ink.accent,
)
}
}
@@ -0,0 +1,89 @@
package io.unom.punktfunk
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
// The ink the console (gamepad) UI draws with under the chosen background palette.
//
// The console screens were white-on-dark throughout with the brand violet hardcoded as the accent.
// Both had to become palette-derived at once: a pale field needs dark text or it is unreadable,
// and a violet focus wash on a copper field is exactly the clash this exists to fix.
//
// Published as a CompositionLocal rather than passed down, so a leaf (a row, a hint pill, a card)
// can ask for the right colour without every caller in between knowing about palettes. The Apple
// client uses an environment value and `pf-console-ui` a thread-local for the same reason.
/** Everything about the console's look that follows the chosen palette. */
class GamepadInk(
/** Primary text/glyph colour. */
val fg: Color,
/** Focus wash, selected tab pill, switch track — the palette's own accent. */
val accent: Color,
/** What reads ON the accent (a filled pill's label, a switch knob). */
val onAccent: Color,
/** The base fill every glass surface starts from, at its resting opacity. */
val glass: Color,
/** What a wash laid UNDER text tends toward: black on a dark field, white on a pale one. */
val shade: Color,
/**
* How hard those washes go. A pale field needs far less mixing toward white at the dark
* field's strength bleaches the chroma straight out of the gradient.
*/
val shadeScale: Float,
/** True when the field is pale, for the few places that branch rather than blend. */
val isLight: Boolean,
) {
/** The foreground at [alpha]. */
fun fg(alpha: Float): Color = fg.copy(alpha = alpha)
/** The accent at [alpha]. */
fun accent(alpha: Float): Color = accent.copy(alpha = alpha)
/** A wash under text: [alpha] is the dark-field strength, scaled for a pale one. */
fun shade(alpha: Float): Color = shade.copy(alpha = alpha * shadeScale)
companion object {
fun of(p: GamepadPalette): GamepadInk {
val accent = p.accentColor
// Chosen by luminance, not by `light`: an accent is picked for contrast against the
// GLASS, not against the field.
val accentLuma =
0.2126 * p.accent.first + 0.7152 * p.accent.second + 0.0722 * p.accent.third
val onAccent = if (accentLuma > 0.55) Color.Black else Color.White
if (!p.light) {
return GamepadInk(
fg = Color.White,
accent = accent,
onAccent = onAccent,
glass = Color.White.copy(alpha = 0.08f),
shade = Color.Black,
shadeScale = 1f,
isLight = false,
)
}
val (gr, gg, gb) = p.ground
return GamepadInk(
// Tinted toward the palette's own ground so it doesn't read as a foreign grey.
fg = Color((gr * 0.16).toFloat(), (gg * 0.14).toFloat(), (gb * 0.20).toFloat()),
accent = accent,
onAccent = onAccent,
// More body than the dark glass carries: white frost over a bright gradient has
// far less separating it from its backdrop than dark glass over a dark one.
glass = Color.White.copy(alpha = 0.55f),
shade = Color.White,
shadeScale = 0.45f,
isLight = true,
)
}
/** The shipped dark look — what a preview or a test composition gets. */
val DARK = of(GamepadPalette.named("violet"))
}
}
/**
* The ink of the palette currently drawing, for everything under [App]. Provided from the live
* settings alongside [LocalGamepadPalette], so a change on the gamepad settings screen re-inks
* every console surface at once.
*/
val LocalGamepadInk = compositionLocalOf { GamepadInk.DARK }
@@ -152,8 +152,9 @@ fun GamepadNavEffect(
* keyboard). Same hysteresis + hold-to-repeat as [GamepadNavEffect] but on both axes the dominant
* stick axis (or the pressed D-pad/HAT) commits a [NavDir], and it re-arms only after the stick
* returns near centre (so a flick is one step). [onActivate] is A / center, [onTertiary] is X,
* [onSecondary] is Y. B is left to MainActivity's BACK remap the screen's BackHandler (so B "peels
* one layer": close the keyboard, then the screen).
* [onSecondary] is Y, and [onShoulder] is L1 (-1) / R1 (+1) a step SIDEWAYS out of the list, which
* the settings screen uses for its section tabs. B is left to MainActivity's BACK remap the
* screen's BackHandler (so B "peels one layer": close the keyboard, then the screen).
*/
@Composable
fun GamepadNavEffect2D(
@@ -162,6 +163,7 @@ fun GamepadNavEffect2D(
onActivate: () -> Unit,
onTertiary: () -> Unit = {},
onSecondary: () -> Unit = {},
onShoulder: (Int) -> Unit = {},
) {
val activity = LocalContext.current as? MainActivity ?: return
val state = remember { NavInputState() }
@@ -169,6 +171,7 @@ fun GamepadNavEffect2D(
val currentOnActivate by rememberUpdatedState(onActivate)
val currentOnTertiary by rememberUpdatedState(onTertiary)
val currentOnSecondary by rememberUpdatedState(onSecondary)
val currentOnShoulder by rememberUpdatedState(onShoulder)
DisposableEffect(active) {
// Stable probe refs so onDispose only releases the slot if WE still own it — during a
@@ -196,7 +199,10 @@ fun GamepadNavEffect2D(
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true }
else -> false // B / shoulders → MainActivity (B remaps to BACK → BackHandler)
// Edge-only, no auto-repeat: a held shoulder shouldn't spin through the tabs.
KeyEvent.KEYCODE_BUTTON_L1 -> { if (edge) currentOnShoulder(-1); true }
KeyEvent.KEYCODE_BUTTON_R1 -> { if (edge) currentOnShoulder(1); true }
else -> false // B → MainActivity (remapped to BACK → BackHandler)
}
}
if (active) {
@@ -0,0 +1,218 @@
package io.unom.punktfunk
import androidx.compose.ui.graphics.Color
// The console (gamepad) UI's background colour families, and the ink each one calls for.
//
// A palette is a short ordered ramp of DISTINCT hues, not one hue at several brightnesses. The
// field samples that ramp so several tones show at once and pool into each other, the way a real
// gradient poster does. An earlier version rotated ONE field's hue per palette, which is why every
// non-default palette read flat and monotone.
//
// A palette also owns the UI sitting on it: [accent] is the focus wash / selected pill / switch
// colour, and [light] flips the ink so a pale field gets dark text instead of white.
//
// The table, [ramp] and [CELL_RAMP] are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the
// Apple client's `GamepadPalette.swift` under the same ids, so one `ui_palette` value is one look
// on every client. Keep the three copies in step: a palette added here without the others is a
// value the other clients silently render as Violet.
/** One background colour family. */
class GamepadPalette(
/** The stored `ui_palette` value ([Settings.uiPalette]). */
val id: String,
/** What the settings row shows. */
val name: String,
/**
* The colour ramp, dark end first. Empty = the brand default's explicit field, kept
* bit-identical to what every install already sees.
*/
val stops: List<Triple<Double, Double, Double>>,
/** The field's ground — what it settles onto and what the calm mix lifts toward. */
val ground: Triple<Double, Double, Double>,
/** The UI accent: focus wash, selected tab pill, switch track. */
val accent: Triple<Double, Double, Double>,
/** A pale field: the UI flips to dark ink and the legibility scrims go white. */
val light: Boolean,
) {
/** Four drifting blob colours, spread across the ramp so the field shows several hues. */
val blobColors: List<Color> by lazy {
val s = stops.ifEmpty { VIOLET_BLOBS }
(0..3).map { color(ramp(s, 0.15 + 0.25 * it)) }
}
/** The field's ground as a Compose colour. */
val groundColor: Color by lazy { color(ground) }
/** The accent as a Compose colour. */
val accentColor: Color by lazy { color(accent) }
companion object {
/**
* Where each of the 16 mesh cells samples the ramp on the clients that draw a mesh. Kept
* here so the three ports stay one table even though this client approximates the field
* with blobs.
*/
val CELL_RAMP = listOf(
0.10, -0.06, 0.04, -0.12,
-0.08, 0.14, -0.10, 0.06,
0.06, -0.12, 0.16, -0.04,
-0.10, 0.08, -0.06, 0.12,
)
/** The brand default's blob ramp — the colours the pre-palette field used. */
private val VIOLET_BLOBS = listOf(
Triple(0.53, 0.47, 0.96), Triple(0.24, 0.20, 0.72), Triple(0.62, 0.30, 0.80),
Triple(0.22, 0.38, 0.86), Triple(0.53, 0.47, 0.96),
)
/**
* The twelve shipped palettes: the brand default, five more dark fields, then six pale
* ones. Cycling order runs dark light, so stepping the row walks the range one way.
*/
val ALL = listOf(
// --- dark fields (white ink) ---
GamepadPalette(
"violet", "Violet", emptyList(),
ground = Triple(0.075, 0.060, 0.160),
accent = Triple(0.525, 0.471, 0.961), light = false,
),
GamepadPalette(
// Deep indigo climbing through violet into a hot magenta.
"nebula", "Nebula",
listOf(
Triple(0.07, 0.05, 0.20), Triple(0.26, 0.14, 0.54), Triple(0.52, 0.20, 0.72),
Triple(0.82, 0.26, 0.62), Triple(0.98, 0.46, 0.68),
),
ground = Triple(0.055, 0.040, 0.135),
accent = Triple(0.95, 0.42, 0.72), light = false,
),
GamepadPalette(
// Ink-blue water: teal → cerulean → a violet undertow.
"abyss", "Abyss",
listOf(
Triple(0.02, 0.10, 0.17), Triple(0.04, 0.28, 0.42), Triple(0.07, 0.46, 0.63),
Triple(0.16, 0.38, 0.78), Triple(0.26, 0.22, 0.58),
),
ground = Triple(0.018, 0.070, 0.130),
accent = Triple(0.26, 0.76, 0.92), light = false,
),
GamepadPalette(
// Banked coals: plum embers → crimson → burnt orange → gold.
"ember", "Ember",
listOf(
Triple(0.16, 0.03, 0.10), Triple(0.45, 0.06, 0.12), Triple(0.72, 0.18, 0.06),
Triple(0.90, 0.42, 0.08), Triple(0.95, 0.68, 0.18),
),
ground = Triple(0.090, 0.035, 0.040),
accent = Triple(0.98, 0.62, 0.26), light = false,
),
GamepadPalette(
// Forest floor into moss and a lime break.
"moss", "Moss",
listOf(
Triple(0.03, 0.11, 0.09), Triple(0.06, 0.27, 0.20), Triple(0.09, 0.45, 0.31),
Triple(0.28, 0.61, 0.28), Triple(0.58, 0.77, 0.31),
),
ground = Triple(0.025, 0.085, 0.070),
accent = Triple(0.48, 0.86, 0.46), light = false,
),
GamepadPalette(
// Neutral, but never flat: barely-there saturation that still travels from a cool
// charcoal to a warm stone.
"graphite", "Graphite",
listOf(
Triple(0.06, 0.07, 0.11), Triple(0.15, 0.18, 0.25), Triple(0.30, 0.31, 0.35),
Triple(0.45, 0.42, 0.38), Triple(0.60, 0.56, 0.49),
),
ground = Triple(0.055, 0.055, 0.070),
accent = Triple(0.78, 0.80, 0.86), light = false,
),
// --- pale fields (dark ink) ---
GamepadPalette(
// The holographic foil: rose → lilac → periwinkle → aqua, with a white bloom.
"holo", "Holo",
listOf(
Triple(0.99, 0.72, 0.90), Triple(0.80, 0.60, 0.98), Triple(0.58, 0.62, 0.99),
Triple(0.55, 0.86, 0.98), Triple(0.94, 0.98, 1.00),
),
ground = Triple(0.96, 0.92, 0.99),
accent = Triple(0.42, 0.28, 0.86), light = true,
),
GamepadPalette(
// The poster sunset: periwinkle → magenta → scarlet → tangerine → gold.
"sunset", "Sunset",
listOf(
Triple(0.55, 0.45, 0.92), Triple(0.86, 0.31, 0.66), Triple(0.97, 0.26, 0.34),
Triple(0.99, 0.51, 0.18), Triple(1.00, 0.80, 0.22),
),
ground = Triple(0.98, 0.74, 0.34),
accent = Triple(0.64, 0.13, 0.44), light = true,
),
GamepadPalette(
// Peach into blush and lilac — the softest of the set.
"bloom", "Bloom",
listOf(
Triple(1.00, 0.86, 0.72), Triple(0.99, 0.73, 0.79), Triple(0.95, 0.65, 0.89),
Triple(0.82, 0.68, 0.96), Triple(0.73, 0.79, 0.99),
),
ground = Triple(0.99, 0.90, 0.89),
accent = Triple(0.72, 0.24, 0.55), light = true,
),
GamepadPalette(
// First light: pale gold → coral → lilac.
"dawn", "Dawn",
listOf(
Triple(1.00, 0.92, 0.70), Triple(1.00, 0.80, 0.62), Triple(0.99, 0.66, 0.62),
Triple(0.90, 0.62, 0.78), Triple(0.77, 0.69, 0.95),
),
ground = Triple(1.00, 0.93, 0.82),
accent = Triple(0.82, 0.33, 0.28), light = true,
),
GamepadPalette(
// Sea glass: mint → aqua → a pale sky.
"mint", "Mint",
listOf(
Triple(0.82, 0.98, 0.90), Triple(0.62, 0.94, 0.88), Triple(0.55, 0.88, 0.95),
Triple(0.63, 0.82, 0.99), Triple(0.82, 0.87, 1.00),
),
ground = Triple(0.90, 0.98, 0.96),
accent = Triple(0.04, 0.42, 0.40), light = true,
),
GamepadPalette(
// Near-white, but iridescent rather than flat — rose, sky, mint and cream in turn.
"opal", "Opal",
listOf(
Triple(0.98, 0.92, 0.96), Triple(0.87, 0.93, 0.99), Triple(0.91, 0.99, 0.95),
Triple(0.99, 0.96, 0.88), Triple(0.94, 0.90, 0.99),
),
ground = Triple(0.97, 0.96, 0.99),
accent = Triple(0.36, 0.32, 0.44), light = true,
),
)
/**
* The palette stored under [id], falling back to the brand default an unknown name is a
* palette a newer client shipped, not a reason to draw nothing.
*/
fun named(id: String): GamepadPalette = ALL.firstOrNull { it.id == id } ?: ALL[0]
/** Sample an ordered colour ramp at [t] ∈ [0, 1] (linear between neighbouring stops). */
fun ramp(
stops: List<Triple<Double, Double, Double>>,
t: Double,
): Triple<Double, Double, Double> {
if (stops.isEmpty()) return Triple(0.0, 0.0, 0.0)
if (stops.size == 1) return stops[0]
val x = t.coerceIn(0.0, 1.0) * (stops.size - 1)
val i = x.toInt().coerceAtMost(stops.size - 2)
val f = x - i
val (ar, ag, ab) = stops[i]
val (br, bg, bb) = stops[i + 1]
return Triple(ar + (br - ar) * f, ag + (bg - ag) * f, ab + (bb - ab) * f)
}
fun color(c: Triple<Double, Double, Double>): Color =
Color(c.first.toFloat(), c.second.toFloat(), c.third.toFloat())
}
}
@@ -39,6 +39,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -63,10 +64,35 @@ import io.unom.punktfunk.kit.security.KnownHostStore
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
// a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it,
// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings.
// L1/R1 change SECTION, B closes. Both write the same SharedPreferences, so values round-trip with
// the touch settings.
//
// The rows are split across SECTION TABS ([GpTab]) — a shoulder press on a pad, a tap on a phone.
// They used to be one long scroll with inline `Group · Subgroup` headers, which on a TV meant
// walking past Display and Audio to reach the controller settings. The tab names match the desktop
// console's and the Apple client's, so a setting is found under the same word wherever you look.
private class GpRow(
/**
* The settings screen's sections. Order IS the strip order and the L1/R1 cycle order; the names
* match `pf-console-ui`'s `TABS` and the Apple client's `GpSettingsTab`.
*/
enum class GpTab(val title: String) {
STREAM("Stream"),
VIDEO("Video"),
AUDIO("Audio"),
CONTROLLER("Controller"),
INTERFACE("Interface"),
PROFILES("Profiles"),
}
internal class GpRow(
val id: String,
val tab: GpTab,
/**
* A sub-heading above this row, for the few tabs that hold more than one group. Most rows have
* none: the tab pill already names the section, and repeating it would be a second label
* saying the same word.
*/
val header: String?,
val label: String,
val value: String,
@@ -78,6 +104,15 @@ private class GpRow(
val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail)
)
/**
* The row at [index], or null when it is dimmed. The single place the "disabled ⇒ inert" half of
* [GpRow.enabled] is enforced, so the three input paths (pad left/right, A, and a tap on the
* already-focused row) cannot drift apart before this, `enabled` dimmed the label and nothing
* else, and every dimmed row still stepped its setting.
*/
internal fun liveRow(rows: List<GpRow>, index: Int): GpRow? =
rows.getOrNull(index)?.takeIf { it.enabled }
@Composable
fun GamepadSettingsScreen(
initial: Settings,
@@ -124,10 +159,34 @@ fun GamepadSettingsScreen(
// path there is this screen's own Controller-optimized UI toggle, which swaps in the standard
// interface remote-navigably. The strings branch on it.
val tv = remember { isTvDevice(context) }
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
val allRows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
// Which section is showing, and where each one's focus was when it was last left — a detour
// into another tab shouldn't lose your place.
var tab by remember { mutableStateOf(GpTab.STREAM) }
// True while the STRIP holds the cursor rather than the list. Up from the first row moves
// here and Down goes back — the only route to the sections on a D-pad remote, which has no
// shoulder buttons at all (and is exactly what a TV box ships with).
var tabFocused by remember { mutableStateOf(false) }
val tabFocus = remember { mutableStateMapOf<GpTab, Int>() }
val rows = allRows.filter { it.tab == tab }
var focus by remember { mutableIntStateOf(0) }
if (focus > rows.lastIndex) focus = rows.lastIndex
if (focus > rows.lastIndex) focus = rows.lastIndex.coerceAtLeast(0)
// L1/R1 — one section along, wrapping (the strip is a ring, like A's value cycle).
fun selectTab(next: GpTab) {
if (next == tab) return
tabFocus[tab] = focus
tab = next
// Clamp: a tab's length follows the hardware and the catalog, so a remembered index can
// outlive the row it pointed at.
focus = (tabFocus[next] ?: 0)
.coerceIn(0, (allRows.count { it.tab == next } - 1).coerceAtLeast(0))
}
fun stepTab(delta: Int) {
val all = GpTab.entries
selectTab(all[((all.indexOf(tab) + delta) % all.size + all.size) % all.size])
}
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
// value text slides in its AnimatedContent, so the motion matches the button press.
var adjustDir by remember { mutableIntStateOf(1) }
@@ -142,18 +201,28 @@ fun GamepadSettingsScreen(
active = navActive && pinProfile == null,
onDirection = { dir ->
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) }
NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) }
NavDir.UP -> if (focus > 0) focus-- else tabFocused = true
NavDir.DOWN -> if (tabFocused) tabFocused = false else if (focus < rows.lastIndex) focus++
// On the strip, left/right walks sections; on a row it steps the value. A disabled
// row is INERT, not just dim — the step is refused instead of writing a setting
// that has nothing to act on (see `liveRow`).
NavDir.LEFT ->
if (tabFocused) stepTab(-1) else { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
NavDir.RIGHT ->
if (tabFocused) stepTab(1) else { adjustDir = 1; liveRow(rows, focus)?.adjust(1) }
}
},
onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() },
// A on the strip drops into the section you picked, which is what "confirm" means there.
onActivate = {
if (tabFocused) tabFocused = false else { adjustDir = 1; liveRow(rows, focus)?.activate() }
},
// The shoulders work from either place — a real pad never has to visit the strip.
onShoulder = { delta -> stepTab(delta) },
)
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
// +1 accounts for the heading being item 0.
LaunchedEffect(focus) {
LaunchedEffect(focus, tab) {
runCatching {
val itemIndex = focus + 1
val info = listState.layoutInfo
@@ -172,9 +241,21 @@ fun GamepadSettingsScreen(
// where a fixed title + a fixed detail/legend strip ate most of the (short) height.
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
GamepadFormBackground(Modifier.fillMaxSize())
Column(Modifier.fillMaxSize().systemBarsPadding()) {
// The strip is PINNED while the rows scroll under it: it is this screen's primary
// navigation now, and a switcher you have to scroll back up to find isn't one. The
// title stays in the scrolling list (landscape has no height to spare, and the
// selected pill already says which section you are in).
ConsoleTabStrip(
titles = GpTab.entries.map { it.title },
selected = GpTab.entries.indexOf(tab),
onSelect = { tabFocused = false; selectTab(GpTab.entries[it]) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
focused = tabFocused,
)
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().systemBarsPadding(),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
@@ -185,9 +266,19 @@ fun GamepadSettingsScreen(
ConsoleHeader("Default settings", horizontalInset = false)
}
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
if (focus == index) { adjustDir = 1; row.activate() } else focus = index
})
SettingRowView(
row,
focused = index == focus && !tabFocused,
adjustDir = adjustDir,
onClick = {
// Same inertness as the pad path above — tapping a dimmed row focuses it
// (so its detail explains itself) but never flips it.
tabFocused = false
if (focus != index) focus = index
else if (row.enabled) { adjustDir = 1; row.activate() }
},
)
}
}
}
}
@@ -204,8 +295,23 @@ fun GamepadSettingsScreen(
// a profile row doesn't adjust, it opens the pin picker, and the "No profiles yet"
// placeholder does nothing at all — advertising ↔/A on those would be a lie.
val focused = rows.getOrNull(focus)
// The shoulders always change section, so that cell leads on every row. Tappable too,
// like the others — a user without a working pad can still reach every tab.
// Advertise the shoulders only where they EXIST: a TV remote has none (its route is Up
// into the strip) and a touch user taps a pill, so on those the cell would be both a
// lie and the reason a 360 dp legend runs out of room. Defaults to the pad case off an
// Activity (preview/tests), like GamepadHintBar's own glyph choice.
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
val sections = listOfNotNull(
GamepadHint('⇄', Color(0xFF9A93C7), "Section", onClick = { stepTab(1) })
.takeIf { padIsGamepad },
)
GamepadHintBar(
when {
if (tabFocused) listOf(
GamepadHint('↔', Color(0xFF9A93C7), "Section"),
PadGlyph.hint('A', "Open") { tabFocused = false },
PadGlyph.hint('B', "Done", onClick = onBack),
) else sections + when {
focused != null && !focused.enabled -> listOf(
PadGlyph.hint('B', "Done", onClick = onBack),
)
@@ -240,6 +346,7 @@ fun GamepadSettingsScreen(
@Composable
private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp)
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
@@ -251,7 +358,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
label = "chevrons",
)
val valueColor by animateColorAsState(
Color.White.copy(alpha = if (focused) 1f else 0.6f),
ink.fg(if (focused) 1f else 0.6f),
tween(160),
label = "valueColor",
)
@@ -260,7 +367,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
Text(
row.header.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = Color.White.copy(alpha = 0.45f),
color = ink.fg(0.45f),
letterSpacing = 1.4.sp,
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp),
)
@@ -286,7 +393,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
fontWeight = FontWeight.SemiBold,
// A disabled row (the "No profiles yet" placeholder) dims but stays focusable,
// so its detail line can still explain what would go here.
color = Color.White.copy(alpha = if (row.enabled) 1f else 0.45f),
color = ink.fg(if (row.enabled) 1f else 0.45f),
maxLines = 1,
)
Spacer(Modifier.weight(1f))
@@ -294,7 +401,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
// A toggle is a switch, not text — the sliding knob + tinting track IS the value.
ConsoleSwitch(on = row.toggled, focused = focused)
} else {
Text(" ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
Text(" ", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
// The value slides in the direction it was stepped and its width animates, so
// cycling a choice reads as motion through a list rather than a text swap.
AnimatedContent(
@@ -315,7 +422,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
overflow = TextOverflow.Ellipsis,
)
}
Text(" ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
Text(" ", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
}
}
// The focused row carries its own one-line description — no dedicated (space-eating)
@@ -328,7 +435,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
Text(
row.detail,
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.6f),
color = ink.fg(0.6f),
maxLines = 2,
modifier = Modifier.padding(top = 6.dp),
)
@@ -339,22 +446,24 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
/** Build the console settings rows from the current [Settings], writing through [update].
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the
* AV1 codec entry (see `codecOptionsFor`). */
private fun buildSettingsRows(
* AV1 codec entry (see `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one
* tab at a time. */
internal fun buildSettingsRows(
s: Settings,
hasBodyVibrator: Boolean,
av1Capable: Boolean,
update: (Settings) -> Unit,
): List<GpRow> {
fun <T> choice(
id: String, header: String?, label: String, detail: String,
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
id: String, tab: GpTab, header: String?, label: String, detail: String,
options: List<Pair<T, String>>, current: T, enabled: Boolean = true, write: (T) -> Unit,
): GpRow {
val idx = options.indexOfFirst { it.first == current }
return GpRow(
id, header, label,
id, tab, header, label,
value = options.getOrNull(idx)?.second ?: "",
detail = detail,
enabled = enabled,
adjust = { delta ->
if (idx < 0) {
options.firstOrNull()?.let { write(it.first) } != null
@@ -370,47 +479,25 @@ private fun buildSettingsRows(
)
}
fun toggle(
id: String, header: String?, label: String, detail: String,
value: Boolean, write: (Boolean) -> Unit,
id: String, tab: GpTab, header: String?, label: String, detail: String,
value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit,
): GpRow = GpRow(
id, header, label,
id, tab, header, label,
value = if (value) "On" else "Off",
detail = detail,
enabled = enabled,
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
activate = { write(!value) },
toggled = value,
)
// Grouped and ordered by the cross-client category map (General / Display / Audio /
// Controllers), with the same sub-section names the touch settings and the desktop clients use,
// so a setting sits in the same place whichever surface you found it on. The ROWS stay the
// couch-relevant subset: a pad can't drive a touch-input picker, and adding one for the sake of
// symmetry would be parity in name only.
// Grouped by the cross-client tab map (Stream / Video / Audio / Controller / Interface /
// Profiles), so a setting sits under the same word whichever client you found it on. The ROWS
// stay the couch-relevant subset: a pad can't drive a touch-input picker, and adding one for
// the sake of symmetry would be parity in name only.
return listOf(
choice(
"hud", "General · Statistics", "Statistics overlay",
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
"A 3-finger tap cycles the tiers live.",
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
) { update(s.copy(statsVerbosity = it)) },
toggle(
"autoWake", "General · Session", "Auto-wake on connect",
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
s.autoWakeEnabled,
) { update(s.copy(autoWakeEnabled = it)) },
toggle(
"library", "General · Library", "Game library",
"Browse a paired host's games with Y (experimental).",
s.libraryEnabled,
) { update(s.copy(libraryEnabled = it)) },
toggle(
"gamepadUI", "General · Interface", "Controller-optimized UI",
"Turn off to use the touch interface even with a controller connected.",
s.gamepadUiEnabled,
) { update(s.copy(gamepadUiEnabled = it)) },
choice(
"resolution", "Display · Resolution", "Resolution",
"resolution", GpTab.STREAM, null, "Resolution",
"The host creates a virtual display at exactly this size — no scaling. " +
"Custom sizes are typed in the touch settings.",
// A custom size (typed in the touch settings) leads the list so it stays visible and
@@ -424,69 +511,86 @@ private fun buildSettingsRows(
s.width to s.height,
) { (w, h) -> update(s.copy(width = w, height = h)) },
choice(
"refresh", null, "Refresh rate", "Frame rate the host renders and streams at.",
"refresh", GpTab.STREAM, null, "Refresh rate",
"Frame rate the host renders and streams at.",
REFRESH_OPTIONS, s.hz,
) { update(s.copy(hz = it)) },
choice(
"bitrate", "Display · Quality", "Bitrate",
"bitrate", GpTab.STREAM, null, "Bitrate",
"Automatic uses the host's default. A host's options (Up on its tile) can measure the " +
"link and set an informed value.",
BITRATE_OPTIONS, s.bitrateKbps,
) { update(s.copy(bitrateKbps = it)) },
choice(
"codec", null, "Video codec",
"A preference — the host falls back if it can't encode this one.",
codecOptionsFor(s.codec, av1Capable), s.codec,
) { update(s.copy(codec = it)) },
toggle(
"hdr", null, "10-bit HDR",
"HDR10 — engages when the host sends HDR content and this display supports it.",
s.hdrEnabled,
) { update(s.copy(hdrEnabled = it)) },
toggle(
"lowLatency", "Display · Decoding", "Low-latency mode",
"The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.",
s.lowLatencyMode,
) { update(s.copy(lowLatencyMode = it)) },
choice(
"compositor", "Display · Host output", "Compositor",
"compositor", GpTab.STREAM, "Host output", "Compositor",
"Which compositor drives the virtual output — honored only if available on the host.",
COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.compositor,
) { update(s.copy(compositor = it)) },
choice(
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
"codec", GpTab.VIDEO, null, "Video codec",
"A preference — the host falls back if it can't encode this one.",
codecOptionsFor(s.codec, av1Capable), s.codec,
) { update(s.copy(codec = it)) },
toggle(
"hdr", GpTab.VIDEO, null, "10-bit HDR",
"HDR10 — engages when the host sends HDR content and this display supports it.",
s.hdrEnabled,
) { update(s.copy(hdrEnabled = it)) },
toggle(
"lowLatency", GpTab.VIDEO, "Decoding", "Low-latency mode",
"The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.",
s.lowLatencyMode,
) { update(s.copy(lowLatencyMode = it)) },
choice(
"audio", GpTab.AUDIO, null, "Audio channels",
"The speaker layout requested from the host.",
AUDIO_CHANNEL_OPTIONS, s.audioChannels,
) { update(s.copy(audioChannels = it)) },
toggle(
"mic", null, "Microphone", "Send this device's microphone to the host's virtual mic.",
"mic", GpTab.AUDIO, null, "Microphone",
"Send this device's microphone to the host's virtual mic.",
s.micEnabled,
) { update(s.copy(micEnabled = it)) },
toggle(
"echoCancel", null, "Echo cancellation",
"echoCancel", GpTab.AUDIO, null, "Echo cancellation",
"Filter the stream's own audio out of the mic pickup. Applies while the microphone is on.",
s.echoCancel,
) { update(s.copy(echoCancel = it)) },
toggle(
"padForward", "Controllers", "Forward controllers",
"padForward", GpTab.CONTROLLER, null, "Forward controllers",
"Send this device's controllers to the host. Turn it off when your controller " +
"already reaches the host another way — USB passthrough such as VirtualHere — " +
"so games don't see two of them.",
s.gamepadForwarding,
) { update(s.copy(gamepadForwarding = it)) },
// Everything below the master switch follows it — dim and inert while nothing is being
// forwarded, the same relationship the touch settings draw with `enabled =`. This screen
// had the capability (`GpRow.enabled`) and used it only for the profiles placeholder, so
// the pad rows kept stepping settings that had nothing to act on.
choice(
"padType", null, "Controller type",
"padType", GpTab.CONTROLLER, null, "Controller type",
"The virtual pad the host creates — Automatic matches this controller.",
GAMEPAD_OPTIONS, s.gamepad,
GAMEPAD_OPTIONS, s.gamepad, enabled = s.gamepadForwarding,
) { update(s.copy(gamepad = it)) },
choice(
"systemButtons", GpTab.CONTROLLER, null, "Guide button",
"Where the guide (Xbox/PS) and share presses go while streaming — Automatic " +
"sends them to the host whenever this device delivers them.",
SYSTEM_BUTTON_OPTIONS, s.systemButtons, enabled = s.gamepadForwarding,
) { update(s.copy(systemButtons = it)) },
choice(
"guideGesture", GpTab.CONTROLLER, null, "Hold Select for guide",
"Hold Select alone to press the host's guide button — keep holding for a " +
"Gaming-Mode host's quick-access menu. A Select tap still goes through.",
GUIDE_GESTURE_OPTIONS, s.guideGesture, enabled = s.gamepadForwarding,
) { update(s.copy(guideGesture = it)) },
) + listOfNotNull(
if (hasBodyVibrator) {
toggle(
"phoneRumble", null, "Rumble on this phone",
"phoneRumble", GpTab.CONTROLLER, null, "Rumble on this phone",
"Also play controller 1's rumble on this phone's own vibration motor — " +
"for clip-on pads without rumble motors.",
s.rumbleOnPhone,
@@ -498,20 +602,63 @@ private fun buildSettingsRows(
// NOT gated on the vibrator (the bug A2 fixed in the touch settings): an SC2 capture has
// nothing to do with this device's motor, and a TV box is where it matters most.
toggle(
"sc2", null, "Steam Controller 2 passthrough",
"sc2", GpTab.CONTROLLER, "Passthrough", "Steam Controller 2 passthrough",
"Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " +
"it as-is — Steam on the host drives it like the physical pad.",
s.sc2Capture,
s.sc2Capture, enabled = s.gamepadForwarding,
) { update(s.copy(sc2Capture = it)) },
// The SC2 row's twin, and missing here until now: the touch settings have carried both
// side by side, so a couch user on a TV box — where there IS no touch interface to fall
// back to — could turn on SC2 passthrough but not the Sony one. Same no-vibrator-gate
// reasoning: this capture renders feedback on the CONTROLLER's motors, not this device's.
toggle(
"dsCapture", GpTab.CONTROLLER, null, "DualSense / DualShock passthrough (USB)",
"Drive a USB-connected Sony pad directly — rumble on any phone, plus adaptive " +
"triggers, lightbar and gyro.",
s.dsCapture, enabled = s.gamepadForwarding,
) { update(s.copy(dsCapture = it)) },
// The palette leads Interface: it is the one row whose effect you can see while you step
// it (the backdrop behind this very list recolours), so it wants to be the first thing
// found in the section.
choice(
"palette", GpTab.INTERFACE, null, "Background",
"The colour family this backdrop drifts through — it changes as you step, so pick by " +
"looking. Appearance only.",
GamepadPalette.ALL.map { it.id to it.name },
GamepadPalette.named(s.uiPalette).id,
) { update(s.copy(uiPalette = it)) },
choice(
"hud", GpTab.INTERFACE, null, "Statistics overlay",
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
"A 3-finger tap cycles the tiers live.",
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
) { update(s.copy(statsVerbosity = it)) },
toggle(
"autoWake", GpTab.INTERFACE, null, "Auto-wake on connect",
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
s.autoWakeEnabled,
) { update(s.copy(autoWakeEnabled = it)) },
toggle(
"library", GpTab.INTERFACE, null, "Game library",
"Browse a paired host's games with Y (experimental).",
s.libraryEnabled,
) { update(s.copy(libraryEnabled = it)) },
toggle(
"gamepadUI", GpTab.INTERFACE, null, "Controller-optimized UI",
"Turn off to use the touch interface even with a controller connected.",
s.gamepadUiEnabled,
) { update(s.copy(gamepadUiEnabled = it)) },
)
}
/**
* The trailing Profiles section the Android mirror of the desktop console's (design §5.2a, §5.4):
* one row per catalog profile, valued with how many saved hosts pin it, activating into the
* pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the standard
* interface, so an empty catalog shows one dimmed placeholder explaining where they come from
* instead of a dead-looking empty header. On a TV that phrasing changes: "touch interface" points
* instead of a dead-looking empty tab. On a TV that phrasing changes: "touch interface" points
* nowhere useful on a touchless device, so the strings name the actual route the
* Controller-optimized UI toggle a few rows up, which swaps the standard interface in
* (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists).
@@ -532,7 +679,8 @@ private fun buildProfileRows(
return listOf(
GpRow(
id = "noProfiles",
header = "Profiles",
tab = GpTab.PROFILES,
header = null,
label = "No profiles yet",
value = "",
detail = "Profiles bundle stream settings for different uses — pinned ones become " +
@@ -544,12 +692,13 @@ private fun buildProfileRows(
),
)
}
return profiles.mapIndexed { i, p ->
return profiles.map { p ->
// Counted straight off the host records, so it agrees with what the carousel renders.
val pins = savedHosts.count { p.id in it.pinnedProfileIds }
GpRow(
id = "profile:${p.id}",
header = if (i == 0) "Profiles" else null,
tab = GpTab.PROFILES,
header = null,
label = p.name,
value = when (pins) {
0 -> "Not pinned"
@@ -84,6 +84,9 @@ suspend fun connectToHost(
// The host's approval-list / trust-store label for this device — the same
// Build.MODEL convention the pairing dialogs use for nativePair.
Build.MODEL ?: "Android",
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
// user with it off does not make the host provision endpoints it will never feed.
settings.padHaptics || settings.padSpeaker,
)
}
}
@@ -90,6 +90,7 @@ fun LibraryScreen(
onBack: () -> Unit,
navActive: Boolean = true,
) {
val ink = LocalGamepadInk.current
BackHandler(onBack = onBack)
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -145,7 +146,14 @@ fun LibraryScreen(
launching = false
if (handle != 0L) {
onLaunched(
ActiveSession(handle, settings, host.clipboardSync),
ActiveSession(
handle,
settings,
host.clipboardSync,
hostId = host.id,
// Where to come back to when this game exits.
launchedFromLibrary = true,
),
)
}
else Toast.makeText(
@@ -170,8 +178,8 @@ fun LibraryScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
CircularProgressIndicator(color = Color.White)
Text("Launching…", color = Color.White, style = MaterialTheme.typography.bodyLarge)
CircularProgressIndicator(color = ink.fg)
Text("Launching…", color = ink.fg, style = MaterialTheme.typography.bodyLarge)
}
}
}
@@ -195,17 +203,19 @@ fun LibraryScreen(
@Composable
private fun LoadingState() {
val ink = LocalGamepadInk.current
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) {
CircularProgressIndicator(color = Color.White)
Text("Loading library…", color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyLarge)
CircularProgressIndicator(color = ink.fg)
Text("Loading library…", color = ink.fg(0.7f), style = MaterialTheme.typography.bodyLarge)
}
}
@Composable
private fun MessageState(text: String) {
val ink = LocalGamepadInk.current
Text(
text,
color = Color.White.copy(alpha = 0.75f),
color = ink.fg(0.75f),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
@@ -219,6 +229,7 @@ private fun Coverflow(
navActive: Boolean,
onLaunch: (GameEntry) -> Unit,
) {
val ink = LocalGamepadInk.current
BoxWithConstraints(Modifier.fillMaxSize()) {
// Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen.
val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp)
@@ -241,7 +252,22 @@ private fun Coverflow(
onActivate = { games.getOrNull(navTarget)?.let(onLaunch) },
)
// Design D4: the launcher entries lead the strip (the client groups them at parse time).
// A coverflow is one-dimensional, so instead of a second focus rail the heading names the
// group the cursor is in and changes as it crosses the boundary. Only drawn when the
// library actually has both groups — otherwise the screen is exactly what it was.
val bothGroups = games.any { it.isLauncher } && games.any { !it.isLauncher }
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
if (bothGroups) {
Text(
if (current?.isLauncher == true) "LAUNCHERS" else "GAMES",
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.45f),
letterSpacing = 2.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
)
}
HorizontalPager(
state = pagerState,
pageSize = PageSize.Fixed(coverWidth),
@@ -299,15 +325,16 @@ private fun Coverflow(
current?.title ?: " ",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = Color.White,
color = ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (current != null) {
Text(
if (current.isCustom) "CUSTOM" else "STEAM",
if (current.isLauncher) "${current.storeLabel.uppercase()} \u00B7 LAUNCHER"
else current.storeLabel.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = Color.White.copy(alpha = 0.5f),
color = ink.fg(0.5f),
letterSpacing = 2.sp,
)
}
@@ -319,6 +346,7 @@ private fun Coverflow(
/** One cover: walks the art candidates (portrait → header → hero) then a text placeholder. */
@Composable
private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Modifier) {
val ink = LocalGamepadInk.current
val candidates = game.art.posterCandidates
var idx by remember(game.id) { mutableStateOf(0) }
val shape = RoundedCornerShape(16.dp)
@@ -326,7 +354,7 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
modifier = modifier
.clip(shape)
.background(Color(0xFF241F3D))
.border(1.dp, Color.White.copy(alpha = 0.12f), shape),
.border(1.dp, ink.fg(0.12f), shape),
contentAlignment = Alignment.Center,
) {
if (idx < candidates.size) {
@@ -339,24 +367,29 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder
)
} else {
// A launcher rarely has poster art. Naming the launcher says "opens Steam"; the title
// would read as "a game whose cover failed to load".
Text(
game.title,
if (game.isLauncher) game.storeLabel else game.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White.copy(alpha = 0.75f),
color = ink.fg(0.75f),
textAlign = TextAlign.Center,
modifier = Modifier.padding(12.dp),
)
}
// Store badge, top-start.
// Store badge, top-start — brand-filled for a launcher entry (design D4).
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
Text(
if (game.isCustom) "Custom" else "Steam",
game.storeLabel,
style = MaterialTheme.typography.labelSmall,
color = Color.White,
color = ink.fg,
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(Color.Black.copy(alpha = 0.5f))
.background(
if (game.isLauncher) MaterialTheme.colorScheme.primary
else Color.Black.copy(alpha = 0.5f),
)
.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
@@ -44,6 +44,8 @@ data class SettingsOverlay(
val invertScroll: Boolean? = null,
val gamepad: Int? = null,
val gamepadForwarding: Boolean? = null,
val systemButtons: String? = null,
val guideGesture: String? = null,
val statsVerbosity: StatsVerbosity? = null,
/**
* Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere
@@ -78,6 +80,8 @@ data class SettingsOverlay(
invertScroll = invertScroll ?: base.invertScroll,
gamepad = gamepad ?: base.gamepad,
gamepadForwarding = gamepadForwarding ?: base.gamepadForwarding,
systemButtons = systemButtons ?: base.systemButtons,
guideGesture = guideGesture ?: base.guideGesture,
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
presentPriority = presentPriority ?: base.presentPriority,
@@ -115,6 +119,8 @@ data class SettingsOverlay(
gamepadForwarding =
if (after.gamepadForwarding != before.gamepadForwarding) after.gamepadForwarding
else gamepadForwarding,
systemButtons = if (after.systemButtons != before.systemButtons) after.systemButtons else systemButtons,
guideGesture = if (after.guideGesture != before.guideGesture) after.guideGesture else guideGesture,
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
@@ -142,6 +148,8 @@ data class SettingsOverlay(
"invert_scroll" -> copy(invertScroll = null)
"gamepad" -> copy(gamepad = null)
"gamepad_forwarding" -> copy(gamepadForwarding = null)
"system_buttons" -> copy(systemButtons = null)
"guide_gesture" -> copy(guideGesture = null)
"stats_verbosity" -> copy(statsVerbosity = null)
"low_latency_mode" -> copy(lowLatencyMode = null)
"present_priority" -> copy(presentPriority = null)
@@ -166,6 +174,8 @@ data class SettingsOverlay(
if (invertScroll != null) add("invert_scroll")
if (gamepad != null) add("gamepad")
if (gamepadForwarding != null) add("gamepad_forwarding")
if (systemButtons != null) add("system_buttons")
if (guideGesture != null) add("guide_gesture")
if (statsVerbosity != null) add("stats_verbosity")
if (lowLatencyMode != null) add("low_latency_mode")
if (presentPriority != null) add("present_priority")
@@ -198,6 +208,8 @@ data class SettingsOverlay(
invertScroll?.let { j.put("invert_scroll", it) }
gamepad?.let { j.put("gamepad", it) }
gamepadForwarding?.let { j.put("gamepad_forwarding", it) }
systemButtons?.let { j.put("system_buttons", it) }
guideGesture?.let { j.put("guide_gesture", it) }
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
lowLatencyMode?.let { j.put("low_latency_mode", it) }
presentPriority?.let { j.put("present_priority", it) }
@@ -214,6 +226,7 @@ data class SettingsOverlay(
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel",
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
"system_buttons", "guide_gesture",
"stats_verbosity",
"low_latency_mode", "present_priority", "smooth_buffer",
)
@@ -237,6 +250,8 @@ data class SettingsOverlay(
invertScroll = j.optBooleanOrNull("invert_scroll"),
gamepad = j.optIntOrNull("gamepad"),
gamepadForwarding = j.optBooleanOrNull("gamepad_forwarding"),
systemButtons = j.optStringOrNull("system_buttons"),
guideGesture = j.optStringOrNull("guide_gesture"),
statsVerbosity = j.optStringOrNull("stats_verbosity")
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
@@ -45,6 +45,20 @@ data class Settings(
* bind which is why it gates the USB capture paths, not just the wire sends.
*/
val gamepadForwarding: Boolean = true,
/**
* Where the guide (Xbox/PS) and misc/share presses land while streaming the
* cross-client `system_buttons` key: `"auto"` (forward on Android the press reaches
* the app on most devices) | `"forward"` | `"local"`.
*/
val systemButtons: String = "auto",
/**
* The hold-Select guide gesture the cross-client `guide_gesture` key: `"auto"` (off
* on Android) | `"on"` | `"off"`. On: holding Select alone 350 ms sends the HOST's
* guide, down until release (long hold = the host's long-press a Gaming-Mode host's
* QAM); a Select tap is delivered on release, slightly delayed. For devices whose
* shell intercepts the physical guide button.
*/
val guideGesture: String = "auto",
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
* can capture; the resolved count drives the decoder + AAudio layout. */
val audioChannels: Int = 2,
@@ -91,6 +105,16 @@ data class Settings(
* client's `libraryEnabled`.
*/
val libraryEnabled: Boolean = true,
/**
* Which colour family the console (gamepad) UI's living backdrop drifts through the
* cross-client `ui_palette` key: `"violet"` (the brand default), `"tide"`, `"forest"`,
* `"ember"`, `"rose"`, `"graphite"`. See [GamepadPalette], whose table and maths mirror the
* desktop console's and the Apple client's under the same names. Presentation only: nothing
* about a stream depends on it, so it is a device preference and never part of a profile.
* An unknown value reads as the default rather than failing a newer client may have shipped
* a palette this build doesn't know.
*/
val uiPalette: String = "violet",
/**
* "Low-latency mode" the master switch over the latency pipeline: the async decode loop
* (native; burst-feed + present-newest-per-vsync, the Apple client's discipline), decoder ranking
@@ -156,6 +180,26 @@ data class Settings(
*/
val dsCapture: Boolean = true,
/**
* Render the host's DualSense **voice-coil haptics** on a captured USB pad (tier A).
*
* The pad's own 4-channel audio device carries them, driven directly over usbfs Android's
* audio framework denylists that device by VID/PID, so there is no supported route to it. The
* two kinds are arbitrated rather than mixed, and on evidence: wire rumble is suppressed only
* while haptics frames are actually arriving, so a title that drives classic rumble and sends
* no haptics audio keeps rumbling. Off, or on an uncaptured/Bluetooth pad, the pad stays on
* ordinary rumble (tier C), which on this client already drives the same actuators.
*/
val padHaptics: Boolean = true,
/**
* Render the pad's **built-in speaker** on a captured USB pad. Independent of [padHaptics]
* the host sends the two as separate streams and either can play alone. Off by default: the
* speaker is a small, easily-startling loudspeaker in the user's hands, and unlike haptics it
* duplicates audio they are already hearing.
*/
val padSpeaker: Boolean = false,
/**
* How a physical mouse drives the host the cross-client mouse model (see [MouseMode]).
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
@@ -228,6 +272,8 @@ class SettingsStore(context: Context) {
compositor = prefs.getInt(K_COMPOSITOR, 0),
gamepad = prefs.getInt(K_GAMEPAD, 0),
gamepadForwarding = prefs.getBoolean(K_GAMEPAD_FORWARDING, true),
systemButtons = prefs.getString(K_SYSTEM_BUTTONS, "auto") ?: "auto",
guideGesture = prefs.getString(K_GUIDE_GESTURE, "auto") ?: "auto",
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
micEnabled = prefs.getBoolean(K_MIC, false),
@@ -248,6 +294,7 @@ class SettingsStore(context: Context) {
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
uiPalette = prefs.getString(K_UI_PALETTE, "violet") ?: "violet",
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
presentPriority = prefs.getString(K_PRESENT_PRIORITY, "latency") ?: "latency",
smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0),
@@ -255,6 +302,8 @@ class SettingsStore(context: Context) {
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true),
padSpeaker = prefs.getBoolean(K_PAD_SPEAKER, false),
mouseMode = prefs.getString(K_MOUSE_MODE, null)
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
@@ -275,6 +324,8 @@ class SettingsStore(context: Context) {
.putInt(K_COMPOSITOR, s.compositor)
.putInt(K_GAMEPAD, s.gamepad)
.putBoolean(K_GAMEPAD_FORWARDING, s.gamepadForwarding)
.putString(K_SYSTEM_BUTTONS, s.systemButtons)
.putString(K_GUIDE_GESTURE, s.guideGesture)
.putInt(K_AUDIO_CH, s.audioChannels)
.putString(K_CODEC, s.codec)
.putBoolean(K_MIC, s.micEnabled)
@@ -283,6 +334,7 @@ class SettingsStore(context: Context) {
.putString(K_TOUCH_MODE, s.touchMode.name)
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
.putBoolean(K_LIBRARY, s.libraryEnabled)
.putString(K_UI_PALETTE, s.uiPalette)
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
.putString(K_PRESENT_PRIORITY, s.presentPriority)
.putInt(K_SMOOTH_BUFFER, s.smoothBuffer)
@@ -290,6 +342,8 @@ class SettingsStore(context: Context) {
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
.putBoolean(K_DS_CAPTURE, s.dsCapture)
.putBoolean(K_PAD_HAPTICS, s.padHaptics)
.putBoolean(K_PAD_SPEAKER, s.padSpeaker)
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
.apply()
@@ -305,6 +359,8 @@ class SettingsStore(context: Context) {
const val K_COMPOSITOR = "compositor"
const val K_GAMEPAD = "gamepad"
const val K_GAMEPAD_FORWARDING = "gamepad_forwarding"
const val K_SYSTEM_BUTTONS = "system_buttons"
const val K_GUIDE_GESTURE = "guide_gesture"
const val K_AUDIO_CH = "audio_channels"
const val K_CODEC = "codec"
const val K_MIC = "mic_enabled"
@@ -317,6 +373,7 @@ class SettingsStore(context: Context) {
const val K_TOUCH_MODE = "touch_mode"
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
const val K_LIBRARY = "library_enabled"
const val K_UI_PALETTE = "ui_palette"
/**
* Bumped AGAIN to restart every install at the new default (ON). History: the original
@@ -335,6 +392,8 @@ class SettingsStore(context: Context) {
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
const val K_SC2_CAPTURE = "sc2_capture"
const val K_DS_CAPTURE = "ds_capture"
const val K_PAD_HAPTICS = "pad_haptics"
const val K_PAD_SPEAKER = "pad_speaker"
const val K_MOUSE_MODE = "mouse_mode"
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
@@ -378,6 +437,96 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
return Triple(maxOf(w, h), minOf(w, h), hz)
}
/**
* Sentinel [Settings.width]/[Settings.height] meaning "the native mode, narrowed so the picture
* clears the display cutout and the rounded corners" — resolved at connect by [safeDisplayMode],
* exactly as `0` is resolved by [nativeDisplayMode]. Negative, so it can never collide with a real
* size; distinct from the UI's `-1` "Custom…" sentinel.
*/
const val SAFE_AREA_MODE = -2
/**
* Safe-area stream geometry the pure part, so it is unit-testable without a Display.
*
* The phone clips the picture in HARDWARE: the cutout (notch / punch-hole) and the four rounded
* corners eat whatever the stream draws under them. [StreamScreen] deliberately draws edge-to-edge
* (`LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS`) and centres the video at its own aspect ratio
* (`Modifier.aspectRatio`), so which pixels survive is decided purely by the mode's aspect:
*
* * A 16:9 mode on a 20:9 phone pillarboxes, and those black bars land exactly on the unsafe
* regions which is why the presets have always "just worked".
* * The NATIVE mode has the panel's own aspect, so it fills every pixel, cutout and corners
* included. That is the mode that loses its corners.
*
* So asking the host for a mode narrower by the unsafe inset is the entire fix: the existing
* aspect-fit centres it inside the safe region, and pointer mapping follows for free (MouseInput
* derives the picture rect from the live video size, not from the window).
*/
object SafeArea {
/** The host rejects odd dimensions and anything under 320 px wide (`validate_dimensions`). */
const val MIN_WIDTH = 320
/**
* [nativeWidth] reduced by [perSideInsetPx] on each side, even-floored and clamped to the
* host's floor. Height is deliberately untouched: under aspect-fit only one axis can bind, and
* on a landscape phone that axis is always the horizontal one insetting height as well would
* shrink the picture without uncovering anything.
*/
fun insetWidth(nativeWidth: Int, perSideInsetPx: Int): Int {
val inset = perSideInsetPx.coerceAtLeast(0)
return (nativeWidth - inset * 2).coerceAtLeast(MIN_WIDTH) / 2 * 2
}
}
/**
* The per-side inset, in pixels, that the **landscape** stream must clear on this display.
*
* Two contributions, and the larger wins:
* * **The cutout.** [DisplayCutout] is rotation-aware, so in landscape the housing shows up on
* `left`/`right`. The settings screen may be portrait though, where the very same housing is
* reported on `top`/`bottom` and the horizontal insets read zero which would compute "no inset
* needed" for exactly the devices that need one. The stream is always landscape, so a vertical
* inset now becomes a horizontal one then: fall back to it.
* * **The rounded corners.** These are NOT part of the cutout insets. For a FULL-HEIGHT picture the
* horizontal clearance a corner of radius `r` needs is exactly `r`: at the topmost row the
* display boundary sits at `x = r`, so anything left of that is clipped. Not conservative it is
* the precise requirement for a picture that spans the full height.
*
* `0` when the display has neither, which makes the safe mode identical to the native one.
*/
private fun displaySideInsetPx(context: Context): Int {
val display = probeDisplay(context) ?: return 0
var inset = 0
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
display.cutout?.let { cut ->
val horizontal = maxOf(cut.safeInsetLeft, cut.safeInsetRight)
val vertical = maxOf(cut.safeInsetTop, cut.safeInsetBottom)
inset = maxOf(inset, if (horizontal > 0) horizontal else vertical)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
for (position in intArrayOf(
android.view.RoundedCorner.POSITION_TOP_LEFT,
android.view.RoundedCorner.POSITION_TOP_RIGHT,
android.view.RoundedCorner.POSITION_BOTTOM_LEFT,
android.view.RoundedCorner.POSITION_BOTTOM_RIGHT,
)) {
display.getRoundedCorner(position)?.let { inset = maxOf(inset, it.radius) }
}
}
return inset
}
/**
* The native mode narrowed to clear the cutout and the rounded corners the [SAFE_AREA_MODE]
* resolution, as a landscape `(width, height, hz)`. Same height and refresh as [nativeDisplayMode];
* only the width moves.
*/
fun safeDisplayMode(context: Context): Triple<Int, Int, Int> {
val (w, h, hz) = nativeDisplayMode(context)
return Triple(SafeArea.insetWidth(w, displaySideInsetPx(context)), h, hz)
}
/**
* True when this device's display can actually present HDR10, so we should advertise HDR to the
* host. On an SDR panel we advertise `0` instead the host then sends a proper 8-bit BT.709 stream
@@ -412,12 +561,21 @@ fun displaySupportsHdr(context: Context): Boolean {
return supported
}
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
/**
* Resolve [Settings] (with its `0`=native and [SAFE_AREA_MODE] placeholders) to the concrete mode to
* request. The safe-area sentinel is checked first because it resolves BOTH axes together it is one
* mode, not an independent width and height, and mixing half of it with a native height would ask
* for a size neither sentinel means.
*/
fun Settings.effectiveMode(context: Context): Triple<Int, Int, Int> {
val native = nativeDisplayMode(context)
val w = if (width > 0) width else native.first
val h = if (height > 0) height else native.second
val hz = if (hz > 0) hz else native.third
val base = if (width == SAFE_AREA_MODE && height == SAFE_AREA_MODE) {
safeDisplayMode(context)
} else {
nativeDisplayMode(context)
}
val w = if (width > 0) width else base.first
val h = if (height > 0) height else base.second
val hz = if (hz > 0) hz else base.third
return Triple(w, h, hz)
}
@@ -471,9 +629,10 @@ val RENDER_SCALE_OPTIONS = RenderScale.PRESETS.map { it to RenderScale.label(it)
// ---- UI option tables (value, label). The first entry is always the "auto/native" default. ----
/** (width, height, label). `(0,0)` = native display. */
/** (width, height, label). `(0,0)` = native display; [SAFE_AREA_MODE] = native minus the cutout. */
val RESOLUTION_OPTIONS = listOf(
Triple(0, 0, "Native display"),
Triple(SAFE_AREA_MODE, SAFE_AREA_MODE, "Native display (safe area)"),
Triple(1280, 720, "1280 × 720"),
Triple(1920, 1080, "1920 × 1080"),
Triple(2560, 1440, "2560 × 1440"),
@@ -539,6 +698,15 @@ fun codecOptionsFor(stored: String, av1Capable: Boolean): List<Pair<String, Stri
}
}
/** Resolved [Settings.systemButtons]: forward the raw guide/misc presses? Auto = forward on
* Android the press reaches the app on most devices, and where the shell shows its own UI
* for it that's the shell's business. */
fun Settings.systemButtonsForward(): Boolean = systemButtons != "local"
/** Resolved [Settings.guideGesture]: auto = OFF on Android (the raw press already reaches the
* host); "on" is for devices whose shell intercepts the physical guide button. */
fun Settings.guideGestureEnabled(): Boolean = guideGesture == "on"
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2,
* AV1=4, PyroWave=8 (never decodable here, but the byte is the shared contract). */
fun Settings.preferredCodec(): Int = when (codec) {
@@ -621,3 +789,17 @@ val GAMEPAD_OPTIONS = listOf(
io.unom.punktfunk.kit.Gamepad.PREF_DUALSHOCK4 to "DualShock 4",
io.unom.punktfunk.kit.Gamepad.PREF_STEAMDECK to "Steam Deck",
)
/** (stored `system_buttons` value, label) — where the guide/share presses land while streaming. */
val SYSTEM_BUTTON_OPTIONS = listOf(
"auto" to "Automatic",
"forward" to "Send to host",
"local" to "This device",
)
/** (stored `guide_gesture` value, label) — the hold-Select guide gesture. */
val GUIDE_GESTURE_OPTIONS = listOf(
"auto" to "Automatic",
"on" to "On",
"off" to "Off",
)
@@ -603,6 +603,10 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
@Composable
private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: android.content.Context) {
val (nw, nh, nhz) = nativeDisplayMode(context)
// The safe-area row carries its resolved size the same way the native row does. On a display with
// no cutout and square corners this equals the native mode — the row stays, honestly showing that
// it changes nothing here, rather than silently vanishing on some devices and not others.
val (sw, sh, _) = safeDisplayMode(context)
// "Custom…" picked while the stored size is still a preset — keeps the size fields visible
// until an edit actually makes it custom (or a preset is re-picked). Custom itself is detected
// from the stored size, never flagged (see [isCustomResolution]), so nothing new persists.
@@ -611,7 +615,13 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
SettingsGroup("Resolution") {
SettingDropdown(
label = "Resolution",
options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) } +
options = RESOLUTION_OPTIONS.map { (w, h, lbl) ->
(w to h) to when (w) {
0 -> "$lbl ($nw × $nh)"
SAFE_AREA_MODE -> "$lbl ($sw × $sh)"
else -> lbl
}
} +
// The (-1, -1) sentinel can't collide with a real size; once a custom size is
// stored its label carries the live value, like the native row carries ($nw × $nh).
((-1 to -1) to if (s.isCustomResolution()) "Custom (${s.width} × ${s.height})" else "Custom…"),
@@ -620,7 +630,10 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
caption = "The host makes a display exactly this size — no scaling. Native follows " +
"this device's panel.",
) { (w, h) ->
if (w < 0) {
// ONLY -1 is "Custom…". The other negative value is the safe-area sentinel, which is a
// stored mode like any preset — a blanket `w < 0` here would open the custom fields for it
// and overwrite it with a concrete size.
if (w == -1) {
// Seed from the current *effective* size so the fields start from something
// sensible (the resolved native mode, not the 0 × 0 placeholder).
customPicked = true
@@ -838,6 +851,25 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
caption = "The virtual pad the host creates. Automatic matches your controller; " +
"every connected one is forwarded as its own player.",
) { g -> update(s.copy(gamepad = g)) }
SettingDropdown(
label = "Guide button",
options = SYSTEM_BUTTON_OPTIONS,
selected = s.systemButtons,
field = "system_buttons",
enabled = s.gamepadForwarding,
caption = "Where the guide (Xbox/PS) and share presses go while streaming. " +
"Automatic sends them to the host whenever this device delivers them.",
) { v -> update(s.copy(systemButtons = v)) }
SettingDropdown(
label = "Hold Select for guide",
options = GUIDE_GESTURE_OPTIONS,
selected = s.guideGesture,
field = "guide_gesture",
enabled = s.gamepadForwarding,
caption = "Hold Select alone to press the host's guide button — keep holding for a " +
"Gaming-Mode host's quick-access menu. A Select tap still goes through, " +
"slightly delayed. For devices that intercept the real guide button.",
) { v -> update(s.copy(guideGesture = v)) }
DeviceScopeOnly {
ClickableRow(
title = "Connected controllers",
@@ -877,6 +909,22 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
enabled = s.gamepadForwarding,
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
)
// Both only ever apply to a captured pad, so they follow that row and gate on it.
ToggleRow(
title = "Controller haptics",
subtitle = "Play the host's fine-grained DualSense haptics on the pad itself — " +
"the pad keeps ordinary rumble for games that don't send them",
checked = s.padHaptics,
enabled = s.gamepadForwarding && s.dsCapture,
onCheckedChange = { on -> update(s.copy(padHaptics = on)) },
)
ToggleRow(
title = "Controller speaker",
subtitle = "Play audio the game sends to the controller's own speaker",
checked = s.padSpeaker,
enabled = s.gamepadForwarding && s.dsCapture,
onCheckedChange = { on -> update(s.copy(padSpeaker = on)) },
)
}
}
}
@@ -26,13 +26,25 @@ import kotlin.math.roundToInt
* presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow]`. Every read
* is length-guarded, so an older native lib simply omits the lines it can't feed.
*
* The shown `display` and `end-to-end` numbers EXCLUDE the OS present floor (see [osFloorMs]) at
* every tier, and the detailed tier names what was excluded on its own line. The principle is the
* Apple client's: metrics report what Punktfunk controls, so the compositor's own latch and scanout
* which no client can pace under is reported rather than charged. It also stops the HUD reading
* worse than it is: the usual Android streaming overlays stop measuring at decode-complete, so a
* headline that carried the compositor's wait was compared against numbers that never contained it.
*
* The RAW figures are not lost the native 1 Hz `pf.present` logcat line keeps `paceMs`, `latchMs`
* and `e2eMs` unshaved, so a HUD-off A/B and any cross-session comparison still work off the
* untouched numbers.
*
* [verbosity] selects how many lines render (each tier a superset of the last see
* [StatsVerbosity]):
* - [StatsVerbosity.COMPACT] one line, `fps · end-to-end ms · Mb/s` (+ a loss flag).
* - [StatsVerbosity.NORMAL] the res/fps/Mb·s line, the end-to-end p50/p95 headline, and the
* reliability counters (1821) when nonzero.
* - [StatsVerbosity.DETAILED] also the decoder label, the video-feed descriptor (1013), and the
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero).
* - [StatsVerbosity.DETAILED] also the decoder label, the video-feed descriptor (1013), the
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero),
* and the excluded-floor line when one was measured.
* [StatsVerbosity.OFF] renders nothing. Older native layouts simply omit the lines they lack (the
* counter line falls back to the cumulative `lostTotal` at index 9 on a pre-window lib).
*/
@@ -95,9 +107,15 @@ internal fun StatsOverlay(
// equation gains its `display` term; otherwise (older lib / no callbacks) the endpoint
// honestly stays capture→decoded — the equation always tiles the headline interval.
val dispValid = s.size >= 26 && s[22] != 0.0
// The OS present floor this window (see [osFloorMs]) is excluded from every shown
// display / end-to-end number, at every tier — it is pipeline depth no client can pace
// under, so charging it to Punktfunk made our HUD read worse than clients that simply
// never measure it. 0.0 when unmeasured, which leaves the numbers exactly as raw as
// they were.
val floorMs = osFloorMs(s)
val tag = if (skew) "" else " (same-host clock)"
val (p50, p95, endpoint) = if (dispValid) {
Triple(s[24], s[25], "capture→displayed")
Triple(shave(s[24], floorMs), shave(s[25], floorMs), "capture→displayed")
} else {
Triple(s[2], s[3], "capture→decoded")
}
@@ -120,6 +138,11 @@ internal fun StatsOverlay(
// dropping/serializing, an fps deficit is upstream.
val split = s.size >= 30 && s[29] != 0.0 && (s[26] > 0 || s[27] > 0)
val displayTerm = when {
// Floor excluded: what remains of the `display` term is the half Punktfunk
// owns (the presenter's pace wait), and the excluded line below carries the
// latch — printing the split too would report the same milliseconds twice.
dispValid && floorMs > 0 ->
" + display ${"%.1f".format(shave(s[23], floorMs))}"
dispValid && split ->
" + display ${"%.1f".format(s[23])} " +
"(pace ${"%.1f".format(s[26])} + latch ${"%.1f".format(s[27])})"
@@ -143,16 +166,14 @@ internal fun StatsOverlay(
"= $hostTerms + $decodeTerm$displayTerm$presents",
Color.White,
)
// Metric fairness: the Apple client's HUD shaves ~2 refresh periods of OS
// pipeline floor off its shown display/end-to-end; Android shows raw. This twin
// applies the same shave so iPhone↔Android HUD numbers compare directly.
if (dispValid && hz > 0) {
val shave = 2000.0 / hz
// What the numbers above leave out, named — the Apple client's
// `os present +N excluded` line, same wording so the two HUDs read alike.
// (This replaces the old "≈ Apple-HUD equiv" twin: both clients now shave, and
// Android's shave is measured rather than assumed at 2 refresh periods.)
if (floorMs > 0) {
statLine(
"≈ Apple-HUD equiv: end-to-end " +
"${"%.1f".format((s[24] - shave).coerceAtLeast(0.0))} · display " +
"${"%.1f".format((s[23] - shave).coerceAtLeast(0.0))} (2 refresh)",
Color(0xFFA8D8B8),
"os present +${"%.1f".format(floorMs)} excluded (display pipeline minimum)",
Color(0xFF9AA6B8),
)
}
}
@@ -167,6 +188,37 @@ private fun statLine(text: String, color: Color) {
Text(text, color = color, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
}
/**
* The OS present floor to exclude from the shown `display` / `end-to-end` numbers, ms the
* measured `latch` p50 at index 27, i.e. release`OnFrameRendered`: SurfaceFlinger's own latch and
* scanout. That is compositor pipeline depth no client can pace under, so it is reported as
* excluded rather than charged to Punktfunk the Apple client's policy since its presentation
* rebuild, where the same floor is measured from the display link's vend lead.
*
* Measured, not assumed: the previous Android treatment used a fixed `2000/hz` twin, but the latch
* varies with panel rate, tunnelled playback and the vendor's low-latency mode (~21 ms p50 observed
* where the ~2-interval model predicts less), and this term self-adapts to all three. It is also
* available on every render path the presenter's and both legacy release-immediately ones since
* the release stamp it starts from is parked on every render, so it does not depend on
* `presenterActive` (29).
*
* `0.0` means unmeasured no display stage this window (an older native lib, API < 33, or a
* platform that refused the callback), or no latch sample paired and every caller then leaves its
* number raw, which is the honest fallback: we exclude only what we actually measured.
*/
private fun osFloorMs(s: DoubleArray): Double {
val dispValid = s.size >= 26 && s[22] != 0.0
if (!dispValid || s.size < 28) return 0.0
return s[27].coerceAtLeast(0.0)
}
/**
* Subtract the excluded [floorMs] from a shown latency [ms], clamped at zero the percentiles are
* drawn from different sample sets (a p50 latch against a p50/p95 end-to-end), so the difference can
* legitimately go slightly negative on a well-paced window without anything being wrong.
*/
private fun shave(ms: Double, floorMs: Double): Double = (ms - floorMs).coerceAtLeast(0.0)
/**
* The single [StatsVerbosity.COMPACT] line: `238 fps · 1.3 ms · 921 Mb/s`. The end-to-end p50 term
* is dropped when no in-range latency sample landed (`latValid` false), and a loss flag
@@ -174,8 +226,9 @@ private fun statLine(text: String, color: Color) {
* one reliability signal worth surfacing even at the tersest tier.
*/
private fun compactLine(s: DoubleArray, latValid: Boolean): String {
// Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window.
val e2eP50 = if (s.size >= 26 && s[22] != 0.0) s[24] else s[2]
// Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window,
// less the excluded OS present floor — the same number the richer tiers headline.
val e2eP50 = if (s.size >= 26 && s[22] != 0.0) shave(s[24], osFloorMs(s)) else s[2]
val parts = buildList {
add("${s[0].roundToInt()} fps")
if (latValid) add("${"%.1f".format(e2eP50)} ms")
@@ -73,6 +73,7 @@ import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.deviceBodyVibrator
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.Sc2Capture
import io.unom.punktfunk.kit.SessionEndReason
import io.unom.punktfunk.kit.VideoDecoders
import io.unom.punktfunk.models.ActiveSession
import java.util.concurrent.atomic.AtomicBoolean
@@ -86,7 +87,7 @@ import kotlinx.coroutines.delay
* the connect that produced this handle.
*/
@Composable
fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> Unit) {
val handle = session.handle
val initialSettings = session.settings
val micEnabled = initialSettings.micEnabled
@@ -200,12 +201,32 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
while (true) {
delay(1000)
if (NativeBridge.nativeSessionEnded(handle)) {
Toast.makeText(
context,
"Connection lostthe host may be asleep. Wake it to reconnect.",
Toast.LENGTH_LONG,
).show()
onDisconnect()
// WHY it ended decides what the user is told. This used to show the "host may be
// asleep" line for EVERY ending — including a game the player had just quit and a
// session the host ended on purposewhich reads as a failure report for
// something nobody did wrong. Only a connection that actually died says that now.
val reason = SessionEndReason.fromNative(NativeBridge.nativeEndReason(handle))
when (reason) {
SessionEndReason.LOST ->
Toast.makeText(
context,
"Connection lost — the host may be asleep. Wake it to reconnect.",
Toast.LENGTH_LONG,
).show()
SessionEndReason.HOST_ERROR ->
Toast.makeText(
context,
"The host ended the session with an error.",
Toast.LENGTH_LONG,
).show()
// Deliberate endings — the player quit the game, the host was stopped, or we
// closed it. Leaving the stream IS the feedback; a toast would only add noise.
SessionEndReason.GAME_EXITED,
SessionEndReason.HOST_ENDED,
SessionEndReason.LOCAL,
SessionEndReason.NONE -> {}
}
onSessionEnded(reason)
return@LaunchedEffect
}
}
@@ -323,13 +344,14 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// controller (Automatic). Built here, released on dispose.
val router = GamepadRouter(
context, handle, initialSettings.gamepad, initialSettings.gamepadForwarding,
initialSettings.systemButtonsForward(), initialSettings.guideGestureEnabled(),
)
activity?.gamepadRouter = router
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
// the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it
// (must be held ~1.5 s) and fires onExitChord on its main-thread timer, so leave the stream
// the same way the Back gesture does.
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onSessionEnded(SessionEndReason.LOCAL) }
router.onExitChord = { activity?.requestStreamExit?.invoke() }
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
@@ -506,6 +528,28 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
var dsUsbReceiver: BroadcastReceiver? = null
if (ds != null) {
feedback.sink = ds
// Tier-A pad audio: render the host's 0xD1 streams on the pad's own 4-channel USB
// audio device. Bound here rather than inside DsCapture because the session handle
// lives at this layer; DsCapture decides WHEN (it knows the wire index and the link
// lifetime), this decides WHETHER.
if (initialSettings.padHaptics || initialSettings.padSpeaker) {
ds.padAudio = object : DsCapture.PadAudioHook {
override fun start(pad: Int, fd: Int) {
val ok = NativeBridge.nativeStartPadAudio(
handle,
pad,
fd,
initialSettings.padHaptics,
initialSettings.padSpeaker,
)
Log.i("punktfunk", "pad audio on pad $pad: ${if (ok) "started" else "unavailable"}")
}
// Returns only once the render thread is joined — DsCapture calls this before
// closing the connection whose descriptor that thread borrows.
override fun stop(pad: Int) = NativeBridge.nativeStopPadAudio(handle, pad)
}
}
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val usbDev = ds.findUsbDevice()
when {
@@ -594,7 +638,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
}
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onSessionEnded(SessionEndReason.LOCAL) }
// Leaving the app (Home, task switch, screen off) MUST end the session. Android does not
// suspend a process for going to background, so without this the native worker kept running and
@@ -602,14 +646,14 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// host still saw a live client and held the session (and its display + encoder) open until the
// OS eventually reclaimed the process, which on a TV box is effectively never.
//
// Route it through `onDisconnect()` so the composable's `onDispose` above runs the one real
// Route it through `onSessionEnded()` so the composable's `onDispose` above runs the one real
// teardown path. Deliberately NOT a `nativeDisconnectQuit`: backgrounding isn't a user "quit",
// so the host should linger the display and make coming straight back a fast reconnect.
DisposableEffect(handle) {
val lifecycle = (context as? LifecycleOwner)?.lifecycle
val obs = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) {
onDisconnect()
onSessionEnded(SessionEndReason.LOCAL)
}
}
lifecycle?.addObserver(obs)
@@ -61,6 +61,16 @@ data class ActiveSession(
* from "a different host" (a notice; a URL may never preempt a live session).
*/
val hostId: String? = null,
/**
* This session was started by launching a title from [hostId]'s library, rather than by
* connecting to the host's desktop.
*
* Decides where the client goes when the session ENDS: a title launched out of a library
* belongs back in that library when its game exits one press from the next one not on the
* host-selection screen. Only meaningful together with a
* [io.unom.punktfunk.kit.SessionEndReason.GAME_EXITED] ending.
*/
val launchedFromLibrary: Boolean = false,
)
/** Trust state of a host, shown as a colored pill on its card. */
@@ -0,0 +1,158 @@
package io.unom.punktfunk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
// The console UI's background palettes. These assertions are the CONTRACT the Rust
// (`pf-console-ui::library`) and Swift (`GamepadPalette.swift`) ports reproduce — the same ids in
// the same order, the same light/dark split, the same ramp — so one `ui_palette` value is one look
// on every client.
class GamepadPaletteTest {
private fun luma(c: Triple<Double, Double, Double>) =
0.2126 * c.first + 0.7152 * c.second + 0.0722 * c.third
/** Hue angle in degrees, or null for something too grey to have one. */
private fun hue(c: Triple<Double, Double, Double>): Double? {
val (r, g, b) = c
val max = maxOf(r, g, b)
val min = minOf(r, g, b)
val d = max - min
if (d < 0.04) return null
val h = when (max) {
r -> 60.0 * (((g - b) / d) % 6.0)
g -> 60.0 * ((b - r) / d + 2.0)
else -> 60.0 * ((r - g) / d + 4.0)
}
return (h + 360.0) % 360.0
}
/** Ids, order and the light/dark split are the cross-client contract. */
@Test
fun tableMatchesTheOtherClients() {
assertEquals(
listOf(
"violet", "nebula", "abyss", "ember", "moss", "graphite",
"holo", "sunset", "bloom", "dawn", "mint", "opal",
),
GamepadPalette.ALL.map { it.id },
)
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
val firstLight = GamepadPalette.ALL.indexOfFirst { it.light }
assertEquals(6, firstLight)
assertTrue(GamepadPalette.ALL.drop(firstLight).all { it.light })
// An unknown name is a newer client's palette, not an error.
assertEquals("violet", GamepadPalette.named("chartreuse").id)
assertEquals("violet", GamepadPalette.named("").id)
// The brand default keeps the shipped field rather than a generated ramp.
assertTrue(GamepadPalette.named("violet").stops.isEmpty())
}
/**
* A palette must read as SEVERAL hues, not one hue at several brightnesses that was exactly
* the complaint about the hue-rotation model this replaced.
*/
@Test
fun everyPaletteIsMultiTone() {
for (p in GamepadPalette.ALL) {
val stops = p.stops.ifEmpty { continue }
val hues = stops.mapNotNull { hue(it) }
assertTrue("${p.id}: too few coloured stops", hues.size >= 3)
var spread = 0.0
for (a in hues) {
for (b in hues) {
val d = Math.abs(a - b) % 360.0
spread = maxOf(spread, minOf(d, 360.0 - d))
}
}
// Graphite and Opal are deliberately near-neutral; the rest must travel.
val floor = if (p.id == "graphite" || p.id == "opal") 20.0 else 45.0
assertTrue("${p.id} spans only $spread° of hue", spread >= floor)
}
}
/** A pale palette really is pale — its ink flips, so a mislabelled one is unreadable. */
@Test
fun palettesAreHonestAboutLightness() {
for (p in GamepadPalette.ALL) {
if (p.light) {
assertTrue("${p.id}'s ground is dark", luma(p.ground) > 0.6)
assertTrue("${p.id}'s accent is too pale", luma(p.accent) < 0.45)
} else {
assertTrue("${p.id}'s ground is light", luma(p.ground) < 0.2)
assertTrue("${p.id}'s accent is too dark", luma(p.accent) > 0.25)
}
}
}
/** The ramp is the shared sampling rule the Rust and Swift ports reproduce. */
@Test
fun rampInterpolatesBetweenStops() {
val stops = listOf(
Triple(0.0, 0.0, 0.0), Triple(1.0, 0.0, 0.0), Triple(1.0, 1.0, 1.0),
)
assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(stops, 0.0))
assertEquals(Triple(1.0, 1.0, 1.0), GamepadPalette.ramp(stops, 1.0))
assertEquals(Triple(1.0, 0.0, 0.0), GamepadPalette.ramp(stops, 0.5))
assertEquals(0.5, GamepadPalette.ramp(stops, 0.25).first, 1e-9)
// Out of range clamps rather than throwing.
assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(stops, -3.0))
assertEquals(Triple(1.0, 1.0, 1.0), GamepadPalette.ramp(stops, 9.0))
assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(emptyList(), 0.5))
}
/** The ink a palette calls for: white on a dark field, near-black on a pale one. */
@Test
fun inkFollowsTheField() {
val dark = GamepadInk.of(GamepadPalette.named("violet"))
assertTrue(!dark.isLight)
assertEquals(1f, dark.fg.red, 1e-6f)
assertEquals(1f, dark.shadeScale, 1e-6f)
val light = GamepadInk.of(GamepadPalette.named("holo"))
assertTrue(light.isLight)
assertTrue("pale fields need dark ink", light.fg.red < 0.3f)
// A pale field's scrims must pull far less, or they bleach the gradient.
assertTrue(light.shadeScale < 0.5f)
}
/**
* Every settings row lands in exactly one tab a row missing from the tab map is a setting
* that became unreachable on a TV, which is precisely what this screen exists to prevent.
*/
@Test
fun everySettingsRowHasATab() {
val rows = buildSettingsRows(Settings(), hasBodyVibrator = true, av1Capable = true) {}
assertTrue(rows.isNotEmpty())
assertEquals(rows.size, rows.map { it.id }.toSet().size)
// Profiles is built separately (from the catalog), so no settings row claims it.
assertTrue(rows.none { it.tab == GpTab.PROFILES })
for (t in listOf(GpTab.STREAM, GpTab.VIDEO, GpTab.AUDIO, GpTab.CONTROLLER, GpTab.INTERFACE)) {
assertTrue("$t is empty", rows.any { it.tab == t })
}
}
/** The Background row steps the shared `ui_palette` key and wraps on A, like every choice row. */
@Test
fun backgroundRowStepsTheSharedKey() {
var s = Settings()
fun rows() = buildSettingsRows(s, hasBodyVibrator = false, av1Capable = false) { s = it }
fun palette() = rows().first { it.id == "palette" }
assertEquals("violet", s.uiPalette)
assertEquals("Violet", palette().value)
assertTrue("already the first = thud", !palette().adjust(-1))
assertTrue(palette().adjust(1))
assertEquals(GamepadPalette.ALL[1].id, s.uiPalette)
// A from the last entry wraps home.
s = s.copy(uiPalette = GamepadPalette.ALL.last().id)
palette().activate()
assertEquals("violet", s.uiPalette)
// A store written by a newer client shows the palette that is actually drawing.
s = s.copy(uiPalette = "chartreuse")
assertEquals("Violet", palette().value)
}
}
@@ -0,0 +1,97 @@
package io.unom.punktfunk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The controller-navigable settings rows: what the master forwarding switch governs, and that a
* governed row is inert rather than merely dim.
*
* The touch settings and the desktop console have carried this relationship for a while (`enabled =
* s.gamepadForwarding` / `RowSpec.enabled`); this screen dimmed nothing and stepped everything, so
* these tests pin both halves the flag AND the refusal to write.
*/
class GamepadSettingsRowsTest {
/** Rows for a given forwarding state, capturing whatever a row writes back. */
private fun rows(
forwarding: Boolean,
sink: MutableList<Settings> = mutableListOf(),
): List<GpRow> = buildSettingsRows(
Settings(gamepadForwarding = forwarding),
hasBodyVibrator = true,
av1Capable = true,
) { sink += it }
private fun row(rows: List<GpRow>, id: String): GpRow =
rows.first { it.id == id }
/** Every row that only means something while a controller is actually being forwarded. */
private val governed = listOf("padType", "systemButtons", "guideGesture", "sc2", "dsCapture")
@Test
fun `forwarding off dims every row that depends on it`() {
val off = rows(forwarding = false)
for (id in governed) {
assertFalse("$id should be dimmed with forwarding off", row(off, id).enabled)
}
// The master switch itself stays live — otherwise it could never be turned back on.
assertTrue(row(off, "padForward").enabled)
}
@Test
fun `forwarding on leaves them all live`() {
val on = rows(forwarding = true)
for (id in governed) {
assertTrue("$id should be live with forwarding on", row(on, id).enabled)
}
}
@Test
fun `a dimmed row is inert - liveRow withholds it and nothing is written`() {
val writes = mutableListOf<Settings>()
val off = rows(forwarding = false, sink = writes)
for (id in governed) {
val i = off.indexOfFirst { it.id == id }
assertNull("$id must not be reachable while dimmed", liveRow(off, i))
// What the screen actually does on left/right/A — the whole point is that it no-ops.
liveRow(off, i)?.adjust(1)
liveRow(off, i)?.adjust(-1)
liveRow(off, i)?.activate()
}
assertEquals("a dimmed row wrote a setting", emptyList<Settings>(), writes)
}
@Test
fun `the same rows do write once forwarding is on`() {
val writes = mutableListOf<Settings>()
val on = rows(forwarding = true, sink = writes)
val i = on.indexOfFirst { it.id == "sc2" }
assertNotNull(liveRow(on, i))
liveRow(on, i)?.activate()
assertEquals(1, writes.size)
assertFalse("activate flips the toggle", writes[0].sc2Capture)
}
/**
* R18: the Sony passthrough toggle the touch settings have always had. It matters most exactly
* where this screen is the only one reachable a TV box has no touch interface to fall back to.
*/
@Test
fun `the DualSense passthrough toggle is present, next to its SC2 twin`() {
val on = rows(forwarding = true)
val ids = on.map { it.id }
assertTrue("dsCapture row is missing", "dsCapture" in ids)
assertEquals(
"the two passthrough rows belong side by side",
ids.indexOf("sc2") + 1,
ids.indexOf("dsCapture"),
)
// Drawn as a switch, and reading the persisted default.
assertEquals(true, row(on, "dsCapture").toggled)
}
}
@@ -0,0 +1,48 @@
package io.unom.punktfunk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pure JVM test of the safe-area stream geometry ([SafeArea]) and the sentinel that selects it
* the width-only inset that keeps the picture clear of the cutout and the rounded corners.
* Run: `./gradlew :app:testDebugUnitTest`.
*/
class SafeAreaTest {
@Test
fun insetsBothSidesAndStaysHostValid() {
// A punch-hole phone: 2400 px wide, 96 px of unsafe edge per side → 2208.
assertEquals(2400 - 96 * 2, SafeArea.insetWidth(2400, 96))
// Odd results even-floor — the host rejects odd dimensions outright, and an inset
// subtraction lands odd about half the time.
assertEquals(0, SafeArea.insetWidth(2401, 95) % 2)
// No cutout and square corners → the native width, unchanged.
assertEquals(2400, SafeArea.insetWidth(2400, 0))
}
@Test
fun absurdInsetsCannotDriveTheModeUnderTheHostFloor() {
assertEquals(SafeArea.MIN_WIDTH, SafeArea.insetWidth(1280, 5000))
// A negative reading is treated as no inset rather than widening past the panel.
assertEquals(1280, SafeArea.insetWidth(1280, -40))
}
@Test
fun safeModeIsNarrowerThanNativeWheneverThereIsAnInset() {
val native = 2556
assertTrue(SafeArea.insetWidth(native, 60) < native)
}
@Test
fun theSentinelIsAPresetAndNeverReadsAsCustom() {
// The safe-area mode is a stored preset, not a typed size: `isCustomResolution` must be
// false for it, or the touch settings would open the custom width/height fields on it and
// the gamepad screen would prepend a bogus "Custom · -2 × -2" row.
val s = Settings(width = SAFE_AREA_MODE, height = SAFE_AREA_MODE)
assertTrue(!s.isCustomResolution())
// And it must be distinct from the UI's own "Custom…" sentinel (-1).
assertTrue(SAFE_AREA_MODE != -1)
assertTrue(RESOLUTION_OPTIONS.any { it.first == SAFE_AREA_MODE && it.second == SAFE_AREA_MODE })
}
}
@@ -106,6 +106,14 @@ class ScreenshotTest {
@Test
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
@Test
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
/** A PALE palette: the whole UI flips to dark ink on white frost, which only a shot proves. */
@Test
fun consoleSettingsLight() =
shootRoot("console-settings-light") { ConsoleSettingsScene(paletteId = "holo") }
@Test
fun trust() = shootScreen("trust") {
HostsScene()
@@ -31,6 +31,12 @@ import io.unom.punktfunk.BrandDark
import io.unom.punktfunk.ConnectModal
import io.unom.punktfunk.ConnectPhase
import io.unom.punktfunk.ConnectTakeover
import androidx.compose.runtime.CompositionLocalProvider
import io.unom.punktfunk.GamepadInk
import io.unom.punktfunk.GamepadPalette
import io.unom.punktfunk.GamepadSettingsScreen
import io.unom.punktfunk.LocalGamepadInk
import io.unom.punktfunk.LocalGamepadPalette
import io.unom.punktfunk.Settings
import io.unom.punktfunk.TouchMode
import io.unom.punktfunk.SettingsCategory
@@ -355,9 +361,11 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
// dispValid, displayP50, e2eDispP50, e2eDispP95].
// 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its
// video-feed line; the display stage is valid (dispValid 1) so the headline is the
// directly-measured capture→displayed pair (1.8/2.6) and the Phase-2 stage terms
// (host 0.6 + network 0.3 + decode 0.4 + display 0.5) tile it, rendering the full split
// equation; the decoder label shows the ranked low-latency decoder. Light per-window loss
// directly-measured capture→displayed pair, less the excluded OS present floor (the 0.3
// latch p50) — 1.5/2.3 shown from 1.8/2.6 raw — and the Phase-2 stage terms
// (host 0.6 + network 0.3 + decode 0.4 + display 0.2) tile the shaved headline, with the
// `os present +0.3 excluded` line naming what came off; the decoder label shows the ranked
// low-latency decoder. Light per-window loss
// (lost 2 · skipped 1 · FEC 5 of 238) so the reliability line (NORMAL/DETAILED) and the
// compact loss flag both render.
StatsOverlay(
@@ -404,3 +412,25 @@ internal fun WakeTimedOutScene() =
@Composable
internal fun ConnectConsoleScene() =
ConnectTakeover(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {})
/**
* The real console settings screen the section tab strip, the glass rows, the focused row's
* unfolded detail, and the living (calmed) backdrop behind them. The touch [SettingsScene] can't
* stand in for it: this is a different screen with different navigation, and the strip is the part
* a layout regression would eat first.
*/
@Composable
internal fun ConsoleSettingsScene(paletteId: String = "violet") {
// The scene calls the screen directly, so it has to publish the palette locals `App` would
// normally provide — without them a light palette would render with the default DARK ink and
// the shot would silently prove nothing.
val palette = GamepadPalette.named(paletteId)
CompositionLocalProvider(
LocalGamepadPalette provides palette,
LocalGamepadInk provides GamepadInk.of(palette),
) {
GamepadSettingsScreen(
initial = SHOT_SETTINGS.copy(uiPalette = paletteId), onChange = {}, onBack = {},
)
}
}
@@ -23,8 +23,9 @@ import android.view.InputDevice
* Input: parse ([DsDevice.parseState]) typed mirror on an [GamepadRouter.ExternalPad] (buttons
* diffed, axes on-change the exit chord participates like any pad) + the rich plane (touch
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
* device units, the wire's contract). The wire slot is claimed lazily on the FIRST parsed report
* and freed on unplug/[stop], so indices never leak.
* device units, the wire's contract). The wire slot is claimed when the capture engages, with the
* first parsed report as the fallback for a claim that found no free index, and freed on
* unplug/[stop], so indices never leak.
*
* Feedback: implements [GamepadFeedback.PadFeedbackSink] rumble / trigger / lightbar / player
* LED events addressed to this pad's wire index become USB output reports on the physical pad
@@ -78,6 +79,33 @@ class DsCapture(
@Volatile
var onActiveChanged: ((active: Boolean) -> Unit)? = null
/**
* Tier-A pad audio, bound by the app layer (which owns the session handle).
*
* [start] is called once the router has assigned this pad a wire index, which the host uses to
* address the `0xD1` stream. [stop] is called **before** the USB link closes on [stop] and on
* unplug alike and must not return until nothing is still writing to the descriptor.
*/
interface PadAudioHook {
fun start(pad: Int, fd: Int)
fun stop(pad: Int)
}
@Volatile
var padAudio: PadAudioHook? = null
/** True once [PadAudioHook.start] has run for the current capture, so it fires exactly once. */
@Volatile private var padAudioStarted = false
/**
* The renderer's OWN connection to the pad.
*
* It must not share [usb]'s descriptor: two transfer engines on one usbfs descriptor reap each
* other's completions (see [HidUsbLink.openAuxConnection]), which strands both the HID reader
* and the audio ring. Closed only after the hook's stop has returned.
*/
@Volatile private var padAudioConn: android.hardware.usb.UsbDeviceConnection? = null
val isActive: Boolean get() = model != null
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
@@ -105,18 +133,28 @@ class DsCapture(
// (the same init hid-playstation/SDL send on open).
if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m))
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
ensureSlot(m)
onActiveChanged?.invoke(true)
return true
}
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
fun stop() {
// Before anything touches the link: the pad-audio renderer borrows this connection's
// descriptor, and `usb.stop()` closes it. The hook does not return until its thread is
// joined, so ordering this first is what makes the borrow sound.
stopPadAudio()
val m = model
if (m != null) {
// The interfaces are about to release with the kernel driver still detached — a
// mid-rumble teardown would leave the motors running with nobody to stop them.
// EP0-direct (the reader thread is stopping; the queue would never drain).
usb.writeControl(stopReport(m))
// Nothing can retry after this point, so a failure is worth saying out loud: it is
// the difference between a quiet pad and one that buzzes until it is unplugged.
if (!usb.writeControl(stopReport(m))) Log.w(TAG, "teardown rumble stop was not written")
// Motors silenced above; this hands back the lightbar, player LEDs and adaptive
// triggers the game was holding, which outlive the link just as stubbornly.
resetRichFeedback(m)
}
disarmBackstop()
usb.stop()
@@ -131,20 +169,119 @@ class DsCapture(
private fun onReport(report: ByteArray, len: Int) {
val m = model ?: return
if (!DsDevice.parseState(m, report, len, state)) return
val p = pad ?: router.openExternal(m.pref)?.also {
pad = it
Log.i(TAG, "captured $m → wire pad ${it.index}")
} ?: return // all 16 wire indices taken — drop until one frees
// Normally claimed already, at capture time; this is the retry for a capture that engaged
// while every wire index was taken.
val p = pad ?: ensureSlot(m) ?: return // all 16 taken — drop until one frees
mirrorTyped(p)
mirrorRich(p, m)
}
/**
* Claim this capture's wire slot and start pad audio on it. Idempotent; null when all 16
* indices are taken.
*
* Claimed when the capture engages rather than on the first report, because a pad that reports
* nothing is still a pad: with the lazy claim, a captured-but-silent pad left the host with no
* arrival, hence no virtual pad, no pad-audio capability and so no `0xD1` a renderer sitting
* at zero frames, indistinguishable from a broken pipeline (it took a physical replug to
* clear). Callable from the main thread (capture start) and the link thread (the fallback).
*/
@Synchronized
private fun ensureSlot(m: DsDevice.Model): GamepadRouter.ExternalPad? {
pad?.let { return it }
val p = router.openExternal(m.pref) ?: return null
pad = p
Log.i(TAG, "captured $m → wire pad ${p.index}")
// The wire index exists from here on, and the host addresses pad audio by it.
startPadAudio(p.index)
return p
}
/** Hand the renderer its own descriptor. Caller holds the monitor; fires once per capture. */
private fun startPadAudio(index: Int) {
val hook = padAudio ?: return
if (padAudioStarted) return
// A dedicated connection, NOT usb.fileDescriptor — see padAudioConn.
val conn = usb.openAuxConnection()
val fd = conn?.fileDescriptor ?: -1
if (fd < 0) {
conn?.close()
Log.w(TAG, "pad audio: could not open a second USB connection")
return
}
padAudioConn = conn
padAudioStarted = true
// Real-world self test, opt-in: `adb shell setprop debug.punktfunk.pad_audio_selftest 3`
// drives the voice coils for N seconds through the actual client path before the renderer
// takes over — the one check that proves the descriptor, the interface claim and the write
// path all work on THIS device, without needing a host to be streaming. Same convention as
// debug.punktfunk.force_parts.
val secs = runCatching {
Class.forName("android.os.SystemProperties")
.getMethod("get", String::class.java, String::class.java)
.invoke(null, "debug.punktfunk.pad_audio_selftest", "0") as String
}.getOrNull()?.toIntOrNull() ?: 0
if (secs > 0) {
// Diagnostic mode: the self test OWNS this descriptor for the capture, and the renderer
// must not also drive it — two engines on one usbfs descriptor reap each other's
// completions, which is precisely the fault this test exists to expose.
Thread({
val r = NativeBridge.nativePadAudioSelfTest(fd, secs, 60)
Log.i(TAG, "pad audio self-test → ${if (r > 0) "PASS ($r frames)" else "FAIL ($r)"}")
}, "pf-pad-selftest").start()
} else {
// B6: hand the coils back before the first haptics frame. Any rumble earlier in this
// session asserted HAPTICS_SELECT, which firmware-mutes them, and nothing else ever
// clears it — so without this the stream renders into a muted actuator and looks for
// all the world like the host is sending nothing.
restoreAudioHaptics()
hook.start(index, fd)
}
}
/**
* B6: clear the rumble/haptics-select bits so the pad's voice coils answer the audio-haptics
* path again. EP0-direct, like the other out-of-band writes here: this has to land even when
* the interrupt-OUT queue is busy or draining, and it is idempotent.
*/
private fun restoreAudioHaptics() {
val m = model ?: return
if (m == DsDevice.Model.DUALSHOCK4) return // no voice coils, no audio-haptics path
if (!usb.writeControl(DsDevice.ds5AudioHapticsReport(m))) {
Log.w(TAG, "pad audio: could not hand the coils back to audio haptics")
}
}
/**
* Stop the renderer, then close the connection whose descriptor it borrows in that order.
*
* Runs on [stop] and on unplug alike. Skipping it on unplug left the render thread writing to a
* descriptor whose device was gone, leaked the connection, and because the started flag stayed
* set and the native tier-A registry stayed armed for that index cost the pad both its pad
* audio and its wire rumble on the way back in.
*/
@Synchronized
private fun stopPadAudio() {
if (!padAudioStarted) return
padAudioStarted = false
// The hook's stop joins the render thread, so nothing is using the descriptor once it
// returns — only then is it safe to close the connection that owns it.
pad?.let { padAudio?.stop(it.index) }
padAudioConn?.close()
padAudioConn = null
}
private fun onLinkClosed() {
Log.i(TAG, "Sony USB link closed (unplug)")
// Before releaseSlot(), which forgets the wire index the renderer is addressed by.
stopPadAudio()
disarmBackstop()
val wasActive = model != null
model = null
releaseSlot()
// Release the transport too: the link only *signals* the drop, so without this an unplug
// left its connection open, its interfaces claimed and its detach receiver registered.
usb.stop()
if (wasActive) onActiveChanged?.invoke(false)
}
@@ -216,17 +353,24 @@ class DsCapture(
override fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long) {
val m = model ?: return
if (low == 0 && high == 0) {
disarmBackstop()
} else {
armBackstop(backstopMs)
}
if (m == DsDevice.Model.DUALSHOCK4) {
val stop = low == 0 && high == 0
if (!stop) armBackstop(backstopMs)
val sent = if (m == DsDevice.Model.DUALSHOCK4) {
ds4Low = low
ds4High = high
writeDs4()
} else {
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high))
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high), OutReportQueue.KEY_RUMBLE)
}
if (stop) {
// Disarm only once the stop is actually on its way. Dropping the net *before* the
// write — as this used to — meant a discarded stop left the motors running with
// nothing scheduled to try again; a USB pad holds its last level until told zero.
if (sent) disarmBackstop() else armBackstop(STOP_RETRY_MS)
// B6: the stop report just re-asserted HAPTICS_SELECT on its way past, so if a
// haptics stream is live the coils it drives were muted by the very write that
// silenced the motors. Give them back.
if (sent && padAudioStarted) restoreAudioHaptics()
}
}
@@ -252,6 +396,9 @@ class DsCapture(
usb.writeRaw(0, DsDevice.ds5TriggerReport(m, which, effect))
}
// Coalescable: the DS4's write is full-state (motors AND lightbar, rebuilt from the current
// fields on every call), so a newer one supersedes an older one wholesale — nothing is lost by
// collapsing a backlog of them down to the last.
private fun writeDs4() = usb.writeRaw(
0,
DsDevice.ds4Report(
@@ -261,8 +408,38 @@ class DsCapture(
(ds4Rgb shr 8) and 0xFF,
ds4Rgb and 0xFF,
),
OutReportQueue.KEY_RUMBLE,
)
/**
* Hand the pad back neutral: adaptive triggers released, lightbar dark, player LEDs clear.
*
* Rumble stops the moment nothing renews it, but these are LATCHED in the controller's
* firmware they outlive the stream, the app, and being unplugged. Ending a session while a
* game held a weapon's trigger resistance left the physical trigger stiff afterwards, with
* nothing to release it but another game that happens to set one.
*
* EP0-direct like the rumble stop above: the reader thread is stopping, so the interrupt-OUT
* queue would never drain. Writes are best-effort the pad may already be gone.
*/
private fun resetRichFeedback(m: DsDevice.Model) {
if (m == DsDevice.Model.DUALSHOCK4) {
// No adaptive triggers or player LEDs on a DS4, and its write is full-state, so
// blacking the lightbar is a single composed report.
ds4Rgb = 0
usb.writeControl(DsDevice.ds4Report(0, 0, 0, 0, 0))
return
}
// An all-zero effect block is mode 0x00 — no effect — which is what releases the trigger.
for (which in 0..1) {
usb.writeControl(
DsDevice.ds5TriggerReport(m, which, ByteArray(DsDevice.TRIGGER_EFFECT_LEN)),
)
}
usb.writeControl(DsDevice.ds5LightbarReport(m, 0, 0, 0))
usb.writeControl(DsDevice.ds5PlayerLedsReport(m, 0))
}
/** The report that stops the motors. The DS4's is a full-state write, so it zeroes the
* composed motor state and carries the current lightbar rather than blacking it out. */
private fun stopReport(m: DsDevice.Model): ByteArray = if (m == DsDevice.Model.DUALSHOCK4) {
@@ -284,7 +461,12 @@ class DsCapture(
backstop?.let { mainHandler.removeCallbacks(it) }
val r = Runnable {
backstop = null
model?.let { usb.writeRaw(0, stopReport(it)) }
val m = model ?: return@Runnable
// The net itself can be refused (a full queue, a connection going away). Re-arm rather
// than give up: this is the last thing between a stalled poll thread and a pad that
// buzzes until it is unplugged. It stops re-arming as soon as the link closes, which
// clears `model` and disarms.
if (!usb.writeRaw(0, stopReport(m), OutReportQueue.KEY_RUMBLE)) armBackstop(STOP_RETRY_MS)
}
backstop = r
mainHandler.postDelayed(r, ms.coerceAtLeast(1))
@@ -297,5 +479,9 @@ class DsCapture(
private companion object {
const val TAG = "DsCapture"
/** How soon to retry a rumble stop whose write was rejected. Short: the motors are running
* and the host has already moved on, so nothing else is coming to silence them. */
const val STOP_RETRY_MS = 100L
}
}
@@ -276,11 +276,26 @@ object DsDevice {
* the classic compat-vibration path AND `VIBRATION2` (firmware 2.24's full-range replot;
* older firmware ignores the unknown flag2 bit) the host parser accepts either.
*/
/**
* B6: hand the voice coils back to the audio-haptics path.
*
* Every [ds5RumbleReport] asserts `HAPTICS_SELECT` (flag0 bit1), which is SDL's
* "disable audio haptics" bit the firmware mutes the coils the 0xD1 haptics stream drives.
* Until now NOTHING ever cleared it again, so a single rumble anywhere in a session left tier-A
* haptics silent for the rest of that pad's life, with no error and nothing in a log.
*
* The undo is a report whose flag0 has BOTH bits clear (SDL's own comment: "Leaving emulated
* rumble bits off will restore audio haptics"). No other valid flag is set, so nothing else
* about the pad's state is touched. Mirrors `Ds5Feedback::audio_haptics_packet` on the desktop
* client, which is the same packet one transport over.
*/
fun ds5AudioHapticsReport(model: Model): ByteArray = newDs5(model)
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
it[39] = DS5_FLAG2_VIBRATION2.toByte()
it[3] = amp8(high).toByte()
it[4] = amp8(low).toByte()
it[3] = wireAmplitudeToByte(high).toByte()
it[4] = wireAmplitudeToByte(low).toByte()
}
/**
@@ -324,17 +339,11 @@ object DsDevice {
ByteArray(Model.DUALSHOCK4.outputSize).also {
it[0] = 0x05
it[1] = (DS4_FLAG0_MOTORS or DS4_FLAG0_LED).toByte()
it[4] = amp8(high).toByte()
it[5] = amp8(low).toByte()
it[4] = wireAmplitudeToByte(high).toByte()
it[5] = wireAmplitudeToByte(low).toByte()
it[6] = r.toByte()
it[7] = g.toByte()
it[8] = b.toByte()
}
// Wire u16 amplitude → motor byte; a nonzero command never collapses to 0 (parity with the
// vibrator path's toAmplitude).
private fun amp8(v16: Int): Int {
val a = (v16 ushr 8) and 0xFF
return if (v16 != 0 && a == 0) 1 else a
}
}
@@ -88,6 +88,9 @@ class GamepadFeedback(
const val TAG_PLAYER_LEDS: Byte = 0x02
const val TAG_TRIGGER: Byte = 0x03
const val TAG_HID_RAW: Byte = 0x05
/** Sparse-log cadence for swallowed render failures — see [noteRenderFailure]. */
const val LOG_EVERY = 128L
}
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 2830). */
@@ -125,37 +128,51 @@ class GamepadFeedback(
fun start() {
running = true
rumbleThread = Thread({
var failures = 0L
while (running) {
val ev = NativeBridge.nativeNextRumble(handle)
if (ev < 0L) continue // timeout / closed
// ev bits 49..52 = wire pad index; bits 32..47 = backstop duration (ms);
// 16..31 = low; 0..15 = high. These are EFFECTIVE commands from the core's shared
// rumble policy engine — it owns every lease/staleness/close decision (uniform
// across all clients; the old 60 s legacy-host exposure is gone) and emits
// explicit zeros, so apply verbatim: (0, 0) = cancel, non-zero = one-shot for
// the backstop (the hardware net under a stalled poll thread).
val pad = ((ev ushr 49) and 0xFL).toInt()
val backstopMs = ((ev ushr 32) and 0xFFFF)
renderRumble(
pad,
((ev ushr 16) and 0xFFFF).toInt(),
(ev and 0xFFFF).toInt(),
backstopMs,
)
// Layout + semantics live in `unpackRumbleEvent` (RumbleWire.kt), tested there
// against the Rust packer.
val cmd = unpackRumbleEvent(ev) ?: continue // timeout / closed
// Rendering is binder calls into the vibrator service, and every one of them can
// throw unchecked — DeadSystemRuntimeException when system_server goes down, and
// the ordinary RuntimeException a dying service wraps its RemoteException in.
// Unguarded, ONE of those killed this thread outright: `running` stayed true, so
// nothing noticed and nothing restarted it, and rumble was gone for the rest of
// the session. Losing a single command is recoverable; losing the loop is not.
runCatching {
renderRumble(cmd.pad, cmd.low, cmd.high, cmd.backstopMs)
}.onFailure { failures = noteRenderFailure("rumble", it, failures) }
}
}, "pf-rumble").apply { isDaemon = true; start() }
hidoutThread = Thread({
// 128: the raw as-is passthrough events are [pad][kind tag][report kind][≤64 bytes].
val buf = ByteBuffer.allocateDirect(128)
var failures = 0L
while (running) {
val n = NativeBridge.nativeNextHidout(handle, buf)
if (n < 0) continue // timeout / closed
dispatchHidout(buf, n)
// Same hazard as the rumble loop above: lights/trigger rendering is binder and USB
// calls, and an unchecked throw here would silently end the rich-feedback plane.
runCatching { dispatchHidout(buf, n) }
.onFailure { failures = noteRenderFailure("hidout", it, failures) }
}
}, "pf-hidout").apply { isDaemon = true; start() }
}
/**
* Record a render failure the poll loop swallowed, and return the updated count. Logged on the
* first occurrence and sparsely after: a genuinely dead vibrator service fails on *every*
* command, which at a rumble plane's rate would bury the log.
*/
private fun noteRenderFailure(plane: String, t: Throwable, seen: Long): Long {
if (seen == 0L || seen % LOG_EVERY == 0L) {
Log.w(TAG, "$plane render failed (#${seen + 1}) — command dropped, poll loop alive", t)
}
return seen + 1
}
/** Idempotent. Stops + joins the poll threads (must complete before the router is released / handle freed). */
fun stop() {
running = false
@@ -264,12 +281,12 @@ class GamepadFeedback(
return
}
val bind = rumbleBindFor(pad) ?: return
val lo = toAmplitude(low)
val hi = toAmplitude(high)
val lo = wireAmplitudeToByte(low)
val hi = wireAmplitudeToByte(high)
val m = bind.vm
if (m != null) {
if (lo == 0 && hi == 0) {
m.cancel() // (0,0) = stop
runCatching { m.cancel() } // (0,0) = stop
return
}
val combo = CombinedVibration.startParallel()
@@ -294,7 +311,7 @@ class GamepadFeedback(
// API 2830 legacy single-motor path: blend both motors into one effect.
val lv = bind.legacy ?: return
if (lo == 0 && hi == 0) {
lv.cancel() // (0,0) = stop
runCatching { lv.cancel() } // (0,0) = stop
return
}
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
@@ -314,8 +331,8 @@ class GamepadFeedback(
*/
private fun renderDeviceRumble(low: Int, high: Int, durationMs: Long) {
val v = deviceVibrator ?: return
val lo = toAmplitude(low)
val hi = toAmplitude(high)
val lo = wireAmplitudeToByte(low)
val hi = wireAmplitudeToByte(high)
if (lo == 0 && hi == 0) {
runCatching { v.cancel() } // (0,0) = stop
return
@@ -329,12 +346,6 @@ class GamepadFeedback(
}
}
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
private fun toAmplitude(v16: Int): Int {
val a = (v16 ushr 8) and 0xFF
return if (v16 != 0 && a == 0) 1 else a
}
// One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
// self-terminates on a lost stop; cancel on zero. Floor the duration at 1 ms: `createOneShot`
// throws IllegalArgumentException on a non-positive duration, and a lease can carry ttl_ms==0
@@ -50,12 +50,35 @@ class GamepadRouter(
* capture links, which `StreamScreen` does not start at all while this is off.
*/
private val forwarding: Boolean = true,
/**
* Forward raw guide/QAM presses (`Settings.systemButtons` resolved auto = forward on
* Android, where the press reaches the app on most devices; `local` exists for
* cross-client profile parity with the Gaming-Mode clients). Off keeps them entirely
* with this device.
*/
private val systemForward: Boolean = true,
/**
* The hold-Select guide gesture (`Settings.guideGesture` resolved auto = off on
* Android): holding Select ALONE [GUIDE_HOLD_MS] sends the HOST's guide button, down
* until release so a long hold is the host's long-press, a Gaming-Mode host's QAM. A
* Select tap is delivered on release (delayed by up to the threshold); a Select pressed
* while other buttons are down passes through untouched, so the exit/mic chords keep
* working. pf-client-core's `SelectGesture`, on the main-thread handler.
*/
private val guideGesture: Boolean = false,
) {
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
var held = 0
// Hold-Select→guide gesture state ([guideGesture]): the pending Select's hold
// timer / a delivered tap's owed release (both on the main handler), and whether
// the held Select was transformed into a synthetic guide.
var pendingGuide: Runnable? = null
var pendingTapUp: Runnable? = null
var selectAsGuide = false
}
/** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
@@ -139,7 +162,24 @@ class GamepadRouter(
* the mic-mute chord ([MIC_CHORD]).
*/
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
// Raw system buttons stay local under the "local" policy — no wire send and no held
// tracking, symmetric on both edges so nothing leaks into the chords either.
if (!systemForward && (bit == Gamepad.BTN_GUIDE || bit == Gamepad.BTN_MISC1)) return
if (down) {
if (guideGesture && send) {
// A Select pressed ALONE is held back until it resolves: a tap (delivered
// on release), a combo member (the next button flushes it as a real
// press), or — past GUIDE_HOLD_MS — a synthetic guide. Held state records
// it either way, so the exit/mic chords read as if the gesture didn't
// exist (Select+Y still fires the mic toggle: the flush sends Select's
// down before Y's).
if (bit == Gamepad.BTN_BACK && slot.held == 0) {
slot.held = slot.held or bit
armGuide(slot)
return
}
flushPendingSelect(slot)
}
if (send && forwarding) {
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
}
@@ -155,7 +195,8 @@ class GamepadRouter(
onMicChord?.invoke()
}
} else {
if (send && forwarding) {
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
if (!owned && send && forwarding) {
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
}
slot.held = slot.held and bit.inv()
@@ -167,6 +208,61 @@ class GamepadRouter(
}
}
/** Start a pending Select's hold countdown ([GUIDE_HOLD_MS] → a synthetic guide, down until release). */
private fun armGuide(slot: Slot) {
val r = Runnable {
slot.pendingGuide = null
slot.selectAsGuide = true
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, true, slot.index)
}
}
slot.pendingGuide = r
mainHandler.postDelayed(r, GUIDE_HOLD_MS)
}
/**
* A second button joined while Select was pending it was a real Select after all; its
* deferred down goes out before the caller sends the new button's, preserving chronology.
*/
private fun flushPendingSelect(slot: Slot) {
val r = slot.pendingGuide ?: return
mainHandler.removeCallbacks(r)
slot.pendingGuide = null
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index)
}
}
/**
* Select released with gesture state outstanding true when the gesture owned the
* release. A transformed hold lifts the synthetic guide; a pending tap delivers its
* held-back press now, with the release [TAP_PRESS_MS] behind it (a back-to-back pair
* can fold into nothing in the host's per-pad input fold).
*/
private fun consumeSelectRelease(slot: Slot): Boolean {
if (slot.selectAsGuide) {
slot.selectAsGuide = false
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index)
}
return true
}
val r = slot.pendingGuide ?: return false
mainHandler.removeCallbacks(r)
slot.pendingGuide = null
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index)
val up = Runnable {
slot.pendingTapUp = null
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index)
}
slot.pendingTapUp = up
mainHandler.postDelayed(up, TAP_PRESS_MS)
}
return true
}
/** Arm the exit-chord hold timer (once); on expiry, if the chord is still held, flush + leave. */
private fun armExit() {
if (pendingExit != null) return // already counting down
@@ -362,6 +458,24 @@ class GamepadRouter(
/** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */
private fun releaseHeld(slot: Slot) {
// Gesture first: a pending (never-sent) Select just drops its timer; an owed tap
// release goes out NOW (its down is already on the wire and the handle may not
// outlive this slot); a transformed guide — which is not in `held` — is lifted.
slot.pendingGuide?.let { mainHandler.removeCallbacks(it) }
slot.pendingGuide = null
slot.pendingTapUp?.let {
mainHandler.removeCallbacks(it)
slot.pendingTapUp = null
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index)
}
}
if (slot.selectAsGuide) {
slot.selectAsGuide = false
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index)
}
}
var bits = slot.held
while (bits != 0) {
val bit = bits and -bits // lowest set bit
@@ -403,5 +517,14 @@ class GamepadRouter(
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
const val EXTERNAL_ID_BASE = -1000
/** pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the host's guide goes down. */
const val GUIDE_HOLD_MS = 350L
/**
* pf-client-core's `TAP_PRESS`: a held-back Select tap's release trails its press by
* this much, so the pair can't coalesce into no press at all.
*/
const val TAP_PRESS_MS = 50L
}
}
@@ -14,8 +14,8 @@ import android.hardware.usb.UsbRequest
import android.os.Build
import android.util.Log
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeoutException
import java.util.concurrent.atomic.AtomicBoolean
/**
* Generic USB transport for a client-captured HID controller the device-agnostic half of what
@@ -81,17 +81,57 @@ class HidUsbLink(
/** Pending OUT reports, submitted by the reader thread only one thread may drive a
* connection's [UsbRequest]s ([UsbDeviceConnection.requestWait] returns ANY completed
* request; a second waiter would steal the reader's completions). */
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
* request; a second waiter would steal the reader's completions). See [OutReportQueue] for
* what gets discarded when it fills, and why that is not simply "the oldest". */
private val outQueue = OutReportQueue()
private var reader: Thread? = null
private var detachReceiver: BroadcastReceiver? = null
@Volatile private var running = false
/** Latches on the first "this link is down" signal so [onClosed] fires exactly once, however
* many of the racing detectors (detach broadcast, reader error streak, failed re-queue) see
* it. Reset by [start]. */
private val down = AtomicBoolean(false)
/** First attached matching device, or null. Does not need USB permission to enumerate. */
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
/**
* Open a SECOND connection to the same device, for a consumer that needs its own descriptor.
*
* **Not a convenience a correctness requirement.** `UsbDeviceConnection.requestWait()`
* returns *any* completed request on that connection, and the same is true of the usbfs reap
* ioctl underneath it: two independent transfer engines sharing one descriptor steal each
* other's completions. This link's reader owns its connection exclusively (see the note on
* [outQueue]), so anything else driving transfers on this device the isochronous audio
* renderer must open its own.
*
* usbfs allows the same device to be opened many times, and claims are per (descriptor,
* interface), so a claim made on this connection does not conflict with one made on that.
*
* The caller owns the returned connection and must close it.
*/
fun openAuxConnection(): UsbDeviceConnection? {
val dev = device ?: return null
return usb.openDevice(dev)
}
/**
* The open connection's usbfs file descriptor, or -1 when the link is not running.
*
* Handed to native code that drives interfaces this link deliberately does NOT claim the
* pad's isochronous audio endpoint (see `pad_audio` on the native side), which Android's own
* USB API cannot reach because `UsbRequest` rejects anything that is not bulk or interrupt.
* usbfs claims are per interface, so a native claim of the audio interface leaves this link's
* HID claim untouched.
*
* **The borrower must stop using it before [stop] runs**: closing the connection while a
* transfer is in flight pulls the descriptor out from under the kernel.
*/
val fileDescriptor: Int get() = connection?.fileDescriptor ?: -1
/**
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
* obtained USB permission. Returns false when nothing could be claimed.
@@ -114,6 +154,7 @@ class HidUsbLink(
connection = conn
device = dev
claims = claimed
down.set(false)
running = true
Log.i(
config.tag,
@@ -134,10 +175,7 @@ class HidUsbLink(
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
if (gone?.deviceName == dev.deviceName) {
Log.i(config.tag, "USB detached (${dev.deviceName})")
if (running) {
running = false
onClosed()
}
linkDown()
}
}
}
@@ -221,6 +259,9 @@ class HidUsbLink(
if (live.isEmpty()) {
Log.e(config.tag, "no IN request could be queued")
finishReader(claims)
// `start` already returned true, so without this the owner would sit waiting on a
// capture that never streams and never reports itself dead.
linkDown()
return
}
val scratch = ByteArray(64)
@@ -295,10 +336,23 @@ class HidUsbLink(
} finally {
finishReader(claims)
}
if (running) {
running = false
onClosed()
}
linkDown()
}
/**
* Report the link down, exactly once, from whichever detector noticed first the detach
* broadcast (main thread) or the reader thread on its way out.
*
* This only *signals*; releasing the connection and the interfaces stays the owner's job, via
* the [stop] its `onClosed` handler calls. Previously nothing released them on this path: the
* detach receiver flipped a flag and fired the callback, so an unplug left the connection open,
* the interfaces claimed (the pad could not return to Android's own input stack) and the
* receiver still registered and a re-plug overwrote the field holding it, leaking a receiver
* that stayed live for the process's lifetime.
*/
private fun linkDown() {
running = false
if (down.compareAndSet(false, true)) onClosed()
}
private fun finishReader(claims: List<Claim>) {
@@ -314,28 +368,35 @@ class HidUsbLink(
* Write one raw report to the device: kind 0 = output report (the active interface's
* interrupt-OUT, else a `SET_REPORT(Output)` control transfer), kind 1 = feature report
* (`SET_REPORT(Feature)`). [data] is the full report, id byte first, hidapi framing.
*
* [coalesce] tells the pending-OUT queue whether a newer report of the same kind may replace
* this one [OutReportQueue.KEY_RUMBLE] for motor levels, the default [OutReportQueue.NO_COALESCE]
* for one-shots (lightbar, player LEDs, trigger effects) the sender will not repeat.
*
* Returns whether the report reached the device or is queued for it. A caller that is writing
* a **stop** needs this: a discarded stop has nothing behind it, so it must not be mistaken
* for one that landed.
*/
fun writeRaw(kind: Int, data: ByteArray) {
if (data.isEmpty()) return
when (kind) {
fun writeRaw(kind: Int, data: ByteArray, coalesce: Int = OutReportQueue.NO_COALESCE): Boolean {
if (data.isEmpty()) return false
return when (kind) {
0 -> {
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
// newest-wins: these are level-styled commands the sender re-sends anyway.
while (outQueue.size >= 32) outQueue.poll()
outQueue.offer(data)
// Interrupt-OUT rides UsbRequests submitted by the reader thread.
outQueue.offer(data, coalesce)
} else {
setReport(REPORT_TYPE_OUTPUT, data)
}
}
1 -> setReport(REPORT_TYPE_FEATURE, data)
else -> false
}
}
private fun setReport(type: Int, data: ByteArray) {
val conn = connection ?: return
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
sendReport(conn, ifId, type, data)
private fun setReport(type: Int, data: ByteArray): Boolean {
val conn = connection ?: return false
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return false
return sendReport(conn, ifId, type, data)
}
/**
@@ -344,9 +405,8 @@ class HidUsbLink(
* queue would never drain (e.g. a rumble stop before the interfaces release). Safe from any
* thread: EP0 control transfers are independent of the reader's `requestWait`.
*/
fun writeControl(data: ByteArray) {
if (data.isNotEmpty()) setReport(REPORT_TYPE_OUTPUT, data)
}
fun writeControl(data: ByteArray): Boolean =
data.isNotEmpty() && setReport(REPORT_TYPE_OUTPUT, data)
private fun sendKeepAlive(conn: UsbDeviceConnection, ifaceId: Int) {
for (f in config.keepAliveFeatures) sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, f)
@@ -358,27 +418,48 @@ class HidUsbLink(
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
*/
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
private fun sendReport(
conn: UsbDeviceConnection,
ifaceId: Int,
type: Int,
data: ByteArray,
): Boolean {
val id = data[0].toInt() and 0xFF
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
conn.controlTransfer(
0x21, // host→device, class, interface
0x09, // SET_REPORT
(type shl 8) or id,
ifaceId,
payload,
payload.size,
WRITE_TIMEOUT_MS,
)
// controlTransfer returns the byte count, or a negative value on failure — a failed write
// must be reported as such, not swallowed (a dropped rumble stop has nothing behind it).
val n = runCatching {
conn.controlTransfer(
0x21, // host→device, class, interface
0x09, // SET_REPORT
(type shl 8) or id,
ifaceId,
payload,
payload.size,
WRITE_TIMEOUT_MS,
)
}.getOrDefault(-1)
return n >= 0
}
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
/**
* Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed].
*
* Safe to call from the `onClosed` handler itself that is how an unplug now gets cleaned up,
* and it arrives on the reader thread, which must not try to join itself.
*/
fun stop() {
running = false
// Claim the down-latch so the reader's own exit does not report a close the owner asked for.
down.set(true)
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
detachReceiver = null
runCatching { reader?.join(1000) }
reader = null
if (reader !== Thread.currentThread()) {
runCatching { reader?.join(1000) }
// Only forget the thread once it is actually gone: clearing it while it still runs
// would let a later stop() skip the join and free the connection under it.
reader = null
}
outQueue.clear()
activeClaim = null
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
@@ -69,6 +69,10 @@ object NativeBridge {
* list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank
* the host falls back to a fingerprint-derived "device abcd1234" label. */
deviceName: String?,
/** Advertise `CLIENT_CAP_PAD_AUDIO` the SESSION-level negotiation for the 0xD1 per-pad
* DualSense plane. Without it the host never sets `HOST_CAP_PAD_AUDIO` and emits nothing,
* so a captured pad's own render capabilities would have nothing to gate. */
padAudioOk: Boolean,
): Long
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
@@ -83,6 +87,18 @@ object NativeBridge {
*/
external fun nativeSessionEnded(handle: Long): Boolean
/**
* WHY the session ended, as a [SessionEndReason] ordinal decode with
* [SessionEndReason.fromNative]. `0` (NONE) before it ends, or on a `0` handle.
*
* The companion to [nativeSessionEnded], which only says THAT it ended. Both are needed: the
* flag to leave a dead stream, this to decide what to tell the user. A player quitting their
* game and a host falling off the network both end the session, and with no way to separate
* them the watchdog said "the host may be asleep" for all of them wrong for every deliberate
* ending. Cheap (one atomic load); UI-safe.
*/
external fun nativeEndReason(handle: Long): Int
/**
* Run the SPAKE2 PIN ceremony, presenting [certPem]/[keyPem]. Returns the host's verified
* fingerprint (64-hex) to persist + pin, or `""` on failure (wrong PIN / MITM / unreachable).
@@ -332,6 +348,46 @@ object NativeBridge {
*/
external fun nativeSetMicMuted(handle: Long, muted: Boolean)
/**
* Start tier-A DualSense pad audio: render the host's `0xD1` streams on the pad's own
* 4-channel USB audio device.
*
* [fd] is an open [android.hardware.usb.UsbDeviceConnection]'s file descriptor. Native code
* **borrows** it it claims the pad's audio interface through usbfs (which leaves any HID
* claim on the same device alone) and never closes the descriptor. The caller must keep the
* connection open until [nativeStopPadAudio] returns.
*
* This also declares the pad's render capability to the host; without it no `0xD1` is sent.
*
* Returns false when there is nothing to render. A kernel that refuses the interface claim is
* NOT reported here the renderer discovers that on its own thread and the session simply
* carries on without tier A, because some OEM kernels refuse and no app-side fix exists.
*/
external fun nativeStartPadAudio(
handle: Long,
pad: Int,
fd: Int,
haptics: Boolean,
speaker: Boolean,
): Boolean
/**
* Stop tier-A pad audio and join its render thread, and hand the pad back to wire rumble.
*
* Returns only once the thread is joined so the `UsbDeviceConnection` may be closed as soon
* as this returns, and not before.
*/
external fun nativeStopPadAudio(handle: Long, pad: Int)
/**
* Drive the pad with a test tone through the real render path no host, no session.
*
* [fd] must come from a connection **nothing else is driving transfers on**: two engines on
* one usbfs descriptor reap each other's completions. Blocks for roughly [seconds]; run it off
* the main thread. Returns sample frames written, or negative on failure.
*/
external fun nativePadAudioSelfTest(fd: Int, seconds: Int, hz: Int): Int
/**
* Is a mic capture actually RUNNING i.e. did [nativeStartMic] open a stream, and has
* [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than
@@ -0,0 +1,89 @@
package io.unom.punktfunk.kit
/**
* The pending interrupt-OUT reports for a captured controller: a bounded FIFO whose overflow
* policy knows which reports may be thrown away and which may not.
*
* The queue exists because only one thread may drive a connection's `UsbRequest`s, so writes from
* the feedback threads are handed to the reader thread rather than submitted directly. It has to
* be bounded a stalled or unplugged device would otherwise grow it without limit and the
* question is what to discard when it fills.
*
* The old policy was "newest wins": drop from the head until there is room. That is right for
* rumble, which is *level-styled* the host re-sends it continuously, so a dropped frame is
* replaced milliseconds later and nothing is permanently lost. It is wrong for everything else.
* A lightbar colour, a player-LED mask and an adaptive-trigger effect are **one-shots**: the host
* sends them on change and never repeats them. Dropping one leaves the pad wrong until the next
* time that value happens to change, which may be never.
*
* So eviction is driven by an explicit [key] supplied by the caller, not by inspecting the bytes.
* That distinction cannot be recovered from the report itself: every DualSense output report
* carries the *same* report id and differs only in its `valid_flag` bytes, so an id-keyed policy
* would happily let a rumble supersede a lightbar the very bug this replaces, relocated.
*
* Two rules:
* - A report offered with a coalescing key **replaces** the pending report with that key, in
* place. A burst of rumble collapses to its latest value and never displaces anything else.
* - Only when the queue is full does anything get dropped, and then the oldest *coalescable*
* report goes first. A one-shot is discarded only if the queue is full of nothing but
* one-shots which needs [cap] distinct one-shots outstanding, far beyond what a real pad
* produces.
*
* Thread-safe: offered by the feedback threads, drained by the reader thread.
*/
internal class OutReportQueue(private val cap: Int = CAP) {
private class Entry(val key: Int, val data: ByteArray)
private val items = ArrayDeque<Entry>()
/**
* Queue [data] for submission. [key] is [NO_COALESCE] for a one-shot, or a caller-chosen
* constant identifying a level-styled stream whose newer values supersede older ones.
*
* Returns false only if the report had to be dropped outright the caller can then treat the
* write as failed rather than assuming it is on its way.
*/
fun offer(data: ByteArray, key: Int = NO_COALESCE): Boolean = synchronized(items) {
if (key != NO_COALESCE) {
val at = items.indexOfFirst { it.key == key }
if (at >= 0) {
// Supersede in place: keeping the queue position stops a fast rumble stream from
// repeatedly jumping the one-shots queued ahead of it.
items[at] = Entry(key, data)
return true
}
}
if (items.size >= cap) {
val victim = items.indexOfFirst { it.key != NO_COALESCE }
if (victim >= 0) {
items.removeAt(victim)
} else if (key != NO_COALESCE) {
// Nothing coalescable to sacrifice and this report is itself replaceable — drop it
// rather than a one-shot that will never come again.
return false
} else {
items.removeFirst()
}
}
items.addLast(Entry(key, data))
return true
}
/** The next report to submit, or null when nothing is pending. */
fun poll(): ByteArray? = synchronized(items) { items.removeFirstOrNull()?.data }
fun clear() = synchronized(items) { items.clear() }
val size: Int get() = synchronized(items) { items.size }
companion object {
/** This report is a one-shot: never superseded, evicted only as a last resort. */
const val NO_COALESCE = 0
/** Motor levels — re-sent continuously, so only the newest is worth keeping. */
const val KEY_RUMBLE = 1
/** Deep enough to absorb a burst, small enough that a stalled device cannot bloat us. */
const val CAP = 32
}
}
@@ -0,0 +1,47 @@
package io.unom.punktfunk.kit
/**
* The two conversions every rumble path in this module needs, in one place.
*
* Both used to be transcribed per call site: [wireAmplitudeToByte] existed twice, byte-identical,
* in `GamepadFeedback` and `DsDevice`; [unpackRumbleEvent] was inline bit-shifting in the poll loop
* with no test on either side of the JNI boundary. Neither is complicated which is exactly why a
* silent divergence between copies would have been hard to notice.
*/
/**
* Wire amplitude (`0..0xFFFF`) an 8-bit motor/vibrator level.
*
* The high byte, except that a **nonzero command never collapses to zero**: anything below 0x0100
* would otherwise round to silence, turning a weak-but-real rumble into no rumble at all. 1 is
* imperceptibly light, but it moves.
*/
internal fun wireAmplitudeToByte(v16: Int): Int {
val a = (v16 ushr 8) and 0xFF
return if (v16 != 0 && a == 0) 1 else a
}
/** One effective rumble command, as packed by the native side's `nativeNextRumble`. */
internal data class RumbleCmd(val pad: Int, val low: Int, val high: Int, val backstopMs: Long)
/**
* Unpack `NativeBridge.nativeNextRumble`'s `jlong`, or null for the timeout/closed sentinel.
*
* Layout, mirroring `clients/android/native/src/feedback.rs::pack_rumble`:
* bits 49..52 = wire pad index, 32..47 = backstop duration (ms), 16..31 = low, 0..15 = high.
* The pad field is 4 bits because `punktfunk_core::input::MAX_PADS` is 16 the Rust side has a
* compile-time assertion tying the two together, so this can't silently start truncating.
*
* These are EFFECTIVE commands from the core's shared rumble policy engine: it owns every
* lease/staleness/close decision and emits explicit zeros, so apply them verbatim
* `(0, 0)` = cancel, non-zero = one-shot for the backstop.
*/
internal fun unpackRumbleEvent(ev: Long): RumbleCmd? {
if (ev < 0L) return null // timeout / closed
return RumbleCmd(
pad = ((ev ushr 49) and 0xFL).toInt(),
low = ((ev ushr 16) and 0xFFFF).toInt(),
high = (ev and 0xFFFF).toInt(),
backstopMs = (ev ushr 32) and 0xFFFF,
)
}
@@ -273,10 +273,20 @@ class Sc2Capture(
private fun onLinkClosed() {
Log.i(TAG, "SC2 link closed (unplug / power-off)")
// Both transports share this callback, so read which one was live BEFORE clearing it —
// releasing the other would tear down a link that never dropped.
val dropped = activeLink
activeLink = LINK_NONE
dongleLink = false
releaseSlot()
releaseUiKeys()
// Release the transport too — see the note in DsCapture.onLinkClosed. The Puck makes this
// worse than a single leak: it is the pad that gets power-cycled, so the same process can
// round-trip a link many times in one session.
when (dropped) {
LINK_USB -> usb.stop()
LINK_BLE -> ble.stop()
}
onActiveChanged?.invoke(false)
}
@@ -0,0 +1,56 @@
package io.unom.punktfunk.kit
/**
* Why a stream session ended the Kotlin mirror of `punktfunk_core::client::PunktfunkEndReason`,
* read via [NativeBridge.nativeEndReason].
*
* The distinction that matters to a UI is **normal vs alarming**, and it is not a spectrum: a
* player quitting their game and a host falling off the network both arrive as "the session
* ended". With no way to tell them apart this client showed one message for all of them — and it
* was the alarming one ("Connection lost — the host may be asleep"), in front of players who had
* just quit their own game.
*
* Ordinals are an ABI contract with the Rust side: append only, never renumber.
*/
enum class SessionEndReason {
/** Not ended, or ended before a reason could be observed. Also the fallback for an unknown value. */
NONE,
/** This client closed the session — the user pressed back or stop. Nothing to report. */
LOCAL,
/**
* The host's launched game exited. A normal finish, and the one reason worth acting on: go back
* to the library the title was launched from, so the next one is a tap away.
*/
GAME_EXITED,
/** The host ended the session deliberately (an operator "End", or it simply finished). Normal. */
HOST_ENDED,
/** The host closed reporting a failure of its own. Worth showing; the host's log has the detail. */
HOST_ERROR,
/**
* The connection died rather than being closed: idle timeout, reset, the network going away.
* This and only this is the "the host may be asleep, wake it" case.
*/
LOST;
/**
* Is this an ordinary outcome rather than something to alarm the user about?
*
* The question nearly every caller actually asks. [LOCAL], [GAME_EXITED] and [HOST_ENDED] were
* all meant to happen. [NONE] counts as normal no evidence of trouble is not evidence of it.
*/
val isNormal: Boolean
get() = this != HOST_ERROR && this != LOST
companion object {
/**
* Decode the JNI byte. An unrecognized value becomes [NONE] rather than throwing: this
* crosses an ABI where the native side may be newer than this code.
*/
fun fromNative(v: Int): SessionEndReason = entries.getOrNull(v) ?: NONE
}
}
@@ -132,6 +132,27 @@ class HostDiscovery(context: Context) {
handler.post(poll)
}
/**
* Tear the browse down and start a fresh one. This is the manual rescan, and the recovery path
* for a browse that started while blocked (permission not yet granted, multicast filtered) or
* that never started at all ([start] gives up when `nativeDiscoveryStart` returns 0, and
* nothing else would ever retry it).
*
* It also puts a query back on the wire: `mdns-sd` re-queries on a doubling backoff that caps
* at an hour, so a long-lived browse is effectively passive a host that appeared since, or
* whose announcement was lost to multicast, may never be asked for again.
*
* The currently-shown host set is left alone across the swap (rather than blinking empty via
* [stop]'s notification); the first poll of the new browse publishes the fresh set.
*/
fun restart() {
val keep = onChange
onChange = null
stop()
onChange = keep
start()
}
fun stop() {
if (!running && nativeHandle == 0L) return
running = false
@@ -37,9 +37,51 @@ data class Artwork(val portrait: String?, val header: String?, val hero: String?
val posterCandidates: List<String> get() = listOfNotNull(portrait, header, hero)
}
/** One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`). */
data class GameEntry(val id: String, val store: String, val title: String, val art: Artwork) {
/**
* One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`).
*
* [role] is `"game"` (the default, and what an older host omits) or `"launcher"` an entry that
* opens the launcher itself (Steam Big Picture, Heroic) rather than a title. Kept a plain nullable
* String on purpose: the host owns the vocabulary, and an unknown future value must degrade to a
* game rather than break the decode (design D4).
*/
data class GameEntry(
val id: String,
val store: String,
val title: String,
val art: Artwork,
val role: String? = null,
) {
val isCustom: Boolean get() = store == "custom"
/** Whether this entry opens a launcher rather than a game. */
val isLauncher: Boolean get() = role == "launcher"
/**
* Display name for the store badge the same table the other clients use
* (`pf-console-ui::library::store_label`). Before this the UI said "Steam" for every non-custom
* entry, which a Lutris or GOG title made a lie.
*/
val storeLabel: String get() = when (store) {
"steam" -> "Steam"
"custom" -> "Custom"
"heroic" -> "Heroic"
"lutris" -> "Lutris"
"epic" -> "Epic"
"gog" -> "GOG"
"xbox" -> "Xbox"
else -> "Game"
}
}
/**
* Design D4: launcher entries lead the shelf, keeping the host's title order within each group.
* Applied once where the library is fetched, so no screen has to remember the rule and a library
* without launcher entries comes back untouched.
*/
fun List<GameEntry>.launchersFirst(): List<GameEntry> {
val launchers = filter { it.isLauncher }
return if (launchers.isEmpty()) this else launchers + filterNot { it.isLauncher }
}
/** Fetch outcome — three states so the UI can guide setup (the common case is "not paired yet"). */
@@ -108,10 +150,11 @@ object LibraryClient {
header = resolveArt(str(art, "header"), base),
hero = resolveArt(str(art, "hero"), base),
),
role = str(o, "role"),
),
)
}
return out
return out.launchersFirst()
}
/** A present, non-null, non-blank JSON string field, else null. */
@@ -127,8 +170,14 @@ object LibraryClient {
* An OkHttpClient that presents the paired client cert and pins the host's self-signed cert by
* SHA-256(DER) reused for BOTH the library fetch and the cover-art loads (so a paired client
* reaches the host's own art proxy). The pinning trust manager trusts the host by fingerprint and
* defers to normal public trust for any other origin (an external CDN URL); the hostname verifier
* accepts the pinned host (whose self-signed cert has no matching SAN) and defers otherwise.
* defers to normal public trust for any other origin (an external CDN URL).
*
* The two checks are only sound TOGETHER, and the composition is the point: the trust manager
* cannot fail closed on its own (it has no hostname, so it must let a CDN chain through), so the
* hostname verifier is what makes the pinned host pin-only. Loosen either and a publicly-trusted
* certificate for any name is accepted for the host which is exactly what 2026-08-05 review M-2
* found. The host's own cert is self-signed with no matching SAN, so it can never satisfy the
* default verifier; the pin is its only credential, on purpose.
*/
fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String): OkHttpClient {
val clientCert = CertificateFactory.getInstance("X.509")
@@ -162,7 +211,26 @@ fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String)
val defaultVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
val verifier = HostnameVerifier { hostname, session ->
hostname == host || defaultVerifier.verify(hostname, session)
if (hostname == host) {
// The PINNED host fails closed: only the pinned leaf is acceptable for this name.
//
// This used to be a bare `hostname == host`, which composed with the trust manager's
// system-CA fall-through into "any publicly-trusted certificate, for any name, is
// accepted for the pinned host" — the pin was decorative (2026-08-05 review M-2). A
// MITM with any free CA-issued cert intercepted the connection, received the client's
// mTLS IDENTITY certificate, and served attacker-chosen library JSON and art URLs.
// The Rust (`pf-client-core`) and Apple (`ClientTLS`) paths already fail closed here;
// only Android did not.
try {
sha256Hex((session.peerCertificates.firstOrNull() as? X509Certificate)?.encoded ?: return@HostnameVerifier false) == pinned
} catch (_: Exception) {
false
}
} else {
// Any other origin (an external CDN art URL) is ordinary public trust: the system
// trust manager validated the chain, and this checks the name against it.
defaultVerifier.verify(hostname, session)
}
}
return OkHttpClient.Builder()
@@ -0,0 +1,102 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The pending-OUT queue's overflow policy. What is being pinned here is the distinction the old
* "drop from the head until there is room" policy did not make: rumble is re-sent continuously and
* may be thrown away, while a lightbar/player-LED/trigger report is sent once and never repeated.
*/
class OutReportQueueTest {
/** A report carrying a 0..255 marker so a test can tell which one came back out. */
private fun report(marker: Int) = byteArrayOf(0x02, marker.toByte())
// Masked: the marker rides in a Byte, and Byte.toInt() sign-extends.
private fun drain(q: OutReportQueue): List<Int> =
generateSequence { q.poll() }.map { it[1].toInt() and 0xFF }.toList()
@Test
fun `rumble supersedes the pending rumble instead of queueing another`() {
val q = OutReportQueue()
assertTrue(q.offer(report(1), OutReportQueue.KEY_RUMBLE))
assertTrue(q.offer(report(2), OutReportQueue.KEY_RUMBLE))
assertTrue(q.offer(report(3), OutReportQueue.KEY_RUMBLE))
assertEquals("a rumble burst must collapse to one entry", 1, q.size)
assertArrayEquals(report(3), q.poll())
assertNull(q.poll())
}
@Test
fun `superseding keeps the queue position so a rumble stream cannot jump one-shots`() {
val q = OutReportQueue()
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
q.offer(report(10)) // a one-shot queued behind it
q.offer(report(2), OutReportQueue.KEY_RUMBLE)
// The newer rumble takes the OLD rumble's slot, so the one-shot does not get starved
// behind an endlessly-renewed entry.
assertEquals(listOf(2, 10), drain(q))
}
@Test
fun `a full queue sacrifices rumble, never a one-shot`() {
val q = OutReportQueue(cap = 4)
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
q.offer(report(10))
q.offer(report(11))
q.offer(report(12))
assertEquals(4, q.size)
// Full. The old policy dropped the head — here that is a rumble, but only by luck of
// ordering; what matters is that the one-shots all survive.
assertTrue(q.offer(report(13)))
assertEquals(listOf(10, 11, 12, 13), drain(q))
}
@Test
fun `the one-shot the host never repeats survives a rumble storm`() {
val q = OutReportQueue(cap = 4)
// The exact regression: a lightbar colour queued once, then a flood of rumble. Under the
// old newest-wins eviction the colour was dropped from the head and never came back,
// leaving the pad lit wrong until the value next happened to change.
q.offer(report(200)) // lightbar
repeat(50) { q.offer(report(it), OutReportQueue.KEY_RUMBLE) }
val out = drain(q)
assertTrue("the lightbar report must still be queued, got $out", out.contains(200))
assertEquals("rumble must not have accumulated", listOf(200, 49), out)
}
@Test
fun `a queue full of one-shots refuses a rumble rather than dropping one`() {
val q = OutReportQueue(cap = 2)
q.offer(report(10))
q.offer(report(11))
assertFalse(
"with nothing coalescable to sacrifice, the replaceable report yields",
q.offer(report(1), OutReportQueue.KEY_RUMBLE),
)
assertEquals(listOf(10, 11), drain(q))
}
@Test
fun `only a queue of nothing but one-shots drops one, and it is the oldest`() {
val q = OutReportQueue(cap = 2)
q.offer(report(10))
q.offer(report(11))
assertTrue(q.offer(report(12)))
assertEquals(listOf(11, 12), drain(q))
}
@Test
fun `clear empties the queue`() {
val q = OutReportQueue()
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
q.offer(report(10))
q.clear()
assertEquals(0, q.size)
assertNull(q.poll())
}
}
@@ -0,0 +1,79 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The Kotlin half of the rumble JNI boundary. The Rust half is pinned by `pack_rumble_tests` in
* `clients/android/native/src/feedback.rs`; the two suites describe the same layout from opposite
* sides, which is the only thing that catches one of them drifting.
*/
class RumbleWireTest {
/** `pack_rumble` from the native side, transcribed — the packer these tests unpack. */
private fun pack(pad: Int, low: Int, high: Int, backstopMs: Int): Long =
((pad and 0xF).toLong() shl 49) or
((backstopMs.coerceAtMost(0xFFFF)).toLong() shl 32) or
(low.toLong() shl 16) or
high.toLong()
@Test
fun `every field round-trips at its extremes`() {
val cases = listOf(
listOf(0, 0, 0, 0),
listOf(15, 0xFFFF, 0xFFFF, 0xFFFF),
listOf(1, 0x1234, 0x5678, 500),
listOf(7, 0, 0xFFFF, 2000),
)
for ((pad, low, high, backstop) in cases) {
val cmd = unpackRumbleEvent(pack(pad, low, high, backstop))!!
assertEquals("pad", pad, cmd.pad)
assertEquals("low", low, cmd.low)
assertEquals("high", high, cmd.high)
assertEquals("backstop", backstop.toLong(), cmd.backstopMs)
}
}
/** MAX_PADS is 16, so all 16 indices must survive the 4-bit field without aliasing. */
@Test
fun `all sixteen pad indices are distinct`() {
val seen = (0 until 16).map { unpackRumbleEvent(pack(it, 1, 2, 3))!!.pad }
assertEquals((0 until 16).toList(), seen)
}
@Test
fun `the negative sentinel is not a command`() {
assertNull(unpackRumbleEvent(-1L))
assertNull(unpackRumbleEvent(Long.MIN_VALUE))
}
@Test
fun `a stop is distinguishable from a hold`() {
val stop = unpackRumbleEvent(pack(2, 0, 0, 0))!!
val hold = unpackRumbleEvent(pack(2, 0x8000, 0x8000, 500))!!
assertEquals(0, stop.low)
assertEquals(0, stop.high)
assertNotEquals(stop, hold)
}
// --- wireAmplitudeToByte (was two byte-identical private copies) ---
@Test
fun `amplitude takes the high byte`() {
assertEquals(0xFF, wireAmplitudeToByte(0xFFFF))
assertEquals(0x80, wireAmplitudeToByte(0x8000))
assertEquals(0x12, wireAmplitudeToByte(0x1234))
}
@Test
fun `zero stays silent but a weak nonzero never does`() {
assertEquals("only a real zero may render as silence", 0, wireAmplitudeToByte(0))
// Everything below 0x0100 has a zero high byte — without the floor these all vanish.
for (v in listOf(1, 0x0042, 0x00FF)) {
assertEquals("wire $v collapsed to silence", 1, wireAmplitudeToByte(v))
}
assertEquals(1, wireAmplitudeToByte(0x0100)) // first value that reaches 1 on its own
}
}
+8
View File
@@ -64,6 +64,14 @@ libc = "0.2"
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
opus = "0.3"
# Tier-A pad audio (WP9). Android's audio framework denylists the DualSense's output by VID/PID,
# so the pad's isochronous endpoint is driven directly on the fd `UsbDeviceConnection` hands over.
# Our own crates, developed openly because the hole they fill — isochronous USB in Rust — is an
# ecosystem-wide one: https://github.com/unom-io/usbfs-iso
# Pinned by revision rather than floating: this is a transport under a real-time deadline and it
# should move when we choose to. Becomes a plain version dependency once the crates are published.
uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
[lints]
workspace = true
+99 -6
View File
@@ -18,6 +18,29 @@ use std::time::Duration;
/// observes its `running=false` flag promptly on teardown.
const PULL_TIMEOUT: Duration = Duration::from_millis(100);
/// Width of the packed `pad` field in [`pack_rumble`] — 4 bits, i.e. indices 0..15.
const PAD_BITS: u32 = 4;
/// The packing is only lossless while every representable pad index fits in [`PAD_BITS`]. This was
/// a comment before; growing `MAX_PADS` past 16 would have silently aliased pad 16 onto pad 0
/// rather than failing the build.
const _: () = assert!(
punktfunk_core::input::MAX_PADS <= 1usize << PAD_BITS,
"MAX_PADS no longer fits the 4-bit pad field in the packed rumble long"
);
/// Pack one effective rumble command into the `jlong` `nativeNextRumble` returns.
///
/// Layout — mirrored by `unpackRumbleEvent` in `RumbleWire.kt`: bits 49..52 `pad`, 32..47
/// `backstop_ms`, 16..31 `low`, 0..15 `high`. Always non-negative, so the `-1` timeout/closed
/// sentinel stays unambiguous. Split out from the JNI entry point purely so it can be tested
/// without a live session handle — the shift arithmetic is the part worth pinning.
fn pack_rumble(pad: u16, low: u16, high: u16, backstop_ms: u32) -> jlong {
(jlong::from(pad & ((1 << PAD_BITS) - 1)) << 49)
| (jlong::from(backstop_ms.min(0xFFFF) as u16) << 32)
| (jlong::from(low) << 16)
| jlong::from(high)
}
// HID-output kind tags written into the returned ByteBuffer (Kotlin reads them back).
const TAG_LED: u8 = 0x01;
const TAG_PLAYER_LEDS: u8 = 0x02;
@@ -54,12 +77,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
// handle.
let h = unsafe { &*(handle as *const SessionHandle) };
match h.client.next_rumble_command(PULL_TIMEOUT) {
Ok(cmd) => {
(jlong::from(cmd.pad & 0xF) << 49)
| (jlong::from(cmd.backstop_ms.min(0xFFFF) as u16) << 32)
| (jlong::from(cmd.low) << 16)
| jlong::from(cmd.high)
}
// A pad whose coils are ACTIVELY being driven by the 0xD1 haptics stream must not see
// wire rumble: `DsDevice` sets `valid_flag0` bit 1 (`HAPTICS_SELECT`) on every rumble
// write, and that bit disables the audio-haptics path — so one replayed command would
// mute the coils the stream is driving. Gating on *arrival of haptics frames* rather
// than on "a stream is open" is what keeps a rumble-only title working: it renders no
// haptics audio, so the host emits nothing on 0xD1 and the pad keeps its rumble.
// Dropping it here rather than in Kotlin keeps the rule next to the reason.
Ok(cmd) if crate::pad_audio::haptics_owns_coils((cmd.pad & 0xF) as u8) => -1,
Ok(cmd) => pack_rumble(cmd.pad, cmd.low, cmd.high, cmd.backstop_ms),
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
}
})
@@ -156,7 +182,74 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
out[3..n].copy_from_slice(&data);
n
}
HidOutput::AudioCtl { .. } => {
// DS5 pad-audio routing/volumes — no Android replay path yet (the 0xD1 sample
// plane isn't rendered here either); drop it like TrackpadHaptic.
return -1;
}
};
n as jint
})
}
#[cfg(test)]
mod pack_rumble_tests {
use super::*;
use punktfunk_core::input::MAX_PADS;
/// Kotlin's `unpackRumbleEvent`, transcribed — if these two ever disagree the boundary is
/// broken, and nothing else in the build would say so.
fn unpack(ev: jlong) -> (u16, u16, u16, u32) {
let pad = ((ev >> 49) & 0xF) as u16;
let backstop = ((ev >> 32) & 0xFFFF) as u32;
let low = ((ev >> 16) & 0xFFFF) as u16;
let high = (ev & 0xFFFF) as u16;
(pad, low, high, backstop)
}
#[test]
fn round_trips_every_field_at_its_extremes() {
for &(pad, low, high, backstop) in &[
(0u16, 0u16, 0u16, 0u32),
(15, 0xFFFF, 0xFFFF, 0xFFFF),
(1, 0x1234, 0x5678, 500),
(7, 0, 0xFFFF, 2000),
] {
let ev = pack_rumble(pad, low, high, backstop);
assert_eq!(unpack(ev), (pad, low, high, backstop), "pad {pad}");
}
}
#[test]
fn every_representable_pad_survives_the_four_bit_field() {
for pad in 0..MAX_PADS as u16 {
let (got, ..) = unpack(pack_rumble(pad, 1, 2, 3));
assert_eq!(got, pad, "pad {pad} aliased in the packed long");
}
}
#[test]
fn a_packed_command_is_never_negative() {
// `-1` is the timeout/closed sentinel; any packed value colliding with it would read as
// "no command" and the rumble would simply vanish.
assert!(pack_rumble(15, 0xFFFF, 0xFFFF, 0xFFFF) >= 0);
assert!(pack_rumble(0, 0, 0, 0) >= 0);
}
#[test]
fn an_oversized_backstop_saturates_instead_of_corrupting_the_pad_field() {
let ev = pack_rumble(3, 0, 0, u32::MAX);
let (pad, _, _, backstop) = unpack(ev);
assert_eq!(pad, 3, "a huge backstop must not bleed into the pad bits");
assert_eq!(backstop, 0xFFFF);
}
#[test]
fn a_stop_is_distinguishable_from_a_hold() {
let stop = pack_rumble(2, 0, 0, 0);
let hold = pack_rumble(2, 0x8000, 0x8000, 500);
assert_ne!(stop, hold);
assert_eq!(unpack(stop).1, 0);
assert_eq!(unpack(stop).2, 0);
}
}
+2
View File
@@ -37,6 +37,8 @@ mod discovery;
mod feedback;
#[cfg(target_os = "android")]
mod mic;
/// Tier-A DualSense pad audio: the 0xD1 plane rendered on the pad's own USB endpoint.
mod pad_audio;
mod session;
mod stats;
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
File diff suppressed because it is too large Load Diff
+38 -1
View File
@@ -145,6 +145,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
timeout_ms: jint,
launch: JString<'local>,
device_name: JString<'local>,
pad_audio_ok: jboolean,
) -> jlong {
let host: String = match env.get_string(&host) {
Ok(s) => s.into(),
@@ -268,7 +269,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// CLIENT_CAP_PHASE_LOCK is honest: the async decode loop's presenter feeds
// report_phase (advisory in v1 — the host arms on report receipt — but the Hello
// should say what the client does).
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK,
// CLIENT_CAP_PAD_AUDIO is the SESSION-level negotiation, separate from the per-pad
// arrival bits: without it the host never sets HOST_CAP_PAD_AUDIO and never emits 0xD1,
// so declaring a pad's render caps later would have nothing to gate. Gated on the
// settings so a user with pad audio off does not make the host provision endpoints.
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
| if pad_audio_ok != 0 {
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
} else {
0
},
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
// every decoder this device would use; `debug.punktfunk.force_parts` overrides for the
// on-glass experiment): AU prefixes then arrive as `Frame::part` pieces and the decode
@@ -291,6 +301,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
audio: Mutex::new(None),
#[cfg(target_os = "android")]
mic: Mutex::new(None),
#[cfg(target_os = "android")]
pad_audio: Mutex::new(None),
// A fresh session is never muted (mute is per-session UI state, not a setting).
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
};
@@ -392,6 +404,31 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnde
})
}
/// `NativeBridge.nativeEndReason(handle): Int` — WHY the session ended, as a
/// `punktfunk_core::client::PunktfunkEndReason` byte (Kotlin mirrors it in `SessionEndReason`).
///
/// Companion to `nativeSessionEnded`, which only says THAT it ended. Kotlin's watchdog needs both:
/// the flag to leave a dead stream, and this to decide what — if anything — to tell the user. A
/// player quitting their game and a host dropping off the network both end the session, and until
/// this existed the watchdog worded them identically ("the host may be asleep"), which is wrong for
/// every deliberate ending. `0` (NONE) on a `0` handle or before the session ends. Cheap (one
/// atomic load); safe on the UI thread.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason(
_env: JNIEnv,
_this: JObject,
handle: jlong,
) -> jint {
jni_guard(0, || {
if handle == 0 {
return 0;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.client.end_reason() as jint
})
}
/// `NativeBridge.nativePair(host, port, certPem, keyPem, pin, name): String` — run the SPAKE2 PIN
/// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint
/// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns
+15
View File
@@ -61,6 +61,11 @@ pub(crate) struct SessionHandle {
audio: Mutex<Option<crate::audio::AudioPlayback>>,
#[cfg(target_os = "android")]
mic: Mutex<Option<crate::mic::MicCapture>>,
/// Tier-A DualSense pad audio (the 0xD1 plane), started by `nativeStartPadAudio` once Kotlin
/// has claimed the pad's audio interface and handed its descriptor over. Session-lifetime and
/// `Option` because a session may have no wired DualSense at all, which is the common case.
#[cfg(target_os = "android")]
pub(crate) pad_audio: Mutex<Option<crate::pad_audio::PadAudio>>,
/// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's
/// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`]
/// for the same reason the stats gate is: the mic stops and restarts across a surface
@@ -99,6 +104,14 @@ impl SessionHandle {
fn stop_mic(&self) {
let _ = self.mic.lock().unwrap().take();
}
/// Stop pad audio. Dropping the [`crate::pad_audio::PadAudio`] joins its render thread, which
/// is what guarantees nothing is still writing to the descriptor when Kotlin closes the
/// `UsbDeviceConnection`. Idempotent.
#[cfg(target_os = "android")]
pub(crate) fn stop_pad_audio(&self) {
let _ = self.pad_audio.lock().unwrap().take();
}
}
impl Drop for SessionHandle {
@@ -108,6 +121,8 @@ impl Drop for SessionHandle {
self.stop_audio();
#[cfg(target_os = "android")]
self.stop_mic();
#[cfg(target_os = "android")]
self.stop_pad_audio();
}
}
@@ -460,6 +460,111 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
})
}
/// `NativeBridge.nativeStartPadAudio(handle, pad, fd, haptics, speaker): Boolean` — start tier-A
/// DualSense pad audio on a descriptor Kotlin has already obtained.
///
/// `fd` comes from `UsbDeviceConnection.getFileDescriptor()` **after** claiming the pad's audio
/// streaming interface. Kotlin owns that connection and **must keep it open until
/// `nativeStopPadAudio` returns**: the renderer borrows the descriptor and never closes it, so
/// closing early would pull it out from under an in-flight isochronous transfer.
///
/// Returns `false` when there is nothing to render (both kinds disabled) or the thread would not
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
/// app-side fix worth blocking a session on.
#[no_mangle]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
_env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jni::sys::jint,
fd: jni::sys::jint,
haptics: jboolean,
speaker: jboolean,
) -> jboolean {
jni_guard(0, || {
if handle == 0 || fd < 0 || !(0..16).contains(&pad) {
return 0;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
// Replace any previous renderer first: dropping it joins the old thread, so two of them
// can never hold the same descriptor at once.
h.stop_pad_audio();
// The capability declaration and the rumble suppression are NOT done here: the renderer
// makes both only once its USB stream actually opens (see `pad_audio::render`). Doing them
// at spawn time would, on a kernel that refuses the interface claim, take the pad off wire
// rumble and give it nothing in return — no haptics of any kind.
match crate::pad_audio::start(
std::sync::Arc::clone(&h.client),
pad as u8,
fd,
haptics != 0,
speaker != 0,
) {
Some(p) => {
*h.pad_audio.lock().unwrap() = Some(p);
1
}
None => 0,
}
})
}
/// `NativeBridge.nativePadAudioSelfTest(fd, seconds, hz): Int` — drive the pad directly with a
/// tone through the real client render path, with no host and no session involved.
///
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
/// never reveal that the client handed the renderer a descriptor something else was already
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
#[no_mangle]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
_env: JNIEnv,
_this: JObject,
fd: jni::sys::jint,
seconds: jni::sys::jint,
hz: jni::sys::jint,
) -> jni::sys::jint {
jni_guard(-1, || {
if fd < 0 {
return -1;
}
// SAFETY: Kotlin holds the owning UsbDeviceConnection open across this call and drives no
// other transfers on it (it opens a dedicated connection for exactly this).
unsafe { crate::pad_audio::self_test(fd, seconds, hz) }
})
}
/// `NativeBridge.nativeStopPadAudio(handle, pad)` — stop tier-A pad audio and join its thread.
///
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
/// `UsbDeviceConnection` as soon as this returns and not before.
#[no_mangle]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
_env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jni::sys::jint,
) {
jni_guard((), || {
if handle != 0 {
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.stop_pad_audio();
if (0..16).contains(&pad) {
// Withdraw the capability and hand the pad back to wire rumble, in that order:
// the host stops sending 0xD1 before tier C resumes, so the two never overlap.
h.client.set_pad_audio_caps(pad as u8, 0);
crate::pad_audio::set_tier_a(pad as u8, false);
crate::pad_audio::clear_haptics_liveness(pad as u8);
}
}
})
}
/// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream.
///
/// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung
+5 -1
View File
@@ -121,7 +121,11 @@ PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli
host's virtual pad.
- **App Store screenshots** are automated — `tools/screenshots.sh all` renders the real UI at the
required pixel sizes via a DEBUG-only shot mode; the `apple` CI workflow captures the iOS sizes on
every main push. See the script header for details.
every main push. See the script header for details. The script's `SCENES` array is the listing
set, in listing order; override it (`SCENES="06-gamepad-home 10-edithost" tools/screenshots.sh ios`)
to capture any of the other scenes in `ShotScenes.all`. Mock data — hosts, adverts, profiles — is
seeded in `ShotMock` so a capture is byte-for-byte deterministic and never browses the real LAN
(a stranger's hostname reached the live listing that way once).
- Deeper design notes live in the internal planning repo (punktfunk-planning:
`apple-stage2-presenter.md`).
@@ -206,6 +206,20 @@ struct ContentView: View {
model.setStatsVerbosity(StatsVerbosity(rawValue: raw) ?? .normal)
}
#if os(iOS) || os(tvOS)
// Coming back to the app re-arms the LAN browse. The home's `onAppear`/`onDisappear` do
// NOT fire across background/foreground, and a browse the system suspended while we were
// away does not resume on its own so the host grid came back empty and stayed empty
// until the app was relaunched. No-op unless the browse is already running (mid-session
// the home has deliberately torn it down).
//
// Mobile only: macOS never suspends the process, and its `scenePhase` flips on every
// window focus change re-arming there would rebuild the browser each time you alt-tab.
// A Mac browse that genuinely breaks is caught by `HostDiscovery`'s own sweep instead.
.onChange(of: scenePhase) { _, phase in
if phase == .active { discovery.refreshIfRunning() }
}
#endif
#if os(iOS) || os(tvOS)
// Backgrounding driver. Only .background/.active matter; .inactive (a transient peek) is
// ignored so neither branch fires for a Control-Center pull.
//
@@ -335,6 +349,16 @@ struct ContentView: View {
active: fullscreenForSession && model.connection != nil,
isFullscreen: $isFullscreen))
#endif
// A game launched from the library just exited, so the session ended on purpose: put the
// player back in that host's library rather than on host selection. Set on the outer Group
// (like the sheets below) so it survives the streaming home transition the disconnect
// drives, and consumed here the model hands the host over once and we clear it, so a
// later manual dismiss of the library can't be undone by a stale value.
.onChange(of: model.returnToLibrary) { _, host in
guard let host else { return }
model.returnToLibrary = nil
libraryTarget = host
}
// On the outer Group so the sheet survives the trust-prompt home transition
// (the "Pair with PIN instead" path disconnects first the host's accept loop
// is sequential, a pairing connection would queue behind the live session).
@@ -512,6 +536,9 @@ struct ContentView: View {
waker: waker,
gamepadUI: gamepadUIActive,
onCancelConnect: { model.disconnect() })
// The takeover mounts OUTSIDE the gamepad screens (it covers the whole home), so
// it publishes the palette's ink itself rather than inheriting it.
.gamepadPaletteInk()
}
}
@@ -47,12 +47,16 @@ struct ConnectOverlay: View {
return nil
}
@Environment(\.gamepadInk) private var ink
var body: some View {
if let phase {
ZStack {
if gamepadUI {
// Console: an opaque, living aurora over everything.
Color.black.ignoresSafeArea()
// Console: an opaque, living aurora over everything, in the chosen palette.
// The takeover's own text rides `ink`, so a pale palette flips it here too
// without that this is the one console screen that stays white-on-white.
ink.isLight ? Color.white.ignoresSafeArea() : Color.black.ignoresSafeArea()
GamepadScreenBackground().ignoresSafeArea()
Color.clear.contentShape(Rectangle()).onTapGesture {}
content(phase).padding(40).frame(maxWidth: 460)
@@ -70,7 +74,8 @@ struct ConnectOverlay: View {
.padding(40)
}
}
.environment(\.colorScheme, .dark)
// The console takeover follows the palette; the default UI's modal stays dark.
.environment(\.colorScheme, gamepadUI && ink.isLight ? .light : .dark)
.transition(.opacity)
#if os(iOS) || os(macOS)
.background { ConnectControllerInput(waker: waker, onCancelConnect: onCancelConnect) }
@@ -12,6 +12,7 @@ import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
struct GamepadAddHostView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.dismiss) private var dismiss
let onAdd: (StoredHost) -> Void
@@ -47,12 +48,12 @@ struct GamepadAddHostView: View {
VStack(spacing: 4) {
Text("Add Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white)
.foregroundStyle(ink.fg)
if !compact {
Text("Hosts on this network appear automatically — add one by address "
+ "for everything else.")
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.55))
.foregroundStyle(ink.fg(0.55))
.multilineTextAlignment(.center)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72)
}
@@ -73,6 +74,9 @@ struct GamepadAddHostView: View {
}
// No aurora the same clean Liquid-Glass-over-dark base as the gamepad settings screen.
.background { GamepadFormBackground() }
// Publish the palette's ink to this screen (text, glass, accent, scrims) a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
// A port can't exceed 5 digits cap while typing so the row can't grow absurd.
.onChange(of: port) { _, value in
if value.count > 5 { port = String(value.prefix(5)) }
@@ -143,7 +147,7 @@ struct GamepadAddHostView: View {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
.foregroundStyle(.white)
.foregroundStyle(ink.fg)
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
.glassBackground(Circle(), interactive: true)
.contentShape(Circle())
@@ -180,22 +184,22 @@ struct GamepadAddHostView: View {
if row.isAction {
Label("Add Host", systemImage: "plus.circle.fill")
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(canAdd ? Color.brand : .white.opacity(0.35))
.foregroundStyle(canAdd ? ink.accent : ink.fg(0.35))
.frame(maxWidth: .infinity)
} else {
Text(row.label)
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(.white)
.foregroundStyle(ink.fg)
Spacer(minLength: 12)
Text(row.value.isEmpty ? row.placeholder : row.value)
.font(.geistFixed(m.valueFont, .medium))
.foregroundStyle(row.value.isEmpty ? .white.opacity(0.35) : .white)
.foregroundStyle(row.value.isEmpty ? ink.fg(0.35) : ink.fg)
.lineLimit(1)
.truncationMode(.head) // keep the end of a long address visible while typing
if editing == row.id {
// The live-edit caret: this row is what the keyboard tray is typing into.
Rectangle()
.fill(Color.brand)
.fill(ink.accent)
.frame(width: 2, height: m.labelFont + 2)
}
}
@@ -206,12 +210,12 @@ struct GamepadAddHostView: View {
// takes the brand wash, and the edited row keeps its brand caret border.
.consoleGlass(
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil,
tint: (focused || editing == row.id) ? ink.accent(0.30) : nil,
interactive: focused)
.overlay {
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder(
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06),
editing == row.id ? ink.accent(0.7) : ink.fg(focused ? 0.28 : 0.06),
lineWidth: 1)
}
.scaleEffect(focused ? 1.0 : 0.98)
@@ -89,6 +89,7 @@ struct GamepadHint: Identifiable {
/// worn as a self-contained Liquid Glass pill (like the top-bar controller chip) so it floats over
/// the backdrop instead of dissolving into it.
struct GamepadHintBar: View {
@Environment(\.gamepadInk) private var ink
let hints: [GamepadHint]
// 10-foot legend on tvOS, in-hand sizes elsewhere.
@@ -108,26 +109,35 @@ struct GamepadHintBar: View {
HStack(spacing: 7) {
Image(systemName: hint.glyph)
.font(.system(size: Self.glyphFont))
.foregroundStyle(.white)
.foregroundStyle(ink.fg)
Text(hint.text)
}
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
}
}
.font(.geist(Self.textFont, .semibold, relativeTo: .subheadline))
.foregroundStyle(.white.opacity(0.85))
.foregroundStyle(ink.fg(0.85))
.padding(Self.pad)
.consoleGlass(Capsule())
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
.overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1))
}
}
/// The console backdrop: a living aurora in the brand's violet family, drifting slowly over black
/// so it reads as ambience behind the cards, never as content. On iOS 18 / macOS 15+ it's an
/// animated `MeshGradient` a continuous silk of colour whose control points wander on slow,
/// out-of-phase sinusoids finished with an elliptical vignette (pools light in the centre, sinks
/// the corners) and a top/bottom legibility scrim. Older OSes fall back to the original drifting
/// radial-blob field, unchanged, so nothing regresses.
/// The console backdrop: a living aurora drifting slowly over black so it reads as ambience behind
/// the cards, never as content. On iOS 18 / macOS 15+ it's an animated `MeshGradient` a continuous
/// silk of colour whose control points wander on slow, out-of-phase sinusoids finished with an
/// elliptical vignette (pools light in the centre, sinks the corners) and a top/bottom legibility
/// scrim. Older OSes fall back to the original drifting radial-blob field, unchanged, so nothing
/// regresses.
///
/// `calm` is what the FORM screens (settings, add-host) wear: the same living field with its pools
/// dimmed onto its own corner colour, so those screens keep real colour under their Liquid Glass
/// rows without the launcher's contrast. They used to sit on a still gradient; nothing in the
/// gamepad UI is backed by a static image now. Motion is identical in both modes on purpose only
/// the contrast differs, so a screen change can't make the field jump.
///
/// `GamepadPalette` recolours the whole thing (the shared `ui_palette` setting) by transforming the
/// COLOURS, not by stacking a filter see GamepadPalette.swift for why.
///
/// Deliberately pure SwiftUI, no `.metal`: these sources build under both SwiftPM (`swift run`/
/// tests) and the Xcode project's synchronized folders, and a compiled metallib is only reliably
@@ -136,77 +146,90 @@ struct GamepadHintBar: View {
/// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's
/// header). Honors Reduce Motion by freezing the field at a fixed phase.
struct GamepadScreenBackground: View {
@Environment(\.gamepadInk) private var ink
/// Quiet the field for a form screen (see the type comment).
var calm = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
var body: some View {
let palette = GamepadPalette.named(paletteID)
Group {
if reduceMotion {
composite(at: 0)
composite(at: 0, palette: palette)
} else {
// 30 Hz is plenty for a field that drifts centimetres per minute, and halves the
// redraw cost of a battery-fed couch device vs. the display's native rate.
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
composite(at: context.date.timeIntervalSinceReferenceDate)
composite(at: context.date.timeIntervalSinceReferenceDate, palette: palette)
}
}
}
.ignoresSafeArea()
}
/// The colour field under a very slow warm/cool hue sway, an elliptical vignette, and the
/// title/hints legibility scrim.
private func composite(at t: TimeInterval) -> some View {
ZStack {
Color.black
colorField(at: t)
/// The colour field under a very slow warm/cool hue sway, the calm flattening, an elliptical
/// vignette, and the title/hints legibility scrim in that order, matching the console
/// shader's `composite` so the two platforms' backdrops stay the same picture.
private func composite(at t: TimeInterval, palette: GamepadPalette) -> some View {
// Where the scrims tend, and how hard. Mixing a PALE field toward white at the dark
// field's strength bleaches the chroma straight out of the gradient, so a pale palette
// gets under half the same `u_scrim.a` the console shader carries.
let scrim: Color = palette.light ? ink.fg : .black
let strength = palette.light ? 0.45 : 1.0
return ZStack {
Self.color(palette.ground)
colorField(at: t, palette: palette)
// ±8° over ~5 min the whole field very slowly warms and cools.
.hueRotation(.degrees(sin(t * 0.021) * 8))
// Cinematic vignette: darker toward the edges so the cards sit in the pooled light.
// Soft (extends past the frame) so the corners deepen rather than crush to black.
// Calm = col·0.6 + ground·0.4: over the ground, `.opacity` IS the multiply
.opacity(calm ? 0.6 : 1)
if calm {
// and a plusLighter wash of the palette's own ground IS the add. Chosen so the
// ground lands exactly where it was and the bright pools come down to meet it.
Self.color(palette.ground)
.opacity(0.4)
.blendMode(.plusLighter)
}
// Cinematic vignette: the edges settle toward the scrim so the cards sit in the
// pooled light. Soft (extends past the frame) so the corners deepen rather than
// crush. Halved under calm: a launcher's cards sit in the pooled centre, but a form
// screen's rows run out toward the edges, where crushing them just eats the list.
EllipticalGradient(
colors: [.clear, .black.opacity(0.42)],
colors: [.clear, scrim.opacity((calm ? 0.21 : 0.42) * strength)],
center: .center, startRadiusFraction: 0.25, endRadiusFraction: 1.15)
// Legibility grounding for the pinned title (top) and hint pill (bottom). This one
// darkens the aurora itself (it's the backdrop's bottom layer nothing behind it to
// blur), so it stays a gradient, just a light one now.
// works on the field itself (it's the backdrop's bottom layer nothing behind it to
// blur), so it stays a gradient, just a light one.
LinearGradient(
stops: [
.init(color: .black.opacity(0.38), location: 0),
.init(color: .black.opacity(0.06), location: 0.32),
.init(color: .black.opacity(0.08), location: 0.68),
.init(color: .black.opacity(0.40), location: 1),
.init(color: scrim.opacity(0.38 * strength), location: 0),
.init(color: scrim.opacity(0.06 * strength), location: 0.32),
.init(color: scrim.opacity(0.08 * strength), location: 0.68),
.init(color: scrim.opacity(0.40 * strength), location: 1),
],
startPoint: .top, endPoint: .bottom)
}
}
@ViewBuilder private func colorField(at t: TimeInterval) -> some View {
@ViewBuilder private func colorField(at t: TimeInterval, palette: GamepadPalette) -> some View {
if #available(iOS 18, macOS 15, tvOS 18, *) {
MeshGradient(
width: 4, height: 4,
points: Self.meshPoints(at: t),
colors: Self.meshColors,
colors: palette.meshColors.map(Self.color),
smoothsColors: true)
} else {
LegacyBlobField(t: t)
LegacyBlobField(t: t, palette: palette)
}
}
// MARK: - MeshGradient aurora (iOS 18 / macOS 15+)
/// Sixteen mesh colours (row-major, 4×4): dark-violet corners sink the frame, the edges carry
/// mid-tone violets, and the four interior points hold the bright brand family a violet and a
/// blue-violet up top, a magenta-violet and a violet below so warm pools on the left, cool on
/// the right, and the silk shifts temperature as those interior points drift.
private static let meshColors: [Color] = {
let corner = Color(red: 0.075, green: 0.060, blue: 0.160)
return [
corner, Color(red: 0.34, green: 0.27, blue: 0.72), Color(red: 0.30, green: 0.26, blue: 0.74), corner,
Color(red: 0.42, green: 0.20, blue: 0.54), Color(red: 0.49, green: 0.39, blue: 0.95), Color(red: 0.28, green: 0.31, blue: 0.84), Color(red: 0.16, green: 0.26, blue: 0.64),
Color(red: 0.45, green: 0.23, blue: 0.60), Color(red: 0.53, green: 0.31, blue: 0.75), Color(red: 0.35, green: 0.35, blue: 0.91), Color(red: 0.19, green: 0.28, blue: 0.70),
corner, Color(red: 0.22, green: 0.18, blue: 0.54), Color(red: 0.24, green: 0.20, blue: 0.58), corner,
]
}()
static func color(_ c: SIMD3<Double>) -> Color {
Color(red: c.x, green: c.y, blue: c.z)
}
/// The 4×4 control points at time `t`: every boundary point is PINNED to the frame (so the mesh
/// always fills edge-to-edge a drifting edge point would shrink the mesh and expose the black
@@ -233,15 +256,18 @@ struct GamepadScreenBackground: View {
}
/// Pre-18/15 fallback for `GamepadScreenBackground`: the original drifting radial-blob field four
/// soft colour blobs on slow Lissajous paths, additively blended. Kept verbatim so older OSes see
/// exactly the aurora they shipped with (the mesh path is the upgrade for OS 18/15+).
/// soft colour blobs on slow Lissajous paths, additively blended. Geometry and motion are verbatim
/// so older OSes see exactly the aurora they shipped with (the mesh path is the upgrade for OS
/// 18/15+); only the blob COLOURS now pass through the palette, so an older device honours the
/// setting too instead of being stuck on violet.
private struct LegacyBlobField: View {
let t: TimeInterval
let palette: GamepadPalette
/// One drifting color blob: a base position + drift ellipse (unit coordinates), angular speeds
/// (rad/s periods of 3090 s), and a radius that slowly breathes.
/// (rad/s periods of 3090 s), and a radius that slowly breathes. The COLOUR comes from the
/// palette's ramp at draw time (see `blobColors`), so an older OS honours the setting too.
private struct Blob {
let color: Color
let center: CGPoint
let drift: CGSize
let speed: (x: Double, y: Double)
@@ -252,20 +278,16 @@ private struct LegacyBlobField: View {
}
private static let blobs: [Blob] = [
Blob(color: Color(red: 0.53, green: 0.47, blue: 0.96), // brand violet
center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10),
Blob(center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10),
speed: (0.111, 0.083), phase: (0.0, 1.9),
radius: 0.52, breathe: (0.07, 0.061), opacity: 0.52),
Blob(color: Color(red: 0.24, green: 0.20, blue: 0.72), // deep indigo
center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14),
Blob(center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14),
speed: (0.071, 0.096), phase: (2.4, 0.7),
radius: 0.58, breathe: (0.08, 0.049), opacity: 0.55),
Blob(color: Color(red: 0.62, green: 0.30, blue: 0.80), // plum
center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09),
Blob(center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09),
speed: (0.089, 0.067), phase: (4.1, 3.2),
radius: 0.44, breathe: (0.09, 0.078), opacity: 0.42),
Blob(color: Color(red: 0.22, green: 0.38, blue: 0.86), // cool blue
center: CGPoint(x: 0.70, y: 0.12), drift: CGSize(width: 0.10, height: 0.08),
Blob(center: CGPoint(x: 0.70, y: 0.12), drift: CGSize(width: 0.10, height: 0.08),
speed: (0.059, 0.104), phase: (1.2, 5.0),
radius: 0.40, breathe: (0.06, 0.055), opacity: 0.38),
]
@@ -275,26 +297,31 @@ private struct LegacyBlobField: View {
let side = max(geo.size.width, geo.size.height)
ZStack {
ForEach(Self.blobs.indices, id: \.self) { i in
blobView(Self.blobs[i], in: geo.size, side: side)
blobView(Self.blobs[i], tone: palette.blobColors[i], in: geo.size, side: side)
}
}
.drawingGroup()
}
}
private func blobView(_ blob: Blob, in size: CGSize, side: CGFloat) -> some View {
private func blobView(
_ blob: Blob, tone: SIMD3<Double>, in size: CGSize, side: CGFloat
) -> some View {
let x = blob.center.x + blob.drift.width * CGFloat(sin(t * blob.speed.x + blob.phase.x))
let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y))
let r = side * blob.radius
* (1 + blob.breathe.amount * CGFloat(sin(t * blob.breathe.speed + blob.phase.x)))
let color = GamepadScreenBackground.color(tone)
return Circle()
.fill(RadialGradient(
colors: [blob.color, blob.color.opacity(0)],
colors: [color, color.opacity(0)],
center: .center, startRadius: 0, endRadius: r / 2))
.frame(width: r, height: r)
.position(x: x * size.width, y: y * size.height)
.opacity(blob.opacity)
.blendMode(.plusLighter)
// Additive only works over a DARK ground; over a pale one every blob saturates to
// white and the field turns grey. Pale palettes tint instead.
.blendMode(palette.light ? .normal : .plusLighter)
}
}
@@ -304,15 +331,17 @@ private struct LegacyBlobField: View {
/// the tray's text sits on a softly blurred backdrop that dissolves into the rows.
struct GamepadTrayScrim: View {
let edge: VerticalEdge
@Environment(\.gamepadInk) private var ink
var body: some View {
let fromEdge: UnitPoint = edge == .top ? .top : .bottom
let toContent: UnitPoint = edge == .top ? .bottom : .top
Rectangle()
.fill(.ultraThinMaterial)
// These trays always sit on the dark console UI; force dark so the material frosts dark
// (white text stays legible) regardless of the system appearance.
.environment(\.colorScheme, .dark)
// Force the frost to match the PALETTE, not the system appearance: the tray exists
// to keep the pinned title legible, so it has to frost dark under white ink and
// light under dark ink.
.environment(\.colorScheme, ink.isLight ? .light : .dark)
// Fade the whole blur out toward the content so it dissolves rather than ending on a line.
.mask {
LinearGradient(
@@ -330,27 +359,16 @@ struct GamepadTrayScrim: View {
}
}
/// The calm backdrop for the gamepad UI's form screens (settings, add-host) NOT the launcher's
/// drifting aurora (this stays still and quiet), but deliberately NOT near-black either: Liquid
/// Glass refracts whatever sits behind it, so over black the rows turn invisible. A deep indigo
/// base plus two soft, static violet/indigo glows give the glass real colour and luminance to lens,
/// so the rows read as glass while the screen stays restful.
/// The backdrop for the gamepad UI's form screens (settings, add-host). It used to be a STILL pair
/// of glows over a deep indigo base deliberately not near-black, because Liquid Glass refracts
/// whatever sits behind it and over black the rows turn invisible. It is now the launcher's own
/// living field at `calm`, which keeps that luminance under the glass, keeps the palette setting
/// honoured on every screen rather than only the launcher, and leaves nothing in the gamepad UI
/// backed by a static image. Kept as its own type because that is what the form screens ask for by
/// name; the console (`pf-console-ui`) made the same substitution behind its `Bg::Form`.
struct GamepadFormBackground: View {
var body: some View {
ZStack {
Color(red: 0.075, green: 0.062, blue: 0.150)
// Violet lift top-leading, cooler indigo bottom-trailing resolution-independent
// (fraction radii) so the glow scale tracks the window on any screen.
EllipticalGradient(
colors: [Color(red: 0.40, green: 0.31, blue: 0.68).opacity(0.9), .clear],
center: UnitPoint(x: 0.26, y: 0.14),
startRadiusFraction: 0, endRadiusFraction: 0.78)
EllipticalGradient(
colors: [Color(red: 0.20, green: 0.24, blue: 0.58).opacity(0.75), .clear],
center: UnitPoint(x: 0.82, y: 0.9),
startRadiusFraction: 0, endRadiusFraction: 0.78)
}
.ignoresSafeArea()
GamepadScreenBackground(calm: true)
}
}
@@ -373,6 +391,7 @@ struct ConsoleBareButtonStyle: ButtonStyle {
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
/// when the pad or its battery state changes.
struct ControllerStatusChip: View {
@Environment(\.gamepadInk) private var ink
let controller: GamepadManager.DiscoveredController
// Legible from the couch on tvOS, quiet in hand elsewhere.
@@ -397,15 +416,15 @@ struct ControllerStatusChip: View {
Image(systemName: batterySymbol(level))
.font(.system(size: Self.font))
.foregroundStyle(level <= 0.2 && !controller.isCharging
? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7)))
? AnyShapeStyle(.red) : AnyShapeStyle(ink.fg(0.7)))
}
}
.font(.geist(Self.font, .medium, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.7))
.foregroundStyle(ink.fg(0.7))
.padding(.horizontal, Self.hPad)
.padding(.vertical, Self.vPad)
.background(Capsule().fill(.white.opacity(0.08)))
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
.background(Capsule().fill(ink.fg(0.08)))
.overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1))
}
private func batterySymbol(_ level: Float) -> String {
@@ -23,14 +23,15 @@ import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
import GameController
/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host
/// action. Hashable so it can be the carousel's scroll-position identity.
/// One navigable tile: a saved host, a discovered-but-unsaved one, or one of the trailing
/// actions. Hashable so it can be the carousel's scroll-position identity.
private enum GamepadHomeTarget: Hashable {
/// A saved host's own tile, or one of its pinned host+profile cards (§5.2a) which on a
/// controller-first surface are THE profile affordance: focus and press, no menus.
case saved(UUID, profile: String?)
case discovered(String)
case addHost
case rescan
}
/// A fully-resolved launcher tile display fields + the activate action, built fresh each render
@@ -63,6 +64,7 @@ private struct HomeTile: Identifiable {
}
struct GamepadHomeView: View {
@Environment(\.gamepadInk) private var ink
@ObservedObject var store: HostStore
@ObservedObject var model: SessionModel
@ObservedObject var discovery: HostDiscovery
@@ -114,6 +116,9 @@ struct GamepadHomeView: View {
.padding(.top, compact ? 4 : 8)
}
.background { GamepadScreenBackground() }
// Publish the palette's ink to this screen (text, glass, accent, scrims) a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
.onAppear { discovery.start() }
.onDisappear { discovery.stop() }
// Reachability sweep (mDNS-independent) so routed/VPN hosts that never advertise still show
@@ -176,18 +181,33 @@ struct GamepadHomeView: View {
// MARK: - Chrome
private var titleBar: some View {
Text("Select a Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.overlay(alignment: .trailing) {
// Which pad is driving this UI (name + battery) quiet, and only where there's
// room; a compact-height phone gives the pixels to the carousel instead.
if !compact, let active = gamepads.active {
ControllerStatusChip(controller: active)
.padding(.trailing, 20)
}
}
// The chip used to be a trailing `.overlay`, which reserves no width: on a portrait phone
// it sat directly on top of the centred title ("Select a Host" ran straight into the pad
// name). Laying it out as a row with a hidden mirror on the leading side keeps the title
// optically centred AND clear of the chip at every width; the title shrinks a little
// before it would ever truncate.
HStack(spacing: 12) {
statusChip(hidden: true)
Text("Select a Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.lineLimit(1)
.minimumScaleFactor(0.75)
.frame(maxWidth: .infinity)
statusChip(hidden: false)
}
.padding(.horizontal, 20)
}
/// Which pad is driving this UI (name + battery) quiet, and only where there's room; a
/// compact-height phone gives the pixels to the carousel instead. `hidden` renders the same
/// chip purely as a width reserve.
@ViewBuilder private func statusChip(hidden: Bool) -> some View {
if !compact, let active = gamepads.active {
ControllerStatusChip(controller: active)
.opacity(hidden ? 0 : 1)
.accessibilityHidden(hidden)
}
}
private var cardSpacing: CGFloat {
@@ -247,10 +267,14 @@ struct GamepadHomeView: View {
private var hints: [GamepadHint] {
let selected = tiles.first { $0.id == selection }
let action: String? = switch selected?.id {
case .addHost: "Add Host"
case .rescan: "Rescan"
default: nil
}
var hints = [GamepadHint(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
text: selected?.id == .addHost ? "Add Host"
: (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
if libraryEnabled, selected?.hasLibrary == true {
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
}
@@ -310,7 +334,15 @@ struct GamepadHomeView: View {
subtitle: "Register a host by address",
icon: "plus",
activate: { showAddHost = true })
return saved + discovered + [add]
// A controller surface has no toolbar and no pull-to-refresh, so the rescan the field
// asked for is a tile like any other one press from wherever the stick already is.
let rescan = HomeTile(
id: .rescan,
title: "Rescan",
subtitle: discovery.isScanning ? "Scanning…" : "Look for hosts on this network",
icon: "arrow.clockwise",
activate: { discovery.refresh() })
return saved + discovered + [add, rescan]
}
/// Only saved hosts have a library matches the touch grid, where "Browse Library" is a
@@ -327,6 +359,7 @@ struct GamepadHomeView: View {
/// touch grid's `HostCardView`. Renders only its base look; the centered-tile pop is layered on by
/// the caller's `.scrollTransition` so it always tracks the real scroll position.
private struct GamepadHostTile: View {
@Environment(\.gamepadInk) private var ink
let tile: HomeTile
let size: CGSize
@@ -366,7 +399,7 @@ private struct GamepadHostTile: View {
if tile.isPaired {
Image(systemName: "lock.fill")
.font(.system(size: Self.statusFont, weight: .semibold))
.foregroundStyle(.white.opacity(0.5))
.foregroundStyle(ink.fg(0.5))
}
if tile.isOnline {
Circle()
@@ -379,7 +412,7 @@ private struct GamepadHostTile: View {
Spacer(minLength: 0)
Text(tile.title)
.font(.geist(Self.titleFont, .bold, relativeTo: .title2))
.foregroundStyle(.white)
.foregroundStyle(ink.fg)
.lineLimit(1)
.minimumScaleFactor(0.7)
if let profile = tile.profile {
@@ -389,7 +422,7 @@ private struct GamepadHostTile: View {
}
Text(tile.subtitle)
.font(.geist(Self.subtitleFont, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.55))
.foregroundStyle(ink.fg(0.55))
.lineLimit(1)
.padding(.top, 2)
}
@@ -399,12 +432,12 @@ private struct GamepadHostTile: View {
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
.consoleGlass(
RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
tint: tile.filled ? Color.brand.opacity(0.20) : nil)
tint: tile.filled ? ink.accent(0.20) : nil)
.overlay {
RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
.strokeBorder(
LinearGradient(
colors: [.white.opacity(0.22), .white.opacity(0.04)],
colors: [ink.fg(0.22), ink.fg(0.04)],
startPoint: .top, endPoint: .bottom),
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5]))
}
@@ -416,15 +449,15 @@ private struct GamepadHostTile: View {
return ZStack {
shape.fill(tile.filled
? AnyShapeStyle(LinearGradient(
colors: [Color.brand, Color.brand.opacity(0.68)],
colors: [ink.accent, ink.accent(0.68)],
startPoint: .top, endPoint: .bottom))
: AnyShapeStyle(Color.brand.opacity(0.16)))
: AnyShapeStyle(ink.accent(0.16)))
if tile.isConnecting {
ProgressView().tint(.white)
ProgressView().tint(ink.fg)
} else if let icon = tile.icon {
Image(systemName: icon)
.font(.system(size: Self.iconFont, weight: .semibold))
.foregroundStyle(Color.brand)
.foregroundStyle(ink.accent)
} else if let mark = osIconImage(for: tile.osChain) {
// The OS mark stands in for the initial (template asset tints like the text it
// replaces), and carries the label, since nothing else on the tile names the OS.
@@ -432,18 +465,18 @@ private struct GamepadHostTile: View {
.resizable()
.scaledToFit()
.frame(width: Self.monogramFont, height: Self.monogramFont)
.foregroundStyle(tile.filled ? .white : Color.brand)
.foregroundStyle(tile.filled ? ink.fg : ink.accent)
.accessibilityLabel(tile.osChain ?? "")
} else {
Text(monogram(tile.title))
.font(.geistFixed(Self.monogramFont, .bold))
.foregroundStyle(tile.filled ? .white : Color.brand)
.foregroundStyle(tile.filled ? ink.fg : ink.accent)
}
}
.frame(width: Self.badgeSide, height: Self.badgeSide)
.overlay {
if !tile.filled {
shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1)
shape.strokeBorder(ink.accent(0.5), lineWidth: 1)
}
}
}
@@ -0,0 +1,91 @@
// The ink the gamepad UI draws with under the chosen background palette.
//
// The console screens were white-on-dark throughout with the brand violet hardcoded as the
// accent. Both had to become palette-derived at once: a pale field needs dark text or it is
// unreadable, and a violet focus wash on a copper field is exactly the clash this exists to fix.
//
// Handed down the view tree as an environment value rather than passed to each screen, so a
// leaf (a row, a hint pill, a card) can ask for the right colour without every caller in between
// knowing about palettes. `pf-console-ui` does the same thing with a thread-local `Ink`.
import PunktfunkShared
import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
struct GamepadInk: Equatable, Sendable {
/// Primary text/glyph colour.
let fg: Color
/// Focus wash, selected tab pill, switch track, caret the palette's own accent.
let accent: Color
/// What reads ON the accent (a filled pill's label).
let onAccent: Color
/// The base fill every glass surface starts from.
let glass: Color
/// What a wash laid UNDER text tends toward: black on a dark field, white on a pale one.
let shade: Color
/// How hard those washes go. A pale field needs far less mixing toward white at the dark
/// field's strength bleaches the chroma straight out of the gradient.
let shadeScale: Double
/// True when the field is pale, for the few places that need to branch rather than blend
/// (a material's `colorScheme`, a shadow's presence).
let isLight: Bool
/// The foreground at `alpha`.
func fg(_ alpha: Double) -> Color { fg.opacity(alpha) }
/// The accent at `alpha`.
func accent(_ alpha: Double) -> Color { accent.opacity(alpha) }
/// A wash under text: `alpha` is the dark-field strength, scaled for a pale one.
func shade(_ alpha: Double) -> Color { shade.opacity(alpha * shadeScale) }
static func of(_ p: GamepadPalette) -> GamepadInk {
let accent = Color(red: p.accent.x, green: p.accent.y, blue: p.accent.z)
let accentLuma = 0.2126 * p.accent.x + 0.7152 * p.accent.y + 0.0722 * p.accent.z
// Chosen by luminance, not by `light`: an accent is picked for contrast against the
// GLASS, not against the field.
let onAccent: Color = accentLuma > 0.55 ? .black : .white
guard p.light else {
return GamepadInk(
fg: .white, accent: accent, onAccent: onAccent,
glass: Color(red: 0.086, green: 0.086, blue: 0.125),
shade: .black, shadeScale: 1, isLight: false)
}
return GamepadInk(
// Tinted toward the palette's own ground so it doesn't read as a foreign grey.
fg: Color(red: p.ground.x * 0.16, green: p.ground.y * 0.14, blue: p.ground.z * 0.20),
accent: accent, onAccent: onAccent,
glass: .white,
shade: .white, shadeScale: 0.45, isLight: true)
}
/// The shipped dark look what a preview or a test composition gets.
static let dark = GamepadInk.of(GamepadPalette.named("violet"))
}
private struct GamepadInkKey: EnvironmentKey {
static let defaultValue = GamepadInk.dark
}
extension EnvironmentValues {
/// The ink of the palette currently drawing. Set once, high up (see `GamepadInkModifier`).
var gamepadInk: GamepadInk {
get { self[GamepadInkKey.self] }
set { self[GamepadInkKey.self] = newValue }
}
}
extension View {
/// Resolve the stored `ui_palette` and publish its ink to everything below. Applied by the
/// gamepad screens' common root so no individual view has to read the setting.
func gamepadPaletteInk() -> some View { modifier(GamepadInkModifier()) }
}
private struct GamepadInkModifier: ViewModifier {
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
func body(content: Content) -> some View {
content.environment(\.gamepadInk, GamepadInk.of(GamepadPalette.named(paletteID)))
}
}
#endif
@@ -14,6 +14,7 @@ import SwiftUI
#if os(iOS) || os(macOS)
struct GamepadKeyboard: View {
@Environment(\.gamepadInk) private var ink
@Binding var text: String
/// Restricts typed characters (e.g. digits for a port field); backspace always works.
var allowed: CharacterSet?
@@ -79,7 +80,7 @@ struct GamepadKeyboard: View {
}
.overlay {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(.white.opacity(0.12), lineWidth: 1)
.strokeBorder(ink.fg(0.12), lineWidth: 1)
}
.sensoryFeedback(.selection, trigger: cursor)
.sensoryFeedback(.impact(weight: .light), trigger: pressTick)
@@ -110,11 +111,11 @@ struct GamepadKeyboard: View {
.font(.geist(15, .semibold, relativeTo: .callout))
}
}
.foregroundStyle(focused ? Color.black : .white)
.foregroundStyle(focused ? Color.black : ink.fg)
.frame(maxWidth: .infinity, minHeight: compact ? 34 : 42)
.background {
RoundedRectangle(cornerRadius: 9, style: .continuous)
.fill(focused ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.white.opacity(0.08)))
.fill(focused ? AnyShapeStyle(ink.accent) : AnyShapeStyle(ink.fg(0.08)))
}
.animation(.smooth(duration: 0.12), value: focused)
.contentShape(Rectangle())
@@ -35,6 +35,10 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
let onActivate: (Item) -> Void
/// B back/dismiss; nil disables it.
var onBack: (() -> Void)?
/// L1 (`-1`) / R1 (`+1`) a step SIDEWAYS out of the list: the settings screen's section
/// tabs. Wired on tvOS too, where the focus engine owns up/down but leaves the shoulders
/// to the poll. nil the shoulders do nothing.
var onShoulder: ((Int) -> Void)?
/// Whether this list currently owns controller input same handoff contract as
/// GamepadCarousel's `isActive` (a covered screen must stop polling the shared pad).
var isActive: Bool = true
@@ -159,6 +163,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
case .up, .down: break
}
}
input.onShoulder = { forward in onShoulder?(forward ? 1 : -1) }
#else
input.onMove = { direction in
switch direction {
@@ -170,6 +175,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
}
input.onConfirm = { activate() }
input.onBack = onBack
input.onShoulder = { forward in onShoulder?(forward ? 1 : -1) }
#endif
}
@@ -53,7 +53,18 @@ struct HomeView: View {
NavigationStack {
Group {
if store.hosts.isEmpty && discoveredUnsaved.isEmpty {
emptyState
#if os(tvOS)
emptyState // no pull-to-refresh on a remote; the action row carries Refresh
#else
// Inside a ScrollView purely so the pull gesture works on the ONE screen
// where a rescan matters most: the one that found nothing.
ScrollView {
emptyState
.frame(maxWidth: .infinity)
.containerRelativeFrame(.vertical)
}
.refreshable { await discovery.rescan() }
#endif
} else {
ScrollView {
if !store.hosts.isEmpty {
@@ -94,6 +105,7 @@ struct HomeView: View {
} label: {
Label("Settings", systemImage: "gearshape")
}
refreshButton
}
.padding(.top, 24)
// One FULL-WIDTH focus target for any downward move out of the grid.
@@ -106,6 +118,9 @@ struct HomeView: View {
.focusSection()
#endif
}
#if !os(tvOS)
.refreshable { await discovery.rescan() }
#endif
}
}
.navigationTitle("Punktfunk")
@@ -151,6 +166,7 @@ struct HomeView: View {
if showsArrangeMenu {
ToolbarItem(placement: .topBarTrailing) { arrangeMenu }
}
ToolbarItem(placement: .topBarTrailing) { refreshButton }
ToolbarItem(placement: .topBarTrailing) { addHostButton }
#else
if showsArrangeMenu {
@@ -159,6 +175,10 @@ struct HomeView: View {
.help("Sort and group the host list")
}
}
ToolbarItem(placement: .primaryAction) {
refreshButton
.help("Scan the network for hosts again")
}
ToolbarItem(placement: .primaryAction) {
addHostButton
.help("Add a host")
@@ -324,13 +344,20 @@ struct HomeView: View {
ContentUnavailableView {
Label("No Hosts", systemImage: "rectangle.connected.to.line.below")
} description: {
Text("Add your punktfunk host with the + button.")
Text("Add your Punktfunk host with the + button, or scan the network again.")
} actions: {
Button("Add Host") { showAddHost = true }
.glassProminentButtonStyle()
#if os(iOS)
.controlSize(.large)
#endif
// The screen a host SHOULD have appeared on is where a rescan is worth offering
// outright rather than hiding behind a pull gesture.
Button("Scan Again") { discovery.refresh() }
.disabled(discovery.isScanning)
#if os(iOS)
.controlSize(.large)
#endif
#if os(tvOS)
Button("Settings") { showSettings = true }
#endif
@@ -345,6 +372,18 @@ struct HomeView: View {
}
}
/// Re-run mDNS discovery from scratch. Discovery heals itself now (`HostDiscovery`'s sweep),
/// so this is the fallback the field asked for and the fastest way past the iOS
/// local-network permission gate, which only a NEW browser can clear.
private var refreshButton: some View {
Button {
discovery.refresh()
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.disabled(discovery.isScanning)
}
#if !os(tvOS)
/// One host has no order and nothing to divide, so the control stays out of the way until
/// there is a list to arrange.
@@ -19,6 +19,7 @@ import SwiftUI
import GameController
struct LibraryCoverflowView: View {
@Environment(\.gamepadInk) private var ink
let games: [GameEntry]
let imageSession: URLSession?
var onLaunch: ((String) -> Void)?
@@ -46,18 +47,24 @@ struct LibraryCoverflowView: View {
.padding(.vertical, compact ? 6 : 10)
}
.background { GamepadScreenBackground() }
// Publish the palette's ink to this screen (text, glass, accent, scrims) a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
}
@ViewBuilder private func content(for size: CGSize) -> some View {
// Fit the tallest poster into the height the detail line + paddings leave (the hints are a
// safe-area inset, already out of this budget) capped so it never dwarfs a large iPad and
// clamped by width on a narrow screen.
let reserved: CGFloat = compact ? 72 : 96 // detail line + spacers
let reserved: CGFloat = (compact ? 72 : 96) + (showsGroupHeading ? 26 : 0)
let coverHeight = min(360, min(max(140, size.height - reserved), size.width * 0.9))
let coverWidth = coverHeight * 2 / 3
VStack(spacing: 0) {
Spacer(minLength: 4)
if showsGroupHeading {
groupHeading.padding(.bottom, 6)
}
carousel(coverWidth: coverWidth, coverHeight: coverHeight)
detailPanel
.padding(.top, 12)
@@ -89,10 +96,12 @@ struct LibraryCoverflowView: View {
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
.frame(width: width, height: height)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
.overlay(alignment: .topLeading) {
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
}
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(.white.opacity(0.12), lineWidth: 1)
.strokeBorder(ink.fg(0.12), lineWidth: 1)
}
.shadow(color: .black.opacity(0.5), radius: 16, y: 12)
.scrollTransition { content, phase in
@@ -112,21 +121,42 @@ struct LibraryCoverflowView: View {
}
}
/// Does this library have both groups? Only then does the heading earn its row a
/// launcher-less library gets exactly the layout it had before design D4.
private var showsGroupHeading: Bool {
games.contains(where: \.isLauncher) && games.contains { !$0.isLauncher }
}
/// Which group the cursor is in. A coverflow is one-dimensional, so instead of a second focus
/// rail (a whole new up/down nav model for two or three tiles) the heading names the group and
/// changes as the selection crosses the boundary the launcher entries lead the strip.
private var groupHeading: some View {
let selected = games.first { $0.id == selection }
return Text(selected?.isLauncher == true ? "LAUNCHERS" : "GAMES")
.font(.geist(11, .semibold, relativeTo: .caption2))
.tracking(1.4)
.foregroundStyle(ink.fg(0.45))
}
/// The centered title + store tag empty (not hidden) so the layout doesn't jump.
@ViewBuilder private var detailPanel: some View {
let game = games.first { $0.id == selection }
VStack(spacing: 6) {
Text(game?.title ?? " ")
.font(.geist(compact ? 22 : 25, .bold, relativeTo: .title))
.foregroundStyle(.white)
.foregroundStyle(ink.fg)
.lineLimit(1)
.minimumScaleFactor(0.75)
.multilineTextAlignment(.center)
if let game {
Text(game.isCustom ? "CUSTOM" : "STEAM")
.font(.geist(11, .semibold, relativeTo: .caption2))
.tracking(1.2)
.foregroundStyle(.white.opacity(0.5))
// main's richer store label, in the palette's ink.
Text(
game.isLauncher
? "\(game.storeLabel.uppercased()) · LAUNCHER" : game.storeLabel.uppercased()
)
.font(.geist(11, .semibold, relativeTo: .caption2))
.tracking(1.2)
.foregroundStyle(ink.fg(0.5))
}
}
.frame(maxWidth: .infinity)
@@ -139,7 +169,10 @@ struct LibraryCoverflowView: View {
private var hints: [GamepadHint] {
var hints: [GamepadHint] = []
if onLaunch != nil {
hints.append(.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Launch"))
// You *open* a launcher and *launch* a game the hint follows the focused entry.
let opens = games.first { $0.id == selection }?.isLauncher == true
hints.append(
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch"))
}
hints.append(.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close"))
return hints
@@ -80,21 +80,47 @@ struct LibraryView: View {
}
private var grid: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 18) {
ForEach(games) { game in
if let onLaunch {
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
.buttonStyle(.plain)
} else {
GameCard(game: game, imageSession: imageSession)
}
// Design D4: launcher entries get their own section above the titles, never interleaved.
// Both headers appear only when both groups exist, so a library without launcher entries
// renders exactly as it did before.
let launchers = games.filter(\.isLauncher)
let titles = games.filter { !$0.isLauncher }
let both = !launchers.isEmpty && !titles.isEmpty
return ScrollView {
VStack(alignment: .leading, spacing: 18) {
if !launchers.isEmpty {
if both { sectionHeader("Launchers") }
tiles(launchers)
}
if !titles.isEmpty {
if both { sectionHeader("Games") }
tiles(titles)
}
}
.padding()
}
}
private func tiles(_ entries: [GameEntry]) -> some View {
LazyVGrid(columns: columns, spacing: 18) {
ForEach(entries) { game in
if let onLaunch {
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
.buttonStyle(.plain)
} else {
GameCard(game: game, imageSession: imageSession)
}
}
}
}
private func sectionHeader(_ text: String) -> some View {
Text(text)
.font(.geist(12, .semibold, relativeTo: .caption))
.tracking(1.1)
.foregroundStyle(.secondary)
}
private var columns: [GridItem] {
#if os(tvOS)
let minW: CGFloat = 220
@@ -152,12 +178,15 @@ struct LibraryView: View {
return
}
do {
// `launchersFirst` groups launcher entries ahead of titles once, here, so the grid and
// the gamepad coverflow both inherit the D4 ordering.
games = try await LibraryClient.fetch(
address: current.address,
port: current.effectiveMgmtPort,
certPEM: identity.certPEM,
keyPEM: identity.keyPEM,
hostFingerprint: current.pinnedSHA256)
hostFingerprint: current.pinnedSHA256
).launchersFirst
imageSession?.finishTasksAndInvalidate()
imageSession = try LibraryImageLoader.session(
address: current.address,
@@ -185,7 +214,9 @@ private struct GameCard: View {
.aspectRatio(2.0 / 3.0, contentMode: .fit)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
.overlay(alignment: .topLeading) {
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
}
Text(game.title)
.font(.geist(12, relativeTo: .caption))
.lineLimit(2)
@@ -12,14 +12,21 @@ import AppKit
/// The store-provenance badge (Steam vs. a user-curated custom entry) overlaid on a poster
/// shared by the touch grid's `GameCard` and the gamepad coverflow's cover cell.
struct StoreBadge: View {
let isCustom: Bool
/// Which store surfaced the entry, already resolved to a display name (`GameEntry.storeLabel`).
let label: String
/// A launcher entry (design D4) gets the brand fill, so "opens Steam" is legible at poster size
/// without reading the title.
var isLauncher: Bool = false
var body: some View {
Text(isCustom ? "Custom" : "Steam")
Text(label)
.font(.geist(11, .semibold, relativeTo: .caption2))
.foregroundStyle(isLauncher ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(.ultraThinMaterial, in: Capsule())
.background(
isLauncher ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.ultraThinMaterial),
in: Capsule())
.padding(6)
}
}
@@ -24,6 +24,13 @@ import ImageIO
@MainActor
enum ScreenshotMode {
/// This process was launched to capture a screenshot. Cheap enough to consult from the
/// stores' persistence paths (`HostStore` / `ProfileStore`), which must NOT write their
/// mock contents back into a real user's App Group when the harness runs on a dev Mac.
static var isActive: Bool {
!(ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? "").isEmpty
}
/// The scene requested via PUNKTFUNK_SHOT_SCENE, or nil for a normal launch.
static var requestedScene: ShotScene? {
let name = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? ""
@@ -41,8 +48,11 @@ struct ScreenshotHostView: View {
scene.make()
.environment(\.colorScheme, scene.colorScheme)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
.ignoresSafeArea()
// Black fills the display, but the SCENE keeps its safe area. Ignoring it wholesale
// here pushed the stream hero's HUD under the Dynamic Island (the resolution/bitrate
// line was unreadable in every 6.9" capture); scenes that genuinely want full bleed
// the streamed frame itself ignore it themselves.
.background(Color.black.ignoresSafeArea())
#if os(macOS)
.background(MacShotWindowConfigurator(scene: scene))
#elseif os(iOS)
@@ -129,18 +139,64 @@ enum MacSelfCapture {
#endif
#if os(iOS)
/// Best-effort orientation lock for the requested scene (landscape for the stream hero, portrait
/// for chrome). Requires the app to allow those orientations in Info.plist.
/// Orientation lock for the requested scene (landscape for the stream hero, portrait for chrome).
/// Requires the app to allow those orientations in Info.plist it does, for both.
private struct IOSOrientationConfigurator: UIViewControllerRepresentable {
let orientation: ShotOrientation
func makeUIViewController(context: Context) -> UIViewController { UIViewController() }
func makeUIViewController(context: Context) -> ShotOrientationController {
ShotOrientationController(mask: mask)
}
func updateUIViewController(_ vc: UIViewController, context: Context) {
guard let scene = vc.view.window?.windowScene else { return }
let mask: UIInterfaceOrientationMask = orientation == .landscape ? .landscapeRight : .portrait
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
vc.setNeedsUpdateOfSupportedInterfaceOrientations()
func updateUIViewController(_ vc: ShotOrientationController, context: Context) {
vc.mask = mask
vc.applyGeometry()
}
private var mask: UIInterfaceOrientationMask {
orientation == .landscape ? .landscapeRight : .portrait
}
}
/// Asks the window scene to rotate, from a place where there IS a window.
///
/// The previous version made the request inside `updateUIViewController`, where `view.window` is
/// still nil: SwiftUI makes exactly one update pass for a representable mounted as a `.background`,
/// before the hierarchy is in a window, so the `guard` fell through and nothing ever asked again.
/// Every scene declared `.landscape` the stream hero and the trust card was therefore captured
/// in PORTRAIT at the portrait App Store size. Overriding `supportedInterfaceOrientations` as well
/// keeps the scene from rotating back if the simulator reports a device orientation change.
final class ShotOrientationController: UIViewController {
var mask: UIInterfaceOrientationMask
init(mask: UIInterfaceOrientationMask) {
self.mask = mask
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("not from a nib") }
override var supportedInterfaceOrientations: UIInterfaceOrientationMask { mask }
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
applyGeometry()
}
func applyGeometry() {
// `view.window` once mounted; the connected-scene lookup covers the first update pass,
// which still runs before this controller is in a window.
let scene = view.window?.windowScene
?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
guard let scene else { return }
// Report a refusal instead of silently shipping the wrong orientation that is exactly
// how every landscape scene went out as a portrait PNG for as long as it did.
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in
print("PF_SHOT_ORIENTATION_REFUSED \(error.localizedDescription)")
fflush(stdout)
}
setNeedsUpdateOfSupportedInterfaceOrientations()
}
}
#endif
@@ -81,24 +81,126 @@ enum ShotScenes {
@MainActor
enum ShotMock {
/// A populated saved-host grid: a pinned recent host, a couple more, mixed online state.
// Stable ids so the store, the adverts and the profile bindings all point at the same things
// across every scene and every run.
static let battlestationID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000001")!
static let livingRoomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000002")!
static let workshopID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000003")!
static let officeID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000004")!
static let editingID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000005")!
static let bedroomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000006")!
static let hdrProfileID = "a71c4e0d9f22"
static let couchProfileID = "3e88b107c4da"
/// The catalog the host cards read their chips and pinned cards from. Seeded once, on the
/// first store build `ProfileStore` is a singleton, and in shot mode its write-back is
/// suppressed, so this never reaches a real user's catalog.
static func installProfiles() {
guard !profilesInstalled else { return }
profilesInstalled = true
ProfileStore.shared.debugSet([
StreamProfile(name: "4K HDR", id: hdrProfileID, accent: "#8B7BF7"),
StreamProfile(name: "Couch 1080p", id: couchProfileID, accent: "#4FD1A5"),
])
}
private static var profilesInstalled = false
/// A populated saved-host grid: the most-recent host bound to a profile (its chip), a second
/// paired machine, and one asleep box we hold a MAC for (so its card offers Wake-on-LAN). OS
/// chains give every tile its real vendor mark instead of a letter monogram.
///
/// No PINNED host+profile card: it renders a second tile for the SAME host, which is the
/// feature working as designed but reads as a duplicate to anyone meeting the app in a store
/// listing. The binding chip carries the profile story on its own.
static func hostStore() -> HostStore {
installProfiles()
let store = HostStore()
store.hosts = [
StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420)),
StoredHost(name: "Living Room PC", address: "192.168.1.41", port: 9777,
pinnedSHA256: fingerprint),
StoredHost(name: "Workshop", address: "10.0.0.7", port: 9777),
StoredHost(
id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420),
macAddresses: ["a4:b1:c2:d3:e4:f5"], profileID: hdrProfileID,
osChain: "windows/11"),
StoredHost(
id: livingRoomID, name: "Living Room PC", address: "192.168.1.41", port: 9777,
pinnedSHA256: hostFingerprint(1), lastConnected: Date().addingTimeInterval(-86_400),
macAddresses: ["b8:27:eb:11:22:33"], osChain: "linux/fedora/bazzite"),
StoredHost(
id: officeID, name: "Office NUC", address: "192.168.1.33", port: 9777,
pinnedSHA256: hostFingerprint(4), lastConnected: Date().addingTimeInterval(-259_200),
profileID: couchProfileID, osChain: "linux/ubuntu"),
StoredHost(
id: workshopID, name: "Workshop", address: "10.0.0.7", port: 9777,
pinnedSHA256: hostFingerprint(2), macAddresses: ["de:ad:be:ef:00:07"],
osChain: "linux/arch"),
StoredHost(
id: editingID, name: "Editing Rig", address: "192.168.1.62", port: 9777,
pinnedSHA256: hostFingerprint(5), lastConnected: Date().addingTimeInterval(-604_800),
osChain: "linux/nobara"),
StoredHost(
id: bedroomID, name: "Bedroom Mini", address: "192.168.1.77", port: 9777,
pinnedSHA256: hostFingerprint(6), macAddresses: ["00:1a:2b:3c:4d:5e"],
osChain: "windows/11"),
]
return store
}
static let host = StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: fingerprint)
/// Discovery, seeded rather than live. Two saved hosts advertise (so their cards read ONLINE
/// through the real `advertises` path, and the reachability probe skips them no network from
/// a capture), "Workshop" stays quiet so the grid shows an asleep machine, and one genuinely
/// new host populates the "On this network" section.
///
/// A live browse made the shot non-deterministic AND leaked whatever was on the capturing
/// machine's LAN into the App Store listing.
static func discovery() -> HostDiscovery {
let discovery = HostDiscovery()
discovery.debugSet([
HostDiscovery.debugAdvert(
id: "battlestation", name: "Battlestation", host: "192.168.1.20",
fingerprintHex: fingerprint.hexLower, macAddresses: ["a4:b1:c2:d3:e4:f5"],
osChain: "windows/11"),
HostDiscovery.debugAdvert(
id: "living-room", name: "Living Room PC", host: "192.168.1.41",
fingerprintHex: hostFingerprint(1).hexLower, macAddresses: ["b8:27:eb:11:22:33"],
osChain: "linux/fedora/bazzite"),
HostDiscovery.debugAdvert(
id: "office-nuc", name: "Office NUC", host: "192.168.1.33",
fingerprintHex: hostFingerprint(4).hexLower, osChain: "linux/ubuntu"),
HostDiscovery.debugAdvert(
id: "studio", name: "Studio PC", host: "192.168.1.58",
fingerprintHex: hostFingerprint(3).hexLower, requiresPairing: true, allowsTofu: false,
osChain: "windows/11"),
])
return discovery
}
static let host = StoredHost(
id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: fingerprint, osChain: "windows/11")
/// What the pairing sheet calls THIS device. Taken from the platform, not from
/// `UIDevice.current.name` on a capture simulator that is the harness's own throwaway name
/// (`pf-shot-iphone-6.9` went out on the store listing that way).
static var clientDeviceName: String {
#if os(tvOS)
"Apple TV"
#elseif os(macOS)
"MacBook Pro"
#else
UIDevice.current.userInterfaceIdiom == .pad ? "iPad Pro" : "iPhone"
#endif
}
/// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs.
static let fingerprint = Data((0..<32).map { UInt8(($0 &* 37 &+ 0x1d) & 0xff) })
static let fingerprint = hostFingerprint(0)
/// Distinct per host `StoredHost.matches` prefers a fingerprint comparison, so sharing one
/// across the mock grid made a single advert light up every card.
static func hostFingerprint(_ seed: Int) -> Data {
Data((0..<32).map { UInt8((($0 &* 37) &+ 0x1d &+ (seed &* 91)) & 0xff) })
}
}
// MARK: - Home
@@ -106,7 +208,7 @@ enum ShotMock {
private struct ShotHome: View {
@StateObject private var store = ShotMock.hostStore()
@StateObject private var model = SessionModel()
@StateObject private var discovery = HostDiscovery()
@StateObject private var discovery = ShotMock.discovery()
var body: some View {
#if os(macOS)
@@ -134,7 +236,7 @@ private struct ShotHome: View {
private struct ShotGamepadHome: View {
@StateObject private var store = ShotMock.hostStore()
@StateObject private var model = SessionModel()
@StateObject private var discovery = HostDiscovery()
@StateObject private var discovery = ShotMock.discovery()
@StateObject private var waker = HostWaker()
var body: some View {
@@ -166,7 +268,7 @@ private struct ShotConnect: View {
@StateObject private var store = ShotMock.hostStore()
@StateObject private var model = SessionModel()
@StateObject private var discovery = HostDiscovery()
@StateObject private var discovery = ShotMock.discovery()
@StateObject private var waker = HostWaker()
var body: some View {
@@ -243,9 +345,9 @@ private struct ShotSettings: View {
#elseif os(iOS)
// SettingsView owns its NavigationSplitView (sidebar + detail) and Done button, so it is
// rendered directly a wrapping NavigationStack would nest a split view in a stack. Open
// on General so the shot lands on real controls (iPad: sidebar + General detail; iPhone:
// the General page) instead of the bare category list.
SettingsView(initialCategory: .general)
// on Display rather than the bare category list: resolution, frame rate, bitrate, HDR and
// codec are what someone reads a streaming app's settings shot to find out.
SettingsView(initialCategory: .display)
#else
NavigationStack { SettingsView() }
#endif
@@ -255,16 +357,44 @@ private struct ShotSettings: View {
// MARK: - Pair (PIN ceremony)
private struct ShotPair: View {
/// The PIN as the host's web console shows it, and a device name that doesn't depend on what
/// the capture simulator happens to be called.
private var sheet: some View {
PairSheet(
host: ShotMock.host, shotPIN: "418 306",
shotClientName: ShotMock.clientDeviceName, onPaired: { _ in })
}
var body: some View {
#if os(iOS)
// PRESENT it, don't rebuild it. `PairSheet` is a bottom sheet on iOS it carries its own
// `.presentationDetents([.medium, .large])` and the system's Liquid Glass background, both
// of which only exist inside a real `.sheet`. Composed into a ZStack instead (what this
// scene used to do), the detents were inert, the grouped Form stretched to the full height
// of the screen, and the capture was a thin strip of content over a huge black void.
ShotHome()
.sheet(isPresented: .constant(true)) {
// Pinned to one detent. The sheet ships `[.medium, .large]` so it can grow over
// the keyboard, and the resting height leaves a wide empty band between the form
// and the button row; a capture wants the snug version.
sheet.presentationDetents([.fraction(0.52)])
}
#elseif os(tvOS)
// tvOS pushes the ceremony as a full screen (HomeView's `navigationDestination`).
NavigationStack { sheet }
#else
// macOS: a fixed-width panel (`.frame(width: 400).fixedSize()`) that hugs its content, so
// floating it over the dimmed grid matches how the window-modal sheet reads. `screencapture
// -l<windowID>` grabs one window, and an AppKit sheet is a child window a real `.sheet`
// would fall outside the capture.
ZStack {
ShotHome().blur(radius: 28).overlay(Color.black.opacity(0.5))
PairSheet(host: ShotMock.host, onPaired: { _ in })
.frame(maxWidth: 460)
sheet
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
.clipShape(RoundedRectangle(cornerRadius: 18))
.shadow(radius: 40, y: 16)
.padding(40)
}
#endif
}
}
@@ -65,6 +65,14 @@ final class SessionModel: ObservableObject {
@Published private(set) var connection: PunktfunkConnection?
/// The host this session is for (a value copy; identity = id).
@Published private(set) var activeHost: StoredHost?
/// The library entry this session was launched with (`connect(launchID:)`), or nil if the user
/// just connected to the host's desktop. Kept because where the client should go when the
/// session ends depends on where it came FROM: a title launched out of the library belongs back
/// in that library when its game exits, not on the host-selection screen.
private var launchedTitleID: String?
/// Set when a session ended because its game exited and it began as a library launch: the host
/// whose library to reopen. The view layer consumes it and sets it back to nil.
@Published var returnToLibrary: StoredHost?
/// The settings THIS session runs on the globals with its profile overlaid, resolved once at
/// connect (design/client-settings-profiles.md §4.2). Also mirrored into `SessionSettings` for
/// the readers that live in PunktfunkKit and can't see this model.
@@ -249,6 +257,7 @@ final class SessionModel: ObservableObject {
guard phase == .idle else { return }
phase = .connecting
activeHost = host
launchedTitleID = launchID
errorMessage = nil
settings = effective
statsVerbosity = StatsVerbosity(rawValue: effective.statsVerbosity) ?? .normal
@@ -607,6 +616,8 @@ final class SessionModel: ObservableObject {
}
connection = nil
activeHost = nil
// Read by `sessionEnded` BEFORE it calls us, so clearing here can't rob it of the answer.
launchedTitleID = nil
phase = .idle
fps = 0
mbps = 0
@@ -626,10 +637,36 @@ final class SessionModel: ObservableObject {
/// Called (via the main actor) when the pump hits end-of-session.
func sessionEnded() {
guard connection != nil else { return }
guard let conn = connection else { return }
let name = activeHost?.displayName ?? "host"
// WHY it ended, asked while the connection is still up `disconnect` tears it down.
let reason = conn.sessionEndReason
// Where a game exit sends us: back into the library this title was launched from, so the
// next one is a tap away. Only for a launch that CAME from the library a game exiting in
// a plain desktop session has no library to return to.
let host = activeHost
let cameFromLibrary = launchedTitleID != nil
disconnect(deliberate: false) // host/network ended it keep the linger for a reconnect
errorMessage = "Session ended by \(name)."
switch reason {
case .gameExited:
// The player quit their own game. Not a failure, and they are probably after the next
// title so no banner, and back to the library it came from.
if cameFromLibrary, let host {
returnToLibrary = host
}
case .hostEnded, .local:
// Someone asked for this: an operator "End" on the host, or our own close racing in.
// Say it plainly, without the error framing.
errorMessage = "\(name) ended the session."
case .hostError:
errorMessage = "\(name) ended the session with an error."
case .lost:
errorMessage = "Lost the connection to \(name)."
case .none:
// No verdict (an older core, or the close raced the read): keep the wording this path
// has always used rather than inventing one.
errorMessage = "Session ended by \(name)."
}
}
/// Resize overlay START (main actor from the Match-window follower's `onResizeTarget`): the
@@ -675,8 +712,13 @@ final class SessionModel: ObservableObject {
// `gamepadForwarding` off means the host gets this device's pads from somewhere else
// (USB passthrough, or a pad plugged into the host) capture still runs, and still
// watches for the escape chord, but puts nothing on the wire.
// System-button routing: whether raw guide/share presses ride the wire, and whether
// hold-Select arms as the alternate guide route (auto = on everywhere but macOS
// iOS reserves the physical Home press, tvOS never delivers it).
let capture = GamepadCapture(
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding)
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding,
systemForward: settings.systemButtonsForward,
guideGesture: settings.guideGestureEnabled)
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) on tvOS the only
// controller way out of a stream (B/Menu is swallowed during sessions; see ContentView).
capture.onDisconnectRequest = { [weak self] in self?.disconnect() }
@@ -58,8 +58,8 @@ struct AcknowledgementsView: View {
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
Text(
"Punktfunk uses the open-source components below, each under its own license. "
+ "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ "
+ "(dynamically linked, replaceable)."
+ "Video decoding uses the system's own VideoToolbox framework, so nothing "
+ "is bundled for it — and no Punktfunk client bundles FFmpeg on any platform."
)
.font(.geist(Self.captionFont, relativeTo: .caption))
.foregroundStyle(.secondary)
@@ -11,7 +11,13 @@
// the thumb it's the last option); A always cycles forward, wrapping, so every option is reachable
// with one button. Toggles read left = off, right = on refusing a no-op with the same thud.
//
// The trailing Profiles section (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager
// The rows are split across SECTION TABS (`GpSettingsTab`) L1/R1 on a pad, a tap elsewhere. They
// used to be one long scroll with inline group headers, which meant thumbing past Video and Audio
// to reach the controller settings; a tab is one shoulder press, and each tab remembers where its
// focus was. The tab names match the desktop console's and the Android client's, so a setting is
// found under the same word wherever you look for it.
//
// The trailing Profiles tab (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager
// for this controller-first surface: a row per catalog profile opens the pin-to-hosts picker an
// in-place swap of the row list (B peels back, the "one layer" rule GamepadAddHostView set) with
// one toggle row per saved host, writing `StoredHost.pinnedProfileIDs` via HostStore.setPinned.
@@ -27,7 +33,19 @@ import GameController
import CoreHaptics
#endif
/// The settings screen's sections. Order IS the strip order and the L1/R1 cycle order; the names
/// match `pf-console-ui`'s `TABS` and the Android client's `GpTab`.
enum GpSettingsTab: String, CaseIterable, Hashable {
case stream = "Stream"
case video = "Video"
case audio = "Audio"
case controller = "Controller"
case interface = "Interface"
case profiles = "Profiles"
}
struct GamepadSettingsView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.dismiss) private var dismiss
/// The saved-host store the pin picker writes `setPinned` through it and the profile rows
/// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen
@@ -39,6 +57,8 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.compositor) private var compositor = 0
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
@AppStorage(DefaultsKey.gamepadForwarding) private var gamepadForwarding = true
@AppStorage(DefaultsKey.systemButtons) private var systemButtons = "auto"
@AppStorage(DefaultsKey.guideGesture) private var guideGesture = "auto"
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
@@ -53,6 +73,9 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
/// The gamepad UI's background colour family the backdrop BEHIND this screen re-colours as
/// the row steps, which is why the picker lives here and not in a sheet.
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
@AppStorage(DefaultsKey.presentPriority) private var presentPriority =
SettingsOptions.presentPriorityDefault
@@ -72,12 +95,23 @@ struct GamepadSettingsView: View {
#if os(iOS)
/// `.compact` in a landscape phone window tighter chrome so more rows fit.
@Environment(\.verticalSizeClass) private var vSizeClass
/// `.regular` only on an iPad-class window see `showsSectionHint`.
@Environment(\.horizontalSizeClass) private var hSizeClass
private var compact: Bool { vSizeClass == .compact }
#else
private let compact = false // no size classes on macOS; the sheet is sized generously
#endif
@State private var focusID: String?
/// The section showing. The pin picker ignores it that layer replaces the whole list.
@State private var tab: GpSettingsTab = .stream
/// Where each tab's focus was when it was last left, so a detour doesn't lose your place.
@State private var tabFocus: [GpSettingsTab: String] = [:]
@Namespace private var tabHighlight
#if os(tvOS)
/// Real focus on the strip the tvOS route to the sections (see `tabStrip`).
@FocusState private var focusedTab: GpSettingsTab?
#endif
/// The pin-to-hosts picker's profile non-nil swaps the row list for one toggle row per
/// saved host (§5.2a); B (Menu on tvOS) peels back to the settings rows.
@State private var pinTarget: StreamProfile?
@@ -91,7 +125,8 @@ struct GamepadSettingsView: View {
focusID: $focusID,
onAdjust: { row, delta in adjust(id: row.id, by: delta) },
onActivate: { activate(id: $0.id) },
onBack: { back() }
onBack: { back() },
onShoulder: { step(tabBy: $0) }
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
@@ -99,20 +134,25 @@ struct GamepadSettingsView: View {
}
.frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) {
Text(title)
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white)
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, compact ? 4 : 8)
.frame(maxWidth: .infinity)
.overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) }
.background { GamepadTrayScrim(edge: .top) }
VStack(spacing: compact ? 4 : 8) {
Text(title)
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.frame(maxWidth: .infinity)
.overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) }
// The picker is one layer deeper its rows aren't sections of anything, so the
// strip would be a control that does nothing while it's up.
if pinTarget == nil { tabStrip }
}
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, compact ? 4 : 8)
.background { GamepadTrayScrim(edge: .top) }
}
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
Text(focusedDetail)
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.55))
.foregroundStyle(ink.fg(0.55))
.lineLimit(2, reservesSpace: true)
.animation(.smooth(duration: 0.2), value: focusID)
GamepadHintBar(hints: hints)
@@ -125,9 +165,13 @@ struct GamepadSettingsView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.background { GamepadTrayScrim(edge: .bottom) }
}
// No aurora here the settings read as clean Liquid Glass over a quiet dark base, so the
// glass rows are the only material on the screen.
// The launcher's living field, calmed (GamepadFormBackground) the glass rows keep real
// colour and luminance to lens without the launcher's contrast, and the palette setting
// applies here too, so this screen previews the row you're stepping.
.background { GamepadFormBackground() }
// Publish the palette's ink to this screen (text, glass, accent, scrims) a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
.onAppear {
gamepads.refresh()
gamepads.startDiscovery()
@@ -135,13 +179,108 @@ struct GamepadSettingsView: View {
.onDisappear { gamepads.stopDiscovery() }
}
/// The section switcher. Horizontally scrollable so a narrow phone in landscape never has to
/// squeeze six pills the selected one is always scrolled into view, whether it was reached
/// by shoulder button, tap, or (tvOS) the focus engine.
private var tabStrip: some View {
ScrollViewReader { proxy in
ScrollView(.horizontal) {
HStack(spacing: 6) {
ForEach(GpSettingsTab.allCases, id: \.self) { t in
#if os(tvOS)
// Focusable, because L1/R1 is NOT a route here: a Siri Remote has no
// extended gamepad profile, so it never reaches GamepadMenuList's poll.
// As focusable Buttons the pills are simply above the rows, and moving
// focus up onto one switches section the standard tvOS tab bar.
Button { select(tab: t) } label: { pill(t) }
.buttonStyle(ConsoleBareButtonStyle())
.focused($focusedTab, equals: t)
.id(t)
#else
pill(t)
.contentShape(Capsule())
.onTapGesture { select(tab: t) }
.id(t)
#endif
}
}
.padding(.horizontal, 24)
}
.scrollIndicators(.never)
.animation(.smooth(duration: 0.22), value: tab)
.onChange(of: tab) { _, t in
withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(t) }
}
#if os(tvOS)
.onChange(of: focusedTab) { _, t in
// Focus IS selection on a tab bar; nil means focus dropped back into the rows.
if let t { select(tab: t) }
}
#endif
}
}
private func pill(_ t: GpSettingsTab) -> some View {
let selected = t == tab
return Text(t.rawValue)
.font(.geist(compact ? 12 : 13, .semibold, relativeTo: .footnote))
.foregroundStyle(selected ? ink.fg : ink.fg(0.55))
.padding(.horizontal, 13)
.padding(.vertical, 7)
.background {
// One shared capsule that MOVES between pills, rather than one per pill fading
// in and out the highlight travels the way the press did.
if selected {
Capsule()
.fill(ink.accent(0.85))
.matchedGeometryEffect(id: "tab", in: tabHighlight)
}
}
}
/// Whether the legend advertises the shoulder shortcut. Held back on an iPhone, whose legend
/// is already at its width and would push "Done" off the edge the strip is visible and
/// tappable there anyway. Never on tvOS: a Siri Remote has no shoulders, and its route to the
/// sections is the focus engine (see `tabStrip`).
private var showsSectionHint: Bool {
#if os(tvOS)
false
#elseif os(iOS)
hSizeClass == .regular
#else
true
#endif
}
/// L1/R1 one section along, wrapping (the strip is a ring, like A's value cycle).
private func step(tabBy delta: Int) {
guard pinTarget == nil else { return }
let all = GpSettingsTab.allCases
guard let i = all.firstIndex(of: tab) else { return }
let n = all.count
select(tab: all[((i + delta) % n + n) % n])
}
private func select(tab next: GpSettingsTab) {
guard next != tab else { return }
tabFocus[tab] = focusID
// Restore where this tab was, if that row is still in it (a row can come and go with the
// hardware it depends on); otherwise the focus list seeds its first row. Resolved against
// `allRows` rather than `rows` so it doesn't depend on `tab`'s write being visible yet.
let landing = tabFocus[next].flatMap { id in
allRows.contains { $0.tab == next && $0.id == id } ? id : nil
}
tab = next
focusID = landing
}
/// Touch/click fallback for closing the controller path is B, a hardware keyboard's Esc
/// rides the cancel action.
private var closeButton: some View {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
.foregroundStyle(.white)
.foregroundStyle(ink.fg)
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
.glassBackground(Circle(), interactive: true)
.contentShape(Circle())
@@ -164,7 +303,19 @@ struct GamepadSettingsView: View {
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
private var hints: [GamepadHint] {
guard pinTarget != nil else {
return [
// The shoulders change section, so that cell leads where it fits and where the
// shoulders exist at all (see `showsSectionHint`).
let sections: [GamepadHint] = showsSectionHint
? [.init(glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"),
text: "Section")]
: []
// A dimmed row takes neither, so offering them would be the same lie the row itself
// used to tell only Done remains, and the detail line says what to turn on first.
guard rows.first(where: { $0.id == focusID })?.enabled ?? true else {
return sections
+ [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")]
}
return sections + [
.init(glyph: "arrow.left.and.right", text: "Adjust"),
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
@@ -194,29 +345,24 @@ struct GamepadSettingsView: View {
private func rowView(_ row: Row, focused: Bool) -> some View {
let m = GamepadFormMetrics.self
// No section header: the tab strip names the section now, and repeating it above the
// first row of every tab was just a second label saying the same word.
return VStack(alignment: .leading, spacing: 6) {
if let header = row.header {
Text(header)
.font(.geist(m.headerFont, .semibold, relativeTo: .caption))
.tracking(1.4)
.foregroundStyle(.white.opacity(0.45))
.padding(.leading, m.rowHPad)
.padding(.top, 14)
}
HStack(spacing: 14) {
Image(systemName: row.icon)
.font(.system(size: m.iconFont))
.foregroundStyle(focused ? Color.brand : .white.opacity(0.55))
.foregroundStyle(focused ? ink.accent : ink.fg(0.55))
.frame(width: m.iconWidth)
Text(row.label)
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(.white)
.foregroundStyle(ink.fg)
.lineLimit(1)
Spacer(minLength: 12)
HStack(spacing: 9) {
Image(systemName: "chevron.left")
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
.foregroundStyle(
ink.fg(focused && row.adjustable && row.enabled ? 0.6 : 0))
// Keyed by the value so a change slides the new option in instead of
// hard-swapping the string a QUIET horizontal slip following the user's
// motion (a right-step enters from the right), crossfading over ~14 pt.
@@ -227,7 +373,7 @@ struct GamepadSettingsView: View {
ZStack {
Text(row.value)
.font(.geist(m.valueFont, .medium, relativeTo: .callout))
.foregroundStyle(focused ? .white : .white.opacity(0.6))
.foregroundStyle(focused ? ink.fg : ink.fg(0.6))
.lineLimit(1)
.id(row.value)
.transition(.asymmetric(
@@ -237,19 +383,23 @@ struct GamepadSettingsView: View {
.animation(.smooth(duration: 0.22), value: row.value)
Image(systemName: "chevron.right")
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
.foregroundStyle(
ink.fg(focused && row.adjustable && row.enabled ? 0.6 : 0))
}
}
// Contents only the glass and border below stay at full strength, so a dimmed row
// still reads as a row you can sit on (which you can: its detail is the point).
.opacity(row.enabled ? 1 : 0.45)
.padding(.horizontal, m.rowHPad)
.padding(.vertical, m.rowVPad)
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
.consoleGlass(
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: focused ? Color.brand.opacity(0.30) : nil,
tint: focused ? ink.accent(0.30) : nil,
interactive: focused)
.overlay {
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1)
.strokeBorder(ink.fg(focused ? 0.28 : 0.06), lineWidth: 1)
}
.scaleEffect(focused ? 1.0 : 0.98)
.animation(.smooth(duration: 0.18), value: focused)
@@ -264,8 +414,9 @@ struct GamepadSettingsView: View {
private struct Row: Identifiable {
let id: String
/// Section header drawn above this row (the first row of each group carries it).
var header: String?
/// Which section tab this row belongs to. Every row has exactly one, and `rows` shows
/// only the current tab's see `allRows`.
var tab: GpSettingsTab = .stream
let icon: String
let label: String
let value: String
@@ -274,6 +425,13 @@ struct GamepadSettingsView: View {
/// Whether left/right means anything here false hides the value's chevrons (the
/// Profiles rows navigate, and the placeholder rows do nothing at all).
var adjustable = true
/// Dimmed and inert when false: a row whose meaning depends on another setting that is
/// currently off. It stays in the list and stays FOCUSABLE its `detail` is how the
/// user learns which switch to flip first, and a row that vanished mid-list would
/// shift everything under the cursor. Enforced centrally in `adjust(id:by:)` /
/// `activate(id:)`, not per closure, so no row builder can forget it.
/// (Android's `GpRow.enabled` and `pf-console-ui`'s `RowSpec.enabled` are the twins.)
var enabled = true
/// Left/right step; returns whether the value actually changed (false boundary thud).
let adjust: (Int) -> Bool
/// A cycle forward (wrapping) / flip.
@@ -284,18 +442,27 @@ struct GamepadSettingsView: View {
/// (never on state captured at wire time).
private func adjust(id: String, by delta: Int) -> Bool {
lastAdjustDelta = delta
return rows.first { $0.id == id }?.adjust(delta) ?? false
guard let row = rows.first(where: { $0.id == id }), row.enabled else { return false }
return row.adjust(delta)
}
private func activate(id: String) {
lastAdjustDelta = 1 // A always cycles forward
rows.first { $0.id == id }?.activate()
guard let row = rows.first(where: { $0.id == id }), row.enabled else { return }
row.activate()
}
/// What the focus list actually shows: the current tab's rows or the pin picker's, which
/// replaces the whole list while it's up (same screen, one layer deeper, so the focus list's
/// controller wiring and the tvOS focus engine carry over as is).
private var rows: [Row] {
// The pin picker replaces the whole list while it's up same screen, one layer deeper,
// so the focus list's controller wiring (and the tvOS focus engine) carries over as is.
if let profile = pinTarget { return pinRows(for: profile) }
return allRows.filter { $0.tab == tab }
}
/// Every row on the screen, tagged with its section. Built as one list (not per tab) so the
/// platform-conditional insertions below can still place a row RELATIVE to another by id.
private var allRows: [Row] {
let resolution = resolutionOptions
let refresh = SettingsOptions.refreshRates(including: hz)
.map { (label: "\($0) Hz", tag: $0) }
@@ -303,7 +470,7 @@ struct GamepadSettingsView: View {
let controllers = SettingsOptions.controllerOptions(gamepads)
var list: [Row] = [
choiceRow(
id: "resolution", header: "Stream", icon: "aspectratio",
id: "resolution", tab: .stream, icon: "aspectratio",
label: "Resolution",
detail: "The host creates a virtual display at exactly this size — no scaling.",
options: resolution, current: "\(width)x\(height)"
@@ -314,53 +481,48 @@ struct GamepadSettingsView: View {
height = parts[1]
},
choiceRow(
id: "refresh", icon: "gauge.with.needle", label: "Refresh rate",
id: "refresh", tab: .stream, icon: "gauge.with.needle", label: "Refresh rate",
detail: "Rates this display can actually show.",
options: refresh, current: hz
) { hz = $0 },
choiceRow(
id: "bitrate", icon: "speedometer", label: "Bitrate",
id: "bitrate", tab: .stream, icon: "speedometer", label: "Bitrate",
detail: "Automatic uses the host's default (20 Mbps). "
+ "Run a speed test from the touch UI for an informed value.",
options: bitrate, current: bitrateKbps
) { bitrateKbps = $0 },
choiceRow(
id: "compositor", icon: "macwindow", label: "Compositor",
id: "compositor", tab: .stream, icon: "macwindow", label: "Compositor",
detail: "Which compositor drives the virtual output — honored only if "
+ "available on the host.",
options: SettingsOptions.compositors, current: compositor
) { compositor = $0 },
toggleRow(
id: "autoWake", icon: "power", label: "Auto-wake on connect",
detail: "Send Wake-on-LAN to a sleeping saved host and wait for it before "
+ "streaming. Off connects straight through.",
value: $autoWakeEnabled),
choiceRow(
id: "codec", header: "Video", icon: "film", label: "Video codec",
id: "codec", tab: .video, icon: "film", label: "Video codec",
detail: "A preference — the host falls back if it can't encode this one "
+ "(10-bit and 4:4:4 are HEVC-only).",
options: SettingsOptions.codecs, current: codec
) { codec = $0 },
toggleRow(
id: "hdr", icon: "sun.max", label: "10-bit HDR",
id: "hdr", tab: .video, icon: "sun.max", label: "10-bit HDR",
detail: "HDR10 — engages when the host sends HDR content and this display "
+ "supports it.",
value: $hdrEnabled),
toggleRow(
id: "chroma", icon: "textformat", label: "Full chroma (4:4:4)",
id: "chroma", tab: .video, icon: "textformat", label: "Full chroma (4:4:4)",
detail: "Sharper text and UI at more bandwidth — needs host opt-in and "
+ "hardware decode.",
value: $enable444),
choiceRow(
id: "presentPriority", icon: "rectangle.stack", label: "Prioritize",
id: "presentPriority", tab: .video, icon: "rectangle.stack", label: "Prioritize",
detail: "Lowest latency shows each frame the moment the display can take it; "
+ "Smoothness buffers a few frames to even out network hiccups. Applies "
+ "from the next session.",
options: SettingsOptions.presentPriorities, current: presentPriority
) { presentPriority = $0 },
choiceRow(
id: "smoothBuffer", icon: "square.stack.3d.up", label: "Smoothness buffer",
id: "smoothBuffer", tab: .video, icon: "square.stack.3d.up",
label: "Smoothness buffer",
detail: "How many frames Smoothness holds — each adds about a refresh of "
+ "display latency and absorbs about a refresh of jitter. Only applies "
+ "when prioritizing smoothness.",
@@ -368,66 +530,102 @@ struct GamepadSettingsView: View {
) { smoothBuffer = $0 },
choiceRow(
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
id: "audio", tab: .audio, icon: "speaker.wave.2", label: "Audio channels",
detail: "The speaker layout requested from the host.",
options: SettingsOptions.audioChannels, current: audioChannels
) { audioChannels = $0 },
toggleRow(
id: "mic", icon: "mic", label: "Microphone",
id: "mic", tab: .audio, icon: "mic", label: "Microphone",
detail: "Send this device's microphone to the host's virtual mic.",
value: $micEnabled),
toggleRow(
id: "echoCancel", icon: "waveform", label: "Echo cancellation",
id: "echoCancel", tab: .audio, icon: "waveform", label: "Echo cancellation",
detail: "Cancel the audio this device plays out of the mic signal — stops "
+ "speaker setups feeding the game back to the host.",
value: $echoCancel),
toggleRow(
id: "padForward", header: "Controller", icon: "gamecontroller",
id: "padForward", tab: .controller, icon: "gamecontroller",
label: "Forward controllers",
detail: "Send this device's controllers to the host. Turn it off when your "
+ "controller already reaches the host another way — USB passthrough such "
+ "as VirtualHere — so games don't see two of them.",
value: $gamepadForwarding),
// The four rows below only mean something while something is being forwarded, so
// they follow the switch above the same relationship the touch settings draw with
// `.disabled(!effective.gamepadForwarding)`. This screen could not express it until
// `Row.enabled` existed, so it alone left them live and steppable.
choiceRow(
id: "pad", icon: "gamecontroller", label: "Use controller",
id: "pad", tab: .controller, icon: "gamecontroller", label: "Use controller",
detail: "Which pad is forwarded to the host, as player 1.",
options: controllers, current: gamepads.preferredID
options: controllers, current: gamepads.preferredID,
enabled: gamepadForwarding
) { gamepads.preferredID = $0 },
choiceRow(
id: "padType", icon: "dpad", label: "Controller type",
id: "padType", tab: .controller, icon: "dpad", label: "Controller type",
detail: "The virtual pad the host creates — Automatic matches this controller.",
options: SettingsOptions.padTypes, current: gamepadType
options: SettingsOptions.padTypes, current: gamepadType,
enabled: gamepadForwarding
) { gamepadType = $0 },
choiceRow(
id: "systemButtons", tab: .controller, icon: "house.circle",
label: "Guide button",
detail: "Where the guide (Xbox/PS) and share presses go while streaming — "
+ "Automatic sends them to the host whenever this device delivers them.",
options: SettingsOptions.systemButtons, current: systemButtons,
enabled: gamepadForwarding
) { systemButtons = $0 },
choiceRow(
id: "guideGesture", tab: .controller, icon: "hand.point.up.left",
label: "Hold Select for guide",
detail: "Hold Select alone to press the host's guide button — keep holding "
+ "for a Gaming-Mode host's quick-access menu. A tap still goes through.",
options: SettingsOptions.guideGestures, current: guideGesture,
enabled: gamepadForwarding
) { guideGesture = $0 },
choiceRow(
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
id: "palette", tab: .interface, icon: "paintpalette", label: "Background",
detail: "The colour family this backdrop drifts through — it changes as you "
+ "step, so pick by looking. Appearance only.",
options: GamepadPalette.all.map { (label: $0.name, tag: $0.id) },
current: GamepadPalette.named(paletteID).id
) { paletteID = $0 },
toggleRow(
id: "autoWake", tab: .interface, icon: "power", label: "Auto-wake on connect",
detail: "Send Wake-on-LAN to a sleeping saved host and wait for it before "
+ "streaming. Off connects straight through.",
value: $autoWakeEnabled),
choiceRow(
id: "hud", tab: .interface, icon: "chart.bar", label: "Statistics overlay",
detail: "How much to show while streaming — Compact is a one-line pill, "
+ "Detailed adds the latency stage breakdown.",
options: SettingsOptions.statsVerbosities, current: statsVerbosityRaw
) { statsVerbosityRaw = $0 },
choiceRow(
id: "hudPlacement", icon: "rectangle.inset.topright.filled", label: "Overlay position",
id: "hudPlacement", tab: .interface, icon: "rectangle.inset.topright.filled",
label: "Overlay position",
detail: "Which corner the statistics overlay sits in.",
options: SettingsOptions.hudPlacements, current: hudPlacement
) { hudPlacement = $0 },
toggleRow(
id: "library", icon: "square.grid.2x2", label: "Game library",
id: "library", tab: .interface, icon: "square.grid.2x2", label: "Game library",
detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")).",
value: $libraryEnabled),
toggleRow(
id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI",
id: "gamepadUI", tab: .interface, icon: "hand.tap",
label: "Controller-optimized UI",
detail: "Turn off to use the touch interface even with a controller connected.",
value: $gamepadUIEnabled),
]
#if os(macOS)
// The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside
// the Video group) macOS only, mirroring the touch SettingsView's Presentation row
// the Video tab) macOS only, mirroring the touch SettingsView's Presentation row
// (the DCP swapID-panic mitigation; see DefaultsKey.windowedSafePresent).
if let at = list.firstIndex(where: { $0.id == "smoothBuffer" }) {
list.insert(
toggleRow(
id: "windowedSafePresent", icon: "macwindow.badge.plus",
id: "windowedSafePresent", tab: .video, icon: "macwindow.badge.plus",
label: "Safe windowed presentation",
detail: "Windowed streams present in step with the compositor — avoids a "
+ "macOS display-driver crash on high-refresh displays, at a small "
@@ -437,14 +635,14 @@ struct GamepadSettingsView: View {
}
#endif
#if os(iOS)
// The device-rumble mirror slots in after "Controller type" (staying inside the
// Controller group the next row carries the "Interface" header). iPhone only in
// practice: hidden where the device itself can't play haptics (iPad).
// The device-rumble mirror slots in after "Controller type", inside the Controller tab.
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
if CHHapticEngine.capabilitiesForHardware().supportsHaptics,
let at = list.firstIndex(where: { $0.id == "padType" }) {
list.insert(
toggleRow(
id: "deviceRumble", icon: "iphone.radiowaves.left.and.right",
id: "deviceRumble", tab: .controller,
icon: "iphone.radiowaves.left.and.right",
label: "Rumble on this iPhone",
detail: "Also play player 1's rumble on the phone's own Taptic Engine — "
+ "for clip-on pads without rumble motors.",
@@ -464,17 +662,17 @@ struct GamepadSettingsView: View {
private var profileRows: [Row] {
guard !profiles.profiles.isEmpty else {
return [Row(
id: "noProfiles", header: "Profiles", icon: "slider.horizontal.3",
id: "noProfiles", tab: .profiles, icon: "slider.horizontal.3",
label: "No profiles yet", value: "",
detail: emptyCatalogDetail,
adjustable: false,
adjust: { _ in false }, activate: {})]
}
return profiles.profiles.enumerated().map { i, profile in
return profiles.profiles.map { profile in
let pins = store.hosts
.filter { ($0.pinnedProfileIDs ?? []).contains(profile.id) }.count
return Row(
id: "profile-\(profile.id)", header: i == 0 ? "Profiles" : nil,
id: "profile-\(profile.id)", tab: .profiles,
icon: "slider.horizontal.3", label: profile.name,
value: pins == 0 ? "Not pinned" : "Pinned to \(pins) host\(pins == 1 ? "" : "s")",
detail: profileDetail,
@@ -496,7 +694,8 @@ struct GamepadSettingsView: View {
private func pinRows(for profile: StreamProfile) -> [Row] {
guard !store.hosts.isEmpty else {
return [Row(
id: "noHosts", icon: "desktopcomputer", label: "No saved hosts yet",
id: "noHosts", tab: .profiles, icon: "desktopcomputer",
label: "No saved hosts yet",
value: "",
detail: "Pair with a host first, then pin this profile to it.",
adjustable: false,
@@ -506,7 +705,7 @@ struct GamepadSettingsView: View {
let hostID = host.id
let pinned = (host.pinnedProfileIDs ?? []).contains(profile.id)
return Row(
id: "pinHost-\(hostID.uuidString)", icon: "desktopcomputer",
id: "pinHost-\(hostID.uuidString)", tab: .profiles, icon: "desktopcomputer",
label: host.displayName,
value: pinned ? "Pinned" : "Off",
detail: "A pinned profile appears as its own card on the host — one press "
@@ -568,14 +767,16 @@ struct GamepadSettingsView: View {
// MARK: - Row builders
private func choiceRow<T: Equatable>(
id: String, header: String? = nil, icon: String, label: String, detail: String,
options: [(label: String, tag: T)], current: T, write: @escaping (T) -> Void
id: String, tab: GpSettingsTab, icon: String, label: String, detail: String,
options: [(label: String, tag: T)], current: T, enabled: Bool = true,
write: @escaping (T) -> Void
) -> Row {
let index = options.firstIndex { $0.tag == current }
return Row(
id: id, header: header, icon: icon, label: label,
id: id, tab: tab, icon: icon, label: label,
value: index.map { options[$0].label } ?? "",
detail: detail,
enabled: enabled,
adjust: { delta in
// Unknown current value: snap to the first option on any step.
guard let index else {
@@ -595,13 +796,14 @@ struct GamepadSettingsView: View {
}
private func toggleRow(
id: String, header: String? = nil, icon: String, label: String, detail: String,
value: Binding<Bool>
id: String, tab: GpSettingsTab, icon: String, label: String, detail: String,
value: Binding<Bool>, enabled: Bool = true
) -> Row {
Row(
id: id, header: header, icon: icon, label: label,
id: id, tab: tab, icon: icon, label: label,
value: value.wrappedValue ? "On" : "Off",
detail: detail,
enabled: enabled,
adjust: { delta in
// Directional semantics: left = off, right = on; a no-op reads as a boundary.
let target = delta > 0
@@ -34,6 +34,22 @@ enum SettingsOptions {
("DualShock 4", 4),
]
/// System-button routing (the cross-client `system_buttons` key): where the guide
/// (Xbox/PS) and share presses land while streaming. Auto = forward on Apple.
static let systemButtons: [(label: String, tag: String)] = [
("Automatic", "auto"),
("Send to host", "forward"),
("This device", "local"),
]
/// The hold-Select guide gesture (the cross-client `guide_gesture` key). Auto = on
/// everywhere but macOS.
static let guideGestures: [(label: String, tag: String)] = [
("Automatic", "auto"),
("On", "on"),
("Off", "off"),
]
static let hudPlacements: [(label: String, tag: String)] =
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
@@ -155,14 +171,26 @@ enum SettingsOptions {
/// This device's native mode first, then the presets, deduped by dimensions (native wins a
/// tie).
///
/// On iOS the native row is followed by its **safe-area** variant, which is the same mode
/// narrowed so the picture clears the sensor housing and the rounded corners see
/// [`SafeDisplay`] for why a narrower mode is the whole fix. It is emitted unconditionally and
/// left to the dedup below: on a device with no housing the two modes are identical, the
/// duplicate is dropped, and no pointless row appears.
@MainActor
static func resolutionModes() -> [(name: String, w: Int, h: Int)] {
var native: [(name: String, w: Int, h: Int)] = []
#if os(iOS) || os(tvOS)
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels (tvOS: the TV mode)
native = [("This device",
Int(max(bounds.width, bounds.height)),
Int(min(bounds.width, bounds.height)))]
let nativeW = Int(max(bounds.width, bounds.height))
let nativeH = Int(min(bounds.width, bounds.height))
native = [("This device", nativeW, nativeH)]
#if os(iOS)
let safe = SafeDisplay.mode(
nativeWidth: nativeW, nativeHeight: nativeH,
sideInsetPoints: mainWindowSideInset(), scale: UIScreen.main.nativeScale)
native.append(("This device (safe area)", safe.width, safe.height))
#endif
#else
if let screen = NSScreen.main {
let scale = screen.backingScaleFactor
@@ -175,6 +203,26 @@ enum SettingsOptions {
return (native + resolutionPresets).filter { seen.insert("\($0.w)x\($0.h)").inserted }
}
#if os(iOS)
/// The key window's per-side safe-area inset in points, resolved for the LANDSCAPE stream even
/// when this settings screen is currently portrait (see `SafeDisplay.sideInsetPoints`).
///
/// Zero when no window is up yet the safe mode then equals the native one and `resolutionModes`
/// dedups the row away, which is the right answer for a device we can't measure.
@MainActor
private static func mainWindowSideInset() -> Double {
let insets = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
.first { $0.isKeyWindow }?
.safeAreaInsets
guard let insets else { return 0 }
return SafeDisplay.sideInsetPoints(
left: Double(insets.left), right: Double(insets.right), top: Double(insets.top),
isPhone: UIDevice.current.userInterfaceIdiom == .phone)
}
#endif
/// Refresh rates the device can actually display (no point asking the host to render frames
/// the screen can't show), plus any stored custom value so it stays selectable.
@MainActor
@@ -126,6 +126,14 @@ enum SettingsFields {
.init(name: "gamepad_forwarding", key: DefaultsKey.gamepadForwarding,
overlay: \.gamepadForwarding, effective: \.gamepadForwarding)
}
static var systemButtons: SettingsField<String> {
.init(name: "system_buttons", key: DefaultsKey.systemButtons,
overlay: \.systemButtons, effective: \.systemButtons)
}
static var guideGesture: SettingsField<String> {
.init(name: "guide_gesture", key: DefaultsKey.guideGesture,
overlay: \.guideGesture, effective: \.guideGesture)
}
static var statsVerbosity: SettingsField<String> {
.init(name: "stats_verbosity", key: DefaultsKey.statsVerbosity,
overlay: \.statsVerbosity, effective: \.statsVerbosity)
@@ -681,6 +681,29 @@ extension SettingsView {
}
.disabled(!effective.gamepadForwarding)
}
described("Where the guide (Xbox/PS) and share presses go while streaming. "
+ "Automatic sends them to the host whenever this device delivers them "
+ "— the hold-Select gesture below reaches the host regardless.",
field: "system_buttons") {
Picker("Guide button", selection: scoped(SettingsFields.systemButtons)) {
Text("Automatic").tag("auto")
Text("Send to host").tag("forward")
Text("This device").tag("local")
}
.disabled(!effective.gamepadForwarding)
}
described("Hold Select on its own to press the host's guide button — keep "
+ "holding for a Gaming-Mode host's quick-access menu. A Select tap still "
+ "goes through, slightly delayed. Automatic arms it wherever the real "
+ "button can't reach the host (this device reserves it).",
field: "guide_gesture") {
Picker("Hold Select for guide", selection: scoped(SettingsFields.guideGesture)) {
Text("Automatic").tag("auto")
Text("On").tag("on")
Text("Off").tag("off")
}
.disabled(!effective.gamepadForwarding)
}
#if os(iOS)
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
if !inProfileScope, CHHapticEngine.capabilitiesForHardware().supportsHaptics {
@@ -191,6 +191,12 @@ final class HostStore: ObservableObject {
private func persist() {
#if DEBUG
// The screenshot harness fills a store with mock hosts (ShotMock) purely to render a
// scene. On a dev Mac that store is the SAME App-Group suite the real app reads, so
// persisting would replace the tester's saved hosts with "Battlestation" & co.
if ScreenshotMode.isActive { return }
#endif
if let data = try? JSONEncoder().encode(hosts) {
defaults.set(data, forKey: Self.key)
}
@@ -20,7 +20,14 @@ final class ProfileStore: ObservableObject {
static let shared = ProfileStore()
@Published private(set) var catalog: ProfileCatalog {
didSet { catalog.save() }
didSet {
#if DEBUG
// Shot mode seeds this SINGLETON with mock profiles to populate the host cards.
// Saving would write them into the tester's real catalog see HostStore.persist().
if ScreenshotMode.isActive { return }
#endif
catalog.save()
}
}
var profiles: [StreamProfile] { catalog.profiles }
@@ -33,6 +40,14 @@ final class ProfileStore: ObservableObject {
id.flatMap { catalog.profile(id: $0) }
}
#if DEBUG
/// Shot-mode seed: replace the catalog outright so a capture shows a known set of profiles
/// rather than the tester's. Safe because `didSet` suppresses the write-back in shot mode.
func debugSet(_ profiles: [StreamProfile]) {
catalog = ProfileCatalog(profiles: profiles)
}
#endif
/// This host's default profile, dangling ids dropped a deleted profile resolves as "Default
/// settings", never an error (§4.4).
func binding(for host: StoredHost) -> StreamProfile? { catalog.binding(for: host) }

Some files were not shown because too many files have changed in this diff Show More