Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aef7f7877f | ||
|
|
5e30805490 | ||
|
|
7312f0ddba | ||
|
|
e7ebaf591c | ||
|
|
010949fead | ||
|
|
f5931650e0 | ||
|
|
519d004cab | ||
|
|
46201fd9c3 | ||
|
|
f320f4b465 | ||
|
|
89eb031cd6 | ||
|
|
2991001fe4 | ||
|
|
19df33e0f7 | ||
|
|
c920204184 | ||
|
|
6f4613e146 | ||
|
|
3b08da11ff | ||
|
|
3ee88bb8cf | ||
|
|
773eea24d9 | ||
|
|
3b5c95959b | ||
|
|
a2bc9a2bdc | ||
|
|
cb07a8f983 | ||
|
|
11abff5343 | ||
|
|
cf7baf3ba8 |
@@ -171,6 +171,13 @@ jobs:
|
||||
- name: Rust Android targets (no-op unless the toolchain pin outran the image)
|
||||
run: rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
|
||||
|
||||
# Must precede every cargo step below: skia-bindings' ~19 MB prebuilt download runs inside
|
||||
# a build script with no retry, and a truncated transfer here does not surface as a network
|
||||
# error — it silently becomes a from-source Skia build that dies in the container. See the
|
||||
# script for the measured failure.
|
||||
- name: curl with retries (skia-bindings' prebuilt fetch has none)
|
||||
run: sh scripts/ci/install-retrying-curl.sh
|
||||
|
||||
# Same key namespace as ci.yml/deb.yml ON PURPOSE: identical Cargo.lock, identical
|
||||
# CARGO_HOME layout (/usr/local/cargo), so the registry/git downloads dedupe with
|
||||
# the rest of the fleet in the central cache. target/ is deliberately NOT cached
|
||||
@@ -209,9 +216,25 @@ jobs:
|
||||
# The task lints arm64-v8a AND armeabi-v7a, and reuses the build task's exact cargo-ndk
|
||||
# environment — see the long note on `registerCargoNdkClippy` in kit/build.gradle.kts for why
|
||||
# both pointer widths are load-bearing and why the environment must not be duplicated here.
|
||||
# The `STARTING A FULL BUILD` check turns the manual rule in this workflow's `env:` block
|
||||
# ("Every ABI's log must show DOWNLOAD AND INSTALL SUCCEEDED") into something that fails the
|
||||
# job by itself. Without it a missed prebuilt reads as a Gradle stack trace with the real
|
||||
# cause ~1,800 lines up — which is exactly how 2026-08-22 spent a week looking like a lint
|
||||
# failure. This is the first cargo step in the job, so it catches the drop earliest.
|
||||
#
|
||||
# No pipefail: the runner is dash. Capture, then decide.
|
||||
- name: Clippy (Android target, deny warnings)
|
||||
working-directory: clients/android
|
||||
run: ./gradlew :kit:cargoNdkClippy --stacktrace
|
||||
run: |
|
||||
set -e
|
||||
rc=0
|
||||
./gradlew :kit:cargoNdkClippy --stacktrace > /tmp/android-clippy.log 2>&1 || rc=$?
|
||||
cat /tmp/android-clippy.log
|
||||
if grep -q "STARTING A FULL BUILD" /tmp/android-clippy.log; then
|
||||
echo "::error::skia-bindings did not get its prebuilt archive and started building Skia from source — the download was dropped (see DOWNLOAD AND INSTALL FAILED above). This is a fetch failure, not a lint failure."
|
||||
exit 1
|
||||
fi
|
||||
exit $rc
|
||||
|
||||
# The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were
|
||||
# running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app
|
||||
|
||||
@@ -107,6 +107,12 @@ jobs:
|
||||
# registry/git are download caches, target/ the incremental build. The target key
|
||||
# carries the rustc version — resolved via `rustc --version` (below) rather than parsed
|
||||
# from rust-toolchain.toml, so a pin bump there invalidates stale incremental state too.
|
||||
# `pf-console-ui` pulls skia-safe, so a target-cache miss makes this job download a prebuilt
|
||||
# Skia from the same no-retry build-script fetch that took the android job out on
|
||||
# 2026-08-22, over the same load-shedding runner network. Cheap insurance; see the script.
|
||||
- name: curl with retries (skia-bindings' prebuilt fetch has none)
|
||||
run: sh scripts/ci/install-retrying-curl.sh
|
||||
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
@@ -270,6 +276,12 @@ jobs:
|
||||
- name: sccache (no-op once the image bakes it)
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
# `pf-console-ui` pulls skia-safe, so a target-cache miss makes this job download a prebuilt
|
||||
# Skia from the same no-retry build-script fetch that took the android job out on
|
||||
# 2026-08-22, over the same load-shedding runner network. Cheap insurance; see the script.
|
||||
- name: curl with retries (skia-bindings' prebuilt fetch has none)
|
||||
run: sh scripts/ci/install-retrying-curl.sh
|
||||
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
|
||||
+548
@@ -12,6 +12,554 @@ with the version table of the release you are moving to, then read **Breaking ch
|
||||
|
||||
---
|
||||
|
||||
## v0.31.3
|
||||
|
||||
41 commits since v0.31.2 (26 non-merge), counted at the tip this was cut from.
|
||||
|
||||
**One versioned surface moves, and additively: the management API.** `WIRE_VERSION` stays **2**, the
|
||||
C ABI stays **25** — `include/punktfunk_core.h` is **byte-identical to the v0.31.2 tag**, as it was
|
||||
to v0.31.1 — and so do the driver protocol, the gamepad channel, the plugin index schema and the
|
||||
host event schema. `pf-driver-proto` shows no diff. No `#[repr(C)]` struct moves and no C function
|
||||
changes signature, so an embedder takes this release without recompiling anything.
|
||||
`api/openapi.json` gains **one route and one field** (`PATCH /clients/{fingerprint}`, the
|
||||
`RenameClient` schema, and `PairedClient.label`); nothing existing changes shape, so a consumer that
|
||||
ignores both is unaffected. **`@punktfunk/host` is re-cut to 0.1.6** so a plugin can actually reach
|
||||
the generated types for that route; `@punktfunk/plugin-kit` stays at 0.4.4. One dependency moves,
|
||||
lockfile-only, for a security advisory.
|
||||
|
||||
The cycle is fix-shaped and the faults share a family resemblance: **a session degrading or ending
|
||||
against something ordinary that nothing was checking**. The host mistaking Steam's pre-launch trees
|
||||
for the game and then reading their exit as the game's (#372); a fullscreen game mode-setting the
|
||||
virtual display under both stream loops, which no in-place encoder rebuild can converge on (#373);
|
||||
the forced-keyframe coalesce window measured in frames rather than time (#377); an Android TV
|
||||
negotiating the refresh its own console pin had installed rather than what the panel outputs
|
||||
(#378); a startup capacity probe large enough to black-hole the link it was measuring (#379); a
|
||||
hand-back that never verified the panel came back (#375); a half-minted audio devnode that nothing
|
||||
afterwards recognised (#381); and a failed compositor build that unlinked the working one it never
|
||||
replaced (#382). Alongside: two Android input/present fixes (#376, #380), the console's
|
||||
per-frame cost and its new resolution switch (#384, #385), one feature (#374), and CI (#370, #383).
|
||||
|
||||
### Versions
|
||||
|
||||
| | v0.31.2 | v0.31.3 | Notes |
|
||||
|---|---|---|---|
|
||||
| Wire protocol | 2 | **2** | unchanged. No message added, removed or re-shaped |
|
||||
| C ABI | 25 | **25** | unchanged. `include/punktfunk_core.h` has **no diff at all** against the v0.31.2 tag — the second release running |
|
||||
| Rust edition | 2024 | **2024** | unchanged |
|
||||
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
|
||||
| Workspace crate dirs | 27 | **27** | unchanged (39 `[workspace] members`, also unchanged) |
|
||||
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3); `pf-driver-proto` shows no diff against the v0.31.2 tag |
|
||||
| Windows virtual-gamepad channel | 3 | **3** | unchanged. #374 exercises the UMDF HID pad through `devtest` but changes no backend |
|
||||
| Plugin index schema | 1 | **1** | unchanged |
|
||||
| Host event schema | 1 | **1** | unchanged (`punktfunk-host/src/events.rs`) |
|
||||
| `api/openapi.json` | 0.31.2 | **0.31.3** | **additive**: one route (`PATCH /clients/{fingerprint}`), one schema (`RenameClient`), one response field (`PairedClient.label`), plus the `info.version` stamp. Regenerated in #374 on a runner where `openapi_document_is_complete_and_checked_in` executes; **re-stamped** here, not regenerated — `punktfunk-host` does not build on macOS. `api/` and `docs-site/public/` are byte-identical to each other |
|
||||
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files, `packaging/gamescope/PKGBUILD` still declares `pfhdr8`. #382 fixes the Deck **source** build, not the patch set |
|
||||
| `@punktfunk/host` (SDK) | 0.1.5 | **0.1.6** | **cut**, for the generated client `sdk/src/gen/punktfunk.ts` — it carries `PATCH /clients/{fingerprint}`, `RenameClient` and `PairedClient.label`, and a plugin resolves `@punktfunk/host` from the registry, so those types reach nobody until a version ships them. `SDK_VERSION` moves with `package.json`; see the drift note at the end |
|
||||
| `@punktfunk/plugin-kit` | 0.4.4 | **0.4.4** | unchanged; nothing under `plugin-kit/` moved. 0.4.4 remains the registry's `latest` |
|
||||
|
||||
### ⚠ Breaking changes
|
||||
|
||||
**None.** No wire change, no ABI change, no driver-protocol change, no plugin-contract change, and
|
||||
the one API change is additive. Every 0.31.x host, client, driver and plugin keeps interoperating in
|
||||
both directions with no re-pairing and no rebuild.
|
||||
|
||||
Five **behaviour** changes that break no build but change what a machine does:
|
||||
|
||||
- **`GameRunning` is reported up to `SHIM_WINDOW` (5 s) later than before** for a lease matched by
|
||||
process scan. A scan match must now be seen *continuously* for that window before it latches out
|
||||
of the start phase. A provider plugin's runstate report still latches immediately — that is the
|
||||
launcher's own statement, not an inference — and exit detection is untouched.
|
||||
- **The GameStream stream loop now re-opens the encoder at a source-driven mode**, and does not tell
|
||||
the client. GameStream has no mid-stream mode-change message, so Moonlight decodes a bitstream
|
||||
that disagrees with the resolution it configured its decoder from. Tolerant decoders re-init off
|
||||
the SPS; a strict one (Media Foundation on Xbox) may stall. This is the same bargain the first
|
||||
open in that function already takes for the monitor-mirror case (§7.3), and the alternative it
|
||||
replaces is ending the session outright.
|
||||
- **`GET /api/v1/clients` grows `label`**, and `PairedClient.subject` is now documented as *not* a
|
||||
device name. A console or integration that displayed `subject` should prefer `label` and fall back
|
||||
to `subject` only when it is unset.
|
||||
- **The startup capacity probe no longer bursts at a flat 2 Gbps.** Its target is derived from
|
||||
`stream_cap_kbps × 2`, still capped at 2 Gbps. `PUNKTFUNK_ABR_PROBE_KBPS` and its `> 0` filter are
|
||||
unchanged, so an embedder that pins the probe explicitly sees no difference.
|
||||
- **Android no longer pins the panel to its highest refresh mode on a TV.** `highRefreshModeId`
|
||||
stays 0 there, which `setConsoleHighRefreshRate` already treats as a no-op. Phones and tablets are
|
||||
unaffected — the pin exists for their refresh governors.
|
||||
|
||||
### `PATCH /clients/{fingerprint}`: an operator label for a paired client
|
||||
|
||||
Every moonlight-common-c client self-signs with the same fixed subject (`CN=NVIDIA GameStream
|
||||
Client`), so the certificate carries no device identity at all: five paired devices are five
|
||||
identical rows, distinguishable only by fingerprint prefix — most sharply when choosing which to
|
||||
unpair. Reported from the field as a rename request.
|
||||
|
||||
The label is operator-supplied and stored host-side, keyed by fingerprint:
|
||||
|
||||
- **`client-labels.json`, a SIDECAR to `paired.json`, not a field inside it.** `paired.json` is a
|
||||
bare `Vec<Vec<u8>>` of DERs; giving it a shape would be a migration on the one file that decides
|
||||
who may connect, and a label is not part of that trust decision — a corrupt or missing label file
|
||||
must never be able to lock anybody out. Every read failure degrades to "no names". Writes take the
|
||||
same atomic temp-file + rename as `save_paired`, serialized by `LABELS_LOCK` so two concurrent
|
||||
renames cannot lose one of the two names in a whole-file rewrite. Fingerprints are normalized to
|
||||
lowercase hex.
|
||||
- **Route semantics.** A whitespace-only body **clears** rather than storing a blank name; only an
|
||||
already-paired fingerprint may be named (a label for an unknown one would be invisible and never
|
||||
cleaned up); unpairing forgets the label, so the file cannot grow without bound and re-pairing the
|
||||
same certificate starts unnamed.
|
||||
- **Scrubbing reuses `native_pairing::sanitize_device_name`** rather than growing a second one — it
|
||||
already strips C0/C1 controls and Unicode bidi overrides and caps at 64. That is not cosmetic
|
||||
here: the label is the *only* thing distinguishing two paired devices in the console, so an
|
||||
unscrubbed one could dress a stranger's device up as the operator's TV and be spared an unpair on
|
||||
that basis.
|
||||
- **Lanes.** The new route takes the plugin/cert lanes of the `DELETE` beside it — neither may reach
|
||||
it — not the roster `GET`'s read permission.
|
||||
`every_route_is_classified_for_the_plugin_and_cert_lanes` pins that.
|
||||
- **Console.** A pencil on Moonlight rows opens the existing `promptText` dialog seeded with the
|
||||
current label, not the `CN=…` fallback (or every rename would start by deleting boilerplate).
|
||||
Native rows keep their pairing-supplied name and get no pencil.
|
||||
|
||||
Test: `client_label_round_trips_scrubs_and_is_forgotten_on_unpair` — name it, see it in the list,
|
||||
watch a bidi override and collapsed whitespace get scrubbed, clear it two ways, reject a malformed
|
||||
and an unpaired fingerprint, and assert the unpair forgot it on disk.
|
||||
|
||||
### The encoder follows an autonomous source mode or format change
|
||||
|
||||
A fullscreen game can mode-set the virtual display mid-session with no client `Reconfigure`. The
|
||||
IDD-push capturer already handles that — it re-opens its ring at the new mode on a confirmed
|
||||
descriptor change — but nothing re-opened the **encoder**, which is the one component that cannot
|
||||
follow a resolution change in place. Every submit then failed with `captured frame 1920x1080 !=
|
||||
encoder 3840x2160`, and the submit-error path only rebuilds the encoder IN PLACE, at the SAME
|
||||
configured size, which cannot converge on a size the source has already left. All five resets burned
|
||||
on it and the video session ended ~3 s later with audio still running.
|
||||
|
||||
Field report 2026-08-22 (host 0.31.2, RX 6800 XT, AMF/HEVC 4K60):
|
||||
|
||||
```
|
||||
IDD push: display descriptor changed — recreating the ring at the new mode
|
||||
target_id=259 from=3840x2160 hdr=true to=1920x1080 hdr=true
|
||||
encoder submit failed — encoder rebuilt in place, forcing an IDR
|
||||
error=captured frame 1920x1080 != encoder 3840x2160 reset=1 max=5
|
||||
... reset=5 max=5
|
||||
encoder did not recover after repeated in-place rebuilds — ending the video session ... resets=6
|
||||
```
|
||||
|
||||
Both stream loops now track what the encoder was opened against `(format, width, height)` and, when
|
||||
the source delivers something else, re-open through the same `open_video` path a client-initiated
|
||||
resize uses:
|
||||
|
||||
- **Native (`native/stream.rs`)** publishes the new mode to the client exactly as an accepted resize
|
||||
does, so its mode slot, stats and aspect follow. PyroWave's `Automatic` rate is re-resolved for
|
||||
the new mode — it is a per-mode bpp pin, so carrying the old one across hands the encoder the
|
||||
wrong operating point; H.26x rates stay with ABR, and an explicit client rate is never
|
||||
second-guessed.
|
||||
- **GameStream (`gamestream/stream.rs`)** does the same bookkeeping the capture-loss rebuild in that
|
||||
loop already does (ring depth, RFI caps, forced IDR, in-flight numbering restart), and derives
|
||||
`gs_bit_depth(frame.format)` per open so an HDR flip that recreates the ring at P010 re-opens at
|
||||
the right depth. It cannot notify the client; see the behaviour note above.
|
||||
|
||||
A failed re-open does **not** end the session on the first try: the mode-set is exactly the kind of
|
||||
event that leaves the driver settling, which is the transient the submit path's backoff exists for
|
||||
("NVENC session open failing after a codec switch", 2026-07). It spends the shared `encoder_resets`
|
||||
budget at the existing exponential pace (100 ms → 1.6 s), re-entering the follow-the-source guard
|
||||
each round — the same ~3 s ceiling as before, but every round is now a real attempt at the new mode
|
||||
rather than an in-place re-init that cannot converge. The exhausted path is tagged accurately as an
|
||||
encoder **reopen** failure, not a submit failure.
|
||||
|
||||
This also covers a mid-session frame-format change (an HDR flip re-creating the ring at a new
|
||||
format), which failed identically.
|
||||
|
||||
### The forced-keyframe coalesce window gets an absolute floor
|
||||
|
||||
`keyframe_coalesce` was `frame_interval * 2`. The window bounds IDR emission in **time** — it has to
|
||||
outlast the round trip in which the client receives and decodes the IDR it already asked for — so a
|
||||
frame count is the wrong unit, and it collapses exactly where it matters: 16.7 ms at 120 fps, while
|
||||
a Moonlight client that has lost decode sync re-asks roughly every 30 ms. The gate never closed
|
||||
between requests, so effectively every request became a full keyframe, whose bulk saturates the send
|
||||
path, which causes the loss that prompts the next request. The storm sustains itself and reads as
|
||||
stutter at a flat latency, because frames are being lost rather than queued.
|
||||
|
||||
Field log (AMD RX 7800 XT, Bazzite 44, 1080p120 HEVC over the GameStream plane): **1118 IDR requests
|
||||
in one 91 s session, 1115 honoured, 3 coalesced** — about one full IDR every tenth frame at a
|
||||
100 Mbps target. The same session's H.264 leg (libav VAAPI, same bitrate) took 2 requests and was
|
||||
clean, which is what made it read as an HEVC fault.
|
||||
|
||||
`keyframe_coalesce_window(frame_interval)` is now `(frame_interval * 2).max(100 ms)`. 100 ms matches
|
||||
the encoder-reset backoff in the same loop and is about one IDR's service time on a saturated link.
|
||||
Note this is **not a 120-only fix**: 60 fps sat at 33.3 ms, also under the floor. A slow stream keeps
|
||||
the frame-scaled window — the floor only ever raises it. NVENC ref-invalidation (cheap, no IDR
|
||||
spike) is still never rate-limited. `keyframe_coalesce_window_outlasts_a_clients_request_cadence`
|
||||
pins all three cases.
|
||||
|
||||
### The game lease stops latching on Steam's pre-launch trees
|
||||
|
||||
`reaper SteamLaunch AppId=<appid>` is the **appid's** wrapper, not the game's, and Steam wraps its
|
||||
pre-launch work for a title in one too — so a launch is a chain of appid-tagged trees and only the
|
||||
last is the game. The lease matched the first tree two seconds in, and that single sighting latched
|
||||
it out of `START_GRACE` (300 s, ending nothing) into `EXIT_CONFIRM` (3 s, ending the session). When
|
||||
that tree exited with the game still starting, the watch called it the game exiting and closed the
|
||||
connection with `APP_EXITED`. Reported as having to launch Rocket League twice: the first launch
|
||||
streamed the "Processing Vulkan shaders" dialog and dropped ten seconds in.
|
||||
|
||||
Linux has nothing else to catch it — `procscan::running_hint` is Windows-only and no provider
|
||||
reports runstate for Steam, so an appid scan with three seconds of slack is the whole signal.
|
||||
(Steam's `registry.vdf` is not an option: `RunningAppID` is no longer set on modern Steam Linux, and
|
||||
the per-app `Running` key is unreliable.)
|
||||
|
||||
Two layers, because only one of them can be certain:
|
||||
|
||||
- The matcher **rejects** a `SteamLaunch AppId=` reaper whose payload is `fossilize_replay` —
|
||||
Steam's shader replayer, never a game. `program_name` handles the full-path form.
|
||||
- A scan match must be seen **continuously for `SHIM_WINDOW`** before it latches. This is the rule
|
||||
already applied to a spawned child ("a launcher about to hand off looks exactly like the game for
|
||||
its first few seconds"); the scan side never had it. It bounds the pre-launch trees nobody has
|
||||
named yet, at the cost of the `GameRunning` latency in the behaviour note above.
|
||||
|
||||
Exit detection is untouched, and a provider report still latches immediately. Diagnostics: the log
|
||||
said `procs=1` and never *which* process, which is what made this unclosable from a log alone —
|
||||
`procscan::names` puts the short names on the line.
|
||||
|
||||
### The startup capacity probe is sized from the session, not from a flat ceiling
|
||||
|
||||
The probe burst at a flat 2 Gbps on the reasoning that it must measure the link and not itself. That
|
||||
reasoning is obsolete: the ABR already clamps the measured ceiling to `stream_cap_kbps` (what this
|
||||
session's mode + codec could plausibly use), so **every bit measured above `cap / 0.7` is discarded
|
||||
the moment it lands**. The height bought a number nothing reads and paid bufferbloat for it — a
|
||||
constrained link can black-hole under it. Measured on webOS: a 6 s probe timeout delaying first
|
||||
video to **14 s**, and a "successful" probe still reporting `send_dropped=20211`; the same shape is
|
||||
now reported on a Fire TV Stick 4K Max.
|
||||
|
||||
The target is `stream_cap_kbps × 2`, capped at the old 2 Gbps. **×2 is the smallest multiplier that
|
||||
can still prove the cap** — the ceiling is `delivered × 0.7`, so proving `cap` needs
|
||||
`delivered ≥ cap × 1.43` — so this can never cap anyone: a session whose mode justifies a high
|
||||
ceiling asks for a high target by itself, and a mode `stream_ceiling_kbps` declines to size still
|
||||
gets 2 Gbps. Deliberately **not** a platform `cfg!`: the constraint is the session's, not Android's,
|
||||
and webOS has the same bug.
|
||||
|
||||
Second half of the black screen: if the burst takes the first keyframe down with it, nothing
|
||||
re-requested one and the client sat on black until an unrelated recovery path happened to fire. A
|
||||
keyframe is now requested at probe end when no frame completed across the burst — compared against
|
||||
the count snapshotted at the burst's **leading edge** rather than against 0, so it also covers a
|
||||
mid-session embedder speed test that kills a running stream. One request per probe, through the
|
||||
control task's coalescer, so it cannot IDR-storm.
|
||||
|
||||
### Android: the console's high-refresh pin is not applied on a TV
|
||||
|
||||
Field report: on Android TV / Fire Stick, latency explodes whenever the client's refresh differs
|
||||
from the host's, and setting the refresh by hand is the only workaround. The client was
|
||||
manufacturing that mismatch itself, in three steps:
|
||||
|
||||
1. `MainActivity.onCreate` pins the panel to its highest-refresh mode for the console UI
|
||||
(`setConsoleHighRefreshRate(true)`) — unconditionally, TVs included. That pin exists for phone
|
||||
refresh governors (Nothing OS's LTPO logic among them) which cap third-party apps at 60 Hz. No TV
|
||||
has one.
|
||||
2. At connect, `nativeDisplayMode` resolves "Native" refresh from `display.mode` — which now reports
|
||||
the mode the pin installed, not the TV's real HDMI output. So the session negotiates (say) 120.
|
||||
3. `StreamScreen` releases the pin again on TV, by design: there the decoder's own
|
||||
`setFrameRate(CHANGE_FRAME_RATE_ALWAYS)` governs the HDMI mode. The panel falls back to 60 while
|
||||
the host is already serving 120.
|
||||
|
||||
A 120 fps stream on a 60 Hz output, by construction, on exactly the two form factors in the report.
|
||||
Picking a refresh explicitly is precisely what bypasses step 2, which is why that is the workaround
|
||||
people found. The mode comparator sorts refresh before area, so the same pin could also drop a 4K TV
|
||||
to 1080p120 and negotiate the stream at that.
|
||||
|
||||
Fixed at the choke point: `resolveHighRefreshMode` returns early on a TV, leaving `highRefreshModeId`
|
||||
at 0, which `setConsoleHighRefreshRate` already treats as a no-op — so all three of its callers are
|
||||
covered by the one guard.
|
||||
|
||||
Also in the same chain: `nativeDisplayMode` **truncated** the panel rate, so a TV reporting the
|
||||
fractional NTSC rates over HDMI (59.94, 29.97, 23.976) asked the host for 59 / 29 / 23 — rates no
|
||||
display mode has, which the host serves by clamping down to the highest it advertises at or below.
|
||||
Rounded now, which also makes it agree with `MainActivity.streamPanelFps`; the two describe the same
|
||||
panel and must not disagree.
|
||||
|
||||
### Android: `acquireLatestImageAsync` hands back a fence it already gave away
|
||||
|
||||
Every `pf-decode` SIGABRT on the Shield is fdsan catching a double-close of the acquire fence the ASC
|
||||
presenter passes to `ASurfaceTransaction_setBuffer`, in three shapes: inside `Fence::Fence(int)`
|
||||
under `setBuffer` when the number had already been re-owned (`fd N is owned by unique_fd, was
|
||||
expected to be unowned`), at the end of `Transaction::apply` when the layer state is torn down, and
|
||||
in `Parcel::freeDataNoInit` once the number churns.
|
||||
|
||||
The fence is not ours to give. `AImageReader::acquireLatestImage` drains with a **single `int*`
|
||||
out-param it overwrites per image**, then releases each dropped image with whatever that out-param
|
||||
currently holds — the successor's fence — and returns the last value written. So as soon as a burst
|
||||
gives it two images to collapse, the caller receives an fd the reader has already adopted and
|
||||
closed, plus one leaked fd per extra drop. **This is unfixed as of AOSP main**, so the newest-wins
|
||||
collapse has to happen on our side.
|
||||
|
||||
Both present intents now drain with `acquireNextImageAsync`, whose fence is always a fresh dup we
|
||||
exclusively own, and latency picks the newest itself — the loop the smoothing FIFO already ran.
|
||||
Superseded candidates drop as before (image back to the pool, its own acquire fence closed). Reader
|
||||
drops now show up in `skipped` instead of vanishing inside the reader lock.
|
||||
|
||||
### Android: a DualSense's buttons, touchpad click and Mute
|
||||
|
||||
Three defects reported against a Bluetooth DualSense on a Fire TV Stick 4K Max, **re-implemented
|
||||
from #371's diagnosis**. #371 itself is not merged: all three problems are real and correctly
|
||||
identified, but each fix as sent lands somewhere that breaks more hardware than it repairs.
|
||||
|
||||
1. **Some buttons never reach the stream.** Fire OS tags certain DualSense buttons `SOURCE_KEYBOARD`
|
||||
even though the keycodes are standard `BUTTON_*`, and `MainActivity`'s
|
||||
`event.isFromSource(SOURCE_GAMEPAD)` gate then drops them. The event's source class is the
|
||||
platform's per-event guess; the DEVICE's is the fact. New `MainActivity.fromPad` widens to the
|
||||
device — but ONLY for `KeyEvent.isGamepadButton` keycodes. That exclusion is the whole safety of
|
||||
it: DPAD keycodes are a keyboard's arrow keys and BACK is a remote's way out of the stream, and
|
||||
both share their keycodes with a pad. `Gamepad.isPad` is untouched (source-class only), and no
|
||||
vendor-id or device-name matching is added anywhere — the field report records both pads being
|
||||
IDENTIFIED correctly; only their button positions were wrong.
|
||||
2. **Touchpad click and Mute were dropped.** Both have wire bits (`BTN_TOUCHPAD`, `BTN_MISC1`) and no
|
||||
Android keycode, so `GENERIC_SONY`'s `0x13d`/`0x13e` rows borrow `BUTTON_15`/`BUTTON_16` to carry
|
||||
them into `buttonBit`. Inside `GENERIC_SONY` and nowhere else: `0x13d`/`0x13e` are
|
||||
`BTN_THUMBL`/`BTN_THUMBR` — L3 and R3 — in the standard Linux mapping, and they mean touchpad and
|
||||
mute only inside the straight-through report order a driverless pad uses. A row in `SONY_MODERN`,
|
||||
or an override above `padMap(dev)`, would cost every Xbox pad, Switch Pro, 8BitDo, Steam Deck and
|
||||
hid-playstation DualSense both stick clicks. `correct()`'s `genericKeyCode` guard is unchanged.
|
||||
3. **Mute toggles the mic** — once per press, and only on a pad that has one. Edge-triggered through
|
||||
the existing `completesChord` as the one-button chord it is: `onButton` still calls
|
||||
`slotButton(down = true)` on auto-repeat, so an unguarded check would flap the mic for as long as
|
||||
the button is held. Gated on a new `Slot.hasMuteButton`.
|
||||
|
||||
### The console's per-frame cost, and a resolution switch for 4K boxes
|
||||
|
||||
A field report of a sluggish console UI on a Fire TV Stick 4K Max and a Valerion projector. The Skia
|
||||
shell is faster than the Compose one it replaced per unit of work; it was doing far more work than
|
||||
anyone had counted, on every frame whether or not anything had changed. Four costs, **none of which
|
||||
change a pixel**:
|
||||
|
||||
- `Fonts::paragraph` built a `ParagraphBuilder`, added its text and called `layout()` on every call —
|
||||
the whole shaper, HarfBuzz, line breaking and font fallback, for every string on screen, sixty
|
||||
times a second. Now built once per distinct `(text, shape, weight, size, width, colour)` and kept.
|
||||
Position is deliberately **not** in the key, so a shelf that scrolls and a screen that slides both
|
||||
re-use what they already shaped. Cold entries are dropped past a ceiling by the two frames that
|
||||
last drew them, so the live set is what is on screen and paging a large library cannot grow it
|
||||
forever.
|
||||
- `LayerEnv::paint` raised an unbounded `save_layer` **unconditionally** — including on the settled
|
||||
path, where alpha is 1, scale is 1 and slide is 0. That allocates an offscreen the size of the
|
||||
whole SURFACE and composites it back, to apply an alpha of one, on every frame the console sat
|
||||
still. Skia does not elide it: `SkCanvas::saveLayerAlphaf` forwards alpha ≥ 1 straight to
|
||||
`saveLayer(bounds, nullptr)`, whose only early-out is an empty clip. On a 4K panel that is a
|
||||
**33 MB render target per frame against a 64 MB budget** on a 2 GB box — evicting real work to do
|
||||
nothing. Dropping it is pixel-identical rather than close: nothing in this crate draws with a
|
||||
blend mode other than `SrcOver`, `SrcOver` is associative, and there is no LCD subpixel text to
|
||||
gain or lose an isolation.
|
||||
- The toast's layer was unbounded too, for a 34 dp pill; it takes the pill's rect now.
|
||||
- `draw_clipped` measured its ellipsis fit by allocating a `String` **per character**, for every
|
||||
over-long title on screen, every frame. It measures out of a stack buffer now.
|
||||
|
||||
On the Android host the render thread takes the same priority lift the decode thread has always had
|
||||
(`-8`, a band below the stream's `-10`, so the two do not compete when the console is up
|
||||
mid-session). And the console logged its GLES version and cache budget but never its render
|
||||
resolution or frame cost, so "it feels sluggish" could not be triaged from a log bundle at all — it
|
||||
now names the surface size and reports mean and peak draw time once a minute, timed around the
|
||||
**draw** and not the swap (`eglSwapBuffers` blocks on vsync, so wall-clock per iteration is always
|
||||
the panel period and says nothing).
|
||||
|
||||
**`Reduce interface resolution`** (#384) is the lever that commit deliberately left out: an
|
||||
off-by-default Android switch, under Reduce motion, capping the buffer's long edge at 1920 via
|
||||
`SurfaceHolder.setFixedSize` and letting the compositor scale up. Two things it had to get right:
|
||||
`setFixedSize` shrinks the BUFFER and not the VIEW, so everything speaking in surface pixels is
|
||||
scaled to match — safe-area insets, the design-unit scale, and pointer coordinates, which a mouse
|
||||
still reports in view pixels and which would otherwise land the cursor at twice its true offset (one
|
||||
factor on both axes, so aspect survives exactly). And the buffer is sized from the `SurfaceView`'s
|
||||
laid-out size via `onSizeChanged`, **not** `displayMetrics`, which has a long history of disagreeing
|
||||
with a view's real size by a system bar. The factor reaches the pointer listeners through
|
||||
`rememberUpdatedState` — `AndroidView`'s `factory` runs once, so a captured value would freeze at 1.
|
||||
|
||||
⚠ This is the INTERFACE only and shares nothing with the stream: picture size is `effectiveMode` off
|
||||
`Display.mode.physicalWidth`, and picture scaling is the separate `renderScale`.
|
||||
|
||||
`platform_row_split_hides_only_the_other_platforms_concepts` pins the exact ordered set of rows the
|
||||
desktop does not show; the new switch is Android-only by design, so #385 grows that expected list by
|
||||
one between the Controllers action row and the console-UI switch. The row-COUNT assertion beside it
|
||||
was already updated and passed, which is why only the ordered-set one went red.
|
||||
|
||||
### gamescope: the hand-back verifies the panel came back
|
||||
|
||||
Field reports on 0.31.x, Bazzite and Nobara: after disconnecting, the box's own physical screen stays
|
||||
black. **It could not be reproduced** — #375 carries the full negative write-up, five scenarios
|
||||
across both distro families on real VMs, all recovering cleanly, and the mechanism first proposed
|
||||
disproved on glass. So this does not guess at the trigger; it closes the gap that lets ANY trigger
|
||||
end as a dark panel.
|
||||
|
||||
`do_restore_tv_session` issued a lifecycle verb and logged what systemd said about the **job**. "The
|
||||
job succeeded" and "the box shows a picture" are different questions, and nothing in that file had
|
||||
ever asked the second — the restore walked away the moment the verb returned, so every way of ending
|
||||
dark looked identical to success in the log.
|
||||
|
||||
It now measures. After the hand-back a detached watcher polls `detect_active_session()`, whose `None`
|
||||
means no compositor of our uid is running at all — exactly the symptom. Still dark 25 s later, it
|
||||
climbs a ladder of remedies, each measured on both images (Bazzite 44.20260818, Nobara f44,
|
||||
2026-08-22):
|
||||
|
||||
1. **STOP** the autologin unit. Its login session's script is parked on `systemctl --user --wait
|
||||
start <unit>` on both images, so a stop releases that wait, the session exits, and `Relogin=true`
|
||||
logs back in — starting the unit inside a session with a seat. `stop`, not `restart`: a restart
|
||||
does **not** release the parked waiter (measured), which is why it cannot rescue a box the
|
||||
ordinary restart already failed to bring back.
|
||||
2. Restart the display manager — what the pre-0.31.0 takeover did on every disconnect, and proven on
|
||||
the Bazzite VM to return the box to game mode.
|
||||
3. `PUNKTFUNK_RECOVER_SESSION_CMD`, then an ERROR naming the command a human has to run.
|
||||
|
||||
### Windows audio: an abandoned devnode is adopted, and capture endpoints stamp the right hive
|
||||
|
||||
Minting an audio devnode is two PnP steps: `SetupDiRegisterDeviceInfo` makes it real and bindable,
|
||||
then the owner marker goes into Device Parameters. A host that dies between them — the 0.30.0
|
||||
TLS-destructor abort did exactly this, five times on one field box — leaves a registered,
|
||||
driver-bound, endpoint-serving devnode carrying **no marker**.
|
||||
|
||||
Nothing resolved it afterwards. `find_role_devnode` matches on the marker, so the next pass minted a
|
||||
SECOND devnode and the orphan stayed: a duplicate `Punktfunk Speakers` / `Punktfunk Microphone` in
|
||||
the Sound zoo that no uninstall removed, because `devnode_cleanup` is marker-matched too. A field box
|
||||
showed exactly this shape — `Punktfunk Speakers (3- Punktfunk)` beside an unstamped `Punktfunk
|
||||
Speakers (4- Steam Streaming Speakers)`. Reproduced on .173 against the shipping 0.31.2 binary by
|
||||
clearing the marker: `ROOT\MEDIA\0005` was minted and `0004` abandoned, still active and still
|
||||
serving two live microphone endpoints.
|
||||
|
||||
- `minted.rs` **adopts before it mints**: an unmarked `ROOT\MEDIA\NNNN` devnode carrying the role's
|
||||
Steam hardware id is re-marked and reused, so the endpoint GUID survives and no device-change
|
||||
broadcast is paid.
|
||||
- `devnode_cleanup` sweeps the same shape, so orphans already on a box go at uninstall instead of
|
||||
outliving the product.
|
||||
- The instance prefix is what keeps both off Valve's own devices: Steam's devnodes carry these
|
||||
hardware ids and are ROOT-enumerated too, but live under `ROOT\SteamStreamingSpeakers\*` /
|
||||
`ROOT\SteamStreamingMicrophone\*`. Only `ROOT\MEDIA\*` can come from our
|
||||
`SetupDiCreateDeviceInfoW(DICD_GENERATE_ID)`. `is_abandoned_mint` carries that rule with unit
|
||||
tests.
|
||||
|
||||
Separately, `write_stamps` falls back to a raw-registry write when the property store denies it, and
|
||||
that fallback built its path from `MMDEV_RENDER_PATH` **unconditionally** — so stamping the minted
|
||||
microphone's CAPTURE endpoint reached for `…\MMDevices\Audio\Render\{capture-guid}\Properties`, a key
|
||||
that cannot exist. `RegOpenKeyExW` failed, `write_stamps` returned the error, and `stamp_identity`
|
||||
degraded to "keeps the driver's default name". Invisible to the pad program (render-only endpoints)
|
||||
and on any box where the property store route succeeds — it only bites where the property store is
|
||||
denied, exactly the boxes the ACL repair exists for. The hive now follows the direction the endpoint
|
||||
id encodes, with render as the default for anything unrecognised. Unit-tested.
|
||||
|
||||
### Steam Deck: a failed gamescope rebuild took HDR from boxes whose compositor still worked
|
||||
|
||||
ROOT CAUSE of "HDR stopped working after updating to 0.31.2" on a Deck **source** install. Two
|
||||
defects, one symptom.
|
||||
|
||||
1. `scripts/steamdeck/build-gamescope.sh` has been **unbuildable since 2026-08-13**, when `3ac4548c`
|
||||
turned `-Denable_gamescope_wsi_layer=true` on. The layer needs `x11-xcb`, which Debian splits into
|
||||
its own `libx11-xcb-dev`; the distrobox apt list — last touched 2026-07-31 — never got it.
|
||||
Measured on `debian:trixie` against that list verbatim, gamescope at the pinned `5fb8dce4`:
|
||||
`Run-time dependency x11-xcb found: NO` → `src/layer/meson.build:3:14: ERROR: Dependency
|
||||
"x11-xcb" not found`. `meson setup` exits 1 with the list as it was and 0 with `libx11-xcb-dev`
|
||||
added, and `build-punktfunk-gamescope.sh` treats a missing layer as a hard error, so the whole
|
||||
build fails. `ci/gamescope-trixie.Dockerfile` walked into the identical trap one release later
|
||||
(`1b28a7f7`, v0.28.1) and asserts x11-xcb at image build; this list never got the same fix.
|
||||
Debian-family only — Arch's libx11 and Fedora's libX11-devel carry `x11-xcb.pc`. `xkbcommon-x11`
|
||||
and `libdisplay-info` measured absent too and are added with it.
|
||||
2. The build-failure branch then called `unwire`, deleting `PUNKTFUNK_GAMESCOPE_BIN` from `host.env`.
|
||||
**A failed build REPLACED NOTHING** — the previously installed binary is still on disk and still
|
||||
passes `verifies`. So a rebuild that never landed took HDR away from a box that had been streaming
|
||||
it minutes earlier: the script warns into a log nobody reads and exits 0, the update reports
|
||||
success, and the host then resolves the distro's stock `/usr/bin/gamescope` at patch level 0 and
|
||||
fixes the session at 8-bit SDR in the Welcome — which the `punktfunk/1` handshake cannot take
|
||||
back.
|
||||
|
||||
`libdisplay-info` is also pinned to the vendored subproject, like wlroots, so a system copy cannot
|
||||
change what the build links.
|
||||
|
||||
### Measured and dropped: the UMDF pad input-silence theory
|
||||
|
||||
`devtest` grows `--idle-after N` / `--resume-after M`, which stop and restart the state frames while
|
||||
still pumping, to test what a Moonlight client actually does. The hypothesis:
|
||||
`UhidManager::heartbeat` documents that a UMDF pad "treats a multi-second input silence as an
|
||||
unplugged controller", the native plane calls it every tick and `SessionPads::pump_rumble` does not
|
||||
— and the two planes differ in exactly the way that would expose it, since punktfunk's own client
|
||||
re-sends every live pad's snapshot every 100 ms (`input_task.rs` refresh tick) while
|
||||
moonlight-common-c sends a controller packet only on **change**.
|
||||
|
||||
**Measured on .173 (Win11 26200) and it does NOT reproduce**: with `--xboxhid --idle-after 12
|
||||
--seconds 75` the pad sat through 58 s of total input silence with `SWD\PUNKTFUNK\PF_XBOX_0` at
|
||||
Status=OK and its promoted `HID\PUNKTFUNK&IG_00` child present throughout. The one-line "add a
|
||||
heartbeat to the GameStream arm" fix this was going to justify is therefore **not** warranted, and
|
||||
was not made.
|
||||
|
||||
Two things the same run did establish, and they are real:
|
||||
|
||||
- **Two live processes wanting pad index 0 collide** exactly as
|
||||
`PadCreateFault::IndexOwnedElsewhere` describes (`Global\pfds-boot-0`, ACCESS_DENIED because the
|
||||
mailbox DACL is SYSTEM+LocalService). `dfcffcdd` (v0.31.1) put **both** input planes on that one
|
||||
name — before it, GameStream used `Global\pfxusb-boot-0` and the two could never collide — so the
|
||||
hazard is new even though it is not what the reporter hit. A clean release-then-retake does not
|
||||
collide (0 s, 1 s and 3 s gaps all created their pad), so an ordinary client reconnect is not the
|
||||
trigger.
|
||||
- **`PUNKTFUNK_HOST_CMD=serve` on .173 means GameStream is switched off there**, so that box has
|
||||
never exercised the plane `dfcffcdd` changed — which is how a compile-only fix reached users
|
||||
unexercised.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- **`h2` 0.4.15 → 0.4.18, for RUSTSEC-2026-0258** (unbounded empty DATA frames, disclosed
|
||||
2026-08-17; fixed in 0.4.16). Lockfile-only and transitive — no manifest declares `h2`, and
|
||||
`cargo metadata --locked` accepts the two-line change with no other package moving, so the
|
||||
resolver needed nothing else. This was the only finding across all five Rust lockfiles; the two
|
||||
remaining `cargo audit` lines (`audiopus_sys`, `paste`) are the pre-existing *unmaintained*
|
||||
warnings already allowed in `.cargo/audit.toml`.
|
||||
⚠ `THIRD-PARTY-NOTICES.txt` still records `h2 0.4.15` and is **not** regenerated here: the
|
||||
generator walks the dependency closure of the machine it runs on, and on macOS that drops the
|
||||
`rusqlite` / `libsqlite3-sys` / `fallible-iterator` cluster (575 → 566 crates) — removing
|
||||
attributions a Linux or Windows build genuinely links. Regenerate it on Linux. Nothing gates the
|
||||
checked-in copy, and every packaging script regenerates it on its own platform, so this is
|
||||
cosmetic drift rather than a shipped inaccuracy.
|
||||
|
||||
### CI
|
||||
|
||||
- **The flatpak build stopped updating runtimes it already has.** Every attempt died on
|
||||
`dl.flathub.org` serving a 404 for one object of the then-current `rust-stable//25.08` commit;
|
||||
`retry.sh` burned all 10 attempts (~9 min) on it and `flatpak-builder` segfaulted on its own error
|
||||
path (rc=139), so the wrapper could not tell a dead end from a load blip. Root cause is ours:
|
||||
`--install-deps-only` does not install what is missing, it **updates** what is present, and
|
||||
`ci/flatpak-ci.Dockerfile` bakes the entire runtime set — so that update was a pure no-op on a
|
||||
healthy run while making every build depend on Flathub's health at that minute. Nothing wanted the
|
||||
newer commit; the manifest pins a runtime **version**, not a commit.
|
||||
`scripts/ci/flatpak-deps-present.sh` now asks first and reaches for Flathub only on a real miss;
|
||||
it fails **open** (anything it cannot parse takes the full install path) and has a `--self-test`
|
||||
that stubs `flatpak` over baked / cold / each dep missing / wrong version / unreadable manifest.
|
||||
`--install-deps-from=flathub` is dropped from the build step: `builder_manifest_install_deps()`
|
||||
runs whenever that flag is set, so the step billed as offline was re-running the same update.
|
||||
`packaging/flatpak/build-flatpak.sh` keeps it — a dev box has no baked image. `flatpak.yml` now
|
||||
also triggers on the deps-check script itself, so a change to that decision cannot ship untested.
|
||||
- **A dropped Skia download read as a lint failure.** `scripts/ci/retry.sh` wraps every single-shot
|
||||
network call in CI, but one of the largest fetches was never wrappable that way: `skia-bindings`
|
||||
pulls ~19 MB of prebuilt Skia per target from INSIDE its build script, with a bare `curl -sS -f -L`
|
||||
and no retry. Measured on main 2026-08-22, android job: `curl: (18) end of response with 17054400
|
||||
bytes missing` — 2 MB of 19,057,024 arrived before the connection closed. `skia-bindings` then
|
||||
swallowed it, falling through to starting a full from-source Skia build the CI containers carry no
|
||||
deps for, so the job surfaced as something else entirely.
|
||||
- **`mgmt/tests.rs` has one `ConfigDirOverride`, not one copy per test.** The unsafe-hygiene gate
|
||||
failed at 6 process-global-API mentions against a baseline of 3: the new rename test had
|
||||
copy-pasted the existing `EnvGuard` + lock + tempdir dance, which is exactly the duplication gate
|
||||
C exists to catch. The single guard also makes the pairing harder to get wrong — the lock is a
|
||||
**field** rather than a separate `_serial` binding a test could forget, and `Drop::drop` runs
|
||||
before any field drops, so the environment is restored while the guard still holds the lock. Back
|
||||
to 3.
|
||||
|
||||
### `sdk/src/gen/punktfunk.ts` had drifted from its own generator
|
||||
|
||||
The generated client in #374 is bigger than the feature. Regenerating it from the **unchanged**
|
||||
committed spec already produced a ~700-line diff — the checked-in copy had drifted from its own
|
||||
pinned generator, and nothing in CI regenerates or verifies it (unlike `api/openapi.json` and
|
||||
`include/punktfunk_core.h`, which are both gated). #374 lands the clean regeneration rather than
|
||||
hand-patching generated code.
|
||||
|
||||
**`@punktfunk/host` 0.1.6 is cut for it** (`sdk-v0.1.6`, published by `sdk-publish.yml`), because a
|
||||
plugin resolves the SDK from the registry: the types for `PATCH /clients/{fingerprint}` could not
|
||||
reach one while they sat in `sdk/` unpublished. That single regenerated file is the whole diff since
|
||||
`sdk-v0.1.5`.
|
||||
|
||||
`SDK_VERSION` in `sdk/src/version.ts` moves with `package.json`. It is a hand-maintained constant —
|
||||
`tsconfig.build.json` sets `rootDir: "src"` so it cannot import `package.json`, and the runner ships
|
||||
as one bundled `runner-cli.js` with no manifest beside it — and the runner compares it against the
|
||||
SDK installed in the plugins tree to decide whether to reinstall. Shipping 0.1.6 with the constant
|
||||
still reading 0.1.5 would publish the types and then never deliver them; `version.test.ts` exists for
|
||||
exactly that and gates it.
|
||||
|
||||
---
|
||||
|
||||
## v0.31.2
|
||||
|
||||
10 commits since v0.31.1 (6 non-merge), counted at the tip this was cut from.
|
||||
|
||||
Generated
+38
-38
@@ -1090,7 +1090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1222,7 +1222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"pf-win-display",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
@@ -1959,9 +1959,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.15"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||
checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -2343,7 +2343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2446,7 +2446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2975,7 +2975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2996,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3032,7 +3032,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3050,7 +3050,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3073,7 +3073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3083,7 +3083,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3109,7 +3109,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3122,7 +3122,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3136,11 +3136,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3169,14 +3169,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3191,7 +3191,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3199,7 +3199,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-lc-rs",
|
||||
@@ -3211,7 +3211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3220,7 +3220,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3253,7 +3253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3264,7 +3264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
@@ -3275,7 +3275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3487,7 +3487,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3497,7 +3497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"anyhow",
|
||||
@@ -3521,7 +3521,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3538,7 +3538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pf-client-core",
|
||||
@@ -3554,7 +3554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3572,7 +3572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"cbindgen",
|
||||
@@ -3605,7 +3605,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
@@ -3614,7 +3614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3684,7 +3684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3698,7 +3698,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3722,7 +3722,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.31.2"
|
||||
"version": "0.31.3"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/client-logs": {
|
||||
|
||||
@@ -932,6 +932,10 @@ private val TEST_BUTTONS = listOf(
|
||||
"Select" to KeyEvent.KEYCODE_BUTTON_SELECT,
|
||||
"Start" to KeyEvent.KEYCODE_BUTTON_START,
|
||||
"Guide" to KeyEvent.KEYCODE_BUTTON_MODE,
|
||||
// The two buttons Android has no keycode for, on the keycodes [Gamepad.buttonBit] borrows for
|
||||
// them. Only a driverless Sony pad reaches these; every other controller leaves them dark.
|
||||
"Touch" to KeyEvent.KEYCODE_BUTTON_15,
|
||||
"Mute" to KeyEvent.KEYCODE_BUTTON_16,
|
||||
"↑" to KeyEvent.KEYCODE_DPAD_UP,
|
||||
"↓" to KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
"←" to KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
|
||||
@@ -628,7 +628,7 @@ class MainActivity : ComponentActivity() {
|
||||
// keyboard arrows and belong to the VK path below — and BACK, which is how a pad with
|
||||
// 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)) {
|
||||
if (fromPad(event)) {
|
||||
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
|
||||
@@ -710,7 +710,7 @@ class MainActivity : ComponentActivity() {
|
||||
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are) — and, for a real
|
||||
// pad, WHICH pad family, so the glyphs wear its lettering/shapes.
|
||||
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
|
||||
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
||||
lastPadIsGamepad = fromPad(event)
|
||||
if (lastPadIsGamepad) {
|
||||
lastPadStyle = Gamepad.styleFor(event.device)
|
||||
lastPadDeviceId = event.deviceId
|
||||
@@ -718,7 +718,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
// The Controllers debug screen sees pad events before the navigation remap below.
|
||||
padKeyProbe?.let { if (it(event)) return true }
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
if (fromPad(event)) {
|
||||
// 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. Read
|
||||
@@ -741,6 +741,32 @@ class MainActivity : ComponentActivity() {
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Did this key event come from a controller — the question every pad branch here actually
|
||||
* means when it asks `isFromSource(SOURCE_GAMEPAD)`.
|
||||
*
|
||||
* The event's source class is the platform's per-EVENT guess, and some boxes get it wrong:
|
||||
* Fire OS is reported to deliver a Bluetooth DualSense's Triangle, touchpad and Mode/PS with
|
||||
* standard `KEYCODE_BUTTON_*` keycodes but a SOURCE_KEYBOARD tag, and the plain gate then
|
||||
* drops them before anything can map them. The DEVICE's source classes are the fact, so widen
|
||||
* to the device — but only for keycodes that cannot be anything BUT a gamepad button.
|
||||
*
|
||||
* That restriction is the whole safety of this. [KeyEvent.isGamepadButton] is exactly the
|
||||
* `KEYCODE_BUTTON_*` block — no `KEYCODE_DPAD_*`, no `KEYCODE_BACK` — and both exclusions are
|
||||
* load-bearing: a keyboard's arrow keys share the D-pad keycodes and belong to the VK path
|
||||
* ([Gamepad.buttonBit]), and a remote's or keyboard's BACK shares `KEYCODE_BACK` and has to
|
||||
* keep leaving the stream, which for a device with no pad on it is the documented way out
|
||||
* ([Gamepad.padButtonBit]). Widening on the device alone — or on its vendor id, which for
|
||||
* `0x045E`/`0x054C` covers those vendors' keyboards and mice too — routes both into the pad
|
||||
* branch and breaks them.
|
||||
*
|
||||
* The RAW keycode is what is asked: routing happens before [Gamepad.padKeyCode]'s correction,
|
||||
* and both the raw and the corrected keycode are in this block for every button concerned.
|
||||
*/
|
||||
private fun fromPad(event: KeyEvent): Boolean =
|
||||
event.isFromSource(InputDevice.SOURCE_GAMEPAD) ||
|
||||
(KeyEvent.isGamepadButton(event.keyCode) && Gamepad.isPad(event.device))
|
||||
|
||||
/**
|
||||
* `true` (back) / `false` (forward) when this key event is a MOUSE side button, null when it is
|
||||
* anything else — including a remote's or keyboard's BACK, which must keep exiting the stream.
|
||||
|
||||
@@ -114,6 +114,22 @@ data class Settings(
|
||||
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
|
||||
*/
|
||||
val gamepadUiEnabled: Boolean = true,
|
||||
/**
|
||||
* Draw the console UI at 1080p and let the display scale it up, instead of at the panel's own
|
||||
* resolution. Off by default — this is a deliberate sharpness-for-smoothness trade, not
|
||||
* something to impose on a device that does not need it.
|
||||
*
|
||||
* It exists for 4K TVs and projectors. Their graphics chips are chosen to decode and composite
|
||||
* video, not to shade a UI, and are far slower than a phone's; at 4K every pass the console
|
||||
* draws — the mesh backdrop above all — costs four times what it does at 1080p on hardware
|
||||
* that is nowhere near four times faster. A "premium" 4K box is MORE likely to want this than
|
||||
* a cheap 1080p stick, which never had the extra pixels to begin with.
|
||||
*
|
||||
* Read by [io.unom.punktfunk.console.SkiaConsoleShell], which applies it with
|
||||
* `SurfaceHolder.setFixedSize` — the compositor then scales the smaller buffer up for free.
|
||||
* The stream is untouched; that has its own `renderScale`.
|
||||
*/
|
||||
val reduceUiResolution: Boolean = false,
|
||||
/**
|
||||
* When [gamepadUiEnabled] actually takes over — the cross-client `gamepad_ui_mode` pair,
|
||||
* mirroring the Apple client's `gamepadUIMode`: `"connected"` (default, and what the switch
|
||||
@@ -329,6 +345,7 @@ class SettingsStore(context: Context) {
|
||||
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||
reduceUiResolution = prefs.getBoolean(K_REDUCE_UI_RES, false),
|
||||
gamepadUiMode = prefs.getString(K_GAMEPAD_UI_MODE, GAMEPAD_UI_WHEN_CONNECTED)
|
||||
?: GAMEPAD_UI_WHEN_CONNECTED,
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
@@ -373,6 +390,7 @@ class SettingsStore(context: Context) {
|
||||
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
|
||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||
.putBoolean(K_REDUCE_UI_RES, s.reduceUiResolution)
|
||||
.putString(K_GAMEPAD_UI_MODE, s.gamepadUiMode)
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
.putString(K_UI_PALETTE, s.uiPalette)
|
||||
@@ -415,6 +433,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_HUD = "stats_hud_enabled"
|
||||
const val K_TOUCH_MODE = "touch_mode"
|
||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||
const val K_REDUCE_UI_RES = "reduce_ui_resolution"
|
||||
const val K_GAMEPAD_UI_MODE = "gamepad_ui_mode"
|
||||
const val K_LIBRARY = "library_enabled"
|
||||
const val K_UI_PALETTE = "ui_palette"
|
||||
|
||||
@@ -328,6 +328,7 @@ internal object ConsoleJson {
|
||||
j.put("android.ds_capture", s.dsCapture)
|
||||
j.put("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
j.put("android.gamepad_ui_enabled", s.gamepadUiEnabled)
|
||||
j.put("android.reduce_ui_resolution", s.reduceUiResolution)
|
||||
// A store written by the nesting build carries the stale wrapper; drop it rather than
|
||||
// round-trip a copy of these keys that nothing reads for the life of the install.
|
||||
j.remove("extra")
|
||||
@@ -386,6 +387,7 @@ internal object ConsoleJson {
|
||||
gamepadUiMode = j.optString("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
.ifEmpty { s.gamepadUiMode },
|
||||
gamepadUiEnabled = j.optBoolean("android.gamepad_ui_enabled", s.gamepadUiEnabled),
|
||||
reduceUiResolution = j.optBoolean("android.reduce_ui_resolution", s.reduceUiResolution),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
@@ -137,13 +138,53 @@ fun SkiaConsoleShell(
|
||||
// Phone) still read a step too small in the hand: the floor is what sets the phone scale
|
||||
// (the couch term only wins on tablets and TVs), so this is a phones-only bump.
|
||||
val tv = remember { io.unom.punktfunk.isTvDevice(context) }
|
||||
val scale = if (tv) 0f else {
|
||||
val dm = context.resources.displayMetrics
|
||||
val couch = minOf(dm.widthPixels, dm.heightPixels) / 800f
|
||||
maxOf(couch, density.density * 0.75f).coerceIn(0.75f, 3f)
|
||||
// The SurfaceView's own laid-out size, fed back by `onSizeChanged` below — deliberately not
|
||||
// `displayMetrics`. The reduced buffer's aspect ratio has to match the RECT it is scaled into
|
||||
// or the compositor stretches the whole interface, and while those two normally agree,
|
||||
// `displayMetrics` has a long history of disagreeing with a view's real size by a system bar
|
||||
// depending on the version and on who is currently hiding what. "Normally agree" is not
|
||||
// something to hang picture geometry on. Zero until the first layout, which is exactly what
|
||||
// `render` wants: the surface comes up at its natural size and is re-fixed a frame later.
|
||||
var viewW by remember { mutableStateOf(0) }
|
||||
var viewH by remember { mutableStateOf(0) }
|
||||
// "Reduce interface resolution" (`Settings.reduceUiResolution`): cap the console's BUFFER at
|
||||
// 1920 on its long edge and let the compositor scale it up to the panel. 1 means "draw at the
|
||||
// panel's own resolution" — the setting is off, or the display is already at or under 1080p
|
||||
// and there is nothing to give back.
|
||||
//
|
||||
// ONE factor on both axes, so the aspect ratio survives exactly and no layout can stretch.
|
||||
// Everything else in this function that speaks in SURFACE pixels multiplies by it — the insets
|
||||
// and design-unit scale just below, the pointer coordinates further down — because
|
||||
// `setFixedSize` shrinks the buffer WITHOUT shrinking the view: a mouse still reports its
|
||||
// position in view pixels, and handing those straight to a half-size surface would land the
|
||||
// cursor at twice its true offset.
|
||||
val render = if (!settings.reduceUiResolution) 1f else {
|
||||
val long = maxOf(viewW, viewH)
|
||||
if (long > 1920) 1920f / long else 1f
|
||||
}
|
||||
LaunchedEffect(handle, left, top, right, bottom, scale) {
|
||||
if (handle != 0L) NativeBridge.nativeConsoleSetViewport(handle, left, top, right, bottom, scale)
|
||||
// The pointer listeners below are installed in `factory`, which runs ONCE — capturing `render`
|
||||
// directly would freeze them at its first-composition value (1, before the first layout has
|
||||
// reported a size), and a mouse would keep reporting view pixels into a half-size surface for
|
||||
// the rest of the session. Same reason `platformUp` is held this way.
|
||||
val currentRender by rememberUpdatedState(render)
|
||||
val dm = context.resources.displayMetrics
|
||||
val scale = if (tv) 0f else {
|
||||
val couch = minOf(dm.widthPixels, dm.heightPixels) / 800f
|
||||
// `render` too: the design-unit scale is in SURFACE pixels, so shrinking the buffer without
|
||||
// shrinking this would draw the type larger on screen than the same phone draws it today.
|
||||
maxOf(couch, density.density * 0.75f).coerceIn(0.75f, 3f) * render
|
||||
}
|
||||
LaunchedEffect(handle, left, top, right, bottom, scale, render) {
|
||||
if (handle != 0L) {
|
||||
NativeBridge.nativeConsoleSetViewport(
|
||||
handle,
|
||||
left * render,
|
||||
top * render,
|
||||
right * render,
|
||||
bottom * render,
|
||||
scale,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The pad, raw, before MainActivity's B→Back and stick→D-pad synthesis: face buttons and the
|
||||
@@ -272,7 +313,9 @@ fun SkiaConsoleShell(
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.onSizeChanged { viewW = it.width; viewH = it.height },
|
||||
factory = { ctx ->
|
||||
SurfaceView(ctx).apply {
|
||||
// The console draws opaque, edge to edge; Compose overlays sit above it.
|
||||
@@ -305,7 +348,8 @@ fun SkiaConsoleShell(
|
||||
MotionEvent.ACTION_CANCEL -> 5
|
||||
else -> return@setOnTouchListener false
|
||||
}
|
||||
NativeBridge.nativeConsolePointer(handle, kind, ev.x, ev.y, 0f)
|
||||
// View pixels → SURFACE pixels (see `render` above).
|
||||
NativeBridge.nativeConsolePointer(handle, kind, ev.x * currentRender, ev.y * currentRender, 0f)
|
||||
if (ev.actionMasked == MotionEvent.ACTION_UP) v.performClick()
|
||||
true
|
||||
}
|
||||
@@ -313,13 +357,27 @@ fun SkiaConsoleShell(
|
||||
if (handle != 0L && ev.actionMasked == MotionEvent.ACTION_SCROLL &&
|
||||
ev.isFromSource(InputDevice.SOURCE_CLASS_POINTER)
|
||||
) {
|
||||
NativeBridge.nativeConsolePointer(handle, 4, ev.x, ev.y, ev.getAxisValue(MotionEvent.AXIS_VSCROLL))
|
||||
NativeBridge.nativeConsolePointer(handle, 4, ev.x * currentRender, ev.y * currentRender, ev.getAxisValue(MotionEvent.AXIS_VSCROLL))
|
||||
true
|
||||
} else false
|
||||
}
|
||||
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
|
||||
}
|
||||
},
|
||||
// Applied here rather than in `factory` so flipping the setting takes effect without
|
||||
// leaving the console: `setFixedSize` re-creates the buffer and the render thread
|
||||
// re-wraps it through the ordinary surfaceChanged path. `setSizeFromLayout` is the
|
||||
// documented way back to "the view's own size" when the setting goes off again.
|
||||
update = { view ->
|
||||
if (render < 1f) {
|
||||
view.holder.setFixedSize(
|
||||
(viewW * render).roundToInt().coerceAtLeast(1),
|
||||
(viewH * render).roundToInt().coerceAtLeast(1),
|
||||
)
|
||||
} else {
|
||||
view.holder.setSizeFromLayout()
|
||||
}
|
||||
},
|
||||
)
|
||||
when (platformScreen) {
|
||||
"licenses" -> ConsoleLicensesScreen(onBack = { platformScreen = null }, navActive = true)
|
||||
|
||||
@@ -282,6 +282,17 @@ object Gamepad {
|
||||
* `KEYCODE_DPAD_*` are included but must only be routed here when the event is from a gamepad
|
||||
* (a keyboard's arrow keys share these keycodes and belong to the VK path) — see MainActivity.
|
||||
* L2/R2 are forwarded as the analog trigger axes, never as buttons.
|
||||
*
|
||||
* [BTN_TOUCHPAD] and [BTN_MISC1] have no Android keycode at all, so
|
||||
* [PadButtons.GENERIC_SONY] BORROWS the last two rows of `Generic.kl`'s joystick block for
|
||||
* them ([KEYCODE_BUTTON_15][KeyEvent.KEYCODE_BUTTON_15] / `_16`, evdev `BTN_BASE5`/`BTN_BASE6`)
|
||||
* — see there. This table is global, so a device that genuinely presses one of those two
|
||||
* emits the bit as well. That is the cost of the borrow, and it is why the borrow is at the
|
||||
* TOP of the block rather than at `BUTTON_1`/`BUTTON_2`: those are a flight stick's trigger
|
||||
* and thumb button, which any joystick-usage HID device reports, whereas reaching `BUTTON_15`
|
||||
* takes a pad that declares fifteen. The residual case — a fifteen-button HOTAS whose button
|
||||
* 16 also toggles the client's mic — is the one this leaves on the table; narrowing it
|
||||
* further needs per-device knowledge the router does not have (see `GamepadRouter`).
|
||||
*/
|
||||
fun buttonBit(keyCode: Int): Int = when (keyCode) {
|
||||
KeyEvent.KEYCODE_BUTTON_A -> BTN_A
|
||||
@@ -295,6 +306,8 @@ object Gamepad {
|
||||
KeyEvent.KEYCODE_BUTTON_START -> BTN_START
|
||||
KeyEvent.KEYCODE_BUTTON_SELECT -> BTN_BACK
|
||||
KeyEvent.KEYCODE_BUTTON_MODE -> BTN_GUIDE
|
||||
KeyEvent.KEYCODE_BUTTON_15 -> BTN_TOUCHPAD // borrowed — see the KDoc
|
||||
KeyEvent.KEYCODE_BUTTON_16 -> BTN_MISC1 // borrowed — see the KDoc
|
||||
KeyEvent.KEYCODE_DPAD_UP -> BTN_DPAD_UP
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> BTN_DPAD_DOWN
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> BTN_DPAD_LEFT
|
||||
@@ -404,9 +417,12 @@ object Gamepad {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* L2 R2 Create Options L3 R3 PS touchpad mute, i.e. `0x130`..`0x13e` 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.
|
||||
*
|
||||
* This order — and ONLY this order — is where `0x13d`/`0x13e` mean the touchpad click and
|
||||
* the mute button. Everywhere else they are L3/R3.
|
||||
*/
|
||||
GENERIC_SONY,
|
||||
|
||||
@@ -453,7 +469,23 @@ object Gamepad {
|
||||
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.
|
||||
// Touchpad click and mute. The wire has bits for both ([BTN_TOUCHPAD] /
|
||||
// [BTN_MISC1]) and Android has no keycode for either, so these two borrow
|
||||
// BUTTON_15/BUTTON_16 to reach [buttonBit] — see its KDoc for the cost.
|
||||
//
|
||||
// ONLY here. `0x13d`/`0x13e` are BTN_THUMBL/BTN_THUMBR (L3/R3) in the standard
|
||||
// Linux mapping — [genericKeyCode] says so itself — and they mean touchpad and
|
||||
// mute purely because a driverless DualSense enumerates its buttons straight
|
||||
// through in its own report order, which is what GENERIC_SONY IS. Hoisting
|
||||
// this above `padMap(dev)` would put L3 on the touchpad and R3 on the mic for
|
||||
// every Xbox pad, Switch Pro, 8BitDo, Steam Deck and `hid-playstation`
|
||||
// DualSense on the couch. There is no scancode that means the same button on
|
||||
// all pads; that is the entire reason this enum exists.
|
||||
0x13d -> KeyEvent.KEYCODE_BUTTON_15 // touchpad click → BTN_TOUCHPAD
|
||||
0x13e -> KeyEvent.KEYCODE_BUTTON_16 // mute → BTN_MISC1
|
||||
// Unreachable with the guard above in force (it only lets `0x130`..`0x13e`
|
||||
// through, and every one of those is now named), and KEYCODE_UNKNOWN is the
|
||||
// safe answer if that ever changes.
|
||||
else -> KeyEvent.KEYCODE_UNKNOWN
|
||||
}
|
||||
GENERIC_XBOX -> when (scan) {
|
||||
|
||||
@@ -101,6 +101,18 @@ class GamepadRouter(
|
||||
* the whole session. The capture-link pads carry the same flag on [ExternalPad].
|
||||
*/
|
||||
val motionReaches: Boolean = true,
|
||||
/**
|
||||
* Whether [Gamepad.BTN_MISC1] means a MUTE button on this particular pad — the one bit
|
||||
* whose physical meaning differs per controller, and the gate on the mic toggle in
|
||||
* [slotButton].
|
||||
*
|
||||
* A DualSense has one; a Steam Controller 2 puts its QAM button on the same wire bit
|
||||
* (`Sc2Device`), and QAM must not mute anyone's microphone. Asked once at open, off the
|
||||
* fact each path actually knows: the report order for an [InputDevice] (only
|
||||
* [Gamepad.PadButtons.GENERIC_SONY] mints this bit there), the declared pad kind for a
|
||||
* capture link.
|
||||
*/
|
||||
val hasMuteButton: Boolean = false,
|
||||
) {
|
||||
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
|
||||
var held = 0
|
||||
@@ -160,7 +172,8 @@ class GamepadRouter(
|
||||
|
||||
/**
|
||||
* Invoked (main thread) each time the mic-mute chord ([MIC_CHORD], Select + Y) is COMPLETED on
|
||||
* a pad — the couch equivalent of the stream's on-screen mute button, which a gamepad user
|
||||
* a pad, or a pad's own mute button ([Gamepad.BTN_MISC1] — a DualSense's) is pressed — the
|
||||
* couch equivalent of the stream's on-screen mute button, which a gamepad user
|
||||
* cannot reach. `StreamScreen` wires it to the mute toggle. Unlike the exit chord this fires
|
||||
* immediately: muting is the kind of thing you want to have already happened, and the on-screen
|
||||
* indicator makes an accidental toggle self-evident. The buttons still go to the host — the
|
||||
@@ -234,15 +247,40 @@ class GamepadRouter(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this bit's WIRE SEND kept with this device, though the bit is otherwise tracked normally?
|
||||
*
|
||||
* Exactly one is: a real mute button ([Slot.hasMuteButton]) under the "local" [systemForward]
|
||||
* policy. It is tracked — the mic toggle in [slotButton] is edge-triggered off held state —
|
||||
* but not forwarded, so every send site has to ask, including [releaseHeld]'s close-time
|
||||
* flush, or a mute held across a disconnect would put a release on the wire for a press that
|
||||
* never went out. Every other system button under that policy leaves [slotButton] at the top
|
||||
* and never reaches a send at all.
|
||||
*/
|
||||
private fun localOnly(slot: Slot, bit: Int): Boolean =
|
||||
!systemForward && bit == Gamepad.BTN_MISC1 && slot.hasMuteButton
|
||||
|
||||
/**
|
||||
* One button transition on [slot] — the shared body behind [onButton] and an [ExternalPad]'s
|
||||
* transitions: forward the wire event, track held state, arm/disarm the exit chord, and fire
|
||||
* the instant chords ([MIC_CHORD], [STATS_CHORD]).
|
||||
* the instant chords ([MIC_CHORD], [STATS_CHORD], and the mute button's own mic toggle).
|
||||
*/
|
||||
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
|
||||
// Raw system buttons stay local under the "local" policy — no wire send and no held
|
||||
// tracking, symmetric on both edges so nothing leaks into the chords either.
|
||||
if (!systemForward && (bit == Gamepad.BTN_GUIDE || bit == Gamepad.BTN_MISC1)) return
|
||||
// tracking, symmetric on both edges so nothing leaks into the chords either. A Steam
|
||||
// Controller 2's QAM button is BTN_MISC1 and keeps exactly that behaviour.
|
||||
//
|
||||
// A real MUTE button ([Slot.hasMuteButton]) is deliberately exempt: that policy's own
|
||||
// words are "keeps them entirely with this device", and toggling this device's microphone
|
||||
// is precisely what a mute button does with itself. Returning here would have left the
|
||||
// button present and silently dead under `local`, for a reason nobody would ever find. It
|
||||
// loses its wire send instead (see [localOnly]) and keeps the held tracking the toggle's
|
||||
// edge-trigger reads. It cannot leak into a chord — MISC1 is in none of them.
|
||||
if (!systemForward &&
|
||||
(bit == Gamepad.BTN_GUIDE || (bit == Gamepad.BTN_MISC1 && !slot.hasMuteButton))
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (down) {
|
||||
if (guideGesture && send) {
|
||||
// A Select pressed ALONE is held back until it resolves: a tap (delivered
|
||||
@@ -258,7 +296,7 @@ class GamepadRouter(
|
||||
}
|
||||
flushPendingSelect(slot)
|
||||
}
|
||||
if (send && forwarding) {
|
||||
if (send && forwarding && !localOnly(slot, bit)) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
}
|
||||
val wasHeld = slot.held
|
||||
@@ -268,11 +306,26 @@ class GamepadRouter(
|
||||
// Mic mute and the stats-tier cycle, each edge-triggered on the button that COMPLETES
|
||||
// its chord (see [completesChord]) — the two meanings this client gives Select plus a
|
||||
// face button. Both leave the press on the wire: the game still gets its buttons.
|
||||
if (completesChord(wasHeld, bit, MIC_CHORD)) onMicChord?.invoke()
|
||||
//
|
||||
// A pad's own mute button is a second trigger for the SAME toggle, not a new
|
||||
// mechanism — so it gets the same edge-trigger, expressed as the one-button chord it
|
||||
// is. That is load-bearing rather than tidy: [onButton] deliberately still calls this
|
||||
// with `down = true` on auto-repeat and suppresses only `send` (its repeatCount
|
||||
// guard), so an unguarded `bit == BTN_MISC1` would flap the mic for as long as the
|
||||
// button is held down.
|
||||
//
|
||||
// [Slot.hasMuteButton] is the other half, and it is not belt-and-braces: BTN_MISC1 is
|
||||
// the wire's misc/QAM bit, and `Sc2Device` puts a Steam Controller 2's QAM button on
|
||||
// it. Reading "any MISC1" as mute would mute the microphone on every QAM press.
|
||||
if (completesChord(wasHeld, bit, MIC_CHORD) ||
|
||||
(slot.hasMuteButton && completesChord(wasHeld, bit, Gamepad.BTN_MISC1))
|
||||
) {
|
||||
onMicChord?.invoke()
|
||||
}
|
||||
if (completesChord(wasHeld, bit, STATS_CHORD)) onStatsChord?.invoke()
|
||||
} else {
|
||||
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
|
||||
if (!owned && send && forwarding) {
|
||||
if (!owned && send && forwarding && !localOnly(slot, bit)) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
}
|
||||
slot.held = slot.held and bit.inv()
|
||||
@@ -543,7 +596,15 @@ class GamepadRouter(
|
||||
// time. Cheap enough to ask unconditionally; the answer holds for the pad's lifetime.
|
||||
val motionReaches = NativeBridge.nativePadMotionReaches(handle, pref)
|
||||
if (forwarding && hasGyro && !motionReaches) onMotionUnreachable?.invoke()
|
||||
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
// `DsDevice` raises BTN_MISC1 from the DualSense report's mute bit; `Sc2Device` raises the
|
||||
// same bit from the Steam Controller 2's QAM button, which must not touch the microphone.
|
||||
// The declared kind separates them (a DualShock 4 has no mute button either).
|
||||
val hasMute = pref == Gamepad.PREF_DUALSENSE || pref == Gamepad.PREF_DUALSENSEEDGE
|
||||
slots[syntheticId] = Slot(
|
||||
index,
|
||||
Gamepad.AxisMapper(handle, index),
|
||||
hasMuteButton = hasMute,
|
||||
)
|
||||
return ExternalPad(syntheticId, index, motionReaches)
|
||||
}
|
||||
|
||||
@@ -603,10 +664,15 @@ class GamepadRouter(
|
||||
// Asked here, off the kind this pad just DECLARED — not off the session's resolved backend,
|
||||
// which under Automatic answers for whichever pad happened to be active at dial time. Held
|
||||
// for the slot's life; the sensor path reads it on every sample.
|
||||
val map = Gamepad.padMap(dev)
|
||||
val slot = Slot(
|
||||
index,
|
||||
Gamepad.AxisMapper(handle, index, Gamepad.padMap(dev)),
|
||||
Gamepad.AxisMapper(handle, index, map),
|
||||
NativeBridge.nativePadMotionReaches(handle, pref),
|
||||
// The only route to BTN_MISC1 on this path is GENERIC_SONY's `0x13e` row, so the
|
||||
// report order IS the answer — and unlike `pref` it survives the user pinning every
|
||||
// pad to one type, which would otherwise cost a DualSense its mute button.
|
||||
hasMuteButton = map.buttons == Gamepad.PadButtons.GENERIC_SONY,
|
||||
)
|
||||
slots[dev.id] = slot
|
||||
// After the table holds the slot, so a listener that sends on this device the moment it is
|
||||
@@ -652,7 +718,9 @@ class GamepadRouter(
|
||||
var bits = slot.held
|
||||
while (bits != 0) {
|
||||
val bit = bits and -bits // lowest set bit
|
||||
if (forwarding) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
if (forwarding && !localOnly(slot, bit)) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
}
|
||||
bits = bits and bit.inv()
|
||||
}
|
||||
slot.held = 0
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -155,6 +156,44 @@ class GamepadChordTest {
|
||||
assertEquals(instantChords, pad.press(Gamepad.BTN_BACK))
|
||||
}
|
||||
|
||||
/**
|
||||
* A pad's own mute button (a DualSense's) is a second trigger for the mic toggle, and
|
||||
* `slotButton` reads it through the SAME edge rule expressed as a one-button chord.
|
||||
*
|
||||
* That is not decoration. `onButton` deliberately still calls `slotButton(down = true)` on
|
||||
* auto-repeat and suppresses only the wire send (its repeatCount guard), so a plain
|
||||
* `bit == BTN_MISC1` would toggle the mic on every repeat — hold the button and the mic
|
||||
* flaps. `completesChord` against a single-bit mask is exactly "a fresh press of it".
|
||||
*
|
||||
* The other half is which buttons must NOT reach it. `0x13e` is R3 on every pad but a
|
||||
* driverless Sony one, so a mapping that leaked touchpad/mute meanings outside
|
||||
* [Gamepad.PadButtons.GENERIC_SONY] would put the mic toggle on every R3 press in the house.
|
||||
*
|
||||
* `slotButton` ANDs this rule with `Slot.hasMuteButton`, because BTN_MISC1 is the wire's
|
||||
* misc/QAM bit and a Steam Controller 2's QAM button rides it too. That term needs a live
|
||||
* `Slot`, which needs an InputManager and a main Looper, so it is out of reach from here —
|
||||
* the edge rule below is the half a unit test can hold.
|
||||
*/
|
||||
@Test
|
||||
fun `the mute button toggles the mic once per press`() {
|
||||
fun fires(wasHeld: Int, bit: Int) =
|
||||
GamepadRouter.completesChord(wasHeld, bit, Gamepad.BTN_MISC1)
|
||||
|
||||
assertTrue("a fresh press must toggle", fires(0, Gamepad.BTN_MISC1))
|
||||
assertFalse("auto-repeat re-fired the toggle", fires(Gamepad.BTN_MISC1, Gamepad.BTN_MISC1))
|
||||
assertTrue(
|
||||
"a press while other buttons are held is still a fresh press",
|
||||
fires(Gamepad.BTN_A or Gamepad.BTN_BACK, Gamepad.BTN_MISC1),
|
||||
)
|
||||
for (other in listOf(
|
||||
Gamepad.BTN_A, Gamepad.BTN_X, Gamepad.BTN_Y, Gamepad.BTN_BACK,
|
||||
Gamepad.BTN_LS_CLICK, Gamepad.BTN_RS_CLICK, Gamepad.BTN_GUIDE, Gamepad.BTN_TOUCHPAD,
|
||||
)) {
|
||||
assertFalse("$other toggled the mic", fires(0, other))
|
||||
assertFalse("$other toggled the mic under a held mute", fires(Gamepad.BTN_MISC1, other))
|
||||
}
|
||||
}
|
||||
|
||||
/** The chord bits are the wire's, so they must stay inside the 32-bit button mask. */
|
||||
@Test
|
||||
fun `chord masks are wire button bits`() {
|
||||
|
||||
@@ -64,12 +64,44 @@ class PadButtonsTest {
|
||||
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. */
|
||||
/**
|
||||
* The touchpad click and the mute button reach the wire, on the two bits that exist for them.
|
||||
* Android has no keycode for either, so [Gamepad.PadButtons.GENERIC_SONY] borrows BUTTON_15
|
||||
* and BUTTON_16 to carry them into [Gamepad.buttonBit] — the keycode is an implementation
|
||||
* detail of that hop, the BIT is the contract, so both halves are pinned here.
|
||||
*/
|
||||
@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)))
|
||||
fun `a DualSense's touchpad and mute reach their wire buttons`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_15, sony(0x13d))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_16, sony(0x13e))
|
||||
assertEquals(Gamepad.BTN_TOUCHPAD, Gamepad.buttonBit(sony(0x13d)))
|
||||
assertEquals(Gamepad.BTN_MISC1, Gamepad.buttonBit(sony(0x13e)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression the touchpad/mute mapping is one hoist away from causing, and the reason it
|
||||
* lives inside GENERIC_SONY rather than anywhere above `padMap(dev)`.
|
||||
*
|
||||
* `0x13d`/`0x13e` are `BTN_THUMBL`/`BTN_THUMBR` — L3 and R3 — in the standard Linux/AOSP
|
||||
* mapping, which is what [Gamepad.genericKeyCode] says they are. They mean touchpad click and
|
||||
* mute ONLY inside the straight-through enumeration a driverless Sony pad uses. Read as
|
||||
* touchpad and mute anywhere else, every Xbox pad, Switch Pro, 8BitDo, Steam Deck and
|
||||
* `hid-playstation` DualSense loses both stick clicks — and R3 starts toggling the microphone.
|
||||
*/
|
||||
@Test
|
||||
fun `every other pad keeps L3 and R3 on those scancodes`() {
|
||||
for (p in listOf(
|
||||
Gamepad.PadButtons.NATIVE,
|
||||
Gamepad.PadButtons.GENERIC_XBOX,
|
||||
Gamepad.PadButtons.SONY_MODERN,
|
||||
)) {
|
||||
val l3 = p.correct(0x13d, Gamepad.genericKeyCode(0x13d))
|
||||
val r3 = p.correct(0x13e, Gamepad.genericKeyCode(0x13e))
|
||||
assertEquals("$p L3", KeyEvent.KEYCODE_BUTTON_THUMBL, l3)
|
||||
assertEquals("$p R3", KeyEvent.KEYCODE_BUTTON_THUMBR, r3)
|
||||
assertEquals("$p L3 bit", Gamepad.BTN_LS_CLICK, Gamepad.buttonBit(l3))
|
||||
assertEquals("$p R3 bit", Gamepad.BTN_RS_CLICK, Gamepad.buttonBit(r3))
|
||||
}
|
||||
}
|
||||
|
||||
/** An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS. */
|
||||
@@ -116,6 +148,30 @@ class PadButtonsTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The guard's NEGATIVE path — the half that decides anything.
|
||||
*
|
||||
* The cases above all deliver the keycode `Generic.kl` would have produced, so the guard is
|
||||
* transparent in every one of them and the assertions would hold with it deleted. These are
|
||||
* the ones that fail without it: a device-specific key layout answering something the table
|
||||
* disagrees with, on a scancode the table has an opinion about. The layout wins — it knows
|
||||
* this controller, and the table is only ever a guess about a pad nothing knew.
|
||||
*/
|
||||
@Test
|
||||
fun `a device layout outranks the table on a scancode the table would have rewritten`() {
|
||||
// `Generic.kl` calls 0x134 BUTTON_Y, and GENERIC_SONY/GENERIC_XBOX both rewrite that
|
||||
// scancode to BUTTON_L1. A layout that says BUTTON_X must survive both.
|
||||
for (p in listOf(Gamepad.PadButtons.GENERIC_SONY, Gamepad.PadButtons.GENERIC_XBOX)) {
|
||||
assertEquals("$p", KeyEvent.KEYCODE_BUTTON_X, p.correct(0x134, KeyEvent.KEYCODE_BUTTON_X))
|
||||
}
|
||||
// And the two rows added for the touchpad and mute are no different: a pad whose layout
|
||||
// resolved 0x13d itself keeps that answer rather than the borrowed BUTTON_15.
|
||||
assertEquals(
|
||||
KeyEvent.KEYCODE_BUTTON_1,
|
||||
Gamepad.PadButtons.GENERIC_SONY.correct(0x13d, KeyEvent.KEYCODE_BUTTON_1),
|
||||
)
|
||||
}
|
||||
|
||||
/** Correcting twice is correcting once — the output is never itself a generic-layout answer. */
|
||||
@Test
|
||||
fun `correction is idempotent`() {
|
||||
|
||||
@@ -223,6 +223,7 @@ impl ConsoleHost {
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("pf-console".into())
|
||||
.spawn(move || {
|
||||
boost_thread_priority();
|
||||
let run = || -> Result<()> {
|
||||
let console = Console::new(opts, entry, &thread_handles)?;
|
||||
render_loop(console, thread_shared.clone(), thread_store)
|
||||
@@ -249,6 +250,34 @@ impl ConsoleHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort: lift the console's render thread off the default nice band, the same way
|
||||
/// `decode::setup::boost_thread_priority` lifts the decode thread. This thread IS the console's
|
||||
/// frame loop — every menu press waits on it — and at default priority a TV box's scheduler is
|
||||
/// free to park it on a little core behind whatever else the system is doing, which reads as a
|
||||
/// UI that lags the remote. `-8` rather than the decode path's `-10`: a stream's frames are the
|
||||
/// harder deadline, and the two should not compete when the console is up during a session.
|
||||
///
|
||||
/// Non-fatal if the platform refuses (the exact floor a foreground app may set is policy).
|
||||
fn boost_thread_priority() {
|
||||
// SAFETY: `gettid`/`setpriority` on the calling thread are always-safe syscalls; PRIO_PROCESS
|
||||
// with a TID targets that one task on Linux — the idiom `Process.setThreadPriority` uses.
|
||||
unsafe {
|
||||
let tid = libc::gettid();
|
||||
if libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -8) != 0 {
|
||||
log::debug!(
|
||||
"console: setpriority(-8) failed (non-fatal): {}",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How often the render loop reports what a frame is costing it. Nothing in a bug report from a
|
||||
/// TV said whether the console was drawing at 4K or at 60 Hz, so "it feels sluggish" could not be
|
||||
/// triaged from a log bundle at all — this is that missing line. One line a minute is cheap
|
||||
/// enough to leave on for everyone, and the answer is only useful from the box that is slow.
|
||||
const FRAME_REPORT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// No input for this long = the console is being looked at, not used — halve the redraw
|
||||
/// rate (`IDLE_FRAME_STEP` slept between swaps). 60 s keeps every interaction and its
|
||||
/// afterglow at full smoothness and only calms a genuinely parked screen.
|
||||
@@ -283,6 +312,9 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
// SurfaceView forever. Dying raises `Dead`, and Kotlin answers with the touch UI.
|
||||
let mut gl_failures = 0u32;
|
||||
const GL_FAILURE_LIMIT: u32 = 3;
|
||||
// What a frame is costing, reported once a `FRAME_REPORT` window (see there).
|
||||
let (mut frames, mut frame_time, mut frame_peak) = (0u32, Duration::ZERO, Duration::ZERO);
|
||||
let mut report_at = Instant::now();
|
||||
|
||||
loop {
|
||||
// Take everything queued. With no surface up, block until something arrives.
|
||||
@@ -446,8 +478,17 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
skia = None;
|
||||
match g.wrap_window(&egl, w, h) {
|
||||
Ok(surf) => {
|
||||
// The console's real render resolution — the one number a bug report
|
||||
// from a TV never carried. A 4K panel is 4× the fragment work of 1080p
|
||||
// for every pass the shell draws.
|
||||
log::info!("console: drawing at {w}×{h}");
|
||||
skia = Some((surf, w, h));
|
||||
gl_failures = 0;
|
||||
// Start the frame window here, not at loop entry: the console parks
|
||||
// with no surface while a stream is up, and a window that had been
|
||||
// open across that would report its first frame as "1 frame in 20 min".
|
||||
(frames, frame_time, frame_peak, report_at) =
|
||||
(0, Duration::ZERO, Duration::ZERO, Instant::now());
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("console: {e:#}");
|
||||
@@ -462,6 +503,11 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
insets,
|
||||
scale,
|
||||
};
|
||||
// Around the DRAW only, not the swap: `eglSwapBuffers` blocks on vsync, so
|
||||
// wall-clock per iteration is always ~the panel period and says nothing. What
|
||||
// matters is how much of that period the shell spends building the frame —
|
||||
// once that passes the period, the console is missing vsyncs.
|
||||
let drew = Instant::now();
|
||||
console.frame(
|
||||
surf.canvas(),
|
||||
&viewport,
|
||||
@@ -470,6 +516,20 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
&pads,
|
||||
);
|
||||
g.context.flush_and_submit();
|
||||
let cost = drew.elapsed();
|
||||
frame_time += cost;
|
||||
frame_peak = frame_peak.max(cost);
|
||||
frames += 1;
|
||||
if report_at.elapsed() >= FRAME_REPORT {
|
||||
log::info!(
|
||||
"console: {w}×{h}, {frames} frames in {:?} — {:.1} ms/frame mean, {:.1} ms peak",
|
||||
report_at.elapsed(),
|
||||
frame_time.as_secs_f64() * 1000.0 / f64::from(frames),
|
||||
frame_peak.as_secs_f64() * 1000.0,
|
||||
);
|
||||
(frames, frame_time, frame_peak, report_at) =
|
||||
(0, Duration::ZERO, Duration::ZERO, Instant::now());
|
||||
}
|
||||
if let Err(e) = s.swap() {
|
||||
// The window went away under us; wait for the next surface.
|
||||
log::warn!("console: {e:#} — dropping the surface");
|
||||
|
||||
@@ -21,6 +21,25 @@
|
||||
//! handle early at worst reuses a buffer a touch soon (a visible tear), never a use-after-free. The
|
||||
//! fences are the correctness of *timing*, not of memory — which is what lets this ship behind an
|
||||
//! auto-fallback with the residual risk being visual, not a crash.
|
||||
//!
|
||||
//! **The acquire fence must come from `acquireNextImageAsync`, never `acquireLatestImageAsync`.**
|
||||
//! `AImageReader::acquireLatestImage` (`NdkImageReader.cpp`, unfixed as of AOSP main) drains with
|
||||
//! one `int*` out-param it overwrites per image, then releases each dropped image with whatever the
|
||||
//! out-param currently holds — the *successor's* fence:
|
||||
//!
|
||||
//! ```text
|
||||
//! acquireImageLocked(&prev, fd) → *fd = F1 (prev = img1)
|
||||
//! acquireImageLocked(&next, fd) → *fd = F2 (next = img2; F1 overwritten and leaked)
|
||||
//! prev->close(*fd) → reader adopts F2 as img1's release fence, then closes it
|
||||
//! acquireImageLocked(&next, fd) → no buffer; leaves *fd alone
|
||||
//! returns img2 with *fd = F2 ← already given away and closed
|
||||
//! ```
|
||||
//!
|
||||
//! So the moment a burst gives it two images to collapse, the caller is handed a stale fd plus one
|
||||
//! leaked fd per extra drop. Passing that stale fd to `setBuffer` transfers it to SurfaceFlinger,
|
||||
//! which closes it again — an `fdsan` `SIGABRT` on the decode thread, either at `Fence::Fence(int)`
|
||||
//! inside `setBuffer` (the number was already re-owned) or at the end of `Transaction::apply` when
|
||||
//! the layer state is torn down. `AscBackend::drain_reader` therefore does newest-wins itself.
|
||||
|
||||
use ndk::hardware_buffer::HardwareBuffer;
|
||||
use ndk::media::image_reader::{AcquireResult, Image, ImageFormat, ImageReader};
|
||||
@@ -376,19 +395,24 @@ impl AscBackend {
|
||||
true
|
||||
}
|
||||
|
||||
/// Acquire newly rendered images out of the reader: latency keeps only the newest (older are
|
||||
/// dropped back to the pool by `acquireLatest`); smooth keeps order up to capacity.
|
||||
/// Acquire newly rendered images out of the reader: latency keeps only the newest (older ones
|
||||
/// drop back to the pool as they are superseded); smooth keeps order up to capacity.
|
||||
///
|
||||
/// Both modes drain with `acquireNextImageAsync`, one image at a time. `acquireLatestImageAsync`
|
||||
/// is the obvious newest-wins call and is NOT usable — see the acquire-fence note at the top of
|
||||
/// this module.
|
||||
fn drain_reader(&mut self) {
|
||||
if self.fifo_capacity == 0 {
|
||||
// Newest-wins: one acquire-latest collapses the whole burst to the freshest buffer.
|
||||
if let Some(acq) = self.acquire(true) {
|
||||
// Newest-wins: collapse the burst to the freshest buffer ourselves. Each superseded
|
||||
// candidate drops here — its image returns to the pool, its own acquire fence closes.
|
||||
while let Some(acq) = self.acquire() {
|
||||
if self.candidate.replace(acq).is_some() {
|
||||
self.skipped += 1; // an un-presented candidate was superseded
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Smooth: pull every ready image in order into the FIFO, evicting the oldest past cap.
|
||||
while let Some(acq) = self.acquire(false) {
|
||||
while let Some(acq) = self.acquire() {
|
||||
self.fifo.push_back(acq);
|
||||
while self.fifo.len() > self.fifo_capacity {
|
||||
self.fifo.pop_front();
|
||||
@@ -398,19 +422,13 @@ impl AscBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire one image (`latest` drops older, else FIFO) and pair its decode stamps + cadence due.
|
||||
/// `None` when the reader is empty or a transient acquire error occurs.
|
||||
fn acquire(&mut self, latest: bool) -> Option<Acquired> {
|
||||
/// Acquire the next image and pair its decode stamps + cadence due. `None` when the reader is
|
||||
/// empty or a transient acquire error occurs.
|
||||
fn acquire(&mut self) -> Option<Acquired> {
|
||||
// SAFETY: we never touch the image's pixels — the acquire fence is handed straight to
|
||||
// SurfaceFlinger via `setBuffer`, which is exactly the "await before access" the async
|
||||
// acquire requires.
|
||||
let res = unsafe {
|
||||
if latest {
|
||||
self.reader.acquire_latest_image_async()
|
||||
} else {
|
||||
self.reader.acquire_next_image_async()
|
||||
}
|
||||
};
|
||||
let res = unsafe { self.reader.acquire_next_image_async() };
|
||||
let (image, fence) = match res {
|
||||
Ok(AcquireResult::Image(pair)) => pair,
|
||||
Ok(_) => return None, // no buffer available / max acquired
|
||||
|
||||
@@ -317,6 +317,12 @@ impl ImageReader {
|
||||
/// If the returned file descriptor is not [`None`], it must be awaited before attempting to
|
||||
/// access the [`Image`] returned.
|
||||
///
|
||||
/// **The returned fence is unsound whenever the platform actually drops an older image.**
|
||||
/// `AImageReader::acquireLatestImage` reuses one out-param across the drain and releases each
|
||||
/// dropped image with the *successor's* fence fd, so the fd handed back has already been given
|
||||
/// to the reader (and closed by it) — adopting it here yields a double close and an `fdsan`
|
||||
/// abort. Drain with [`ImageReader::acquire_next_image_async()`] and pick the newest yourself.
|
||||
///
|
||||
/// <https://developer.android.com/ndk/reference/group/media#aimagereader_acquirelatestimageasync>
|
||||
#[cfg(feature = "api-level-26")]
|
||||
#[doc(alias = "AImageReader_acquireLatestImageAsync")]
|
||||
|
||||
@@ -1250,7 +1250,9 @@ pub fn show_scoped(
|
||||
"Above 1× supersamples for sharpness; below is lighter on the host",
|
||||
&scale_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
|
||||
// 1 Mbit/s per step: the rungs that matter on a thin link are 3, 4, 6 — a 5-wide step
|
||||
// could not name any of them, and typing was the only way to reach one.
|
||||
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 1.0);
|
||||
bitrate_row.set_title("Bitrate");
|
||||
bitrate_row
|
||||
.set_subtitle("Mbit/s · 0 = host default · a host card's menu has a network speed test");
|
||||
|
||||
@@ -246,16 +246,22 @@ impl Screen {
|
||||
match self {
|
||||
Screen::AddHost(s) => s.text_input(text),
|
||||
Screen::Pair(s) => s.text_input(text),
|
||||
Screen::Settings(s) => s.text_input(text),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw key edits while a field is editing (Backspace repeats, Return = done).
|
||||
/// Returns true when consumed.
|
||||
pub(crate) fn edit_key(&mut self, key: crate::input::Key) -> bool {
|
||||
///
|
||||
/// Takes the context because a field can commit into the settings store on close —
|
||||
/// the settings screen's typed bitrate does, where add-host and pair only hold text a
|
||||
/// later action row reads.
|
||||
pub(crate) fn edit_key(&mut self, key: crate::input::Key, ctx: &mut Ctx) -> bool {
|
||||
match self {
|
||||
Screen::AddHost(s) => s.edit_key(key),
|
||||
Screen::Pair(s) => s.edit_key(key),
|
||||
Screen::Settings(s) => s.edit_key(key, ctx),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -265,6 +271,7 @@ impl Screen {
|
||||
match self {
|
||||
Screen::AddHost(s) => s.editing(),
|
||||
Screen::Pair(s) => s.editing(),
|
||||
Screen::Settings(s) => s.editing(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ use crate::glyphs::{Hint, HintKey};
|
||||
use crate::pointer::Pointer;
|
||||
use crate::screens::{Ctx, Outbox, Screen};
|
||||
use crate::theme::{fg, Fonts, W};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec, TabStrip, TAB_STRIP_H};
|
||||
use crate::widgets::{
|
||||
permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec, TabStrip, TAB_STRIP_H,
|
||||
};
|
||||
use pf_client_core::audio_format::{AUDIO_FORMATS, AUDIO_FORMAT_OPUS};
|
||||
use pf_client_core::menu_nav::{MenuEvent, MenuPulse};
|
||||
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
|
||||
@@ -80,6 +82,12 @@ enum RowId {
|
||||
/// beside the palette row for the same reason it does: both are presentation, and the
|
||||
/// effect of stepping this one is visible on the backdrop behind it.
|
||||
ReduceMotion,
|
||||
/// Draw the console at 1080p and let the display scale it up, instead of at the panel's
|
||||
/// own resolution. Android-only, and beside [`RowId::ReduceMotion`] on purpose: both are
|
||||
/// "give up some fidelity for a smoother console", and this is the one that matters on a
|
||||
/// 4K TV or projector, where every pass the shell draws costs four times what it does at
|
||||
/// 1080p on a GPU that is not four times faster.
|
||||
ReduceUiResolution,
|
||||
/// How the game library arranges its titles — see `library::LibraryView`. The library
|
||||
/// changes it in place now, from the bar over its own field, which is where an
|
||||
/// arrangement you want to SEE the effect of belongs; this row stays because both
|
||||
@@ -128,6 +136,7 @@ mod android_keys {
|
||||
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";
|
||||
pub const REDUCE_UI_RES: &str = "android.reduce_ui_resolution";
|
||||
}
|
||||
|
||||
/// The Android console-UI mode's stored values (`GamepadUi.kt`).
|
||||
@@ -247,6 +256,7 @@ const TABS: [(&str, &[RowId]); 7] = [
|
||||
&[
|
||||
RowId::Palette,
|
||||
RowId::ReduceMotion,
|
||||
RowId::ReduceUiResolution,
|
||||
RowId::LibraryView,
|
||||
RowId::LibraryCollections,
|
||||
RowId::Stats,
|
||||
@@ -280,7 +290,18 @@ const RESOLUTIONS: [(u32, u32); 6] = [
|
||||
const REFRESH: [u32; 5] = [0, 30, 60, 90, 120];
|
||||
/// Mirrors [`punktfunk_core::render_scale::PRESETS`] (and the desktop pickers).
|
||||
const RENDER_SCALES: [f64; 9] = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||
const BITRATES: [u32; 7] = [0, 5_000, 10_000, 20_000, 30_000, 50_000, 80_000];
|
||||
/// The rungs left/right steps through, in kbps. Tight at the bottom, where one rung is the
|
||||
/// difference between watchable and a slideshow on a thin link, and coarse at the top, where
|
||||
/// a rung is noise; the ceiling is 2 Gbps. The list is deliberately long — a ladder no thumb
|
||||
/// can walk to the value it wants is what the Y field is for.
|
||||
const BITRATES: [u32; 30] = [
|
||||
0, 1_000, 2_000, 3_000, 4_000, 5_000, 6_000, 8_000, 10_000, 12_000, 15_000, 20_000, 25_000,
|
||||
30_000, 40_000, 50_000, 60_000, 80_000, 100_000, 125_000, 150_000, 200_000, 250_000, 300_000,
|
||||
400_000, 500_000, 750_000, 1_000_000, 1_500_000, 2_000_000,
|
||||
];
|
||||
/// What the typed field accepts, in Mbps: the ladder's own ceiling. The host clamps to its
|
||||
/// range anyway (500 kbps – 8 Gbps), so this is about what a client should let you ask for.
|
||||
const CUSTOM_MAX_MBPS: u32 = 2_000;
|
||||
const COMPOSITORS: [(&str, &str); 5] = [
|
||||
("auto", "Automatic"),
|
||||
("kwin", "KWin"),
|
||||
@@ -363,6 +384,14 @@ pub(crate) struct SettingsScreen {
|
||||
/// can't create profiles (design §5.4: the desktop app does), so the list is stable
|
||||
/// for the screen's lifetime.
|
||||
profiles: Vec<(String, String)>,
|
||||
/// The Bitrate row's typed rate in Mbps while Y has the field open — `None` the rest of
|
||||
/// the time. Every other row on this screen is a list of options, and a ladder is the
|
||||
/// right shape for a list; a bitrate is a NUMBER, and the one a link actually carries is
|
||||
/// rarely a round rung. Y rather than A so the A-cycles-forward grammar holds everywhere.
|
||||
custom_bitrate: Option<String>,
|
||||
/// The tray keyboard the field types through, where the platform has no keyboard of its
|
||||
/// own (on a Deck, Steam's keyboard types and ours never draws — same rule as add-host).
|
||||
keyboard: Keyboard,
|
||||
}
|
||||
|
||||
impl SettingsScreen {
|
||||
@@ -380,6 +409,112 @@ impl SettingsScreen {
|
||||
tab: 0,
|
||||
tab_cursors: [0; TABS.len()],
|
||||
profiles,
|
||||
custom_bitrate: None,
|
||||
keyboard: Keyboard::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A text field is open — the run loop keeps SDL text input started, so a hardware
|
||||
/// keyboard (and Steam's, on a Deck) types straight into it.
|
||||
pub(crate) fn editing(&self) -> bool {
|
||||
self.custom_bitrate.is_some()
|
||||
}
|
||||
|
||||
/// Committed text from SDL. Digits only, four of them: 2000 Mbps is the ceiling.
|
||||
pub(crate) fn text_input(&mut self, text: &str) {
|
||||
for ch in text.chars() {
|
||||
self.type_char(ch);
|
||||
}
|
||||
}
|
||||
|
||||
fn type_char(&mut self, ch: char) -> bool {
|
||||
let Some(buf) = self.custom_bitrate.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
if !permits(Charset::Digits, ch) || buf.chars().count() >= 4 {
|
||||
return false;
|
||||
}
|
||||
buf.push(ch);
|
||||
true
|
||||
}
|
||||
|
||||
fn backspace(&mut self) -> bool {
|
||||
self.custom_bitrate.as_mut().and_then(String::pop).is_some()
|
||||
}
|
||||
|
||||
/// Raw key edits while the field is open (Backspace repeats, Return/Escape are done).
|
||||
pub(crate) fn edit_key(&mut self, key: crate::input::Key, ctx: &mut Ctx) -> bool {
|
||||
use crate::input::Key as K;
|
||||
if self.custom_bitrate.is_none() {
|
||||
return false;
|
||||
}
|
||||
match key {
|
||||
K::Backspace => {
|
||||
self.backspace();
|
||||
true
|
||||
}
|
||||
K::Return | K::Escape => {
|
||||
self.commit_custom(ctx);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the field, storing what was typed. An empty field (or a typed `0`) leaves the
|
||||
/// rate alone: a cleared field is an abandoned edit, and "let the host decide" is the
|
||||
/// ladder's own first rung, not something to reach by deleting four digits.
|
||||
fn commit_custom(&mut self, ctx: &mut Ctx) {
|
||||
let Some(text) = self.custom_bitrate.take() else {
|
||||
return;
|
||||
};
|
||||
let Ok(mbps) = text.parse::<u32>() else {
|
||||
return;
|
||||
};
|
||||
if mbps == 0 {
|
||||
return;
|
||||
}
|
||||
// The same rebase-then-save every other write here does: another writer may have
|
||||
// stored the file while the keyboard was up.
|
||||
*ctx.settings = ctx.store.load();
|
||||
ctx.settings.bitrate_kbps = mbps.min(CUSTOM_MAX_MBPS) * 1000;
|
||||
ctx.store.save(ctx.settings);
|
||||
}
|
||||
|
||||
/// The field is modal while it is up: the tray takes the events, and closing commits.
|
||||
fn custom_menu(&mut self, ev: MenuEvent, ctx: &mut Ctx) -> Option<MenuPulse> {
|
||||
if ctx.deck {
|
||||
// Steam's keyboard is doing the typing (text arrives through `text_input`); the
|
||||
// pad is only here to say when it's done.
|
||||
return match ev {
|
||||
MenuEvent::Back | MenuEvent::Confirm => {
|
||||
self.commit_custom(ctx);
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
let (msg, pulse) = self.keyboard.menu(ev);
|
||||
match msg {
|
||||
KeyMsg::Type(c) => {
|
||||
if self.type_char(c) {
|
||||
Some(MenuPulse::Move)
|
||||
} else {
|
||||
Some(MenuPulse::Boundary)
|
||||
}
|
||||
}
|
||||
KeyMsg::Backspace => {
|
||||
if self.backspace() {
|
||||
Some(MenuPulse::Move)
|
||||
} else {
|
||||
Some(MenuPulse::Boundary)
|
||||
}
|
||||
}
|
||||
KeyMsg::Done => {
|
||||
self.commit_custom(ctx);
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
KeyMsg::None => pulse,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,6 +584,27 @@ impl SettingsScreen {
|
||||
/// Mouse/touch. The strip is checked first — its pills sit above the list and a press
|
||||
/// there is never meant for a row.
|
||||
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
|
||||
if self.custom_bitrate.is_some() && !ctx.deck {
|
||||
if !self.keyboard.covers(p) {
|
||||
if p.press() {
|
||||
self.commit_custom(ctx);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let (msg, _) = self.keyboard.pointer(p);
|
||||
match msg {
|
||||
KeyMsg::Type(c) => {
|
||||
self.type_char(c);
|
||||
}
|
||||
KeyMsg::Backspace => {
|
||||
self.backspace();
|
||||
}
|
||||
KeyMsg::Done => self.commit_custom(ctx),
|
||||
KeyMsg::None => {}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if let Some(tab) = self.strip.pointer(p) {
|
||||
self.show_tab(tab, ctx);
|
||||
return true;
|
||||
@@ -469,6 +625,9 @@ impl SettingsScreen {
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
if self.custom_bitrate.is_some() {
|
||||
return self.custom_menu(ev, ctx);
|
||||
}
|
||||
match ev {
|
||||
MenuEvent::Back => {
|
||||
fx.pop();
|
||||
@@ -480,6 +639,16 @@ impl SettingsScreen {
|
||||
}
|
||||
let ids = self.row_ids(ctx);
|
||||
self.clamp_cursor(ids.len());
|
||||
// Y on the Bitrate row opens the typed rate; on every other row it means nothing,
|
||||
// and the hint bar only offers it where it does.
|
||||
if ev == MenuEvent::Secondary {
|
||||
return if ids.get(self.list.cursor) == Some(&RowId::Bitrate) {
|
||||
self.custom_bitrate = Some(String::new());
|
||||
Some(MenuPulse::Confirm)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
let (msg, pulse) = self.list.menu(ev, ids.len());
|
||||
self.apply_row(msg, pulse, &ids, ctx, fx)
|
||||
}
|
||||
@@ -582,6 +751,20 @@ impl SettingsScreen {
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
|
||||
if self.custom_bitrate.is_some() {
|
||||
if ctx.deck {
|
||||
return vec![
|
||||
Hint::new(HintKey::Key("STEAM + X"), "Keyboard"),
|
||||
Hint::new(HintKey::Confirm, "Done"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
];
|
||||
}
|
||||
return vec![
|
||||
Hint::new(HintKey::Confirm, "Type"),
|
||||
Hint::new(HintKey::Tertiary, "Delete"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
];
|
||||
}
|
||||
let ids = self.row_ids(ctx);
|
||||
// The shoulders always change section, so that hint leads on every row.
|
||||
let mut hints = vec![Hint::new(HintKey::Shoulders, "Section")];
|
||||
@@ -595,6 +778,12 @@ impl SettingsScreen {
|
||||
Hint::new(HintKey::Confirm, "Open"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
],
|
||||
// The one row with a value the ladder cannot name every version of.
|
||||
Some(RowId::Bitrate) => vec![
|
||||
Hint::new(HintKey::Adjust, "Adjust"),
|
||||
Hint::new(HintKey::Secondary, "Type a rate"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
],
|
||||
Some(_) => vec![
|
||||
Hint::new(HintKey::Adjust, "Adjust"),
|
||||
Hint::new(HintKey::Confirm, "Change"),
|
||||
@@ -627,20 +816,49 @@ impl SettingsScreen {
|
||||
k,
|
||||
dt,
|
||||
);
|
||||
let seat = self
|
||||
.keyboard
|
||||
.seat(self.custom_bitrate.is_some() && !ctx.deck, dt);
|
||||
let tray_h = if seat > 0.0 {
|
||||
(Keyboard::tray_height() + 12.0) * k * seat
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let list_rect = Rect::from_ltrb(
|
||||
rect.left,
|
||||
rect.top + strip_h as f32,
|
||||
rect.right,
|
||||
rect.bottom - detail_h as f32,
|
||||
rect.bottom - detail_h as f32 - tray_h as f32,
|
||||
);
|
||||
let ids = self.row_ids(ctx);
|
||||
self.clamp_cursor(ids.len());
|
||||
let rows: Vec<RowSpec> = ids
|
||||
let mut rows: Vec<RowSpec> = ids
|
||||
.iter()
|
||||
.map(|id| row_spec(*id, ctx, &self.profiles))
|
||||
.collect();
|
||||
self.list
|
||||
.render(canvas, list_rect, &rows, fonts, k, dt, true);
|
||||
// While the field is open the Bitrate row IS the field: it shows the digits typed so
|
||||
// far and carries the caret, so the value being edited is where the value lives.
|
||||
if let (Some(text), Some(i)) = (
|
||||
self.custom_bitrate.as_ref(),
|
||||
ids.iter().position(|id| *id == RowId::Bitrate),
|
||||
) {
|
||||
rows[i].value = Some(if text.is_empty() {
|
||||
"Mbps".into()
|
||||
} else {
|
||||
format!("{text} Mbps")
|
||||
});
|
||||
rows[i].value_dim = text.is_empty();
|
||||
rows[i].caret = true;
|
||||
}
|
||||
self.list.render(
|
||||
canvas,
|
||||
list_rect,
|
||||
&rows,
|
||||
fonts,
|
||||
k,
|
||||
dt,
|
||||
self.custom_bitrate.is_none(),
|
||||
);
|
||||
let detail = ids
|
||||
.get(self.list.cursor)
|
||||
.copied()
|
||||
@@ -652,9 +870,19 @@ impl SettingsScreen {
|
||||
13.0 * k,
|
||||
fg(0.55),
|
||||
f64::from(rect.left) + f64::from(rect.width()) / 2.0,
|
||||
f64::from(rect.bottom) - detail_h + 6.0 * k,
|
||||
f64::from(rect.bottom) - detail_h - tray_h + 6.0 * k,
|
||||
f64::from(rect.width()) * 0.8,
|
||||
);
|
||||
if seat > 0.0 {
|
||||
self.keyboard.render(
|
||||
canvas,
|
||||
fonts,
|
||||
f64::from(rect.width()),
|
||||
f64::from(rect.bottom),
|
||||
seat,
|
||||
k,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -685,6 +913,7 @@ fn row_on(id: RowId, platform: crate::platform::Platform) -> bool {
|
||||
| RowId::DsCapture
|
||||
| RowId::GamepadUi
|
||||
| RowId::GamepadUiMode
|
||||
| RowId::ReduceUiResolution
|
||||
| RowId::Controllers
|
||||
| RowId::Licenses
|
||||
);
|
||||
@@ -828,7 +1057,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
if s.bitrate_kbps == 0 {
|
||||
"Automatic".into()
|
||||
} else {
|
||||
format!("{} Mbps", s.bitrate_kbps / 1000)
|
||||
bitrate_label(s.bitrate_kbps)
|
||||
},
|
||||
),
|
||||
RowId::Compositor => (
|
||||
@@ -936,6 +1165,11 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
// Phrased as the thing that is ON, not as the suppression, so "On" means the
|
||||
// reduction is in effect — the same way every other toggle on this screen reads.
|
||||
RowId::ReduceMotion => (None, "Reduce motion", on_off(s.reduce_motion).into()),
|
||||
RowId::ReduceUiResolution => (
|
||||
None,
|
||||
"Reduce interface resolution",
|
||||
on_off(extra_bool(s, android_keys::REDUCE_UI_RES, false)).into(),
|
||||
),
|
||||
RowId::LibraryView => (
|
||||
None,
|
||||
"Library view",
|
||||
@@ -1031,7 +1265,9 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
|
||||
"The host renders larger or smaller than the stream mode and this window \
|
||||
resamples — above 1× supersamples, below saves bandwidth."
|
||||
}
|
||||
RowId::Bitrate => "Automatic uses the host's default (20 Mbps).",
|
||||
RowId::Bitrate => {
|
||||
"Automatic uses the host's default (20 Mbps). Y types an exact rate, up to 2 Gbps."
|
||||
}
|
||||
RowId::Compositor => {
|
||||
"Which compositor drives the virtual output — honored only if available on the host."
|
||||
}
|
||||
@@ -1131,6 +1367,12 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
|
||||
fades. Also the gentler choice on an OLED, where a still field can sit for \
|
||||
hours."
|
||||
}
|
||||
RowId::ReduceUiResolution => {
|
||||
"Draws the menus at 1080p and lets the display scale them up. Text goes a \
|
||||
little softer; the console gets much smoother on a 4K TV or projector, whose \
|
||||
graphics chip is far slower than the panel in front of it. Nothing about a \
|
||||
stream changes — this is the interface only."
|
||||
}
|
||||
RowId::LibraryView => {
|
||||
"Shelf shows one cover at a time, big. Grid shows about eighteen at once — \
|
||||
for when you already know what you are looking for. The library's own bar \
|
||||
@@ -1199,6 +1441,26 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// A rate as the row says it: Mbps to a gigabit, Gbps above it, and a decimal only where
|
||||
/// dropping one would print two different rates the same way (12.5 Mbps, 1.5 Gbps). Rates
|
||||
/// off the ladder are real — the field below types them, and the desktop shells' free-form
|
||||
/// spinner has always been able to store one.
|
||||
fn bitrate_label(kbps: u32) -> String {
|
||||
let unit = |v: f64, suffix: &str| {
|
||||
if (v - v.round()).abs() < 0.05 {
|
||||
format!("{} {suffix}", v.round())
|
||||
} else {
|
||||
format!("{v:.1} {suffix}")
|
||||
}
|
||||
};
|
||||
let mbps = f64::from(kbps) / 1000.0;
|
||||
if kbps >= 1_000_000 {
|
||||
unit(mbps / 1000.0, "Gbps")
|
||||
} else {
|
||||
unit(mbps, "Mbps")
|
||||
}
|
||||
}
|
||||
|
||||
fn on_off(v: bool) -> &'static str {
|
||||
if v {
|
||||
"On"
|
||||
@@ -1263,8 +1525,21 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
.map(|i| s.render_scale = RENDER_SCALES[i])
|
||||
}
|
||||
RowId::Bitrate => {
|
||||
let cur = BITRATES.iter().position(|b| *b == s.bitrate_kbps);
|
||||
step_option(cur, BITRATES.len(), delta, wrap).map(|i| s.bitrate_kbps = BITRATES[i])
|
||||
// A typed rate (or one a desktop shell's spinner stored) sits BETWEEN rungs, and
|
||||
// the generic step snaps a value it cannot find to the first option — which here
|
||||
// is Automatic, i.e. one nudge throws the custom rate away. Step to the rung the
|
||||
// thumb is heading for instead.
|
||||
let stepped = match BITRATES.iter().position(|b| *b == s.bitrate_kbps) {
|
||||
Some(i) => step_option(Some(i), BITRATES.len(), delta, wrap),
|
||||
None if delta < 0 => BITRATES.iter().rposition(|b| *b < s.bitrate_kbps),
|
||||
// Above the top rung there is nothing higher to step to; A (which wraps) still
|
||||
// comes back round to Automatic.
|
||||
None => BITRATES
|
||||
.iter()
|
||||
.position(|b| *b > s.bitrate_kbps)
|
||||
.or(if wrap { Some(0) } else { None }),
|
||||
};
|
||||
stepped.map(|i| s.bitrate_kbps = BITRATES[i])
|
||||
}
|
||||
RowId::Compositor => step_str(&COMPOSITORS, &mut s.compositor, delta, wrap),
|
||||
RowId::Codec => step_str(&CODECS, &mut s.codec, delta, wrap),
|
||||
@@ -1396,6 +1671,9 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
step_option(cur, all.len(), delta, wrap).map(|i| s.ui_palette = all[i].id.to_string())
|
||||
}
|
||||
RowId::ReduceMotion => toggle(&mut s.reduce_motion, delta, wrap),
|
||||
RowId::ReduceUiResolution => {
|
||||
toggle_extra(s, android_keys::REDUCE_UI_RES, false, delta, wrap)
|
||||
}
|
||||
RowId::LibraryView => {
|
||||
let all = &crate::library::LibraryView::ALL;
|
||||
let cur = crate::library::LibraryView::parse(&s.library_view);
|
||||
@@ -1994,10 +2272,14 @@ pub(super) mod tests {
|
||||
assert_eq!(ctx.settings.mouse_mode, "capture");
|
||||
}
|
||||
|
||||
/// A rate that is not a rung — typed on the row, or stored by a desktop shell's
|
||||
/// free-form spinner — steps to its NEIGHBOUR. Every other picker here snaps an
|
||||
/// unrecognised value to its first option, which on this row is Automatic: one nudge
|
||||
/// would throw away the exact rate the user went to the trouble of typing.
|
||||
#[test]
|
||||
fn unknown_value_snaps_to_first() {
|
||||
fn an_off_ladder_rate_steps_to_its_neighbour() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
settings.bitrate_kbps = 12_345; // set via a desktop shell's free-form field
|
||||
settings.bitrate_kbps = 12_345;
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
@@ -2012,7 +2294,81 @@ pub(super) mod tests {
|
||||
t: 0.0,
|
||||
};
|
||||
assert!(adjust(RowId::Bitrate, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 0, "snapped to Automatic");
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 15_000, "the rung above");
|
||||
ctx.settings.bitrate_kbps = 12_345;
|
||||
assert!(adjust(RowId::Bitrate, -1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 12_000, "the rung below");
|
||||
// The ends still thud rather than wrap under left/right.
|
||||
ctx.settings.bitrate_kbps = 2_000_000;
|
||||
assert!(!adjust(RowId::Bitrate, 1, false, &mut ctx), "the ceiling");
|
||||
// …and a rung it does know steps as it always did.
|
||||
ctx.settings.bitrate_kbps = 5_000;
|
||||
assert!(adjust(RowId::Bitrate, -1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 4_000);
|
||||
}
|
||||
|
||||
/// The typed rate: Y opens the field on the Bitrate row (and nowhere else), digits land
|
||||
/// in it, and closing stores what was typed — clamped to the ceiling, because four
|
||||
/// digits can ask for 9999 Mbps and no client should send that.
|
||||
#[test]
|
||||
fn a_typed_bitrate_is_stored_and_clamped() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
// A snapshot store, not the file one: this test SAVES, and a unit test must not
|
||||
// rewrite the machine's real settings file to prove it.
|
||||
let store = crate::store::SnapshotStore::new(settings.clone(), Vec::new());
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: &store,
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
let mut fx = Outbox::default();
|
||||
let ids = s.row_ids(&ctx);
|
||||
s.list.cursor = ids
|
||||
.iter()
|
||||
.position(|id| *id == RowId::Bitrate)
|
||||
.expect("the bitrate row");
|
||||
s.menu(MenuEvent::Secondary, &mut ctx, &mut fx);
|
||||
assert!(s.editing(), "Y opens the field");
|
||||
s.text_input("13x7"); // digits only: the 'x' is refused, not typed
|
||||
assert!(s.edit_key(crate::input::Key::Return, &mut ctx));
|
||||
assert!(!s.editing(), "Return closes it");
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 137_000);
|
||||
|
||||
s.menu(MenuEvent::Secondary, &mut ctx, &mut fx);
|
||||
s.text_input("99999"); // four digits fit; the fifth is refused
|
||||
assert!(s.edit_key(crate::input::Key::Return, &mut ctx));
|
||||
assert_eq!(
|
||||
ctx.settings.bitrate_kbps, 2_000_000,
|
||||
"clamped to the ceiling"
|
||||
);
|
||||
|
||||
// An emptied field is an abandoned edit, not a request for Automatic.
|
||||
s.menu(MenuEvent::Secondary, &mut ctx, &mut fx);
|
||||
assert!(s.edit_key(crate::input::Key::Return, &mut ctx));
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 2_000_000, "left alone");
|
||||
|
||||
// Y is the bitrate row's alone — on a neighbour it does nothing at all.
|
||||
s.list.cursor = 0;
|
||||
s.menu(MenuEvent::Secondary, &mut ctx, &mut fx);
|
||||
assert!(!s.editing());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rates_read_in_the_biggest_round_unit() {
|
||||
assert_eq!(bitrate_label(20_000), "20 Mbps");
|
||||
assert_eq!(bitrate_label(12_500), "12.5 Mbps");
|
||||
assert_eq!(bitrate_label(1_000_000), "1 Gbps");
|
||||
assert_eq!(bitrate_label(1_500_000), "1.5 Gbps");
|
||||
assert_eq!(bitrate_label(2_000_000), "2 Gbps");
|
||||
}
|
||||
|
||||
/// The Profiles section trails the settings rows: one row per catalog profile whose
|
||||
@@ -2155,6 +2511,9 @@ pub(super) mod tests {
|
||||
RowId::Sc2Passthrough,
|
||||
RowId::DsCapture,
|
||||
RowId::Controllers,
|
||||
// Between the Input tab's rows and the rest of Interface: this one sits under
|
||||
// Reduce motion, which is earlier in that tab than the console-UI switch.
|
||||
RowId::ReduceUiResolution,
|
||||
RowId::GamepadUi,
|
||||
RowId::GamepadUiMode,
|
||||
RowId::Licenses,
|
||||
@@ -2250,11 +2609,12 @@ 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 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:?}");
|
||||
// 35 desktop rows + the ten Android-only ones (design android-skia-console-port.md
|
||||
// D3): eight `extra`-backed settings and two platform-screen action rows.
|
||||
assert_eq!(seen.len(), 45, "{seen:?}");
|
||||
assert!(seen.contains(&RowId::Palette));
|
||||
assert!(seen.contains(&RowId::ReduceMotion));
|
||||
assert!(seen.contains(&RowId::ReduceUiResolution));
|
||||
assert!(seen.contains(&RowId::AudioFormat));
|
||||
// The catalog rows belong to the trailing tab, which builds them at render time.
|
||||
assert!(TABS[PROFILES_TAB].1.is_empty());
|
||||
|
||||
@@ -1005,8 +1005,20 @@ impl Shell {
|
||||
pub(crate) fn key(&mut self, key: crate::input::Key, shift: bool, repeat: bool) -> bool {
|
||||
use crate::input::Key as S;
|
||||
if self.editing() {
|
||||
let mut ctx = Ctx {
|
||||
hosts: &self.hosts,
|
||||
library: &self.library,
|
||||
settings: &mut self.settings,
|
||||
store: &*self.store,
|
||||
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(),
|
||||
};
|
||||
if let Some(top) = self.stack.last_mut() {
|
||||
if top.edit_key(key) {
|
||||
if top.edit_key(key, &mut ctx) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,8 +163,19 @@ impl Shell {
|
||||
let bw = lead + tw + pad_x;
|
||||
let bx = (w - bw) / 2.0;
|
||||
let by = h - BOTTOM_BAND * k - bh - 8.0 * k + (1.0 - slide) * 12.0 * k;
|
||||
canvas.save_layer_alpha_f(None, alpha);
|
||||
let rect = Rect::from_xywh(bx as f32, by as f32, bw as f32, bh as f32);
|
||||
// BOUNDED to the pill. Unbounded, `save_layer` allocates an offscreen the size of
|
||||
// the whole SURFACE and composites it back — on a 4K TV that is a 33 MB render
|
||||
// target raised and torn down every frame, for four seconds, to fade a 34 dp pill
|
||||
// (and on a box whose whole Skia budget is 64 MB, it evicts real work to do it).
|
||||
//
|
||||
// Everything drawn inside is inside `rect`: the pill fill, `theme::panel`'s
|
||||
// hairline ON that rect, the kind mark centred in it, and text that ends a `pad_x`
|
||||
// short of its right edge. There is no blur to reach further, so the outset is
|
||||
// slack for the stroke rather than a computed reach — `screens::home` needs 36 k
|
||||
// for the same layer only because it wraps a σ = 10 k halo.
|
||||
let bounds = rect.with_outset((12.0 * k as f32, 12.0 * k as f32));
|
||||
canvas.save_layer_alpha_f(Some(bounds), alpha);
|
||||
canvas.draw_rrect(
|
||||
skia_safe::RRect::new_rect_xy(rect, (bh / 2.0) as f32, (bh / 2.0) as f32),
|
||||
&fill(crate::theme::shade(0.6)),
|
||||
|
||||
@@ -67,6 +67,8 @@ impl Shell {
|
||||
}
|
||||
None => dt,
|
||||
};
|
||||
// The shaped-paragraph cache's clock, before anything asks it to draw.
|
||||
fonts.begin_frame();
|
||||
self.sync();
|
||||
// Publish the palette's ink before ANYTHING draws — every widget, glyph and panel in
|
||||
// the crate reads it (see `theme::set_ink`), so a frame that skipped this would paint
|
||||
@@ -80,10 +82,14 @@ impl Shell {
|
||||
crate::theme::set_reduce_motion(reduce);
|
||||
self.pads = pads.to_vec();
|
||||
self.glyphs = GlyphStyle::from_pref(pad_pref);
|
||||
self.chip = Some(pad.map_or_else(
|
||||
|| "No controller — keyboard works too".to_string(),
|
||||
str::to_owned,
|
||||
));
|
||||
// Compared before it is rebuilt: this string changes when someone plugs a controller
|
||||
// in, and was being re-allocated 60 times a second to say so. (`pads` above is left
|
||||
// alone — it is at most a handful of small structs, and `PadInfo` would have to grow a
|
||||
// `PartialEq` in another crate to be worth the same treatment.)
|
||||
let chip = pad.unwrap_or("No controller — keyboard works too");
|
||||
if self.chip.as_deref() != Some(chip) {
|
||||
self.chip = Some(chip.to_owned());
|
||||
}
|
||||
|
||||
let (full_w, full_h) = (f64::from(viewport.width), f64::from(viewport.height));
|
||||
let ins = viewport.insets;
|
||||
@@ -353,7 +359,26 @@ impl LayerEnv<'_> {
|
||||
scale: f64,
|
||||
) -> Vec<(crate::glyphs::HintKey, Rect)> {
|
||||
let canvas = self.canvas;
|
||||
canvas.save_layer_alpha_f(None, alpha.clamp(0.0, 1.0) as f32);
|
||||
// Only RAISE the layer when it carries something. A settled screen is painted at full
|
||||
// alpha, unscaled and unslid, and an unbounded `save_layer` allocates an offscreen the
|
||||
// size of the whole SURFACE and composites it back — so the console was paying for one
|
||||
// full-screen offscreen on every frame it sat still, to apply an alpha of 1. Skia does
|
||||
// not elide it either: `SkCanvas::saveLayerAlphaf` forwards alpha ≥ 1 straight to
|
||||
// `saveLayer(bounds, nullptr)`, whose only early-out is an empty clip.
|
||||
//
|
||||
// Dropping the layer is pixel-identical rather than merely close: nothing in this crate
|
||||
// draws with a blend mode other than `SrcOver`, and `SrcOver` is associative, so
|
||||
// compositing the draws into a transparent layer and then over the backdrop lands on
|
||||
// exactly the value drawing them straight onto the backdrop does. (It is also why the
|
||||
// text stays grayscale-AA — no LCD subpixel text to gain or lose an isolation.) Same
|
||||
// reasoning `screens::home` already bounds its per-tile layer by.
|
||||
let layered = alpha < 0.999 || (scale - 1.0).abs() > 0.001 || dy.abs() > 0.001;
|
||||
if layered {
|
||||
canvas.save_layer_alpha_f(None, alpha.clamp(0.0, 1.0) as f32);
|
||||
} else {
|
||||
// Still a save: the transform below is undone by the same `restore`.
|
||||
canvas.save();
|
||||
}
|
||||
canvas.translate((0.0, dy as f32));
|
||||
let (cx, cy) = ((self.w / 2.0) as f32, (self.h / 2.0) as f32);
|
||||
canvas.translate((cx, cy));
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use skia_safe::textlayout::{
|
||||
FontCollection, ParagraphBuilder, ParagraphStyle, TextAlign, TextStyle, TypefaceFontProvider,
|
||||
FontCollection, Paragraph, ParagraphBuilder, ParagraphStyle, TextAlign, TextStyle,
|
||||
TypefaceFontProvider,
|
||||
};
|
||||
use skia_safe::{
|
||||
gradient, Canvas, Color4f, Font, FontMgr, FontStyle, MaskFilter, Paint, PathEffect, Point,
|
||||
RRect, Rect, TileMode, Typeface,
|
||||
};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// --- Paint ----------------------------------------------------------------------------------
|
||||
|
||||
@@ -521,7 +524,7 @@ pub(crate) const EDGE_INSET: f64 = 24.0;
|
||||
// --- Typography ---------------------------------------------------------------------------
|
||||
|
||||
/// Geist weights the console uses (matching the Apple client's `.geist(size, weight)`).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum W {
|
||||
Regular,
|
||||
Medium,
|
||||
@@ -538,6 +541,111 @@ pub(crate) struct Fonts {
|
||||
semibold: Typeface,
|
||||
bold: Typeface,
|
||||
collection: FontCollection,
|
||||
/// Shaped paragraphs, keyed by everything that shapes one ([`ParaKey`]).
|
||||
///
|
||||
/// `Paragraph::layout` runs the whole shaper — HarfBuzz, line breaking, font fallback —
|
||||
/// and the shell re-built every paragraph on screen from scratch EVERY frame, which on a
|
||||
/// TV box is the largest CPU cost in the frame. Position is deliberately not part of the
|
||||
/// key (`paint` takes it), so one shaped paragraph serves a string wherever it moves to:
|
||||
/// a scrolling shelf and a screen transition both re-use it rather than re-shaping.
|
||||
///
|
||||
/// `RefCell` because every draw path here takes `&self` and the console's shell is
|
||||
/// single-threaded by construction (one render thread owns it on all three ABIs).
|
||||
paragraphs: RefCell<HashMap<ParaKey, Cached>>,
|
||||
/// The frame counter [`Fonts::begin_frame`] bumps — the cache's liveness clock.
|
||||
frame: Cell<u64>,
|
||||
}
|
||||
|
||||
/// The three paragraph shapes the console draws. A single tag rather than a loose
|
||||
/// `(TextAlign, Option<usize>)` pair because it is half of a hash key, and because those two
|
||||
/// were never independent — every call site picks one of these three.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
enum Para {
|
||||
/// Centred, wrapping freely.
|
||||
Centered,
|
||||
/// Left-aligned, wrapping freely.
|
||||
Leading,
|
||||
/// Left-aligned, clamped to one ellipsized line.
|
||||
Heading,
|
||||
}
|
||||
|
||||
impl Para {
|
||||
/// The paragraph style this shape asks for: alignment, and the line clamp if it has one.
|
||||
fn style(self) -> (TextAlign, Option<usize>) {
|
||||
match self {
|
||||
Para::Centered => (TextAlign::Center, None),
|
||||
Para::Leading => (TextAlign::Left, None),
|
||||
Para::Heading => (TextAlign::Left, Some(1)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything [`shape`] bakes into a laid-out `Paragraph` — change any of it and the shaped
|
||||
/// result differs, so all of it is in the key.
|
||||
///
|
||||
/// The floats ride as bits: the sizes and widths are all `k`-scaled, so they are never whole
|
||||
/// numbers, and `f64`/`f32` are not `Hash`. Bit equality is the right test anyway — the same
|
||||
/// `k` produces the same bits, and a different `k` must re-shape.
|
||||
#[derive(PartialEq, Eq, Hash)]
|
||||
struct ParaKey {
|
||||
text: String,
|
||||
kind: Para,
|
||||
weight: W,
|
||||
size: u64,
|
||||
max_w: u32,
|
||||
/// ARGB, as `[a, r, g, b]`.
|
||||
color: [u8; 4],
|
||||
}
|
||||
|
||||
/// One shaped paragraph and the frame it was last drawn on.
|
||||
struct Cached {
|
||||
para: Paragraph,
|
||||
used: u64,
|
||||
}
|
||||
|
||||
/// How many shaped paragraphs stay resident before the cold ones are dropped. A screen draws
|
||||
/// well under this; the ceiling exists for the library, where paging a large catalogue walks
|
||||
/// through thousands of titles and every one of them would otherwise be kept forever.
|
||||
const PARA_CACHE_MAX: usize = 512;
|
||||
|
||||
/// Build and lay out one paragraph — the shaping [`Fonts::draw_paragraph`]'s cache exists to
|
||||
/// do exactly once per distinct key.
|
||||
///
|
||||
/// A free function rather than a method because the cache hands it a `&ParaKey` borrowed out
|
||||
/// of the map it is inserting into, which rules out holding `&self` across the call.
|
||||
fn shape(collection: &FontCollection, key: &ParaKey) -> Paragraph {
|
||||
let (align, clamp) = key.kind.style();
|
||||
let mut style = ParagraphStyle::new();
|
||||
style.set_text_align(align);
|
||||
if let Some(lines) = clamp {
|
||||
style.set_max_lines(lines);
|
||||
style.set_ellipsis("\u{2026}");
|
||||
}
|
||||
let mut ts = TextStyle::new();
|
||||
ts.set_font_families(&["Geist"]);
|
||||
ts.set_font_size(f64::from_bits(key.size) as f32);
|
||||
let [a, r, g, b] = key.color;
|
||||
ts.set_color(skia_safe::Color::from_argb(a, r, g, b));
|
||||
ts.set_font_style(match key.weight {
|
||||
W::Regular => FontStyle::normal(),
|
||||
W::Medium => FontStyle::new(
|
||||
skia_safe::font_style::Weight::MEDIUM,
|
||||
skia_safe::font_style::Width::NORMAL,
|
||||
skia_safe::font_style::Slant::Upright,
|
||||
),
|
||||
W::SemiBold => FontStyle::new(
|
||||
skia_safe::font_style::Weight::SEMI_BOLD,
|
||||
skia_safe::font_style::Width::NORMAL,
|
||||
skia_safe::font_style::Slant::Upright,
|
||||
),
|
||||
W::Bold => FontStyle::bold(),
|
||||
});
|
||||
style.set_text_style(&ts);
|
||||
let mut builder = ParagraphBuilder::new(&style, collection.clone());
|
||||
builder.add_text(&key.text);
|
||||
let mut p = builder.build();
|
||||
p.layout(f32::from_bits(key.max_w));
|
||||
p
|
||||
}
|
||||
|
||||
/// The Geist faces ride in the binary — the console must look right on a bare gamescope
|
||||
@@ -574,6 +682,8 @@ pub(crate) fn build_fonts() -> Result<Fonts> {
|
||||
semibold,
|
||||
bold,
|
||||
collection,
|
||||
paragraphs: RefCell::new(HashMap::new()),
|
||||
frame: Cell::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -641,50 +751,59 @@ impl Fonts {
|
||||
}
|
||||
}
|
||||
|
||||
/// `clamp` caps the paragraph at that many lines and ellipsizes what doesn't fit; `None`
|
||||
/// wraps freely. A heading has to clamp — an over-long one used to grow DOWNWARD into the
|
||||
/// screen's content, which is why both other clients pin theirs to one line.
|
||||
/// Start a frame — the paragraph cache's clock. Anything not drawn on this frame or the
|
||||
/// one before it becomes a candidate for eviction, so the live set is exactly "what the
|
||||
/// last two frames drew". The shell calls this once per `render_in`.
|
||||
pub(crate) fn begin_frame(&self) {
|
||||
self.frame.set(self.frame.get().wrapping_add(1));
|
||||
}
|
||||
|
||||
/// Draw a shaped paragraph, building and laying it out only the first time this exact
|
||||
/// (text, shape, weight, size, width, colour) is asked for — see [`Fonts::paragraphs`].
|
||||
/// `at` is the paragraph's TOP-LEFT, and is deliberately not part of the key.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paragraph(
|
||||
fn draw_paragraph(
|
||||
&self,
|
||||
canvas: &Canvas,
|
||||
text: &str,
|
||||
kind: Para,
|
||||
w: W,
|
||||
size: f64,
|
||||
color: Color4f,
|
||||
align: TextAlign,
|
||||
max_w: f64,
|
||||
clamp: Option<usize>,
|
||||
) -> skia_safe::textlayout::Paragraph {
|
||||
let mut style = ParagraphStyle::new();
|
||||
style.set_text_align(align);
|
||||
if let Some(lines) = clamp {
|
||||
style.set_max_lines(lines);
|
||||
style.set_ellipsis("\u{2026}");
|
||||
}
|
||||
let mut ts = TextStyle::new();
|
||||
ts.set_font_families(&["Geist"]);
|
||||
ts.set_font_size(size as f32);
|
||||
ts.set_color(color.to_color());
|
||||
ts.set_font_style(match w {
|
||||
W::Regular => FontStyle::normal(),
|
||||
W::Medium => FontStyle::new(
|
||||
skia_safe::font_style::Weight::MEDIUM,
|
||||
skia_safe::font_style::Width::NORMAL,
|
||||
skia_safe::font_style::Slant::Upright,
|
||||
),
|
||||
W::SemiBold => FontStyle::new(
|
||||
skia_safe::font_style::Weight::SEMI_BOLD,
|
||||
skia_safe::font_style::Width::NORMAL,
|
||||
skia_safe::font_style::Slant::Upright,
|
||||
),
|
||||
W::Bold => FontStyle::bold(),
|
||||
at: Point,
|
||||
) {
|
||||
let frame = self.frame.get();
|
||||
// ponytail: the key owns its text, so a HIT still costs one small `String` allocation
|
||||
// where a borrowed-key lookup would cost none. Deliberate — it is a rounding error
|
||||
// against the shape it replaces, and the alternatives (hash-only keys, `hashbrown`'s
|
||||
// raw entry) trade a real collision risk or a dependency for it. Revisit only if a
|
||||
// profile ever puts this line on the board.
|
||||
let key = ParaKey {
|
||||
text: text.to_owned(),
|
||||
kind,
|
||||
weight: w,
|
||||
size: size.to_bits(),
|
||||
max_w: (max_w as f32).to_bits(),
|
||||
color: {
|
||||
// The 8-bit ARGB the paragraph actually bakes, not the `Color4f` it came
|
||||
// from — two float colours that round to the same pixel share an entry.
|
||||
let c = color.to_color();
|
||||
[c.a(), c.r(), c.g(), c.b()]
|
||||
},
|
||||
};
|
||||
let mut cache = self.paragraphs.borrow_mut();
|
||||
let entry = cache.entry(key).or_insert_with_key(|k| Cached {
|
||||
para: shape(&self.collection, k),
|
||||
used: frame,
|
||||
});
|
||||
style.set_text_style(&ts);
|
||||
let mut b = ParagraphBuilder::new(&style, self.collection.clone());
|
||||
b.add_text(text);
|
||||
let mut p = b.build();
|
||||
p.layout(max_w as f32);
|
||||
p
|
||||
entry.used = frame;
|
||||
entry.para.paint(canvas, at);
|
||||
// Drop what the last two frames did not draw. Every entry still on screen is
|
||||
// re-stamped above on the frame it appears in, so this only reaps strings that left.
|
||||
if cache.len() > PARA_CACHE_MAX {
|
||||
cache.retain(|_, c| c.used + 1 >= frame);
|
||||
}
|
||||
}
|
||||
|
||||
/// Centered, wrapping paragraph with `y` as its TOP edge (shaping + CJK fallback).
|
||||
@@ -700,8 +819,8 @@ impl Fonts {
|
||||
y: f64,
|
||||
max_w: f64,
|
||||
) {
|
||||
let p = self.paragraph(text, w, size, color, TextAlign::Center, max_w, None);
|
||||
p.paint(canvas, Point::new((cx - max_w / 2.0) as f32, y as f32));
|
||||
let at = Point::new((cx - max_w / 2.0) as f32, y as f32);
|
||||
self.draw_paragraph(canvas, text, Para::Centered, w, size, color, max_w, at);
|
||||
}
|
||||
|
||||
/// [`centered`](Self::centered)'s LEFT-ALIGNED twin: `x` is the text's left edge, `y` its
|
||||
@@ -719,8 +838,8 @@ impl Fonts {
|
||||
y: f64,
|
||||
max_w: f64,
|
||||
) {
|
||||
let p = self.paragraph(text, w, size, color, TextAlign::Left, max_w, None);
|
||||
p.paint(canvas, Point::new(x as f32, y as f32));
|
||||
let at = Point::new(x as f32, y as f32);
|
||||
self.draw_paragraph(canvas, text, Para::Leading, w, size, color, max_w, at);
|
||||
}
|
||||
|
||||
/// A screen's heading: left-aligned at `x`, top edge at `y`, clamped to ONE ellipsized
|
||||
@@ -743,8 +862,8 @@ impl Fonts {
|
||||
y: f64,
|
||||
max_w: f64,
|
||||
) {
|
||||
let p = self.paragraph(text, w, size, color, TextAlign::Left, max_w, Some(1));
|
||||
p.paint(canvas, Point::new(x as f32, y as f32));
|
||||
let at = Point::new(x as f32, y as f32);
|
||||
self.draw_paragraph(canvas, text, Para::Heading, w, size, color, max_w, at);
|
||||
}
|
||||
|
||||
/// A single shaped line, middle-ellipsized to `max_w`, drawn at a baseline. For
|
||||
@@ -770,8 +889,12 @@ impl Fonts {
|
||||
let ell_w = font.measure_str(ell, None).0;
|
||||
let mut fitted = String::new();
|
||||
let mut used = 0.0f32;
|
||||
// The char goes onto the stack to be measured, not into a fresh `String` per character:
|
||||
// this runs for every over-long title on screen, every frame, and the allocation was
|
||||
// the bulk of it. `encode_utf8` writes the same bytes `to_string` would have.
|
||||
let mut buf = [0u8; 4];
|
||||
for ch in text.chars() {
|
||||
let cw = font.measure_str(ch.to_string().as_str(), None).0;
|
||||
let cw = font.measure_str(&*ch.encode_utf8(&mut buf), None).0;
|
||||
if used + cw + ell_w > max_w as f32 {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -483,7 +483,12 @@ fn gamescope_patch_level() -> u32 {
|
||||
cursor composited into the capture stream"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
// INFO, not DEBUG: this is the whole reason a box streams SDR, and the branch above
|
||||
// announces the good news at INFO. A field report ("HDR stopped working after the
|
||||
// update") cost a deep dive because the handshake's `capture_supports_hdr=false` was
|
||||
// visible at INFO while the ONE line saying why sat a level below it. Fires once per
|
||||
// process — the answer is cached in `LEVEL`.
|
||||
tracing::info!(
|
||||
bin = %gamescope_bin(),
|
||||
"gamescope has no {PFHDR_MARKER} marker — sessions on this backend stay 8-bit SDR \
|
||||
with a host-composited cursor (install punktfunk-gamescope for HDR)"
|
||||
|
||||
@@ -128,18 +128,31 @@ impl DataPump {
|
||||
// becomes the climb ceiling and slow start does the rest. Old hosts decline (all-zero
|
||||
// reply) or never answer (timeout clears the state so LossReports resume) — either way
|
||||
// the ceiling stays negotiated, exactly the old behavior. PUNKTFUNK_ABR_PROBE=0 opts out.
|
||||
// `PUNKTFUNK_ABR_PROBE_KBPS` lowers the burst target (unset/0/garbage → the 2 Gbps
|
||||
// default): the target is deliberately far above any plausible link so the burst measures
|
||||
// the link and not itself, but on links the burst DISTURBS that backfires — a constrained
|
||||
// Wi-Fi link can black-hole under 2 Gbps (measured on webOS: the probe hitting the 6 s
|
||||
// timeout delayed first video to 14 s, and a "successful" one still reported
|
||||
// send_dropped=20211), and a 2-3 core TV client starves decoding the firehose. An
|
||||
// embedder that caps its own speed test wants this capped to match.
|
||||
// The burst target is DERIVED from `stream_cap_kbps`, not set "far above any plausible
|
||||
// link". It used to be a flat 2 Gbps on that reasoning — the burst must measure the link
|
||||
// and not itself — but the ABR already discards every bit measured above what the session
|
||||
// could use: `set_ceiling` clamps to the stream cap set a few lines up, so everything past
|
||||
// `stream_cap_kbps / 0.7` is thrown away the moment it lands. All that height bought was
|
||||
// bufferbloat for a number nothing reads, and on links the burst DISTURBS it backfires — a
|
||||
// constrained Wi-Fi link can black-hole under 2 Gbps (measured on webOS: the probe hitting
|
||||
// the 6 s timeout delayed first video to 14 s, and a "successful" one still reported
|
||||
// send_dropped=20211; the same shape is reported on a Fire TV Stick 4K Max), and a 2-3
|
||||
// core TV client starves decoding the firehose.
|
||||
//
|
||||
// ×2 is the smallest multiplier that still PROVES the cap: the measured ceiling is
|
||||
// `delivered × 0.7`, so reaching `stream_cap_kbps` needs `delivered ≥ cap × 1.43` and the
|
||||
// rest is margin. Deriving it this way cannot cap anyone — a session whose mode and codec
|
||||
// justify a high ceiling asks for a correspondingly high target by itself, and a mode we
|
||||
// cannot size (`stream_ceiling_kbps` → `u32::MAX`) still gets the old 2 Gbps. It also
|
||||
// fixes webOS and every other constrained client, not just the box that reported it.
|
||||
//
|
||||
// `PUNKTFUNK_ABR_PROBE_KBPS` overrides the target outright (unset/0/garbage → the derived
|
||||
// one). An embedder that caps its own speed test wants this capped to match.
|
||||
let capacity_probe_kbps: u32 = std::env::var("PUNKTFUNK_ABR_PROBE_KBPS")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<u32>().ok())
|
||||
.filter(|&v| v > 0)
|
||||
.unwrap_or(2_000_000);
|
||||
.unwrap_or_else(|| probe_target_kbps(stream_cap_kbps));
|
||||
const CAPACITY_PROBE_MS: u32 = 800;
|
||||
const CAPACITY_PROBE_DELAY: Duration = Duration::from_secs(2);
|
||||
const CAPACITY_PROBE_TIMEOUT: Duration = Duration::from_secs(6);
|
||||
@@ -154,6 +167,9 @@ impl DataPump {
|
||||
// in; the embedder path had neither, so an unanswered request wedged the report tick and a
|
||||
// finished one left the ABR window anchored before the burst.
|
||||
let mut was_probing = false;
|
||||
// `frames_completed` as the burst began, so the probe-end block below can ask "did ANY
|
||||
// frame survive this burst" rather than only "has one ever arrived" — see there.
|
||||
let mut frames_at_probe_start: u64 = 0;
|
||||
// The window this closes is discarded outright: no LossReport, no standing-latency close,
|
||||
// no ABR feed. Two causes, both of them "this window's signals describe something other
|
||||
// than the link, and one bogus congestion verdict here ends slow start for good":
|
||||
@@ -289,6 +305,24 @@ impl DataPump {
|
||||
last_report = Instant::now();
|
||||
discard_abr_window = true;
|
||||
flush_in_window = false;
|
||||
// …and if the burst swallowed the video with it, re-anchor the decoder. This runs
|
||||
// on EVERY probe end — a successful one, a timed-out one, an embedder "Test
|
||||
// connection" — and the frame-count guard is what makes it a no-op the rest of the
|
||||
// time: a burst the link couldn't hold can take the keyframe down with it, and
|
||||
// then nothing re-requests one, so the client sits on black until some unrelated
|
||||
// recovery path happens to fire. That is the reported Fire TV / webOS black
|
||||
// screen. Compared against the count SNAPSHOTTED at the burst's leading edge
|
||||
// rather than against 0: at startup the two are the same test, but this one also
|
||||
// catches a burst that kills an already-running stream (an embedder speed test
|
||||
// mid-session), which the cumulative counter never could. At most one request per
|
||||
// probe, and it funnels through the control task's coalescer like the other two
|
||||
// emitters in this file, so it cannot IDR-storm.
|
||||
if st.frames_completed == frames_at_probe_start {
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::Keyframe);
|
||||
tracing::warn!(
|
||||
"no frame survived the capacity probe — requested a keyframe to re-anchor"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Arm a watchdog on the leading edge of ANY probe, so a host that silently ignores
|
||||
// `ProbeRequest` (an old build — anticipated, see the capacity-probe timeout below)
|
||||
@@ -296,6 +330,7 @@ impl DataPump {
|
||||
if !was_probing && probe_active {
|
||||
let burst = Duration::from_millis(pump_probe.lock().unwrap().duration_ms as u64);
|
||||
probe_watchdog = Some(Instant::now() + burst + CAPACITY_PROBE_TIMEOUT);
|
||||
frames_at_probe_start = st.frames_completed;
|
||||
}
|
||||
if !probe_active {
|
||||
probe_watchdog = None;
|
||||
@@ -797,6 +832,18 @@ fn should_report_delivery(packets_received: u64, confirmed: &mut bool) -> bool {
|
||||
owed
|
||||
}
|
||||
|
||||
/// The capacity probe's burst target for a session bounded at `stream_cap_kbps`, in kbps — the
|
||||
/// default `PUNKTFUNK_ABR_PROBE_KBPS` overrides. See the probe's comment in the pump for why it is
|
||||
/// derived rather than fixed: `BitrateController::set_ceiling` clamps the measurement to the
|
||||
/// stream cap, so every bit measured above `cap / 0.7` is discarded, and bursting for it only
|
||||
/// buys bufferbloat. ×2 clears that `1.43×` bar with margin.
|
||||
///
|
||||
/// `u32::MAX` in (a mode [`crate::abr::stream_ceiling_kbps`] declines to size) keeps the historic
|
||||
/// 2 Gbps, which is also the ceiling on the whole derivation: this can only ever lower the target.
|
||||
fn probe_target_kbps(stream_cap_kbps: u32) -> u32 {
|
||||
stream_cap_kbps.saturating_mul(2).min(2_000_000)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -836,6 +883,40 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The burst has to be big enough to PROVE the stream cap and no bigger. Anything the burst
|
||||
/// measures above `cap / 0.7` is discarded by `BitrateController::set_ceiling` (pinned by
|
||||
/// `abr::tests::the_stream_bound_clamps_a_learned_ceiling_only`) and paid for in bufferbloat.
|
||||
#[test]
|
||||
fn the_probe_target_proves_the_stream_cap_without_overshooting_it() {
|
||||
// Real modes, from the smallest a session runs to the largest — including 1440p120, the
|
||||
// field session that walked to 657 Mbps and taught the ABR the cap in the first place.
|
||||
for (w, h, hz, codec, depth) in [
|
||||
(1280, 720, 60, crate::quic::CODEC_HEVC, 8),
|
||||
(1920, 1080, 60, crate::quic::CODEC_H264, 8),
|
||||
(2560, 1440, 120, crate::quic::CODEC_HEVC, 8),
|
||||
(3840, 2160, 120, crate::quic::CODEC_HEVC, 10),
|
||||
] {
|
||||
let cap = crate::abr::stream_ceiling_kbps(w, h, hz, codec, depth, 0);
|
||||
let target = probe_target_kbps(cap);
|
||||
// Enough: a link that delivers the whole burst measures `delivered × 0.7`, and that
|
||||
// has to reach the cap or the session can never climb to what its mode allows.
|
||||
assert!(
|
||||
target.saturating_mul(7) / 10 >= cap,
|
||||
"{w}x{h}@{hz}: a {target} kbps burst cannot prove a {cap} kbps cap"
|
||||
);
|
||||
// …and no more: a target that overshoots what the clamp keeps is pure bufferbloat.
|
||||
// (The old flat 2 Gbps overshot 1440p120 by 6×.)
|
||||
assert!(
|
||||
target <= cap.saturating_mul(2),
|
||||
"{w}x{h}@{hz}: {target} kbps chases capacity the clamp discards"
|
||||
);
|
||||
}
|
||||
// A mode `stream_ceiling_kbps` declines to size (`u32::MAX`) keeps the historic 2 Gbps,
|
||||
// which is also the hard ceiling on the derivation — it can only ever lower the target.
|
||||
assert_eq!(probe_target_kbps(u32::MAX), 2_000_000);
|
||||
assert_eq!(probe_target_kbps(1_500_000), 2_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pipeline_gap_is_taken_exactly_once() {
|
||||
let slot = AtomicU32::new(0);
|
||||
|
||||
@@ -252,6 +252,16 @@ the route where there are no face buttons to press, such as an Android TV remote
|
||||
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.
|
||||
|
||||
**Reduce interface resolution** — *default: off.* Android only, in the controller-optimized
|
||||
settings. Draws the menus at 1080p and lets the display scale them up, instead of drawing at the
|
||||
panel's own resolution. Text goes a little softer; the interface gets much smoother. It is for 4K
|
||||
televisions and projectors, whose graphics chips are built to decode and composite video rather
|
||||
than to draw a moving interface, and are far slower than the ones in phones — at 4K every part of
|
||||
the interface costs four times what it does at 1080p, on hardware nowhere near four times faster.
|
||||
A premium 4K box is *more* likely to want this than a cheap 1080p stick, which never had the extra
|
||||
pixels in the first place. Nothing about a stream changes: picture quality is
|
||||
[**Resolution** and **Bitrate**](#video), and this is the interface only.
|
||||
|
||||
## Overlay
|
||||
|
||||
**Statistics overlay** — *default: Normal.* Four tiers — Off, Compact, Normal, Detailed — each a
|
||||
|
||||
@@ -282,7 +282,7 @@ table, where client and host read the *same* variable name for their own half of
|
||||
| `PUNKTFUNK_PRESENTER` | `arrival` | Turn the frame-pacing engine off for this run: frames present the instant they decode, exactly as they did before the **Prioritize** setting existed. A diagnostic — if a pacing change is suspected of causing judder or added delay, this switches it off without reinstalling anything. Linux and Windows clients. |
|
||||
| `PUNKTFUNK_VRR_FIFO` | `1` | Force the display mode used to follow a **variable-refresh (VRR / FreeSync / G-Sync)** screen, on graphics drivers too old to offer the modern one. You almost certainly don't need this: where the driver supports the modern mode — which is what **Follow variable refresh rate** in [client settings](/docs/client-settings#video) uses — following the panel is already automatic and costs almost nothing. On an older driver the only way to follow the panel is a mode that measured roughly 27 ms *worse* on a fixed-refresh screen, so it stays off unless you ask for it, and it's only worth asking if you genuinely have a VRR screen and play fullscreen. Check the Detailed [stats overlay](/docs/stats): `vrr yes` means the panel really is following the stream. Linux and Windows clients. |
|
||||
| `PUNKTFUNK_PRESENT_DEBUG` | `1` | Log the presenter's own 1-second summary (display mode, buffer drops, pacing counters) every second, even when nothing is going wrong. Without it the line appears only when there is something to report. |
|
||||
| `PUNKTFUNK_ABR_PROBE_KBPS` | kbps, e.g. `900000` | The startup link-capacity probe's burst target (default 2 Gbps — deliberately above any plausible link so the burst measures the link, not itself). Lower it on links the burst shouldn't slam, or when the measured ceiling comes out wrong for your setup. |
|
||||
| `PUNKTFUNK_ABR_PROBE_KBPS` | kbps, e.g. `90000` | The startup link-capacity probe's burst target. By default it's derived from the session — twice what your resolution, refresh rate and codec could plausibly use, which is the most the climb ceiling is ever allowed to reach — and capped at 2 Gbps. Lower it further on links the burst shouldn't slam, or when the measured ceiling comes out wrong for your setup. |
|
||||
| `PUNKTFUNK_ABR_PROBE` | `0` | Skip the startup link-capacity probe entirely. The adaptive-bitrate climb ceiling then stays at the negotiated starting rate — a blunt instrument; prefer `PUNKTFUNK_ABR_MAX_MBPS`. |
|
||||
| `PUNKTFUNK_ABR_MAX_MBPS` | Mbps, e.g. `300` | Hard cap on the adaptive bitrate's climb ceiling, whatever the startup probe measured. The escape hatch when adaptive sessions keep climbing past what your client's **decoder** can sustain (periodic hitch + "receive backlog stopped draining" in the client log). An explicit bitrate setting still bypasses ABR entirely. |
|
||||
|
||||
|
||||
@@ -44,9 +44,13 @@ from the [stats overlay](/docs/stats), so it shows even with stats off.
|
||||
|
||||
The mute lasts for that stream only — the next session starts unmuted; nothing is written to your
|
||||
settings. With **Stream microphone** off in [client settings](/docs/client-settings#audio) the
|
||||
shortcut does nothing and no badge appears. **Linux and Windows** clients only (a Steam Deck stream
|
||||
is the Linux client, so an attached keyboard gets the chord); on Apple and Android turn **Stream
|
||||
microphone** off in settings instead.
|
||||
shortcut does nothing and no badge appears.
|
||||
|
||||
The **keyboard** chord is **Linux and Windows** only (a Steam Deck stream is the Linux client, so an
|
||||
attached keyboard gets it). On **Android** a controller can reach the same toggle: **Select + Y**,
|
||||
and on a DualSense the pad's own **Mute** button does it too — one toggle per press, and the badge
|
||||
is the same. On **Apple** clients there is no shortcut; turn **Stream microphone** off in settings
|
||||
instead.
|
||||
|
||||
Alt-Tabbing away releases input on its own and takes it back when you return. A release you asked
|
||||
for with the chord stays released until you opt back in. Either way, keys and buttons you were
|
||||
|
||||
@@ -52,6 +52,12 @@ The console lists every paired device with its access (and a live countdown for
|
||||
From there you can change the level, extend or cut the expiry, or **remove** the device — removing
|
||||
revokes it immediately, even mid-session. Re-pairing a removed device is just the PIN ceremony again.
|
||||
|
||||
**Naming a Moonlight device.** Every Moonlight-compatible client identifies itself with the same
|
||||
built-in name, so several of them look identical in the list. Use the pencil on the row to give it
|
||||
one of your own ("Living room TV") — the name is stored on the host, so every browser sees it, and
|
||||
removing the device forgets it. Devices paired with Punktfunk's own apps send a real name already
|
||||
and have no pencil.
|
||||
|
||||
Can't pair at all? [Troubleshooting → Pairing is rejected](/docs/troubleshooting#pairing-is-rejected--the-client-cant-connect).
|
||||
|
||||
## How it works, briefly
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.31.2"
|
||||
"version": "0.31.3"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/client-logs": {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
Wire-compatible with 0.31.x — everything you have already paired keeps working, and you can update one side at a time. Nothing here changes how a host and a client agree on what to send each other, so an old client on a new host, or the other way round, streams exactly as it does today.
|
||||
|
||||
This is a fix release about streams that ended, froze, stuttered or never arrived while everything involved was doing something perfectly ordinary. Launching a Steam game that had shaders to process dropped the stream about ten seconds in, so people learned to launch everything twice. A fullscreen game that picks its own screen resolution mid-play froze the picture on a Windows host and ended the video a few seconds later with the sound still running. On an Android TV or a Fire Stick the app was quietly asking your host for a frame rate your television does not actually output, which is where the latency people had been working around by hand was coming from. And on a slower connection the very first thing a client does — a quick burst to measure what the link can carry — was big enough to choke the link it was measuring, delaying the picture by many seconds or losing it entirely. There is new work too: your Moonlight devices can be given names, and a 4K television or projector can now run the on-screen interface at a lower resolution to keep it smooth.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **A Steam game with shaders to process dropped the stream about ten seconds into launching it.** You watched the "Processing Vulkan shaders" dialog, lost the stream, reconnected and launched again — and the second launch worked, which is why this looked like bad luck rather than a bug.
|
||||
- **Android TV and Fire Stick: the app negotiated a frame rate your TV does not output.** Setting the refresh rate by hand was the known workaround; it is no longer needed, and the latency it was papering over is gone.
|
||||
- **A slow first picture, or none at all, on a constrained connection.** The startup speed test was so large it could black-hole the very link it was measuring — one case took fourteen seconds to show video. It is now sized to the session, and if the test does swallow the opening frame the client asks for a new one instead of sitting on black.
|
||||
- **Windows: a game that changed your screen resolution mid-stream froze the picture and then ended the session.** The sound carried on throughout, which is exactly what makes this look like a problem at the client's end.
|
||||
- **Fire TV: a DualSense had buttons that never reached the game**, and its touchpad click and Mute button did nothing. Mute now mutes your microphone.
|
||||
- **New:** name your Moonlight devices instead of a list of identical rows, and — on a 4K TV or projector — **Reduce interface resolution** for a smoother on-screen interface.
|
||||
|
||||
## Before you update
|
||||
|
||||
- **Steam Deck, and only if you installed the host from source: re-run your update after taking this release.** A source install builds a patched compositor, and that build has been failing since mid-August because of a missing system package. The failure was silent — it reported success, and quietly dropped back to the system's own compositor, which is why HDR disappeared on boxes that had been streaming it minutes earlier. The missing package is added here, so the next build succeeds. Nothing to do on a packaged install.
|
||||
|
||||
## New
|
||||
|
||||
- **Give your Moonlight-paired devices names.** This is not a display bug being fixed: every Moonlight-compatible client identifies itself with the same built-in name, so it says which *app* is connecting and nothing about which device. Until now that name was all the console could show, and someone who had paired a phone, a television and a handheld saw three rows reading identically. Each Moonlight row now has a pencil next to it — name it "Living room TV", and that is what the list says from then on, including when you are choosing which device to remove. Devices paired with Punktfunk's own apps already send a real name and are left alone. Names live on the host, so every browser you open the console in sees the same ones, and removing a device forgets its name.
|
||||
- **Reduce interface resolution, for a 4K television or projector.** The on-screen interface is drawn at whatever resolution the panel hands it, and on a 4K set that is four times the work of 1080p on a chip built to decode video rather than to draw a moving interface — which is why the premium 4K boxes are the ones that feel sluggish, not the cheap 1080p sticks that never had the extra pixels. The new switch sits directly under Reduce motion, because it is the same kind of bargain: text goes a little softer, the interface gets smoother. It is off by default. **It changes the interface only and does nothing to your stream** — picture quality is still Resolution and Bitrate, which are separate settings and untouched by this.
|
||||
|
||||
## Improved
|
||||
|
||||
- **The on-screen interface got substantially cheaper to draw, on every device.** Independently of the switch above, it was doing a surprising amount of work on every single frame whether or not anything had changed: re-measuring and re-laying-out every piece of text on screen sixty times a second, and allocating a full-screen scratch image to apply an effect that did nothing whenever the interface was sitting still. On a 4K panel that scratch image alone was larger than the memory budget the whole interface is allowed on a 2 GB box, so it was evicting real work in order to do nothing. Both are gone, and the result is pixel-for-pixel identical. The interface also now gets a scheduling priority just below the stream's, so a TV box cannot park it behind background work and leave it lagging your remote.
|
||||
- **When the interface is slow, the logs can now say so.** It recorded which graphics version it had and how much memory it was allowed, and never what resolution it was drawing at or how long a frame took — so "it feels sluggish" could not be looked into from a log bundle at all. It now reports both.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **Launching a Steam game dropped the stream while it was still starting, so you had to launch it twice.** Reported on Rocket League: the stream showed the "Processing Vulkan shaders" dialog and then ended about ten seconds in, every time, with a second launch working fine. The host was doing this to itself. Steam does its preparation work for a game — processing shaders, most visibly — under the same marker it uses for the game itself, so a launch is a short chain of things that all look like your game, and only the last one is. The host accepted the first one, and from that moment it was no longer waiting for a game to start but watching for one to exit; when the preparation step finished a few seconds later, that was read as the game exiting and the session was closed. Two things change. The shader step is now recognised for what it is and never mistaken for a game. And anything else must be seen continuously for a few seconds before the host will believe it is your game — the rule it already applied to programs a launcher starts, now applied to what it finds by looking. The cost is a few seconds' delay before the host says a game is running; nothing about detecting a game *exiting* changes, so a game you quit still ends the session as promptly as before.
|
||||
- **Android TV and Fire Stick: the app asked your host for a frame rate the television does not output, and the latency went through the roof.** People had already found the workaround — set the refresh rate by hand — without knowing what it was working around. The app pins the panel to its highest refresh rate while you are in the on-screen interface; that exists for phones whose systems otherwise cap apps at 60, and no television needs it. But when the stream started, the app read the panel's *pinned* rate rather than what the TV genuinely outputs over HDMI, negotiated the session at that — and then released the pin, because on a TV the video decoder is what should be driving the HDMI mode. The result was a 120-frame stream arriving at a 60 Hz output, by construction, on exactly the two kinds of device in the reports. The pin is no longer applied on a television at all. A TV that really can do 120 still gets it by choosing it. In the same chain: a TV that reports the fractional broadcast rates (59.94, 29.97, 23.976) had them cut down to 59, 29 and 23 — rates no display actually has — and they are rounded properly now.
|
||||
- **A slow first picture, or a black screen, on a constrained connection.** Before any video, a client sends a short burst to work out how much the link can carry. That burst was a fixed, very large size on the reasoning that it should measure the link rather than itself — but the result is capped afterwards to what the session could plausibly use, so everything above that was measured and immediately thrown away. What it bought was nothing; what it cost was a flooded link. On a constrained Wi-Fi connection it could black-hole outright: one measured case spent six seconds timing out and took fourteen seconds to show any video, and the same shape came in from a Fire TV Stick 4K Max. The burst is now sized from what the session can actually use, which can never come out lower than what is needed to prove the ceiling. And the second half of the black screen is closed too: if the burst takes the opening frame down with it, the client now asks for another one instead of waiting for some unrelated recovery to happen along.
|
||||
- **Windows: a game that changed your screen resolution during a stream froze the picture and then ended the session.** Reported from a 4K session where the game switched the display to 1080p while it ran. A fullscreen game is allowed to choose its own resolution, and your host followed it — but the part of the host that compresses the picture cannot change size while it is running, and it was being rebuilt over and over at the size the game had already left. After about three seconds of that, the video ended while the sound kept playing, so you were left with a frozen picture, working audio and no option but to reconnect. The host now rebuilds at the size the game actually chose and tells your client about the new one, exactly as it does when *you* change the resolution from the client. The same fix covers a game that switches HDR on or off mid-play, which failed in the same way. If a rebuild does not take the first time — a display that has just changed mode is often still settling — it is retried for the same few seconds rather than the session being given up on immediately.
|
||||
- **The same resolution change in a Moonlight-compatible session ended it too, and now does not.** One caveat worth knowing, because it is a real trade: the protocol Moonlight speaks has no way for a host to announce a resolution change once a stream is running, so your client is not told. Most clients notice from the picture itself and adjust; a strict one — Media Foundation on Xbox is the known example — may stall instead and need reconnecting. That is the same bargain these sessions already take whenever the host's picture and the client's request disagree, and it is strictly better than what it replaces, which was every such stream ending.
|
||||
- **Moonlight-compatible sessions stuttered at high frame rates, and the host was doing it to itself.** When a client loses its place in the video it asks the host for a complete picture to start again from, and the host is supposed to ignore repeat requests that arrive too quickly. The gap it waited for was measured in frames rather than in time, which at 120 frames a second is about a sixtieth of a second — far shorter than the time a client needs to ask, receive and decode — so the requests never looked like repeats and nearly all of them were honoured. One field session recorded 1,118 such requests in 91 seconds and honoured 1,115: a complete picture roughly every tenth frame, each one large enough to saturate the connection, causing the loss that prompted the next request. It reads as heavy stutter while every latency figure stays flat, because frames are being lost rather than delayed. It also looked like a codec fault, because the same session's H.264 stream — encoded by a different part of the host — asked twice in the whole session and was completely clean. The host now waits a fixed tenth of a second before honouring another request. The field case was a 120-frame session, but the old window was too short at 60 as well, so this is not only a fix for high-refresh displays.
|
||||
- **Fire TV: a DualSense had buttons that never reached the game, and its touchpad click and Mute button did nothing.** Three separate faults on one controller, all reported together over Bluetooth. Some of its buttons were being labelled by the system as coming from a keyboard rather than a controller, and the app was dropping them on that basis — it now trusts what the *device* is rather than the system's per-press guess, and only ever for keycodes that are genuinely controller buttons, so a remote's Back button and a keyboard's arrow keys are untouched. The touchpad click and the Mute button had nowhere to go at all and were simply discarded; both now travel to your game. And Mute genuinely mutes your microphone, once per press — held down, it no longer flickers the microphone on and off — on controllers that actually have the button.
|
||||
- **Android: the app could crash outright while playing, most often on an NVIDIA Shield.** The system call the app used to pick up the newest video frame hands back a resource it has already given away when more than one frame arrives at once, which the system's own safety check then catches by killing the app. It is a bug in Android that is still unfixed upstream, so the app stops using that call and picks the newest frame itself.
|
||||
- **Linux: after disconnecting, the box's own screen could stay black.** Reported on both Bazzite and Nobara. The hand-back at the end of a session asked the system to bring the desktop session back and then walked away the moment the request was accepted — but "the request was accepted" and "the screen is showing something" are different questions, and nothing had ever asked the second one, so every way of ending up dark looked identical to success. It now checks: if the box is still dark twenty-five seconds after the hand-back, it works through a ladder of increasingly firm remedies, each of which was measured on real machines of both families, and if it still cannot fix it, it says exactly what a human should run. This is not a guess at one trigger — the specific fault people reported could not be reproduced. It closes the gap that lets *any* trigger end as a dark panel.
|
||||
- **Windows: duplicate "Punktfunk Speakers" and "Punktfunk Microphone" devices piled up in your sound settings.** Creating one of these is two steps, and a host that died between them left behind a fully working device with no ownership mark on it. Nothing ever recognised that afterwards, so the next start created a second one and the stray outlived it — and because uninstalling also went by the ownership mark, uninstalling did not remove it either. One field machine showed exactly this. The host now recognises a stray from a previous run and adopts it instead of creating another, and uninstalling sweeps up ones already on the machine. Separately, on machines where the usual naming route is blocked, the microphone's name was being written to a location that only exists for speakers, so it silently kept the driver's default name.
|
||||
- **Steam Deck: HDR stopped working after updating to 0.31.2 on a source install.** Two faults with one symptom. The build of the patched compositor had been failing since mid-August on a missing system package — added here — and the failure path then went on to *unlink the compositor that was already installed and working*. A build that never produced anything replaced nothing, so removing the perfectly good previous one meant the host fell back to the system's own compositor and fixed the session at 8-bit, which cannot be taken back once a session has started. A failed build now leaves the working installation alone.
|
||||
|
||||
## Thanks
|
||||
|
||||
Almost everything above came from someone reporting exactly what they saw and on what — the game they launched and the dialog it hung on, the two 4K boxes that felt slow, the make of controller and which button did nothing, the card and the frame rate, the fourteen seconds before a picture appeared. Two entries are worth calling out for a different reason. The Linux black-screen fix ships *without* a reproduction: five scenarios were run across both distributions on real machines, the mechanism first proposed was disproved, and rather than guess, the fix closes the gap that lets any cause end the same way. And the DualSense work was re-implemented from a contributor's diagnosis rather than merged as sent — all three faults were real and correctly identified, but each proposed fix reached further than the hardware that needed it. The diagnosis was the hard part and it was right. Thank you.
|
||||
|
||||
## For developers
|
||||
|
||||
Protocol, ABI, driver and embedder detail — including the version table — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.31.3/CHANGELOG.md).
|
||||
|
||||
The short version: nothing versioned moves. The streaming protocol, the embedding interface, the driver protocol, the gamepad channel and the add-on contract are exactly where 0.31.2 left them — `include/punktfunk_core.h` has no diff at all against the v0.31.2 tag — and no header, package or plugin needs rebuilding, re-pairing or re-publishing in any direction. The one surface that grows is the management API, additively: a `PATCH /api/v1/clients/{fingerprint}` route sets or clears a paired client's label, and `GET /clients` gains a `label` field alongside the existing certificate subject. Nothing existing changed shape, so a consumer that ignores both is unaffected. The TypeScript SDK is re-cut as `@punktfunk/host` 0.1.6 so an add-on can actually reach the generated types for that route; the add-on toolkit is unchanged. One dependency moves for a security advisory (`h2`, lockfile-only), and one behaviour worth knowing about if you integrate: the host now reports a game as running a few seconds later than it used to when it identifies that game by scanning processes rather than by a plugin's own report.
|
||||
@@ -0,0 +1,4 @@
|
||||
• Fixes the big latency jump on Android TV and Fire Stick — the app was asking your host for a frame rate your TV doesn't actually output. Setting the refresh by hand is no longer needed.
|
||||
• A DualSense on Fire TV: buttons that never reached your game now do, and Mute mutes your mic.
|
||||
• Fixes an app crash while streaming, most often on NVIDIA Shield.
|
||||
• Smoother interface on 4K TVs and projectors, plus a new Reduce interface resolution switch.
|
||||
@@ -109,6 +109,16 @@ echo "==> configuring"
|
||||
# (gamescope's own meson.build hard-errors if libliftoff/vkroots are missing from this list, so
|
||||
# all three go together.)
|
||||
#
|
||||
# **libdisplay-info is in the list for exactly the wlroots reason**, learned the hard way on the
|
||||
# SteamOS VM 2026-08-23: it is a vendored submodule too, so a build box that merely HAS
|
||||
# libdisplay-info-dev makes meson link it SHARED, and the binary then dies on SteamOS with
|
||||
# `libdisplay-info.so.2: cannot open shared object file` — it builds, it installs, it prints its
|
||||
# +pfhdr banner in the box, and build-gamescope.sh's on-glass check is the only thing between that
|
||||
# and a host promising HDR it cannot deliver. Debian trixie has the -dev package, Fedora and Arch
|
||||
# have it too, and any of them can pull it in transitively, so "don't install it" is not a fix
|
||||
# that holds. Pinning the fallback makes the outcome the same everywhere, which is the whole
|
||||
# point of this list.
|
||||
#
|
||||
# The C++ runtime goes STATIC for the same reason wlroots does: this binary is built on a ROLLING
|
||||
# distro and has to start on a FROZEN one. Arch's gcc (16.1.1 when this was written) makes the
|
||||
# compositor require `GLIBCXX_3.4.35`, and SteamOS 3.8.16 ships libstdc++ 3.4.34 — so the published
|
||||
@@ -124,7 +134,7 @@ export LDFLAGS="${LDFLAGS:-} -static-libstdc++ -static-libgcc"
|
||||
meson setup "$BUILD" "$SRCDIR" \
|
||||
--prefix="$PREFIX" \
|
||||
--buildtype=release \
|
||||
-Dforce_fallback_for="libliftoff,vkroots,wlroots${EXTRA_FALLBACK:+,$EXTRA_FALLBACK}" \
|
||||
-Dforce_fallback_for="libliftoff,vkroots,wlroots,libdisplay-info${EXTRA_FALLBACK:+,$EXTRA_FALLBACK}" \
|
||||
-Dpipewire=enabled \
|
||||
-Denable_tests=false \
|
||||
-Denable_openvr_support=false \
|
||||
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/bin/sh
|
||||
# Put a retrying `curl` first on PATH for the rest of the job.
|
||||
#
|
||||
# WHY THIS EXISTS: `scripts/ci/retry.sh` already wraps every single-shot network command in CI,
|
||||
# for the reason documented there — the runner box runs many jobs in parallel and its network
|
||||
# drops packets under that load. But one of the biggest fetches in this workspace is NOT ours to
|
||||
# wrap: skia-bindings downloads ~19 MB of prebuilt Skia per target from inside its build script,
|
||||
# with a bare `curl -sS -f -L` and no retry at all (build_support/binary_cache/utils.rs).
|
||||
#
|
||||
# When that transfer truncates the job does not fail with a network error. skia-bindings'
|
||||
# `try_prepare_download` swallows it, prints `DOWNLOAD AND INSTALL FAILED`, and falls through to
|
||||
# `STARTING A FULL BUILD` — a from-source Skia build that the CI containers carry no deps for.
|
||||
# What the operator sees is a Gradle stack trace under "Clippy (Android target)" with the real
|
||||
# cause 1,800 lines up. Measured on main 2026-08-22:
|
||||
#
|
||||
# DOWNLOAD AND INSTALL FAILED: curl error code: "18"
|
||||
# curl stderr: "curl: (18) end of response with 17054400 bytes missing"
|
||||
#
|
||||
# (19,057,024 bytes on the wire; it got 2 MB before git.unom.io closed the connection. The same
|
||||
# asset pulls fine from a dev box, so this is the load-shedding retry.sh was written for.)
|
||||
#
|
||||
# A shim is the only lever that reaches inside a build script. It is also the cheapest correct
|
||||
# one: skia-bindings already passes `-C -` (resume) and caches the part-file under
|
||||
# OUT_DIR/.cache, so a retry CONTINUES the truncated transfer instead of restarting it.
|
||||
#
|
||||
# Applies to every curl in the job, which is what we want — the workspace's other build-script
|
||||
# fetches are single-shot too.
|
||||
#
|
||||
# POSIX sh on purpose: Gitea's act_runner executes a step's `run:` under `sh -e` (dash) inside
|
||||
# the Linux job containers — see the shader-gate note in ci.yml for what assuming bash cost.
|
||||
#
|
||||
# Usage: sh scripts/ci/install-retrying-curl.sh
|
||||
set -e
|
||||
|
||||
# Resolve the REAL curl before the shim is on PATH, and bake the absolute path into the shim —
|
||||
# a shim that re-resolves `curl` by name would exec itself.
|
||||
real_curl=$(command -v curl || true)
|
||||
if [ -z "$real_curl" ]; then
|
||||
echo "::warning::no curl on PATH — skipping the retrying-curl shim"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# RUNNER_TEMP (not /usr/local/bin): the job containers run as root but the macOS runner is a
|
||||
# persistent host where a system dir is neither writable nor ours to litter.
|
||||
shim_dir="${RUNNER_TEMP:-/tmp}/pf-retrying-curl"
|
||||
mkdir -p "$shim_dir"
|
||||
|
||||
# --retry-all-errors is what makes this cover error 18: a truncated transfer is a *transfer*
|
||||
# failure, not an HTTP status, so plain --retry (which only retries transient HTTP codes and
|
||||
# connection errors) would let it through. Needs curl >= 7.71; the CI images are well past it.
|
||||
cat > "$shim_dir/curl" <<EOF
|
||||
#!/bin/sh
|
||||
exec $real_curl --retry 5 --retry-delay 3 --retry-all-errors "\$@"
|
||||
EOF
|
||||
chmod +x "$shim_dir/curl"
|
||||
|
||||
if [ -n "${GITHUB_PATH:-}" ]; then
|
||||
echo "$shim_dir" >> "$GITHUB_PATH"
|
||||
echo "retrying curl installed: $shim_dir/curl -> $real_curl"
|
||||
else
|
||||
echo "::warning::GITHUB_PATH unset — shim written to $shim_dir but not on PATH"
|
||||
fi
|
||||
@@ -68,6 +68,22 @@ log "Building punktfunk-gamescope (HDR 10-bit capture; ~5-10 min, best-effort)"
|
||||
# the two lists in step). Provisioned here, not in install.sh's main pass, so a dep problem can
|
||||
# only ever cost this feature. glm/stb come in as meson wraps; wlroots/libliftoff/vkroots/
|
||||
# libdisplay-info are vendored submodules — none of those need packages.
|
||||
#
|
||||
# ⚠ The last two names are the WSI LAYER's, and x11-xcb's absence is why this leg failed on every
|
||||
# Deck from 2026-08-13 (3ac4548c turned `-Denable_gamescope_wsi_layer=true` on) until it was
|
||||
# noticed as "HDR stopped working after an update". It does NOT fail the compositor build — it
|
||||
# fails layer/meson.build, and build-punktfunk-gamescope.sh treats a missing layer as a hard
|
||||
# error, so the whole build exits non-zero. ci/gamescope-trixie.Dockerfile walked into the
|
||||
# identical trap one release later (1b28a7f7, v0.28.1) and now asserts x11-xcb at image build;
|
||||
# this list never got the same fix.
|
||||
#
|
||||
# ⚠ Do NOT "sync this list with the CI image". That one is for a .deb that RUNS on Debian; this
|
||||
# one builds in trixie for a binary that must run on SteamOS. Taking libdisplay-info-dev from it
|
||||
# (tried on the lab VM, 2026-08-23) built, installed and printed its +pfhdr banner in the box —
|
||||
# then died on glass with `libdisplay-info.so.2: cannot open shared object file`, because meson
|
||||
# had preferred the system lib over gamescope's vendored submodule and linked it SHARED. The
|
||||
# durable fix is the force_fallback_for pin in build-punktfunk-gamescope.sh, next to wlroots;
|
||||
# the package has no reason to be here. Only add a name whose soname SteamOS itself ships.
|
||||
if ! distrobox enter "$BOX" -- bash -lc '
|
||||
set -e
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
@@ -85,7 +101,8 @@ sudo apt-get install -y -qq --no-install-recommends \
|
||||
libvulkan-dev libglm-dev libpixman-1-dev libeis-dev \
|
||||
libavif-dev libdecor-0-dev hwdata libluajit-5.1-dev \
|
||||
libpipewire-0.3-dev libspa-0.2-dev libsdl2-dev \
|
||||
xwayland liblcms2-dev >/dev/null
|
||||
xwayland liblcms2-dev \
|
||||
libx11-xcb-dev libxkbcommon-x11-dev >/dev/null
|
||||
' ; then
|
||||
warn "could not provision gamescope build deps in '$BOX' — sessions stay SDR (re-run update.sh to retry)"
|
||||
exit 0
|
||||
@@ -94,8 +111,20 @@ if ! distrobox enter "$BOX" -- bash -lc "
|
||||
set -e
|
||||
bash '$PKGDIR/build-punktfunk-gamescope.sh' --prefix \"\$HOME/.local\" --no-setcap
|
||||
"; then
|
||||
warn "punktfunk-gamescope failed to build — sessions stay SDR (re-run update.sh to retry)"
|
||||
unwire
|
||||
# A failed build REPLACED NOTHING — the previously installed binary is untouched on disk. If it
|
||||
# still passes the on-glass check it is the very binary that was streaming HDR before this run,
|
||||
# so keep it wired and say it is stale. Unwiring here took HDR away from boxes whose compositor
|
||||
# still worked, on an update that changed nothing about it (field report: HDR "lost" going to
|
||||
# 0.31.2, host.env silently missing PUNKTFUNK_GAMESCOPE_BIN afterwards). `unwire` belongs only
|
||||
# where the binary itself fails `verifies` — the else branch at the bottom, which also removes
|
||||
# it. `wire` re-arms a box a previous run of this bug already unwired.
|
||||
if verifies; then
|
||||
warn "punktfunk-gamescope failed to build — keeping the installed $("$GS_BIN" --version 2>&1 | head -1) (stale; re-run update.sh to retry)"
|
||||
wire
|
||||
else
|
||||
warn "punktfunk-gamescope failed to build and none is installed — sessions stay SDR (re-run update.sh to retry)"
|
||||
unwire
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@punktfunk/host",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.",
|
||||
"type": "module",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
|
||||
+1
-1
@@ -8,4 +8,4 @@
|
||||
*
|
||||
* `version.test.ts` fails if this and `package.json` disagree, so the duplication cannot rot.
|
||||
*/
|
||||
export const SDK_VERSION = "0.1.5";
|
||||
export const SDK_VERSION = "0.1.6";
|
||||
|
||||
Reference in New Issue
Block a user