Compare commits

..
Author SHA1 Message Date
enricobuehler c5eea458b4 fix(windows/cursor): gate the between-session recycle off — calling it from ADD deadlocks
The ADD path holds the manager 'device' mutex, and invalidate_cached_device
takes it. Its own doc says it must not be called from inside that mutex. Calling
it there deadlocked the session: on .173 the ADD stopped after
SET_RENDER_ADAPTER, no monitor was created, and the client reported 'no frames
received'.

The mechanism itself is sound and measured — recycling the driver's WUDFHost
clears the declare (pid 3872 -> 19932, adapter_luid 0x8ed607 -> 0x1a8f6ca,
cursor_excluded true -> false, next session streamed normally). What is missing
is a call site that runs OUTSIDE that mutex and is still on every session's
path; the handshake site tried earlier is not reached on this host.

Off unless PUNKTFUNK_CURSOR_RECYCLE=1. A session that self-composites is the
old behaviour; a deadlocked one is a regression, and the tree should not carry
that while the call site is unsolved.
2026-08-09 00:40:54 +02:00
enricobuehler 7f141eb9a4 fix(windows/cursor): record the declare where it actually happens, and retire the stale control handle
Two fixes that together make the reconnect case work.

1. The declare point is send_cursor_channel, not the ADD request's hw_cursor
   flag. The driver declares its IddCx hardware cursor when the cursor CHANNEL
   arrives -- that is what emits 'cursor channel delivered - driver declares the
   hardware cursor'. Recording it at ADD time (and before that in
   capture_virtual_output) left CURSOR_DECLARED false, so the between-session
   clean returned at its first gate and logged nothing, three runs in a row.

2. The cached control handle dies with the recycled WUDFHost. The first run that
   actually reached the recycle produced a session with NO FRAMES, because the
   ADD that followed used a stale handle. Retire it via
   manager::invalidate_cached_device so the next ensure_device reopens against
   the respawned host, and give WUDF a moment to come back.
