Compare commits

...
Author SHA1 Message Date
enricobuehler 6a82a602a1 fix(clients/android): the button correction fired on pads that never needed it
ci / docs-drift (pull_request) Successful in 50s
ci / web (pull_request) Successful in 1m12s
ci / bun-nix (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 2m12s
ci / rust (pull_request) Successful in 5m27s
android / android (pull_request) Successful in 6m41s
Two field reports (2026-08-21), one shape: a GameSir G8+ and an Xbox Elite
Series 2 ("Xbox Wireless Controller" over Bluetooth) with X answering Y, Y
answering LB, and the two shoulders answering menu buttons — everything else
correct. That is not a stray mapping, it is exactly what `GENERIC_XBOX` does to
scancodes `0x133`/`0x134`/`0x136`/`0x137`, so the correction added yesterday was
firing on pads whose buttons were already where `Generic.kl` says they are.

It fired because it asked the wrong question. `hasKeys(BUTTON_C, BUTTON_Z)`
answers for what a device DECLARES, not what it reports: `hid-input` allocates
`BTN_A + n` straight through for every button in the descriptor, so BTN_C
(`0x132`) and BTN_Z (`0x135`) are set on ANY pad declaring six or more —
including a standard-layout pad that never presses either. The signal is
therefore identical on the pad that needs correcting and the pad that does not,
and no amount of tightening it could have separated them. It is the same pad
model in both reports: an Elite Series 2 needed the correction on a Fire TV and
another Elite Series 2 was broken by it here.

What does separate them is the axes. A HID gamepad describes its triggers either
as the Accelerator/Brake usages — which become `ABS_GAS`/`ABS_BRAKE`, names
Android has words for — or as two more generic axes on `ABS_Z`/`ABS_RZ`, which
it does not. A descriptor well-formed enough to name its triggers puts its
buttons at the standard positions too. It is also the firmware line on the pad in
the report: an Xbox Wireless Controller over Bluetooth reports GAS/BRAKE after
its firmware update and Z/Rz before it, and only the older one was ever wrong.

`padButtons` now takes `namedTriggers` and answers NATIVE whenever it is set —
no correction of any kind, on buttons or axes, for a pad Android already reads.
`padMap` computed that fact one line below and only ever spent it on the axes;
it now decides both. `hasKeys` stays for the narrower question it can answer —
WHICH straight-through order, once the axes have established there is one — where
a false positive costs nothing.

Moonlight decides it on the same fact (`ControllerHandler`, `gasRange == null`
beside the `"Xbox Wireless Controller"` name); yesterday's commit cited its
tables and then replaced its discriminator, which is where this came in.

Verified: `:kit:testDebugUnitTest` and `:app:testDebugUnitTest` green (16 cases
in PadButtonsTest, 3 new: the gate holds for every vendor/declaration
combination, the four reported buttons stay themselves, and the report-order
choice past the gate is unchanged), `:app:compileDebugKotlin` clean. The
DualSense report filed alongside these — Triangle dead in the client UI and in
the stream — is NOT explained by this and is not fixed here: a button that
reaches neither is one `buttonBit` maps to nothing, which no branch of the
correction produces for Triangle. The Controllers screen prints the raw scancode
and keycode of every press; that line off the reporter's pad will pin it.
2026-08-21 13:10:23 +02:00
enricobuehler 2be444b329 Merge pull request 'A provider plugin can report which of its titles are running' (#361) from worktree-playnite-runstate into main
ci / docs-site (push) Successful in 1m14s
ci / web (push) Successful in 1m19s
ci / bun-nix (push) Successful in 1m24s
ci / docs-drift (push) Successful in 27s
deb / build-publish-gamescope (push) Successful in 43s
ci / rust-arm64 (push) Successful in 3m26s
deb / build-publish-client-arm64 (push) Successful in 1m20s
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) Successful in 1m7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
deb / build-publish (push) Successful in 4m16s
nix / flake (push) Successful in 20m48s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 57s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m59s
ci / rust (push) Successful in 8m24s
arch / build-publish (push) Successful in 8m31s
deb / build-publish-host (push) Successful in 7m6s
docker / builders-arm64cross (push) Successful in 15s
docker / deploy-docs (push) Successful in 39s
plugin-kit-publish / publish (push) Successful in 41s
android / android (push) Successful in 12m41s
windows-host / package (push) Successful in 13m2s
windows-host / winget-source (push) Skipped
deb / smoke-install (push) Successful in 4m17s
windows-host / canary-manifest (push) Successful in 31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m1s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m45s
Reviewed-on: #361
2026-08-20 19:22:10 +00:00
enricobuehler 669a1bc0ce The video data port was never open, and nothing could tell us (#360)
ci / docs-drift (push) Successful in 29s
ci / bun-nix (push) Successful in 53s
ci / docs-site (push) Successful in 1m13s
ci / web (push) Successful in 1m17s
apple / swift (push) Failing after 1m34s
apple / distribute (push) Skipped
apple / screenshots (push) Skipped
ci / rust-arm64 (push) Successful in 1m52s
deb / build-publish-gamescope (push) Successful in 1m9s
decky / build-publish (push) Successful in 1m5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 22s
deb / build-publish-client-arm64 (push) Successful in 1m55s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 16s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 44s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m59s
deb / build-publish-host (push) Successful in 6m4s
ci / rust (push) Successful in 7m23s
arch / build-publish (push) Successful in 8m11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 16s
docker / builders-arm64cross (push) Successful in 14s
docker / deploy-docs (push) Successful in 39s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m14s
android / android (push) Successful in 10m46s
flatpak / build-publish (push) Successful in 7m15s
deb / build-publish (push) Successful in 5m6s
deb / smoke-install (push) Successful in 3m49s
windows-host / package (push) Successful in 13m18s
windows-host / winget-source (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m56s
windows-host / canary-manifest (push) Successful in 27s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m4s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m54s
Merged with `apple / swift` red: the Mac runner's volume is out of space (594 "No space left on device" lines; the only distinct error is "You can't save the file because the volume 'Macintosh HD' is out of space"). It passed on the parent commit 1280f697 and the only delta since was one line in scripts/ci/docs-undocumented-env-baseline.txt — Swift compilation of the Apple client cannot be affected by a Rust host firewall rule, a core control message, or a CI text baseline. A rerun hit the same wall, so the mac-mini-1 disk needs the owed cleanup.

Every check that can validate this change is green: ci/rust (480 core tests), ci/rust-arm64, android, docs-drift, web, docs-site, bun-nix, and both windows-client legs. The Windows HOST build — which the PR checks never run, and which is the only thing that compiles the cfg(windows) firewall function — was dispatched separately on the branch and passed, including "Clippy (host + tray, Windows)".
2026-08-20 18:48:28 +00:00
enricobuehler 8ff6fe6093 fix(host): regenerate the API spec, and stop the runstate tests colliding
ci / bun-nix (pull_request) Successful in 23s
ci / web (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m13s
ci / docs-drift (pull_request) Successful in 1m5s
ci / rust-arm64 (pull_request) Successful in 2m54s
android / android (pull_request) Successful in 5m23s
ci / rust (pull_request) Successful in 7m17s
nix / flake (pull_request) Successful in 7m49s
`api/openapi.json` (and its docs-site copy) gain exactly the new route and its
three schemas — nothing else moved, which is the check worth doing on a
regenerated spec.

The test fix is one the tests found themselves, on the first run in an
environment that actually executes them: all three shared the provider id
`playnite` and cleared the whole process-global table between cases, so under
parallel scheduling they flipped each other's answers — `omitted_is_not_running`
read `None` for a title another test had just wiped. Each now takes ids only it
uses and forgets only its own row, which also retires the blunt `reset()` that
made the collision possible.
2026-08-20 20:38:08 +02:00
enricobuehler 1758266bda chore(plugin-kit): export the running-report surface, and bump to 0.4.4
`reportRunning` and its two types were reachable only through the deep
`./reconcile.js` path — `index.ts` re-exports an explicit list, not a star — so
no plugin could import them from the package root the way it imports every
other provider symbol.

Version bumped because it is a published package and the addition is what a
consumer would depend on; the playnite plugin deliberately does NOT, calling the
route through the untyped host seam instead so it is not gated on this publish.
2026-08-20 20:38:08 +02:00
enricobuehler d5fb1e4479 feat(host): a provider plugin can report which of its titles are running
The host derives liveness by scanning, which needs something recognizable on
disk. A Playnite-launched emulated game, a manually added one, or a library
plugin that records no install directory has none — and its launch is a
`playnite://` hand-off, so the host holds no process either. The lease went
`Untracked`: the exit was never noticed, `session_on_game_exit` could not fire,
and `POST /game/end` had nothing to aim at. Playnite knew the whole time.

New `PUT /library/provider/{provider}/running` takes a provider's complete
running set (with the pid where it knows one) — declarative and idempotent like
the reconcile beside it, so a missed event or a plugin restart self-corrects
rather than drifting. `crate::runstate` holds it and expires it after 90s
unless restated, which is what makes it safe for a live provider to hold a
session open for a game the host cannot see: a plugin that dies stops counting
and the host falls back to scanning, exactly as today.

`LeaseKind::Reported` is the lease that follows from it. `open` reaches it when
the spec is empty and a provider speaks for the id, and — the load-bearing part
on Windows, where every launch is a hand-off by construction — the three shim
reclassification paths now fall back to it where they fell to `Untracked`.
Phase 1 takes "running" as the game appearing; phase 2 takes "stopped" as the
exit. Unlike `procscan::running_hint`, which may only ever delay an exit because
Steam's registry flag survives an unclean one, a fresh report is decisive in
both directions. A reported pid joins the termination ladders on the same terms
as a spawned one: re-resolved and start-time-pinned at the moment of use.

The route is the plugin lane's, like the reconcile. No new authority — the host
maps `external_id` through the catalog, so a provider can only speak about
entries it published; an unknown id is counted, not refused, because a report
legitimately races its own reconcile and 400-ing the batch would throw away the
liveness of every other running title.

plugin-kit gains `ProviderClient.reportRunning`; a 404 from an older host means
"this host tracks games by scanning".
2026-08-20 20:38:08 +02:00
enricobuehler ea3c9e1202 ci(docs-drift): baseline the new control-message constant
ci / bun-nix (pull_request) Successful in 40s
ci / web (pull_request) Successful in 1m14s
ci / docs-drift (pull_request) Successful in 27s
ci / docs-site (pull_request) Successful in 1m23s
ci / rust-arm64 (pull_request) Successful in 2m23s
ci / rust (pull_request) Successful in 6m15s
android / android (pull_request) Successful in 6m42s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m22s
apple / swift (pull_request) Failing after 1m35s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m49s
`check-docs-drift.sh` scans for `PUNKTFUNK_*` identifiers and asks that
each be documented in docs-site or explicitly baselined. It cannot tell
an env knob from a cbindgen-exported `#define`, so the new
`PUNKTFUNK_MSG_DELIVERY_REPORT` header constant tripped it.

It is a wire message type byte, not a knob an operator can set — same as
every other `PUNKTFUNK_MSG_*`, all of which are already in the baseline.
Added in sorted position beside them.
2026-08-20 20:27:37 +02:00
enricobuehler 685c4bd99a Merge pull request 'A pad that was never there kept the console UI on, and the picture sat in the corner' (#359) from worktree-android-gamepad-ui-and-corner into main
ci / rust-arm64 (push) Successful in 2m19s
ci / web (push) Successful in 1m28s
ci / bun-nix (push) Successful in 28s
ci / docs-drift (push) Successful in 26s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 17s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 17s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 17s
ci / docs-site (push) Successful in 2m10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 29s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 18s
docker / builders-arm64cross (push) Successful in 25s
ci / rust (push) Successful in 8m23s
docker / deploy-docs (push) Successful in 1m43s
android / android (push) Successful in 10m20s
2026-08-20 18:22:33 +00:00
enricobuehler 0519b057d5 fix(clients/android): a pad that was never there, and a picture in the corner
ci / rust-arm64 (pull_request) Successful in 3m37s
ci / web (pull_request) Successful in 1m55s
android / android (pull_request) Successful in 8m52s
ci / bun-nix (pull_request) Successful in 19s
ci / docs-site (pull_request) Successful in 2m25s
ci / docs-drift (pull_request) Successful in 23s
ci / rust (pull_request) Successful in 11m0s
Two field reports from one Android user, with one shape between them: a
decision taken once, at a moment when the answer was still wrong, and never
revisited.

The console UI could not be dismissed. "With a controller" asks whether a pad
is attached, and the client answered that with `isPad` — does this device's
source class include gamepad or joystick. That is the right question for
ROUTING an event and the wrong one for presence: devices publish inputs that
claim the source class while being no such thing (OEM game-mode overlays, the
gaming-phone shoulder triggers), and one of them is enough to pin the console
UI on forever, because a pad that was never there can never disconnect.

`pads()` now filters on `looksLikeController`: the source claim AND hardware
behind it — a stick, a HAT, or the A/B face buttons — on a device the platform
did not synthesize itself. The claim is cheap; the hardware is not. `isPad`
keeps its looser meaning for the event lane, where it is correct. It is not a
complete defence (an OEM device that declares BTN_GAMEPAD and two axes is
indistinguishable from a pad at this layer), so the master switch stays the
guaranteed way out — and the Controllers screen still lists everything real in
one column or the other, which is where someone looks when the client's idea of
"a pad is attached" disagrees with the room.

The picture sat in the top-left corner. The ASurfaceControl layer composites
into the SurfaceView's on-screen rectangle, read once at `surfaceCreated` — but
the stream screen hides the system bars and switches the window to draw into
the display cutout a frame or two later, and each of those grows the view under
a surface that is never recreated. The layer went on painting at the size it
started with, anchored at the origin. It passed on glass because a device whose
bars were already hidden when the surface arrived never sees the gap.

The size is now live: a packed atomic on the session handle, seeded by
`nativeStartVideo`, re-reported by `nativeVideoSurfaceSize` from every
`surfaceChanged`, and read by the layer before each present. One atomic load per
frame, and rotation and multi-window come along for free.

Verified: `:kit:cargoNdkClippy` (arm64 + armv7, deny warnings), `:kit:` and
`:app:` unit tests, and the native crate's own suite. The new JNI symbol is
exported in the built `.so`.
2026-08-20 20:07:57 +02:00
enricobuehler 33b029695f Merge pull request 'The gamescope prune deleted the compositor and shipped only its launcher' (#355) from gamescope-show-version-banner into main
ci / rust-arm64 (push) Successful in 1m48s
ci / web (push) Successful in 1m2s
ci / bun-nix (push) Successful in 30s
ci / docs-drift (push) Successful in 24s
ci / docs-site (push) Successful in 1m23s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 18s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / deploy-docs (push) Successful in 42s
docker / builders-arm64cross (push) Successful in 27s
ci / rust (push) Canceled after 16m22s
nix / flake (push) Successful in 28m29s
Reviewed-on: #355
2026-08-20 18:00:07 +00:00
enricobuehler 1f6f01cb76 Merge pull request 'The in-stream "Switch to Desktop" went unwatched on Nobara, and both distro families leaked an idled Game Mode' (#358) from worktree-nobara-gamemode-switch into main
ci / web (push) Successful in 1m25s
ci / rust-arm64 (push) Successful in 2m36s
ci / bun-nix (push) Successful in 23s
ci / docs-site (push) Successful in 1m43s
ci / docs-drift (push) Successful in 26s
ci / rust (push) Canceled after 5m55s
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/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (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
deb / build-publish-gamescope (push) Successful in 52s
deb / build-publish-client-arm64 (push) Successful in 1m30s
deb / build-publish (push) Successful in 4m43s
deb / build-publish-host (push) Successful in 5m38s
arch / build-publish (push) Successful in 9m37s
android / android (push) Successful in 11m1s
windows-host / package (push) Successful in 13m55s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 45s
deb / smoke-install (push) Successful in 5m43s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 23m26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 24m6s
2026-08-20 17:51:44 +00:00
enricobuehler fade2f7af3 Merge remote-tracking branch 'origin/main' into worktree-nobara-gamemode-switch
ci / bun-nix (pull_request) Successful in 33s
ci / web (pull_request) Successful in 1m12s
ci / docs-site (pull_request) Successful in 1m21s
ci / docs-drift (pull_request) Successful in 48s
ci / rust-arm64 (pull_request) Successful in 1m55s
android / android (pull_request) Successful in 7m59s
ci / rust (pull_request) Successful in 16m58s
2026-08-20 19:48:15 +02:00
enricobuehler 6e4cc335c5 Merge pull request 'The console can switch itself off, and a TV remote can reach everything it offers' (#354) from worktree-console-ui-off-toggle into main
ci / bun-nix (push) Successful in 25s
ci / docs-drift (push) Successful in 31s
deb / build-publish (push) Canceled after 46s
ci / web (push) Successful in 1m36s
ci / docs-site (push) Successful in 1m48s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 25s
ci / rust-arm64 (push) Successful in 2m47s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 20s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 24s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 18s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
deb / build-publish-client-arm64 (push) Successful in 1m53s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m6s
android / android (push) Canceled after 5m17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m27s
arch / build-publish (push) Canceled after 5m27s
ci / rust (push) Canceled after 5m33s
deb / build-publish-host (push) Canceled after 4m21s
deb / build-publish-gamescope (push) Canceled after 19s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / builders-arm64cross (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 2m4s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 6m48s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 12m41s
flatpak / build-publish (push) Successful in 7m18s
Reviewed-on: #354
2026-08-20 17:46:35 +00:00
enricobuehler d9662c010d fix(gamescope): 0.31.0's idled takeover left nothing watching for "Switch to Desktop"
The managed takeover stopped stopping the display manager and started idling the
box's autologin session instead (c2f5e91b). That commit also deleted the two lines
the old DM stop carried — `record_session_select_baseline()` and
`STOPPED_DM = Some(dm)` — and 38a0f54b then removed every remaining writer of that
static. The in-stream switch gate in `create_managed_session` still reads it, so
`honor_session_select_switch` became unreachable code on every box.

Bazzite never noticed, for two reasons measured on `.41`: its `os-session-select`
is a thin wrapper over `steamosctl` D-Bus calls and writes NO sentinel (verified —
`~/.config/steamos-session-select` does not exist before or after a completed
switch), and `is_steam_htpc_platform()` defaults the mid-stream session watcher ON
for bazzite/steamos, which follows the switch by itself.

Nobara gets neither. `ID=nobara` matches no HTPC default, so no watcher; and its
ChimeraOS-layout `os-session-select` DOES write the sentinel, which was the only
thing the host had to see the switch by. So on Nobara the user's "Switch to
Desktop" went entirely unhandled: the capture loss it causes drives a rebuild, the
rebuild relaunches game mode over the booting desktop, and the stream is thrown
straight back in — the 2026-07-24 field report, reopened by a user 2026-08-20.

Arm the gate on the takeover that actually exists now. `takeover_idled()` reads
IDLE_DROPIN_ARMED, the idle drop-in re-baselines the sentinel when it goes in, and
STOPPED_DM is documented as what it has become: adoption-only state for a takeover
stranded by a pre-0.31.0 host. The hand-back keeps that host's DM restore + switch
replay (now `replay_switch_under_restored_dm`) but runs it only for such an adopted
takeover — a 0.31.0 takeover leaves the DM up precisely so the OS does that work
itself, and on the VM it does: `Updated user selected session to plasma`, then
Plasma.

Both hand-back paths also give the box its own Game Mode back, which neither did.
The takeover replaces that session's ExecStart with a sleep, and a mid-stream
switch is the one exit that leaked it — the disconnect restore sweeps it, but a
switch is not a disconnect. Measured on Bazzite `.41`: after a completed switch to
KDE the unit still carried `ExecStart=/usr/bin/sleep infinity`, so "Return to
Gaming Mode" would have started a unit that does nothing. That is the same barred
way back `release_autologin_mask` already exists to prevent for the mask this
drop-in replaced, so it goes there — covering the watcher-driven distros — and in
`honor_session_select_switch`, covering the sentinel-driven ones.

Verified on the Nobara VM (123 on home-node-3) by driving the real path against
live systemd: takeover -> real `steamos-session-select plasma` -> rebuild. The
rebuild refuses to relaunch game mode, the idle drop-in is gone, and the box
arrives in KDE. Backed out to the 0.31.0 shape on the same box it fails at "the
switch did not advance the sentinel" — the blindness itself. The extended
`the_mask_comes_off_only_when_the_box_takes_itself_back` covers the watcher path
against a live user manager, and fails without the hand-back.
2026-08-20 19:33:19 +02:00
enricobuehler 2b0913cf53 Merge pull request 'A DualSense and an Elite Series 2 on a Fire TV pressed each other's buttons — Android reads the scancode now' (#357) from worktree-android-pad-hid-remap into main
docker / builders-arm64cross (push) Successful in 13s
ci / docs-drift (push) Successful in 31s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 18s
ci / web (push) Successful in 1m22s
ci / docs-site (push) Successful in 1m15s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 19s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 21s
ci / rust-arm64 (push) Successful in 2m31s
ci / bun-nix (push) Successful in 2m18s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 56s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 29s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 12s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 38s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 31s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m27s
docker / deploy-docs (push) Successful in 43s
ci / rust (push) Successful in 6m35s
android / android (push) Successful in 8m48s
2026-08-20 17:18:46 +00:00
enricobuehler 1280f697be fix(host,core): the video data port was never open, and nothing could tell
apple / swift (pull_request) Successful in 2m16s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m15s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m53s
ci / rust-arm64 (pull_request) Successful in 2m38s
ci / web (pull_request) Successful in 1m29s
ci / docs-site (pull_request) Successful in 58s
ci / docs-drift (pull_request) Failing after 28s
ci / bun-nix (pull_request) Successful in 56s
android / android (pull_request) Successful in 7m21s
ci / rust (pull_request) Canceled after 7m48s
A field host streamed 1919 frames into a black screen while its own log
blamed the client. Four faults, each of which alone makes the failure
invisible.

The Windows firewall rules are `localport=`-scoped (47998-48010, 9777,
5353), but the media data plane binds an EPHEMERAL port per session. No
such rule can ever cover it, so Windows Firewall drops the client's
hole-punch on EVERY session on EVERY Windows host — `punched=false` on
the "data plane bound" line, in all six sessions of two field logs. The
punch then never opens the return path and video is sent blind to an
address the client merely reported. `service install` now also adds a
program-scoped inbound UDP rule for the host executable, which covers
whatever port a session picks. Program-scoped rather than a pinned port:
pinning into 47998-48010 would collide with Sunshine/Apollo.

`LossReport` carried only `loss_ppm`, which is ambiguous at zero — loss
is a ratio over the packets that arrived, so a flawless link and a link
delivering NOTHING both report 0. The host read total silence as perfect
and decayed adaptive FEC to its floor. Clients now also send a
`DeliveryReport` with the session's received-packet count. It is a new
type byte, NOT a field appended to `LossReport`: that message is
length-checked exactly, so lengthening it would make every shipped host
reject the loss reports its FEC runs on. Sent every window while the
count is zero, once when the first packets land, then never — an older
host warns per unknown message and must not be flooded on a good
session.

`NO_VIDEO_RETRY` (client got nothing) and `FLUSH_COOLDOWN` (client
drowning) were both 2000 ms, so the host's cadence classifier could not
tell two opposite faults apart and picked the wrong one out loud. The
no-video cooldown moves to core beside `FLUSH_COOLDOWN` at 2600 ms, and
both sides compare against the shared constant rather than a copy.

The diagnosis now leads with the delivery count: zero is an error naming
the data plane, a confirmed count keeps the old confident wording, and an
old client that cannot answer gets a warning that says so instead of
guessing. A punch that never arrives is also its own warning now, rather
than a debug field on an info line.
2026-08-20 19:14:49 +02:00
enricobuehler 3b39710a5a fix(clients/android): controllers Android has no key layout for stop landing on the wrong buttons
ci / web (pull_request) Successful in 1m37s
ci / docs-site (pull_request) Successful in 1m43s
ci / bun-nix (pull_request) Successful in 30s
ci / docs-drift (pull_request) Successful in 27s
ci / rust-arm64 (pull_request) Successful in 2m19s
android / android (pull_request) Successful in 6m57s
ci / rust (pull_request) Successful in 7m23s
Reported from a Fire TV Stick 4K Max: a DualSense and an Xbox Elite Series 2,
both over Bluetooth, both identified correctly but with buttons on the wrong
actions ("L1 being L2"). Both work in Moonlight on the same box.

Android names a pad's buttons through a KEY LAYOUT file matched on VID/PID. A
pad with no matching file falls back to AOSP's `Generic.kl`, which assigns
keycodes by SCANCODE POSITION — 0x130→BUTTON_A, 0x131→BUTTON_B, 0x132→BUTTON_C
and up. That is only right if the pad's buttons sit where the file assumes, and
a HID gamepad with no kernel driver numbers its buttons 1..n straight through in
its OWN report order, so every keycode past the first divergence is somebody
else's button. Neither pad has a layout there: AOSP ships none for the Elite
Series 2 over Bluetooth (045e:0b05) on any version, and the DualSense's
(054c:0ce6) both postdates Fire OS and carries `requires_kernel_config
CONFIG_HID_PLAYSTATION`, which a Fire TV kernel has not. A DualSense reporting
straight through puts L2 on 0x136, which `Generic.kl` calls BUTTON_L1 — the
reported symptom exactly, and Circle and R1 (BUTTON_C / BUTTON_Z) mapped to
nothing at all and were dropped.

This client read `KeyEvent.keyCode` only, so it inherited every one of those
errors — in the stream, in the console shell's navigation, and in the
Controllers screen's own tester, which is why the tester agreed with the bug.

Resolve buttons from the SCANCODE instead, which is the pad's own report
position and immune to the layout file — the same reason `Keymap.toVk` reads
`scanCode` for keyboards. Two things keep it off pads that already work:

  1. the correction applies ONLY where the delivered keycode is what
     `Generic.kl` would have said, so a device-specific layout always wins;
  2. which report order to read is decided from what the DEVICE declares —
     a pad numbering straight through claims BUTTON_C and BUTTON_Z, keycodes
     no real controller has a button for — never from a model table.

`Gamepad.padKeyCode(event)` is a drop-in for `event.keyCode` and every pad
reader now goes through it: the streaming branch, the Skia console shell's
probe, the older Compose navigation, and the Controllers tester. The console and
the game must not disagree about which button a user pressed.

Axes get the same treatment: a pad declaring none of LTRIGGER/RTRIGGER,
BRAKE/GAS or BRAKE/THROTTLE is one Android never mapped, and its triggers are on
raw axes. A Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry, so
only the triggers (RX/RY) were missed; every other such pad puts the right stick
on Rx/Ry and the triggers on Z/Rz — the shape that makes pulling a trigger swing
the right stick. Whether those idle at −1 is measured off the device's own range
rather than assumed, so a pad reporting an honest 0..1 is not rescaled to a
permanent half-pull.

Also: the Controllers screen now reports each press as its raw scancode and
keycode plus the correction, names the resolved layout on the pad's card, and
reads axes through the same map the stream does — so it can be trusted again,
and so an unmapped pad can be pinned from a field report without the hardware.
The Xbox Bluetooth product ids (One S, Elite Series 2 and its Core) join the
identity table; without them an Elite streamed under the Xbox 360 identity.

Moonlight carries the same two report-order tables (`ControllerHandler`'s
`isNonStandardDualShock4` / `isNonStandardXboxBtController`), which is why both
pads work there on the same box.

Verified: :kit:testDebugUnitTest and :app:testDebugUnitTest both green (13 new
cases in PadButtonsTest covering the scancode tables and the axis resolution),
:app:compileDebugKotlin clean. NOT yet tested on glass — the reporter's Fire TV
is the confirmation this needs. The Xbox pad's Guide button stays unreachable
either way: it arrives as KEY_HOMEPAGE, and Android never delivers KEYCODE_HOME
to an app.
2026-08-20 18:46:56 +02:00
enricobuehler 19243c30b4 Merge pull request 'GameStream sessions built the XUSB pad that Steam cannot see — Xbox backend parity with the native plane' (#356) from worktree-gamestream-xbox-hid-parity into main
deb / build-publish-host (push) Successful in 5m28s
ci / bun-nix (push) Successful in 29s
ci / docs-drift (push) Successful in 50s
ci / docs-site (push) Successful in 1m18s
ci / web (push) Successful in 1m27s
ci / rust-arm64 (push) Successful in 1m55s
deb / build-publish-gamescope (push) Successful in 46s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 24s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 20s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 10s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
deb / build-publish-client-arm64 (push) Successful in 2m29s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
deb / build-publish (push) Successful in 5m35s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m4s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 2m7s
docker / builders-arm64cross (push) Successful in 20s
docker / deploy-docs (push) Successful in 48s
ci / rust (push) Successful in 7m27s
android / android (push) Successful in 10m54s
deb / smoke-install (push) Successful in 3m58s
arch / build-publish (push) Successful in 11m11s
windows-host / package (push) Successful in 14m38s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m13s
2026-08-20 16:33:41 +00:00
enricobuehler dfcffcdd50 fix(gamestream/windows): Moonlight sessions built the XUSB pad that Steam cannot see
ci / bun-nix (pull_request) Successful in 36s
ci / docs-drift (pull_request) Successful in 35s
ci / web (pull_request) Successful in 1m36s
ci / docs-site (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 1m39s
ci / rust (pull_request) Successful in 5m36s
android / android (pull_request) Successful in 5m51s
Reported from the field: gamepad input does nothing on GameStream clients, and it
reproduces across every client and device a user tries (Artemis on a Steam Deck and
on an Android phone both). That breadth is the tell — it is not a client at all.

On Windows there are two virtual Xbox backends and they are not interchangeable to a
game. The XUSB companion registers only GUID_DEVINTERFACE_XUSB and exposes no HID
collection (pf_xusb.inx says so in its own header: "a non-HID UMDF2 driver", Class =
System), so Steam's hidapi enumeration, SDL, RawInput, DirectInput, joy.cpl and
WGI/GameInput cannot see it at all — only classic XInputGetState can. The native plane
moved to the real HID pad as its DEFAULT on 2026-08-09 (bd5735b8) for exactly that
reason; its doc comment records the reporter who lost a controller for two weeks to it.

The GameStream plane never got that change. It has bound `crate::inject::gamepad`
since the first gamepad commit, when that name meant uinput and Windows had no second
backend; Windows later gave the same name the XUSB companion, so this plane inherited
it by module-name coincidence rather than by any decision. bd5735b8 did touch
control.rs — but only to widen the rumble closure's arity, and its note reasons about
"the uinput backend", not about the Windows one sitting behind the same import. So
every Moonlight session since has presented a pad most games cannot enumerate, while
native punktfunk sessions on the same host got the good one.

There was also no way out: `windows_xbox_hid` was `pub(super)`, i.e. unreachable from
this module, so PUNKTFUNK_XBOX_BACKEND did nothing here — and the Windows manager
ignores the arrival `kind` byte, so a client could not ask for a DualSense either, the
escape that saved the earlier reporter.

- native.rs / native/gamepad.rs: `mod gamepad` and `windows_xbox_hid` become
  pub(crate). The knob keeps ONE definition and one name; widening visibility breaks
  no existing caller. Its doc now records that both planes read it, and why being
  `pub(super)` was itself the bug.
- gamestream/control.rs: a `SessionPads` enum is the one place this plane picks a
  backend — the HID pad when the shared knob says so, the XUSB companion otherwise,
  and on every other platform the single backend that exists.
  `PUNKTFUNK_XBOX_BACKEND=xusb` now reverts both planes together.

The HID pad's rich-feedback plane is dropped rather than plumbed: an Xbox pad has no
lightbar or adaptive triggers, and GameStream has no vocabulary for one — its rumble
message (0x010B) carries the two handle motors and nothing else, which is why the
trigger levels were already dropped at the call site.

No test: a `cfg(target_os = "windows")` #[test] would compile NOWHERE. ci.yml excludes
it by target, and windows-host.yml lints the host with `cargo clippy -p punktfunk-host`
without `--all-targets`, which does not build `cfg(test)` modules at all — the same
blind spot that workflow's own comments blame for letting the Linux twin's tests rot to
the wrong arity. It would be dead weight, not coverage.

VERIFIED
  * ON WINDOWS (.133, the only box where this arm compiles at all):
    `cargo check -p punktfunk-host` clean in 57s, then
    `cargo clippy -p punktfunk-host -- -D warnings` clean in 42s — both on default
    features, which include `gamestream`. This compiles BOTH enum arms: `Xusb` and
    `Hid` are Windows types alike.
    Non-vacuous by construction: `cargo clean -p punktfunk-host` ran first (a scp'd
    tree plus this box's lagging clock and a warm shared target dir otherwise yield a
    fresh `Finished` having compiled nothing), and both passes logged
    `Compiling punktfunk-host v0.31.0`. The only warning is punktfunk-core's
    header-write notice — benign and pre-existing.
  * `cargo fmt --all --check` clean.

NOT VERIFIED — owed
  * Linux: no box could run it (.25's root filesystem is 100% full; .21/.136/.138 were
    down; OrbStack here hangs even on `docker run alpine echo`). Exposure is small —
    the Linux arm is three calls on a `GamepadManager` whose Windows XUSB sibling has
    the identical method surface and just compiled — and ci.yml DOES run on
    pull_request, so opening a PR closes this.
  * On glass: no Moonlight session has driven the HID pad through this plane yet. That
    is the real acceptance test, and the log line to look for is
    "virtual Xbox pad created (Windows UMDF HID)" where it used to say
    "virtual Xbox 360 created (Windows XUSB companion)".
  * windows-host.yml has NO `pull_request` trigger (push to main, v* tags,
    workflow_dispatch only), so CI will not re-check this arm on a PR — the .133 run
    above is deliberately standing in for it.
2026-08-20 18:19:19 +02:00
enricobuehler f2b5b3e567 Merge pull request '0.31.0 release notes, re-cut on current main — and the bun install CI flake' (#351) from worktree-release-next-prep into main
ci / bun-nix (push) Successful in 26s
ci / docs-drift (push) Successful in 29s
ci / docs-site (push) Successful in 1m22s
ci / web (push) Successful in 2m13s
ci / rust-arm64 (push) Successful in 2m35s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m5s
android-screenshots / screenshots (push) Successful in 2m12s
ci / rust (push) Successful in 8m26s
decky / build-publish (push) Successful in 41s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m37s
android / android (push) Successful in 9m57s
linux-client-screenshots / screenshots (push) Successful in 2m32s
sbom / sbom (push) Successful in 26s
arch / build-publish (push) Successful in 10m14s
flatpak / build-publish (push) Successful in 5m2s
docker / builders-arm64cross (push) Successful in 13s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 13s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 29s
docker / deploy-docs (push) Successful in 17s
deb / smoke-install (push) Successful in 4m40s
deb / build-publish (push) Successful in 3m24s
deb / build-publish-host (push) Successful in 7m31s
deb / build-publish-gamescope (push) Successful in 22s
deb / build-publish-client-arm64 (push) Successful in 1m11s
web-screenshots / screenshots (push) Successful in 6m19s
windows-host / package (push) Successful in 13m57s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Successful in 19s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m11s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m43s
apple / swift (push) Failing after 18s
apple / distribute (push) Skipped
apple / screenshots (push) Skipped
2026-08-20 07:50:58 +00:00
enricobuehler 7af6c323d0 Merge remote-tracking branch 'origin/main' into worktree-console-ui-off-toggle
android / android (pull_request) Successful in 7m15s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m44s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m8s
ci / bun-nix (pull_request) Successful in 31s
ci / docs-drift (pull_request) Successful in 31s
ci / web (pull_request) Successful in 1m4s
ci / docs-site (pull_request) Successful in 2m3s
ci / rust-arm64 (pull_request) Successful in 2m14s
ci / rust (pull_request) Successful in 6m34s
# Conflicts:
#	docs-site/content/docs/client-settings.md
2026-08-20 09:13:10 +02:00
enricobuehler 76e6618b84 fix(console-ui): Settings and the library are reachable without face buttons, for a TV remote
ci / docs-drift (pull_request) Successful in 36s
ci / bun-nix (pull_request) Successful in 38s
ci / web (pull_request) Successful in 1m15s
ci / docs-site (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 1m29s
android / android (pull_request) Canceled after 1m54s
ci / rust (pull_request) Canceled after 1m26s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 1m59s
An Android TV's only input is its remote — a D-pad, OK and Back — and the
console bound both Settings and the game library to face buttons that
remote does not have. X opened Settings, Y opened the shelf, and neither
event could be produced: the Kotlin bridge emits Move/Confirm/Back for a
remote and nothing else, the carousel's only action tiles are Add Host and
Rescan, and the hint chips that also carry those actions need a finger on
a touchscreen. So on a TV the two were simply unreachable. This is the
gap the deleted Compose home used to paper over with its own "no face
buttons" mapping, and the same wall Apple hit on the Siri Remote.

Down on the carousel now opens Settings — the other free direction beside
up, which already opens the host menu. It is a second route on a pad and
the only route on a remote, so the hint bar names the one the device in
hand actually has: ▼ with no pad attached, X with one. That is the whole
extent of the legend change; Y still opens the library and still says so.

The shelf gains a Library row on the host's own options menu, which up
reaches, offered on exactly the terms Y offers it (saved AND paired). It
replaces the menu rather than stacking on it, so Back from the shelf lands
on the carousel and not on a menu about the host just left. This is also
the route `docs/game-library.md` has been describing all along — "open its
options and choose Library" — which until now did not exist.

▼ is a new hint glyph: the ▲ triangle stood on its head rather than a
second drawing routine, and pressable in the hint bar exactly as ▲ is.
2026-08-20 09:06:05 +02:00
enricobuehler 7d2a8778d1 fix(nix): the prune deleted the compositor and shipped only its launcher
ci / docs-drift (pull_request) Successful in 30s
ci / bun-nix (pull_request) Successful in 1m0s
ci / web (pull_request) Successful in 1m15s
ci / docs-site (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 1m52s
ci / rust (pull_request) Successful in 5m16s
nix / flake (pull_request) Successful in 12m29s
ROOT CAUSE of every gamescope symptom chased today. nixpkgs WRAPS this package:
makeWrapper leaves the real compositor ELF at bin/.gamescope-wrapped and
installs a small launcher at bin/gamescope that sets PATH (xwininfo) before
exec'ing it. Our prune kept only `gamescope`:

    find $out/bin -mindepth 1 ! -name gamescope -delete

so it deleted the compositor and shipped the launcher pointing at a path that
no longer existed. MEASURED (run 19622): $out/bin held one 16 KB file, and
`strings` on it showed the PATH prologue and '/bin/.gamescope-wrapped'.

That single line explains all of it:
  * `--version` printed NOTHING — the launcher exec'd a deleted target. I had
    attributed this to the build sandbox, and to upstream's PrintVersion()
    landing after 3.16.25. Both were wrong.
  * the +pfhdr marker was 'missing from the binary' — a wrapper carries no
    version string, so it never could have been there.

Keep the target through the prune, and assert on the WRAPPED ELF rather than
the launcher (which would otherwise pass or fail by accident). The launcher
references its target by absolute path, so renaming the launcher stays safe.

The guards were right on every run; they were reporting a genuinely broken
artifact, not a flaky check.
2026-08-20 07:02:22 +00:00
enricobuehler 6007bc42cd docs(client-settings): the console-off switch is in both places on Android, and neither row is offered on a TV
The Interface section owned two facts this changes: which surfaces carry
"Gamepad-optimized browsing" (the Android console home carries it now, so
that home can be left from inside it) and what an Android TV sees — the
mode row used to be described as present-but-inert there, and both rows are
simply absent now.
2026-08-20 08:40:59 +02:00
enricobuehler 16d54b73a1 feat(console-ui): the console can switch itself off, where there is a touch UI to switch off to
The Android console's settings screen had a picker for WHEN it fronts the
app but no switch for WHETHER it does — that lived only in the touch
settings, which a user already inside the console has no way to reach.
The console UI became the only UI on Android, so the way back out was a
setting you could only change from the thing you were trying to get to.

Adds the "Controller-optimized UI" row over the same `gamepadUiEnabled`
the touch switch writes, folded through `extra` under
`android.gamepad_ui_enabled` like the other Android-only rows. Off
returns to the touch home immediately: the console's save bumps the
snapshot generation, the host emits its settings event, and Kotlin's
`applySettings` now folds the flag back into the App state that
`gamepadUiActive` reads.

Shown only where "off" has somewhere to land. `ConsoleOptions.fallback_ui`
(new, threaded to `Ctx` beside `deck`) is true only for the Android touch
shell — false on a TV and for the desktop session, where this console IS
the interface and an off switch would strand the user in nothing.

The mode row follows the same rule and moves under it: it decides nothing
on a TV (the tv term satisfies `gamepadUiActive`'s OR on its own) or while
the switch is off, so it is hidden in both cases rather than offered as a
control that changes nothing. Renamed to the touch screen's own word for
it, "Show it" — "Controller UI" directly under "Controller-optimized UI"
was two rows told apart by their tails.
2026-08-20 08:40:59 +02:00
enricobuehler d60b1dda29 fix(nix): split the +pfhdr failure into its two possible stages
The marker is not in the installed ELF, and patch 0005 applies cleanly to a
src/meson.build that still carries the exact line it rewrites. Those two facts
cannot both be reasoned from the log as it stands, so stop guessing and
instrument the two stages separately:

  * postPatch now asserts +pfhdr is in src/meson.build after our vcs_tag
    substitution — the same gate build-punktfunk-gamescope.sh applies. If it
    fires, patch 0005 or the substitution lost the marker, and it prints the
    version block as patched.
  * the installCheck failure branch now prints evidence instead of an
    assertion: $out/bin, anything under $out mentioning pfhdr, and the
    version-shaped strings actually present in the binary.

Whichever fires, the next run says which stage drops it. Each guess here costs
a full compositor build, and I have now spent three on this one symptom.
2026-08-20 06:39:28 +00:00
enricobuehler 675030935a fix(nix): assert +pfhdr in the installed binary, not by running --version
`--version` produces EMPTY output under nix's build sandbox — measured on BOTH
nixpkgs' 3.16.25 and the pinned 5fb8dce4 (runs 19551 / 19573 / 19594). It is a
sandbox property, not a defect: gamescope calls PrintVersion() before the getopt
loop (src/main.cpp:721), so `gamescope --version` does print the banner on a
real system, which is what the host's capability probe reads. My earlier claim
that the probe itself was broken was wrong.

packaging/gamescope/build-punktfunk-gamescope.sh already avoids running the
binary, asserting on src/meson.build instead. Grepping the installed ELF is
strictly stronger than either: the version string reaches .rodata via
GamescopeVersion.h's k_szGamescopeVersion, so this proves the marker survived
patching, meson configuration and compilation into the artifact we ship — and it
cannot be defeated by the binary being unable to start.

The src pin from the previous commit stays. It is still right for its own
reason: the patches are authored against 5fb8dce4, nixpkgs' 3.16.24 could not
take patch 0009 at all, and every other channel ships this exact commit.
2026-08-20 06:21:37 +00:00
enricobuehler 0e5a059098 fix(nix): pin gamescope's src to 5fb8dce4, like every other channel
nix was the ONLY channel not pinning the compositor — the RPM spec, the
PKGBUILD, build-punktfunk-gamescope.sh and packaging/gamescope/README.md all
ship 5fb8dce4 (3.16.25-11), while gamescope.nix patched whatever version
nixpkgs happened to carry. That produced two failures in two days, both the
same bug:

  * nixpkgs shipped 3.16.24, where patch 0009's context does not exist, so the
    build died at patchPhase. host.gamescopeHdr defaults true, so every
    `services.punktfunk.host.enable = true` failed the same way.
  * bumping the lock to 3.16.25 fixed the patches, and then --version printed
    NOTHING. Upstream's `gamescope::PrintVersion()` landed AFTER the 3.16.25
    tag; in that tag `--version` is `return 0;` with the comment "We always
    print the version to stderr anyway". The host reads that banner to decide a
    session's bit depth and cursor compositing BEFORE the virtual display
    exists, so a silent banner is a silent fall back to SDR.

The installCheck was right to fail — this was not a check problem.

Also in this commit: the +pfhdr guard now prints the banner it read. That is
how the empty output was identified at all; without it the failure is
indistinguishable from the binary not starting, at ~15 min per guess.

vcs_tag now substitutes the PINNED version (old.version is the pre-override
attr and would still say 3.16.25).

nix-instantiate --parse clean. Hash from nix-prefetch-git --fetch-submodules.
2026-08-19 23:16:18 +00:00
enricobuehler 245173a731 fix(nix): make the +pfhdr guard show the banner it actually read
The memory raise got gamescope building, and it now reaches installCheckPhase
and fails there:

    punktfunk-gamescope: the +pfhdr marker is missing — the patches did not take

Patch 0005 applied cleanly to src/meson.build in that same run, so the message
is misleading: the patch DID take. Two very different causes are
indistinguishable from the log as written —

  * the binary never ran (shrunk RPATH, missing loader dep), so --version
    printed nothing at all; or
  * it ran and printed a version without +pfhdrN, meaning upstream no longer
    builds the banner from VCS_TAG.

A guard that reports 'missing' without showing what it read cannot be acted on,
and each guess costs a full compositor build. Capture the output and print it on
failure, with a note on how to read the two cases apart.

No behaviour change: same assertion, same exit. Also verified in this run: the
chmod fix works (prune, rename and layer install all completed) and the WSI
layer is installed.
2026-08-19 22:46:07 +00:00
70 changed files with 3123 additions and 254 deletions
+49
View File
@@ -537,6 +537,55 @@ legs as follow-ups. Both landed here.
ring layer's line shape; `nativeRenderLogs(header)` hands Kotlin the rendered bundle, and the
upload rides the client's own mTLS.
### A provider plugin can report which of its titles are **running**
New: `PUT /api/v1/library/provider/{provider}/running`, body
`{"running":[{"external_id":"…","pid":1234}]}` — the **live** counterpart to the static `detect`
hints a reconcile carries. `detect` says *how to recognize* a title's process; this says *it is
running now*, and carries the pid where the provider knows one. Additive: no existing route,
payload or behaviour changes, and a host with no reporting plugin behaves exactly as before.
It exists because one class of title could never be tracked at all. The host derives liveness by
scanning (`procscan` + `DetectSpec`), which needs something recognizable on disk — an install
directory, an executable, a Steam reaper. A Playnite-launched emulated game, a manually added one,
or a library plugin that records no install directory has none of that, and its launch is a
`playnite://` hand-off, so the host holds no process either: the lease went `Untracked`, its exit
was never noticed, `session_on_game_exit` could not fire, and `POST /game/end` had nothing to aim
at. Playnite knew the whole time — it starts the game, tracks it in the mode the person configured,
and fires an event on both edges carrying the pid. That was being thrown away.
- **Declarative and idempotent**, like the reconcile beside it: the body is the provider's
**complete** running set, so a missed event, a plugin restart or an install mid-game self-correct
on the next report instead of drifting. Absent from the set = stopped.
- **Reports expire** (`crate::runstate::REPORT_TTL`, 90 s; the answer carries `ttl_s`). This is what
makes it safe for a live provider to hold a streaming session open for a game the host cannot
see: a plugin that dies with a game running stops counting shortly after and the host falls back
to scanning. Reporters must restate well inside the window.
- **New `gamelease::LeaseKind::Reported`** — a lease with no process signal of its own, tracked by
what its provider says. `open` reaches it when the spec is empty and a provider speaks for the id;
the shim-reclassification paths (every Windows launch is a hand-off by construction) fall back to
it too, where they previously fell to `Untracked`. Phase 1 accepts "running" as the game
appearing; phase 2 treats "stopped" as the exit, and — unlike `procscan::running_hint`, which may
only ever *delay* an exit because Steam's registry flag survives an unclean exit — a fresh
provider report is decisive in both directions. A reported pid joins the termination ladders on
the same terms as a spawned one (re-resolved and start-time-pinned at the moment of use).
- **Route authority**: the plugin lane, like the reconcile (`mgmt::auth::plugin_may_access`, and its
exhaustive classification table). No new authority — the host maps `external_id` through the
catalog, so a provider can only ever speak about entries it published; an unknown id is *counted*,
not refused, because a report legitimately races its own reconcile and 400-ing the batch would
throw away the liveness of every other running title.
- **`@punktfunk/plugin-kit`: `ProviderClient.reportRunning(providerId, running)`**, returning
`{matched, unknown, ttlS}`; a 404 from an older host means "this host tracks games by scanning".
Version bumped to **0.4.4** — **unpublished, `plugin-kit-v0.4.4` owed.**
The Playnite half lives in `punktfunk-plugin-playnite` (**0.4.5**, exporter **0.4.0**): the C#
exporter hooks Playnite's `OnGameStarted`/`OnGameStopped`/`OnGameStartupCancelled` and writes a
small `punktfunk-running.json` beside the library export, re-stamped every 30 s and *deleted* when
Playnite closes; the plugin polls it and restates the set to this route. It calls the route through
the kit's untyped host seam rather than `reportRunning`, deliberately — depending on the method
would make that repo unbuildable until the kit publishes, for the same request. Needs a host
carrying this route; an older one 404s and the plugin carries on without it.
### Everything else an integrator might notice
- **`mgmt-endpoint` is followed everywhere.** `PUNKTFUNK_MGMT_BIND` moved off 47990 left every plugin,
+125
View File
@@ -1860,6 +1860,69 @@
}
}
},
"/api/v1/library/provider/{provider}/running": {
"put": {
"tags": [
"library"
],
"summary": "Report which of a provider's titles are running",
"description": "The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to\nrecognize* a title's process, this one says *it is running now* (design §9,\n[`crate::runstate`]). For a provider that starts games itself and knows when they stop —\nPlaynite tracks every launch and fires an event on both edges — this is a fact the host would\notherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated\ngame, a manually added one) could not derive at all.\n\nDeclarative and idempotent, like the reconcile: the body is the provider's **complete** running\nset, so a missed event, a plugin restart or an install mid-game all self-correct on the next\nreport rather than drifting.\n\nThe report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a\nlive provider to keep a streaming session open for a game the host cannot see: a plugin that\ndies with a game running stops counting shortly after, and the host falls back to process\nscanning exactly as it does without one. Re-report on every change **and** on a timer well\ninside the window.\n\nTitles the provider does not currently publish are ignored (counted in `unknown`), not an error:\na report may legitimately race its own reconcile.",
"operationId": "reportProviderRunning",
"parameters": [
{
"name": "provider",
"in": "path",
"description": "The provider id ([a-z0-9._-], `manual` reserved)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProviderRunningInput"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "The report was accepted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProviderRunningAccepted"
}
}
}
},
"400": {
"description": "Invalid provider id or payload",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/library/scanners": {
"get": {
"tags": [
@@ -7792,6 +7855,46 @@
}
}
},
"ProviderRunningAccepted": {
"type": "object",
"description": "The result of a liveness report.",
"required": [
"matched",
"unknown",
"ttl_s"
],
"properties": {
"matched": {
"type": "integer",
"description": "How many reported titles matched an entry this provider currently publishes.",
"minimum": 0
},
"ttl_s": {
"type": "integer",
"format": "int64",
"description": "Seconds this report stays authoritative without being restated — re-report inside it while\nanything is running.",
"minimum": 0
},
"unknown": {
"type": "integer",
"description": "How many were ignored because no such entry exists (a report that raced a reconcile).",
"minimum": 0
}
}
},
"ProviderRunningInput": {
"type": "object",
"description": "Request body for `reportProviderRunning`.",
"properties": {
"running": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RunningTitle"
},
"description": "Every title of this provider's that is running **right now**. The full set, not a delta:\nanything absent from it is reported as stopped."
}
}
},
"ReleaseDisplayRequest": {
"type": "object",
"description": "Request body for `releaseDisplay`.",
@@ -7846,6 +7949,28 @@
}
}
},
"RunningTitle": {
"type": "object",
"description": "One running title in a provider's liveness report.",
"required": [
"external_id"
],
"properties": {
"external_id": {
"type": "string",
"description": "The provider's own stable id for the title — the same key its reconcile payload uses."
},
"pid": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "The process id the provider started for it, when it knows one. Optional, and never trusted\nas a bare number: the host re-resolves it and pins it to its start time before it is ever\nsignalled, so a stale or recycled pid simply contributes nothing.",
"minimum": 0
}
}
},
"RuntimeRequest": {
"type": "object",
"required": [
@@ -96,7 +96,11 @@ internal fun ControllersScreen(
InputDevice.getDeviceIds()
.toList()
.mapNotNull { InputDevice.getDevice(it) }
.filter { !it.isVirtual && !Gamepad.isPad(it) }
// Everything real that is NOT counted as a controller — including a device that claims
// a pad source with no pad hardware behind it, which the Gamepads list above now
// rejects. One list or the other, never neither: this screen is where someone looks
// when the client's idea of "a pad is attached" disagrees with the room.
.filter { !it.isVirtual && !Gamepad.looksLikeController(it) }
}
DisposableEffect(Unit) {
val im = context.getSystemService(InputManager::class.java)
@@ -136,14 +140,19 @@ internal fun ControllersScreen(
// Read ONCE, up front: the test can end inside this very event, and the release that
// ended it still has to be swallowed here — see the B branch below.
val consume = consuming
// The CORRECTED keycode, so this screen shows the button the stream will send and not
// the one Android guessed for a pad it has no key layout for — the two differ on every
// controller [Gamepad.padKeyCode] exists for, and a tester that disagrees with the
// stream is worse than no tester. The raw pair is still reported in "Last input".
val code = Gamepad.padKeyCode(event)
when (event.action) {
KeyEvent.ACTION_DOWN -> {
held[event.keyCode] = true
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) bHeld = true
held[code] = true
if (code == KeyEvent.KEYCODE_BUTTON_B) bHeld = true
}
KeyEvent.ACTION_UP -> {
held[event.keyCode] = false
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) {
held[code] = false
if (code == KeyEvent.KEYCODE_BUTTON_B) {
bHeld = false
if (consume) {
if (event.eventTime - event.downTime >= HOLD_TO_FINISH_MS) {
@@ -167,23 +176,43 @@ internal fun ControllersScreen(
}
}
}
lastInput = "${event.device?.name}: ${KeyEvent.keyCodeToString(event.keyCode)}"
// Raw scancode AND keycode, plus the correction when one fired: this line is what a
// field report needs to pin an unmapped pad's report order without the device in hand.
val raw = KeyEvent.keyCodeToString(event.keyCode).removePrefix("KEYCODE_")
val fixed = KeyEvent.keyCodeToString(code).removePrefix("KEYCODE_")
lastInput = "${event.device?.name}: scan 0x%X · %s%s".format(
event.scanCode,
raw,
if (code != event.keyCode) "$fixed" else "",
)
consume
}
val motionProbe: (MotionEvent) -> Boolean = probe@{ event ->
if (!Gamepad.isPad(event.device)) return@probe false
// Through the device's resolved map, exactly as `Gamepad.AxisMapper` reads it while
// streaming — on a pad Android has no key layout for, the right stick and the triggers
// are not on the axes their names suggest.
val map = Gamepad.padMap(event.device)
axes["LX"] = event.getAxisValue(MotionEvent.AXIS_X)
axes["LY"] = event.getAxisValue(MotionEvent.AXIS_Y)
axes["RX"] = event.getAxisValue(MotionEvent.AXIS_Z)
axes["RY"] = event.getAxisValue(MotionEvent.AXIS_RZ)
axes["LT"] = maxOf(
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
event.getAxisValue(MotionEvent.AXIS_BRAKE),
)
axes["RT"] = maxOf(
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
event.getAxisValue(MotionEvent.AXIS_GAS),
)
axes["RX"] = event.getAxisValue(map.rightStickX)
axes["RY"] = event.getAxisValue(map.rightStickY)
axes["LT"] = if (map.leftTrigger == Gamepad.AXIS_NONE) {
maxOf(
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
event.getAxisValue(MotionEvent.AXIS_BRAKE),
)
} else {
map.level(event.getAxisValue(map.leftTrigger))
}
axes["RT"] = if (map.rightTrigger == Gamepad.AXIS_NONE) {
maxOf(
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
event.getAxisValue(MotionEvent.AXIS_GAS),
)
} else {
map.level(event.getAxisValue(map.rightTrigger))
}
axes["HX"] = event.getAxisValue(MotionEvent.AXIS_HAT_X)
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
consuming
@@ -689,6 +718,16 @@ private fun PadRow(info: PadInfo, gamepadSetting: Int) {
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Only when a correction is actually in force: on a pad Android has a key layout for
// there is nothing to say, and a line that says "normal" on every device teaches
// nobody anything. Named rather than merely flagged, so a field report can quote it.
padButtonsNote(info.buttons)?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (info.canRumble) {
OutlinedButton(onClick = { info.dev?.let(::testRumble) }) { Text("Test rumble") }
} else {
@@ -784,6 +823,12 @@ internal data class PadInfo(
val controllerNumber: Int,
val resolvedPref: Int,
val canRumble: Boolean,
/**
* The report order this pad's buttons were resolved to ([Gamepad.padButtons]). Defaults to
* the pad Android already knows, which is what a screenshot scene wants and what the note
* under the card stays silent about.
*/
val buttons: Gamepad.PadButtons = Gamepad.PadButtons.NATIVE,
val dev: InputDevice? = null,
)
@@ -793,6 +838,7 @@ internal fun padInfoOf(dev: InputDevice): PadInfo = PadInfo(
forwarded = isForwarded(dev),
controllerNumber = dev.controllerNumber,
resolvedPref = Gamepad.prefFor(dev),
buttons = Gamepad.padMap(dev).buttons, // via padMap so the list refresh reuses the cache
canRumble = deviceHasVibrator(dev),
dev = dev,
)
@@ -823,6 +869,20 @@ internal fun testRumble(dev: InputDevice) {
}
/** Identity line: VID:PID + the source classes Android assigned. */
/**
* What to say about a pad whose buttons had to be resolved from their scancodes because Android
* has no key layout for it — null for a pad it does know, which needs no explanation.
*/
private fun padButtonsNote(buttons: Gamepad.PadButtons): String? = when (buttons) {
Gamepad.PadButtons.NATIVE -> null
Gamepad.PadButtons.GENERIC_SONY ->
"Android has no button layout for this controller — read as a PlayStation pad"
Gamepad.PadButtons.GENERIC_XBOX ->
"Android has no button layout for this controller — read as an Xbox pad"
Gamepad.PadButtons.SONY_MODERN ->
"Android has no button layout for this controller — face buttons corrected"
}
private fun deviceDetail(dev: InputDevice): String =
"%04X:%04X · %s".format(dev.vendorId, dev.productId, sourcesLabel(dev.sources))
@@ -11,6 +11,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalContext
import io.unom.punktfunk.kit.Gamepad
import kotlin.math.abs
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
@@ -96,7 +97,7 @@ fun GamepadNavEffect(
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
val down = ev.action == KeyEvent.ACTION_DOWN
val edge = down && ev.repeatCount == 0
when (ev.keyCode) {
when (Gamepad.padKeyCode(ev)) {
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
// TV remote (no face buttons): Up → Settings, Down → a saved host's Options.
@@ -202,7 +203,7 @@ fun GamepadNavEffect2D(
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
val down = ev.action == KeyEvent.ACTION_DOWN
val edge = down && ev.repeatCount == 0
when (ev.keyCode) {
when (Gamepad.padKeyCode(ev)) {
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
KeyEvent.KEYCODE_DPAD_UP -> { state.dpadY = if (down) -1 else 0; true }
@@ -616,7 +616,7 @@ class MainActivity : ComponentActivity() {
// no BUTTON_SELECT scancode delivers its Select: see [Gamepad.padButtonBit], which is
// why this asks it rather than `buttonBit`).
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
val bit = Gamepad.padButtonBit(event.keyCode, event.flags)
val bit = Gamepad.padButtonBit(Gamepad.padKeyCode(event), event.flags)
if (bit != 0) {
// The router forwards the bit on this device's own wire pad index and tracks held
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
@@ -708,8 +708,10 @@ class MainActivity : ComponentActivity() {
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
// buttons to the navigation the focus system / back stack understand; D-pad *keys*
// already move focus on their own, so they fall through to super untouched.
when (event.keyCode) {
// already move focus on their own, so they fall through to super untouched. Read
// through [Gamepad.padKeyCode] so a pad Android has no key layout for reaches the
// menus on the right buttons too, not only the stream.
when (Gamepad.padKeyCode(event)) {
// B → back. Drive the OnBackPressedDispatcher directly rather than synthesising a
// BACK KeyEvent: a synthetic event isn't "tracking", so the framework's default
// onKeyUp(BACK) never calls onBackPressed() and Compose BackHandlers wouldn't fire.
@@ -940,6 +940,17 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
// The view's CURRENT pixel size, for the ASurfaceControl layer's
// destination rect. It is reported here and not only at
// surfaceCreated because the view grows a frame or two after the
// stream screen appears — hiding the system bars and switching on
// cutout drawing both resize it, and neither recreates the surface.
// A layer left on the start-up rect paints the picture small, in the
// top-left corner. The view's own size, not the buffer geometry in
// `width`/`height`: the layer composites in the view's space.
NativeBridge.nativeVideoSurfaceSize(
handle, this@apply.width, this@apply.height,
)
// Re-assert the frame-rate vote: a buffer-geometry change can reset
// the surface's frame-rate setting on some OEM builds, silently
// dropping the 120 Hz pin mid-stream. Mirrors the native hint's
@@ -325,6 +325,7 @@ internal object ConsoleJson {
extra.put("android.sc2_capture", s.sc2Capture)
extra.put("android.ds_capture", s.dsCapture)
extra.put("android.gamepad_ui_mode", s.gamepadUiMode)
extra.put("android.gamepad_ui_enabled", s.gamepadUiEnabled)
j.put("extra", extra)
return j
}
@@ -379,6 +380,7 @@ internal object ConsoleJson {
dsCapture = extra.optBoolean("android.ds_capture", s.dsCapture),
gamepadUiMode = extra.optString("android.gamepad_ui_mode", s.gamepadUiMode)
.ifEmpty { s.gamepadUiMode },
gamepadUiEnabled = extra.optBoolean("android.gamepad_ui_enabled", s.gamepadUiEnabled),
)
}
}
@@ -159,6 +159,9 @@ object SkiaConsole {
val opts = JSONObject()
.put("device_name", deviceName(app))
.put("gpu_cache_bytes", gpuCacheBytes(app))
// The touch shell exists as a fallback on phones/tablets but not on a TV —
// gates the console's own "Controller-optimized UI" off switch.
.put("fallback_ui", !io.unom.punktfunk.isTvDevice(app))
.put("settings", ConsoleJson.settings(initial, base))
.put("profiles", JSONArray(ConsoleJson.profiles(profiles)))
.put("known_hosts", JSONObject(ConsoleJson.knownHosts(knownHostStore.all())))
@@ -159,7 +159,12 @@ fun SkiaConsoleShell(
if (ev.action != KeyEvent.ACTION_DOWN && ev.action != KeyEvent.ACTION_UP) return@probe false
val fromPad = ev.isFromSource(InputDevice.SOURCE_GAMEPAD)
if (fromPad) {
val bit = when (ev.keyCode) {
// The CORRECTED keycode: a pad Android has no key layout for delivers its buttons
// under other buttons' names, so read raw this console answered ✕ with whatever
// sat in BUTTON_A's scancode slot. Same resolution the stream uses — the console
// and the game must not disagree about which button a user pressed.
val code = Gamepad.padKeyCode(ev)
val bit = when (code) {
KeyEvent.KEYCODE_BUTTON_A -> 0
KeyEvent.KEYCODE_BUTTON_B -> 1
KeyEvent.KEYCODE_BUTTON_X -> 2
@@ -179,7 +184,7 @@ fun SkiaConsoleShell(
}
return@probe true
}
val dbit = when (ev.keyCode) {
val dbit = when (code) {
KeyEvent.KEYCODE_DPAD_UP -> 0
KeyEvent.KEYCODE_DPAD_DOWN -> 1
KeyEvent.KEYCODE_DPAD_LEFT -> 2
@@ -191,7 +196,7 @@ fun SkiaConsoleShell(
padState.push(handle)
return@probe true
}
if (ev.keyCode == KeyEvent.KEYCODE_BUTTON_SELECT && down && ev.repeatCount == 0) {
if (code == KeyEvent.KEYCODE_BUTTON_SELECT && down && ev.repeatCount == 0) {
NativeBridge.nativeConsoleMenu(handle, 0) // ▲ opens the tile's options on Home
return@probe true
}
@@ -3,6 +3,7 @@ package io.unom.punktfunk.kit
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.roundToInt
/**
@@ -127,8 +128,12 @@ object Gamepad {
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
// The Bluetooth revisions (0x02E0/0x02FD Xbox One S, 0x0B05/0x0B22 Elite Series 2 and its
// Core) are here for the same reason as the wired ones: they are the pads a couch actually
// pairs to a TV box, and without them an Elite streams under the Xbox 360 identity.
private val PID_XBOXONE = setOf(
0x02D1, 0x02DD, 0x02E3, 0x02EA, 0x0B00, 0x0B12, 0x0B13, 0x0B20,
0x02D1, 0x02DD, 0x02E0, 0x02E3, 0x02EA, 0x02FD,
0x0B00, 0x0B05, 0x0B12, 0x0B13, 0x0B20, 0x0B22,
)
/**
@@ -188,9 +193,53 @@ object Gamepad {
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
}
/** All connected gamepad/joystick [InputDevice]s, in system enumeration order. */
fun pads(): List<InputDevice> =
InputDevice.getDeviceIds().toList().mapNotNull { InputDevice.getDevice(it) }.filter { isPad(it) }
/**
* True when [dev] is a controller someone can actually hold: a pad source ([isPad]) that is a
* REAL device carrying real pad hardware — a stick, a HAT, or the A/B face buttons.
*
* [isPad] alone answers "did this event come from a pad source", which is the right question
* for ROUTING an event and the wrong one for "is a controller attached". Devices publish
* inputs that claim `SOURCE_GAMEPAD`/`SOURCE_JOYSTICK` while being no such thing — OEM
* game-mode overlays and the gaming-phone shoulder triggers among them — and one of those is
* enough to pin the console UI on forever: a pad that was never there cannot disconnect, so
* "With a controller" has no way back to the touch UI.
*
* The capability probe is what separates them: a source class is a claim, a stick or a face
* button is hardware. It is not a complete defence — an OEM device that declares `BTN_GAMEPAD`
* and a pair of axes is indistinguishable from a pad at this layer — so the master switch stays
* the guaranteed way out. `isVirtual` only means "device id < 0" (the platform's own synthetic
* device), which is worth excluding but catches none of the above.
*/
fun looksLikeController(dev: InputDevice?): Boolean {
val d = dev ?: return false
return looksLikeController(
padSource = isPad(d),
virtual = d.isVirtual,
hasStick = d.getMotionRange(MotionEvent.AXIS_X, InputDevice.SOURCE_JOYSTICK) != null ||
d.getMotionRange(MotionEvent.AXIS_HAT_X, InputDevice.SOURCE_JOYSTICK) != null,
// `hasKeys` answers for the DEVICE, so a pad with no sticks at all (an arcade stick,
// a d-pad-only pad) still counts.
hasFaceButtons = d.hasKeys(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B)
.any { it },
)
}
/** [looksLikeController]'s decision, over plain facts — the seam its truth table is tested at
* (an [InputDevice] cannot be built off a device). */
fun looksLikeController(
padSource: Boolean,
virtual: Boolean,
hasStick: Boolean,
hasFaceButtons: Boolean,
): Boolean = padSource && !virtual && (hasStick || hasFaceButtons)
/**
* All connected controllers, in system enumeration order — the devices that answer "is a pad
* attached", so the filter is [looksLikeController] rather than the looser [isPad].
*/
fun pads(): List<InputDevice> = InputDevice.getDeviceIds().toList()
.mapNotNull { InputDevice.getDevice(it) }
.filter { looksLikeController(it) }
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
fun firstPad(): InputDevice? = pads().firstOrNull()
@@ -293,6 +342,334 @@ object Gamepad {
else -> BTN_BACK
}
// ---------------------------------------------------------------------------------------
// Controllers Android has no key layout for
//
// Android turns a pad's raw evdev scancode into a `KeyEvent.keyCode` through a KEY LAYOUT
// file matched on USB VID/PID (`Vendor_054c_Product_0ce6.kl` & co.). A pad with no matching
// file falls back to AOSP's `Generic.kl`, which assigns keycodes by SCANCODE POSITION —
// `0x130`→BUTTON_A, `0x131`→BUTTON_B, `0x132`→BUTTON_C, and so on up. That is only right if
// the pad's buttons happen to sit at the positions the file assumes, and a HID gamepad with
// no kernel driver behind it numbers its buttons 1..n straight through IN ITS OWN REPORT
// ORDER — so every keycode after the first divergence is somebody else's button.
//
// Reported from a Fire TV Stick 4K Max (2026-08-20): a DualSense and an Xbox Elite Series 2,
// both over Bluetooth, both identified correctly but with buttons landing on the wrong
// actions ("L1 being L2"). Neither has a layout there — AOSP ships none for the Elite
// Series 2 over Bluetooth (`045e:0b05`) on ANY version, and the DualSense's
// (`054c:0ce6`) both postdates Fire OS and carries `requires_kernel_config
// CONFIG_HID_PLAYSTATION`, which a Fire TV kernel does not have. A DualSense reporting
// straight through puts L2 on `0x136`, which `Generic.kl` calls BUTTON_L1: the reported
// symptom exactly.
//
// The fix is to resolve buttons from the SCANCODE, which is the pad's own report position and
// is immune to the layout file — the same reason [Keymap.toVk] reads `scanCode` for keyboards.
// Two things keep it from breaking a pad that already works:
//
// 1. Nothing is corrected on a pad that names its triggers ([padButtons]). A descriptor
// well-formed enough to call them Accelerator/Brake puts its buttons at the standard
// positions too, and that is the fact — not the model — that separates the two firmwares
// of the SAME Xbox pad, only the older of which needs any of this.
// 2. Past that gate the correction still applies ONLY where the delivered keycode is what
// `Generic.kl` would have said ([genericKeyCode]). A different keycode means a
// device-specific layout IS in force and knows this pad better than we do.
//
// Moonlight carries the same two tables AND the same gate (`ControllerHandler`'s
// `isNonStandardDualShock4` / `isNonStandardXboxBtController`, the latter on `gasRange == null`),
// which is why both pads work there on the same box.
//
// The first cut of this asked `hasKeys(BUTTON_C, BUTTON_Z)` on its own, on the reasoning that a
// pad numbering straight through reaches keycodes no controller has a button for. It does — but
// so does every pad that merely DECLARES six buttons, because `hid-input` allocates `BTN_A + n`
// straight through for the whole descriptor whether or not the pad ever presses them. That fired
// the correction on pads Android was already reading correctly (2026-08-21: an Xbox pad
// answering X with Y, Y with LB, and both shoulders with a menu button), and it could not have
// done otherwise: the signal is identical on the firmware that needs correcting and the one that
// does not. Declaration is not report order. Only the axes tell them apart.
/** [MotionEvent] axis id meaning "this pad has no such axis" — see [PadMap]. */
const val AXIS_NONE = -1
/**
* The report order a controller's buttons are numbered in, and with it which scancode carries
* which physical button. Resolved once per device by [padButtons] from what the device
* declares; [correct] then maps one scancode to the keycode it should have produced.
*/
enum class PadButtons {
/**
* The keycode Android delivered is already right — a device-specific key layout is in
* force, or the generic one happens to agree. [correct] changes nothing.
*/
NATIVE,
/**
* A Sony pad numbering straight through with no kernel driver behind it: □ ✕ ○ △ L1 R1
* L2 R2 Create Options L3 R3 PS, i.e. `0x130`..`0x13c` in that order. The analog trigger
* value rides `AXIS_RX`/`AXIS_RY` on such a pad, so the digital L2/R2 fold to keycodes
* [buttonBit] deliberately drops — the wire carries the axis, never both.
*/
GENERIC_SONY,
/**
* An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS, i.e.
* `0x130`..`0x139`. Also the fallback for an unbranded pad, which near-universally
* clones the Xbox layout — the same assumption [styleFor] makes for its glyphs.
*/
GENERIC_XBOX,
/**
* A Sony pad WITH a kernel driver (`hid-playstation` / `hid-sony`) but still no key
* layout — the combination an Android 11 box on a 5.10 kernel lands in. Such a driver
* emits the modern Linux gamepad codes, where `0x133` is BTN_NORTH (△) and `0x134` is
* BTN_WEST (□); `Generic.kl` reads those two as BUTTON_X and BUTTON_Y, so exactly the
* face pair comes out swapped and nothing else is wrong.
*/
SONY_MODERN,
;
/**
* The keycode scancode [scan] should have produced, given Android delivered [keyCode].
*
* Returns [keyCode] untouched unless it is precisely what [genericKeyCode] would have
* said for [scan] — anything else is a device-specific layout's answer, which outranks
* this table. That guard is what makes the correction idempotent and safe to run on
* every pad: it can only ever fire where Android was guessing in the first place.
*/
fun correct(scan: Int, keyCode: Int): Int {
if (this == NATIVE) return keyCode
if (keyCode != genericKeyCode(scan)) return keyCode
val fixed = when (this) {
GENERIC_SONY -> when (scan) {
0x130 -> KeyEvent.KEYCODE_BUTTON_X // □
0x131 -> KeyEvent.KEYCODE_BUTTON_A // ✕
0x132 -> KeyEvent.KEYCODE_BUTTON_B // ○
0x133 -> KeyEvent.KEYCODE_BUTTON_Y // △
0x134 -> KeyEvent.KEYCODE_BUTTON_L1
0x135 -> KeyEvent.KEYCODE_BUTTON_R1
0x136 -> KeyEvent.KEYCODE_BUTTON_L2 // analog: AXIS_RX
0x137 -> KeyEvent.KEYCODE_BUTTON_R2 // analog: AXIS_RY
0x138 -> KeyEvent.KEYCODE_BUTTON_SELECT // Create / Share
0x139 -> KeyEvent.KEYCODE_BUTTON_START // Options
0x13a -> KeyEvent.KEYCODE_BUTTON_THUMBL
0x13b -> KeyEvent.KEYCODE_BUTTON_THUMBR
0x13c -> KeyEvent.KEYCODE_BUTTON_MODE // PS
// 0x13d touchpad click / 0x13e mute: no wire button, dropped as before.
else -> KeyEvent.KEYCODE_UNKNOWN
}
GENERIC_XBOX -> when (scan) {
0x132 -> KeyEvent.KEYCODE_BUTTON_X
0x133 -> KeyEvent.KEYCODE_BUTTON_Y
0x134 -> KeyEvent.KEYCODE_BUTTON_L1
0x135 -> KeyEvent.KEYCODE_BUTTON_R1
0x136 -> KeyEvent.KEYCODE_BUTTON_SELECT // View
0x137 -> KeyEvent.KEYCODE_BUTTON_START // Menu
0x138 -> KeyEvent.KEYCODE_BUTTON_THUMBL
0x139 -> KeyEvent.KEYCODE_BUTTON_THUMBR
else -> keyCode // 0x130 A / 0x131 B already agree
}
// Only the face pair; every other row of Generic.kl is right for these codes.
SONY_MODERN -> when (scan) {
0x133 -> KeyEvent.KEYCODE_BUTTON_Y // BTN_NORTH = △
0x134 -> KeyEvent.KEYCODE_BUTTON_X // BTN_WEST = □
else -> keyCode
}
NATIVE -> keyCode
}
return fixed
}
}
/**
* AOSP `Generic.kl`'s gamepad rows — the layout Android falls back to when no device-specific
* key layout matches the pad's VID/PID. Scancodes outside it answer [KeyEvent.KEYCODE_UNKNOWN],
* which never equals a real delivered keycode, so [PadButtons.correct]'s guard leaves those
* events alone.
*/
fun genericKeyCode(scan: Int): Int = when (scan) {
0x130 -> KeyEvent.KEYCODE_BUTTON_A
0x131 -> KeyEvent.KEYCODE_BUTTON_B
0x132 -> KeyEvent.KEYCODE_BUTTON_C
0x133 -> KeyEvent.KEYCODE_BUTTON_X
0x134 -> KeyEvent.KEYCODE_BUTTON_Y
0x135 -> KeyEvent.KEYCODE_BUTTON_Z
0x136 -> KeyEvent.KEYCODE_BUTTON_L1
0x137 -> KeyEvent.KEYCODE_BUTTON_R1
0x138 -> KeyEvent.KEYCODE_BUTTON_L2
0x139 -> KeyEvent.KEYCODE_BUTTON_R2
0x13a -> KeyEvent.KEYCODE_BUTTON_SELECT
0x13b -> KeyEvent.KEYCODE_BUTTON_START
0x13c -> KeyEvent.KEYCODE_BUTTON_MODE
0x13d -> KeyEvent.KEYCODE_BUTTON_THUMBL
0x13e -> KeyEvent.KEYCODE_BUTTON_THUMBR
else -> KeyEvent.KEYCODE_UNKNOWN
}
/**
* How one controller must be read: its button report order plus the axes its right stick and
* analog triggers actually arrive on. Resolved once per device by [padMap].
*/
class PadMap(
val buttons: PadButtons,
val rightStickX: Int = MotionEvent.AXIS_Z,
val rightStickY: Int = MotionEvent.AXIS_RZ,
/**
* The trigger axes, or [AXIS_NONE] for a pad Android already names them on — that case
* keeps folding LTRIGGER with BRAKE and RTRIGGER with GAS by max, which is what pads that
* report one pair, the other, or both have always needed.
*/
val leftTrigger: Int = AXIS_NONE,
val rightTrigger: Int = AXIS_NONE,
/** Those trigger axes rest at 1 rather than 0, measured off the device's own range. */
val triggersSigned: Boolean = false,
) {
/** One resolved trigger axis value, folded to the 0..1 the wire scale expects. */
fun level(v: Float): Float = if (triggersSigned) (v + 1f) / 2f else v
}
/** The map every pad with a key layout uses: Android's own names, unchanged. */
private val NATIVE_MAP = PadMap(PadButtons.NATIVE)
/**
* Resolved [PadMap]s, keyed by [InputDevice.getDescriptor] — the device's stable identity
* hash, so a pad that reconnects is recognised and a model resolves once for the process.
* Nothing here depends on a live connection, so entries never need evicting.
*/
private val padMaps = ConcurrentHashMap<String, PadMap>()
/**
* Which report order [dev]'s buttons follow — [namedTriggers] is whether the pad reports its
* triggers under a name Android knows (see [padMap]), and [declaresCZ] whether it declares
* BUTTON_C and BUTTON_Z.
*
* `namedTriggers` decides it, and a pad that has them is [PadButtons.NATIVE] whatever else it
* says. A HID gamepad describes its triggers either as the Accelerator/Brake usages, which
* become `ABS_GAS`/`ABS_BRAKE` and axis names Android has words for, or as two more generic
* axes on `ABS_Z`/`ABS_RZ`, which it does not — and a report descriptor well-formed enough to
* name its triggers puts its buttons at the standard positions too, the ones `Generic.kl`
* already reads correctly. It is the same fact Moonlight decides this on (`gasRange == null`
* beside the `"Xbox Wireless Controller"` name), and it is the one that separates the two
* firmwares of the SAME pad: an Xbox Wireless Controller over Bluetooth reports GAS/BRAKE
* after its firmware update and Z/Rz before it, and only the older one needs correcting.
*
* `declaresCZ` cannot make that call and must never be asked to. `hasKeys` answers for what a
* device DECLARES, not what it reports: `hid-input` allocates `BTN_A + n` straight through for
* every button in the descriptor, so BTN_C (`0x132`) and BTN_Z (`0x135`) are set on any pad
* declaring six or more — a standard-layout pad that never presses either included. Read alone
* it fired the correction on pads whose buttons were already right, which is how an Xbox pad
* came to answer X with Y and Y with LB (field reports, 2026-08-21). It stays as the narrower
* question it can answer — WHICH straight-through order, once `namedTriggers` has established
* there is one — where a false positive costs nothing.
*/
fun padButtons(dev: InputDevice, namedTriggers: Boolean): PadButtons {
val has = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_C, KeyEvent.KEYCODE_BUTTON_Z, 0)
return padButtons(namedTriggers, dev.vendorId == VID_SONY, declaresCZ = has[0] && has[1])
}
/** [padButtons]'s choice over plain facts — the seam its truth table is tested at (an
* [InputDevice] cannot be built off a device). */
fun padButtons(namedTriggers: Boolean, sony: Boolean, declaresCZ: Boolean): PadButtons = when {
namedTriggers -> PadButtons.NATIVE
declaresCZ && sony -> PadButtons.GENERIC_SONY
declaresCZ -> PadButtons.GENERIC_XBOX
sony -> PadButtons.SONY_MODERN
else -> PadButtons.NATIVE
}
/**
* The [PadMap] for [dev] — its button report order and the axes its right stick and triggers
* arrive on, resolved once per device model and cached.
*
* Axes get the same treatment as buttons: a pad Android has a layout for names its triggers
* LTRIGGER/RTRIGGER (or BRAKE/GAS, or BRAKE/THROTTLE) and is left exactly as it was. A pad
* with NONE of those names is one Android never mapped, and its triggers are sitting on two
* raw axes under the names the HID report gave them. Which two depends on the same report
* order the buttons did:
*
* - a Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry = left stick, right
* stick, then the triggers — so the right stick is already right and only the triggers
* (`AXIS_RX`/`AXIS_RY`) are missed;
* - every other such pad puts the right stick on Rx/Ry and the triggers on Z/Rz, which is
* the shape that makes pulling a trigger swing the right stick.
*
* Whether those axes idle at 1 is MEASURED from the device's own range rather than assumed,
* so a pad that reports an honest 0..1 is not rescaled to a permanent half-pull.
*/
fun padMap(dev: InputDevice?): PadMap {
if (dev == null) return NATIVE_MAP
padMaps[dev.descriptor]?.let { return it }
fun has(a: Int) = axis(dev, a) != null
val named = (has(MotionEvent.AXIS_LTRIGGER) && has(MotionEvent.AXIS_RTRIGGER)) ||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_GAS)) ||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_THROTTLE))
val buttons = padButtons(dev, namedTriggers = named)
val rx = axis(dev, MotionEvent.AXIS_RX)
val hasRxRy = rx != null && has(MotionEvent.AXIS_RY)
// Whichever pair the fallback is about to pick, ask THAT one where it rests.
val restsNegative = if (buttons == PadButtons.GENERIC_SONY) {
(rx?.min ?: 0f) < -0.5f
} else {
(axis(dev, MotionEvent.AXIS_Z)?.min ?: 0f) < -0.5f
}
val map = padMap(buttons, namedTriggers = named, hasRxRy = hasRxRy, restsNegative = restsNegative)
padMaps[dev.descriptor] = map
return map
}
/**
* The axis half of [padMap], decided from four facts about the device so it can be pinned
* without one — see `PadButtonsTest`. [namedTriggers] is whether the pad calls its triggers
* anything Android knows (LTRIGGER/RTRIGGER, BRAKE/GAS, BRAKE/THROTTLE); if it does, nothing
* here applies and the pad is read exactly as it always was. [restsNegative] is measured off
* whichever axis pair the fallback picks, never assumed.
*/
fun padMap(
buttons: PadButtons,
namedTriggers: Boolean,
hasRxRy: Boolean,
restsNegative: Boolean,
): PadMap = when {
namedTriggers || !hasRxRy -> PadMap(buttons)
// X, Y, Z, Rz, Rx, Ry = left stick, right stick, triggers. The sticks already read right.
buttons == PadButtons.GENERIC_SONY -> PadMap(
buttons,
leftTrigger = MotionEvent.AXIS_RX,
rightTrigger = MotionEvent.AXIS_RY,
triggersSigned = restsNegative,
)
// Right stick on Rx/Ry and triggers on Z/Rz — the shape in which reading Z/Rz as the
// right stick makes pulling a trigger swing it.
else -> PadMap(
buttons,
rightStickX = MotionEvent.AXIS_RX,
rightStickY = MotionEvent.AXIS_RY,
leftTrigger = MotionEvent.AXIS_Z,
rightTrigger = MotionEvent.AXIS_RZ,
triggersSigned = restsNegative,
)
}
/** [dev]'s range for one joystick [axis], under either source class a pad reports on. */
private fun axis(dev: InputDevice, axis: Int): InputDevice.MotionRange? =
dev.getMotionRange(axis, InputDevice.SOURCE_JOYSTICK)
?: dev.getMotionRange(axis, InputDevice.SOURCE_GAMEPAD)
/**
* The keycode [event] should have carried, given the controller it came from — [event]'s own
* keycode for every pad Android has a key layout for, and the scancode's true button for one
* it does not (see the block comment above [PadButtons]).
*
* A drop-in for `event.keyCode` at every gamepad reader: the console UI's navigation, the
* Controllers screen's tester, and the streaming branch all route through it, so a mis-mapped
* pad is fixed in the menus and in the game at once. Events from anything that is not a
* controller, and events with no scancode (soft keyboards, synthetic events), pass through
* untouched.
*/
fun padKeyCode(event: KeyEvent): Int {
val dev = event.device ?: return event.keyCode
if (event.scanCode == 0 || !isPad(dev)) return event.keyCode
return padMap(dev).buttons.correct(event.scanCode, event.keyCode)
}
/**
* Maps one controller's joystick MotionEvents to axis (+ HAT→dpad) sends on wire pad index [pad],
* **on change only**. Holds the previous axis/hat state so an unchanged frame emits nothing. One
@@ -306,7 +683,12 @@ object Gamepad {
* node (DualSense/DS4 motion sensors), which reports every pad axis as 0. [onMotion] therefore
* folds the event straight in without re-qualifying it.
*/
class AxisMapper(private val handle: Long, private val pad: Int) {
class AxisMapper(
private val handle: Long,
private val pad: Int,
/** Which axes this controller's right stick and triggers arrive on — see [padMap]. */
private val map: PadMap = NATIVE_MAP,
) {
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
private val last = IntArray(6) { Int.MIN_VALUE }
private var hatX = 0 // -1 / 0 / +1
@@ -317,30 +699,18 @@ object Gamepad {
// Sticks: Android floats 1..1, +y = down → ±32767, negate Y for the wire's +y = up.
sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X)))
sendAxis(AXIS_LS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_Y)))
sendAxis(AXIS_RS_X, stick(event.getAxisValue(MotionEvent.AXIS_Z)))
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_RZ)))
sendAxis(AXIS_RS_X, stick(event.getAxisValue(map.rightStickX)))
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(map.rightStickY)))
// Triggers: pads report LTRIGGER/RTRIGGER or BRAKE/GAS (some mirror both) — merge
// with max, the same fold as the Controllers screen probe, so a pad that reports
// only one pair and a pad that reports both behave identically; 0..1 → 0..255.
sendAxis(
AXIS_LT,
trigger(
maxOf(
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
event.getAxisValue(MotionEvent.AXIS_BRAKE),
),
),
)
sendAxis(
AXIS_RT,
trigger(
maxOf(
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
event.getAxisValue(MotionEvent.AXIS_GAS),
),
),
)
// only one pair and a pad that reports both behave identically; 0..1 → 0..255. A pad
// reporting NONE of those names is one Android has no key layout for, and [map]
// carries the raw axes its triggers really landed on instead.
val lt = resolved(event, map.leftTrigger, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE)
val rt = resolved(event, map.rightTrigger, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS)
sendAxis(AXIS_LT, trigger(lt))
sendAxis(AXIS_RT, trigger(rt))
// HAT → dpad button transitions. Android BATCHES joystick ACTION_MOVEs, so a rapid d-pad
// tap (press+release inside one batch window) lives only in the historical samples — the
@@ -383,6 +753,17 @@ object Gamepad {
hatY = 0
}
/**
* One trigger's 0..1 value: [resolvedAxis] when this pad needed one resolved for it,
* else the max of the two names Android gives a trigger it does know.
*/
private fun resolved(event: MotionEvent, resolvedAxis: Int, named: Int, alias: Int): Float =
if (resolvedAxis == AXIS_NONE) {
maxOf(event.getAxisValue(named), event.getAxisValue(alias))
} else {
map.level(event.getAxisValue(resolvedAxis))
}
private fun sendAxis(id: Int, v: Int) {
if (last[id] == v) return
last[id] = v
@@ -605,7 +605,7 @@ class GamepadRouter(
// for the slot's life; the sensor path reads it on every sample.
val slot = Slot(
index,
Gamepad.AxisMapper(handle, index),
Gamepad.AxisMapper(handle, index, Gamepad.padMap(dev)),
NativeBridge.nativePadMotionReaches(handle, pref),
)
slots[dev.id] = slot
@@ -298,6 +298,18 @@ object NativeBridge {
surfaceH: Int,
)
/**
* Re-report the video SurfaceView's on-screen pixel size — call it from every `surfaceChanged`.
*
* The ASurfaceControl present backend composites the picture into exactly this rectangle, and
* the view grows AFTER [nativeStartVideo] has run: the stream screen hides the system bars and
* switches the window to draw into the display cutout a frame or two later, and neither
* recreates the surface. Without this the layer keeps painting at its start-up size in the
* corner of a now-bigger surface. Non-positive values are ignored. No-op on a `0` handle;
* cheap (one atomic store), UI-safe.
*/
external fun nativeVideoSurfaceSize(handle: Long, width: Int, height: Int)
/** Stop + join the decode thread without closing the session. No-op on `0`. */
external fun nativeStopVideo(handle: Long)
@@ -0,0 +1,254 @@
package io.unom.punktfunk.kit
import android.view.KeyEvent
import android.view.MotionEvent
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Pure JVM test of [Gamepad.PadButtons.correct] — the scancode resolution for controllers Android
* has no key layout for. Only `KeyEvent`'s compile-time-inlined keycode constants are involved, so
* no Android runtime is needed. Run: `./gradlew :kit:testDebugUnitTest`.
*
* The regression it pins is a field report from a Fire TV Stick 4K Max (2026-08-20): a DualSense
* and an Xbox Elite Series 2, both over Bluetooth, both identified correctly but with buttons
* landing on the wrong actions — "L1 being L2". Neither pad has a key layout on that box (AOSP
* ships none for `045e:0b05` at all, and the DualSense's requires `CONFIG_HID_PLAYSTATION`), so
* both fall back to `Generic.kl`, which names keycodes by scancode POSITION. A pad with no kernel
* driver numbers its HID buttons 1..n straight through in its own report order, so every keycode
* after the first divergence belongs to a different button.
*
* The table below is the pad's physical button on the left and where `Generic.kl` put it on the
* right; the assertions read it back the other way.
*/
class PadButtonsTest {
private fun sony(scan: Int) =
Gamepad.PadButtons.GENERIC_SONY.correct(scan, Gamepad.genericKeyCode(scan))
private fun xbox(scan: Int) =
Gamepad.PadButtons.GENERIC_XBOX.correct(scan, Gamepad.genericKeyCode(scan))
/**
* The exact report: a DualSense's L2 sits at scancode `0x136`, which `Generic.kl` calls
* BUTTON_L1 — so pulling L2 read as a shoulder press, and L1 (at `0x134`, read as BUTTON_Y)
* read as a face button.
*/
@Test
fun `a DualSense's shoulders stop being each other's buttons`() {
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, sony(0x134)) // L1, delivered as BUTTON_Y
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, sony(0x135)) // R1, delivered as BUTTON_Z
assertEquals(KeyEvent.KEYCODE_BUTTON_L2, sony(0x136)) // L2, delivered as BUTTON_L1
assertEquals(KeyEvent.KEYCODE_BUTTON_R2, sony(0x137)) // R2, delivered as BUTTON_R1
}
/** ✕ is the bottom button — the one A means everywhere else — and □ is the left one. */
@Test
fun `a DualSense's face buttons land on their Xbox positions`() {
assertEquals(KeyEvent.KEYCODE_BUTTON_X, sony(0x130)) // □
assertEquals(KeyEvent.KEYCODE_BUTTON_A, sony(0x131)) // ✕
assertEquals(KeyEvent.KEYCODE_BUTTON_B, sony(0x132)) // ○
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, sony(0x133)) // △
}
/**
* Create/Options/L3/R3/PS. Select in particular: without this it arrived as BUTTON_THUMBL,
* which took the exit, mic and stats chords with it — every one of them is built on Select.
*/
@Test
fun `a DualSense's menu buttons and stick clicks are themselves`() {
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, sony(0x138)) // Create
assertEquals(KeyEvent.KEYCODE_BUTTON_START, sony(0x139)) // Options
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBL, sony(0x13a)) // L3
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBR, sony(0x13b)) // R3
assertEquals(KeyEvent.KEYCODE_BUTTON_MODE, sony(0x13c)) // PS
}
/** The touchpad click and mute have no wire button; they must resolve to nothing, not to R3. */
@Test
fun `a DualSense's touchpad and mute are dropped rather than mistaken`() {
assertEquals(KeyEvent.KEYCODE_UNKNOWN, sony(0x13d))
assertEquals(KeyEvent.KEYCODE_UNKNOWN, sony(0x13e))
assertEquals(0, Gamepad.buttonBit(sony(0x13d)))
}
/** An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS. */
@Test
fun `an Xbox pad numbering straight through keeps its own layout`() {
assertEquals(KeyEvent.KEYCODE_BUTTON_A, xbox(0x130))
assertEquals(KeyEvent.KEYCODE_BUTTON_B, xbox(0x131))
assertEquals(KeyEvent.KEYCODE_BUTTON_X, xbox(0x132))
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, xbox(0x133))
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, xbox(0x134))
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, xbox(0x135))
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, xbox(0x136)) // View
assertEquals(KeyEvent.KEYCODE_BUTTON_START, xbox(0x137)) // Menu
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBL, xbox(0x138))
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBR, xbox(0x139))
}
/** `hid-playstation` emits the modern Linux codes, where only the face pair reads swapped. */
@Test
fun `a driver-backed Sony pad has only its face pair corrected`() {
val m = Gamepad.PadButtons.SONY_MODERN
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, m.correct(0x133, KeyEvent.KEYCODE_BUTTON_X)) // △
assertEquals(KeyEvent.KEYCODE_BUTTON_X, m.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y)) // □
for (scan in listOf(0x130, 0x131, 0x136, 0x137, 0x13a, 0x13b, 0x13c)) {
assertEquals(Gamepad.genericKeyCode(scan), m.correct(scan, Gamepad.genericKeyCode(scan)))
}
}
/**
* The guard that makes all of this safe to run on every pad: a keycode that is NOT what
* `Generic.kl` would have said came from a device-specific key layout, which knows this
* controller better than any table here. Correcting it would break a pad that works.
*/
@Test
fun `a keycode a device layout already resolved is never second-guessed`() {
// AOSP's DualSense layout puts △ on BUTTON_Y itself. Every profile must leave it be.
for (p in Gamepad.PadButtons.entries) {
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, p.correct(0x133, KeyEvent.KEYCODE_BUTTON_Y))
}
// Same for a scancode outside the generic gamepad block entirely — a pad's Back key.
assertEquals(
KeyEvent.KEYCODE_BACK,
Gamepad.PadButtons.GENERIC_SONY.correct(158, KeyEvent.KEYCODE_BACK),
)
}
/** Correcting twice is correcting once — the output is never itself a generic-layout answer. */
@Test
fun `correction is idempotent`() {
for (p in Gamepad.PadButtons.entries) {
for (scan in 0x130..0x13e) {
val once = p.correct(scan, Gamepad.genericKeyCode(scan))
assertEquals(once, p.correct(scan, once))
}
}
}
/**
* The axis half. A pad that names its triggers something Android knows is read exactly as it
* always was — this is the branch that must NOT fire on the pads that already work.
*/
@Test
fun `a pad that names its triggers is read unchanged`() {
for (p in Gamepad.PadButtons.entries) {
val map = Gamepad.padMap(p, namedTriggers = true, hasRxRy = true, restsNegative = true)
assertEquals(MotionEvent.AXIS_Z, map.rightStickX)
assertEquals(MotionEvent.AXIS_RZ, map.rightStickY)
assertEquals(Gamepad.AXIS_NONE, map.leftTrigger)
assertEquals(Gamepad.AXIS_NONE, map.rightTrigger)
}
// Same when there is no Rx/Ry to fall back to in the first place.
val none = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = false, restsNegative = false)
assertEquals(Gamepad.AXIS_NONE, none.leftTrigger)
}
/**
* A Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry — left stick, right
* stick, then the triggers. Only the triggers were being missed; the sticks already read
* right and must be left alone.
*/
@Test
fun `an unmapped Sony pad keeps its sticks and gains its triggers`() {
val map = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = false)
assertEquals(MotionEvent.AXIS_Z, map.rightStickX)
assertEquals(MotionEvent.AXIS_RZ, map.rightStickY)
assertEquals(MotionEvent.AXIS_RX, map.leftTrigger)
assertEquals(MotionEvent.AXIS_RY, map.rightTrigger)
}
/**
* Every other unmapped pad is the opposite way round: right stick on Rx/Ry, triggers on Z/Rz.
* Reading Z/Rz as the right stick there is what makes pulling a trigger swing it — so the two
* pairs must never be mixed up, which is the whole point of pinning them.
*/
@Test
fun `an unmapped Xbox-layout pad has its stick and triggers the other way round`() {
for (p in listOf(Gamepad.PadButtons.GENERIC_XBOX, Gamepad.PadButtons.SONY_MODERN)) {
val map = Gamepad.padMap(p, namedTriggers = false, hasRxRy = true, restsNegative = false)
assertEquals(MotionEvent.AXIS_RX, map.rightStickX)
assertEquals(MotionEvent.AXIS_RY, map.rightStickY)
assertEquals(MotionEvent.AXIS_Z, map.leftTrigger)
assertEquals(MotionEvent.AXIS_RZ, map.rightTrigger)
}
}
/**
* A trigger axis that idles at 1 is rescaled; one that idles at 0 must NOT be, or it would
* read as a permanent half-pull. Which it is gets measured off the device, never assumed —
* both the DualSense's raw RX/RY and the Xbox pad's Z/Rz report an honest 0..1.
*/
@Test
fun `only a trigger that idles negative is rescaled`() {
val signed = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = true)
assertEquals(0f, signed.level(-1f), 1e-6f)
assertEquals(0.5f, signed.level(0f), 1e-6f)
assertEquals(1f, signed.level(1f), 1e-6f)
val unsigned = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = false)
assertEquals(0f, unsigned.level(0f), 1e-6f)
assertEquals(1f, unsigned.level(1f), 1e-6f)
}
/** A pad Android does know is untouched, which is most of them. */
@Test
fun `a pad with a key layout is left alone`() {
for (scan in 0x130..0x13e) {
val generic = Gamepad.genericKeyCode(scan)
assertEquals(generic, Gamepad.PadButtons.NATIVE.correct(scan, generic))
}
}
/**
* The regression that made this gate necessary (field reports, 2026-08-21): an Xbox Wireless
* Controller and a GameSir G8+, both with their buttons at the standard positions and both
* corrected anyway, because `hasKeys` says BUTTON_C and BUTTON_Z for any pad that DECLARES six
* buttons — `hid-input` allocates the whole descriptor `BTN_A + n` straight through whether the
* pad ever presses them or not. Naming the triggers is what tells the two apart.
*/
@Test
fun `a pad that names its triggers is never corrected, whatever it declares`() {
for (sony in listOf(false, true)) {
for (declaresCZ in listOf(false, true)) {
assertEquals(
Gamepad.PadButtons.NATIVE,
Gamepad.padButtons(namedTriggers = true, sony = sony, declaresCZ = declaresCZ),
)
}
}
}
/**
* The four buttons the field reports named, on a pad whose report order is already standard:
* X answering Y, Y answering LB, and both shoulders answering a menu button. NATIVE is what
* keeps them themselves — the correction tables are right for the pads they are for, and this
* is about not reaching one of them.
*/
@Test
fun `an Xbox pad at the standard positions keeps X, Y and its shoulders`() {
val native = Gamepad.PadButtons.NATIVE
assertEquals(KeyEvent.KEYCODE_BUTTON_X, native.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, native.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, native.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, native.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
// What the old heuristic did to each of them, kept here so the difference stays visible.
val wrong = Gamepad.PadButtons.GENERIC_XBOX
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, wrong.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, wrong.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, wrong.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
assertEquals(KeyEvent.KEYCODE_BUTTON_START, wrong.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
}
/** Past the gate, which straight-through order to read is still the question it always was. */
@Test
fun `an unnamed-trigger pad still resolves its report order`() {
fun order(sony: Boolean, declaresCZ: Boolean) =
Gamepad.padButtons(namedTriggers = false, sony = sony, declaresCZ = declaresCZ)
assertEquals(Gamepad.PadButtons.GENERIC_SONY, order(sony = true, declaresCZ = true))
assertEquals(Gamepad.PadButtons.GENERIC_XBOX, order(sony = false, declaresCZ = true))
assertEquals(Gamepad.PadButtons.SONY_MODERN, order(sony = true, declaresCZ = false))
assertEquals(Gamepad.PadButtons.NATIVE, order(sony = false, declaresCZ = false))
}
}
@@ -0,0 +1,67 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The truth table behind "is a controller attached" — the question the console UI's
* "With a controller" mode is answered by. A false positive here is not cosmetic: it pins the
* console UI on with no pad in the room, and no setting short of turning the whole thing off can
* dismiss it, because the phantom pad never disconnects.
*/
class PadPresenceTest {
/** A real pad: the source class plus hardware behind it, in either of the two shapes. */
@Test
fun realPadsCount() {
assertTrue(
Gamepad.looksLikeController(
padSource = true, virtual = false, hasStick = true, hasFaceButtons = true,
),
)
// An arcade stick / d-pad-only pad — buttons, no analog stick.
assertTrue(
Gamepad.looksLikeController(
padSource = true, virtual = false, hasStick = false, hasFaceButtons = true,
),
)
// A wheel or flight stick — axes, no A/B.
assertTrue(
Gamepad.looksLikeController(
padSource = true, virtual = false, hasStick = true, hasFaceButtons = false,
),
)
}
/** The gaming-phone shoulder triggers and OEM game-mode overlays: a virtual device wearing the
* gamepad source class. This is the field report — the console UI that could not be dismissed. */
@Test
fun virtualDevicesAreNotControllers() {
assertFalse(
Gamepad.looksLikeController(
padSource = true, virtual = true, hasStick = true, hasFaceButtons = true,
),
)
}
/** A device that claims a pad source with nothing behind it is not a pad either. */
@Test
fun aSourceClaimWithoutHardwareIsNotAController() {
assertFalse(
Gamepad.looksLikeController(
padSource = true, virtual = false, hasStick = false, hasFaceButtons = false,
),
)
}
/** And a keyboard/mouse with sticks it never reports on the joystick source stays out. */
@Test
fun nonPadSourcesNeverCount() {
assertFalse(
Gamepad.looksLikeController(
padSource = false, virtual = false, hasStick = true, hasFaceButtons = true,
),
)
}
}
@@ -45,6 +45,10 @@ struct CreateOptions {
device_name: String,
/// Skia's resource budget, bytes (Kotlin sizes it from `ActivityManager.memoryClass`).
gpu_cache_bytes: usize,
/// Whether the touch shell exists as a fallback (phones/tablets; false on a TV) —
/// gates the console-off settings row. Default false: absent means don't offer it.
#[serde(default)]
fallback_ui: bool,
/// The settings snapshot the shell starts from (`pf_client_core::trust::Settings` JSON).
settings: pf_client_core::trust::Settings,
/// The profile catalog as `[[id, name], …]`.
@@ -150,6 +154,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleCrea
let console_opts = ConsoleOptions {
device_name: opts.device_name,
deck: false,
fallback_ui: opts.fallback_ui,
store: Some(store.clone()),
platform: Platform::Android,
gpu_cache_bytes: opts.gpu_cache_bytes.max(16 << 20),
@@ -142,21 +142,21 @@ pub(super) struct AscBackend {
impl AscBackend {
/// Create the reader + compositor layer, or `None` on API < 29 / init failure (the caller then
/// runs the SurfaceView presenter). `window` is the SurfaceView's `ANativeWindow`; `src_w/h` the
/// negotiated decode size; `panel_hz` the mode-table panel rate (seeds the learner);
/// negotiated decode size; `surface_size` the LIVE view size the layer composites into;
/// `panel_hz` the mode-table panel rate (seeds the learner);
/// `dataspace` the `ADataSpace` from the negotiated colour; `source_hz` the negotiated stream rate.
#[allow(clippy::too_many_arguments)]
pub(super) fn create(
window: &NativeWindow,
src_w: i32,
src_h: i32,
surface_w: i32,
surface_h: i32,
surface_size: std::sync::Arc<std::sync::atomic::AtomicU64>,
panel_hz: i32,
dataspace: i32,
source_hz: u32,
priority: PresentPriority,
) -> Option<AscBackend> {
let layer = Layer::create(window, surface_w, surface_h)?;
let layer = Layer::create(window, surface_size)?;
let usage = ndk::hardware_buffer::HardwareBufferUsage::GPU_SAMPLED_IMAGE
| ndk::hardware_buffer::HardwareBufferUsage::COMPOSER_OVERLAY;
let reader = match ImageReader::new_with_usage(
@@ -96,8 +96,7 @@ pub(super) fn run_async(
present_priority,
smooth_buffer,
panel_hz,
surface_w,
surface_h,
surface_size,
} = opts;
boost_thread_priority();
let mode = client.mode();
@@ -199,8 +198,7 @@ pub(super) fn run_async(
&window,
mode.width as i32,
mode.height as i32,
surface_w,
surface_h,
surface_size,
panel_hz,
initial_ds,
mode.refresh_hz,
+14 -7
View File
@@ -91,7 +91,14 @@ const NO_VIDEO_PATIENCE: std::time::Duration = std::time::Duration::from_millis(
/// Re-ask cadence once [`NO_VIDEO_PATIENCE`] has elapsed with still nothing received. Slow, because
/// this state is either self-healing on the first ask or not ours to heal — and each pass logs.
const NO_VIDEO_RETRY: std::time::Duration = std::time::Duration::from_millis(2000);
///
/// ⚠ Taken from core, NOT a local number. `FLUSH_COOLDOWN` (the jump-to-live rate limit) is 2000 ms,
/// and the host classifies a keyframe-recovery cadence by matching a cooldown's period ±10 % to
/// decide WHICH client failure it is looking at. The two are opposites — "I have received nothing"
/// versus "I am drowning in frames I cannot drain" — so while this was also 2000 ms the host
/// confidently reported the wrong one, and a black-screen field case was diagnosed as a slow decoder
/// for days (2026-08-20). Keeping the value in core is what stops the two drifting back together.
const NO_VIDEO_RETRY: std::time::Duration = punktfunk_core::client::NO_VIDEO_RETRY;
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
@@ -133,12 +140,12 @@ pub(crate) struct DecodeOptions {
/// named here is not necessarily the one the panel ends up in. The measured timeline spacing
/// corrects it in both directions ([`punktfunk_core::phase::PanelGrid`]).
pub panel_hz: i32,
/// The video `SurfaceView`'s on-screen pixel size (the aspect-fitted display footprint), from
/// Kotlin at `surfaceCreated`. The ASurfaceControl backend composites its layer in this
/// coordinate space — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin
/// couldn't read it yet, and the backend falls back to the window buffer size.
pub surface_w: i32,
pub surface_h: i32,
/// The video `SurfaceView`'s LIVE on-screen pixel size (the aspect-fitted display footprint),
/// packed by [`crate::session::pack_surface_size`] and re-reported by Kotlin on every
/// `surfaceChanged`. The ASurfaceControl backend composites its layer in this coordinate space
/// — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin couldn't read it
/// yet, and the backend falls back to the window buffer size.
pub surface_size: std::sync::Arc<std::sync::atomic::AtomicU64>,
}
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
@@ -24,6 +24,7 @@ use ndk::hardware_buffer::HardwareBuffer;
use ndk::native_window::NativeWindow;
use std::ffi::c_void;
use std::os::fd::{FromRawFd, OwnedFd, RawFd};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{mpsc, Arc};
use super::async_loop::DecodeEvent;
@@ -276,9 +277,14 @@ unsafe extern "C" fn on_complete(context: *mut c_void, stats: *mut ASurfaceTrans
pub(super) struct Layer {
api: Api,
sc: Arc<ScHandle>,
/// Destination rectangle (the SurfaceView's pixel size) — the buffer is scaled to fill it.
dest_w: i32,
dest_h: i32,
/// The SurfaceView's LIVE pixel size, packed by `pack_surface_size` and re-read before every
/// present — the destination rectangle the buffer is scaled to fill. Live rather than captured
/// because the view resizes under a surface that is never recreated (see `dest`).
surface_size: Arc<AtomicU64>,
/// Fallback destination for as long as `surface_size` is still `0` (Kotlin hadn't measured the
/// view when video started): the window's own buffer geometry, the best remaining guess.
fallback_w: i32,
fallback_h: i32,
/// `true` once the first transaction has made the layer visible + set its z-order + frame rate.
configured: bool,
}
@@ -287,13 +293,16 @@ impl Layer {
/// Create the compositor layer over `window` (the SurfaceView's `ANativeWindow`), or `None` on
/// API < 29 / a null layer — the caller then uses the SurfaceView presenter.
///
/// `dest_w/h` are the SurfaceView's **on-screen pixel size** — the coordinate space the child
/// layer is composited into, which is the display footprint of the (aspect-fitted) video view,
/// NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer geometry in a
/// rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) — using it shrank
/// the picture to the top-left corner. A non-positive `dest_w/h` (Kotlin couldn't read the view
/// yet) falls back to that buffer size as the best remaining guess.
pub(super) fn create(window: &NativeWindow, dest_w: i32, dest_h: i32) -> Option<Layer> {
/// `surface_size` carries the SurfaceView's **on-screen pixel size** — the coordinate space the
/// child layer is composited into, which is the display footprint of the (aspect-fitted) video
/// view, NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer
/// geometry in a rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) —
/// using it shrank the picture to the top-left corner. It is read fresh on every present
/// because that view RESIZES mid-stream under a surface that is never recreated: the stream
/// screen hides the system bars and switches on cutout drawing a frame or two after
/// `surfaceCreated`, and each one grows it. An empty `surface_size` (Kotlin hadn't measured the
/// view yet) falls back to the buffer size as the best remaining guess.
pub(super) fn create(window: &NativeWindow, surface_size: Arc<AtomicU64>) -> Option<Layer> {
let api = Api::resolve()?;
// SAFETY: `window.ptr()` is the live `ANativeWindow` the decode thread owns; the name is a
// static NUL-terminated string; the call returns null on failure (checked).
@@ -303,20 +312,11 @@ impl Layer {
log::warn!("asc: createFromWindow returned null — falling back to SurfaceView");
return None;
}
let dest_w = if dest_w > 0 {
dest_w
} else {
window.width().max(1)
};
let dest_h = if dest_h > 0 {
dest_h
} else {
window.height().max(1)
};
let fallback_w = window.width().max(1);
let fallback_h = window.height().max(1);
log::info!(
"asc: layer created, dest {dest_w}x{dest_h} (window buffer {}x{})",
window.width(),
window.height(),
"asc: layer created, dest {:?} (window buffer {fallback_w}x{fallback_h})",
crate::session::unpack_surface_size(surface_size.load(Ordering::Relaxed)),
);
Some(Layer {
sc: Arc::new(ScHandle {
@@ -324,12 +324,20 @@ impl Layer {
release: api.ac_release,
}),
api,
dest_w,
dest_h,
surface_size,
fallback_w,
fallback_h,
configured: false,
})
}
/// The destination rectangle for this present: the live view size, or the window's buffer
/// geometry while Kotlin has reported nothing.
fn dest(&self) -> (i32, i32) {
crate::session::unpack_surface_size(self.surface_size.load(Ordering::Relaxed))
.unwrap_or((self.fallback_w, self.fallback_h))
}
/// Present one decoded buffer at `desired_present_ns` (`CLOCK_MONOTONIC`; `0` = ASAP). Consumes
/// `acquire_fence` (ownership passes to SurfaceFlinger via `setBuffer`). Registers a one-shot
/// completion that reports the real latch + the previous buffer's release fence on `ev_tx`,
@@ -370,11 +378,12 @@ impl Layer {
right: src_w.max(1),
bottom: src_h.max(1),
};
let (dest_w, dest_h) = self.dest();
let dst = ARect {
left: 0,
top: 0,
right: self.dest_w,
bottom: self.dest_h,
right: dest_w,
bottom: dest_h,
};
(self.api.txn_set_geometry)(txn, sc, &src, &dst, TRANSFORM_IDENTITY);
if dataspace != 0 {
@@ -50,8 +50,7 @@ pub(super) fn run_sync(
panel_hz: _,
// The ASurfaceControl backend is async-loop only; the sync loop renders straight to the
// SurfaceView, so it never needs the view's on-screen size.
surface_w: _,
surface_h: _,
surface_size: _,
} = opts;
boost_thread_priority();
let mode = client.mode();
@@ -470,6 +470,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// 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)),
access_seq: std::sync::atomic::AtomicU32::new(0),
// Reported by Kotlin at `surfaceCreated` and on every resize after it.
surface_size: Arc::new(std::sync::atomic::AtomicU64::new(0)),
};
Box::into_raw(Box::new(handle)) as jlong
}
+58 -1
View File
@@ -26,7 +26,7 @@ mod probe;
use punktfunk_core::client::NativeClient;
use std::panic::AssertUnwindSafe;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
@@ -87,6 +87,37 @@ pub(crate) struct SessionHandle {
/// `nativeAccessState` poll ([`access`]) — how the Kotlin poller tells a fresh update
/// (the host's expiry warnings) arrived without holding a blocking event thread.
pub(crate) access_seq: AtomicU32,
/// The video `SurfaceView`'s LIVE on-screen pixel size ([`pack_surface_size`]), written by
/// `nativeStartVideo` and by every `nativeVideoSurfaceSize` the `surfaceChanged` callback
/// sends, read by the ASurfaceControl presenter before each present.
///
/// Shared and live rather than a start-time parameter because the view RESIZES under a surface
/// that is never recreated: hiding the system bars and switching the window to
/// `LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS` both happen a frame or two AFTER `surfaceCreated`,
/// and each one grows the video view. A destination rect captured once at creation then keeps
/// compositing the picture at its old, smaller size anchored at the layer's origin — the
/// "stream in the top-left corner" field report. `0` = nothing reported yet, and the layer
/// falls back to the window's buffer geometry.
pub surface_size: Arc<AtomicU64>,
}
/// Pack a surface's pixel size into one `u64` — so the presenter reads width and height as a
/// single atomic load and can never see a torn pair (a new width against an old height).
/// Non-positive values pack as `0`, the "not reported yet" sentinel.
pub(crate) fn pack_surface_size(w: i32, h: i32) -> u64 {
if w <= 0 || h <= 0 {
return 0;
}
((w as u64) << 32) | (h as u64 & 0xffff_ffff)
}
/// The inverse of [`pack_surface_size`]: `None` for the `0` sentinel.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub(crate) fn unpack_surface_size(packed: u64) -> Option<(i32, i32)> {
if packed == 0 {
return None;
}
Some((((packed >> 32) as u32) as i32, (packed as u32) as i32))
}
struct VideoThread {
@@ -160,3 +191,29 @@ fn parse_hex32(s: &str) -> Option<[u8; 32]> {
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::{pack_surface_size, unpack_surface_size};
/// The pair the presenter reads as one atomic load must survive the round trip — including a
/// size wider than a signed 16-bit value, which every panel this runs on now is.
#[test]
fn surface_size_round_trips() {
assert_eq!(
unpack_surface_size(pack_surface_size(2800, 1260)),
Some((2800, 1260))
);
assert_eq!(unpack_surface_size(pack_surface_size(1, 1)), Some((1, 1)));
}
/// "Not reported yet" — and anything nonsensical — is the one sentinel, so the layer falls back
/// to the window's buffer geometry rather than composing into an empty rectangle.
#[test]
fn non_positive_sizes_are_the_sentinel() {
assert_eq!(pack_surface_size(0, 0), 0);
assert_eq!(pack_surface_size(1920, 0), 0);
assert_eq!(pack_surface_size(-1, 1080), 0);
assert_eq!(unpack_surface_size(0), None);
}
}
+39 -2
View File
@@ -72,6 +72,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
let client = h.client.clone();
let sd = shutdown.clone();
let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
// Seed the live view size with what the view measures right now; `surfaceChanged` keeps it
// current from here on (the bars hide and the cutout mode changes AFTER this call).
h.surface_size.store(
super::pack_surface_size(surface_w, surface_h),
std::sync::atomic::Ordering::Relaxed,
);
let opts = crate::decode::DecodeOptions {
decoder_name: decoder,
ll_feature,
@@ -80,8 +87,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
present_priority,
smooth_buffer,
panel_hz: panel_fps,
surface_w,
surface_h,
surface_size: h.surface_size.clone(),
};
let join = std::thread::Builder::new()
.name("pf-decode".into())
@@ -93,6 +99,37 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeVideoSurfaceSize(handle, width, height)` — the video `SurfaceView`'s
/// on-screen pixel size, re-reported on every `surfaceChanged`.
///
/// The ASurfaceControl presenter composites its child layer into exactly this rectangle, and the
/// view resizes UNDER a surface that is never recreated: the stream screen hides the system bars
/// and asks to draw into the display cutout a frame or two after `surfaceCreated`, both of which
/// grow it. Without this the layer would keep painting the picture at its start-up size, in the
/// corner of a bigger surface. Non-positive values are ignored (they'd blank the picture).
/// No-op on a `0` handle. Stored whether or not video is running — the next `nativeStartVideo`
/// then starts from a measured view rather than the window's guess. Not android-gated: pure `jni`
/// + an atomic store, so it links on the host build too.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSurfaceSize(
_env: EnvUnowned,
_this: JObject,
handle: jlong,
width: jni::sys::jint,
height: jni::sys::jint,
) {
jni_guard((), || {
let packed = super::pack_surface_size(width, height);
if handle == 0 || packed == 0 {
return;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.surface_size
.store(packed, std::sync::atomic::Ordering::Relaxed);
})
}
/// `NativeBridge.nativeVideoMime(handle): String` — the MediaCodec MIME for the codec the host
/// resolved (`"video/hevc"` / `"video/avc"` / `"video/av01"`), so Kotlin can rank `MediaCodecList`
/// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].
+30 -3
View File
@@ -53,9 +53,9 @@ use punktfunk_core::config::Role;
use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::packet::FLAG_PROBE;
use punktfunk_core::quic::{
endpoint, io, window_loss_ppm, BitrateChanged, CursorRenderMode, Hello, LossReport,
ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, SetBitrate, Start,
Welcome,
endpoint, io, window_loss_ppm, BitrateChanged, CursorRenderMode, DeliveryReport, Hello,
LossReport, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, SetBitrate,
Start, Welcome,
};
use punktfunk_core::transport::UdpTransport;
use punktfunk_core::{CompositorPref, Mode, PunktfunkError, Session};
@@ -987,10 +987,18 @@ async fn session(args: Args) -> Result<()> {
let mut ls = send;
let lp = loss_ppm.clone();
let df = dropped_frames.clone();
// Delivery truth for the host's dead-data-plane check: report what actually landed on the
// wire, so the probe reproduces a real client's answer rather than the "cannot answer"
// sentinel — which is exactly what makes it usable for testing that path.
let rxp = rx_wire_packets.clone();
tokio::spawn(async move {
use std::sync::atomic::Ordering::Relaxed;
let mut last_report = std::time::Instant::now();
let mut last_dropped = 0u64;
// Mirrors the real clients' rule (see `pump/data.rs`): report the delivery count every
// window while it is zero, once when the first packets land, then stop — so a host that
// predates the message is not flooded with "unknown control message" on a good session.
let mut delivery_confirmed = false;
loop {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let d = df.load(Relaxed);
@@ -1007,6 +1015,25 @@ async fn session(args: Args) -> Result<()> {
if last_report.elapsed() >= std::time::Duration::from_millis(750) {
last_report = std::time::Instant::now();
let v = lp.swap(u32::MAX, Relaxed);
// Independent of whether there is a fresh loss sample: "no fresh sample" is
// exactly the shape a dead data plane has, so gating it on one would silence
// it in the state it exists to report.
let received = rxp.load(Relaxed);
if received == 0 || !delivery_confirmed {
delivery_confirmed = received > 0;
if io::write_msg(
&mut ls,
&DeliveryReport {
packets_received: received,
}
.encode(),
)
.await
.is_err()
{
break; // control stream gone
}
}
if v != u32::MAX
&& io::write_msg(&mut ls, &LossReport { loss_ppm: v }.encode())
.await
+24 -9
View File
@@ -148,6 +148,10 @@ pub(crate) enum HintKey {
/// there isn't (the library grid spends up on rows) the same menu hangs off
/// [`HintKey::Tertiary`] instead; the button differs, the word "Options" does not.
Up,
/// ▼ — the home carousel's other spare direction, which opens Settings. Advertised in
/// place of [`HintKey::Tertiary`] where no pad is attached, because that is exactly the
/// device that has no X to press: a TV remote is a D-pad, OK and Back.
Down,
Key(&'static str),
}
@@ -272,7 +276,7 @@ fn glyph_width(fonts: &Fonts, key: HintKey, style: GlyphStyle, k: f64) -> f64 {
match resolved(key, style) {
Resolved::Badge(_) | Resolved::Adjust => BADGE_D * k,
Resolved::Shoulders => 2.0 * shoulder_w(fonts, k) + 3.0 * k,
Resolved::Up => BADGE_D * k,
Resolved::Up | Resolved::Down => BADGE_D * k,
Resolved::Key(text) => keycap_w(fonts, text, k),
}
}
@@ -294,6 +298,9 @@ enum Resolved {
/// The d-pad's up — drawn the same in every style, because it is a direction rather
/// than a button whose label changes with the pad.
Up,
/// The d-pad's down — the same triangle stood on its head, and style-free for the
/// same reason [`Resolved::Up`] is.
Down,
Key(&'static str),
}
@@ -317,6 +324,7 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
HintKey::Shoulders => Resolved::Key("Tab"),
HintKey::Adjust => Resolved::Adjust,
HintKey::Up => Resolved::Up,
HintKey::Down => Resolved::Down,
HintKey::Key(t) => Resolved::Key(t),
};
}
@@ -327,6 +335,7 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
HintKey::Secondary => Resolved::Badge(Face::Y),
HintKey::Shoulders => Resolved::Shoulders,
HintKey::Adjust => Resolved::Adjust,
HintKey::Down => Resolved::Down,
HintKey::Up => Resolved::Up,
HintKey::Key(t) => Resolved::Key(t),
}
@@ -394,17 +403,23 @@ fn draw_glyph(
pen += w + 3.0 * k;
}
}
Resolved::Up => {
// ▲ — one solid triangle in a badge-sized slot.
g @ (Resolved::Up | Resolved::Down) => {
// ▲ / ▼ — one solid triangle in a badge-sized slot, the same triangle either
// way up: apex toward the direction it names, base at the other end.
let r = BADGE_D * k / 2.0;
let (cx, cyf) = ((x + r) as f32, cy as f32);
let (tw, th) = ((5.5 * k) as f32, (4.5 * k) as f32);
let mut up = PathBuilder::new();
up.move_to((cx, cyf - th));
up.line_to((cx - tw, cyf + th));
up.line_to((cx + tw, cyf + th));
up.close();
canvas.draw_path(&up.detach(), &fill(fg(0.85)));
let (apex, base) = if matches!(g, Resolved::Down) {
(cyf + th, cyf - th)
} else {
(cyf - th, cyf + th)
};
let mut tri = PathBuilder::new();
tri.move_to((cx, apex));
tri.line_to((cx - tw, base));
tri.line_to((cx + tw, base));
tri.close();
canvas.draw_path(&tri.detach(), &fill(fg(0.85)));
}
Resolved::Adjust => {
// ◀ ▶ — two small solid triangles.
+4
View File
@@ -63,6 +63,10 @@ pub(crate) struct Ctx<'a> {
pub pads: &'a [PadInfo],
/// Steam Deck: never draw our keyboard — Steam's types via SDL text input.
pub deck: bool,
/// The host app has another interface to fall back to when the console is switched
/// off (an Android phone/tablet's touch shell) — see
/// [`crate::shell::ConsoleOptions::fallback_ui`]. Gates the console-off settings row.
pub fallback_ui: bool,
/// The name the HOST stores this client under when pairing (the machine's
/// hostname, resolved by the binary).
pub device_name: &'a str,
@@ -396,6 +396,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads,
deck,
fallback_ui: false,
device_name: "t",
t: 0.0,
}
@@ -255,6 +255,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -301,6 +302,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -323,6 +325,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -941,6 +941,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &[],
deck: false,
fallback_ui: false,
device_name: "test",
t: 0.0,
};
@@ -378,6 +378,7 @@ mod tests {
platform,
pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -429,6 +430,7 @@ mod tests {
platform,
pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
}
+84 -2
View File
@@ -206,7 +206,17 @@ impl HomeScreen {
}
_ => Some(MenuPulse::Boundary),
},
MenuEvent::Move(_) => None,
// Down is Settings — the same screen X opens. The carousel is horizontal, so
// down is the other free direction, and it is the only route to Settings on a
// device whose input has no face buttons: an Android TV remote is a D-pad, OK
// and Back, and X never arrives. (Apple hit this on the Siri Remote too, and
// answered it by moving rows out to the ordinary Settings app.)
MenuEvent::Move(MenuDir::Down) => {
fx.push(Screen::Settings(super::settings::SettingsScreen::new(
ctx.store,
)));
Some(MenuPulse::Confirm)
}
}
}
@@ -279,7 +289,15 @@ impl HomeScreen {
{
hints.push(Hint::new(HintKey::Up, "Options"));
}
hints.push(Hint::new(HintKey::Tertiary, "Settings"));
// Name the route this device actually has. With no pad attached the legend is
// already speaking keyboard, and the one input that reaches here with neither a
// pad NOR letter keys is a TV remote — for which X is not a button that exists.
// Down opens Settings for everyone; only the advertisement changes.
hints.push(if ctx.pads.is_empty() {
Hint::new(HintKey::Down, "Settings")
} else {
Hint::new(HintKey::Tertiary, "Settings")
});
hints.push(Hint::new(HintKey::Back, "Quit"));
hints
}
@@ -859,6 +877,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "test",
t: 0.0,
};
@@ -885,6 +904,67 @@ mod tests {
));
}
/// Everything this screen offers must be reachable from a D-pad, OK and Back alone —
/// an Android TV remote has no face buttons, so Settings (X) and the options menu
/// would otherwise be unreachable there. Up is the menu, down is Settings, and the
/// legend names the direction rather than X when nothing is plugged in.
#[test]
fn a_remote_reaches_settings_and_options_without_face_buttons() {
let mut settings = ctx_settings();
let hosts = [host("paired", true, true, false)];
let pads: Vec<pf_client_core::menu_nav::PadInfo> = Vec::new();
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &hosts,
library: &library,
settings: &mut settings,
store: crate::store::file_store(),
platform: crate::platform::Platform::Android,
pads: &pads,
deck: false,
fallback_ui: true,
device_name: "test",
t: 0.0,
};
let mut s = HomeScreen::new();
// Down opens the same screen X opens.
let mut fx = Outbox::default();
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
assert!(
matches!(fx.nav, Some(crate::screens::Nav::Push(ref sc)) if matches!(**sc, Screen::Settings(_))),
"down must open Settings"
);
// Up still opens the host's own menu — the library hangs off that menu now.
let mut fx = Outbox::default();
s.menu(MenuEvent::Move(MenuDir::Up), &mut ctx, &mut fx);
assert!(
matches!(fx.nav, Some(crate::screens::Nav::Push(ref sc)) if matches!(**sc, Screen::HostOptions(_))),
"up must open the host options menu"
);
// With no pad the legend advertises the direction, not a button that isn't there.
assert!(
s.hints(&ctx).iter().any(|h| h.key == HintKey::Down),
"a padless device is told about down"
);
// With a pad it goes back to naming X, which is faster to press.
let pads = vec![pf_client_core::menu_nav::PadInfo {
name: "Pad".into(),
key: "045e:028e:Pad".into(),
pref: punktfunk_core::config::GamepadPref::Xbox360,
steam_virtual: false,
battery: None,
detail: "045E:028E · gamepad".into(),
forwarded: true,
rumble: false,
}];
ctx.pads = &pads;
assert!(
s.hints(&ctx).iter().any(|h| h.key == HintKey::Tertiary),
"a pad is told about X"
);
}
/// A pinned card's A-press is a connect WITH its profile (one-off), titled so the
/// connecting takeover says which settings are coming (§5.2a).
#[test]
@@ -908,6 +988,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "test",
t: 0.0,
};
@@ -932,6 +1013,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "test",
t: 0.0,
};
@@ -2218,6 +2218,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &[],
deck: false,
fallback_ui: false,
device_name: "test",
t: 0.0,
}
+92 -8
View File
@@ -34,6 +34,10 @@ use skia_safe::{Canvas, Rect};
enum Action {
Wake,
SendLogs,
/// Open this host's game library — the same shelf the home carousel's Y opens, offered
/// here because Y is a face button and a TV remote has none. Saved-and-paired only,
/// exactly like that Y (an unpaired host has no shelf to fetch).
Library,
CopyLink,
Edit,
/// Choose the profile the host's primary tile connects with (opens the
@@ -154,6 +158,12 @@ impl OptionsScreen {
if host.paired && host.online {
a.push(Action::SendLogs);
}
// The shelf, on the same terms the carousel's Y offers it. Ahead of Copy link
// because it is the one row here that goes somewhere rather than acting on the
// host — and on a remote-only device it is the ONLY way to the library.
if host.paired && host.saved {
a.push(Action::Library);
}
a.extend([
Action::CopyLink,
Action::Edit,
@@ -171,6 +181,7 @@ impl OptionsScreen {
match a {
Action::Wake => "Wake host".into(),
Action::SendLogs => "Send logs to host".into(),
Action::Library => "Library".into(),
Action::CopyLink => "Copy link".into(),
Action::Edit => "Edit\u{2026}".into(),
Action::BindProfile => "Default profile\u{2026}".into(),
@@ -234,7 +245,7 @@ impl OptionsScreen {
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
ListMsg::None => pulse,
ListMsg::Activate => {
self.run(action, ctx.store, fx);
self.run(action, ctx, fx);
pulse
}
}
@@ -257,7 +268,8 @@ impl OptionsScreen {
}
}
fn run(&mut self, action: Action, store: &dyn crate::store::SettingsStore, fx: &mut Outbox) {
fn run(&mut self, action: Action, ctx: &Ctx, fx: &mut Outbox) {
let store = ctx.store;
let key = self.host_key().to_string();
match action {
Action::Wake => {
@@ -289,6 +301,24 @@ impl OptionsScreen {
}
fx.pop();
}
// Same two steps the home carousel's Y takes: ask for the shelf, then open it
// on the epoch read BEFORE the command drains, so the screen can tell its own
// fetch's titles from the ones already in the model. `replace`, not push — the
// menu has said its piece, and Back from the shelf belongs on the carousel
// rather than on a menu about the host you just left.
Action::Library => {
let host = self.host();
fx.cmds.push(ConsoleCmd::FetchLibrary {
addr: host.addr.clone(),
mgmt: host.mgmt_port,
fp_hex: host.fp_hex.clone(),
});
let epoch = ctx.library.fetch_epoch();
fx.replace(Screen::Library(super::library::LibraryScreen::new(
self.host(),
epoch,
)));
}
Action::Edit => fx.replace(Screen::AddHost(super::add_host::AddHostScreen::edit(
self.host(),
))),
@@ -407,6 +437,27 @@ mod tests {
use crate::model::ProfileChip;
use crate::screens::Nav;
/// Activate one row. `run` reads the store, and — for Library — the shared library's
/// fetch epoch; nothing else in this menu touches the context, so one throwaway is
/// enough for every action test here.
fn run_action(s: &mut OptionsScreen, action: Action, fx: &mut Outbox) {
let mut settings = pf_client_core::trust::Settings::default();
let library = crate::library::LibraryShared::default();
let ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
store: crate::store::file_store(),
platform: crate::platform::Platform::Desktop,
pads: &[],
deck: false,
fallback_ui: false,
device_name: "test",
t: 0.0,
};
s.run(action, &ctx, fx);
}
fn host() -> HostRow {
HostRow {
key: "aa".into(),
@@ -510,6 +561,38 @@ mod tests {
assert_eq!(s.host_key(), "aa");
}
/// The shelf is on this menu, which is the only route to it that survives a device with
/// no face buttons: home's Y opens it too, but an Android TV remote has no Y. Offered on
/// the same terms that Y is (saved AND paired), and it REPLACES the menu, so Back from
/// the shelf lands on the carousel rather than on a menu about the host just left.
#[test]
fn the_library_hangs_off_the_menu_for_a_padless_device() {
let mut s = OptionsScreen::for_host(&host());
assert!(s
.actions(crate::platform::Platform::Android)
.contains(&Action::Library));
let mut fx = Outbox::default();
run_action(&mut s, Action::Library, &mut fx);
assert!(
matches!(fx.cmds.first(), Some(ConsoleCmd::FetchLibrary { .. })),
"opening the shelf asks for it first"
);
match fx.nav {
Some(Nav::Replace(screen)) => assert!(matches!(*screen, Screen::Library(_))),
_ => panic!("expected the shelf to replace the menu"),
}
// An unpaired host has no shelf to fetch — the row is absent, not inert.
let unpaired = OptionsScreen::for_host(&HostRow {
paired: false,
..host()
});
assert!(!unpaired
.actions(crate::platform::Platform::Android)
.contains(&Action::Library));
}
/// "Default profile…" swaps the menu for the chooser — a Replace like Edit's, and for
/// the same reason — addressed to the HOST's plain key even from rows that carry a
/// composite one.
@@ -520,7 +603,7 @@ mod tests {
.actions(crate::platform::Platform::Desktop)
.contains(&Action::BindProfile));
let mut fx = Outbox::default();
s.run(Action::BindProfile, crate::store::file_store(), &mut fx);
run_action(&mut s, Action::BindProfile, &mut fx);
match fx.nav {
Some(crate::screens::Nav::Replace(screen)) => match *screen {
Screen::BindProfile(b) => assert_eq!(b.host_name(), "Desk"),
@@ -537,7 +620,7 @@ mod tests {
let mut s = OptionsScreen::for_host(&host());
assert!(s.label(Action::Clipboard).ends_with("Off"));
let mut fx = Outbox::default();
s.run(Action::Clipboard, crate::store::file_store(), &mut fx);
run_action(&mut s, Action::Clipboard, &mut fx);
assert_eq!(
fx.cmds,
vec![ConsoleCmd::SetClipboard {
@@ -551,7 +634,7 @@ mod tests {
});
assert!(s.label(Action::Clipboard).ends_with("On"));
let mut fx = Outbox::default();
s.run(Action::Clipboard, crate::store::file_store(), &mut fx);
run_action(&mut s, Action::Clipboard, &mut fx);
assert_eq!(
fx.cmds,
vec![ConsoleCmd::SetClipboard {
@@ -569,12 +652,12 @@ mod tests {
s.list.cursor = i;
let mut fx = Outbox::default();
s.run(Action::Forget, crate::store::file_store(), &mut fx);
run_action(&mut s, Action::Forget, &mut fx);
assert!(fx.cmds.is_empty(), "the first press only arms");
assert!(s.armed);
assert!(s.label(Action::Forget).contains("press again"));
s.run(Action::Forget, crate::store::file_store(), &mut fx);
run_action(&mut s, Action::Forget, &mut fx);
assert_eq!(
fx.cmds,
vec![ConsoleCmd::ForgetHost { key: "aa".into() }],
@@ -597,6 +680,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &[],
deck: false,
fallback_ui: false,
device_name: "test",
t: 0.0,
};
@@ -659,7 +743,7 @@ mod tests {
OptionsScreen::for_game(&host(), &game()),
] {
let mut fx = Outbox::default();
s.run(Action::CopyLink, crate::store::file_store(), &mut fx);
run_action(&mut s, Action::CopyLink, &mut fx);
assert!(matches!(fx.nav, Some(Nav::Pop)));
assert!(fx.toast.is_some());
}
+2
View File
@@ -497,6 +497,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "living-room-deck",
t: 0.0,
};
@@ -537,6 +538,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "deck",
t: 0.0,
};
@@ -233,6 +233,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -274,6 +275,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
+94 -8
View File
@@ -104,6 +104,12 @@ enum RowId {
Sc2Passthrough,
/// DualSense raw-USB capture (touchpad, motion, adaptive triggers).
DsCapture,
/// Whether the console UI fronts the app at all — the touch settings' switch
/// (`Settings.gamepadUiEnabled`), reachable from inside the console it turns off.
/// Only offered where there is another interface to fall back to
/// ([`Ctx::fallback_ui`]): on a TV or the desktop session this console is the only
/// UI, and an off switch would strand the user in nothing.
GamepadUi,
/// When the console UI fronts the app: with a controller attached, or always.
GamepadUiMode,
/// The platform's connected-controllers view (an action row — opens a native screen).
@@ -121,6 +127,7 @@ mod android_keys {
pub const SC2: &str = "android.sc2_capture";
pub const DS_CAPTURE: &str = "android.ds_capture";
pub const GAMEPAD_UI_MODE: &str = "android.gamepad_ui_mode";
pub const GAMEPAD_UI: &str = "android.gamepad_ui_enabled";
}
/// The Android console-UI mode's stored values (`GamepadUi.kt`).
@@ -245,6 +252,7 @@ const TABS: [(&str, &[RowId]); 7] = [
RowId::Stats,
RowId::Fullscreen,
RowId::AutoWake,
RowId::GamepadUi,
RowId::GamepadUiMode,
RowId::Licenses,
],
@@ -384,7 +392,7 @@ impl SettingsScreen {
.1
.iter()
.copied()
.filter(|id| row_on(*id, ctx.platform) && row_applies(*id, ctx.settings))
.filter(|id| row_on(*id, ctx.platform) && row_applies(*id, ctx))
.collect();
}
if self.profiles.is_empty() {
@@ -675,6 +683,7 @@ fn row_on(id: RowId, platform: crate::platform::Platform) -> bool {
| RowId::PhoneGyro
| RowId::Sc2Passthrough
| RowId::DsCapture
| RowId::GamepadUi
| RowId::GamepadUiMode
| RowId::Controllers
| RowId::Licenses
@@ -694,9 +703,22 @@ fn row_on(id: RowId, platform: crate::platform::Platform) -> bool {
}
}
fn row_applies(id: RowId, s: &pf_client_core::trust::Settings) -> bool {
fn row_applies(id: RowId, ctx: &Ctx) -> bool {
match id {
RowId::SmoothBuffer => s.present_priority == "smooth",
RowId::SmoothBuffer => ctx.settings.present_priority == "smooth",
// The console-off switch needs somewhere for "off" to land: only clients with a
// fallback interface (an Android phone/tablet's touch shell) get the row — on a TV
// this console is the only UI, and off would strand the user (the touch settings'
// subtitle even promises "A TV always uses it").
RowId::GamepadUi => ctx.fallback_ui,
// The same two conditions the mode decides anything under: a TV is in console mode
// whatever the mode says (`GamepadUi.kt`: the tv term alone satisfies the OR), and
// while the switch above is off nothing fronts the console at all. Hidden rather
// than dimmed, like the touch screen's picker, and it sits directly below the row
// that drops it so the cursor is never under anything that moves.
RowId::GamepadUiMode => {
ctx.fallback_ui && extra_bool(ctx.settings, android_keys::GAMEPAD_UI, true)
}
_ => true,
}
}
@@ -962,9 +984,17 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
"DualSense over USB",
on_off(extra_bool(s, android_keys::DS_CAPTURE, true)).into(),
),
RowId::GamepadUi => (
None,
"Controller-optimized UI",
on_off(extra_bool(s, android_keys::GAMEPAD_UI, true)).into(),
),
RowId::GamepadUiMode => (
None,
"Controller UI",
// The touch screen's word for the same picker, which now sits under the same
// switch it does there — "Controller UI" beside "Controller-optimized UI"
// would be two rows a reader has to tell apart by their tails.
"Show it",
label_for(
&GAMEPAD_UI_MODES,
extra_str(s, android_keys::GAMEPAD_UI_MODE, "connected"),
@@ -1145,9 +1175,14 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
"Capture a wired DualSense directly (touchpad, motion, adaptive triggers). \
Needs the USB grant when the pad is plugged in."
}
RowId::GamepadUi => {
"Front the app with this console instead of the touch interface. Off returns \
to the touch home immediately switch it back on there."
}
RowId::GamepadUiMode => {
"When this console fronts the app: whenever a controller is attached, or \
always. The touch settings' \"Controller-optimized UI\" switch turns it off."
always for a device that lives docked to a TV. The switch above turns it \
off altogether."
}
RowId::Controllers => "Connected controllers, their grants and a rumble/haptics test.",
RowId::Licenses => "The open-source licences this app ships under.",
@@ -1375,6 +1410,7 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
RowId::PhoneGyro => toggle_extra(s, android_keys::PHONE_GYRO, false, delta, wrap),
RowId::Sc2Passthrough => toggle_extra(s, android_keys::SC2, true, delta, wrap),
RowId::DsCapture => toggle_extra(s, android_keys::DS_CAPTURE, true, delta, wrap),
RowId::GamepadUi => toggle_extra(s, android_keys::GAMEPAD_UI, true, delta, wrap),
RowId::GamepadUiMode => {
let mut v = extra_str(s, android_keys::GAMEPAD_UI_MODE, "connected").to_string();
step_str(&GAMEPAD_UI_MODES, &mut v, delta, wrap).map(|()| {
@@ -1504,6 +1540,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -1538,6 +1575,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -1603,6 +1641,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -1652,6 +1691,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -1686,6 +1726,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -1727,6 +1768,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -1757,6 +1799,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -1793,6 +1836,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -1867,6 +1911,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -1900,6 +1945,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -1931,6 +1977,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -1960,6 +2007,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -2009,6 +2057,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -2060,6 +2109,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -2105,6 +2155,7 @@ pub(super) mod tests {
RowId::Sc2Passthrough,
RowId::DsCapture,
RowId::Controllers,
RowId::GamepadUi,
RowId::GamepadUiMode,
RowId::Licenses,
]
@@ -2147,6 +2198,9 @@ pub(super) mod tests {
extra_str(ctx.settings, android_keys::GAMEPAD_UI_MODE, "connected"),
"always"
);
assert!(extra_bool(ctx.settings, android_keys::GAMEPAD_UI, true));
assert!(adjust(RowId::GamepadUi, 1, true, ctx));
assert!(!extra_bool(ctx.settings, android_keys::GAMEPAD_UI, true));
// Only `extra` moved.
let mut after = ctx.settings.clone();
after.extra = before.extra.clone();
@@ -2154,6 +2208,34 @@ pub(super) mod tests {
});
}
/// The console-off switch exists only where there is a fallback interface for "off"
/// to land in, and the mode row under it only where the mode decides anything: not on
/// a TV (always console, whatever the mode says) and not while the switch is off.
#[test]
fn console_off_switch_needs_a_fallback_ui() {
with_ctx(|ctx| {
ctx.platform = crate::platform::Platform::Android;
// A TV: no off switch (it would strand the user), and no mode row either —
// `gamepadUiActive`'s tv term satisfies the OR on its own.
assert!(
!row_applies(RowId::GamepadUi, ctx),
"a TV offers no off switch"
);
assert!(!row_applies(RowId::GamepadUiMode, ctx));
// A phone or tablet with the console on: both rows.
ctx.fallback_ui = true;
assert!(row_applies(RowId::GamepadUi, ctx));
assert!(row_applies(RowId::GamepadUiMode, ctx));
// Switched off: the switch stays (it is the way back), the mode row goes.
set_extra_bool(ctx.settings, android_keys::GAMEPAD_UI, false);
assert!(row_applies(RowId::GamepadUi, ctx));
assert!(
!row_applies(RowId::GamepadUiMode, ctx),
"the mode row decides nothing while the switch above it is off"
);
});
}
#[test]
fn every_row_has_exactly_one_tab() {
let mut seen: Vec<RowId> = Vec::new();
@@ -2168,9 +2250,9 @@ pub(super) mod tests {
// 2026-08 sweep found them bridged but unreachable) later passes added, minus the
// game-library toggle: this screen never read it, and the library is offered on any
// paired host now.
// 35 desktop rows + the eight Android-only ones (design android-skia-console-port.md
// D3): six `extra`-backed settings and two platform-screen action rows.
assert_eq!(seen.len(), 43, "{seen:?}");
// 35 desktop rows + the nine Android-only ones (design android-skia-console-port.md
// D3): seven `extra`-backed settings and two platform-screen action rows.
assert_eq!(seen.len(), 44, "{seen:?}");
assert!(seen.contains(&RowId::Palette));
assert!(seen.contains(&RowId::ReduceMotion));
assert!(seen.contains(&RowId::AudioFormat));
@@ -2209,6 +2291,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -2250,6 +2333,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -2295,6 +2379,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -2373,6 +2458,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
+15
View File
@@ -183,6 +183,11 @@ pub struct ConsoleOptions {
pub device_name: String,
/// Steam Deck: Steam's keyboard types (SDL text input); ours never draws.
pub deck: bool,
/// Whether the host app has another interface to fall back to when the console is
/// switched off — an Android phone/tablet's touch shell. Shows the console-off switch
/// on the settings screen; false where this console is the only UI there is (the
/// desktop session, an Android TV), where offering "off" would strand the user.
pub fallback_ui: bool,
/// Where settings persist and the profile catalog comes from. `None` = the desktop
/// file store (`pf_client_core::trust`), which is what the Vulkan session wants and the
/// only store there is on Linux/Windows; every other host must supply one.
@@ -203,6 +208,7 @@ impl ConsoleOptions {
ConsoleOptions {
device_name,
deck,
fallback_ui: false,
store: None,
platform: Platform::Desktop,
gpu_cache_bytes: DEFAULT_GPU_CACHE_BYTES,
@@ -247,6 +253,8 @@ pub(crate) struct Shell {
hosts_gen: u64,
device_name: String,
deck: bool,
/// See [`ConsoleOptions::fallback_ui`].
fallback_ui: bool,
pub(crate) in_stream: bool,
connecting: Option<Connecting>,
/// The last host title a connect was raised for, kept past the connect itself so
@@ -352,6 +360,7 @@ impl Shell {
hosts_gen: u64::MAX,
device_name: opts.device_name,
deck: opts.deck,
fallback_ui: opts.fallback_ui,
in_stream: false,
connecting: None,
last_connect_title: None,
@@ -888,6 +897,7 @@ impl Shell {
platform: self.platform,
pads: &self.pads,
deck: self.deck,
fallback_ui: self.fallback_ui,
device_name: &self.device_name,
t: self.t0.elapsed().as_secs_f64(),
};
@@ -951,6 +961,10 @@ impl Shell {
// navigation but "open this tile's menu". Without this the context menu —
// and with it the only way to copy a host's link — is pad-only.
crate::glyphs::HintKey::Up => Some(MenuEvent::Move(MenuDir::Up)),
// ▼ is the same kind of hint: a direction that steers nothing, because
// the only screen publishing it is the home carousel, where down means
// "open Settings". A finger must be able to press what it advertises.
crate::glyphs::HintKey::Down => Some(MenuEvent::Move(MenuDir::Down)),
_ => None,
};
if let Some(ev) = ev {
@@ -970,6 +984,7 @@ impl Shell {
platform: self.platform,
pads: &self.pads,
deck: self.deck,
fallback_ui: self.fallback_ui,
device_name: &self.device_name,
t: self.t0.elapsed().as_secs_f64(),
};
+4
View File
@@ -172,6 +172,7 @@ impl Shell {
platform: self.platform,
pads: &self.pads,
deck: self.deck,
fallback_ui: self.fallback_ui,
device_name: &self.device_name,
t,
glyphs: self.glyphs,
@@ -331,6 +332,8 @@ struct LayerEnv<'a> {
platform: crate::platform::Platform,
pads: &'a [PadInfo],
deck: bool,
/// See [`crate::shell::ConsoleOptions::fallback_ui`] — a screen's row set can ask.
fallback_ui: bool,
device_name: &'a str,
t: f64,
glyphs: GlyphStyle,
@@ -365,6 +368,7 @@ impl LayerEnv<'_> {
platform: self.platform,
pads: self.pads,
deck: self.deck,
fallback_ui: self.fallback_ui,
device_name: self.device_name,
t: self.t,
};
+5 -4
View File
@@ -402,10 +402,11 @@ fn a_replace_carries_the_screen_it_replaced() {
assert!(matches!(s.stack.last(), Some(Screen::HostOptions(_))));
finish_motion(&mut s);
// Walk to "Edit…" and take it. The first fixture host is paired and online and cannot
// wake, so its menu is [Send logs, Copy link, Edit…, Forget, Cancel] — Edit is two down.
// Pressed exactly rather than searched, so that reordering the menu fails HERE instead of
// quietly landing this test's Confirm on "Forget".
// Walk to "Edit…" and take it. The first fixture host is paired, saved and online and
// cannot wake, so its menu is [Send logs, Library, Copy link, Edit…, ] — Edit is three
// down. Pressed exactly rather than searched, so that reordering the menu fails HERE
// instead of quietly landing this test's Confirm on something destructive.
s.handle_menu(MenuEvent::Move(MenuDir::Down));
s.handle_menu(MenuEvent::Move(MenuDir::Down));
s.handle_menu(MenuEvent::Move(MenuDir::Down));
s.handle_menu(MenuEvent::Confirm);
@@ -116,11 +116,16 @@ static MANAGED_LAUNCH: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// (single-instance), so [`schedule_restore_tv_session`] can restart them when the client disconnects.
static STOPPED_AUTOLOGIN: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
/// The display-manager unit we stopped for the takeover (any DM that drove a LIVE gaming session
/// is stopped for the stream — see [`dm_plan`]), so the restore brings the box back via
/// A display-manager unit stopped for a takeover, so the restore brings the box back via
/// `reset-failed` + `restart` of the DM instead of a `--user start` of the gamescope unit (which
/// cannot work on a mask-fragile flavor: without a DM login session there is no seat, so gamescope
/// never gets DRM master — live-proven on the Nobara repro VM 2026-07-24).
///
/// ⚠ **Adoption-only since 0.31.0**: the takeover idles the box's autologin session
/// ([`install_idle_dropin`]) and leaves the DM up, so nothing in this process ever writes this any
/// more — only [`restore_takeover_on_startup`], for a takeover stranded by a host old enough to
/// have stopped one. It is therefore NOT the marker of a live takeover; [`takeover_idled`] is.
/// Reading it as that marker is what silently unreachable-d the in-stream switch gate in 0.31.0.
static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
/// Whether this takeover runtime-masked the [`STOPPED_AUTOLOGIN`] units ([`mask_unit`]) — i.e.
@@ -136,12 +141,16 @@ static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None
static AUTOLOGIN_MASKED: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
/// mtime of the `steamos-session-select` sentinel as of the takeover — the baseline the in-stream
/// "Switch to Desktop" detector compares against. Steam's session-select script writes
/// `~/.config/steamos-session-select` unconditionally in its USER pass, before any of its
/// display-manager checks so it advances even under a DM-stop takeover, where the script's
/// config-rewrite tail is a silent no-op (every write branch is gated on the DM *running*;
/// diagnosed live on the Nobara repro VM 2026-07-24). An advanced mtime after a capture loss is
/// therefore the one durable trace of the user's switch request.
/// "Switch to Desktop" detector compares against. The ChimeraOS-layout `os-session-select`
/// (Nobara, ChimeraOS) writes `~/.config/steamos-session-select` unconditionally in its USER pass,
/// before any of its display-manager checks, so an advanced mtime after a capture loss is the one
/// durable trace of the user's switch request — the switch itself leaves nothing else behind that
/// this host can see.
///
/// ⚠ Bazzite/SteamOS write NO sentinel: their `os-session-select` is a thin wrapper over
/// `steamosctl` D-Bus calls. The detector is therefore inert there by construction, which is
/// exactly right — those platforms default the mid-stream session watcher ON
/// ([`is_steam_htpc_platform`]) and follow the switch with it instead.
///
/// Two levels of `Option`, because "no baseline" and "no sentinel" mean opposite things:
/// * **outer `None`** — never baselined (no takeover this host lifetime). Nothing can read as an
@@ -674,21 +683,28 @@ fn create_managed_session(client: &str, mode: Mode, hdr: bool) -> Result<Virtual
if steamos_session_present() {
return create_managed_session_steamos(mode, hdr);
}
// In-stream "Switch to Desktop" under a DM-stop takeover: the user's session-select inside
// the streamed game mode advanced the sentinel, but its config rewrite was a silent no-op
// (every write branch needs the DM running, and the takeover stopped it) — so without this,
// the capture loss it caused would just relaunch game mode ("thrown back in", field-tested
// 2026-07-24). Honor the request instead: restore the DM and replay the switch.
let dm_takeover = STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).clone();
if let Some(dm) = dm_takeover {
if session_select_requested() {
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = None;
honor_session_select_switch(dm);
return Err(anyhow!(
"the user switched the box to the desktop session — display manager restored; \
re-detection follows the desktop compositor as it comes up"
));
}
// In-stream "Switch to Desktop": the user's session-select inside the streamed game mode
// advanced the sentinel, so the box is on its way to a desktop session. Without this, the
// capture loss that switch causes just relaunches game mode over the booting desktop — the
// "thrown back in" field report of 2026-07-24, and again on Nobara 2026-08-20.
//
// ⚠ Gated on the IDLED takeover, not on [`STOPPED_DM`]. Until 0.31.0 the takeover stopped the
// display manager, and setting that static was what armed this gate; the idled takeover
// replaced both the stop and the static ([`install_idle_dropin`]) and nothing re-armed the
// gate, so this branch became unreachable on every box. Bazzite did not notice — its
// `os-session-select` is a `steamosctl` D-Bus call that writes no sentinel, and its session
// watcher is on by default ([`is_steam_htpc_platform`]) so the stream follows the switch
// anyway. The ChimeraOS-layout distros are the ones that lost their handling: their
// `os-session-select` DOES write the sentinel, and `ID=nobara` matches no HTPC default.
if takeover_idled() && session_select_requested() {
// `take`, so an adopted DM stop is consumed exactly once — see
// [`honor_session_select_switch`] for why a 0.31.0 takeover has none to consume.
let adopted_dm = std::mem::take(&mut *STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()));
honor_session_select_switch(adopted_dm);
return Err(anyhow!(
"the user switched the box to the desktop session — the box's own game mode is handed \
back; re-detection follows the desktop compositor as it comes up"
));
}
// Post-honor grace: while the selected desktop boots, a managed relaunch would win the race
// (gamescope+Steam start faster than KWin) and a delivering pipeline ends the rebuild's
@@ -1349,19 +1365,34 @@ fn install_idle_dropin() -> Result<()> {
.parent()
.context("the idle drop-in path has no parent directory")?;
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
std::fs::write(
&path,
format!(
"[Service]\nExecStart=\nExecStart={} infinity\n",
sleep_binary()
),
)
.with_context(|| format!("write {}", path.display()))?;
std::fs::write(&path, idle_dropin_body(sleep_binary()))
.with_context(|| format!("write {}", path.display()))?;
systemctl_user(&["daemon-reload"]);
*IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner()) = true;
Ok(())
}
/// The idle drop-in's body (the unit-testable core of [`install_idle_dropin`]).
///
/// The **empty `ExecStart=` comes first and is load-bearing**: `ExecStart` is a list-valued
/// directive, so a drop-in that only adds a line APPENDS to the box's own — which would run the
/// real gamescope session *and* the sleep, i.e. exactly the Steam-fighting session the takeover
/// exists to get out of the way, with no symptom pointing here. The reset is what replaces it.
fn idle_dropin_body(sleep_bin: &str) -> String {
format!("[Service]\nExecStart=\nExecStart={sleep_bin} infinity\n")
}
/// Does THIS host hold the box's game mode idled right now? The successor to "did we stop the
/// display manager" as the marker of a live managed takeover, and so what arms the in-stream
/// switch gate in [`create_managed_session`].
///
/// Reads [`IDLE_DROPIN_ARMED`] — this process's own memory — deliberately, unlike
/// [`remove_idle_dropin`]: a drop-in on disk that we did not write belongs to a dead host, and
/// honoring a "switch" against someone else's takeover would hand back a box we never took.
fn takeover_idled() -> bool {
*IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner())
}
/// Remove the idle drop-in so the box's own Game Mode runs for real again; reports whether one was
/// there. Deliberately NOT gated on [`IDLE_DROPIN_ARMED`] — the flag is this process's memory, and
/// the drop-in outliving a host that died is exactly the case that has to be swept.
@@ -2282,10 +2313,36 @@ fn switch_ends_mask_window(kind: super::ActiveKind) -> bool {
}
/// The host's mid-stream session watcher calls this on every switch it confirms; see
/// [`switch_ends_mask_window`] for which ones actually lift the mask.
/// [`switch_ends_mask_window`] for which ones end the takeover's hold on the box's own game mode.
///
/// This is the SECOND of the two ways a box can leave our takeover mid-stream — the sentinel
/// detector in [`create_managed_session`] is the other — and both owe the box the same hand-back.
/// The watcher is the one that covers Bazzite/SteamOS, where it is on by default
/// ([`is_steam_htpc_platform`]) and no sentinel is ever written; the detector covers the
/// ChimeraOS-layout distros, which are the reverse. Fixing only one leaves the other's boxes
/// holding an idled game mode.
pub fn release_autologin_mask(switched_to: super::ActiveKind) {
if switch_ends_mask_window(switched_to) {
lift_autologin_mask();
if !switch_ends_mask_window(switched_to) {
return;
}
lift_autologin_mask();
// The idle drop-in is the mask's successor and inherits its whole hazard: it replaces the
// box's game-mode `ExecStart` with a sleep, and a switch to a desktop is exactly where that
// stops being ours to hold. Left on, the user's "Return to Gaming Mode" starts a unit that
// only sleeps — the same barred way back this function's mask lift exists to prevent, and
// measured in that state on Bazzite `.41` 2026-08-20 (`ExecStart=/usr/bin/sleep infinity`
// still on the unit after a completed switch to KDE).
//
// Deliberately NOT a full [`clear_takeover`]: the takeover outlives this window, exactly as
// the mask lift's own note says. The box may come back to game mode, and the disconnect
// restore still owes [`STOPPED_AUTOLOGIN`] a start. All this says is "the box's own game mode
// runs for real again".
if remove_idle_dropin() {
tracing::info!(
switched_to = ?switched_to,
"gamescope: the box left our game session for a desktop — removed the takeover's idle \
drop-in so its own Game Mode runs for real again"
);
}
}
@@ -2754,11 +2811,12 @@ fn session_select_mtime() -> Option<std::time::SystemTime> {
/// Record the sentinel baseline, so a LATER write (the user's in-stream "Switch to Desktop") is
/// distinguishable from the switch that led into this session. Taken at **takeover** (the moment
/// [`STOPPED_DM`] is set, which is what arms the honor gate) and again at a successful launch: the
/// switch INTO game mode writes the sentinel on its way in, and that write must never read as a
/// request to go back out. Baselining only at launch left the window in between — a takeover whose
/// launch failed, then a client retry inside the restore debounce — reading a months-old sentinel
/// as a live request and pushing the box to the desktop the user never asked for.
/// the idle drop-in goes in, which is what arms the honor gate — see [`takeover_idled`]) and again
/// at a successful launch: the switch INTO game mode writes the sentinel on its way in, and that
/// write must never read as a request to go back out. Baselining only at launch left the window in
/// between — a takeover whose launch failed, then a client retry inside the restore debounce —
/// reading a months-old sentinel as a live request and pushing the box to the desktop the user
/// never asked for.
fn record_session_select_baseline() {
*SESSION_SELECT_BASELINE
.lock()
@@ -2801,11 +2859,11 @@ fn sentinel_advanced(
///
/// The caller then refuses managed relaunches for [`SWITCH_HONOR_GRACE`] so the capture-loss
/// re-detection follows the desktop compositor once it's up instead of racing it.
fn honor_session_select_switch(dm: String) {
fn honor_session_select_switch(adopted_dm: Option<String>) {
tracing::info!(
%dm,
"gamescope: in-stream session-select detected — restoring the display manager and \
switching the box to the desktop session"
adopted_dm = ?adopted_dm,
"gamescope: in-stream session-select detected — handing the box's own game mode back and \
following the desktop session the user selected"
);
// Consume the takeover state up front: from here on the box is the DM's again. The mask goes
// FIRST and while the unit list still exists — this path discards that list, and it is the only
@@ -2818,7 +2876,43 @@ fn honor_session_select_switch(dm: String) {
clear_takeover();
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
stop_session(SESSION_UNIT); // dead already (the switch shut its Steam down) — clear the unit
if let Err(e) = restore_display_manager(&dm) {
// Give the box its own Game Mode back before anything else can ask for it. The takeover
// replaced that session's `ExecStart` with a sleep ([`install_idle_dropin`]), and a switch is
// the one exit that used to leave it behind: the disconnect restore sweeps it, but a switch is
// not a disconnect. Without this the user's next "Return to Gaming Mode" starts a unit that
// does nothing at all — measured on the Nobara VM 2026-08-20, and on glass it is
// indistinguishable from broken hardware.
if remove_idle_dropin() {
tracing::info!(
"gamescope: removed the takeover's idle drop-in — the box's own Game Mode runs for \
real again"
);
}
// Only an ADOPTED takeover still owes a display-manager restore. 0.31.0 leaves the DM up for
// exactly this reason, so by the time we get here the OS's own switch has already done the
// whole job — config rewrite and relogin (measured end to end on the Nobara VM). A takeover
// inherited from a host old enough to have STOPPED the DM has not: for it the switch really
// was the silent no-op that every write branch of `os-session-select` becomes without a
// running DM, so that one still has to be replayed.
if let Some(dm) = adopted_dm {
replay_switch_under_restored_dm(&dm);
}
record_session_select_baseline();
*SWITCH_HONORED_AT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now());
}
/// Restore a display manager an ADOPTED takeover stopped, then replay the user's switch under it —
/// every verb live-validated on the Nobara repro VM:
/// 1. start the DM (its autologin heads back into game mode briefly — the config still names it);
/// 2. run the distro's own `os-session-select desktop` as the user (its internal pkexec is
/// `allow_any`-authorized), which rewrites the DM autologin config to the desktop session;
/// 3. stop the autologin gamescope unit — the login session exits, and `Relogin=true` relogs
/// into the now-selected desktop.
///
/// Reachable only from [`honor_session_select_switch`], and only for a takeover inherited from a
/// pre-0.31.0 host: nothing stops a display manager any more.
fn replay_switch_under_restored_dm(dm: &str) {
if let Err(e) = restore_display_manager(dm) {
tracing::warn!(
%dm,
reason = %e,
@@ -2831,7 +2925,7 @@ fn honor_session_select_switch(dm: String) {
// Budgeted: this is a 10 s loop, and a single unbounded `is-active` against a system
// manager that is itself mid-restart would consume the whole window in one tick.
let active = crate::proc::output_within(
Command::new("systemctl").args(["is-active", &dm]),
Command::new("systemctl").args(["is-active", dm]),
UNIT_STATE_BUDGET,
)
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
@@ -2888,8 +2982,6 @@ fn honor_session_select_switch(dm: String) {
session instead of switching to the desktop"
);
}
record_session_select_baseline();
*SWITCH_HONORED_AT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now());
}
/// Stop every autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
@@ -2979,6 +3071,13 @@ fn stop_autologin_sessions() -> Result<()> {
// switch.
if plan.dm_relogins {
install_idle_dropin().context("idling the box's autologin game session for the stream")?;
// Baseline the switch sentinel HERE, not only at a successful launch: arming the idle
// drop-in is what arms the honor gate in [`create_managed_session`], so from this instant
// an unbaselined sentinel would read as an in-stream "Switch to Desktop" — including the
// write left by the switch that just brought the box INTO game mode. A successful launch
// re-baselines (tighter still). This moved here from the display-manager stop that 0.31.0
// retired; losing it with that stop is what left the gate unarmed.
record_session_select_baseline();
}
let units: Vec<String> = listed.into_iter().map(|(u, _)| u).collect();
let mut stopped = Vec::new();
@@ -5233,9 +5332,10 @@ mod tests {
use super::{
any_output_size_is, cancel_pending_restore, cgroup_is_punktfunk_owned,
classify_output_size, connected_connector_under, display_manager_unit_under, dm_plan,
game_hz, gamescope_output_size, hdr_args, is_steam_launch, mask_unit, missing_flags,
mode_mismatch, nested_wrapper_script, our_wsi_layer_dir, plan_bind, release_autologin_mask,
script_hardcodes_gamescope, sentinel_advanced, shape_dedicated_command,
game_hz, gamescope_output_size, hdr_args, idle_dropin_body, idle_dropin_path,
install_idle_dropin, is_steam_launch, mask_unit, missing_flags, mode_mismatch,
nested_wrapper_script, our_wsi_layer_dir, plan_bind, release_autologin_mask,
remove_idle_dropin, script_hardcodes_gamescope, sentinel_advanced, shape_dedicated_command,
switch_ends_mask_window, takeover_state_is_live, unmask_unit, xwayland_refusal_marker,
BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind, TakeoverState, WsiPlan,
AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, PENDING_RESTORE, RESTORE_FLIGHT,
@@ -5434,6 +5534,25 @@ mod tests {
assert!(!sentinel_advanced(Some(Some(t0)), None));
}
/// `ExecStart` is list-valued, so the reset line is the whole mechanism: without it the
/// drop-in APPENDS the sleep to the box's own session command and both run — the takeover
/// would then be fighting the very Steam it set out to free, and nothing on the box would say
/// why. Pins the reset, its order, and that the resolved `sleep` is the one that gets run.
#[test]
fn idle_dropin_replaces_exec_start_rather_than_appending() {
let body = idle_dropin_body("/usr/bin/sleep");
assert_eq!(
body, "[Service]\nExecStart=\nExecStart=/usr/bin/sleep infinity\n",
"{body}"
);
let lines: Vec<&str> = body.lines().collect();
assert_eq!(lines[1], "ExecStart=", "the reset must come first: {body}");
// The path is resolved per box ([`sleep_binary`]) and must reach the unit verbatim — a
// bare `sleep` would depend on the unit's PATH, and an ExecStart that fails to EXECUTE is
// the failing unit the display manager relogin-loops against.
assert!(idle_dropin_body("/bin/sleep").contains("ExecStart=/bin/sleep infinity"));
}
#[test]
fn nested_wrapper_script_shapes() {
let relay = std::path::Path::new("/run/user/1000/pf-ei");
@@ -5661,10 +5780,32 @@ mod tests {
release_autologin_mask(crate::ActiveKind::None);
assert_eq!(is_enabled(), "masked-runtime");
// The idle drop-in is the mask's successor and shares this exact window, so it has to come
// off with it. A takeover that leaves it on has replaced the box's game-mode `ExecStart`
// with a sleep — a "Return to Gaming Mode" that starts and does nothing, which is the same
// barred way back, measured on Bazzite `.41` 2026-08-20.
install_idle_dropin().expect("arm the takeover's idle drop-in");
assert!(idle_dropin_path().exists());
// Mid-stream, with the box still ours: the mask is doing its job and must stay. `Gaming` is
// what our own managed session reads as, and `None` is one momentarily down between
// relaunches — lifting on either would void the mask for the whole stream.
release_autologin_mask(crate::ActiveKind::Gaming);
release_autologin_mask(crate::ActiveKind::None);
assert_eq!(is_enabled(), "masked-runtime");
assert!(
idle_dropin_path().exists(),
"the idle drop-in must survive a switch that is not to a desktop"
);
// The user switched the box to its own desktop mid-stream: the window is over, and the way
// back into game mode has to be clear before they ask for it.
release_autologin_mask(crate::ActiveKind::DesktopKde);
assert_ne!(is_enabled(), "masked-runtime");
assert!(
!idle_dropin_path().exists(),
"the idle drop-in outlived the switch — the box's Game Mode is a sleep now"
);
// The restart list SURVIVES the lift: the mask's lifetime is shorter than the takeover's,
// and the disconnect restore still owes these units a `start`.
assert_eq!(STOPPED_AUTOLOGIN.lock().unwrap().as_slice(), [PROBE]);
@@ -5673,6 +5814,7 @@ mod tests {
assert_ne!(is_enabled(), "masked-runtime");
unmask_unit(PROBE);
remove_idle_dropin();
STOPPED_AUTOLOGIN.lock().unwrap().clear();
*AUTOLOGIN_MASKED.lock().unwrap() = false;
}
+1
View File
@@ -215,6 +215,7 @@ include = ["PunktfunkEndReason"]
"MSG_CLOCK_PROBE" = "PUNKTFUNK_MSG_CLOCK_PROBE"
"MSG_CURSOR_RENDER" = "PUNKTFUNK_MSG_CURSOR_RENDER"
"MSG_CURSOR_SHAPE" = "PUNKTFUNK_MSG_CURSOR_SHAPE"
"MSG_DELIVERY_REPORT" = "PUNKTFUNK_MSG_DELIVERY_REPORT"
"MSG_LOSS_REPORT" = "PUNKTFUNK_MSG_LOSS_REPORT"
"MSG_PAIR_CHALLENGE" = "PUNKTFUNK_MSG_PAIR_CHALLENGE"
"MSG_PAIR_PROOF" = "PUNKTFUNK_MSG_PAIR_PROOF"
+7 -1
View File
@@ -1,7 +1,9 @@
//! `CtrlRequest` (the embedder's control-stream requests) and `Negotiated` (the handshake result).
use crate::config::{CompositorPref, GamepadPref, Mode};
use crate::quic::{ClipControl, ClipOffer, ColorInfo, LossReport, ProbeRequest, RfiRequest};
use crate::quic::{
ClipControl, ClipOffer, ColorInfo, DeliveryReport, LossReport, ProbeRequest, RfiRequest,
};
/// A control-stream request the embedder makes on the open handshake stream: a mode switch or a
/// speed test. One outbound channel carries both so the worker's `select!` has a single writer
@@ -15,6 +17,10 @@ pub(crate) enum CtrlRequest {
/// forcing a full IDR. See [`RfiRequest`].
Rfi(RfiRequest),
Loss(LossReport),
/// How many data-plane packets have reached us all session — sent straight after every
/// [`CtrlRequest::Loss`], because `loss_ppm` is ambiguous at zero (no loss and no packets look
/// identical) and only this separates them. See [`DeliveryReport`].
Delivery(DeliveryReport),
/// Adaptive bitrate: ask the host to re-target its encoder (kbps). Sent by the pump's
/// [`BitrateController`] when the user's bitrate setting is Automatic.
SetBitrate(u32),
@@ -57,6 +57,21 @@ pub(crate) const FLUSH_AFTER: Duration = Duration::from_millis(250);
/// the number, so the two can never drift apart.
pub const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
/// Spacing of a client's keyframe re-asks while it has received **no video at all** — the other
/// reason a client asks on a perfectly fixed cadence, and the OPPOSITE fault to [`FLUSH_COOLDOWN`]'s
/// (nothing arriving, versus more arriving than it can drain).
///
/// **Public, and deliberately a different value, for the same reason [`FLUSH_COOLDOWN`] is public.**
/// While both were 2000 ms the host's recovery-cadence detector could not tell which failure it was
/// looking at, and reported the confident wrong one: a 2026-08-20 field case where not one byte of
/// video ever reached the client was diagnosed for days as a client too slow to keep up. Embedders
/// own the no-video timer (it lives in each decode loop), so this is the value they must use — a
/// local copy is exactly the drift that made the two indistinguishable in the first place.
///
/// The delivery count on [`crate::quic::LossReport`] settles it outright for clients new enough to
/// send one; this keeps the period itself informative for those that are not.
pub const NO_VIDEO_RETRY: Duration = Duration::from_millis(2600);
/// A clock-triggered jump-to-live that discarded fewer datagrams than this (and no queued AUs)
/// found NO local backlog: the frames read as late, but nothing here was actually behind. Two
/// causes, and flushing helps neither: a **wall-clock step** (NTP mid-session on either end)
+1 -1
View File
@@ -42,7 +42,7 @@ mod recovery;
mod rumble;
mod worker;
pub use self::frame_channel::FLUSH_COOLDOWN;
pub use self::frame_channel::{FLUSH_COOLDOWN, NO_VIDEO_RETRY};
pub use self::planes::AudioPacket;
pub use self::probe::ProbeOutcome;
pub use self::rumble::{ActuatorQuirks, RumbleCommand};
+3 -3
View File
@@ -11,9 +11,9 @@ use crate::abr::BitrateController;
use crate::config::Role;
use crate::packet::FLAG_PROBE;
use crate::quic::{
io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho, ClockResync, Hello,
LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, ResyncAdmit, ResyncGuard,
ResyncStep, SetBitrate, Start, Welcome,
io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho, ClockResync,
DeliveryReport, Hello, LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe,
ResyncAdmit, ResyncGuard, ResyncStep, SetBitrate, Start, Welcome,
};
use crate::session::Session;
use crate::transport::UdpTransport;
@@ -107,6 +107,7 @@ impl ControlTask {
}
CtrlRequest::Rfi(r) => r.encode(),
CtrlRequest::Loss(r) => r.encode(),
CtrlRequest::Delivery(r) => r.encode(),
CtrlRequest::SetBitrate(k) => SetBitrate { bitrate_kbps: k }.encode(),
CtrlRequest::ClockResync => {
if clock_rtt_ns.is_none() {
+77 -2
View File
@@ -77,6 +77,12 @@ impl DataPump {
// size FEC to the link. Suppressed during a speed test (its FLAG_PROBE filler would skew it).
const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750);
let mut last_report = Instant::now();
// Has the host been told, once, that data-plane packets are reaching us? See the send site:
// the delivery count is reported every window while it is ZERO (the state the host acts on)
// and once more when the first packets land, then never again. A host that predates the
// message logs "unknown control message" for each one, so a healthy session must not stream
// them — one line per session is a fair price on an old host, eighty a minute is not.
let mut delivery_confirmed = false;
let (
mut last_recovered,
mut last_late,
@@ -415,6 +421,27 @@ impl DataPump {
);
} else {
let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm }));
// Rides with the loss report — it is what makes `loss_ppm = 0` readable at the
// host, which cannot otherwise tell a flawless link from one delivering
// nothing. The session TOTAL, not this window's, so one message stands on its
// own. Deliberately inside the same arm: a discarded window is discarded
// because the host was rebuilding or a probe distorted it, and staying silent
// there keeps that contract exact. Nothing is lost — the state this reports
// (no packets at all) produces no discards, so its windows always send.
//
// Sent every window while the count is ZERO, then ONCE when the first packets
// land (so the host stops guessing and can name the other failure confidently),
// then never again: a healthy session must not stream a message that older
// hosts log as unknown on every arrival.
// ponytail: only start-of-session death is covered. A path that dies MID-stream
// leaves the count frozen above zero and silent, which the host still reads as
// healthy — detecting that needs a stalled-counter check with its own timing,
// worth adding if a mid-session case is ever reported.
if should_report_delivery(st.packets_received, &mut delivery_confirmed) {
let _ = ctrl_tx.try_send(CtrlRequest::Delivery(DeliveryReport {
packets_received: st.packets_received,
}));
}
}
// Standing-latency bleed: close the detector's window with this report's loss
// verdict and run its escalation ladder — re-sync first (free; a stale offset
@@ -757,10 +784,58 @@ fn take_pipeline_gap(slot: &AtomicU32) -> Option<u32> {
}
}
/// Does this report window owe the host a [`DeliveryReport`], and record that it has been told?
///
/// Every window while `packets_received` is ZERO — that is the state the host escalates on, and it
/// must keep hearing it — then exactly ONCE more when the first packets land, so the host learns
/// delivery works and can stop hedging its stall diagnosis. Silent after that: a host that predates
/// the message logs every unknown control message, and a healthy hours-long session must not fill
/// its log with them.
fn should_report_delivery(packets_received: u64, confirmed: &mut bool) -> bool {
let owed = packets_received == 0 || !*confirmed;
*confirmed = packets_received > 0;
owed
}
#[cfg(test)]
mod tests {
use super::*;
/// The host must keep hearing "zero" for as long as it is true (that is the black-screen
/// signal), get exactly one confirmation when video starts, and then silence — the noise budget
/// on an older host, which warns per unknown message, is what pays for the first two.
#[test]
fn the_delivery_count_is_reported_while_zero_then_once_more_and_never_again() {
let mut confirmed = false;
// Nothing arriving: reported every window, for as long as it stays true.
for _ in 0..5 {
assert!(
should_report_delivery(0, &mut confirmed),
"a dead data plane must be re-reported every window"
);
}
// First packets land: one confirmation, so the host can name the other failure confidently.
assert!(should_report_delivery(500, &mut confirmed));
// Healthy from here: silent.
for n in [900, 1_200, 90_000] {
assert!(
!should_report_delivery(n, &mut confirmed),
"a healthy session must not stream delivery reports"
);
}
}
/// A session that never receives anything must never look confirmed, no matter how long it runs
/// — the whole point is that the host keeps being told.
#[test]
fn a_session_that_receives_nothing_never_reports_itself_healthy() {
let mut confirmed = false;
for _ in 0..100 {
assert!(should_report_delivery(0, &mut confirmed));
assert!(!confirmed);
}
}
#[test]
fn a_pipeline_gap_is_taken_exactly_once() {
let slot = AtomicU32::new(0);
@@ -935,8 +1010,8 @@ mod tests {
.expect("the window after the gap reports on schedule");
assert!(
matches!(reported, Some(CtrlRequest::Loss(_))),
"the window after the gap must produce a loss report — an idle session's only \
outbound request"
"the window after the gap must produce a loss report — the first of the two requests \
an idle session makes (the delivery count follows it)"
);
assert!(
started.elapsed() >= Duration::from_millis(1_400),
+84
View File
@@ -97,6 +97,33 @@ pub struct LossReport {
pub loss_ppm: u32,
}
/// `client → host`, sent immediately after each [`LossReport`]: data-plane packets this client has
/// received all session, cumulative.
///
/// ⚠ Exists because `loss_ppm` alone is **ambiguous at zero**: a client receiving a flawless stream
/// and a client receiving *nothing at all* both report `loss_ppm = 0` — loss is a ratio over a
/// window whose denominator is the packets that arrived, so no-packets is indistinguishable from
/// no-loss. That ambiguity let a host decay adaptive FEC to its floor while the client sat behind a
/// black screen having received zero bytes, and the host's own stall diagnosis blamed the client for
/// "not sustaining the stream" it had never been sent (field 2026-08-20: a Windows host whose
/// per-session data port was closed inbound, so the client's hole-punch never opened the return
/// path). `0` while the host has sent frames is the one unambiguous statement of "the video data
/// plane is not reaching me" — the control plane carrying this report is, by construction, healthy.
///
/// ⚠ A SEPARATE MESSAGE rather than a field appended to [`LossReport`], and that is load-bearing:
/// `LossReport::decode` length-checks EXACTLY, so a longer report is rejected outright by every host
/// already shipped — a new client would silently lose adaptive FEC against them. Mixed versions are
/// normal here (the field case that motivated this ran a current host against a months-old client),
/// so the compatible shape is a new type byte an older host simply ignores, exactly as it already
/// ignores every other control message it predates.
///
/// Cumulative, not per-window, so a single message is self-contained; `u64` to match the counter it
/// mirrors, with no saturation to reason about.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DeliveryReport {
pub packets_received: u64,
}
/// `client → host`, any time after [`Start`]: reconfigure the encoder to a new target bitrate
/// without reconnecting — the mid-stream lever of adaptive bitrate. The host clamps the request
/// exactly like [`Hello::bitrate_kbps`] (its `[MIN, MAX]` band; `0` → host default), answers with
@@ -270,6 +297,8 @@ pub const MSG_SHARD_PAYLOAD_ACK: u8 = 0x09;
/// and [`BitrateChanged`] already feed. Deliberately NOT in the 0x30 clock block — it carries a
/// duration precisely so that no clock domain is involved.
pub const MSG_PIPELINE_GAP: u8 = 0x0A;
/// Type byte of [`DeliveryReport`].
pub const MSG_DELIVERY_REPORT: u8 = 0x0B;
/// Type byte of [`ProbeRequest`].
pub const MSG_PROBE_REQUEST: u8 = 0x20;
/// Type byte of [`ProbeResult`].
@@ -436,6 +465,26 @@ impl LossReport {
}
}
impl DeliveryReport {
pub fn encode(&self) -> Vec<u8> {
// magic[0..4] type[4] packets_received[5..13]
let mut b = Vec::with_capacity(13);
b.extend_from_slice(CTL_MAGIC);
b.push(MSG_DELIVERY_REPORT);
b.extend_from_slice(&self.packets_received.to_le_bytes());
b
}
pub fn decode(b: &[u8]) -> Result<DeliveryReport> {
if b.len() != 13 || &b[0..4] != CTL_MAGIC || b[4] != MSG_DELIVERY_REPORT {
return Err(PunktfunkError::InvalidArg("bad DeliveryReport"));
}
Ok(DeliveryReport {
packets_received: u64::from_le_bytes(b[5..13].try_into().unwrap()),
})
}
}
impl SetBitrate {
pub fn encode(&self) -> Vec<u8> {
// magic[0..4] type[4] bitrate_kbps[5..9]
@@ -1291,6 +1340,41 @@ mod tests {
.is_err());
}
#[test]
fn delivery_report_roundtrip() {
for packets_received in [0u64, 1, 9_999, u32::MAX as u64 + 1, u64::MAX] {
let r = DeliveryReport { packets_received };
assert_eq!(DeliveryReport::decode(&r.encode()).unwrap(), r);
}
assert!(DeliveryReport::decode(&RequestKeyframe.encode()).is_err());
assert!(DeliveryReport::decode(&LossReport { loss_ppm: 0 }.encode()).is_err());
}
/// The delivery count MUST NOT ride on [`LossReport`]: that message is length-checked EXACTLY,
/// so lengthening it would make every already-shipped host reject the loss reports its adaptive
/// FEC runs on — a silent regression for a new client against an old host, which is the normal
/// mixed-version case here (the field report that motivated this ran a current host against a
/// months-old client). Its own type byte keeps `LossReport` byte-identical while an older host
/// simply ignores the message it does not know.
#[test]
fn the_delivery_count_does_not_disturb_the_loss_report_wire_form() {
let loss = LossReport { loss_ppm: 42 }.encode();
assert_eq!(loss.len(), 9, "LossReport must stay the 9-byte wire form");
assert_eq!(loss[4], MSG_LOSS_REPORT);
let delivery = DeliveryReport {
packets_received: 0,
}
.encode();
assert_ne!(
delivery[4], MSG_LOSS_REPORT,
"a distinct type byte is what makes an old host ignore it instead of failing"
);
// Neither can be silently mis-parsed as the other.
assert!(LossReport::decode(&delivery).is_err());
assert!(DeliveryReport::decode(&loss).is_err());
}
#[test]
fn window_loss_ppm_estimates_and_caps() {
// No traffic → 0. A clean window (nothing recovered) → 0.
+180 -36
View File
@@ -114,9 +114,18 @@ pub enum LeaseKind {
Child,
/// A launcher owns the game; it is recognized by its [`DetectSpec`].
Matched,
/// Nothing identifies this title's process — no detect signals and no child we own. Both
/// lifetime behaviors stay inert for it, and the host says so once in the log rather than
/// guessing.
/// A launcher owns the game and **tells us** when it starts and stops
/// ([`crate::runstate`]) — no process signal of our own.
///
/// The one lease kind whose liveness the host does not determine for itself, and the answer to
/// a title that has nothing to scan for: Playnite launches an emulated or manually-added game
/// through its own tracking and reports the edges, where the host could see only a
/// `playnite://` forwarder exiting. Before this such a title was [`Untracked`](Self::Untracked)
/// — the honest answer at the time, and a dead end.
Reported,
/// Nothing identifies this title's process — no detect signals, no child we own, and no
/// provider reporting on it. Both lifetime behaviors stay inert for it, and the host says so
/// once in the log rather than guessing.
Untracked,
}
@@ -126,6 +135,7 @@ impl LeaseKind {
Self::Nested => "nested",
Self::Child => "child",
Self::Matched => "matched",
Self::Reported => "reported",
Self::Untracked => "untracked",
}
}
@@ -387,6 +397,12 @@ pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease {
LeaseKind::Child
} else if !spec.is_empty() {
LeaseKind::Matched
} else if crate::runstate::speaks_for(game.id.as_deref()) {
// Nothing to scan for, but the provider that published this title is reporting liveness for
// it — so it is tracked after all. Asked once, here, rather than every poll: a lease's kind
// is what decides whether it is watched at all, and a title that flipped kind mid-flight
// would make both lifetime behaviors depend on a plugin's uptime.
LeaseKind::Reported
} else {
LeaseKind::Untracked
};
@@ -551,6 +567,27 @@ fn watch(
s.is_some_and(|p| !scanner.alive(&[p]).is_empty())
};
// What this title's provider says about it, when one reports at all ([`crate::runstate`]) —
// `None` on every host with no reporting plugin, which is what keeps all of this inert until
// someone opts in. Re-read each poll rather than captured: the whole value of it is that it
// changes while the lease is alive.
let reported = || shared.game.id.as_deref().and_then(crate::runstate::opinion);
// What a `Child` lease falls back to once its child turns out to be a shim: the store's own
// signals, else the provider's reporting, else nothing. The same ladder [`open`] walks, minus
// the child that has just gone away — and the reason a hint-less Playnite title is tracked at
// all on Windows, where the launch is `explorer.exe "playnite://…"` and therefore ALWAYS a
// hand-off, so every such lease arrives here.
let fallback_kind = || {
if !shared.spec.is_empty() {
LeaseKind::Matched
} else if crate::runstate::speaks_for(shared.game.id.as_deref()) {
LeaseKind::Reported
} else {
LeaseKind::Untracked
}
};
// ---- Phase 1: wait for the game to show up. ----
let start_deadline = spawned_at + START_GRACE;
loop {
@@ -567,8 +604,10 @@ fn watch(
&& !spawned_up(&spawned)
{
spawned = None;
if spawned_at.elapsed() < SHIM_WINDOW {
if shared.spec.is_empty() {
let quick = spawned_at.elapsed() < SHIM_WINDOW;
kind = fallback_kind();
if quick {
if matches!(kind, LeaseKind::Untracked) {
tracing::info!(
title = %shared.game.title,
"the launch command exited immediately (a launcher handing off) and this \
@@ -582,11 +621,10 @@ fn watch(
}
tracing::debug!(
title = %shared.game.title,
"the launch command handed off and exited — recognizing the game by its store \
signals instead"
kind = kind.as_str(),
"the launch command handed off and exited — recognizing the game another way"
);
kind = LeaseKind::Matched;
} else if shared.spec.is_empty() {
} else if matches!(kind, LeaseKind::Untracked) {
// It ran long enough to have BEEN the game, and nothing else identifies it.
shared.was_running.store(true, Ordering::Relaxed);
finish(&shared, &on_exit, "the launched process exited");
@@ -604,31 +642,30 @@ fn watch(
shared.forget_child();
if quick && status.success() {
// A launcher that handed the game off and exited. Fall back to recognizing
// the game by its store's signals; with none, stop tracking entirely rather
// than pretend the shim's exit was the game's.
kind = if shared.spec.is_empty() {
// the game by its store's signals (or its provider's reporting); with
// neither, stop tracking entirely rather than pretend the shim's exit was
// the game's.
kind = fallback_kind();
if matches!(kind, LeaseKind::Untracked) {
tracing::info!(
title = %shared.game.title,
"the launch command exited immediately (a launcher handing off) and \
this title has no detect signals stopping game tracking for it"
);
LeaseKind::Untracked
} else {
tracing::debug!(
title = %shared.game.title,
"the launch command handed off and exited — recognizing the game by \
its store signals instead"
);
LeaseKind::Matched
};
if matches!(kind, LeaseKind::Untracked) {
shared.set_state(GameState::Untracked);
return;
}
tracing::debug!(
title = %shared.game.title,
kind = kind.as_str(),
"the launch command handed off and exited — recognizing the game \
another way"
);
} else {
// It ran long enough to have BEEN the game (or failed outright). Either way
// the game is gone; only a success after a real run counts as "played".
if shared.spec.is_empty() {
kind = fallback_kind();
if matches!(kind, LeaseKind::Untracked) {
if spawned_at.elapsed() >= SHIM_WINDOW {
shared.was_running.store(true, Ordering::Relaxed);
finish(&shared, &on_exit, "the launched process exited");
@@ -642,11 +679,7 @@ fn watch(
Some(Err(e)) => {
tracing::debug!(error = %e, "could not poll the launched child — falling back to scanning");
child = None;
kind = if shared.spec.is_empty() {
LeaseKind::Untracked
} else {
LeaseKind::Matched
};
kind = fallback_kind();
if matches!(kind, LeaseKind::Untracked) {
shared.set_state(GameState::Untracked);
return;
@@ -680,7 +713,12 @@ fn watch(
&& (child.is_some() || spawned.is_some())
&& spawned_at.elapsed() >= SHIM_WINDOW;
let live = scanner.find(&shared.spec, shared.launch_stamp);
if !live.is_empty() || child_alive {
// A provider saying so is as good as seeing it — better, for a title there is nothing to
// see: it is the launcher that started the game telling us it did. This is the only way a
// [`LeaseKind::Reported`] lease ever leaves this phase, and for a `Matched` one it just
// gets there sooner than the scan would.
let said_running = reported().is_some_and(|l| l.running);
if !live.is_empty() || child_alive || said_running {
known = live.clone();
publish(&live);
shared.was_running.store(true, Ordering::Relaxed);
@@ -754,6 +792,27 @@ fn watch(
gone_since = None;
vetoed = false;
shared.last_seen_ms.store(now_ms(), Ordering::Relaxed);
} else if let Some(said) = reported() {
// Nothing of the game is visible to us, but its provider is still reporting on it — and
// that report is decisive in BOTH directions, where `running_hint` below may only ever
// delay an exit.
//
// The difference is what backs each claim. Steam's registry flag is a leftover that
// survives an unclean exit, so believing it indefinitely produces a session that never
// ends; a provider report is an event from the launcher that started the game, restated
// continuously, and it stops counting the moment it goes stale
// ([`crate::runstate::REPORT_TTL`]) — after which this branch simply stops being taken
// and the scan-only path below resumes. So a *live* provider is allowed to hold the
// session open for a game the host cannot see at all, which is the entire point for a
// title with no detect signals, and a dead one costs at most one TTL.
if said.running {
gone_since = None;
vetoed = false;
shared.last_seen_ms.store(now_ms(), Ordering::Relaxed);
} else {
finish(&shared, &on_exit, "its provider reported the game stopped");
return;
}
} else {
// How long the game's processes have been CONTINUOUSLY absent. Deliberately not reset by
// the veto below — letting it run on is exactly what bounds the veto.
@@ -909,7 +968,7 @@ fn terminate_blocking(shared: &LeaseShared) {
"released the nested session's kept display to end its game"
);
}
LeaseKind::Child | LeaseKind::Matched => {
LeaseKind::Child | LeaseKind::Matched | LeaseKind::Reported => {
#[cfg(target_os = "linux")]
unix_term_ladder(shared);
#[cfg(windows)]
@@ -919,6 +978,26 @@ fn terminate_blocking(shared: &LeaseShared) {
}
}
/// The process this lease's provider reports for its game, re-resolved and pinned to its start
/// time, or `None`.
///
/// The reason the wire carries a pid at all: for a [`LeaseKind::Reported`] title the matcher finds
/// nothing by construction, so without this "End" would have no target and would silently do
/// nothing — the exact failure a spawned pid was folded into the Windows ladder to fix. Resolved at
/// the moment of use rather than stored on the lease, so a report that has since gone stale, or a
/// pid the kernel has since recycled, contributes nothing.
#[cfg(any(target_os = "linux", windows))]
fn reported_proc(shared: &LeaseShared) -> Option<crate::procscan::ProcRef> {
let pid = shared
.game
.id
.as_deref()
.and_then(crate::runstate::opinion)
.filter(|l| l.running)?
.pid?;
crate::procscan::resolve(pid)
}
/// SIGTERM everything that belongs to the game, wait, then SIGKILL whatever ignored it.
///
/// Every pid is re-verified against its recorded start time immediately before each signal, so a pid
@@ -942,11 +1021,22 @@ fn unix_term_ladder(shared: &LeaseShared) {
// `OwnedChild::group_leader`) — never for a child sharing the host's own group.
unsafe { libc::kill(target, sig) == 0 }
};
// Everything the matcher can find, plus the pid the provider reported (see `reported_proc`) —
// which for a `Reported` lease is the only member of this set.
let targets = || {
let mut procs = scanner.find(&shared.spec, shared.launch_stamp);
if let Some(p) = reported_proc(shared) {
if !procs.iter().any(|q| q.pid == p.pid) {
procs.push(p);
}
}
procs
};
let signal_matched = |sig: i32| -> usize {
// Re-scan and re-verify immediately before signalling, so a pid recycled since the last
// sweep is never hit.
scanner
.alive(&scanner.find(&shared.spec, shared.launch_stamp))
.alive(&targets())
.into_iter()
// SAFETY: as above, for a single pid just re-verified to be the process we adopted.
.filter(|p| unsafe { libc::kill(p.pid as i32, sig) == 0 })
@@ -965,9 +1055,7 @@ fn unix_term_ladder(shared: &LeaseShared) {
let deadline = Instant::now() + TERM_GRACE;
while Instant::now() < deadline {
std::thread::sleep(POLL);
let still = scanner
.alive(&scanner.find(&shared.spec, shared.launch_stamp))
.len();
let still = scanner.alive(&targets()).len();
// Signal 0 only probes for existence — the child (or its group) is gone once it fails.
let child_gone = !signal_child(0);
if still == 0 && child_gone {
@@ -1000,11 +1088,19 @@ fn windows_term_ladder(shared: &LeaseShared) {
let live = || {
let mut procs = scanner.alive(&scanner.find(&shared.spec, shared.launch_stamp));
// Re-verified like everything else, so a dead or recycled pid contributes nothing, and
// de-duplicated: the matcher may well have found this same process by its image.
if let Some(p) = shared.spawned {
// de-duplicated: the matcher may well have found this same process by its image. The
// provider's reported pid joins on the same terms, and for a `Reported` lease it is the
// only thing here (see `reported_proc`).
let mut fold = |p: crate::procscan::ProcRef| {
if !scanner.alive(&[p]).is_empty() && !procs.iter().any(|q| q.pid == p.pid) {
procs.push(p);
}
};
if let Some(p) = shared.spawned {
fold(p);
}
if let Some(p) = reported_proc(shared) {
fold(p);
}
procs
};
@@ -1570,6 +1666,54 @@ mod tests {
assert!(!l.shared().is_trackable());
}
/// A title with nothing to scan for is tracked after all when its provider reports on it.
///
/// This is the Playnite case the static `detect` hints could never reach: an emulated game, a
/// manually added one, a library plugin that records no install directory. The launch is a
/// `playnite://` hand-off, so the host holds nothing; the spec is empty, so the matcher finds
/// nothing; and the honest verdict used to be [`LeaseKind::Untracked`] — no exit detection, and
/// `POST /game/end` with nothing to aim at. Playnite knew the whole time.
#[test]
fn a_reported_title_is_tracked_where_it_used_to_be_untracked() {
// The same request with no provider reporting: unchanged, and the control for what follows.
let l = open(
req("playnite:lease-test", DetectSpec::default(), false),
Box::new(|| {}),
);
assert!(matches!(l.shared().kind(), LeaseKind::Untracked));
assert!(!l.shared().is_trackable());
drop(l);
// A provider that speaks for the title — while reporting it NOT running, which is exactly
// what a report looks like at the moment a game is launched. Trackability follows from the
// provider *reporting*, not from what it currently says; a lease whose kind flipped with
// the answer would make both lifetime behaviours depend on a plugin's timing.
crate::runstate::report(
"playnite-lease-test",
["playnite:lease-test".to_string()].into_iter().collect(),
std::collections::HashMap::new(),
);
let l = open(
req("playnite:lease-test", DetectSpec::default(), false),
Box::new(|| {}),
);
assert!(matches!(l.shared().kind(), LeaseKind::Reported));
assert!(
l.shared().is_trackable(),
"so its exit is noticed and `POST /game/end` has a target"
);
drop(l);
crate::runstate::forget("playnite-lease-test");
// …and once the provider is gone, so is the tracking. Pinned because a report that outlived
// its plugin is the one way this could hold a session open forever.
let l = open(
req("playnite:lease-test", DetectSpec::default(), false),
Box::new(|| {}),
);
assert!(matches!(l.shared().kind(), LeaseKind::Untracked));
}
#[test]
fn an_untracked_lease_is_never_terminated() {
let l = open(
@@ -30,7 +30,7 @@
use super::{AppState, CONTROL_PORT};
use crate::inject::gamepad::GamepadManager;
use anyhow::{anyhow, Context, Result};
use punktfunk_core::input::InputEvent;
use punktfunk_core::input::{GamepadEvent, InputEvent};
use punktfunk_core::quic::{classify, GrantClass, HdrMeta, GRANT_ALL};
use rusty_enet::{Event, Host, HostSettings, Packet, PeerID};
use std::net::UdpSocket;
@@ -229,6 +229,65 @@ fn permitted(mask: u32, class: GrantClass, drops: &mut GrantDrops) -> bool {
false
}
/// The virtual Xbox pad a Moonlight session presents, and the one place this plane decides which
/// backend builds it.
///
/// On Windows there are two, and they are not interchangeable to a game: the XUSB companion
/// registers only `GUID_DEVINTERFACE_XUSB` and exposes no HID collection, so Steam's hidapi
/// enumeration, SDL, RawInput, DirectInput, `joy.cpl` and WGI/GameInput cannot see it at all —
/// only classic `XInputGetState` can. The native plane made the HID pad its default on
/// 2026-08-09 for exactly that reason; this plane kept constructing
/// [`GamepadManager`](crate::inject::gamepad::GamepadManager) directly and so kept handing
/// Moonlight clients a pad most games cannot enumerate. Both planes now read the same knob —
/// `native::gamepad::windows_xbox_hid` (not an intra-doc link: it is `cfg(windows)`, so the link
/// would not resolve on any other target) — so `PUNKTFUNK_XBOX_BACKEND=xusb` reverts both
/// together and neither can drift again.
///
/// Everywhere else the choice does not exist: Linux has one uinput X-Box pad, and the stub
/// backend on other platforms drops events.
enum SessionPads {
/// Linux uinput / the Windows XUSB companion — `crate::inject::gamepad`.
Xusb(GamepadManager),
/// The Windows UMDF HID Xbox pad, what the native plane builds by default.
#[cfg(target_os = "windows")]
Hid(crate::inject::xbox_windows::XboxWindowsManager),
}
impl SessionPads {
/// Build this session's pad manager, honoring the shared Windows backend knob.
fn new() -> SessionPads {
#[cfg(target_os = "windows")]
if crate::native::gamepad::windows_xbox_hid() {
return SessionPads::Hid(crate::inject::xbox_windows::XboxWindowsManager::new());
}
SessionPads::Xusb(GamepadManager::new())
}
/// Apply one decoded controller event (create/destroy by mask, then state).
fn handle(&mut self, ev: &GamepadEvent) {
match self {
SessionPads::Xusb(m) => m.handle(ev),
#[cfg(target_os = "windows")]
SessionPads::Hid(m) => m.handle(ev),
}
}
/// Service the pads' feedback protocol and relay changed rumble levels. Games block inside the
/// kernel/driver handshake until answered, so call this every tick.
///
/// The HID pad's rich-feedback plane is discarded rather than plumbed: an Xbox pad has no
/// lightbar or adaptive triggers to report, and GameStream has no vocabulary for one either —
/// its rumble message (`0x010B`, [`super::gamepad::rumble_plaintext`]) carries the two handle
/// motors and nothing else, which is also why the trigger levels are dropped at the call site.
fn pump_rumble(&mut self, rumble: impl FnMut(u16, u16, u16, u16, u16)) {
match self {
SessionPads::Xusb(m) => m.pump_rumble(rumble),
#[cfg(target_os = "windows")]
SessionPads::Hid(m) => m.pump(rumble, |_| {}),
}
}
}
/// Reconcile the control port to the paired-client list: bound while at least one pairing
/// exists, closed when none remain. Idempotent and race-free (see [`Gate::running`]); call it
/// wherever the paired list changes — startup, pairing phase 4, unpair.
@@ -362,7 +421,7 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
// by every outbound message (rumble + the HDR-mode signal): the GCM nonce is derived
// from `seq`, so a per-message-type counter would reuse (key, nonce) pairs across
// message types in the host direction.
let mut pads = GamepadManager::new();
let mut pads = SessionPads::new();
// Pen/touch translator (SS_PEN/SS_TOUCH → virtual tablet / wire touch). Sent only
// by clients that saw our SS_FF_PEN_TOUCH_EVENTS feature flag (rtsp.rs).
let mut pointer = super::pen::GsPointer::new();
@@ -480,7 +539,7 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
hdr_sent = false;
// Unplug the session's virtual pads + tablet (destroying the
// uinput pen releases any held tool/tip kernel-side).
pads = GamepadManager::new();
pads = SessionPads::new();
pointer = super::pen::GsPointer::new();
// Surface the session's enforcement-drop totals (WP13).
drops.end_of_session();
@@ -583,7 +642,7 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
detected = None;
decrypt_fails = 0;
hdr_sent = false;
pads = GamepadManager::new();
pads = SessionPads::new();
pointer = super::pen::GsPointer::new();
drops.end_of_session();
}
@@ -689,7 +748,7 @@ fn on_receive(
detected: &mut Option<Scheme>,
decrypt_fails: &mut u64,
inj_tx: &Sender<InputEvent>,
pads: &mut GamepadManager,
pads: &mut SessionPads,
pointer: &mut super::pen::GsPointer,
grants: u32,
drops: &mut GrantDrops,
+3
View File
@@ -105,6 +105,9 @@ mod plugins;
// session⇄game lifetime binding (design/session-game-lifetime.md §4). Per-OS matchers inside; on a
// platform with neither (macOS, which has no launch path either) the module is an empty shell.
mod procscan;
// The live half of the same binding: what a provider PLUGIN reports about its titles' liveness,
// where `procscan` can only look at the process table.
mod runstate;
mod send_pacing;
#[cfg(target_os = "windows")]
#[path = "windows/service.rs"]
+1
View File
@@ -372,6 +372,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
library::reconcile_provider_entries,
library::delete_provider_entries
))
.routes(routes!(library::report_provider_running))
.routes(routes!(library::get_library_art))
.routes(routes!(stats::stats_capture_start))
.routes(routes!(stats::stats_capture_stop))
+4
View File
@@ -250,6 +250,10 @@ pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
(&Method::DELETE, "/api/v1/library/custom/{}"),
(&Method::PUT, "/api/v1/library/provider/{}"),
(&Method::DELETE, "/api/v1/library/provider/{}"),
// Liveness reporting for a provider's OWN titles. No new authority: the host maps the
// report through the catalog, so a plugin can only ever speak about entries it published,
// and the worst a defective one can do to someone else's session is nothing at all.
(&Method::PUT, "/api/v1/library/provider/{}/running"),
// Stats / telemetry.
(&Method::POST, "/api/v1/stats/capture/start"),
(&Method::POST, "/api/v1/stats/capture/stop"),
+118
View File
@@ -607,12 +607,130 @@ pub(crate) async fn delete_provider_entries(Path(provider): Path<String>) -> Res
if removed > 0 {
tracing::info!(provider, removed, "library provider entries removed");
}
// Its entries are gone, so its opinions about them are meaningless — and a lease must
// never be held open by a provider that no longer exists.
crate::runstate::forget(&provider);
Json(ProviderRemoved { removed }).into_response()
}
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
/// One running title in a provider's liveness report.
#[derive(Deserialize, ToSchema)]
pub(crate) struct RunningTitle {
/// The provider's own stable id for the title — the same key its reconcile payload uses.
pub external_id: String,
/// The process id the provider started for it, when it knows one. Optional, and never trusted
/// as a bare number: the host re-resolves it and pins it to its start time before it is ever
/// signalled, so a stale or recycled pid simply contributes nothing.
#[serde(default)]
pub pid: Option<u32>,
}
/// Request body for `reportProviderRunning`.
#[derive(Deserialize, ToSchema)]
pub(crate) struct ProviderRunningInput {
/// Every title of this provider's that is running **right now**. The full set, not a delta:
/// anything absent from it is reported as stopped.
#[serde(default)]
pub running: Vec<RunningTitle>,
}
/// The result of a liveness report.
#[derive(Serialize, ToSchema)]
pub(crate) struct ProviderRunningAccepted {
/// How many reported titles matched an entry this provider currently publishes.
matched: usize,
/// How many were ignored because no such entry exists (a report that raced a reconcile).
unknown: usize,
/// Seconds this report stays authoritative without being restated — re-report inside it while
/// anything is running.
ttl_s: u64,
}
/// Report which of a provider's titles are running
///
/// The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to
/// recognize* a title's process, this one says *it is running now* (design §9,
/// [`crate::runstate`]). For a provider that starts games itself and knows when they stop —
/// Playnite tracks every launch and fires an event on both edges — this is a fact the host would
/// otherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated
/// game, a manually added one) could not derive at all.
///
/// Declarative and idempotent, like the reconcile: the body is the provider's **complete** running
/// set, so a missed event, a plugin restart or an install mid-game all self-correct on the next
/// report rather than drifting.
///
/// The report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a
/// live provider to keep a streaming session open for a game the host cannot see: a plugin that
/// dies with a game running stops counting shortly after, and the host falls back to process
/// scanning exactly as it does without one. Re-report on every change **and** on a timer well
/// inside the window.
///
/// Titles the provider does not currently publish are ignored (counted in `unknown`), not an error:
/// a report may legitimately race its own reconcile.
#[utoipa::path(
put,
path = "/library/provider/{provider}/running",
tag = "library",
operation_id = "reportProviderRunning",
params(("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)")),
request_body = ProviderRunningInput,
responses(
(status = OK, description = "The report was accepted", body = ProviderRunningAccepted),
(status = BAD_REQUEST, description = "Invalid provider id or payload", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn report_provider_running(
Path(provider): Path<String>,
ApiJson(input): ApiJson<ProviderRunningInput>,
) -> Response {
if let Err(e) = crate::library::validate_provider_name(&provider) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
// Resolve the provider's own keys to the ids the rest of the host uses. A plugin knows its
// titles by `external_id`; a lease knows them by the library id the catalog assigned
// (`playnite:<guid>`), and only the catalog can map between the two — which is also what makes
// this authorization-safe, since a provider can only ever speak about entries it published.
let mine: Vec<(String, String)> = crate::library::load_custom()
.into_iter()
.filter(|e| e.provider.as_deref() == Some(provider.as_str()))
.filter_map(|e| {
let external = e.external_id.clone()?;
Some((external, crate::library::library_id_for(&e)))
})
.collect();
let owned: std::collections::HashSet<String> = mine.iter().map(|(_, id)| id.clone()).collect();
let mut running = std::collections::HashMap::new();
let mut unknown = 0usize;
for t in &input.running {
match mine.iter().find(|(external, _)| *external == t.external_id) {
Some((_, id)) => {
running.insert(id.clone(), t.pid);
}
None => unknown += 1,
}
}
let matched = running.len();
tracing::debug!(
provider,
owned = owned.len(),
matched,
unknown,
"provider liveness report"
);
crate::runstate::report(&provider, owned, running);
Json(ProviderRunningAccepted {
matched,
unknown,
ttl_s: crate::runstate::REPORT_TTL.as_secs(),
})
.into_response()
}
/// Fetch one cover-art image for a library entry
///
/// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams
+61
View File
@@ -1440,6 +1440,16 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
("DELETE", "/api/v1/library/custom/{id}", true, false),
("PUT", "/api/v1/library/provider/{provider}", true, false),
("DELETE", "/api/v1/library/provider/{provider}", true, false),
// Liveness for a provider's own titles: the plugin lane's, like the reconcile beside it,
// and for the same reason — the host maps the report through the catalog, so a provider can
// only ever speak about entries it published. Never the cert lane: a streaming client has
// no titles of its own to report on.
(
"PUT",
"/api/v1/library/provider/{provider}/running",
true,
false,
),
// ---- stats.
("POST", "/api/v1/stats/capture/start", true, false),
("POST", "/api/v1/stats/capture/stop", true, false),
@@ -2935,3 +2945,54 @@ async fn provider_reconcile_validation() {
let (s, _) = send(&app, del).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
/// Liveness reporting: the provider id is validated like every other provider write, and a title
/// the provider does not publish is *counted*, not refused.
///
/// That tolerance is the point. A report races its own reconcile by construction — a game can start
/// before the entry that describes it has landed — and 400-ing the whole report over one unknown id
/// would throw away the liveness of every other running title, which is precisely the failure the
/// launcher-tile 400 taught us to avoid (`sanitize_launcher_entries`). The developer's real catalog
/// is not touched here, so every id in this test is `unknown` by construction — which is exactly
/// the case being pinned.
#[tokio::test]
async fn provider_running_report_validation() {
let app = test_app(test_state(), None);
let put = |provider: &str, body: serde_json::Value| {
axum::http::Request::put(format!("/api/v1/library/provider/{provider}/running"))
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string()))
.unwrap()
};
let (s, json) = send(&app, put("manual", serde_json::json!({"running": []}))).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
assert!(json["error"].as_str().unwrap().contains("reserved"));
let (s, _) = send(&app, put("Bad%2FName", serde_json::json!({"running": []}))).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
// An unreported provider is a legitimate report of "nothing is running".
let (s, json) = send(&app, put("playnite", serde_json::json!({"running": []}))).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(json["matched"], 0);
assert_eq!(json["unknown"], 0);
assert!(json["ttl_s"].as_u64().unwrap() > 0);
// An id this provider does not publish is ignored, not an error.
let (s, json) = send(
&app,
put(
"playnite",
serde_json::json!({"running": [{"external_id": "no-such-title", "pid": 4242}]}),
),
)
.await;
assert_eq!(s, StatusCode::OK);
assert_eq!(json["matched"], 0);
assert_eq!(json["unknown"], 1);
// A report leaves no opinion behind about a title nobody published, so nothing this test did
// can hold a real lease open.
assert!(!crate::runstate::speaks_for(Some("playnite:no-such-title")));
crate::runstate::forget("playnite");
}
+32 -2
View File
@@ -48,8 +48,10 @@ mod compositor;
use compositor::resolve_compositor;
/// Virtual-gamepad backend resolution (plan §W1); `serve_session` + the `Pads` state machine reach
/// `resolve_gamepad`/`resolve_pad_kind`/`route_decision` here.
mod gamepad;
/// `resolve_gamepad`/`resolve_pad_kind`/`route_decision` here. Crate-visible because the choice of
/// Windows Xbox backend (`windows_xbox_hid`) is not the native plane's alone — the GameStream plane
/// presents the same virtual pad and has to make the same choice, from one definition.
pub(crate) mod gamepad;
use gamepad::{resolve_gamepad, resolve_pad_kind, route_decision};
/// The SPAKE2 pairing ceremony (plan §W1); `serve_session` dispatches a PairRequest connection here.
@@ -1402,6 +1404,12 @@ async fn serve_session(
// evidence (a refusal without the score left a 23-minute floor-pinned field session with no
// trace of why).
let cadence_behind_score = Arc::new(AtomicU32::new(0));
// Delivery truth, control task → data plane: the packet count the client reports having
// received all session (`u32::MAX` until a client new enough to answer sends one). The data
// plane needs it to tell a clean link from a dead one — `loss_ppm = 0` means both — before it
// blames the client for a stream that never reached it.
let client_packets_received = Arc::new(AtomicU32::new(u32::MAX));
let client_packets_received_ctl = client_packets_received.clone();
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
let (probe_result_tx, probe_result_rx) = tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
// Mode-switch outcome, data plane → control task (same pattern as `probe_result_tx`): the accept
@@ -1533,6 +1541,7 @@ async fn serve_session(
encoder_ceiling_kbps.clone(),
cadence_degraded.clone(),
cadence_behind_score.clone(),
client_packets_received_ctl,
fec_target_ctl,
phase_ctl_control,
reconfig_tx,
@@ -2091,6 +2100,26 @@ async fn serve_session(
address with no hole-punch; else punched=true the client's observed source, \
false no punch seen, the reported address)"
);
// A punch that never arrives is not a routine fallback — it is the fingerprint of a
// data port the client cannot reach INBOUND, and every client punches (5/s for the
// first three seconds, then every two). Video then goes to an address the client only
// CLAIMED, unverified, and if anything on the path needed the flow opened client-first
// it silently goes nowhere: black picture, healthy control plane, no error anywhere.
// On Windows the usual cause is a firewall rule that opens fixed ports only, while
// this port is ephemeral and different every session (fixed by the program-scoped rule
// `service install` now adds — an install predating it still has the old rules).
// `direct` skips the punch by operator choice, so it is not a failure there.
if !direct && !punched {
tracing::warn!(
%client_udp,
udp_port,
"no hole-punch reached this host's data port — inbound UDP to it looks \
BLOCKED, so video is being sent to the address the client reported without \
any confirmed return path. If the picture stays black while the session is \
otherwise healthy, this line is the reason: allow inbound UDP for the host \
executable (any port), or pin --data-port and open that one"
);
}
let mut session = Session::new(cfg, Box::new(transport))
.map_err(|e| anyhow!("host session: {e:?}"))?;
match source {
@@ -2125,6 +2154,7 @@ async fn serve_session(
encoder_ceiling_kbps,
cadence_degraded,
cadence_behind_score,
client_packets_received,
bitrate_auto,
bit_depth,
chroma,
@@ -30,6 +30,10 @@ pub(super) async fn run(
encoder_ceiling_kbps: Arc<AtomicU32>,
cadence_degraded: Arc<AtomicBool>,
cadence_behind_score: Arc<AtomicU32>,
// Delivery truth, published from every `DeliveryReport` for the data plane's stall diagnosis:
// the packets the client says it has received all session (`u32::MAX` = a client too old to
// send one, the pre-seeded value).
client_packets_received: Arc<AtomicU32>,
fec_target_ctl: Arc<AtomicU8>,
// Phase-locked capture bridge: client PhaseReports land here latest-wins; the encode loop's
// controller drains at its own ~1 Hz cadence (design/phase-locked-capture.md).
@@ -162,6 +166,16 @@ pub(super) async fn run(
if rfi_tx.send((req.first_frame, req.last_frame)).is_err() {
break; // data plane gone
}
} else if let Ok(rep) = punktfunk_core::quic::DeliveryReport::decode(&msg) {
// What the client has actually RECEIVED — published unconditionally, because it
// is what lets the data plane read `loss_ppm = 0` correctly and must survive
// both the `adaptive_fec` opt-out and a pinned FEC percentage (a host with
// PUNKTFUNK_FEC_PCT set is exactly as blind to a dead data plane otherwise).
// Saturated into the u32 bridge; the value only ever matters near zero.
client_packets_received.store(
rep.packets_received.min(u32::MAX as u64 - 1) as u32,
Ordering::Relaxed,
);
} else if let Ok(rep) = LossReport::decode(&msg) {
// Adaptive FEC: size recovery to the loss the client is seeing. The data-plane
// send loop reads `fec_target_ctl` and applies it per frame. Ignored when FEC
+6 -1
View File
@@ -363,8 +363,13 @@ fn degrade_xbox_identity(chosen: GamepadPref) -> GamepadPref {
///
/// The two backends are mutually exclusive per pad by construction (one match arm or the other) —
/// presenting both would hand a game two controllers for one pair of hands.
///
/// Read by BOTH input planes. The native plane branches on it in `Pads::handle`; the GameStream
/// plane in `gamestream::control::SessionPads`. It was `pub(super)` while only the native plane
/// consulted it, and that is exactly how Moonlight sessions spent two releases on the XUSB pad
/// after this default flipped — the knob was unreachable from the module that needed it.
#[cfg(target_os = "windows")]
pub(super) fn windows_xbox_hid() -> bool {
pub(crate) fn windows_xbox_hid() -> bool {
match std::env::var("PUNKTFUNK_XBOX_BACKEND") {
Ok(v) if v.trim().eq_ignore_ascii_case("xusb") => false,
// Anything else — unset, empty, "hid", or a typo — takes the default. A misspelled opt-out
+109 -8
View File
@@ -1319,6 +1319,14 @@ pub(super) struct SessionContext {
/// of what held it there — the score is the missing discriminator between "the detector's
/// budget is wrong" and "this encoder genuinely can't hold cadence").
pub(super) cadence_behind_score: Arc<AtomicU32>,
/// Data-plane packets the CLIENT says it has received all session, from the latest
/// [`punktfunk_core::quic::DeliveryReport`] ([`u32::MAX`] = a client too old to send one).
///
/// The one signal that distinguishes "the link is clean" from "nothing is arriving": both look
/// like `loss_ppm = 0`, because loss is a ratio over the packets that DID arrive. Read by the
/// keyframe-cadence diagnosis below, which without it accuses the client of being too slow for
/// a stream it has never received a byte of.
pub(super) client_packets_received: Arc<AtomicU32>,
/// The client asked for "Automatic" (`Hello::bitrate_kbps == 0`), so `bitrate_kbps` came from
/// the host's codec-aware default. For PyroWave that default is the ~1.6 bpp operating point of
/// the NEGOTIATED MODE (`resolve_bitrate_kbps_for`) — a mid-stream mode switch re-resolves it
@@ -1598,6 +1606,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
encoder_ceiling_kbps,
cadence_degraded,
cadence_behind_score,
client_packets_received,
bitrate_auto,
bit_depth,
// The resolved chroma is already captured in `plan` (above); ignore the duplicate here.
@@ -3006,16 +3015,65 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// subsystems while the real chain was: client refused the codec → demoted to
// a slower decode rung → could not sustain the rate → standing queue.
// Perfect periodicity argues FOR a software cooldown, not against it.
if matches_client_flush_cadence(period) {
tracing::warn!(
let client_rx = client_packets_received.load(Ordering::Relaxed);
// The client has TOLD us it has received nothing all session (a v1 client
// leaves the `u32::MAX` seed, so this only fires on an explicit zero). That
// outranks both cadence verdicts below, which are about a client drowning in
// frames — the opposite failure, and indistinguishable by period alone because
// a client that got no picture re-asks on its own no-video timer at very
// nearly the same spacing. Diagnosing this as "too slow" cost a 2026-08-20
// field investigation days: the host was blameless-looking (`sent` climbing,
// `loss_ppm = 0`, FEC decayed to the floor) while not one byte of video ever
// reached the client.
if client_rx == 0 {
tracing::error!(
period_s = format!("{:.1}", period.as_secs_f64()),
"client keyframe recoveries match the client's jump-to-live cooldown \
the CLIENT cannot sustain the stream and is shedding a standing \
receive queue (check its log for 'receive backlog stopped draining' \
with queue_depth, and for a decode rung that demoted); a slower \
decode path or a link below the bitrate does this, and it is NOT a \
host display disturbance"
frames_sent = sent,
"THE VIDEO DATA PLANE IS NOT REACHING THE CLIENT — it reports 0 \
packets received all session while this host has sent the frames \
counted here, so the picture is black and every keyframe we force is \
wasted. The control plane is healthy (this report arrived on it), so \
the session looks alive: audio, input and the library keep working. \
This is a PATH problem, not decode check that inbound UDP to this \
host's per-session data port is allowed (the 'data plane bound' line \
above shows `punched=false` when the client's hole-punch never \
arrived, which is the fingerprint), and that no other host or \
firewall is intercepting it"
);
} else if matches_client_recovery_cooldown(period) {
if client_rx == u32::MAX {
// This client predates the delivery count, so the period alone has to
// carry the verdict — and it CANNOT: both client cooldowns live in this
// band and they mean opposite things. Say so instead of picking one.
// The old confident wording sent a field investigation after the
// decoder for days while the real fault was that nothing arrived.
tracing::warn!(
period_s = format!("{:.1}", period.as_secs_f64()),
frames_sent = sent,
"client keyframe recoveries land on a client software cooldown, \
but this client is too old to report whether any video reached \
it so this is EITHER a client that cannot sustain the stream \
and is shedding a standing receive queue, OR a client that has \
received nothing at all and is re-asking on its no-video timer. \
They are opposite faults; the host cannot tell them apart from \
the period. Its log does: 'receive backlog stopped draining' \
(with queue_depth) means the first, 'no video received into \
the session' means the second. Upgrading the client makes this \
line decide on its own"
);
} else {
tracing::warn!(
period_s = format!("{:.1}", period.as_secs_f64()),
client_packets_received = client_rx,
"client keyframe recoveries match the client's jump-to-live \
cooldown, and it confirms video IS arriving the CLIENT cannot \
sustain the stream and is shedding a standing receive queue \
(check its log for 'receive backlog stopped draining' with \
queue_depth, and for a decode rung that demoted); a slower \
decode path or a link below the bitrate does this, and it is NOT \
a host display disturbance"
);
}
} else {
tracing::warn!(
period_s = format!("{:.1}", period.as_secs_f64()),
@@ -4191,6 +4249,26 @@ fn matches_client_flush_cadence(period: std::time::Duration) -> bool {
period.abs_diff(flush) < flush / 10
}
/// The client's OTHER re-ask cooldown: it has received no video whatsoever and is asking for a
/// keyframe on its no-video timer. Kept separate from [`matches_client_flush_cadence`] because the
/// two describe opposite faults — drowning in frames versus receiving none — and only the client's
/// reported delivery count can say which. Both are host-side-irrelevant either way: a fixed
/// software cooldown is never the periodic *disturbance* the metronomic branch reports.
///
/// Compared against the SHARED constant, never a copy of the number — the same discipline
/// [`matches_client_flush_cadence`] follows, and the one that was missing when the two cooldowns
/// were both 2000 ms and the host could not even tell that it was guessing.
fn matches_client_no_video_cadence(period: std::time::Duration) -> bool {
let no_video = punktfunk_core::client::NO_VIDEO_RETRY;
period.abs_diff(no_video) < no_video / 10
}
/// Either client cooldown — the band in which a period tells us about the CLIENT's software, not
/// about anything physical on this host.
fn matches_client_recovery_cooldown(period: std::time::Duration) -> bool {
matches_client_flush_cadence(period) || matches_client_no_video_cadence(period)
}
/// One mode's capture/encode pipeline: (capturer, encoder, first frame, frame interval).
/// Dropping the capturer tears down the PipeWire stream and the virtual output with it.
type Pipeline = (
@@ -5068,6 +5146,29 @@ mod tests {
assert!(!matches_client_flush_cadence(std::time::Duration::ZERO));
}
/// The two client cooldowns must stay TELLABLE APART by period, and both must stay out of the
/// display-disturbance branch. While they were both 2000 ms a black-screen field case (nothing
/// ever reached the client) was reported as "the client cannot sustain the stream" — the exact
/// opposite fault — because the periods were identical and the host guessed.
#[test]
fn the_two_client_cooldowns_are_distinguishable_and_both_excluded_from_display_blame() {
let flush = punktfunk_core::client::FLUSH_COOLDOWN;
let no_video = punktfunk_core::client::NO_VIDEO_RETRY;
assert_ne!(
flush, no_video,
"identical cooldowns make the host's verdict a coin flip"
);
// Neither may fall inside the other's ±10% band, or the period stops discriminating.
assert!(!matches_client_flush_cadence(no_video));
assert!(!matches_client_no_video_cadence(flush));
// Both are client software cooldowns: never the metronomic display-disturbance branch.
assert!(matches_client_recovery_cooldown(flush));
assert!(matches_client_recovery_cooldown(no_video));
// A real periodic disturbance still reaches that branch.
assert!(!matches_client_recovery_cooldown(flush * 3));
assert!(!matches_client_recovery_cooldown(std::time::Duration::ZERO));
}
#[test]
fn an_escalated_but_caught_up_encoder_stops_refusing_climbs() {
const DEGRADE: u32 = 10;
+217
View File
@@ -0,0 +1,217 @@
//! What a provider plugin **says** is running — the one liveness signal the host cannot work out
//! for itself.
//!
//! [`crate::procscan`] answers "is this game running" by looking at the process table, and
//! [`crate::gamelease`] turns that into a session lifetime. That works because most stores leave
//! something recognizable behind: an install directory, an executable, a Steam reaper. Some do not,
//! and one store in particular *already knows the answer*: Playnite starts the game itself, tracks
//! it with the mode the person configured (process, directory, original-process), and fires an
//! event on both edges — carrying the pid it started. Every bit of that was being thrown away, and
//! the host was left re-deriving a worse version of it by scanning.
//!
//! So this is the inbound half of [`crate::library::DetectHint`]. That one is *static* ("here is
//! how to recognize my title's process"); this one is *live* ("that title is running right now, and
//! here is its pid"). A provider PUTs its full running set; the host keeps it here; the lease
//! watcher consults it.
//!
//! ### Why the whole set, and why a TTL
//!
//! The wire is declarative — the same shape as the library reconcile, for the same reason. A
//! provider that missed an event, restarted, or was installed mid-game converges on its next PUT
//! instead of drifting forever; there is no per-event delta to lose.
//!
//! And a report **expires**. A plugin that dies with a game running would otherwise leave a claim
//! that is true today and a lie tomorrow — and unlike Steam's registry flag (which
//! [`crate::procscan::running_hint`] must treat as merely a bounded veto because Steam leaves it
//! set on any unclean exit) this claim is allowed to *keep a session alive on its own*. That is
//! only safe while something is actively restating it, so a report older than [`REPORT_TTL`] stops
//! counting and the host falls back to scanning, exactly as it does today. The provider's side of
//! that bargain is to re-PUT well inside the window while anything is running.
use std::collections::{HashMap, HashSet};
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::time::{Duration, Instant};
/// How long a provider's report stays authoritative without being restated.
///
/// Generous enough that a plugin refreshing every 30s survives a slow reconcile or a paused runner,
/// short enough that a *dead* plugin stops vetoing a session end within a couple of minutes. The
/// cost of expiring too early is the pre-existing behaviour (scan-only); the cost of never expiring
/// is a session that can never end on its own, which is the bug this whole area exists to kill.
pub const REPORT_TTL: Duration = Duration::from_secs(90);
/// What a provider says about one of its titles.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Liveness {
/// Whether the provider lists this title as running right now.
pub running: bool,
/// The pid the provider started for it, when it knows one. Never trusted as a bare number —
/// every use re-verifies it through [`crate::procscan`], which pins it to its start time.
pub pid: Option<u32>,
}
/// One provider's most recent report.
struct Report {
/// When it landed — the TTL clock.
at: Instant,
/// Every library id this provider speaks for. What makes "not in `running`" mean *not running*
/// rather than *no opinion*: without it an omitted title is indistinguishable from a title
/// belonging to some other provider entirely.
owned: HashSet<String>,
/// The subset that is running, each with the pid the provider started (when it has one).
running: HashMap<String, Option<u32>>,
}
impl Report {
fn fresh(&self) -> bool {
self.at.elapsed() < REPORT_TTL
}
}
fn table() -> MutexGuard<'static, HashMap<String, Report>> {
static TABLE: OnceLock<Mutex<HashMap<String, Report>>> = OnceLock::new();
TABLE
.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
.unwrap_or_else(|e| e.into_inner())
}
/// Record a provider's report, replacing whatever it said before.
///
/// `owned` is every library id the provider currently publishes; `running` is the subset that is
/// running, keyed the same way, valued by pid where one is known.
pub fn report(provider: &str, owned: HashSet<String>, running: HashMap<String, Option<u32>>) {
table().insert(
provider.to_string(),
Report {
at: Instant::now(),
owned,
running,
},
);
}
/// Forget everything a provider said — its entries are gone, so its opinions are meaningless.
pub fn forget(provider: &str) {
table().remove(provider);
}
/// What a *fresh* provider says about this library id, or `None` when none speaks for it.
///
/// `None` is the answer for every title on a host with no reporting plugin, which is what keeps
/// this entirely inert until someone opts in.
pub fn opinion(app_id: &str) -> Option<Liveness> {
let table = table();
table
.values()
.filter(|r| r.fresh())
.find(|r| r.owned.contains(app_id))
.map(|r| match r.running.get(app_id) {
Some(pid) => Liveness {
running: true,
pid: *pid,
},
None => Liveness {
running: false,
pid: None,
},
})
}
/// Whether any fresh provider reports liveness for this title at all — regardless of what it
/// currently says.
///
/// Asked once, when a lease opens: a title whose provider will tell us when it stops is trackable
/// even with no detect signals whatsoever, which is the whole point (see
/// [`crate::gamelease::LeaseKind::Reported`]).
pub fn speaks_for(app_id: Option<&str>) -> bool {
app_id.is_some_and(|id| opinion(id).is_some())
}
#[cfg(test)]
mod tests {
use super::*;
fn owned(ids: &[&str]) -> HashSet<String> {
ids.iter().map(|s| (*s).to_string()).collect()
}
fn running(ids: &[(&str, Option<u32>)]) -> HashMap<String, Option<u32>> {
ids.iter().map(|(s, p)| ((*s).to_string(), *p)).collect()
}
// The table is process-global and these tests run in parallel, so each takes a provider id and
// app ids only it uses, and cleans up only its own row. An earlier draft shared the id
// `playnite` and cleared the whole table between cases, which made the three of them flip each
// other's answers depending on scheduling — the same shape as `mgmt`'s `local_summary` race.
/// The three answers, and the distinction the whole module turns on: a title its provider omits
/// is *not running*, while a title nobody speaks for has *no opinion*. Conflating them would
/// make every unreported game on the box look like it had just quit.
#[test]
fn omitted_is_not_running_but_unknown_is_no_opinion() {
report(
"answers-test",
owned(&["answers:a", "answers:b"]),
running(&[("answers:a", Some(4242))]),
);
assert_eq!(
opinion("answers:a"),
Some(Liveness {
running: true,
pid: Some(4242)
})
);
assert_eq!(
opinion("answers:b"),
Some(Liveness {
running: false,
pid: None
})
);
assert_eq!(opinion("answers:never-published"), None);
assert!(speaks_for(Some("answers:b")));
assert!(!speaks_for(Some("answers:never-published")));
assert!(!speaks_for(None));
forget("answers-test");
}
/// A report replaces its predecessor wholesale. The set is the message: a title that dropped out
/// of it has stopped, and carrying the old entry forward would be exactly the stuck-running
/// state this exists to prevent.
#[test]
fn a_report_replaces_the_previous_one() {
report(
"replace-test",
owned(&["replace:a"]),
running(&[("replace:a", None)]),
);
report("replace-test", owned(&["replace:a"]), running(&[]));
assert_eq!(
opinion("replace:a"),
Some(Liveness {
running: false,
pid: None
})
);
forget("replace-test");
assert_eq!(opinion("replace:a"), None);
}
/// A stale report stops counting — the bound that makes it safe to let a plugin's claim hold a
/// session open. Seeded with an aged timestamp rather than by sleeping for 90 seconds.
#[test]
fn a_stale_report_has_no_opinion() {
table().insert(
"stale-test".to_string(),
Report {
at: Instant::now() - REPORT_TTL - Duration::from_secs(1),
owned: owned(&["stale:a"]),
running: running(&[("stale:a", Some(7))]),
},
);
assert_eq!(opinion("stale:a"), None);
assert!(!speaks_for(Some("stale:a")));
forget("stale-test");
}
}
@@ -1587,6 +1587,7 @@ fn add_firewall_rules(allow_public: bool) {
eprintln!("warning: could not add firewall rule '{name}' (add it manually if needed)");
}
}
add_data_plane_firewall_rule(profile);
if !allow_public {
println!(
"Note: streaming ports are open on Private/Domain networks only. On a network Windows \
@@ -1596,7 +1597,75 @@ fn add_firewall_rules(allow_public: bool) {
}
}
/// Rule name for the program-scoped data-plane rule (see [`add_data_plane_firewall_rule`]).
const FW_DATA_PLANE_RULE: &str = "Punktfunk UDP (data plane)";
/// Inbound UDP for the host executable itself, at **any** local port.
///
/// The media data plane binds an EPHEMERAL port per session (`0.0.0.0:0`, reported to the client in
/// the Welcome), so no `localport=` rule can cover it — the port-scoped rules above open the fixed
/// control/GameStream/mDNS ports and nothing else. Without this, Windows Firewall drops the client's
/// hole-punch (`PUNCH_MAGIC` → the host's data port) on EVERY session: that is what `punched=false`
/// on the host's "data plane bound" line means. The punch then never opens the return path, video
/// falls back to blind-sending at the address the client merely *reported*, and the moment anything
/// on the path needs the flow opened client-first the stream goes black while the control plane
/// stays healthy — no reconnect, no error, just a session that never shows a picture.
///
/// Program-scoped rather than a pinned port: it covers whatever port the session picks, needs no
/// second rule when the range moves, and cannot collide with another host (a pinned data port in
/// 47998-48010 would land on Sunshine/Apollo's GameStream range). The port rules above are kept as
/// they are — an install whose recorded exe path later moves still has its fixed ports open.
fn add_data_plane_firewall_rule(profile: &str) {
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
eprintln!(
"warning: could not resolve the host executable path ({e}) — skipping the \
data-plane firewall rule; streams may show a black picture behind a healthy \
connection on networks that need the client's hole-punch to open the path"
);
return;
}
};
let ok = run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"add",
"rule",
&format!("name={FW_DATA_PLANE_RULE}"),
"dir=in",
"action=allow",
"protocol=UDP",
&format!("program={}", exe.to_string_lossy()),
profile,
],
);
if ok {
println!(
"Firewall rule added: {FW_DATA_PLANE_RULE} (any UDP port for {}) [{profile}]",
exe.display()
);
} else {
eprintln!(
"warning: could not add firewall rule '{FW_DATA_PLANE_RULE}' — the per-session video \
data port stays closed to inbound, so the client's hole-punch cannot reach it"
);
}
}
fn remove_firewall_rules() {
let _ = run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"delete",
"rule",
&format!("name={FW_DATA_PLANE_RULE}"),
],
);
for suffix in ["TCP", "UDP"] {
// Capital P is the brand; netsh matches a rule name case-INSENSITIVELY, so this still
// reaps the lowercase rules every release up to 0.22.1 created — no orphans on upgrade.
+10 -6
View File
@@ -230,13 +230,15 @@ How the client itself looks. None touches a stream, so none can live in a
**Gamepad-optimized browsing** — *default: on.* Swaps the touch/desktop home for the
controller-optimized one: host carousel, larger focus targets, a swipeable cover browser, steppable
settings. Apple and Android have the switch; on Linux, Windows and the Steam Deck the
controller-optimized home is a separate entry point. An Android TV is always in this mode.
settings. Apple and Android have the switch on Android in both places, ordinary Settings and the
controller-optimized settings themselves, so that home can be left from inside it; on Linux, Windows
and the Steam Deck the controller-optimized home is a separate entry point. An Android TV is always
in this mode, so the switch is not offered there.
**Show it** — *default: With a controller.* Shown while the switch above is on. **With a
controller**: the controller-optimized home appears as a pad connects, the touch interface returns
when the last one disconnects. **Always** keeps it either way — for a phone or tablet docked to a
TV. Apple and Android (an Android TV is in that mode regardless).
TV. Apple and Android (an Android TV is in that mode regardless, so the row is not offered there).
**Background** — *default: Violet.* The colour family of the controller-optimized home's backdrop.
Thirteen: seven dark — **Violet**, **OLED**, **Nebula**, **Abyss**, **Ember**, **Moss**,
@@ -244,9 +246,11 @@ Thirteen: seven dark — **Violet**, **OLED**, **Nebula**, **Abyss**, **Ember**,
flip the interface to dark text on a light field. The backdrop recolours as you step the row.
**OLED** is true black: most of the frame is pixels switched off — no glow, no power on an
OLED/AMOLED panel. Stored under the same name on every client. The row lives in the
controller-optimized settings (**X** from the controller-optimized home) everywhere that has one,
including the Steam Deck and the Linux/Windows console home; the Apple TV carries it in ordinary
Settings next to **Show it** instead, so it's reachable from the Siri Remote.
controller-optimized settings (**X**, or **down** on the host carousel, from the controller-optimized
home) everywhere that has one, including the Steam Deck and the Linux/Windows console home — down is
the route where there are no face buttons to press, such as an Android TV remote, and the hint bar
names whichever your device has; the Apple TV carries it in ordinary Settings next to **Show it**
instead, so it's reachable from the Siri Remote.
## Overlay
+3 -2
View File
@@ -137,8 +137,9 @@ runs what it already knows about the title, so a client can never hand the host
keep a **Show game library** switch, on by default, for turning it off. See
[Client settings](/docs/client-settings).
- **Android** — the library lives only in the controller-optimized home, which a TV always uses and a
phone or tablet switches to when a controller is connected. Press **Y** on a saved host, or open its
options and choose **Library**.
phone or tablet switches to when a controller is connected. Press **Y** on a saved host, or press
**up** for its options and choose **Library** — the route a TV remote takes, having no **Y** to
press.
- **Steam Deck (Decky)** — the panel is a launcher and browses nothing itself: tap **Open
Punktfunk**, which opens the client's console home, where a paired host's **Library** button is —
full-screen covers, gamepad-navigable, and a press starts the stream with the title launching. See
+125
View File
@@ -1860,6 +1860,69 @@
}
}
},
"/api/v1/library/provider/{provider}/running": {
"put": {
"tags": [
"library"
],
"summary": "Report which of a provider's titles are running",
"description": "The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to\nrecognize* a title's process, this one says *it is running now* (design §9,\n[`crate::runstate`]). For a provider that starts games itself and knows when they stop —\nPlaynite tracks every launch and fires an event on both edges — this is a fact the host would\notherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated\ngame, a manually added one) could not derive at all.\n\nDeclarative and idempotent, like the reconcile: the body is the provider's **complete** running\nset, so a missed event, a plugin restart or an install mid-game all self-correct on the next\nreport rather than drifting.\n\nThe report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a\nlive provider to keep a streaming session open for a game the host cannot see: a plugin that\ndies with a game running stops counting shortly after, and the host falls back to process\nscanning exactly as it does without one. Re-report on every change **and** on a timer well\ninside the window.\n\nTitles the provider does not currently publish are ignored (counted in `unknown`), not an error:\na report may legitimately race its own reconcile.",
"operationId": "reportProviderRunning",
"parameters": [
{
"name": "provider",
"in": "path",
"description": "The provider id ([a-z0-9._-], `manual` reserved)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProviderRunningInput"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "The report was accepted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProviderRunningAccepted"
}
}
}
},
"400": {
"description": "Invalid provider id or payload",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/library/scanners": {
"get": {
"tags": [
@@ -7792,6 +7855,46 @@
}
}
},
"ProviderRunningAccepted": {
"type": "object",
"description": "The result of a liveness report.",
"required": [
"matched",
"unknown",
"ttl_s"
],
"properties": {
"matched": {
"type": "integer",
"description": "How many reported titles matched an entry this provider currently publishes.",
"minimum": 0
},
"ttl_s": {
"type": "integer",
"format": "int64",
"description": "Seconds this report stays authoritative without being restated — re-report inside it while\nanything is running.",
"minimum": 0
},
"unknown": {
"type": "integer",
"description": "How many were ignored because no such entry exists (a report that raced a reconcile).",
"minimum": 0
}
}
},
"ProviderRunningInput": {
"type": "object",
"description": "Request body for `reportProviderRunning`.",
"properties": {
"running": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RunningTitle"
},
"description": "Every title of this provider's that is running **right now**. The full set, not a delta:\nanything absent from it is reported as stopped."
}
}
},
"ReleaseDisplayRequest": {
"type": "object",
"description": "Request body for `releaseDisplay`.",
@@ -7846,6 +7949,28 @@
}
}
},
"RunningTitle": {
"type": "object",
"description": "One running title in a provider's liveness report.",
"required": [
"external_id"
],
"properties": {
"external_id": {
"type": "string",
"description": "The provider's own stable id for the title — the same key its reconcile payload uses."
},
"pid": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "The process id the provider started for it, when it knows one. Optional, and never trusted\nas a bare number: the host re-resolves it and pins it to its start time before it is ever\nsignalled, so a stale or recycled pid simply contributes nothing.",
"minimum": 0
}
}
},
"RuntimeRequest": {
"type": "object",
"required": [
+5
View File
@@ -1224,6 +1224,11 @@
#define PUNKTFUNK_MSG_PIPELINE_GAP 10
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`DeliveryReport`].
#define PUNKTFUNK_MSG_DELIVERY_REPORT 11
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ProbeRequest`].
#define PUNKTFUNK_MSG_PROBE_REQUEST 32
+86 -4
View File
@@ -31,11 +31,39 @@
{
lib,
gamescope,
fetchFromGitHub,
python3,
patchDir,
manifestRewriter,
}:
let
# PIN THE COMPOSITOR SOURCE, rather than patching whatever gamescope nixpkgs happens to carry.
# Every other channel already ships this exact commit — packaging/gamescope/README.md,
# punktfunk-gamescope.spec, the PKGBUILD and build-punktfunk-gamescope.sh — and nix was the
# only one tracking nixpkgs' version and hoping ten patches still applied.
#
# They did not, and the failures were not academic (MEASURED 2026-08-19/20):
# * nixpkgs shipped 3.16.24 and patch 0009's context did not exist there at all, so the
# build died at patchPhase — every `services.punktfunk.host.enable = true` with it.
# * bumping the lock to 3.16.25 fixed that, then `--version` printed NOTHING: upstream's
# `gamescope::PrintVersion()` landed AFTER the 3.16.25 tag. The host reads that banner to
# decide a session's bit depth and cursor compositing BEFORE the virtual display exists,
# so a silent banner means a silent fall back to SDR — the exact failure every guard in
# this file is written to prevent.
# Both are the same bug: nixpkgs' gamescope is older than the tree these patches target.
# Pinning makes the nix package agree with every other channel byte for byte.
#
# Bumping this: move the rev, then `nix-prefetch-git --url https://github.com/ValveSoftware/gamescope
# --rev <new> --fetch-submodules` for the hash, and keep packaging/gamescope/README.md in step.
pfRev = "5fb8dce4a09d0a68d097b9faf9513782106bc843";
pfVersion = "3.16.25-11-g5fb8dce";
pfSrc = fetchFromGitHub {
owner = "ValveSoftware";
repo = "gamescope";
rev = pfRev;
fetchSubmodules = true;
hash = "sha256-pGBiO+7LSdIc0k9K+SQnv/Og2DYD/cjvOImxIl91L2A=";
};
# As of nixos-unstable (checked 2026-07-28) `gamescope` IS the buildable derivation — pname
# "gamescope", version 3.16.25, carrying `src`/`patches`/`mesonFlags`. Revisions that wrap it
# (to wire the WSI layer + capabilities) expose the build as `.unwrapped`, so prefer that where
@@ -76,6 +104,8 @@ let
in
unwrapped.overrideAttrs (old: {
pname = "punktfunk-gamescope";
version = pfVersion;
src = pfSrc;
# Read the patch DIRECTORY rather than naming files: `builtins.attrNames` sorts
# lexicographically, which for `000N-` prefixes is exactly the apply order, and a patch added or
@@ -96,7 +126,19 @@ unwrapped.overrideAttrs (old: {
substituteInPlace src/meson.build \
--replace-fail \
"vcs_tag = run_command(vcs_tag_cmd, check: false).stdout().strip()" \
"vcs_tag = '${old.version}'"
"vcs_tag = '${pfVersion}'"
# Source-level gate, the same one packaging/gamescope/build-punktfunk-gamescope.sh applies.
# Splits a missing marker into its two possible stages: fire HERE and patch 0005 or the
# substitution above lost it; pass here and fail the ELF check later, and it was lost in
# meson configuration or compilation instead. Without this the two are indistinguishable,
# at a full compositor build per guess.
grep -q '+pfhdr' src/meson.build || {
echo "punktfunk-gamescope: +pfhdr is not in src/meson.build after patching" >&2
echo " --- version block as patched: ---" >&2
sed -n '/^vcs_tag_cmd/,/^gamescope_version_conf/p' src/meson.build | sed 's/^/ | /' >&2
exit 1
}
'';
# Ship the compositor, renamed, AND the WSI layer built beside it. Everything else nixpkgs
@@ -134,7 +176,17 @@ unwrapped.overrideAttrs (old: {
chmod -R u+w $out
find $out -mindepth 1 -maxdepth 1 ! -name bin -exec rm -rf {} +
find $out/bin -mindepth 1 ! -name gamescope -delete
# KEEP `.gamescope-wrapped`. nixpkgs wraps this package: makeWrapper leaves the real
# compositor ELF at bin/.gamescope-wrapped and installs a small launcher at bin/gamescope
# that sets PATH (xwininfo) before exec'ing it. A prune that keeps only `gamescope` deletes
# the compositor and ships the launcher alone — MEASURED 2026-08-20 (run 19622): $out/bin
# held a single 16 KB file, `--version` printed nothing because the launcher exec'd a path
# that no longer existed, and no +pfhdr marker was present because a wrapper carries no
# version string. Every symptom chased for three builds came from this one line.
#
# The launcher references its target by ABSOLUTE path, so renaming the launcher is safe
# while the target keeps its name.
find $out/bin -mindepth 1 ! -name gamescope ! -name '.gamescope-wrapped' -delete
mv $out/bin/gamescope $out/bin/punktfunk-gamescope
install -Dm0755 "$TMPDIR/pf-layer.so" \
@@ -147,8 +199,38 @@ unwrapped.overrideAttrs (old: {
doInstallCheck = true;
installCheckPhase = ''
runHook preInstallCheck
$out/bin/punktfunk-gamescope --version 2>&1 | grep -q '+pfhdr' \
|| { echo "punktfunk-gamescope: the +pfhdr marker is missing the patches did not take"; exit 1; }
# Assert the marker is compiled INTO the shipped binary, rather than running it.
#
# Running it does not work here and never did: `--version` produced EMPTY output under the
# build sandbox on BOTH nixpkgs' 3.16.25 and the pinned 5fb8dce4 (MEASURED 2026-08-19/20,
# runs 19551 / 19573 / 19594). That is a property of the sandbox, not a defect in the binary:
# gamescope calls PrintVersion() before the getopt loop (src/main.cpp:721 at the pinned rev),
# so `gamescope --version` DOES print the banner on a real system — which is what the host's
# capability probe reads.
#
# packaging/gamescope/build-punktfunk-gamescope.sh makes the same call, asserting on
# src/meson.build. Grepping the installed ELF is strictly stronger: the version string reaches
# .rodata through GamescopeVersion.h's k_szGamescopeVersion, so this proves the marker survived
# patching, meson configuration AND compilation into the artifact we actually ship, and it
# cannot be defeated by the binary being unable to start.
# Grep the WRAPPED ELF: bin/punktfunk-gamescope is nixpkgs' launcher and carries no version
# string at all, so asserting on it would pass only by accident. Fall back to the launcher
# for a future nixpkgs that stops wrapping.
gsElf=$out/bin/.gamescope-wrapped
[ -f "$gsElf" ] || gsElf=$out/bin/punktfunk-gamescope
grep -aq '+pfhdr' "$gsElf" || {
echo "punktfunk-gamescope: the +pfhdr marker is not in the installed binary." >&2
echo " src/meson.build carried it (asserted in postPatch), so it was lost between" >&2
echo " meson configuration and the linked artifact. Evidence:" >&2
echo " --- $out/bin ---" >&2
ls -l $out/bin 2>&1 | sed 's/^/ | /' >&2
echo " --- anything under $out mentioning pfhdr ---" >&2
grep -ral 'pfhdr' $out 2>/dev/null | sed 's/^/ | /' >&2 || echo " | (nothing)" >&2
echo " --- version-ish strings in the binary ---" >&2
grep -aoE '[0-9]+\.[0-9]+\.[0-9]+[^ ]*' "$gsElf" 2>/dev/null \
| sort -u | head -5 | sed 's/^/ | /' >&2 || true
exit 1
}
# The manifest must name a library this derivation actually installed. A manifest pointing at a
# path that does not exist is the worst shape of this bug: the loader reads it, finds nothing,
# and carries on silently, so the box looks healthy and every game renders SDR.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@punktfunk/plugin-kit",
"version": "0.4.3",
"version": "0.4.4",
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
"type": "module",
"license": "MIT OR Apache-2.0",
+3
View File
@@ -22,6 +22,7 @@ export {
} from "./paths.js";
export {
Artwork,
DEFAULT_RUNNING_TTL_S,
DetectHint,
GameMeta,
LaunchSpec,
@@ -29,6 +30,8 @@ export {
ProviderClient,
type ProviderClientService,
ProviderEntry,
type RunningAccepted,
type RunningTitle,
} from "./reconcile.js";
export {
definePluginKit,
+82
View File
@@ -9,6 +9,14 @@ import type { ProviderEntry } from "./wire.js";
export * from "./wire.js";
/**
* The host's liveness-report TTL, in seconds, when it does not say.
*
* Only a fallback for parsing an unexpected answer the authority is the `ttlS` the host returns.
* A reporter should refresh at a fraction of this, so one missed call is not a lapse.
*/
export const DEFAULT_RUNNING_TTL_S = 90;
/** What the host echoed back for one reconciled entry — enough to tell whether a claim took. */
export interface ReconciledEntry {
readonly id: string;
@@ -35,6 +43,36 @@ export interface ProviderClientService {
entries: ReadonlyArray<ProviderEntry>,
store?: string,
) => Effect.Effect<ReadonlyArray<ReconciledEntry>, HostRequestError>;
/**
* Report which of this provider's titles are running **right now** the live counterpart to the
* static `detect` hints in {@link reconcile}.
*
* `detect` says *how to recognize* a title's process; this says *it is running*, and carries the
* pid where the provider knows one. For a launcher that starts games itself and is told when
* they stop, this is a fact the host would otherwise re-derive by scanning and for a title
* with nothing to scan for (an emulated game, a manually added one, a launcher that records no
* install directory) could not derive at all: its lease is `untracked`, its exit is never
* noticed, and the streaming session outlives the game.
*
* **Send the complete set, not a delta.** Anything absent is reported stopped, so a missed
* event, a plugin restart or an install mid-game all self-correct on the next call.
*
* **The host expires a report** (`ttlS` in the answer, 90s at the time of writing) unless it is
* restated which is what makes it safe for the host to keep a session open for a game it
* cannot see. Call this on every change **and** on a timer well inside that window while
* anything is running; a plugin that stops reporting simply hands tracking back to the host's
* process scan.
*
* Titles the host has no entry for are counted in `unknown`, not refused: a report may
* legitimately race its own reconcile.
*
* Fails on a host that predates the route (404) treat that as "this host tracks games by
* scanning" and carry on, exactly as with any other optional capability.
*/
readonly reportRunning: (
providerId: string,
running: ReadonlyArray<RunningTitle>,
) => Effect.Effect<RunningAccepted, HostRequestError>;
/**
* Remove every entry this provider owns **and release its store claim** (the explicit-uninstall
* path). Releasing is what brings the host's built-in scanner back.
@@ -44,6 +82,29 @@ export interface ProviderClientService {
) => Effect.Effect<void, HostRequestError>;
}
/** One running title in a {@link ProviderClientService.reportRunning} call. */
export interface RunningTitle {
/** The provider's own stable id — the same key its reconcile payload uses. */
readonly external_id: string;
/**
* The process the provider started for it, when it knows one. Optional, and never trusted as a
* bare number: the host re-resolves it and pins it to its start time before it is ever
* signalled, so a stale or recycled pid contributes nothing. Worth sending anyway it is what
* gives "End game" something to aim at for a title the host's matcher cannot find.
*/
readonly pid?: number;
}
/** What the host answered to a liveness report. */
export interface RunningAccepted {
/** How many reported titles matched an entry this provider currently publishes. */
readonly matched: number;
/** How many were ignored because no such entry exists (a report that raced a reconcile). */
readonly unknown: number;
/** Seconds the report stays authoritative without being restated. */
readonly ttlS: number;
}
export class ProviderClient extends Context.Service<
ProviderClient,
ProviderClientService
@@ -72,6 +133,27 @@ export class ProviderClient extends Context.Service<
: [],
),
),
reportRunning: (providerId, running) =>
host
.request("PUT", `/library/provider/${providerId}/running`, {
running,
})
.pipe(
// Same posture as the reconcile echo above: the counts are a
// diagnostic, not a contract, so a host that answers something
// unexpected must not fail a plugin's report loop. The TTL falls
// back to the host's own documented default.
Effect.map((body) => {
const b = (body ?? {}) as Record<string, unknown>;
const num = (v: unknown, fallback: number) =>
typeof v === "number" && Number.isFinite(v) ? v : fallback;
return {
matched: num(b.matched, 0),
unknown: num(b.unknown, 0),
ttlS: num(b.ttl_s, DEFAULT_RUNNING_TTL_S),
} satisfies RunningAccepted;
}),
),
remove: (providerId) =>
host
.request("DELETE", `/library/provider/${providerId}`)
@@ -180,6 +180,7 @@ PUNKTFUNK_MSG_CLOCK_ECHO
PUNKTFUNK_MSG_CLOCK_PROBE
PUNKTFUNK_MSG_CURSOR_RENDER
PUNKTFUNK_MSG_CURSOR_SHAPE
PUNKTFUNK_MSG_DELIVERY_REPORT
PUNKTFUNK_MSG_LOSS_REPORT
PUNKTFUNK_MSG_PAIR_CHALLENGE
PUNKTFUNK_MSG_PAIR_PROOF