2026-08-09 00:32:39 +02:00
enricobuehler 06086de328 fix(windows/cursor): do the clean on the ADD path — the handshake call site was never reached
Third and last placement bug in this chain. clean_cursor_for_next_session was
called from the handshake, inside the '#[cfg(windows)] let prep = match (source,
compositor) { (Virtual, Some(comp)) => ...' arm — which is not the path taken on
this host, so the function never ran. The logs said so by omission: no 'skipping'
line, no 'recycled' line, and cursor_excluded stayed true across the reconnect.

Move it next to where the declare is recorded: the ADD request. Every session
that creates a monitor goes through it, with hw_cursor in hand, before the
monitor exists — which is also the moment the recycle is safe.
2026-08-09 00:24:15 +02:00
enricobuehler 7b91afd721 fix(windows/cursor): record the declare from the ADD request — the only place every one passes
CURSOR_DECLARED was set in capture_virtual_output, which is not on the Windows
prep path, so it was never true and the between-session clean returned at its
first gate every time, silently. Both on-glass runs that appeared to 'not fire'
were this; the one run that did fire was a build with no gate at all.

Move it to the ADD request in the driver module, which every session that wants
a hardware cursor goes through by construction.
2026-08-09 00:16:44 +02:00
enricobuehler 0ed4b51104 feat(windows/cursor): un-declare by recycling the driver's WUDFHost — the reconnect case now works
The declare is irrevocable and adapter-wide, but its SCOPE is the WUDFHost
process (monitor.rs DECLARED_TARGETS). So it can be dropped by recycling that
process instead of restarting the device — and unlike /restart-device, which is
a once-per-boot operation whose single use the start-up clean already spends,
this can run whenever no session holds a display.

Proven on .173 2026-08-08 by killing the host by hand: pid 3872 -> 19932,
adapter_luid 0x8ed607 -> 0x1a8f6ca (a fresh adapter object), cursor_excluded
true -> FALSE, and the next capture session streamed normally. Not a zombie:
WUDF respawns the host on the next open.

The pid comes from the driver itself — every ADD reply carries wudf_pid, so
there is no guessing which of the machine's WUDFHost processes is ours.

This is what makes the requirement reachable: run a desktop session, disconnect,
reconnect in capture mode, and the pointer goes back to the OS's own
compositing. restart_device_for_clean_cursor stays for the start-up path, where
a full device restart is the stronger reset and the once-per-boot budget is
untouched.
2026-08-09 00:07:50 +02:00
enricobuehler 3197a4e887 fix(windows/cursor): name the once-per-boot limit — /restart-device cannot clean between sessions
Measured on a freshly cold-booted .173: the start-up clean succeeded (0.07 s,
adapter clean, first session came up cursor_excluded=false and then declared),
and the between-session clean then fired correctly — gate passed,
CURSOR_DECLARED set, no session streaming — and the restart FAILED with 'Das
System muss neu gestartet werden, damit Konfigurationsvorgaenge abgeschlossen
werden'.

So /restart-device is a ONCE-PER-BOOT lever. The first call after a cold boot
works; every later one in the same boot needs a reboot first, and repeated
attempts additionally drive the devnode into 'restart pending'. The earlier
'0.07 s, so just do it whenever' measurement was true only of the first call.

That settles what this mechanism can and cannot do: it cleans the adapter at
host start-up, and nowhere else. Giving a capture session back the lossless
pointer AFTER a desktop session in the same boot needs a different way to
recycle the driver's WUDFHost process, which is where the declare lives.

Also fixes the detector, which missed this wording entirely and logged
restart_pending=false on exactly the failure it exists to name.
2026-08-08 21:10:06 +02:00
enricobuehler df74dd5aee fix(windows/cursor): a device restart is NOT free — gate it again, and stop swallowing pnputil's reason
Two corrections, both from the box.

1. The previous commit made the between-session clean unconditional on the
   grounds that /restart-device is idempotent and costs 0.07 s. It is not free:
   after ~6 restarts in one afternoon .173 put the devnode into RESTART PENDING,
   and pnputil then refuses every further attempt -- 'a system restart is
   pending for this device to complete a previous operation' -- until an actual
   reboot. Restarting speculatively on every capture-mode connect would burn the
   only lever we have. So gate it on an outstanding declare again.

2. That failure was invisible. The script discarded pnputil's output and
   reported a bare exit code, so three separate runs looked like a wiring bug
   (the clean 'not firing') when in fact it ran every time and the restart
   failed. Keep the text, and surface a restart_pending field so the one
   failure that no retry can fix is named in the log.

Also corrects the previous commit's message: CURSOR_DECLARED was never the
problem. Both host processes run as SYSTEM and serve/handshake/capture share
one process, so the flag was set and read correctly all along.
2026-08-08 19:02:49 +02:00
enricobuehler b2020396c9 fix(windows/cursor): stop gating the between-session clean on host-process state
The reconnect case still failed after the keep-alive guard fix, and the logs
said why by omission: neither outcome line appeared, so
clean_cursor_for_next_session was returning at its first early-out —
CURSOR_DECLARED was false even though the previous session had logged
'driver declares the hardware cursor'. The flag is written in
capture_virtual_output and read in the handshake; one of those is not on the
path that actually runs.

Rather than chase that, delete the dependency on it. The operation being
guarded is idempotent and costs 0.07 s: restarting an already-clean adapter
wastes 70 ms once per capture-mode connect, while failing to restart a dirty
one costs that session a full-frame copy for every frame with a visible
pointer, for its entire life. The flag stays only as a log field, so the next
run still shows whether the hint was set.

The real guard remains the one that matters: no session is streaming.
2026-08-08 18:52:19 +02:00
enricobuehler b20184462d fix(windows/cursor): gate the between-session clean on STREAMING sessions, not keep-alive
Measured on .173: the guard never fired in the one case it exists for. After a
desktop-mode session disconnects its monitor LINGERS, so `no_live_displays()`
— which counted keep-alive slots as held — refused the restart on exactly the
reconnect that needed it, and the capture session went back to
`cursor_excluded=true` and forced compositing.

Only a streaming session (SlotState::Active) can be damaged by the restart. A
lingering/pinned monitor has no session attached and a reconnect preempts and
recreates it anyway ("a reused IddCx swap-chain is dead"), so the restart
destroys nothing that was going to survive.
2026-08-08 18:36:41 +02:00
enricobuehler 537a1852ed feat(windows/cursor): give the pointer back after a desktop session — clear the declare between sessions
The start-up clean (5819cf05) cannot reach the case that actually bites: run a
desktop-mode session, disconnect, reconnect with mouse capture. Same host
process, so the adapter is still carrying the first session's hardware-cursor
declare — and because that declare is irrevocable and adapter-wide, the capture
session self-composites the pointer for its entire life. It pays a full-frame
copy for every frame with a visible pointer and draws our straight-alpha
approximation of an XOR cursor, when the OS would do it natively, for free, and
exactly right.

So clear it at the start of the session that does not want it. The host records
a declare when it delivers a cursor channel (`note_cursor_declared`), and the
next session whose Welcome carries no HOST_CAP_CURSOR restarts the device to
drop it. `pnputil /restart-device` recycles the WUDFHost process the driver's
DECLARED_TARGETS lives in — 0.07 s, measured on .173.

Placement is load-bearing: it runs in the handshake right after the Welcome is
sent and BEFORE the display prep is kicked. That is the one moment this
session's display does not exist yet, and the restart takes every monitor on
the adapter with it.

The guard is deliberately conservative. `manager::no_live_displays()` counts
KEEP-ALIVE slots as held, not just streaming ones: a kept monitor belongs to a
client that is expected back, and the cost of being wrong is that someone
else's session dies, against a skip costing only the old self-composited
behaviour. That does mean the clean will not fire while the previous session's
monitor is still kept — telling a kept slot apart from a live one needs
SlotState internals, and is the obvious follow-up.

Verified: `scripts/xcheck.sh windows` green; `cargo fmt --all` clean; full
`cargo build -p punktfunk-host --release --features nvenc` on .173. The
desktop -> disconnect -> capture sequence is NOT yet exercised on glass.
2026-08-08 18:29:18 +02:00
enricobuehler 5819cf054b feat(windows/cursor): clear a sticky hardware-cursor declare at start-up — capture sessions get the OS's own pointer back
The goal this serves: a session that never engages desktop mode should get the
LOSSLESS cursor — composited by Windows itself, for free, with true XOR — and
the host should only pay for compositing when the user actually asks for the
desktop mouse model.

That is already what happens on a clean adapter. The problem is that adapters
do not stay clean. A hardware-cursor declare is irrevocable and ADAPTER-WIDE
(pf-driver-proto v6): once any desktop-mode session declares, DWM stops
compositing the pointer into every later frame on that adapter, so every
capture-latched session afterwards has to self-composite — a full-frame copy
per visible-pointer frame, and our straight-alpha approximation of an XOR
cursor instead of the real thing — for the rest of the adapter's life.

"Until the next reboot" turned out to be far longer than it sounds. With Fast
Startup on (the Windows default) a shutdown plus power-on is a HIBERBOOT: it
restores session 0 and its drivers, so the declare survives what the operator
calls a reboot. Measured on .173 — Kernel-Boot event id 27 reporting `0x1`
where a cold boot reports `0x0`, with lsass/services/wininit keeping their
pre-"reboot" start times, while `LastBootUpTime` reports the older cold boot
and makes uptime checks lie. On such a box the lossless path can be gone for
weeks.

So clear it explicitly at host start, where no session holds a display yet.
`pnputil /restart-device` recycles the WUDFHost process the driver's
`DECLARED_TARGETS` lives in, which is all it takes. Measured at **0.07 s**
against ~6 s of sleeps for the existing Disable+Enable cycle, and unlike that
cycle it is designed for a device in use, so it does not hit the refusal
`reload_vdisplay_adapter` documents as "the expected case here". In the same
call it also repaired an adapter found in CM_PROB_FAILED_POST_START (Code 43).

Best-effort throughout: a failure just leaves the adapter as it was and
sessions self-composite exactly as before. `PUNKTFUNK_CURSOR_CLEAN_START=0`
opts out.

Verified: `scripts/xcheck.sh windows` green; `cargo fmt --all` clean; full
`cargo build -p punktfunk-host --release --features nvenc` on .173 (xcheck
cannot reach punktfunk-host — it needs ffmpeg).
2026-08-08 18:16:06 +02:00
enricobuehler 62624c1daf feat(probe): --cursor-hold — stop the wiggle so the pointer can be parked on a target
The capture-model repro flags (`--cursor-capture` / `--cursor-nochannel`) drive
relative pointer motion in circles for the whole dump, which is right for
keeping a damage-driven desktop publishing frames but makes the pointer
impossible to aim: at radius 10 every 25 ms it walks several hundred pixels a
second, so a `SetCursorPos` on the host is undone before the next frame.

That mattered because the shape UNDER the pointer is the whole question. The
arrow is a colour cursor and proves nothing about the monochrome path — the
I-beam is the only common Windows system cursor with `hbmColor == null`, so it
is the only one that exercises `mono_planes_to_rgba`. Without being able to
park the pointer on a text field there is no way to photograph the case that
matters.

`--cursor-hold` primes the wiggle for ~3 s (enough to clear CURSOR_SUPPRESSED
and get metadata flowing) and then stops, leaving the pointer wherever the host
puts it for the rest of the dump.

Used it to settle the question on .173: a `--cursor-nochannel` session — the
byte-identical wire behaviour of an iPad/Android/tvOS client, which never
advertises CLIENT_CAP_CURSOR — receives a correctly rendered monochrome I-beam
in the video. Decoded from the dump with ffmpeg; the host log shows the session
took the forced-composite path with the GDI poller live.

Verified: `scripts/xcheck.sh windows` green; `cargo fmt --all` clean.
2026-08-08 17:03:33 +02:00
enricobuehler 7f6d1622ee test(pf-capture): pin the composite-cursor regen key and the blend retry escalation
The two pieces of logic the previous commit added had no tests, and both are
the kind that fail silently in opposite directions.

`blend_key_of` is now ONE definition shared by the regen test and the blend
itself, rather than the same tuple built at two call sites. That drift is the
actual bug shape: a key that reports "changed" while the drawn frame is
identical re-encodes for nothing, and a key that reports "unchanged" while the
pointer moved freezes it on screen. Both directions are asserted — a hidden
pointer keys identically wherever it moves, and every visible change (position,
shape serial, and the visible→hidden transition that must strip the pointer
from the frame) moves the key.

`next_blend_backoff` is extracted for the same reason `mono_planes_to_rgba`
was: the arithmetic a bug hides in does not need a live D3D11 device around it
to be checked. The test walks the escalation well past its ceiling and asserts
it PARKS there — an unbounded doubling would mean a device that comes back
after a long stall never gets picked up.

Verified: `scripts/xcheck.sh windows` green; `cargo fmt --all` clean. The tests
themselves compile and run only on Windows (the module is `cfg(windows)`), so
they are pending a run on a box.
2026-08-08 15:31:18 +02:00
enricobuehler 5c1db4662f fix(windows/cursor): the composite model paid a full-frame copy to draw nothing, and its failures were terminal
Three defects in the capture-model cursor path, all of them in the state a
session spends most of its life in.

1. The blend copied the whole frame even when nothing would be drawn.
   `prepare_blend_scratch` ran the scratch build + `CopyResource`
   unconditionally whenever compositing was on, and only THEN skipped the
   quad for a hidden pointer. A 4K FP16 ring slot is 66 MB, so at 120 fps
   that is ~8 GB/s of write bandwidth bought for nothing — and a game that
   grabbed the pointer hides it, which is exactly when the composite model
   is engaged. The overlay is now resolved FIRST and a hidden or unknown
   pointer returns `None` before any allocation or copy; the conversion
   reads the slot directly, which is the same frame it would have got.

2. A hidden pointer moving forced frame regeneration on an idle desktop.
   The regen key was `(serial, x, y, visible)` — raw cursor state — so a
   pointer a game had hidden re-encoded the last slot every time it moved,
   despite the frame being pixel-identical. The key is now what the blend
   would DRAW: `Some((serial, x, y))` visible, `None` hidden. The
   visible⇄hidden transitions still change it, so the frame that must gain
   or lose the pointer is still regenerated.

3. A blend failure was permanent. `cursor_blend_failed` was set once, warned
   once, and the session then streamed a pointer-less desktop for the rest
   of its life — including for a device-loss that heals a frame later. It is
   now a backoff (250 ms doubling to 4 s) that suppresses the blend, drops
   the pass so it rebuilds, and clears on the first success. Every
   escalation logs, so a permanently broken session is distinguishable from
   one transient hiccup at startup, and the recovery says so.

Also: the GDI poller now carries a heartbeat. `alive()` only asks whether the
thread exited, so a poller wedged on an input desktop it can no longer read
(`GetCursorInfo` failing every tick `continue`s before the publish) froze the
pointer in every frame at its last sampled shape while looking perfectly
healthy in the log. The capturer samples the publish count and warns once per
stall — this poller is the ONLY full-fidelity shape source, since the driver's
IddCx query is alpha-only, so its silence is the difference between a correct
pointer and a frozen one.

None of this depends on the open iPad diagnosis (planning-repo
`windows-cursor-model-determinism.md` §2): these are defects on their own
terms, and they are the prerequisites that make the composite path cheap and
observable enough to reason about.

Verified: `scripts/xcheck.sh windows` (clippy -D warnings for pf-frame,
pf-win-display, pf-capture, pf-vdisplay against x86_64-pc-windows-msvc) green;
`cargo fmt --all` clean. NOT run on a Windows box.
2026-08-08 14:25:27 +02:00
enricobuehler 4b514cc07c Merge pull request 'An OLED palette, and split WHETHER the gamepad UI is offered from WHEN it appears' (#116) from worktree-oled-theme-gamepad-ui-split into main
apple / swift (push) Successful in 1m33s
ci / rust-arm64 (push) Successful in 2m51s
ci / web (push) Successful in 3m13s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m45s
ci / bun-nix (push) Successful in 45s
ci / docs-site (push) Successful in 1m30s
ci / rust (push) Successful in 4m55s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 23s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 40s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 1m3s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m51s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 1m2s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 40s
deb / build-publish-client-arm64 (push) Successful in 2m42s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 47s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m18s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m17s
deb / build-publish-host (push) Successful in 6m16s
docker / builders-arm64cross (push) Successful in 11s
release / apple (push) Successful in 9m52s
docker / deploy-docs (push) Successful in 36s
android / android (push) Successful in 13m40s
deb / build-publish (push) Successful in 9m17s
arch / build-publish (push) Successful in 14m36s
flatpak / build-publish (push) Successful in 7m20s
apple / screenshots (push) Successful in 6m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m21s
Reviewed-on: #116
2026-08-08 11:22:34 +00:00
enricobuehler 30bd10e301 feat(clients): an OLED palette, and split WHETHER the gamepad UI is offered from WHEN it appears
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m39s
ci / rust-arm64 (pull_request) Successful in 4m7s
android / android (pull_request) Successful in 5m1s
ci / docs-site (pull_request) Successful in 1m47s
ci / bun-nix (pull_request) Successful in 42s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m23s
ci / rust (pull_request) Successful in 14m32s
Four changes to the client interface, kept together because two of them touch the same rows
and the last is a bug the first would have made far more visible.

A thirteenth `ui_palette` entry, `oled`. The palette table is hand-mirrored in three languages
(`pf-console-ui`'s `library.rs`, `GamepadPalette.swift`, `GamepadPalette.kt`), so it goes into
all three at index 1, directly after the brand default — which keeps `PALETTES[0]` the unknown-id
fallback and keeps the dark-to-pale cycling order intact. What earns the name is arithmetic, not
a darker shade of violet: the ramp's first two stops are literally (0,0,0) and the ground is pure
black, so the shaded half of the field is pixels switched off rather than "very dark grey", and
the calm mix the form screens sit under lifts toward nothing at all. Mean cell luminance is 0.019
against Violet's 0.254. The bright corner keeps a faint indigo-to-violet ember so the backdrop is
still a field with somewhere to go, and that ember carries enough chroma at that luminance
(60 degrees of hue travel across 13 of the 16 cells) to satisfy the existing multi-tone assertion
without adding `oled` to the near-neutral exemption Graphite and Opal take. Each port gains an
`oled_is_actually_black` test that measures the claim — pure-black corner cells, a mean under half
the darkest other field's — rather than restating the table.

A new device key, `gamepad_ui_mode`. The gamepad-UI switch had been deciding two things at once:
whether to offer the controller-optimized interface at all, and that it appears only while a pad
is attached. A user asked for the second half to stop applying. `"connected"` (the default, and
exactly what the lone Bool meant) and `"always"` separate them, surfaced as a "Show it" row
directly under the switch on all five settings surfaces and built only while that switch is on —
a picker whose every option decides nothing is worse than no picker. `GamepadUIEnvironment.isActive`
takes the mode with NO default argument on purpose: a call site that forgot it would silently
strand everyone who chose Always back on "only with a controller", which is the one bug this
parameter exists to make impossible. An unrecognized value waits for a controller, so a mode a
newer client wrote can never trap an older one in a layout it has no way back out of. It stays a
device preference on both platforms, never part of a profile: which interface this device wears
has nothing to do with how a host streams to it.

The smoothness buffer is hidden under Lowest latency, not dimmed. Everywhere else already hid it
— the GTK and WinUI shells, the Apple touch and tvOS screens, the Android touch screen — because
under that intent it names a quantity that does not exist. Two surfaces disagreed: Apple's gamepad
settings screen left the row live and steppable, and the desktop console dimmed it, having no way
to drop a row from a fixed list. That list is now rebuilt each frame through a `row_applies`
filter. The concern about a vanishing row moving everything under the cursor does not apply here
and the new test says why: the row it drops sits directly BELOW the row that drops it, so the only
cursor that can be present when the list shrinks is the one on the intent row, which does not
move. Two latent hazards went with it — `apply_row` had been indexing the row list on the
assumption the cursor is always in range, and nothing re-clamped that cursor when another writer
changed the intent behind the screen's back.

Pale palettes were unreadable on tvOS, reported from the field. `GamepadInk` was never the
problem: it flips correctly for a pale field, it is not platform-gated, and every tvOS gamepad
entry point already published it. The cause is that this app sets `preferredColorScheme` nowhere
and declares no `UIUserInterfaceStyle`, so every SYSTEM-derived colour landing on those screens —
a `.secondary` placeholder, a `.bordered` button's chrome, a NavigationStack title, a material's
frost — resolved against the DEVICE appearance, which the palette cannot reach. On iPhone, iPad
and Mac a great many users sit in Light mode, so under a pale palette those colours came out dark
and the theme looked correct by accident; an Apple TV is Dark essentially always, so every one of
them rendered white on a light field. The mirror image was broken too and had simply never been
reported: a dark palette on a Light-mode iPhone was already drawing dark on dark. The scheme is
now published beside the ink, once, in `GamepadInkModifier`, because the two are halves of one
decision and publishing only the ink silently loses every colour the frameworks draw on the app's
behalf. Two structural amplifiers went with it: `ConsoleGlass` had been scoping the scheme to the
fill inside its `.background {}` on the tvOS and pre-26 branches while the 26 branch put it on the
content, so no console row's own content ever saw it on tvOS; and `LibraryView`'s navigation
chrome and its loading, error and empty states sit above `LibraryCoverflowView` and so were never
inked at all on tvOS and macOS, where that view is presented directly rather than through the
iOS-only `GamepadLibraryScreen` wrapper.

That last one exposed a second tvOS gap worth closing in the same breath: `ui_palette` had no row
in tvOS's ordinary Settings, and the gamepad settings screen that owns it everywhere else needs an
extended-profile controller to open on tvOS. An Apple TV driven by the Siri Remote alone could not
reach the palettes at all, which would now include the OLED one. `SettingsView.tvBody` carries a
Background row.

Verified: pf-console-ui builds, passes `clippy --all-targets -D warnings` and runs 74 tests clean
under linux/amd64 (a Mac `cargo check` of that crate is vacuous — every module is cfg'd to
linux/windows); `cargo fmt --check` clean for it and pf-client-core. Android `:app` runs 80 tests
with 0 failures, including four new `gamepadUiActive` cases and the palette parity table. The
Apple package builds for macOS AND tvOS and its 9 palette/gamepad-UI tests pass — the tvOS
typecheck is possible because the checked-in xcframework already carries a `tvos-arm64` slice. The
tvOS RENDERING fix is compile-verified only; an on-glass Apple TV check under a pale palette is
still owed, and is the one thing here that a build cannot answer.
2026-08-08 12:57:12 +02:00
35 changed files with 1377 additions and 191 deletions
@@ -69,11 +69,14 @@ fun App(forceGamepadUi: Boolean = false) {
// later manual Back out of the library is not undone by a stale value.
var reopenLibraryHostId by remember { mutableStateOf<String?>(null) }
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
// a TV OR the dev force flag). Flips live as controllers connect/disconnect.
// Console (gamepad) mode mirrors the Apple client: the setting AND (its mode says Always OR a
// pad is attached OR this is a TV OR the dev force flag). Flips live as controllers
// connect/disconnect — unless the mode is Always, where it simply stays.
val tv = remember { isTvDevice(context) }
val controllerConnected by rememberControllerConnected()
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
val gamepadUi = gamepadUiActive(
settings.gamepadUiEnabled, settings.gamepadUiMode, controllerConnected, tv, forceGamepadUi,
)
// Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND
// activity instance (the normal case under `launchMode = standard`) can refuse it before that
@@ -67,7 +67,7 @@ class GamepadPalette(
)
/**
* The twelve shipped palettes: the brand default, five more dark fields, then six pale
* The thirteen shipped palettes: the brand default, six more dark fields, then six pale
* ones. Cycling order runs dark → light, so stepping the row walks the range one way.
*/
val ALL = listOf(
@@ -77,6 +77,22 @@ class GamepadPalette(
ground = Triple(0.075, 0.060, 0.160),
accent = Triple(0.525, 0.471, 0.961), light = false,
),
GamepadPalette(
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no
// glow, no power. The first two stops are literally (0,0,0), so the shaded half
// of the field is genuinely off rather than "very dark grey", and the ground is
// pure black too: the calm mix on the form screens lifts toward nothing. What is
// left is a faint indigo→violet ember in the bright corner. The accent stays the
// brand violet — focus has to be findable on black.
"oled", "OLED",
listOf(
Triple(0.000, 0.000, 0.000), Triple(0.000, 0.000, 0.000),
Triple(0.010, 0.020, 0.100), Triple(0.045, 0.016, 0.115),
Triple(0.120, 0.024, 0.130),
),
ground = Triple(0.0, 0.0, 0.0),
accent = Triple(0.525, 0.471, 0.961), light = false,
),
GamepadPalette(
// Deep indigo climbing through violet into a hot magenta.
"nebula", "Nebula",
@@ -665,6 +665,21 @@ internal fun buildSettingsRows(
"Turn off to use the touch interface even with a controller connected.",
s.gamepadUiEnabled,
) { update(s.copy(gamepadUiEnabled = it)) },
) + listOfNotNull(
// WHEN the switch above takes over. Built only while it is ON: turn the switch off from
// this very screen and the row under the cursor would otherwise be one deciding nothing,
// on a screen that is itself about to disappear.
if (s.gamepadUiEnabled) {
choice(
"gamepadUIMode", GpTab.INTERFACE, null, "Show it",
"With a controller: the touch interface comes back when the last one " +
"disconnects. Always keeps this layout either way — for a device that lives " +
"docked to a TV. A TV itself is always in this mode regardless.",
GAMEPAD_UI_MODE_OPTIONS, s.gamepadUiMode,
) { update(s.copy(gamepadUiMode = it)) }
} else {
null
},
)
}
@@ -16,15 +16,35 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import io.unom.punktfunk.kit.Gamepad
/**
* [Settings.gamepadUiMode]: take over only while a controller is attached. The default, and what
* the switch meant when it was a lone Boolean.
*/
const val GAMEPAD_UI_WHEN_CONNECTED = "connected"
/**
* [Settings.gamepadUiMode]: take over whenever the switch is on, pad or no pad — for a phone or
* tablet that lives docked to a TV, where the console layout is the one wanted and the pad is not
* always awake.
*/
const val GAMEPAD_UI_ALWAYS = "always"
/**
* Whether the controller-optimized "console" home (the host carousel + gamepad chrome) should
* replace the touch UI — the Android mirror of the Apple client's `GamepadUIEnvironment.isActive`:
* the user's [enabled] setting AND (a controller is attached OR this is a TV OR the dev [forced]
* flag). A TV counts unconditionally — its remote/gamepad is the only input, so it's always the
* console UI (as long as the setting is on).
* the user's [enabled] setting AND (the [mode] is [GAMEPAD_UI_ALWAYS] OR a controller is attached
* OR this is a TV OR the dev [forced] flag). A TV counts unconditionally — its remote/gamepad is
* the only input, so it's always the console UI (as long as the setting is on), which is why the
* mode row means nothing there. An unrecognized [mode] waits for a controller, so a value a newer
* client wrote can never strand this one in a layout it has no way back out of.
*/
fun gamepadUiActive(enabled: Boolean, controllerConnected: Boolean, tv: Boolean, forced: Boolean): Boolean =
enabled && (controllerConnected || tv || forced)
fun gamepadUiActive(
enabled: Boolean,
mode: String,
controllerConnected: Boolean,
tv: Boolean,
forced: Boolean,
): Boolean = enabled && (mode == GAMEPAD_UI_ALWAYS || controllerConnected || tv || forced)
/** True on a TV: the leanback/television feature or the TELEVISION ui-mode. */
fun isTvDevice(context: Context): Boolean {
@@ -94,11 +94,20 @@ data class Settings(
val touchMode: TouchMode = TouchMode.TRACKPAD,
/**
* Swap the whole home screen for the controller-optimized "console" UI (the host carousel +
* gamepad chrome) whenever a controller is connected — mirrors the Apple client's
* `gamepadUIEnabled`. On by default; turn it off to keep the touch UI even with a pad attached.
* gamepad chrome) — mirrors the Apple client's `gamepadUIEnabled`. On by default; turn it off
* to keep the touch UI even with a pad attached. WHEN it takes over is [gamepadUiMode].
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
*/
val gamepadUiEnabled: Boolean = true,
/**
* When [gamepadUiEnabled] actually takes over — the cross-client `gamepad_ui_mode` pair,
* mirroring the Apple client's `gamepadUIMode`: `"connected"` (default, and what the switch
* has always meant) waits for a controller; `"always"` keeps the console UI with no pad in
* reach, for a phone or tablet that lives docked to a TV. Read only while [gamepadUiEnabled]
* is on, which is why both settings screens hide the row when the switch is off. Anything
* unrecognized resolves to `"connected"`. A TV ignores it — it is always in console mode.
*/
val gamepadUiMode: String = GAMEPAD_UI_WHEN_CONNECTED,
/**
* Show the experimental game-library browser (the coverflow reached with Y from a saved host).
* Fetched from the host's management API over mTLS; needs a paired host. Mirrors the Apple
@@ -107,9 +116,10 @@ data class Settings(
val libraryEnabled: Boolean = true,
/**
* Which colour family the console (gamepad) UI's living backdrop drifts through — the
* cross-client `ui_palette` key: `"violet"` (the brand default), `"tide"`, `"forest"`,
* `"ember"`, `"rose"`, `"graphite"`. See [GamepadPalette], whose table and maths mirror the
* desktop console's and the Apple client's under the same names. Presentation only: nothing
* cross-client `ui_palette` key: `"violet"` (the brand default), then `"oled"`, `"nebula"`,
* `"abyss"`, `"ember"`, `"moss"`, `"graphite"`, then the six pale fields. See
* [GamepadPalette], whose table and maths mirror the desktop console's and the Apple
* client's under the same names. Presentation only: nothing
* about a stream depends on it, so it is a device preference and never part of a profile.
* An unknown value reads as the default rather than failing — a newer client may have shipped
* a palette this build doesn't know.
@@ -303,6 +313,8 @@ 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),
gamepadUiMode = prefs.getString(K_GAMEPAD_UI_MODE, GAMEPAD_UI_WHEN_CONNECTED)
?: GAMEPAD_UI_WHEN_CONNECTED,
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
uiPalette = prefs.getString(K_UI_PALETTE, "violet") ?: "violet",
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
@@ -344,6 +356,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)
.putString(K_GAMEPAD_UI_MODE, s.gamepadUiMode)
.putBoolean(K_LIBRARY, s.libraryEnabled)
.putString(K_UI_PALETTE, s.uiPalette)
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
@@ -384,6 +397,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_GAMEPAD_UI_MODE = "gamepad_ui_mode"
const val K_LIBRARY = "library_enabled"
const val K_UI_PALETTE = "ui_palette"
@@ -778,6 +792,13 @@ fun smoothBufferOptions(hz: Int): List<Pair<Int, String>> {
)
}
/** (stored value, label) for when the console UI takes over — the Apple client's table verbatim.
* Only offered while [Settings.gamepadUiEnabled] is on; a TV is in console mode either way. */
val GAMEPAD_UI_MODE_OPTIONS = listOf(
GAMEPAD_UI_WHEN_CONNECTED to "With a controller",
GAMEPAD_UI_ALWAYS to "Always",
)
/** (mode, label) for the touch-input model. */
val TOUCH_MODE_OPTIONS = listOf(
TouchMode.TRACKPAD to "Trackpad",
@@ -592,11 +592,24 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
SettingsGroup("Interface") {
ToggleRow(
title = "Controller-optimized UI",
subtitle = "Switch to the console home when a controller is connected. A TV " +
"always uses it.",
subtitle = "Swap the touch home for the console home — the host carousel and " +
"gamepad chrome. A TV always uses it.",
checked = s.gamepadUiEnabled,
onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) },
)
// Only decides anything while the switch above is on, so it is HIDDEN rather than
// dimmed when it isn't — a picker whose every option changes nothing is worse than
// no picker, and this group is short enough that nothing jumps far.
if (s.gamepadUiEnabled) {
SettingDropdown(
label = "Show it",
options = GAMEPAD_UI_MODE_OPTIONS,
selected = s.gamepadUiMode,
caption = "With a controller: the touch home comes back when the last one " +
"disconnects. Always keeps the console home either way — for a device " +
"that lives docked to a TV.",
) { v -> update(s.copy(gamepadUiMode = v)) }
}
}
}
}
@@ -33,14 +33,14 @@ class GamepadPaletteTest {
fun tableMatchesTheOtherClients() {
assertEquals(
listOf(
"violet", "nebula", "abyss", "ember", "moss", "graphite",
"violet", "oled", "nebula", "abyss", "ember", "moss", "graphite",
"holo", "sunset", "bloom", "dawn", "mint", "opal",
),
GamepadPalette.ALL.map { it.id },
)
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
val firstLight = GamepadPalette.ALL.indexOfFirst { it.light }
assertEquals(6, firstLight)
assertEquals(7, firstLight)
assertTrue(GamepadPalette.ALL.drop(firstLight).all { it.light })
// An unknown name is a newer client's palette, not an error.
assertEquals("violet", GamepadPalette.named("chartreuse").id)
@@ -72,6 +72,25 @@ class GamepadPaletteTest {
}
}
/**
* OLED is the one palette whose selling point is measurable: it has to be genuinely black,
* not merely the darkest of the dark fields. The blob field this client draws samples the
* ramp at 0.15/0.40/0.65/0.90, so its darkest blob lands in the all-black head of the ramp.
*/
@Test
fun oledIsActuallyBlack() {
val oled = GamepadPalette.named("oled")
assertEquals(Triple(0.0, 0.0, 0.0), oled.ground)
assertEquals(0f, oled.blobColors[0].red, 1e-6f)
assertEquals(0f, oled.blobColors[0].green, 1e-6f)
assertEquals(0f, oled.blobColors[0].blue, 1e-6f)
val mean = oled.stops.sumOf { luma(it) } / oled.stops.size
val darkestOther = GamepadPalette.ALL
.filter { it.id != "oled" && it.stops.isNotEmpty() }
.minOf { p -> p.stops.sumOf { luma(it) } / p.stops.size }
assertTrue("oled means $mean, barely under $darkestOther", mean < darkestOther / 2)
}
/** A pale palette really is pale — its ink flips, so a mislabelled one is unreadable. */
@Test
fun palettesAreHonestAboutLightness() {
@@ -95,4 +95,47 @@ class GamepadSettingsRowsTest {
// Drawn as a switch, and reading the persisted default.
assertEquals(true, row(on, "dsCapture").toggled)
}
/**
* The activation-mode row is a sub-setting of the Controller-optimized UI switch, so it is
* OFFERED only while that switch is on hidden rather than dimmed, because with the switch
* off this whole screen is about to be replaced by the touch UI and a dimmed row there would
* be one last thing to step past on the way out.
*/
@Test
fun `the activation-mode row follows the switch it belongs to`() {
fun ids(enabled: Boolean) = buildSettingsRows(
Settings(gamepadUiEnabled = enabled),
hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
) {}.map { it.id }
val on = ids(enabled = true)
assertTrue("the mode row is missing", "gamepadUIMode" in on)
assertEquals(
"the mode belongs directly under the switch it qualifies",
on.indexOf("gamepadUI") + 1,
on.indexOf("gamepadUIMode"),
)
val off = ids(enabled = false)
assertFalse("the mode row must not outlive its switch", "gamepadUIMode" in off)
assertTrue("the switch itself stays, or it could never be turned back on", "gamepadUI" in off)
}
/** Stepping the mode row writes the shared `gamepad_ui_mode` value, and wraps on A. */
@Test
fun `the activation-mode row steps the shared key`() {
var s = Settings()
fun mode() = buildSettingsRows(
s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
) { s = it }.first { it.id == "gamepadUIMode" }
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
assertEquals("With a controller", mode().value)
assertFalse("already the first = thud", mode().adjust(-1))
assertTrue(mode().adjust(1))
assertEquals(GAMEPAD_UI_ALWAYS, s.gamepadUiMode)
// A from the last entry wraps home.
mode().activate()
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
}
}
@@ -0,0 +1,53 @@
package io.unom.punktfunk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* [gamepadUiActive] is pure table-tested over its inputs, and the mirror of the Apple client's
* `GamepadUIEnvironmentTests`. The two clients share the stored `gamepad_ui_mode` values, so a
* disagreement here is a device that behaves differently from the same setting.
*/
class GamepadUiTest {
/** The default mode is what the switch meant when it was a lone Boolean. */
@Test
fun whenConnectedWaitsForAPad() {
assertTrue(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, true, tv = false, forced = false))
assertFalse(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, false, tv = false, forced = false))
assertFalse(gamepadUiActive(false, GAMEPAD_UI_WHEN_CONNECTED, true, tv = false, forced = false))
assertFalse(gamepadUiActive(false, GAMEPAD_UI_WHEN_CONNECTED, false, tv = false, forced = false))
// A TV is in console mode whatever the mode says — its remote is the only input.
assertTrue(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, false, tv = true, forced = false))
}
/** Always drops the controller from the decision but never the switch, which is the one
* way back to the touch UI. */
@Test
fun alwaysIgnoresThePadButNotTheSwitch() {
assertTrue(gamepadUiActive(true, GAMEPAD_UI_ALWAYS, false, tv = false, forced = false))
assertTrue(gamepadUiActive(true, GAMEPAD_UI_ALWAYS, true, tv = false, forced = false))
assertFalse(gamepadUiActive(false, GAMEPAD_UI_ALWAYS, false, tv = false, forced = false))
assertFalse(gamepadUiActive(false, GAMEPAD_UI_ALWAYS, true, tv = false, forced = false))
}
/** A value a newer client wrote waits for a pad rather than stranding this build in a
* layout it has no way back out of. */
@Test
fun anUnknownModeWaitsForAPad() {
assertFalse(gamepadUiActive(true, "whenever-i-say-so", false, tv = false, forced = false))
assertTrue(gamepadUiActive(true, "whenever-i-say-so", true, tv = false, forced = false))
assertFalse(gamepadUiActive(true, "", false, tv = false, forced = false))
}
/** The shipped default: the console UI still waits for a controller. */
@Test
fun theDefaultIsUnchangedBehaviour() {
val s = Settings()
assertTrue(s.gamepadUiEnabled)
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
assertFalse(gamepadUiActive(s.gamepadUiEnabled, s.gamepadUiMode, false, tv = false, forced = false))
}
}
@@ -77,6 +77,7 @@ class ProfilesTest {
// Device-scope settings are not in the overlay at all, so no profile can move them.
assertEquals(base.gamepadUiEnabled, out.gamepadUiEnabled)
assertEquals(base.gamepadUiMode, out.gamepadUiMode)
assertEquals(base.libraryEnabled, out.libraryEnabled)
assertEquals(base.autoWakeEnabled, out.autoWakeEnabled)
assertEquals(base.sc2Capture, out.sc2Capture)
@@ -99,6 +99,10 @@ struct ContentView: View {
// with no (extended) controller attached tvOS falls back to HomeView as before.
@ObservedObject private var gamepadManager = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
/// When the switch above takes over "connected" (default) or "always". See
/// `GamepadUIEnvironment`.
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
GamepadUIEnvironment.modeWhenConnected
/// Auto-wake on connect (Settings General). On (default): a dial to an offline saved host
/// fires Wake-on-LAN up front and falls into the "Waking" wait if the dial fails. Off: connects
/// go straight through with no wake. The explicit "Wake Host" action is unaffected either way.
@@ -113,7 +117,8 @@ struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
private var gamepadUIActive: Bool {
GamepadUIEnvironment.isActive(
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
mode: gamepadUIMode)
}
// The body is split in two `driven` (the screen plus its lifecycle drivers and sheets) and
@@ -85,16 +85,40 @@ extension EnvironmentValues {
}
extension View {
/// Resolve the stored `ui_palette` and publish its ink to everything below. Applied by the
/// gamepad screens' common root so no individual view has to read the setting.
func gamepadPaletteInk() -> some View { modifier(GamepadInkModifier()) }
/// Resolve the stored `ui_palette` and publish its ink AND the matching colour scheme to
/// everything below. Applied by the gamepad screens' common root so no individual view has to
/// read the setting.
///
/// `active` exists for the one surface that is the same view in both worlds: `LibraryView`
/// renders the coverflow under the gamepad UI and a plain grid without it. Passing `false`
/// publishes nothing, because the touch/desktop layouts sit on the SYSTEM background, where a
/// palette's scheme would invert their own system colours instead of matching them.
func gamepadPaletteInk(_ active: Bool = true) -> some View {
modifier(GamepadInkModifier(active: active))
}
}
private struct GamepadInkModifier: ViewModifier {
var active = true
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
/// The ambient scheme from ABOVE this modifier what gets republished unchanged when the
/// gamepad UI isn't the one drawing, so `active: false` is a true no-op rather than a branch
/// that would change this view's identity.
@Environment(\.colorScheme) private var systemScheme
func body(content: Content) -> some View {
content.environment(\.gamepadInk, GamepadInk.of(GamepadPalette.named(paletteID)))
let palette = GamepadPalette.named(paletteID)
return content
.environment(\.gamepadInk, active ? GamepadInk.of(palette) : .dark)
// The ink alone was never enough. Every SYSTEM-derived colour that lands on these
// screens `.secondary` in a placeholder, a `.bordered` button's chrome, a
// NavigationStack's title, a material's frost resolves against the DEVICE's
// appearance, which no part of this app had ever set. On iPhone and Mac that is often
// Light, so the pale palettes looked correct by accident; an Apple TV is Dark
// essentially always, so on tvOS every one of them came out WHITE on a pale field and
// the interface was unreadable. Publishing the scheme here once, beside the ink it
// has to agree with is what makes a pale palette mean "light" to UIKit too.
.environment(\.colorScheme, active ? (palette.light ? .light : .dark) : systemScheme)
}
}
@@ -32,9 +32,12 @@ struct LibraryView: View {
// setting off) every platform keeps the plain-grid presentation of this same view.
@ObservedObject private var gamepadManager = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
GamepadUIEnvironment.modeWhenConnected
private var gamepadUIActive: Bool {
GamepadUIEnvironment.isActive(
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
mode: gamepadUIMode)
}
#endif
@@ -78,6 +81,16 @@ struct LibraryView: View {
}
}
#endif
#if os(iOS) || os(macOS) || os(tvOS)
// Published HERE, not just inside the coverflow, because the coverflow is only one of
// four things this view renders: the loading spinner, the error state and the empty
// state sit above it, as do the navigation title and toolbar. On iOS those are wrapped
// by GamepadLibraryScreen, which inks the whole thing; tvOS and macOS present this view
// directly in a NavigationStack, so under a pale palette every one of them kept the
// system's own (dark, on an Apple TV) chrome over a light field. Off when the gamepad
// UI isn't drawing the plain grid belongs to the system background.
.gamepadPaletteInk(gamepadUIActive)
#endif
}
@ViewBuilder private var content: some View {
@@ -81,6 +81,9 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
/// When the switch above takes over the row is only built while it is on.
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
GamepadUIEnvironment.modeWhenConnected
/// The gamepad UI's background colour family the backdrop BEHIND this screen re-colours as
/// the row steps, which is why the picker lives here and not in a sheet.
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
@@ -659,6 +662,21 @@ struct GamepadSettingsView: View {
detail: "Turn off to use the touch interface even with a controller connected.",
value: $gamepadUIEnabled),
]
// WHEN the switch above takes over. Built only while it is on: with the switch off this
// screen is unreachable in the first place (no gamepad UI to open it from), so a row
// that decides nothing would exist purely to be found in a screenshot.
if gamepadUIEnabled, let at = list.firstIndex(where: { $0.id == "gamepadUI" }) {
list.insert(
choiceRow(
id: "gamepadUIMode", tab: .interface, icon: "gamecontroller.circle",
label: "Show it",
detail: "With a controller: the touch interface comes back when the last one "
+ "disconnects. Always keeps this layout either way — for a device that "
+ "lives on a TV.",
options: SettingsOptions.gamepadUIModes, current: gamepadUIMode
) { gamepadUIMode = $0 },
at: at + 1)
}
#if os(macOS)
// The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside
// the Video tab) macOS only, mirroring the touch SettingsView's Presentation row
@@ -707,6 +725,14 @@ struct GamepadSettingsView: View {
at: anchor + 1)
}
#endif
// The smoothness buffer only decides anything under Smoothness. Every other settings
// surface touch, tvOS, the GTK and WinUI shells hides it under Lowest latency; this
// screen alone left it live and steppable, which is a row that thuds or silently stores
// a value nothing reads. Removed here rather than omitted from the literal above so the
// macOS safe-present insertion can still anchor on it.
if presentPriority != "smooth" {
list.removeAll { $0.id == "smoothBuffer" }
}
return list + profileRows
}
@@ -53,6 +53,14 @@ enum SettingsOptions {
static let hudPlacements: [(label: String, tag: String)] =
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
/// When the gamepad UI takes over (`DefaultsKey.gamepadUIMode`) only meaningful while
/// `gamepadUIEnabled` is on, so every surface that offers it hides the row when the switch
/// is off rather than showing a picker that decides nothing.
static let gamepadUIModes: [(label: String, tag: String)] = [
("With a controller", GamepadUIEnvironment.modeWhenConnected),
("Always", GamepadUIEnvironment.modeAlways),
]
/// Presentation intent (`DefaultsKey.presentPriority` the 2026-07 rebuild that replaced
/// the visible stage picker with intent; see SessionPresenter's PresentPriority and
/// design/apple-presentation-rebuild.md). The stage ladder survives only as the hidden
@@ -724,11 +724,24 @@ extension SettingsView {
#endif
#if !os(tvOS)
if !inProfileScope {
described("With a controller connected, the host list and library switch to a "
+ "controller-friendly layout — larger focus targets, a swipeable cover "
+ "browser.") {
described("The host list and library switch to a controller-friendly layout — "
+ "larger focus targets, a swipeable cover browser.") {
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
}
// Only meaningful while the switch above is on, so it is HIDDEN rather than
// disabled when it isn't: a picker whose every option decides nothing is worse
// than no picker, and this Section is short enough that nothing jumps far.
if gamepadUIEnabled {
described("With a controller: the touch interface comes back when the last "
+ "one disconnects. Always keeps the controller-friendly layout either "
+ "way — for a device that lives on a TV.") {
Picker("Show it", selection: $gamepadUIMode) {
ForEach(SettingsOptions.gamepadUIModes, id: \.tag) { option in
Text(option.label).tag(option.tag)
}
}
}
}
}
#endif
#if DEBUG && !os(tvOS)
@@ -75,6 +75,13 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
@ObservedObject var gamepads = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
/// When the switch above takes over read (and shown) only while it is on.
@AppStorage(DefaultsKey.gamepadUIMode) var gamepadUIMode =
GamepadUIEnvironment.modeWhenConnected
/// The gamepad UI's background palette. Edited here on tvOS only (see `tvBody`) every other
/// platform reaches it through the gamepad settings screen, which an Apple TV without a
/// controller cannot open.
@AppStorage(DefaultsKey.uiPalette) var uiPalette = "violet"
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10
@@ -488,6 +495,22 @@ struct SettingsView: View {
TVSelectionRow(
title: "Gamepad-optimized browsing",
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
// Hidden while the switch above is off see the touch settings' identical gate.
if gamepadUIEnabled {
TVSelectionRow(
title: "Show it",
options: SettingsOptions.gamepadUIModes, selection: $gamepadUIMode)
// The Apple TV's ONLY route to the shared `ui_palette`. Everywhere else the
// Background row lives on the gamepad settings screen, which is reached from
// the gamepad launcher and on tvOS that launcher needs an extended-profile
// controller, so an Apple TV driven by the Siri Remote alone could not reach
// the palettes at all. It belongs beside "Show it" because both describe the
// same interface: this row is what that interface looks like once it is up.
TVSelectionRow(
title: "Background",
options: GamepadPalette.all.map { (label: $0.name, tag: $0.id) },
selection: $uiPalette)
}
tvCaption(Self.controllersFooter)
NavigationLink("About") { AboutView() }
.padding(.top, 8)
@@ -95,24 +95,19 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
private var materialWash: Color { ink.glass(ink.isLight ? 0.55 : 0.40) }
func body(content: Content) -> some View {
// The scheme goes on the WHOLE modified view, not just the fill inside `.background {}`.
// Scoped to the fill it frosts the material correctly and stops there, so a system colour
// in the row's own content (a `.secondary` label, a `.bordered` button) still resolved
// against the device appearance which is how the pale palettes came out light-on-light
// on tvOS, whose appearance is always Dark. The 26 branch had it right all along; the
// tvOS and pre-26 branches were the odd ones out.
#if os(tvOS)
// ALWAYS the material fallback on tvOS: the gamepad settings list is 15+ of these
// surfaces, and live Liquid Glass per row made the whole screen visibly laggy on the
// Apple TV's GPU (same class of call GlassProminentButton already makes glass fights
// the 10-foot platform). The wash and tint ride overlays two flat fills, no GPU cost.
content.background {
shape.fill(.ultraThinMaterial)
.environment(\.colorScheme, scheme)
.overlay { shape.fill(materialWash) }
.overlay {
if let tint { shape.fill(tint) }
}
}
#else
if #available(iOS 26, macOS 26, *) {
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
} else {
content.background {
content
.background {
shape.fill(.ultraThinMaterial)
.environment(\.colorScheme, scheme)
.overlay { shape.fill(materialWash) }
@@ -120,6 +115,21 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
if let tint { shape.fill(tint) }
}
}
.environment(\.colorScheme, scheme)
#else
if #available(iOS 26, macOS 26, *) {
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
} else {
content
.background {
shape.fill(.ultraThinMaterial)
.environment(\.colorScheme, scheme)
.overlay { shape.fill(materialWash) }
.overlay {
if let tint { shape.fill(tint) }
}
}
.environment(\.colorScheme, scheme)
}
#endif
}
@@ -173,11 +183,14 @@ private struct ConsoleGlassBackground<S: Shape>: ViewModifier {
in: shape)
.environment(\.colorScheme, scheme)
} else {
content.background {
shape.fill(.regularMaterial)
.environment(\.colorScheme, scheme)
.overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) }
}
// Same hoist as ConsoleGlass: the content needs the scheme too, not only the frost.
content
.background {
shape.fill(.regularMaterial)
.environment(\.colorScheme, scheme)
.overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) }
}
.environment(\.colorScheme, scheme)
}
}
}
@@ -3,20 +3,40 @@
// layouts). A pure function, not a singleton: the reactivity comes from callers already observing
// `GamepadManager.shared` and the `DefaultsKey.gamepadUIEnabled` @AppStorage themselves (the same
// local-read pattern SettingsView already uses for GamepadManager), so this stays the single place
// the two combine without adding a second ObservableObject or an environment key nobody else needs.
// the inputs combine without adding a second ObservableObject or an environment key nobody else needs.
import Foundation
import PunktfunkShared
public enum GamepadUIEnvironment {
/// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`);
/// `DefaultsKey.gamepadUIMode`: take over only while a controller is attached. The default,
/// and what the switch meant when it was a lone Bool.
public static let modeWhenConnected = "connected"
/// `DefaultsKey.gamepadUIMode`: take over whenever the switch is on, pad or no pad asked
/// for by people driving a TV-connected iPad or a couch Mac, where the console layout is the
/// one they want and the pad is not always awake.
public static let modeAlways = "always"
/// `enabledSetting` is the user's Settings switch (`DefaultsKey.gamepadUIEnabled`) off means
/// the touch/desktop UI, full stop. `mode` is `DefaultsKey.gamepadUIMode`, and only matters
/// once the switch is on: `modeAlways` takes over unconditionally, anything else (including a
/// value a newer client wrote) waits for a controller.
///
/// `gamepadConnected` is `GamepadManager.shared.active != nil` active only once a usable
/// controller is actually attached (a non-extended-profile device leaves `active` nil, which
/// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function's
/// whole job is the AND, so there's nothing else to inspect, and it keeps the helper testable
/// without a real `GCController` (which XCTest can't construct).
public static func isActive(gamepadConnected: Bool, enabledSetting: Bool) -> Bool {
enabledSetting && (gamepadConnected || forced)
/// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function
/// has nothing else to inspect, and it keeps the helper testable without a real `GCController`
/// (which XCTest can't construct).
/// `mode` carries no default on purpose: a call site that forgot it would silently strand
/// everyone who picked Always back on "only with a controller", which is exactly the bug
/// this parameter exists to make impossible.
public static func isActive(
gamepadConnected: Bool,
enabledSetting: Bool,
mode: String
) -> Bool {
guard enabledSetting else { return false }
return mode == modeAlways || gamepadConnected || forced
}
/// Dev-only escape hatch (like ContentView's `PUNKTFUNK_AUTOCONNECT`): pretend a controller is
@@ -176,16 +176,23 @@ public enum DefaultsKey {
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
public static let hudPlacement = "punktfunk.hudPlacement"
/// iOS/iPadOS/macOS: switch the host list, settings and game library to a controller-friendly
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library).
/// On by default; WHEN it takes over is `gamepadUIMode`. See `GamepadUIEnvironment.isActive`.
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
/// When `gamepadUIEnabled` actually takes over: `"connected"` (the default only while a
/// usable controller is attached, the behaviour this switch has always had) or `"always"`,
/// for someone who prefers the console layout with no pad in reach (a TV-connected iPad, a
/// Mac driven from the couch). Read only while `gamepadUIEnabled` is on, which is why the
/// settings rows hide it when the switch is off. Anything unrecognized reads as
/// `"connected"`. A device preference, never part of a stream profile.
public static let gamepadUIMode = "punktfunk.gamepadUIMode"
/// Which colour family the gamepad UI's living backdrop drifts through a
/// `GamepadPalette` id ("violet" = the brand default, then "tide"/"forest"/"ember"/
/// "rose"/"graphite"). The cross-client `ui_palette` key: the desktop console and the
/// Android client carry the same table under the same names. Presentation only, so it is
/// a device preference and never part of a stream profile. An unknown value reads as the
/// default rather than failing a newer client may have shipped a palette this build
/// doesn't know.
/// `GamepadPalette` id ("violet" = the brand default, then "oled"/"nebula"/"abyss"/"ember"/
/// "moss"/"graphite", then the pale ones). The cross-client `ui_palette` key: the desktop
/// console and the Android client carry the same table under the same names. Presentation
/// only, so it is a device preference and never part of a stream profile. An unknown value
/// reads as the default rather than failing a newer client may have shipped a palette this
/// build doesn't know.
public static let uiPalette = "punktfunk.uiPalette"
/// iPhone: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
/// device's own Taptic Engine for phone-clip pads that ship without rumble motors, where
@@ -65,13 +65,25 @@ public struct GamepadPalette: Identifiable, Equatable, Sendable {
SIMD3(0.22, 0.38, 0.86), SIMD3(0.53, 0.47, 0.96),
]
/// The twelve shipped palettes: the brand default, five more dark fields, then six pale
/// The thirteen shipped palettes: the brand default, six more dark fields, then six pale
/// ones. Cycling order runs dark light, so stepping the row walks the whole range one way.
public static let all: [GamepadPalette] = [
// --- dark fields (white ink) ---
GamepadPalette(
id: "violet", name: "Violet", stops: [],
ground: SIMD3(0.075, 0.060, 0.160), accent: SIMD3(0.525, 0.471, 0.961), light: false),
GamepadPalette(
// For OLED and AMOLED panels, where a black pixel is a pixel switched off no glow,
// no power. The first two stops are literally (0,0,0), so the shaded half of the
// field is genuinely off rather than "very dark grey", and the ground is pure black
// too: the calm mix on the form screens lifts toward nothing. What is left is a
// faint indigoviolet ember in the bright corner. The accent stays the brand violet
// focus has to be findable on black.
id: "oled", name: "OLED",
stops: [SIMD3(0.000, 0.000, 0.000), SIMD3(0.000, 0.000, 0.000),
SIMD3(0.010, 0.020, 0.100), SIMD3(0.045, 0.016, 0.115),
SIMD3(0.120, 0.024, 0.130)],
ground: SIMD3(0, 0, 0), accent: SIMD3(0.525, 0.471, 0.961), light: false),
GamepadPalette(
// Deep indigo climbing through violet into a hot magenta.
id: "nebula", name: "Nebula",
@@ -46,12 +46,29 @@ final class GamepadPaletteTests: XCTestCase {
func testTableMatchesTheOtherClients() {
XCTAssertEqual(
GamepadPalette.all.map(\.id),
["violet", "nebula", "abyss", "ember", "moss", "graphite",
["violet", "oled", "nebula", "abyss", "ember", "moss", "graphite",
"holo", "sunset", "bloom", "dawn", "mint", "opal"])
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
let firstLight = GamepadPalette.all.firstIndex { $0.light }
XCTAssertEqual(firstLight, 6)
XCTAssertTrue(GamepadPalette.all.dropFirst(6).allSatisfy(\.light))
XCTAssertEqual(firstLight, 7)
XCTAssertTrue(GamepadPalette.all.dropFirst(7).allSatisfy(\.light))
}
/// OLED is the one palette whose selling point is measurable: it has to be genuinely black,
/// not merely the darkest of the dark fields.
func testOLEDIsActuallyBlack() {
let oled = GamepadPalette.named("oled")
XCTAssertEqual(oled.ground, SIMD3(0, 0, 0), "the calm lift must be nothing")
let cells = oled.meshColors
XCTAssertGreaterThanOrEqual(
cells.filter { luma($0) == 0 }.count, 3,
"the shaded corner has to be switched off, not dimmed")
let mean = cells.map(luma).reduce(0, +) / Double(cells.count)
let darkestOther = GamepadPalette.all
.filter { $0.id != "oled" }
.map { p in p.meshColors.map(luma).reduce(0, +) / Double(p.meshColors.count) }
.min() ?? 0
XCTAssertLessThan(mean, darkestOther / 2, "oled is barely darker than \(darkestOther)")
}
/// A palette must read as SEVERAL hues, not one hue at several brightnesses that was
@@ -1,14 +1,58 @@
// GamepadUIEnvironment.isActive is a pure AND table-tested exhaustively over its 2x2 inputs.
// GamepadUIEnvironment.isActive is pure table-tested exhaustively over its inputs.
import XCTest
@testable import PunktfunkKit
final class GamepadUIEnvironmentTests: XCTestCase {
func testActiveOnlyWhenEnabledAndConnected() {
XCTAssertTrue(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: true))
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: false))
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: true))
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: false))
private let connected = GamepadUIEnvironment.modeWhenConnected
private let always = GamepadUIEnvironment.modeAlways
/// The default mode is the behaviour the switch had when it was a lone Bool, so an install
/// that never sees the new row is exactly where it was.
func testWhenConnectedIsAPlainAnd() {
XCTAssertTrue(
GamepadUIEnvironment.isActive(
gamepadConnected: true, enabledSetting: true, mode: connected))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: true, enabledSetting: false, mode: connected))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: true, mode: connected))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: false, mode: connected))
}
/// Always drops the controller from the decision entirely but NOT the switch, which stays
/// the one way back to the touch UI.
func testAlwaysIgnoresTheControllerButNotTheSwitch() {
XCTAssertTrue(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: true, mode: always))
XCTAssertTrue(
GamepadUIEnvironment.isActive(
gamepadConnected: true, enabledSetting: true, mode: always))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: false, mode: always))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: true, enabledSetting: false, mode: always))
}
/// A value a newer client wrote must wait for a controller, never strand this build in a
/// layout it has no way back out of.
func testUnknownModeWaitsForAController() {
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: true, mode: "whenever-i-say-so"))
XCTAssertTrue(
GamepadUIEnvironment.isActive(
gamepadConnected: true, enabledSetting: true, mode: "whenever-i-say-so"))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: true, mode: ""))
}
}
+24 -3
View File
@@ -129,6 +129,16 @@ struct Args {
/// host must composite the metadata cursor on its own; decode the dump and look for the
/// pointer.
cursor_nochannel: bool,
/// `--cursor-hold` — with `--cursor-capture`/`--cursor-nochannel`, stop the relative wiggle
/// after a short priming burst instead of circling forever. The wiggle exists to keep a
/// damage-driven desktop publishing frames, but it also DRAGS the host pointer several hundred
/// pixels a second, which makes it impossible to hold the pointer over a chosen target — and
/// the shape under the pointer is the whole point when the question is "does the MONOCHROME
/// I-beam survive compositing?" (the arrow is a colour cursor and proves nothing about the
/// mono path). With this flag: prime for ~3 s so the pointer is un-suppressed and metadata is
/// flowing, then hold still so a `SetCursorPos` on the host can park it on a text field for
/// the rest of the dump.
cursor_hold: bool,
/// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS`
/// seconds (default 4), print what's found, and exit. No connection is made.
discover: Option<u64>,
@@ -309,6 +319,7 @@ fn parse_args() -> Args {
clock_resync: argv.iter().any(|a| a == "--clock-resync"),
cursor_capture: argv.iter().any(|a| a == "--cursor-capture"),
cursor_nochannel: argv.iter().any(|a| a == "--cursor-nochannel"),
cursor_hold: argv.iter().any(|a| a == "--cursor-hold"),
}
}
@@ -900,13 +911,23 @@ async fn session(args: Args) -> Result<()> {
}
});
let wiggle_conn = conn.clone();
let hold = args.cursor_hold;
tokio::spawn(async move {
// Relative circles, forever: keeps the host pointer moving (and, on metadata-cursor
// compositors, keeps cursor updates flowing) for the whole dump.
// Relative circles: keeps the host pointer moving (and, on metadata-cursor
// compositors, keeps cursor updates flowing) for the whole dump — unless
// `--cursor-hold`, which primes and then stops so the pointer can be parked.
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
tracing::info!("cursor-capture: relative pointer wiggle running");
tracing::info!(hold, "cursor-capture: relative pointer wiggle running");
let prime_until = std::time::Instant::now() + std::time::Duration::from_secs(3);
let mut t = 0.0f64;
loop {
if hold && std::time::Instant::now() >= prime_until {
tracing::info!(
"cursor-capture: wiggle primed and STOPPED (--cursor-hold) — the pointer \
now stays where the host puts it"
);
return;
}
let e = InputEvent {
kind: InputKind::MouseMove,
_pad: [0; 3],
+270 -54
View File
@@ -212,6 +212,47 @@ struct KeyedMutexGuard<'a> {
/// (`frame_transport.rs`).
const WAIT_ABANDONED_HRESULT: i32 = 0x0000_0080;
/// First retry delay after a composite-blend failure — short enough that a transient device-loss
/// costs a few pointer-less frames rather than the rest of the session.
const BLEND_RETRY_MIN: Duration = Duration::from_millis(250);
/// Ceiling for the doubling retry: a genuinely broken device stops burning a frame-sized texture
/// allocation every quarter second, while still recovering within ~4 s if it ever comes back.
const BLEND_RETRY_MAX: Duration = Duration::from_secs(4);
/// How long the poller may publish NOTHING before the capturer calls it wedged. It polls at
/// `CursorPoller::INTERVAL` (4 ms), so this is ~250 missed publishes — far outside any scheduling
/// hiccup, and still fast enough to name the fault while a user is still looking at it.
const POLLER_STALL: Duration = Duration::from_secs(1);
/// The next retry delay after a composite-blend failure: [`BLEND_RETRY_MIN`] for the first, then
/// doubling per consecutive failure up to [`BLEND_RETRY_MAX`]. Free function so the escalation is
/// testable without a live D3D11 device (the `mono_planes_to_rgba` precedent — the arithmetic a
/// bug would hide in does not need the plumbing around it).
fn next_blend_backoff(prev: Option<Duration>) -> Duration {
prev.map_or(BLEND_RETRY_MIN, |b| (b * 2).min(BLEND_RETRY_MAX))
}
/// The composite-regen change key for an overlay: what a blend would DRAW — `(serial, x, y)` for a
/// visible pointer, `None` when nothing would be drawn. ONE definition, used by both the regen test
/// and the blend itself, because the two drifting apart is precisely the bug shape here: a key that
/// says "changed" while the drawn frame is identical re-encodes for nothing, and a key that says
/// "unchanged" while the pointer moved freezes it on screen.
fn blend_key_of(ov: Option<&pf_frame::CursorOverlay>) -> Option<(u64, i32, i32)> {
ov.filter(|o| o.visible).map(|o| (o.serial, o.x, o.y))
}
/// A composite-blend failure and its pending retry ([`IddPushCapturer::blend_fail`]).
struct BlendFail {
/// No blend is attempted before this instant.
retry_at: Instant,
/// The delay that produced `retry_at`; doubles per consecutive failure up to
/// [`BLEND_RETRY_MAX`].
backoff: Duration,
/// Consecutive failures without an intervening success — logged, so a session that is
/// permanently pointer-less is distinguishable from one that hiccupped once.
consecutive: u32,
}
impl<'a> KeyedMutexGuard<'a> {
/// Acquire `mutex` at `key`, waiting up to `timeout_ms`. `None` if the acquire times out / errors
/// (the caller skips the frame), so the guard is only ever held when the lock is genuinely held.
@@ -385,13 +426,26 @@ pub struct IddPushCapturer {
/// to a visible pointer is compositing here. Pins `composite_cursor` on — nothing may turn
/// it off (there is no channel to hand the pointer to).
composite_forced: bool,
/// The cursor-quad blend pass (lazy; per capture device). `None` after a build failure —
/// composite mode then degrades to pointer-less frames (warned once).
/// The cursor-quad blend pass (lazy; per capture device). `None` before the first blend and
/// after a failure dropped it; rebuilt on the next attempt that is not suppressed.
cursor_blend: Option<cursor_blend::CursorBlendPass>,
cursor_blend_failed: bool,
/// Composite-blend failure state. `None` = healthy. A failure used to be TERMINAL — one warn,
/// a sticky flag, and the session then streamed a pointer-less desktop for its whole life —
/// but the causes that actually occur (device loss, a transient allocation failure on the
/// frame-sized scratch) heal, and the pointer is the one thing a capture-model session cannot
/// do without. So a failure now only suppresses the blend until `retry_at`, doubling from
/// [`BLEND_RETRY_MIN`] to [`BLEND_RETRY_MAX`] while failures continue, and the first success
/// clears it.
blend_fail: Option<BlendFail>,
/// Sticky: [`Self::live_cursor`] has fallen back to the driver's shm section. The two sources
/// keep independent serial namespaces, so once crossed we never go back (see there).
cursor_shm_latched: bool,
/// Poller heartbeat watch: the last sampled publish count and when it last ADVANCED. A poller
/// that is `alive()` but wedged stops advancing it while never exiting — invisible before.
cursor_poll_watch: (u64, Instant),
/// Whether the wedged-poller warning has already been emitted for the CURRENT stall (cleared
/// when it resumes), so a permanently wedged poller warns once rather than every tick.
cursor_poll_stalled: bool,
/// The frame-sized blend scratch (slot copy + cursor quad): texture + SRV + (w, h, fmt)
/// it was built for — rebuilt when the ring geometry changes.
blend_scratch: Option<(
@@ -401,10 +455,12 @@ pub struct IddPushCapturer {
u32,
DXGI_FORMAT,
)>,
/// The (serial, x, y, visible) of the LAST blended pointer — the composite-regen change
/// key: pointer-only motion produces no driver publish (the declared hardware cursor
/// doesn't dirty frames), so `try_consume` regenerates from the last slot when this moves.
last_blend_key: Option<(u64, i32, i32, bool)>,
/// What the LAST blend actually DREW — the composite-regen change key: pointer-only motion
/// produces no driver publish (the declared hardware cursor doesn't dirty frames), so
/// `try_consume` regenerates from the last slot when this changes. `None` = the frame carries
/// no pointer (hidden or no shape yet), which is why a HIDDEN pointer's position is not part
/// of the key — see [`Self::cursor_blend_key`].
last_blend_key: Option<(u64, i32, i32)>,
/// The ring slot of the last FRESH publish — the regen source.
last_slot: Option<usize>,
/// The target's SDR-white scale (vs 80 nits) for HDR cursor compositing — refreshed on
@@ -1211,10 +1267,17 @@ impl IddPushCapturer {
/// poller meant pointer-less frames, not a degraded pointer.
fn live_cursor(&mut self) -> Option<pf_frame::CursorOverlay> {
if !self.cursor_shm_latched {
if let Some(p) = &self.cursor_poll {
if p.alive() {
return p.read();
}
// Sample the heartbeat and the snapshot together, then drop the borrow so the watch
// can take `&mut self`. `alive()` is liveness only — `watch_cursor_publishes` is what
// tells a working poller apart from a wedged one.
let sampled = self
.cursor_poll
.as_ref()
.filter(|p| p.alive())
.map(|p| (p.publishes(), p.read()));
if let Some((n, overlay)) = sampled {
self.watch_cursor_publishes(n);
return overlay;
}
// The poller is gone (or never started) and we are about to read the shm — latch, so a
// poller that somehow reports alive again cannot re-cross the serial namespaces.
@@ -1255,17 +1318,91 @@ impl IddPushCapturer {
);
}
/// The (serial, x, y, visible) of the CURRENT live cursor — the composite-regen change key.
/// `None` while no source has a shape yet.
fn cursor_blend_key(&mut self) -> Option<(u64, i32, i32, bool)> {
self.live_cursor().map(|o| (o.serial, o.x, o.y, o.visible))
/// Watch the GDI poller's heartbeat and log the transitions. The poller is the ONLY
/// full-fidelity shape source (the driver's query is alpha-only — `cursor_poll.rs`), so a
/// poller that is alive but no longer publishing freezes the pointer in every frame at its
/// last sampled shape and position. That state used to be completely silent: `alive()` stays
/// true, the slot keeps returning its last snapshot, and nothing in the log distinguishes it
/// from a genuinely motionless pointer.
fn watch_cursor_publishes(&mut self, n: u64) {
let (last, since) = self.cursor_poll_watch;
if n != last {
self.cursor_poll_watch = (n, Instant::now());
if self.cursor_poll_stalled {
self.cursor_poll_stalled = false;
tracing::info!(
target_id = self.target_id,
"cursor poller resumed publishing — the pointer tracks again"
);
}
} else if !self.cursor_poll_stalled && since.elapsed() >= POLLER_STALL {
self.cursor_poll_stalled = true;
tracing::warn!(
target_id = self.target_id,
stalled_ms = since.elapsed().as_millis() as u64,
"cursor poller is ALIVE but has stopped publishing — the pointer is frozen at its \
last sampled shape/position (input-desktop reads failing every tick?)"
);
}
}
/// Is the composite blend currently suppressed by a failure's backoff?
fn blend_suppressed(&self) -> bool {
self.blend_fail
.as_ref()
.is_some_and(|f| Instant::now() < f.retry_at)
}
/// Record a composite-blend failure and arm the next retry (see [`BlendFail`]). Logs EVERY
/// escalation rather than only the first — a pointer-less capture-model session is a
/// user-visible fault, and the old warn-once left a permanently broken one indistinguishable
/// in the log from a single transient hiccup at startup.
fn note_blend_failure(&mut self, why: &str) {
let backoff = next_blend_backoff(self.blend_fail.as_ref().map(|f| f.backoff));
let consecutive = self.blend_fail.as_ref().map_or(1, |f| f.consecutive + 1);
self.blend_fail = Some(BlendFail {
retry_at: Instant::now() + backoff,
backoff,
consecutive,
});
tracing::warn!(
consecutive,
retry_in_ms = backoff.as_millis() as u64,
"cursor composite: {why} — frames stay pointer-less until the retry succeeds"
);
}
/// A blend succeeded: retire any failure record so the next one starts at the short backoff.
fn note_blend_success(&mut self) {
if let Some(f) = self.blend_fail.take() {
tracing::info!(
after_consecutive_failures = f.consecutive,
"cursor composite: blend recovered — the pointer is back in frames"
);
}
}
/// What a blend would DRAW this tick — `(serial, x, y)` for a visible pointer, `None` for a
/// hidden or not-yet-known one. Keyed on the drawn RESULT rather than on raw cursor state so
/// that a HIDDEN pointer moving — routine, because that is exactly what a game that grabbed
/// the pointer does — cannot force a frame regeneration on an otherwise idle desktop. The
/// visible⇄hidden transitions still change the key (`Some`⇄`None`), so the frame that must
/// gain or lose the pointer is still regenerated.
fn cursor_blend_key(&mut self) -> Option<(u64, i32, i32)> {
blend_key_of(self.live_cursor().as_ref())
}
/// Composite the pointer for this convert: ensure the frame-sized blend scratch, copy the
/// slot into it, and alpha-blend the GDI poller's shape at its polled position. Returns the
/// scratch (texture + SRV) the conversion should read INSTEAD of the slot; `None` degrades
/// to the pointer-less slot (scratch/pass creation failed — warned once). A hidden pointer
/// blends nothing (the plain copy is the correct frame).
/// to the pointer-less slot, which is the correct frame whenever nothing would be drawn.
///
/// **There is NO scratch and NO copy when the pointer is hidden or unknown.** The full-frame
/// `CopyResource` below is the single largest cost of the composite model — a 4K FP16 ring
/// slot is 66 MB, so at 120 fps an unconditional copy is ~8 GB/s of write bandwidth — and it
/// buys nothing when the blend that follows draws nothing. A game that grabbed the pointer
/// hides it, so this early-out is what makes the capture model free in the state it spends
/// most of its life in.
///
/// # Safety
/// D3D11 calls on the owning capture/encode thread's device + immediate context, called
@@ -1274,6 +1411,18 @@ impl IddPushCapturer {
&mut self,
slot_tex: &ID3D11Texture2D,
) -> Option<(ID3D11Texture2D, ID3D11ShaderResourceView)> {
// Resolve WHAT WOULD BE DRAWN first, and record it as the applied key even when that is
// "nothing" — `try_consume`'s regen test compares against this, so an early-out must still
// leave the key describing the frame we are about to emit. Through `live_cursor`, so a
// dead poller degrades to the shm section here too.
let overlay = self.live_cursor();
self.last_blend_key = blend_key_of(overlay.as_ref());
let ov = overlay.filter(|o| o.visible)?;
// Blending is suppressed while a recent failure's backoff runs — skip the scratch and the
// copy too, not just the draw: with nothing to draw onto it, the copy is pure waste.
if self.blend_suppressed() {
return None;
}
// SAFETY: per the contract above, D3D11 calls on the owning thread's device + immediate
// context while the slot's keyed mutex is held. `CreateTexture2D`/`CreateShaderResourceView`
// take a fully-initialized stack descriptor plus live out-params and are `.ok()`-checked before
@@ -1325,13 +1474,7 @@ impl IddPushCapturer {
self.blend_scratch = Some((t, v, self.width, self.height, fmt));
}
None => {
if !self.cursor_blend_failed {
self.cursor_blend_failed = true;
tracing::warn!(
"cursor blend scratch creation failed — capture-model frames stay \
pointer-less this session"
);
}
self.note_blend_failure("scratch creation failed");
return None;
}
}
@@ -1339,38 +1482,33 @@ impl IddPushCapturer {
let (tex, srv, ..) = self.blend_scratch.as_ref().expect("just ensured");
let (tex, srv) = (tex.clone(), srv.clone());
self.context.CopyResource(&tex, slot_tex);
// Blend the pointer (visible shapes only; hidden = the copy alone is the frame).
// Through `live_cursor`, so a dead poller degrades to the shm section HERE too — this
// is the path that actually draws the pointer in the composite model, and the one that
// used to read the poller unconditionally.
let overlay = self.live_cursor();
self.last_blend_key = overlay.as_ref().map(|o| (o.serial, o.x, o.y, o.visible));
if let Some(ov) = overlay.filter(|o| o.visible) {
if self.cursor_blend.is_none() && !self.cursor_blend_failed {
match cursor_blend::CursorBlendPass::new(&self.device) {
Ok(p) => self.cursor_blend = Some(p),
Err(e) => {
self.cursor_blend_failed = true;
tracing::warn!(
"cursor blend pass build failed — capture-model frames stay \
pointer-less this session: {e:#}"
);
}
// Draw `ov` — resolved and keyed at the top, where a hidden pointer already took the
// early-out, so reaching here means there IS something to blend.
if self.cursor_blend.is_none() {
match cursor_blend::CursorBlendPass::new(&self.device) {
Ok(p) => self.cursor_blend = Some(p),
Err(e) => {
self.note_blend_failure(&format!("blend pass build failed: {e:#}"));
}
}
if let Some(pass) = self.cursor_blend.as_mut() {
// FP16 ring = scRGB linear composition (HDR): linearize the sRGB shape and
// scale it to the target's SDR white so it matches the desktop around it.
let scale = if self.display_hdr {
self.sdr_white_scale
} else {
0.0
};
if let Err(e) = pass.blend(&self.device, &self.context, &tex, &ov, scale) {
if !self.cursor_blend_failed {
self.cursor_blend_failed = true;
tracing::warn!("cursor blend draw failed — pointer-less frames: {e:#}");
}
}
if let Some(pass) = self.cursor_blend.as_mut() {
// FP16 ring = scRGB linear composition (HDR): linearize the sRGB shape and
// scale it to the target's SDR white so it matches the desktop around it.
let scale = if self.display_hdr {
self.sdr_white_scale
} else {
0.0
};
match pass.blend(&self.device, &self.context, &tex, &ov, scale) {
// One good draw retires the whole failure record: whatever broke has healed,
// and the next failure should get the SHORT retry, not the escalated one.
Ok(()) => self.note_blend_success(),
Err(e) => {
// Drop the pass so the block above rebuilds it: a device-loss failure is
// transient, but a pass built against the lost device never succeeds again.
self.cursor_blend = None;
self.note_blend_failure(&format!("blend draw failed: {e:#}"));
}
}
}
@@ -2075,6 +2213,84 @@ mod tests {
use super::stall::Stall;
use super::*;
/// A `CursorOverlay` at `(x, y)` with `serial`, visible or not. `rgba` is never read by the
/// key/backoff logic under test, so a 1×1 pixel keeps the fixtures honest about that.
fn overlay(serial: u64, x: i32, y: i32, visible: bool) -> pf_frame::CursorOverlay {
pf_frame::CursorOverlay {
x,
y,
w: 1,
h: 1,
rgba: std::sync::Arc::new(vec![0, 0, 0, 0]),
serial,
hot_x: 0,
hot_y: 0,
visible,
}
}
/// The regen key is what would be DRAWN, so a hidden pointer keys to `None` no matter where it
/// is. This is the whole point: a game that grabbed the pointer moves it constantly, and each
/// of those moves used to re-encode the last slot for a frame that is pixel-identical.
#[test]
fn a_hidden_pointer_has_no_blend_key_wherever_it_moves() {
assert_eq!(blend_key_of(None), None, "no overlay ⇒ nothing drawn");
assert_eq!(
blend_key_of(Some(&overlay(7, 10, 10, false))),
None,
"hidden ⇒ nothing drawn"
);
assert_eq!(
blend_key_of(Some(&overlay(7, 999, 999, false))),
blend_key_of(Some(&overlay(7, 10, 10, false))),
"a hidden pointer moving must NOT look like a change"
);
}
/// …but every transition that alters the drawn frame still changes the key, or the pointer
/// would freeze on screen (the failure mode opposite to the one above).
#[test]
fn every_visible_change_moves_the_blend_key() {
let shown = blend_key_of(Some(&overlay(7, 10, 10, true)));
assert_eq!(shown, Some((7, 10, 10)));
assert_ne!(
shown,
blend_key_of(Some(&overlay(7, 11, 10, true))),
"a visible pointer moving is a change"
);
assert_ne!(
shown,
blend_key_of(Some(&overlay(8, 10, 10, true))),
"a new shape at the same spot is a change"
);
assert_ne!(
shown,
blend_key_of(Some(&overlay(7, 10, 10, false))),
"visible → hidden must regenerate the frame that loses the pointer"
);
}
/// The retry escalates and then holds at the ceiling — it must never grow without bound (the
/// point of a ceiling is that a device which comes back is picked up within it).
#[test]
fn the_blend_retry_backoff_doubles_then_caps() {
let first = next_blend_backoff(None);
assert_eq!(first, BLEND_RETRY_MIN, "the first failure retries quickly");
assert_eq!(next_blend_backoff(Some(first)), first * 2, "then doubles");
// Walk it well past the cap and assert it PARKS there rather than overshooting.
let mut b = first;
for _ in 0..32 {
b = next_blend_backoff(Some(b));
}
assert_eq!(b, BLEND_RETRY_MAX, "escalation parks at the ceiling");
assert_eq!(
next_blend_backoff(Some(BLEND_RETRY_MAX)),
BLEND_RETRY_MAX,
"and stays there"
);
}
/// W14: the mint must stay inside the publish token's 24-bit generation field, and must skip 0.
///
/// `IDD_GENERATION` is a full `u32` while `FrameToken` carries 24 bits and `unpack` MASKS what it
@@ -68,6 +68,11 @@ pub(super) struct CursorPoller {
/// while the secure desktop needs the software-cursor path to render (see
/// `IddPushCapturer::poll_secure_desktop`).
secure: Arc<AtomicBool>,
/// Monotonic count of published snapshots — the poller's HEARTBEAT. It advances once per
/// successful poll (a failed `GetCursorInfo` `continue`s before the publish), so a thread that
/// is wedged on an input desktop it can no longer read stops advancing this while never
/// exiting. [`Self::alive`] cannot see that state: it only asks whether the thread finished.
ticks: Arc<AtomicU64>,
thread: Option<std::thread::JoinHandle<()>>,
}
@@ -106,10 +111,12 @@ impl CursorPoller {
let slot: Arc<Mutex<Option<pf_frame::CursorOverlay>>> = Arc::new(Mutex::new(None));
let stop = Arc::new(AtomicBool::new(false));
let secure = Arc::new(AtomicBool::new(false));
let (slot_t, stop_t, secure_t) = (slot.clone(), stop.clone(), secure.clone());
let ticks = Arc::new(AtomicU64::new(0));
let (slot_t, stop_t, secure_t, ticks_t) =
(slot.clone(), stop.clone(), secure.clone(), ticks.clone());
let thread = std::thread::Builder::new()
.name("pf-cursor-poll".into())
.spawn(move || run(target_id, rect, &slot_t, &stop_t, &secure_t))
.spawn(move || run(target_id, rect, &slot_t, &stop_t, &secure_t, &ticks_t))
.ok();
if thread.is_none() {
tracing::warn!("cursor poller thread spawn failed — cursor falls back to driver shm");
@@ -118,6 +125,7 @@ impl CursorPoller {
slot,
stop,
secure,
ticks,
thread,
}
}
@@ -133,7 +141,14 @@ impl CursorPoller {
self.secure.load(Ordering::Relaxed)
}
/// The heartbeat count (see [`Self::ticks`]). Compared against its own previous value by the
/// capturer — the ABSOLUTE value means nothing, only whether it is still moving.
pub(super) fn publishes(&self) -> u64 {
self.ticks.load(Ordering::Relaxed)
}
/// Whether the worker thread is (still) alive — `false` degrades the capturer to the shm read.
/// Note this is liveness, NOT health: see [`Self::publishes`].
pub(super) fn alive(&self) -> bool {
self.thread.as_ref().is_some_and(|t| !t.is_finished())
}
@@ -155,6 +170,7 @@ fn run(
slot: &Mutex<Option<pf_frame::CursorOverlay>>,
stop: &AtomicBool,
secure: &AtomicBool,
ticks: &AtomicU64,
) {
// Physical-pixel coordinates on this thread regardless of the process's DPI awareness:
// `rect` comes from CCD (always physical), and a DPI-virtualized `GetCursorInfo` position
@@ -306,6 +322,8 @@ fn run(
}
});
*slot.lock().unwrap_or_else(|p| p.into_inner()) = overlay;
// Heartbeat AFTER the publish, so it counts snapshots the capturer can actually read.
ticks.fetch_add(1, Ordering::Relaxed);
}
}
@@ -656,7 +656,9 @@ impl IddPushCapturer {
composite_cursor: composite_forced,
composite_forced,
cursor_blend: None,
cursor_blend_failed: false,
blend_fail: None,
cursor_poll_watch: (0, std::time::Instant::now()),
cursor_poll_stalled: false,
cursor_shm_latched: false,
blend_scratch: None,
last_blend_key: None,
+6 -6
View File
@@ -1174,12 +1174,12 @@ pub struct Settings {
/// mirrors the Apple client's "Show game library" toggle, default off.
pub library_enabled: bool,
/// Which colour family the gamepad UI's living backdrop drifts through — the shared
/// `ui_palette` key (`"violet"` = the brand default, then `tide`/`forest`/`ember`/
/// `rose`/`graphite`; see `pf-console-ui`'s palette table, and the Apple/Android
/// clients' twins). Presentation only: nothing about a stream depends on it, which is
/// why it is a device preference and never part of a settings profile. An unknown
/// name reads as the default rather than erroring — a newer client may have shipped a
/// palette this binary doesn't know.
/// `ui_palette` key (`"violet"` = the brand default, then `oled`/`nebula`/`abyss`/
/// `ember`/`moss`/`graphite`, then the six pale fields; see `pf-console-ui`'s palette
/// table, and the Apple/Android clients' twins). Presentation only: nothing about a
/// stream depends on it, which is why it is a device preference and never part of a
/// settings profile. An unknown name reads as the default rather than erroring — a
/// newer client may have shipped a palette this binary doesn't know.
#[serde(default = "default_ui_palette")]
pub ui_palette: String,
/// Send Wake-on-LAN before connecting to a saved host and wait for it to boot (the
+50 -4
View File
@@ -246,17 +246,34 @@ const CELL_RAMP: [f64; 16] = [
-0.10, 0.08, -0.06, 0.12,
];
/// The twelve shipped palettes: the brand default, five more dark fields, then six pale ones.
/// The thirteen shipped palettes: the brand default, six more dark fields, then six pale ones.
/// Cycling order runs dark → light, so stepping the row walks the whole range in one direction.
/// Adding one here adds it to every console settings screen; the Apple and Android tables must
/// gain the same entry to keep the `ui_palette` key portable.
#[rustfmt::skip]
pub const PALETTES: [Palette; 12] = [
pub const PALETTES: [Palette; 13] = [
// --- dark fields (white ink) ---
Palette {
id: "violet", name: "Violet", stops: None,
ground: (0.075, 0.060, 0.160), accent: (0.525, 0.471, 0.961), light: false,
},
Palette {
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no glow,
// no power. The ramp's first two stops are literally (0,0,0), so the whole shaded half
// of the field is genuinely off rather than "very dark grey", and the ground is pure
// black too: the calm mix on the form screens lifts toward nothing, so settings and
// pairing sit on an unlit panel. What is left is a faint indigo→violet ember in the
// bright corner, dim enough to stay under a tenth of the other dark fields' mean
// luminance while keeping the backdrop a field with somewhere to go rather than a
// dead rectangle. The accent stays the brand violet — focus has to be findable on
// black.
id: "oled", name: "OLED",
stops: Some(&[
(0.000, 0.000, 0.000), (0.000, 0.000, 0.000), (0.010, 0.020, 0.100),
(0.045, 0.016, 0.115), (0.120, 0.024, 0.130),
]),
ground: (0.0, 0.0, 0.0), accent: (0.525, 0.471, 0.961), light: false,
},
Palette {
// Deep indigo climbing through violet into a hot magenta.
id: "nebula", name: "Nebula",
@@ -857,7 +874,7 @@ mod tests {
assert_eq!(
ids,
[
"violet", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset",
"violet", "oled", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset",
"bloom", "dawn", "mint", "opal",
]
);
@@ -867,7 +884,36 @@ mod tests {
.position(|p| p.light)
.expect("some are light");
assert!(PALETTES[first_light..].iter().all(|p| p.light));
assert_eq!(first_light, 6);
assert_eq!(first_light, 7);
}
/// OLED is the one palette whose selling point is measurable: it has to be genuinely
/// black, not merely the darkest of the dark fields. Pure black corners, a mean well
/// under every other field's, and a ground that lifts to nothing on the form screens.
#[test]
fn oled_is_actually_black() {
let luma = |c: (f64, f64, f64)| 0.2126 * c.0 + 0.7152 * c.1 + 0.0722 * c.2;
let oled = palette("oled");
assert_eq!(
oled.ground,
(0.0, 0.0, 0.0),
"the calm lift must be nothing"
);
let cells = oled.mesh_colors();
assert!(
cells.iter().filter(|c| luma(**c) == 0.0).count() >= 3,
"the shaded corner has to be switched off, not dimmed"
);
let mean = cells.iter().map(|c| luma(*c)).sum::<f64>() / 16.0;
let darkest_other = PALETTES
.iter()
.filter(|p| p.id != "oled")
.map(|p| p.mesh_colors().iter().map(|c| luma(*c)).sum::<f64>() / 16.0)
.fold(f64::MAX, f64::min);
assert!(
mean < darkest_other / 2.0,
"oled means {mean:.3}, only half a stop under {darkest_other:.3}"
);
}
/// Every colour a palette produces stays in gamut, and a pale palette really is pale —
+158 -44
View File
@@ -258,11 +258,17 @@ impl SettingsScreen {
}
}
/// The rows of the CURRENT tab. Profiles is built from the catalog: one row per
/// profile, or the explainer placeholder while there are none.
fn row_ids(&self) -> Vec<RowId> {
/// The rows of the CURRENT tab, minus any whose setting has nothing to act on (see
/// [`row_applies`]). Profiles is built from the catalog: one row per profile, or the
/// explainer placeholder while there are none.
fn row_ids(&self, ctx: &Ctx) -> Vec<RowId> {
if self.tab != PROFILES_TAB {
return TABS[self.tab].1.to_vec();
return TABS[self.tab]
.1
.iter()
.copied()
.filter(|id| row_applies(*id, ctx.settings))
.collect();
}
if self.profiles.is_empty() {
vec![RowId::NoProfiles]
@@ -271,6 +277,16 @@ impl SettingsScreen {
}
}
/// Pull the cursor back onto the list. Every tab but Profiles used to be a fixed length,
/// so this only mattered on entry ([`show_tab`]); the smoothness buffer's row now comes
/// and goes, and another writer (a desktop shell, a session's match-window persist) can
/// take it away between frames while this screen is open.
fn clamp_cursor(&mut self, len: usize) {
if self.list.cursor >= len {
self.list.jump_to(len.saturating_sub(1));
}
}
#[cfg(test)]
pub(crate) fn tab_for_test(&self) -> usize {
self.tab
@@ -278,21 +294,22 @@ impl SettingsScreen {
/// L1/R1 (and Tab/PgUp/PgDn) — move one tab, wrapping (the strip is a ring, like A's
/// value cycle), keeping each tab's own cursor.
fn switch_tab(&mut self, delta: i32) -> Option<MenuPulse> {
fn switch_tab(&mut self, delta: i32, ctx: &Ctx) -> Option<MenuPulse> {
let n = TABS.len() as i32;
self.show_tab((self.tab as i32 + delta).rem_euclid(n) as usize)
self.show_tab((self.tab as i32 + delta).rem_euclid(n) as usize, ctx)
}
/// Show `tab`, parking the cursor the outgoing tab was on. Also the pointer's path in:
/// a press on a pill names a tab outright rather than a direction to step in.
fn show_tab(&mut self, tab: usize) -> Option<MenuPulse> {
fn show_tab(&mut self, tab: usize, ctx: &Ctx) -> Option<MenuPulse> {
if tab >= TABS.len() {
return None;
}
self.tab_cursors[self.tab] = self.list.cursor;
self.tab = tab;
// Clamp the remembered cursor: the Profiles tab's length follows the catalog.
let len = self.row_ids().len();
// Clamp the remembered cursor: the Profiles tab's length follows the catalog, and
// Video's follows whether the smoothness buffer is offered.
let len = self.row_ids(ctx).len();
self.list
.jump_to(self.tab_cursors[self.tab].min(len.saturating_sub(1)));
Some(MenuPulse::Move)
@@ -302,10 +319,11 @@ impl SettingsScreen {
/// there is never meant for a row.
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
if let Some(tab) = self.strip.pointer(p) {
self.show_tab(tab);
self.show_tab(tab, ctx);
return true;
}
let ids = self.row_ids();
let ids = self.row_ids(ctx);
self.clamp_cursor(ids.len());
let (msg, pulse) = self.list.pointer(p, ids.len());
if matches!(msg, ListMsg::None) && pulse.is_none() {
return false;
@@ -325,11 +343,12 @@ impl SettingsScreen {
fx.pop();
return None;
}
MenuEvent::JumpBack => return self.switch_tab(-1),
MenuEvent::JumpForward => return self.switch_tab(1),
MenuEvent::JumpBack => return self.switch_tab(-1, ctx),
MenuEvent::JumpForward => return self.switch_tab(1, ctx),
_ => {}
}
let ids = self.row_ids();
let ids = self.row_ids(ctx);
self.clamp_cursor(ids.len());
let (msg, pulse) = self.list.menu(ev, ids.len());
self.apply_row(msg, pulse, &ids, ctx, fx)
}
@@ -344,8 +363,14 @@ impl SettingsScreen {
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
// A cursor with no row under it can only mean the list shrank between the clamp above
// and here, which nothing does today — but indexing on the assumption would turn that
// into a panic in a shipping console rather than a dropped keypress.
let Some(&focused) = ids.get(self.list.cursor) else {
return pulse;
};
// The Profiles rows navigate instead of editing the settings file.
match ids[self.list.cursor] {
match focused {
RowId::Profile(i) => {
return match msg {
ListMsg::Activate => {
@@ -378,7 +403,7 @@ impl SettingsScreen {
}
match msg {
ListMsg::Adjust(delta) => {
let changed = adjust(ids[self.list.cursor], delta, false, ctx);
let changed = adjust(focused, delta, false, ctx);
if changed {
ctx.settings.save();
Some(MenuPulse::Move)
@@ -388,7 +413,7 @@ impl SettingsScreen {
}
ListMsg::Activate => {
// A cycles forward WRAPPING, so every option is reachable one-handed.
if adjust(ids[self.list.cursor], 1, true, ctx) {
if adjust(focused, 1, true, ctx) {
ctx.settings.save();
}
pulse
@@ -397,8 +422,8 @@ impl SettingsScreen {
}
}
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
let ids = self.row_ids();
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
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")];
hints.extend(match ids.get(self.list.cursor) {
@@ -445,7 +470,8 @@ impl SettingsScreen {
rect.right,
rect.bottom - detail_h as f32,
);
let ids = self.row_ids();
let ids = self.row_ids(ctx);
self.clamp_cursor(ids.len());
let rows: Vec<RowSpec> = ids
.iter()
.map(|id| row_spec(*id, ctx, &self.profiles))
@@ -466,6 +492,24 @@ impl SettingsScreen {
}
}
/// Whether a row is OFFERED at all, as opposed to offered-but-inert.
///
/// The two are a real distinction. Echo cancellation and the pad rows follow a switch the user
/// can see a line or two above them, so dimming them shows the relationship — dropping them
/// would just make settings appear and disappear as the switch flips. The smoothness buffer is
/// different: it is not a sub-setting of a switch, it is a knob on ONE of two intents, and
/// under Lowest latency it names a quantity that doesn't exist. Every other settings surface —
/// the GTK and WinUI shells, the Apple touch/tvOS screens, the Android touch screen — hides it
/// there. This screen was the lone exception because its row list was fixed; it is rebuilt from
/// this filter each frame now, and the row it drops sits directly BELOW the row that drops it,
/// so the cursor is never under anything that moves.
fn row_applies(id: RowId, s: &pf_client_core::trust::Settings) -> bool {
match id {
RowId::SmoothBuffer => s.present_priority == "smooth",
_ => true,
}
}
fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
// The Profiles section: name + how many hosts pin it (counted from the live rows, so
// it reflects what the carousel shows). Read-only here beyond opening the pin screen.
@@ -497,18 +541,17 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
_ => {}
}
let s = &ctx.settings;
// Several rows follow another: echo cancellation only means anything while the mic
// streams, the pad rows only while any controller is forwarded at all, and the
// smoothness buffer only while that intent is chosen. All go dim and inert otherwise
// — the same relationship the desktop shells draw by greying a row out (they hide the
// buffer row entirely; a fixed row list can't, and a row that vanished mid-list would
// move everything under the cursor).
// Two rows follow a switch a line or two above them: echo cancellation only means
// anything while the mic streams, and the pad rows only while any controller is
// forwarded at all. Both go dim and inert otherwise — the same relationship the desktop
// shells draw by greying a row out, and dimming (not dropping) is what shows the
// relationship. The smoothness buffer used to be listed here too; it is dropped from the
// list instead now — see [`row_applies`] for why that one is different.
let enabled = match id {
RowId::EchoCancel => s.mic_enabled,
RowId::Pad | RowId::PadType | RowId::SystemButtons | RowId::GuideGesture => {
s.gamepad_forwarding
}
RowId::SmoothBuffer => s.present_priority == "smooth",
_ => true,
};
let (header, label, value): (Option<&'static str>, &str, String) = match id {
@@ -848,7 +891,10 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
step_option(cur, PRESENT_PRIORITIES.len(), delta, wrap)
.map(|i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string())
}
// Inert unless smoothness is chosen — a boundary thud, matching the dimmed row.
// Under Lowest latency the row isn't offered at all ([`row_applies`]), so this branch
// is only reachable if another writer flipped the intent between the frame that built
// the list and the keypress that lands here — a boundary thud, not a stored value
// nothing will read.
RowId::SmoothBuffer => {
if s.present_priority == "smooth" {
let cur = SMOOTH_BUFFERS
@@ -1093,9 +1139,6 @@ mod tests {
fake_home();
let mut s = SettingsScreen::with_profiles(Vec::new());
rendered(&mut s);
// Row 0 of the leading tab is Resolution, whose first step is Native → Match
// window: one field, one unambiguous effect to assert on.
assert_eq!(s.row_ids()[0], RowId::Resolution);
let first = s.list.row_rect(0).expect("the list drew its rows");
let (mut settings, pads) = ctx_parts();
settings.save(); // seat the fake HOME's file — `apply_row` rebases on it
@@ -1109,6 +1152,9 @@ mod tests {
device_name: "t",
t: 0.0,
};
// Row 0 of the leading tab is Resolution, whose first step is Native → Match
// window: one field, one unambiguous effect to assert on.
assert_eq!(s.row_ids(&ctx)[0], RowId::Resolution);
let mut fx = Outbox::default();
assert!(!ctx.settings.match_window);
assert!(s.pointer(press(first), &mut ctx, &mut fx));
@@ -1232,13 +1278,12 @@ mod tests {
assert!(ctx.settings.echo_cancel);
}
/// The smoothness buffer follows the presentation intent, exactly as echo cancellation
/// follows the mic: dimmed and inert under Lowest latency (where holding frames means
/// nothing), live under Smoothness. The desktop shells hide the row instead; a fixed
/// row list dims it, because a row vanishing mid-list would shift everything under the
/// cursor.
/// The smoothness buffer is OFFERED only under Smoothness — under Lowest latency it names
/// a quantity that doesn't exist, so the row is gone from the Video tab rather than sitting
/// there dimmed. This is what the GTK and WinUI shells and the Apple/Android screens have
/// always done; this screen was the exception until its row list stopped being fixed.
#[test]
fn smoothness_buffer_follows_the_intent() {
fn smoothness_buffer_is_offered_only_under_smoothness() {
let (mut settings, pads) = ctx_parts();
assert_eq!(settings.present_priority, "latency", "the shipped default");
let library = crate::library::LibraryShared::default();
@@ -1251,24 +1296,93 @@ mod tests {
device_name: "t",
t: 0.0,
};
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
let mut s = SettingsScreen::with_profiles(Vec::new());
s.tab = TABS
.iter()
.position(|(name, _)| *name == "Video")
.expect("the Video tab");
let video = s.row_ids(&ctx);
assert!(
!video.contains(&RowId::SmoothBuffer),
"latency hides the buffer row: {video:?}"
);
assert!(video.contains(&RowId::PresentPriority), "the intent stays");
// Even reached out of band it writes nothing — the list it came from is a frame old.
assert!(
!adjust(RowId::SmoothBuffer, 1, false, &mut ctx),
"latency intent = thud"
);
assert_eq!(ctx.settings.smooth_buffer, 0, "and nothing was written");
// Stepping the intent to Smoothness brings the buffer row to life.
// Stepping the intent to Smoothness brings the row into the list, directly under it.
assert!(adjust(RowId::PresentPriority, 1, false, &mut ctx));
assert_eq!(ctx.settings.present_priority, "smooth");
assert!(row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
let video = s.row_ids(&ctx);
let intent = video
.iter()
.position(|id| *id == RowId::PresentPriority)
.expect("the intent row");
assert_eq!(
video.get(intent + 1),
Some(&RowId::SmoothBuffer),
"the row that comes and goes sits BELOW the row that decides it, so the cursor \
never has anything move out from under it"
);
assert!(adjust(RowId::SmoothBuffer, 1, false, &mut ctx));
assert_eq!(ctx.settings.smooth_buffer, 1);
// The intent wraps back and the row goes inert again.
// The intent wraps back and the row leaves again — with the cursor parked on the
// intent row, which is where a user who just stepped it necessarily is.
s.list.cursor = intent;
assert!(adjust(RowId::PresentPriority, -1, false, &mut ctx));
assert_eq!(ctx.settings.present_priority, "latency");
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
let video = s.row_ids(&ctx);
assert!(!video.contains(&RowId::SmoothBuffer));
assert_eq!(
video.get(s.list.cursor),
Some(&RowId::PresentPriority),
"the cursor is still on the row the user was stepping"
);
}
/// A cursor parked past the end of a list that shrank underneath it is pulled back rather
/// than indexed with — the console must not panic because another writer changed the
/// presentation intent while its settings screen was open.
#[test]
fn a_shrinking_list_pulls_the_cursor_back() {
// `apply_row` rebases on the FILE before acting, so this has to be seated — and
// seated with the SHRUNKEN list's intent, which is the state being tested.
fake_home();
let (mut settings, pads) = ctx_parts();
settings.present_priority = "latency".into();
settings.save();
settings.present_priority = "smooth".into();
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
let mut s = SettingsScreen::with_profiles(Vec::new());
s.tab = TABS
.iter()
.position(|(name, _)| *name == "Video")
.expect("the Video tab");
// Park on the last row while the buffer row is still there…
s.list.cursor = s.row_ids(&ctx).len() - 1;
let parked = s.list.cursor;
// …then take it away behind the screen's back, as a desktop shell would.
ctx.settings.present_priority = "latency".into();
let mut fx = Outbox::default();
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert!(pulse.is_some(), "the press was routed, not dropped");
assert!(s.list.cursor < parked, "the cursor came back onto the list");
assert!(fx.nav.is_none());
}
#[test]
@@ -1392,7 +1506,7 @@ mod tests {
("p2".into(), "Game".into()),
]);
s.tab = PROFILES_TAB;
let ids = s.row_ids();
let ids = s.row_ids(&ctx);
assert_eq!(ids, vec![RowId::Profile(0), RowId::Profile(1)]);
let spec = row_spec(RowId::Profile(0), &ctx, &s.profiles);
@@ -1438,7 +1552,7 @@ mod tests {
};
let mut s = SettingsScreen::with_profiles(Vec::new());
s.tab = PROFILES_TAB;
let ids = s.row_ids();
let ids = s.row_ids(&ctx);
assert_eq!(ids, vec![RowId::NoProfiles]);
let spec = row_spec(RowId::NoProfiles, &ctx, &s.profiles);
assert!(!spec.enabled);
+1 -1
View File
@@ -329,7 +329,7 @@ fn dump_console_screens() {
for _ in 0..5 {
s.handle_menu(MenuEvent::JumpForward);
}
for id in ["violet", "ember", "abyss", "holo", "sunset", "mint"] {
for id in ["violet", "oled", "ember", "abyss", "holo", "sunset", "mint"] {
s.settings.ui_palette = id.to_string();
dump(&mut s, 40, 8, &format!("03-settings-{id}"), true);
}
@@ -421,6 +421,34 @@ pub fn hw_cursor_capable() -> bool {
m.driver_proto.load(Ordering::Relaxed) >= 5
}
/// Is NO session currently streaming to a virtual display?
///
/// The safety question for anything that tears the adapter down — notably
/// [`crate::driver::clean_cursor_for_next_session`], whose `pnputil /restart-device` takes every
/// monitor on the adapter with it. Only [`SlotState::Active`] counts: that is a session with live
/// references, and destroying its monitor mid-stream is the cross-session damage worth refusing.
///
/// `Lingering`/`Pinned` slots deliberately do NOT count. They are keep-alive monitors with no
/// session attached, and a reconnect **already** preempts and recreates them — "a reused IddCx
/// swap-chain is dead" (see [`SlotState::Pinned`]) — so a device restart destroys nothing the
/// reconnect was not going to destroy anyway. Counting them was too conservative to be useful: the
/// case this gate exists for is exactly *disconnect from a desktop session, reconnect in capture
/// mode*, and the disconnected session's monitor is lingering at precisely that moment, so the
/// clean-up could never fire when it was most wanted (observed on `.173`, 2026-08-08).
pub fn no_active_sessions() -> bool {
match VDM.get() {
// Before the first backend open there is nothing to protect.
None => true,
Some(m) => !m
.state
.lock()
.unwrap_or_else(|e| e.into_inner())
.slots
.values()
.any(|s| matches!(s, SlotState::Active { .. })),
}
}
pub fn control_device_handle() -> Option<HANDLE> {
VDM.get().and_then(VirtualDisplayManager::device_handle)
}
@@ -158,6 +158,226 @@ enum AdapterCycle {
Refused(String),
}
/// Restart the pf-vdisplay device to CLEAR a sticky IddCx hardware-cursor declare, so sessions that
/// do not want the host to own the pointer get the OS's own cursor compositing back (full fidelity,
/// zero host cost — no GDI poller, no per-frame blend, true XOR instead of our outline
/// approximation).
///
/// **Why this exists.** A hardware-cursor declare is irrevocable and ADAPTER-WIDE
/// (`pf-driver-proto` v6 note): once any desktop-mode session declares, DWM stops compositing the
/// pointer into EVERY later frame on that adapter, and every subsequent session — including
/// capture-latched ones that never asked for a cursor channel — has to self-composite. The state
/// lives in the driver's `DECLARED_TARGETS`, whose scope is the WUDFHost process, so recycling that
/// process clears it.
///
/// **Why `/restart-device` and not the [`reload_vdisplay_adapter`] cycle.** Measured on-glass
/// 2026-08-08 (`.173`): `pnputil /restart-device` returned in **0.07 s** with a NEW WUDFHost pid,
/// against ~6 s of sleeps for `Disable`+`Enable` — and, being designed for a device that is in use,
/// it does not hit the refusal that doc calls "the expected case here". It also repaired an adapter
/// found in `CM_PROB_FAILED_POST_START` (Code 43) in the same call.
///
/// ⚠⚠ **This is a ONCE-PER-BOOT lever, not a cheap one.** Measured on `.173` 2026-08-08: the first
/// `/restart-device` after a cold boot succeeds in 0.07 s; every later one in the same boot fails
/// with *"Das System muss neu gestartet werden, damit Konfigurationsvorgänge abgeschlossen
/// werden"*, and repeated attempts additionally push the devnode into `restart pending`. So this
/// can clean the adapter at host start-up and nowhere else — anything wanting to un-declare
/// mid-boot (e.g. giving a capture session back the lossless pointer after a desktop session) needs
/// a different mechanism to recycle the driver's WUDFHost process, which is where the declare
/// actually lives.
///
/// ⚠ It tears the adapter down, so it must run only when NO session holds a display — the host
/// start-up path. `PUNKTFUNK_CURSOR_CLEAN_START=0` disables it.
///
/// Returns `true` only when pnputil reported success. Best-effort: a failure just leaves the
/// adapter as it was (sessions then self-composite exactly as before).
/// The driver's WUDFHost pid, from the most recent ADD reply. `0` before any monitor was created.
static LAST_WUDF_PID: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
/// Clear a sticky hardware-cursor declare by recycling the driver's WUDFHost process.
///
/// The declare is irrevocable and adapter-wide, but its scope is the WUDFHost process
/// (`monitor.rs` `DECLARED_TARGETS`) — so killing that process drops it. WUDF respawns the host on
/// the next open, with a fresh adapter object.
///
/// **This is what makes un-declaring possible mid-boot.** `pnputil /restart-device` also works but
/// is a ONCE-PER-BOOT operation (see [`restart_device_for_clean_cursor`]); the start-up clean
/// spends it, leaving nothing for the desktop-session→reconnect case. Measured on `.173`
/// 2026-08-08: pid 3872 → 19932, `adapter_luid` 0x8ed607 → 0x1a8f6ca, `cursor_excluded` true →
/// **false**, next session streamed normally.
///
/// Same precondition as the device restart: no session may hold a display, because every monitor
/// on the adapter dies with the host.
fn recycle_wudfhost() -> bool {
let pid = LAST_WUDF_PID.load(std::sync::atomic::Ordering::Relaxed);
if pid == 0 {
tracing::info!("cursor: no driver host pid known yet — nothing to recycle");
return false;
}
// taskkill rather than OpenProcess/TerminateProcess: the host runs as SYSTEM, so it already has
// the rights, and shelling out keeps this off the unsafe-proof budget for a once-per-session
// maintenance action.
match std::process::Command::new(
std::env::var("SystemRoot")
.map(|r| format!(r"{r}\System32 askkill.exe"))
.unwrap_or_else(|_| "taskkill.exe".to_string()),
)
.args(["/PID", &pid.to_string(), "/F"])
.output()
{
Ok(o) if o.status.success() => {
tracing::info!(
pid,
"cursor: recycled the driver's WUDFHost — the hardware-cursor declare is gone"
);
LAST_WUDF_PID.store(0, std::sync::atomic::Ordering::Relaxed);
true
}
Ok(o) => {
tracing::warn!(
pid,
stderr = %String::from_utf8_lossy(&o.stderr).trim().replace('\n', " "),
"cursor: could not recycle the driver's WUDFHost — this session self-composites"
);
false
}
Err(e) => {
tracing::warn!(pid, error = %e, "cursor: taskkill spawn failed");
false
}
}
}
/// Has this host process DECLARED an IddCx hardware cursor since the adapter was last restarted?
/// Set by the ADD path when a session ASKS for a hardware cursor (the one place every declare
/// passes through); cleared when the declare is dropped. The host's own mirror of the
/// driver's `DECLARED_TARGETS` — cheaper than probing, and it only ever needs to be right about
/// "did WE dirty it", because a declare from an earlier BOOT is handled by the start-up clean.
static CURSOR_DECLARED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Give the NEXT session back the lossless cursor: if an earlier session on this host declared the
/// hardware cursor and this one does not want it, restart the device to clear the sticky declare.
///
/// This is the case the start-up clean cannot reach — **run a desktop-mode session, disconnect,
/// reconnect in capture mode**. Same host process, so the adapter is still dirty from the first
/// session and the capture session would self-composite the pointer for its whole life. Declaring
/// is one-way and adapter-wide (`pf-driver-proto` v6), so the only way back is a device restart —
/// 0.07 s, measured.
///
/// Must be called BEFORE this session creates its display, and only when nothing else holds one:
/// the restart takes every monitor on the adapter with it.
///
/// Returns `true` only when it actually restarted.
pub fn clean_cursor_for_next_session(session_wants_declare: bool) -> bool {
use std::sync::atomic::Ordering;
if session_wants_declare || !CURSOR_DECLARED.load(Ordering::Relaxed) {
return false;
}
// Gated deliberately — a device restart is NOT free. Windows puts the devnode into
// "restart pending" after repeated cycles, and `/restart-device` then refuses with "a system
// restart is pending for this device" until an actual reboot (hit on .173 2026-08-08 after ~6
// restarts in one afternoon, which is also what made the earlier runs look like a wiring bug:
// the call ran, the restart failed, and nothing logged the failure). So restart only when a
// declare is actually outstanding, never speculatively.
let previously_declared = true;
// Refuse only while another session is STREAMING — a keep-alive (lingering/pinned) monitor has
// no session attached and a reconnect recreates it regardless, so restarting the adapter costs
// it nothing. Gating on keep-alive too made this dead code in the one case it exists for: after
// a desktop session disconnects its monitor LINGERS, which is exactly when the next
// capture-mode connect needs the declare gone (observed on .173).
if !super::manager::no_active_sessions() {
tracing::info!(
"cursor: this session wants no hardware cursor and an earlier one declared, but a display is still held (live or keep-alive) — skipping the adapter restart, so the pointer stays host-composited for this session"
);
return false;
}
if recycle_wudfhost() {
// The cached control handle died with the host process. Retire it so the next
// `ensure_device` reopens against the respawned WUDFHost — without this the ADD that
// follows runs on a stale handle and the session comes up with no frames at all.
super::manager::invalidate_cached_device("cursor clean: recycled the driver host");
std::thread::sleep(std::time::Duration::from_millis(1500));
CURSOR_DECLARED.store(false, Ordering::Relaxed);
tracing::info!(
previously_declared,
"cursor: restarted the adapter for this capture-mode session — any hardware-cursor \
declare is gone, so the OS composites the pointer itself (full fidelity, no host \
blend). previously_declared=false only means the host-side hint was unset; the \
restart is idempotent either way"
);
return true;
}
false
}
pub fn restart_device_for_clean_cursor() -> bool {
if std::env::var("PUNKTFUNK_CURSOR_CLEAN_START").is_ok_and(|v| v == "0") {
tracing::info!(
"pf-vdisplay: cursor clean-start disabled (PUNKTFUNK_CURSOR_CLEAN_START=0) — a sticky \
hardware-cursor declare from an earlier boot will keep sessions self-compositing"
);
return false;
}
// `$LASTEXITCODE` is pre-seeded to 1 for the same reason `reload_vdisplay_adapter` does it: if
// pnputil never launches, a stale value must not read as success.
const PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
$ad = Get-PnpDevice -Class Display | Where-Object { $_.FriendlyName -match 'punktfunk Virtual Display' } | Select-Object -First 1; \
if (-not $ad) { Write-Output 'ABSENT'; exit }; \
$pnp = ($env:SystemRoot + '\\System32\\pnputil.exe'); $LASTEXITCODE = 1; \
if (Test-Path $pnp) { $out = (& $pnp /restart-device $ad.InstanceId 2>&1 | Out-String) }; \
if ($LASTEXITCODE -eq 0) { Write-Output 'RESTARTED' } \
else { Write-Output ('FAILED ' + ($out -replace '\\s+', ' ')) }";
let ps = std::env::var("SystemRoot")
.map(|r| format!(r"{r}\System32\WindowsPowerShell\v1.0\powershell.exe"))
.unwrap_or_else(|_| "powershell.exe".to_string());
let out = match std::process::Command::new(&ps)
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
PS,
])
.output()
{
Ok(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(),
Err(e) => {
tracing::warn!(error = %e, "pf-vdisplay: cursor clean-start could not spawn powershell");
return false;
}
};
match out.as_str() {
"RESTARTED" => {
tracing::info!(
"pf-vdisplay: restarted the adapter at start-up — any sticky hardware-cursor \
declare is cleared, so sessions without a cursor channel get the OS's own \
(full-fidelity, zero-cost) pointer compositing until one declares again"
);
true
}
"ABSENT" => false, // driver not installed — nothing to clean, and `open` reports that later
// Keep pnputil's own text. The failure that actually occurs is "a system restart is
// pending for this device" — no retry fixes it, and a bare exit code hid it for three runs.
other => {
tracing::warn!(
outcome = other,
// Two distinct wordings, both meaning "not until you reboot":
// "Für das Gerät steht ein Systemneustart aus" (device restart pending)
// "Das System muss neu gestartet werden, damit …" (config ops need a reboot)
// The second is what you actually hit, and it appears after the FIRST successful
// restart of a boot — see the doc on `restart_device_for_clean_cursor`.
needs_reboot = other.contains("Systemneustart")
|| other.contains("muss neu gestartet werden")
|| other.to_ascii_lowercase().contains("restart is pending")
|| other.to_ascii_lowercase().contains("must be restarted"),
"pf-vdisplay: cursor clean-start did not restart the adapter — sessions without a \
cursor channel will self-composite the pointer if an earlier declare is sticky"
);
false
}
}
}
/// Reload the pf-vdisplay ADAPTER device — the in-process equivalent of `reset-pf-vdisplay.ps1`
/// step 3. A crashed/killed WUDFHost can leave the devnode "started" yet HOSTLESS (PnP Status OK, no
/// WUDFHost process, zero device-interface instances) — a zombie no session can open until the stack
@@ -353,6 +573,12 @@ pub unsafe fn send_cursor_channel(
dev: HANDLE,
req: &control::SetCursorChannelRequest,
) -> Result<()> {
// THE declare point. The driver declares its IddCx hardware cursor when this channel arrives —
// not from the ADD request's `hw_cursor` flag, which is why recording the declare there (and,
// before that, in `capture_virtual_output`) left the flag false and the between-session clean
// silently inert. The log line that names this moment is "cursor channel delivered - driver
// declares the hardware cursor".
CURSOR_DECLARED.store(true, std::sync::atomic::Ordering::Relaxed);
let mut none: [u8; 0] = [];
// SAFETY: per this fn's contract `dev` is the live control handle; `bytes_of(req)` borrows the
// caller's request across this synchronous call; no output buffer.
@@ -679,6 +905,26 @@ impl VdisplayDriver for PfVdisplayDriver {
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
hw_cursor: bool,
) -> Result<AddedMonitor> {
// Give a capture-mode session the LOSSLESS pointer back: if an earlier session declared a
// hardware cursor and this one does not want it, recycle the driver's host process BEFORE
// this monitor is added. The ADD path is the only place guaranteed to see every session
// (the handshake call site this replaced sat in a `match (source, compositor)` arm that is
// not taken on this host, so it never ran).
// ⚠ DISABLED BY DEFAULT — opt in with PUNKTFUNK_CURSOR_RECYCLE=1.
//
// The MECHANISM is proven (recycling the driver host clears the declare: measured pid
// 3872→19932, adapter_luid 0x8ed607→0x1a8f6ca, cursor_excluded true→false, next session
// streamed fine). What is NOT solved is calling it from HERE: `invalidate_cached_device`
// takes the manager `device` mutex, which this ADD path already holds, so the session
// DEADLOCKS — observed on .173, the ADD stops after SET_RENDER_ADAPTER and the client gets
// "no frames received". Its own doc warns about exactly this.
//
// The fix is a call site that runs OUTSIDE the mutex and still on every session's path;
// the handshake site tried before is not reached on this host. Until then this stays off:
// a session that self-composites is the old behaviour, a deadlocked one is a regression.
if !hw_cursor && std::env::var("PUNKTFUNK_CURSOR_RECYCLE").is_ok_and(|v| v == "1") {
clean_cursor_for_next_session(false);
}
let session_id = next_session_id();
// The client display's volume rides into the monitor's EDID CTA HDR block; all-zero =
// unknown → the driver keeps its built-in defaults (also what an un-upgraded driver, which
@@ -824,7 +1070,14 @@ impl VdisplayDriver for PfVdisplayDriver {
tracing::info!(
target_id = reply.target_id,
adapter_luid = %format_args!("{:#x}", luid.LowPart),
wudf_pid = reply.wudf_pid,
wudf_pid = {
// The declare lives in THIS process (monitor.rs `DECLARED_TARGETS`), so remember it:
// recycling it is the only way to un-declare that does not cost the once-per-boot
// device restart. Proven on .173 2026-08-08 — killing it gave a new host pid, a NEW
// adapter luid, and `cursor_excluded=false`, with the next session streaming fine.
LAST_WUDF_PID.store(reply.wudf_pid, std::sync::atomic::Ordering::Relaxed);
reply.wudf_pid
},
cursor_excluded = reply.cursor_excluded != 0,
"pf-vdisplay monitor created {}x{}@{}",
mode.width,
+17
View File
@@ -382,6 +382,23 @@ fn real_main() -> Result<()> {
// driver to a stray second host started while the service sat idle.
#[cfg(target_os = "windows")]
vdisplay::manager::claim_instance_eagerly();
// Clean-cursor start (design/windows-cursor-model-determinism.md §4.3): clear any
// sticky IddCx hardware-cursor declare left on the adapter by an EARLIER boot's
// desktop-mode session. That declare is irrevocable and adapter-wide, so without this
// every capture-latched session on the box self-composites the pointer for the rest of
// the adapter's life — paying a full-frame copy per visible-pointer frame and drawing
// our straight-alpha approximation of an XOR cursor — when the OS would otherwise
// composite it natively, for free, at full fidelity.
//
// It is NOT enough to wait for a reboot: with Fast Startup on (the Windows default) a
// shutdown+power-on is a hiberboot that RESTORES session 0 and its drivers, so the
// declare survives what the operator calls a reboot (measured: Kernel-Boot event id 27
// `0x1`, and `lsass`/`services` keeping their pre-"reboot" start times). Only a cold
// boot or a device restart actually clears it — and the device restart costs 0.07 s.
//
// Runs HERE, before any session holds a display: the restart tears the adapter down.
#[cfg(target_os = "windows")]
vdisplay::driver::restart_device_for_clean_cursor();
// Crash recovery for the experimental `pnp_disable_monitors` axis: re-enable any
// monitor devnodes a previous host disabled for an Exclusive session and never
// restored (crash/kill/power loss) — before any new session touches the topology.
+47 -5
View File
@@ -96,11 +96,12 @@ those hiccups out, at that buffer's worth of added delay. Linux and Windows apps
home; the Apple and Android apps have carried the same setting for a while, and it is stored
under the same name, so a [profile](/docs/profiles-and-links) means the same thing on every device.
**Smoothness buffer** — *default: Automatic (two frames).* Only shown under **Smoothness**. How
many frames are held back before showing. Each frame absorbs roughly one screen refresh of network
hiccup and costs one refresh of delay — so on a 120 Hz screen, two frames is about 17 ms of extra
delay bought against 17 ms of jitter. If you never see stutter, you don't need this. Wherever
**Prioritize** is offered, and greyed out until you pick Smoothness.
**Smoothness buffer** — *default: Automatic (two frames).* How many frames are held back before
showing. Each frame absorbs roughly one screen refresh of network hiccup and costs one refresh of
delay — so on a 120 Hz screen, two frames is about 17 ms of extra delay bought against 17 ms of
jitter. If you never see stutter, you don't need this. The row appears wherever **Prioritize** is
offered, and only once you have picked **Smoothness** — under Lowest latency there are no held
frames for it to count, so it isn't shown at all.
**V-Sync** — *default: on.* Tear-free presentation. Turning it off asks the GPU to show each frame
the instant it's ready instead of waiting for the screen's next refresh: the lowest delay a display
@@ -263,6 +264,43 @@ when you return to the host list. The console home carries the row for the deskt
shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV
and Android have no equivalent.
## Interface
These change how the client itself looks and behaves. None of them touches a stream, so none of them
can live in a [profile](/docs/profiles-and-links) — they are decisions about the device in front of
you.
**Gamepad-optimized browsing** — *default: on.* Swaps the touch or desktop home for the
controller-optimized one: the host carousel, larger focus targets, a swipeable cover browser, and
settings you can step with a thumbstick. The Apple and Android apps have this switch. Turn it off to
stay in the touch interface even with a pad in your hands. On Linux, Windows and the Steam Deck the
controller-optimized home is a separate entry point rather than a switch, so there is nothing to
turn off. An Android TV is always in this mode — its remote is the only input it has.
**Show it** — *default: With a controller.* Only shown while the switch above is on, and it decides
*when* that switch takes effect. **With a controller** is the long-standing behaviour: the
controller-optimized home appears as a pad connects and the touch interface returns when the last one
disconnects. **Always** keeps the controller-optimized home either way — for a phone or tablet that
lives docked to a TV, where the pad isn't always awake but the couch layout is always the one you
want. Apple and Android. (An Android TV is in that mode regardless, so the choice changes nothing
there.)
**Background** — *default: Violet.* The colour family the controller-optimized home's living backdrop
drifts through. Thirteen of them: seven dark fields — **Violet**, **OLED**, **Nebula**, **Abyss**,
**Ember**, **Moss**, **Graphite** — then six pale ones, **Holo**, **Sunset**, **Bloom**, **Dawn**,
**Mint** and **Opal**, which flip the whole interface to dark text on a light field. The backdrop
recolours as you step the row, so pick by looking. **OLED** is the one with a practical point rather
than a decorative one: it is true black — most of the frame is pixels switched off, which on an OLED
or AMOLED panel means no glow and no power drawn, with only a faint violet ember left in one corner.
Stored under the same name on every client, so a phone, a Deck and a desktop set to Mint all look
alike. Appearance only — nothing about a stream depends on it.
The row lives in the controller-optimized settings themselves — the screen you reach with **X** from
the controller-optimized home — on every platform that has one, which includes the Steam Deck and the
Linux and Windows console home. The Apple TV is the exception: it carries **Background** in its
ordinary Settings instead, next to **Show it**, because its controller-optimized home needs a real
controller to open and the palettes would otherwise be unreachable from the Siri Remote.
## Overlay
**Statistics overlay** — *default: Normal.* Four tiers — Off, Compact, Normal, Detailed — each a
@@ -292,6 +330,10 @@ stay global and **cannot be put in a settings profile**:
profile forwards.
- **Auto-wake on connect** and **Show game library** — decisions about this device and this network,
not about how a given host is streamed.
- Everything under **Interface****Gamepad-optimized browsing**, **Show it** and **Background**.
How this client looks and which layout it wears has nothing to do with how a host streams to it,
so binding them to a host would only make the same device change appearance depending on what it
connected to.
One switch you might expect here isn't in Settings at all: **Share clipboard** lives in a saved
host's own edit sheet, because handing a machine your clipboard is a decision about that one host —