19 Commits

Author SHA1 Message Date
enricobuehler e28160ebb3 chore(release): bump workspace version to 0.8.4
apple / swift (push) Successful in 1m12s
audit / cargo-audit (push) Successful in 27s
audit / bun-audit (push) Successful in 15s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 1m16s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 49s
ci / bench (push) Successful in 4m45s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 55s
apple / screenshots (push) Successful in 5m55s
ci / rust (push) Successful in 11m23s
android-screenshots / screenshots (push) Successful in 2m37s
windows-host / package (push) Successful in 7m37s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m15s
release / apple (push) Successful in 9m2s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m12s
android / android (push) Successful in 10m53s
arch / build-publish (push) Successful in 9m4s
decky / build-publish (push) Successful in 26s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m26s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
flatpak / build-publish (push) Successful in 4m42s
deb / build-publish (push) Successful in 10m29s
linux-client-screenshots / screenshots (push) Successful in 6m36s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m41s
web-screenshots / screenshots (push) Successful in 2m44s
docker / deploy-docs (push) Successful in 20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m2s
Release 0.8.4 — reconnect reliability and streaming polish over 0.8.3.

The headline fix: a reconnect to a saved host that had stopped advertising on
mDNS (but still had a learned Wake-on-LAN MAC) was routed into a wait-for-mDNS
wake flow instead of being dialed — so a host reached over a routed network
(Tailscale/VPN/another subnet) could never be reconnected, reading as "Offline"
with no connection attempt in the host log. All four full clients now dial
first and only fall into the visible "Waking…" wait when the dial actually
fails. On the host side, the same investigation surfaced Windows-only gaps: a
deliberate quit (⌘D) now tears the pf-vdisplay virtual monitor down immediately
(no 10 s linger churning the reconnect's REMOVE→ADD), while keep_alive=forever
(the gaming-rig preset) correctly outranks the quit on both platforms so a
pinned screen stays alive. Silent deaths are no longer invisible: a panic hook
tees panics into the web console's Logs, a Windows SEH filter names the faulting
module on a native GPU-driver crash, and a watchdog flags a wedged monitor
teardown.

Also in this release: the presenter switches to MAILBOX present, pushes HDR10
mastering metadata, and tags HDR honestly; DualSense rumble accepts the v2
vibration valid-flag and bounds a stale buzz; the core caps the QUIC datagram
send buffer so audio/rumble go latest-wins; the host folds a Steam Deck to a
DualSense on Windows (split-pad touch + pad clicks) and applies input motion on
arrival rather than after the 4 ms tick; Android gains a tiered stats HUD with
windowed lost/skipped/FEC counters; and Windows surfaces a Help/shortcuts
screen and exits fullscreen when the stream stops.

The [workspace.package] version (inherited by every crate via version.workspace)
is the release being cut; the 14 workspace entries in Cargo.lock and the
api/openapi.json info.version are refreshed to match (CI builds --locked).
Canary derives from the tag as minor+1 of the latest stable
(scripts/ci/pf-version.sh), so cutting 0.8.4 keeps canary at 0.9.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:17:01 +02:00
enricobuehler 0b7e4a00ee fix(host): crash visibility + Windows deliberate-quit teardown; pin outranks quit
Two strands from the "Windows host went Offline after reconnect, zero errors in
the logs" investigation.

Crash visibility — the field reports kept arriving with nothing in the Logs
tab because every way the host can die silently was unlogged:
- A panic hook tees each panic (thread, location, backtrace) through `tracing`
  into the in-memory ring + host.log before the default hook runs — a panicking
  thread otherwise only hit stderr, absent from the web console and gone when
  stderr is detached.
- A last-resort Windows SEH filter (windows/crash.rs) logs an unhandled native
  exception with its code, faulting address, and faulting MODULE — the smoking
  gun that separates our bug from a GPU runtime/driver DLL (amfrt64, the UMD,
  d3d11) crashing under us.
- A 10 s watchdog around the vdisplay monitor teardown logs an ERROR when the
  driver REMOVE/CCD-restore looks wedged (it runs under the manager state lock,
  so a hang there silently blocks every future acquire).

Deliberate-quit teardown (Windows pf-vdisplay) — the Windows manager never
wired the linger skip the Linux registry has: on ⌘D (the QUIT close code) the
monitor lingered 10 s, so a quick reconnect hit the Lingering-preempt's
back-to-back REMOVE→ADD churn. A per-lease quit flag (VirtualDisplay::
set_quit_flag) now tears the monitor down immediately on a deliberate quit, so
the reconnect finds the manager Idle and does a clean fresh ADD.

Pin outranks quit (both platforms) — keep_alive=forever (the gaming-rig preset)
promises "the screen stays alive", so a deliberate quit must skip only the
linger WINDOW, never the pin. Windows release() checks keep_alive_forever()
before the quit; the Linux registry had the inverse bug (force_immediate tore
down even a pinned display) — fixed via effective_linger() with a unit test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:17:01 +02:00
enricobuehler ab6790ef6c fix(clients): dial-first reconnect — don't gate the connect on mDNS presence
A saved host that stopped advertising on mDNS but has a learned Wake-on-LAN
MAC was routed into a wake-and-WAIT-for-mDNS flow instead of being dialed. For
a host reached over a routed network (Tailscale/VPN/another subnet) mDNS never
sees it, so the wait could never succeed and reconnects were impossible — the
host log showed no connection attempt at all. The tile's Offline pip and this
connect gate shared the same LAN-only `advertises`/`liveAdvert` predicate, so a
perfectly reachable host read as unreachable. The first connect worked only
because the MAC hadn't been learned yet (a direct dial); once learned, every
reconnect wedged in the waker.

All four full clients carried this pattern (deliberate mirrors of the Apple
`HostWaker`). Each now DIALS FIRST: fire the magic packet up front
(fire-and-forget — harmless if the host is awake, and a genuinely-asleep box is
booting while the dial times out) and attempt the connection unconditionally.
Only a FAILED dial falls into the visible "Waking…" wait, whose redial carries
no fallback so it can't loop. A fingerprint-mismatch/trust-rejection failure
means the host ANSWERED, so it never takes the wake path.

- apple: SessionModel.connect gains `onUnreachable`; startSession dials first.
- linux (relm4): AppMsg::WakeConnect wakes+dials; a per-request wake_fallback
  is armed and consumed in SessionExited, matched by fingerprint/address.
- windows: initiate_waking + ConnectOpts.wake_on_fail; the worker's Failed arm
  escalates to wake_and_connect only on a non-trust failure.
- android: doConnectDirect gains onFailure; doConnect wakes+dials first.

Decky was already correct (always launches `--connect`; WoL fire-and-forget).
Explicit "Wake host" menu actions are unchanged — waiting on mDNS is right when
the user explicitly asked to wake a box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:17:01 +02:00
enricobuehler 52856d4b6b chore(web): refresh bun.lock
apple / swift (push) Successful in 1m12s
android / android (push) Successful in 4m34s
arch / build-publish (push) Successful in 5m39s
audit / bun-audit (push) Successful in 12s
audit / cargo-audit (push) Successful in 1m21s
ci / web (push) Successful in 49s
apple / screenshots (push) Successful in 5m49s
windows-host / package (push) Successful in 7m39s
ci / docs-site (push) Successful in 1m14s
ci / rust (push) Successful in 3m31s
ci / bench (push) Successful in 4m54s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
deb / build-publish (push) Successful in 4m0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 11m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 11m12s
docker / deploy-docs (push) Successful in 18s
Dependency bumps within the existing package.json semver ranges; no
manifest changes.
2026-07-08 13:12:01 +02:00
enricobuehler 6bbd2a9ed6 feat(windows): add deploy-all.ps1 wrapper; fix web console health check
deploy-all.ps1 chains deploy-host.ps1 + build-web.ps1 so a full redeploy is
one command; stops before the web step if the host build/start fails.
build-web.ps1's post-restart check was probing http:// while web-run.cmd
serves HTTPS-only, so it always reported a false failure - probe https via
curl.exe (no -SkipCertificateCheck on the Windows PowerShell 5.1 this runs
under) with a short retry loop for the task's cold start instead.
2026-07-08 13:11:59 +02:00
enricobuehler ade4266336 fix(presenter): MAILBOX present; push HDR10 metadata; honest HDR tag
apple / swift (push) Successful in 1m10s
android / android (push) Successful in 4m43s
arch / build-publish (push) Successful in 5m33s
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 1m9s
windows-host / package (push) Successful in 7m54s
ci / rust (push) Successful in 3m33s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m12s
release / apple (push) Successful in 8m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 45s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 51s
deb / build-publish (push) Successful in 3m59s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
ci / bench (push) Successful in 5m1s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
apple / screenshots (push) Successful in 5m52s
flatpak / build-publish (push) Successful in 4m55s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m52s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m50s
Three Deck-test findings in the Vulkan presenter:

MAILBOX over FIFO when the surface offers it (PUNKTFUNK_PRESENT_MODE=
fifo restores the old default): both are tear-free, but an arrival-
paced presenter blocking in FIFO's present queue eats most of a vblank
whenever gamescope holds images for its composite pass or cadence
drifts against refresh — the display stat flipping 1 ms → 11-13 ms.

VK_EXT_hdr_metadata wired up: the host's 0xCE mastering metadata (or
the Windows presenter's generic HDR10 baseline until it arrives) is
pushed via vkSetHdrMetadataEXT whenever an HDR10 swapchain is (re)-
created. Compositors key their HDR-app signaling on this — colorspace
alone leaves gamescope treating the app as SDR (no SteamOS badge).

And the OSD's HDR tag now reports the display path, not the stream: a
PQ stream tone-mapped onto an SDR surface shows HDR→SDR, so the next
test distinguishes "badge missing" from "HDR never engaged".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 12:29:56 +02:00
enricobuehler dc834ea478 fix(rumble): accept the v2 vibration valid-flag; bound stale buzz to 1.5 s
Two hardenings against runaway vibration (one Deck session buzzed on a
~2 s cadence): the DualSense output-report parse now also treats
valid_flag2 COMPATIBLE_VIBRATION2 (data[39] BIT2) as rumble-carrying —
a writer hardcoding the ≥2.24-firmware convention previously had its
rumble INCLUDING stops silently ignored, and a missed stop re-sends
stale nonzero state forever via the host's 500 ms refresh. And the
client's SDL rumble duration drops 5 s → 1.5 s: long enough that a
couple of lost 500 ms refreshes don't gap genuine rumble, short enough
that stale state dies on its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 12:29:56 +02:00
enricobuehler 4516bd48e1 perf(core): cap the QUIC datagram send buffer — audio/rumble go latest-wins
quinn's default 1 MiB datagram FIFO holds tens of seconds of Opus; on a
congested link the audio/rumble/input planes built a standing delay that
never drained while video (own latest-wins UDP path) stayed live —
observed on the Deck as audio and rumble lagging together by the same
experience-destroying amount. quinn sheds oldest-first at the cap, so a
4 KiB bound makes the plane latest-wins at the source: ~200 ms of stereo
Opus worst case, and sustained congestion costs concealable drops
instead of lag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 12:29:56 +02:00
enricobuehler 4c9c7e606e feat(android): tiered stats HUD + windowed lost/skipped/FEC counters
Stats overlay verbosity tiers (Off/Compact/Normal/Detailed, 3-finger tap
cycles live, old Boolean pref migrated) and the unified-spec line-4
reliability counters: lost/FEC windowed from the connector's cumulative
totals, skipped from the client's own newest-wins drops. Adds the
fec_recovered_shards accessor to NativeClient, mirrored from the
data-plane pump like frames_dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 12:29:56 +02:00
enricobuehler b12c7d3deb style(windows): satisfy rustfmt on the uninstall() teardown comments
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m7s
apple / swift (push) Successful in 1m18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m9s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 39s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m0s
android / android (push) Successful in 5m34s
apple / screenshots (push) Successful in 6m8s
arch / build-publish (push) Successful in 6m57s
ci / web (push) Successful in 1m36s
ci / rust (push) Successful in 3m58s
ci / docs-site (push) Successful in 1m4s
deb / build-publish (push) Successful in 3m57s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 5m3s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 11m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 11m24s
docker / deploy-docs (push) Successful in 29s
rustfmt 1.9.0 aligns standalone // lines out to the column of a preceding
trailing comment, so the two-line fullscreen note placed right after
`set_captured(...); // ...` failed `cargo fmt --check`. Make the first note a
leading comment so no standalone comment follows a trailing one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 11:24:42 +02:00
enricobuehler 8a4b0c7bae feat(windows): surface shortcuts on a Help screen; exit fullscreen when the stream stops
apple / swift (push) Successful in 1m10s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m13s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m13s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 44s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 41s
android / android (push) Successful in 4m11s
ci / rust (push) Failing after 1m21s
ci / web (push) Successful in 55s
apple / screenshots (push) Successful in 5m52s
ci / docs-site (push) Successful in 1m2s
arch / build-publish (push) Has been cancelled
ci / bench (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
The in-stream keyboard shortcuts were only reachable deep in Settings > Input.
Move them to a dedicated Help screen reached from a Help button on the host
list (mirrors the Licenses screen — a hook-free page routed from root), and drop
the reference card out of Settings so there's one home for it.

Fullscreen (F11) was a window-style toggle that stream stop never undid, so
disconnecting while fullscreen left the GUI borderless-fullscreen. Split
toggle_fullscreen into is_fullscreen/enter_fullscreen/exit_fullscreen sharing a
module-level SAVED_PLACEMENT, and have input::uninstall() call exit_fullscreen()
on teardown. exit_fullscreen is a no-op when nothing was saved, so it's safe on
every stream stop and idempotent with F11 — fullscreen is now streaming-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 11:17:19 +02:00
enricobuehler d490397908 chore: gitignore the local flatpak-builder repo/build dirs
apple / swift (push) Successful in 1m12s
android / android (push) Successful in 4m29s
arch / build-publish (push) Successful in 5m23s
ci / web (push) Successful in 1m0s
ci / docs-site (push) Successful in 1m8s
ci / rust (push) Successful in 3m35s
windows-host / package (push) Successful in 8m53s
release / apple (push) Successful in 8m52s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m16s
deb / build-publish (push) Successful in 4m1s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
ci / bench (push) Successful in 4m58s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m1s
apple / screenshots (push) Successful in 5m55s
flatpak / build-publish (push) Successful in 4m31s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m48s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 11m4s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m18s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:51:26 +02:00
enricobuehler a41f679516 perf(host): one input channel — motion applies on arrival, not after the 4 ms tick
Rich input (gyro at the pad's report rate, trackpad contacts) rode a second
mpsc channel that the input thread only drained after its main channel's
4 ms recv timeout — so during pure-gyro aiming (no button/pointer traffic,
the common case) every motion sample ate up to 4 ms of added latency and
quantization before reaching the virtual pad. At the Deck's 250 Hz sensor
cadence that is a full sample period of jitter.

Both planes now share one ClientInput channel: the thread wakes the moment
either arrives and applies rich input immediately. The 4 ms timeout stays
as the feedback-pump tick.

Also adds a debug-level motion-cadence metric (inter-arrival p50/p95/max
per 5 s window) so gyro delivery is measurable end-to-end instead of
'feels floaty'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:51:04 +02:00
enricobuehler e6d9454251 feat(host): fold Steam Deck to DualSense on Windows; split-pad touch + pad clicks
A Deck client asking for 'steamdeck' on a Windows host resolved to Xbox360,
whose apply_rich is a no-op — gyro and both trackpads silently discarded.
Windows already ships a full DualSense backend (UMDF driver, touchpad +
motion, wire units 1:1), so pick_gamepad now folds SteamDeck -> DualSense
there.

The three near-identical DualSense-family appliers (Linux UHID, Windows
DualSense, Windows DS4) are hoisted into one shared
dualsense_proto::DsState::apply_rich, with two mapping upgrades for Steam
dual-pad clients everywhere:
 * the Deck's two pads SPLIT the single DualSense touchpad — left pad ->
   contact 0 on the left half, right pad -> contact 1 on the right half —
   mirroring the physical thumb layout and the split-pad zones games and
   Steam Input already use (the left pad was previously dropped outright)
 * TouchpadEx pad clicks now press the touchpad-click button (persisted
   in DsState::touch_click, OR-ed in by both serializers; previously
   dropped by every DualSense-family backend, Linux included)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:50:53 +02:00
enricobuehler 9ca672d434 fix(host): trackpad Y rides the wire in screen convention, flips at the Deck boundary
Both Deck trackpads were inverted on the virtual pad (first live Deck-to-Deck
session): the client sends SDL's screen-convention coordinates (+y down)
onto the wire, but the Deck's raw report fields are stick convention (+y up)
— and Steam Input parses our report as real Deck hardware — while the host
applier passed the value straight through.

Pin the wire meaning down (quic.rs: TouchpadEx/Touchpad are screen
convention, +x right +y down — what every client capture API produces) and
translate to device-raw in the host applier, the layer that knows the
quirk: steam_proto's apply_rich now negates y (saturating: -32768 has no
i16 negation) for both TouchpadEx surfaces and the DualSense-standin
Touchpad arm, which carried the same latent inversion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:50:30 +02:00
enricobuehler b8fd652cb8 fix(packaging): autoload vhci-hcd and open its attach files for the usbip Deck pad
Steam Input only adopts the virtual Steam Deck controller when it arrives as
a real USB device (raw_gadget or usbip/vhci transports); the UHID fallback
has no USB interface and Steam ignores it. On a stock host neither
precondition for usbip held: vhci-hcd isn't loaded, and its sysfs
attach/detach files are root-only while the host runs as a user service —
so every session silently fell back to UHID and 'no controller appears on
the host' (live-debugged 2026-07-08: client events all arrived, the pad
existed, Steam just refused it).

Ship both preconditions with the host packages:
 * modules-load.d/punktfunk.conf loads vhci-hcd at boot (rpm/deb/arch;
   Bazzite gets it via the RPM payload + a modprobe in the sysext
   post-merge hook)
 * a udev rule in 60-punktfunk.rules grants the input group write on
   vhci_hcd's attach/detach whenever the device appears

Verified end-to-end on a live host: usbip in-process attach, hid-steam
binds all three interfaces, 'Steam Deck' + motion evdev appear, and Steam
adopts the pad (client-mode grab + its own virtual X360 emission).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:50:19 +02:00
enricobuehler 5042ffd935 fix(presenter): order the vk-frame layout transition after the decode semaphore
The submit waits the Vulkan-Video frame's decode-complete timeline semaphore
at FRAGMENT_SHADER, but the acquire barrier ran its layout transition
(VIDEO_DECODE_DST/DPB -> SHADER_READ_ONLY) from srcStage TOP_OF_PIPE — which
the wait does not cover, so no dependency chain formed and the transition
could execute while the decode queue was still writing the image. The race
window is real: the pump ships each frame to the presenter BEFORE its
decode-complete wait (that wait is stats-only).

On RADV the transition physically touches the image (metadata /
decompression), so the race rendered green/yellow block corruption exactly
at freshly-decoded regions — the Steam Deck 'artifacts around the moving
cursor' bug (the cursor is the only damage on an idle desktop). NVIDIA
treats the transition as a no-op, which is why the identical code looked
clean on the dev box.

srcStage is now FRAGMENT_SHADER, matching the semaphore's wait mask — the
standard acquire pattern (same as swapchain acquires) that chains the
transition after the decode signal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:50:06 +02:00
enricobuehler 8bef4e27f9 fix(client-linux): opt into RADV Vulkan Video decode at session start
Mesa gates RADV's video-decode support — the VK_KHR_video_decode_* device
extensions AND the decode-capable queue family — behind
RADV_PERFTEST=video_decode (Steam Deck / VANGOGH included). Without it the
presenter's device advertises no decode queue, Decoder::new's auto chain
skips Vulkan Video, and the session silently lands on VAAPI.

punktfunk-session now appends the token to RADV_PERFTEST before the driver
loads (all streams run through this binary, so every launch path and package
is covered). RADV-only knob: other drivers ignore it, and a Mesa where
decode is already default just no-ops. Verified on the Deck: decode_qf=2,
H264/H265/AV1 caps, first frame path="vulkan-video", 90 fps at 1.9 ms decode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:49:51 +02:00
enricobuehler b664ae373d fix(screenshots): give PF_SHOT_READY headroom for the cold first scene
apple / swift (push) Successful in 1m14s
android / android (push) Successful in 4m17s
arch / build-publish (push) Successful in 5m37s
ci / web (push) Successful in 1m4s
apple / screenshots (push) Successful in 6m11s
ci / docs-site (push) Successful in 1m5s
ci / rust (push) Successful in 3m45s
deb / build-publish (push) Successful in 4m1s
decky / build-publish (push) Successful in 25s
ci / bench (push) Successful in 5m4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m58s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m42s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 56s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m26s
flatpak / build-publish (push) Successful in 4m23s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m39s
docker / deploy-docs (push) Successful in 22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m2s
The linux-client-screenshots job red-lit on a single scene — the first one
(`hosts`) — with "client never signalled PF_SHOT_READY", while the other six
captured fine (~1-2s each). Root cause: the first client launch pays a large
one-time cold-start under software rendering (llvmpipe GL-shader + fontconfig
cache build, plus first-run client-identity generation into the shared scratch
HOME), ~25s on the CI runner; the relm4 shell rebuilt in 0.8.3 tipped it just
over the 20s PF_SHOT_READY cap. Every scene rendered correctly — the cap was
simply too tight for cold start. Raise it to ~60s (warm scenes still break out
in ~1-2s, so the happy path is unchanged). Best-effort job; it never blocked the
release, but now it goes green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 08:30:58 +02:00
58 changed files with 1674 additions and 533 deletions
+4
View File
@@ -34,3 +34,7 @@ __pycache__/
# Claude Code project instructions — local to each dev box, not part of the repo.
CLAUDE.md
# Local flatpak-builder output (build-flatpak.sh) — ostree repo + build dir at the repo root.
.flatpak-repo/
.flatpak-build/
Generated
+14 -14
View File
@@ -2154,7 +2154,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.8.3"
version = "0.8.4"
[[package]]
name = "lazy_static"
@@ -2286,7 +2286,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.8.3"
version = "0.8.4"
dependencies = [
"punktfunk-core",
]
@@ -2767,7 +2767,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-client-core"
version = "0.8.3"
version = "0.8.4"
dependencies = [
"anyhow",
"async-channel",
@@ -2787,7 +2787,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.8.3"
version = "0.8.4"
dependencies = [
"anyhow",
"ash",
@@ -2807,7 +2807,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.8.3"
version = "0.8.4"
dependencies = [
"ash",
"bindgen",
@@ -2816,7 +2816,7 @@ dependencies = [
[[package]]
name = "pf-presenter"
version = "0.8.3"
version = "0.8.4"
dependencies = [
"anyhow",
"ash",
@@ -2999,7 +2999,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.8.3"
version = "0.8.4"
dependencies = [
"android_logger",
"jni",
@@ -3014,7 +3014,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.8.3"
version = "0.8.4"
dependencies = [
"anyhow",
"async-channel",
@@ -3030,7 +3030,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.8.3"
version = "0.8.4"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3044,7 +3044,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.8.3"
version = "0.8.4"
dependencies = [
"anyhow",
"async-channel",
@@ -3067,7 +3067,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.8.3"
version = "0.8.4"
dependencies = [
"aes-gcm",
"bytes",
@@ -3098,7 +3098,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.8.3"
version = "0.8.4"
dependencies = [
"aes",
"aes-gcm",
@@ -3170,7 +3170,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.8.3"
version = "0.8.4"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3184,7 +3184,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.8.3"
version = "0.8.4"
dependencies = [
"anyhow",
"ksni",
+1 -1
View File
@@ -22,7 +22,7 @@ members = [
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
[workspace.package]
version = "0.8.3"
version = "0.8.4"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.8.3"
"version": "0.8.4"
},
"paths": {
"/api/v1/clients": {
@@ -185,8 +185,10 @@ fun ConnectScreen(
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
// the host presented (as an unpaired known host) so the next connect goes straight through and it
// appears in the saved-hosts list.
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
// appears in the saved-hosts list. [onFailure], when set, takes over a failed dial (the wake-wait
// fallback) instead of the error status line — discovery is already restarted when it runs, so
// the wait can observe the host reappear.
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?, onFailure: (() -> Unit)? = null) {
val id = identity ?: run {
status = "Identity not ready yet — try again in a moment"
return
@@ -206,18 +208,25 @@ fun ConnectScreen(
}
onConnected(handle)
} else {
status = "Connection failed — check host/port, PIN, and logcat"
discovery.start()
if (onFailure != null) {
status = ""
onFailure()
} else {
status = "Connection failed — check host/port, PIN, and logcat"
}
}
}
}
// Wake-aware connect. If auto-wake is on (Settings.autoWakeEnabled) and the target is a saved
// host with a learned MAC that ISN'T currently advertising (asleep/off, or just missing from
// mDNS), wake it and WAIT for it to reappear on mDNS (WakeController shows the "Waking…" overlay)
// before dialing — discovery stays running meanwhile so we can see it come back. A fire-and-forget
// packet + the connect timeout wasn't enough for a cold boot. Otherwise (auto-wake off, no MAC, or
// already seen live) dial straight through.
// host with a learned MAC that ISN'T currently advertising, fire a wake packet and DIAL
// IMMEDIATELY — mDNS absence does NOT mean unreachable (a host reached over a routed network —
// Tailscale/VPN/another subnet — is mDNS-blind forever, and gating the dial on presence bricked
// exactly those reconnects). A genuinely-asleep box is already booting while the dial times out;
// only a FAILED dial falls into the wake-and-WAIT-for-mDNS flow (WakeController's "Waking…"
// overlay), which redials once the host reappears. Otherwise (auto-wake off, no MAC, or already
// seen live) dial straight through.
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
if (identity == null) {
status = "Identity not ready yet — try again in a moment"
@@ -232,23 +241,28 @@ fun ConnectScreen(
if (kh != null) discovered.firstOrNull { kh.matches(it) }
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
waker.start(
hostName = name,
connectsAfter = true,
macs = macs,
lastIp = targetHost,
isOnline = { liveAdvert() != null },
onOnline = {
val live = liveAdvert()
// Woke back on a new address? Re-key the saved record so it (and future connects)
// point at the live one, then dial there.
if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) {
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
savedHosts = knownHostStore.all()
}
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
},
)
// Fire-and-forget first packet (harmless if it's awake), then dial-first.
scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) }
doConnectDirect(targetHost, targetPort, name, pinHex, onFailure = {
waker.start(
hostName = name,
connectsAfter = true,
macs = macs,
lastIp = targetHost,
isOnline = { liveAdvert() != null },
onOnline = {
val live = liveAdvert()
// Woke back on a new address? Re-key the saved record so it (and future
// connects) point at the live one, then dial there (no fallback on this
// redial — a second failure surfaces as the plain error).
if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) {
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
savedHosts = knownHostStore.all()
}
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
},
)
})
} else {
doConnectDirect(targetHost, targetPort, name, pinHex)
}
@@ -346,11 +346,12 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
) { update(s.copy(gamepad = it)) },
toggle(
choice(
"hud", "Interface", "Statistics overlay",
"Show FPS, throughput and latency while streaming.",
s.statsHudEnabled,
) { update(s.copy(statsHudEnabled = it)) },
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
"A 3-finger tap cycles the tiers live.",
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
) { update(s.copy(statsVerbosity = it)) },
toggle(
"library", null, "Game library",
"Browse a paired host's games with Y (experimental).",
@@ -30,8 +30,13 @@ data class Settings(
* host emits it when it can, else falls back. AMediaCodec decodes whichever the host resolves. */
val codec: String = "auto",
val micEnabled: Boolean = false,
/** Show the live stats overlay (FPS / throughput / latency) during a stream. */
val statsHudEnabled: Boolean = true,
/**
* How much the in-stream stats overlay shows — see [StatsVerbosity]. Defaults to
* [StatsVerbosity.NORMAL] (the res/fps line + latency headline + reliability counters); the full
* decoder/feed/equation HUD is [StatsVerbosity.DETAILED], and a single terse line is
* [StatsVerbosity.COMPACT]. A 3-finger tap cycles through the tiers live.
*/
val statsVerbosity: StatsVerbosity = StatsVerbosity.NORMAL,
/**
* Touch input model — how touchscreen fingers drive the host. [TouchMode.TRACKPAD] (default):
* the cursor stays put on touch-down and moves by the finger's relative delta (swipe to nudge,
@@ -81,6 +86,27 @@ data class Settings(
/** [Settings.touchMode] values; persisted by name. */
enum class TouchMode { TRACKPAD, POINTER, TOUCH }
/**
* Stats-overlay detail tiers, in cycling order (persisted by name). Each tier is a strict superset
* of the previous one, so toning down never hides a number a lower tier keeps:
* - [OFF] — no overlay (and native sampling is gated off, one atomic load per frame).
* - [COMPACT] — one line: `fps · end-to-end ms · Mb/s` (+ a loss flag when frames drop).
* - [NORMAL] — adds the resolution/refresh line, the end-to-end p50/p95 headline, and the
* reliability counters (lost / skipped / FEC) when nonzero. The default.
* - [DETAILED] — the full HUD: also the decoder label, the video-feed descriptor, and the
* `host+network + decode` stage equation.
* A 3-finger tap in-stream cycles Off → Compact → Normal → Detailed → Off (see [next]).
*/
enum class StatsVerbosity(val label: String) {
OFF("Off"),
COMPACT("Compact"),
NORMAL("Normal"),
DETAILED("Detailed");
/** The next tier for the live 3-finger-tap cycle (wraps Detailed → Off). */
fun next(): StatsVerbosity = entries[(ordinal + 1) % entries.size]
}
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
class SettingsStore(context: Context) {
private val prefs =
@@ -97,7 +123,16 @@ class SettingsStore(context: Context) {
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
micEnabled = prefs.getBoolean(K_MIC, false),
statsHudEnabled = prefs.getBoolean(K_HUD, true),
statsVerbosity = prefs.getString(K_STATS_VERBOSITY, null)
?.let { name -> StatsVerbosity.entries.firstOrNull { it.name == name } }
// Migration from the pre-tier Boolean "stats_hud_enabled": an explicit OFF stays off;
// everyone else (incl. fresh installs) lands on NORMAL — the old always-full HUD toned
// down to the new default, which is the whole point of adding tiers.
?: if (prefs.contains(K_HUD) && !prefs.getBoolean(K_HUD, true)) {
StatsVerbosity.OFF
} else {
StatsVerbosity.NORMAL
},
touchMode = prefs.getString(K_TOUCH_MODE, null)
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
@@ -120,7 +155,7 @@ class SettingsStore(context: Context) {
.putInt(K_AUDIO_CH, s.audioChannels)
.putString(K_CODEC, s.codec)
.putBoolean(K_MIC, s.micEnabled)
.putBoolean(K_HUD, s.statsHudEnabled)
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
.putString(K_TOUCH_MODE, s.touchMode.name)
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
.putBoolean(K_LIBRARY, s.libraryEnabled)
@@ -140,6 +175,10 @@ class SettingsStore(context: Context) {
const val K_AUDIO_CH = "audio_channels"
const val K_CODEC = "codec"
const val K_MIC = "mic_enabled"
const val K_STATS_VERBOSITY = "stats_verbosity"
/** Pre-tier Boolean the [K_STATS_VERBOSITY] enum replaced — read once for migration, never
* written. */
const val K_HUD = "stats_hud_enabled"
const val K_TOUCH_MODE = "touch_mode"
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
@@ -269,6 +308,9 @@ val COMPOSITOR_OPTIONS = listOf(
"gamescope",
)
/** (verbosity, label) for the stats-overlay detail picker. Order = the live 3-finger-tap cycle. */
val STATS_VERBOSITY_OPTIONS = StatsVerbosity.entries.map { it to it.label }
/** (mode, label) for the touch-input model. */
val TOUCH_MODE_OPTIONS = listOf(
TouchMode.TRACKPAD to "Trackpad",
@@ -403,11 +403,17 @@ private fun InterfaceSettings(s: Settings, update: (Settings) -> Unit) {
checked = s.autoWakeEnabled,
onCheckedChange = { on -> update(s.copy(autoWakeEnabled = on)) },
)
ToggleRow(
title = "Stats overlay",
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
checked = s.statsHudEnabled,
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
SettingDropdown(
label = "Stats overlay",
options = STATS_VERBOSITY_OPTIONS,
selected = s.statsVerbosity,
) { v -> update(s.copy(statsVerbosity = v)) }
Text(
"How much the in-stream overlay shows: Compact is a single fps · latency · bitrate " +
"line; Normal adds the resolution and reliability lines; Detailed adds the decoder, " +
"colour and latency-breakdown lines. A 3-finger tap cycles the tiers live.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -16,61 +16,63 @@ import kotlin.math.roundToInt
/**
* The live stats overlay — the unified HUD (`design/stats-unification.md`, Android v1: headline is
* `capture→decoded`, tiled by `host+network` + `decode`). Reads the 18-double layout from
* `capture→decoded`, tiled by `host+network` + `decode`). Reads the 22-double layout from
* [NativeBridge.nativeVideoStats]:
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries,
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms]`. Indexes 1013
* (present on a current native lib) describe the negotiated video feed and render as a
* codec/depth/colour/chroma line; 14/15 render as the stage equation — split into
* `host + network + decode` when the Phase-2 terms at 16/17 are nonzero (a current host sends
* per-AU 0xCF timings; an old host leaves them 0 and the combined `host+network` term stands);
* older layouts just omit those lines.
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries,
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms, lost, skipped,
* fec, frames]`.
*
* [verbosity] selects how many lines render (each tier a superset of the last — see
* [StatsVerbosity]):
* - [StatsVerbosity.COMPACT] — one line, `fps · end-to-end ms · Mb/s` (+ a loss flag).
* - [StatsVerbosity.NORMAL] — the res/fps/Mb·s line, the end-to-end p50/p95 headline, and the
* reliability counters (1821) when nonzero.
* - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (1013), and the
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero).
* [StatsVerbosity.OFF] renders nothing. Older native layouts simply omit the lines they lack (the
* counter line falls back to the cumulative `lostTotal` at index 9 on a pre-window lib).
*/
@Composable
internal fun StatsOverlay(s: DoubleArray, decoderLabel: String = "", modifier: Modifier = Modifier) {
if (s.size < 10) return
internal fun StatsOverlay(
s: DoubleArray,
verbosity: StatsVerbosity,
decoderLabel: String = "",
modifier: Modifier = Modifier,
) {
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
val w = s[6].toInt()
val h = s[7].toInt()
val hz = s[8].toInt()
val latValid = s[4] != 0.0
val skew = s[5] != 0.0
val lost = s[9].toLong()
val detailed = verbosity == StatsVerbosity.DETAILED
Column(
modifier = modifier
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text(
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s",
color = Color.White,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
if (decoderLabel.isNotEmpty()) {
Text(
decoderLabel,
color = Color(0xFFB0D0FF),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
// Compact: everything the glance-value needs on one line, nothing else.
if (verbosity == StatsVerbosity.COMPACT) {
statLine(compactLine(s, latValid), Color.White)
return@Column
}
videoFeedLine(s)?.let { feed ->
Text(
feed,
color = Color.White,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
statLine("$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s", Color.White)
if (detailed && decoderLabel.isNotEmpty()) {
statLine(decoderLabel, Color(0xFFB0D0FF))
}
if (detailed) {
videoFeedLine(s)?.let { statLine(it, Color.White) }
}
if (latValid) {
val tag = if (skew) "" else " (same-host clock)"
Text(
statLine(
"end-to-end ${"%.1f".format(s[2])} ms p50 · ${"%.1f".format(s[3])} p95 · capture→decoded$tag",
color = Color.White,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
Color.White,
)
if (s.size >= 16) {
if (detailed && s.size >= 16) {
// Phase-2 split (s[16]/s[17]): render `host + network` separately when the host
// reported its share this window; otherwise the combined term (old host / no
// matched 0xCF timing).
@@ -79,25 +81,60 @@ internal fun StatsOverlay(s: DoubleArray, decoderLabel: String = "", modifier: M
} else {
"= host+network ${"%.1f".format(s[14])} + decode ${"%.1f".format(s[15])}"
}
Text(
equation,
color = Color.White,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
statLine(equation, Color.White)
}
}
if (lost > 0) {
Text(
"lost $lost",
color = Color(0xFFFFB0B0),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
}
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
}
}
/** One monospace HUD line — the shared type ramp so every tier's rows line up. */
@Composable
private fun statLine(text: String, color: Color) {
Text(text, color = color, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
}
/**
* The single [StatsVerbosity.COMPACT] line: `238 fps · 1.3 ms · 921 Mb/s`. The end-to-end p50 term
* is dropped when no in-range latency sample landed (`latValid` false), and a loss flag
* `· ⚠ lost {n}` is appended when the window (or, on an old lib, the session) dropped frames — the
* one reliability signal worth surfacing even at the tersest tier.
*/
private fun compactLine(s: DoubleArray, latValid: Boolean): String {
val parts = buildList {
add("${s[0].roundToInt()} fps")
if (latValid) add("${"%.1f".format(s[2])} ms")
add("${s[1].roundToInt()} Mb/s")
}
val lostWindow = if (s.size >= 22) s[18].toLong() else s[9].toLong()
val suffix = if (lostWindow > 0) " ⚠ lost $lostWindow" else ""
return parts.joinToString(" · ") + suffix
}
/**
* Format the spec's line-4 counters from the per-window doubles at 1821 —
* `lost {n} ({pct}%) · skipped {m} · FEC {k}`, each term only when nonzero, the whole line `null`
* when all are zero (spec: "only rendered when any value is nonzero"). `pct = lost/(frames+lost)`
* (the received count rides at index 21). A pre-window layout (< 22 doubles) falls back to the
* session-cumulative `lostTotal` so an older native lib still reports loss.
*/
private fun counterLine(s: DoubleArray, lostTotal: Long): String? {
if (s.size < 22) return if (lostTotal > 0) "lost $lostTotal" else null
val lost = s[18].toLong()
val skipped = s[19].toLong()
val fec = s[20].toLong()
val frames = s[21].toLong()
if (lost == 0L && skipped == 0L && fec == 0L) return null
return buildList {
if (lost > 0) {
val pct = 100.0 * lost / (frames + lost).coerceAtLeast(1)
add("lost $lost (${"%.1f".format(pct)}%)")
}
if (skipped > 0) add("skipped $skipped")
if (fec > 0) add("FEC $fec")
}.joinToString(" · ")
}
/**
* Format the negotiated video-feed descriptor from the trailing four stats doubles
* `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
@@ -54,15 +54,19 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
// Live decode stats for the HUD. `showStats` gates the whole pipeline: the native per-frame
// sampling (nativeSetVideoStatsEnabled — hidden HUD costs one atomic load per frame) AND the
// 1 s poll loop, which only runs while the overlay is visible. Enabling resets the native
// window, so re-showing never renders stale data. A 3-finger tap toggles it live; the default
// comes from Settings.
// Live decode stats for the HUD. `statsOn` (verbosity != OFF) gates the whole native pipeline:
// the per-frame sampling (nativeSetVideoStatsEnabled — a hidden HUD costs one atomic load per
// frame) AND the 1 s poll loop, which only runs while the overlay is visible. Enabling resets
// the native window, so re-showing never renders stale data. A 3-finger tap cycles the
// verbosity tier live (Off → Compact → Normal → Detailed → Off); the default comes from
// Settings. The tier only changes how many lines `StatsOverlay` draws — switching between the
// visible tiers keeps sampling running (the effect keys on `statsOn`, not the tier) so it never
// blanks the numbers for a poll interval.
val initialSettings = remember { SettingsStore(context).load() }
var stats by remember { mutableStateOf<DoubleArray?>(null) }
var decoderLabel by remember { mutableStateOf("") }
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
val statsOn = statsVerbosity != StatsVerbosity.OFF
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
val touchMode = initialSettings.touchMode
// "Low-latency mode" master toggle, resolved once for the session. On (the default) enables the
@@ -73,9 +77,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream
// refresh; a phone/tablet gets the softer seamless frame-rate hint instead.
val isTv = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) }
LaunchedEffect(handle, showStats) {
NativeBridge.nativeSetVideoStatsEnabled(handle, showStats)
if (showStats) {
LaunchedEffect(handle, statsOn) {
NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn)
if (statsOn) {
while (true) {
delay(1000)
stats = NativeBridge.nativeVideoStats(handle)
@@ -250,8 +254,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
)
// Live stats HUD (FPS / throughput / capture→client latency), drawn over the video but
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
if (showStats) {
stats?.let { StatsOverlay(it, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp)) }
if (statsOn) {
stats?.let {
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
}
}
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
@@ -262,7 +268,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
else -> streamTouchInput(
handle,
trackpad = touchMode == TouchMode.TRACKPAD,
onToggleStats = { showStats = !showStats },
onCycleStats = { statsVerbosity = statsVerbosity.next() },
)
}
},
@@ -40,7 +40,7 @@ private const val ACCEL_MAX = 3.0f
*
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
* windows); three-finger tap = [onToggleStats] (the stats HUD).
* windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier).
*/
/**
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
@@ -93,7 +93,7 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long) {
internal suspend fun PointerInputScope.streamTouchInput(
handle: Long,
trackpad: Boolean,
onToggleStats: () -> Unit,
onCycleStats: () -> Unit,
) {
var lastTapUp = 0L
var lastTapX = 0f
@@ -218,7 +218,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
} else if (!moved) {
when {
maxFingers >= 3 -> onToggleStats() // in-stream HUD toggle
maxFingers >= 3 -> onCycleStats() // in-stream HUD verbosity cycle
maxFingers == 2 -> { // two-finger tap → right click
NativeBridge.nativeSendPointerButton(handle, 3, true)
NativeBridge.nativeSendPointerButton(handle, 3, false)
@@ -58,7 +58,15 @@ class ScreenshotTest {
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive
fun stream() = shootRoot("stream") { StreamScene() }
fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun streamCompact() = shootRoot("stream-compact") { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
@Test
fun trust() = shootScreen("trust") {
@@ -30,6 +30,7 @@ import io.unom.punktfunk.Settings
import io.unom.punktfunk.TouchMode
import io.unom.punktfunk.SettingsScreen
import io.unom.punktfunk.StatsOverlay
import io.unom.punktfunk.StatsVerbosity
import io.unom.punktfunk.components.HostCard
import io.unom.punktfunk.components.SectionLabel
import io.unom.punktfunk.models.HostStatus
@@ -109,7 +110,7 @@ internal fun SettingsScene() {
compositor = 1,
gamepad = 2,
micEnabled = true,
statsHudEnabled = true,
statsVerbosity = StatsVerbosity.DETAILED,
touchMode = TouchMode.TRACKPAD,
),
onChange = {},
@@ -177,9 +178,12 @@ internal fun PairDialog() {
)
}
/** The live stats HUD (the real StatsOverlay) over a synthetic "streamed frame" gradient. */
/**
* The live stats HUD (the real StatsOverlay) over a synthetic "streamed frame" gradient, at the
* given [verbosity] tier — one scene per tier documents how far each tones the overlay down.
*/
@Composable
internal fun StreamScene() {
internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
Box(
Modifier
.fillMaxSize()
@@ -187,17 +191,21 @@ internal fun StreamScene() {
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
),
) {
// The full 18-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
// e2eP95, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries, colorTransfer,
// chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50]. 10/9/16/1 = a 10-bit BT.2020
// PQ (HDR) 4:2:0 feed so the HUD renders its video-feed line; the Phase-2 stage terms
// (host 0.6 + network 0.3 + decode 0.4) tile the 1.3 ms headline so it renders the full
// split equation, and the decoder label line shows the ranked low-latency decoder.
// The full 22-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
// e2eP95, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries, colorTransfer,
// chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50, lost, skipped, fec, frames].
// 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its
// video-feed line; the Phase-2 stage terms (host 0.6 + network 0.3 + decode 0.4) tile the
// 1.3 ms headline so it renders the full split equation, and the decoder label shows the
// ranked low-latency decoder. Light per-window loss (lost 2 · skipped 1 · FEC 5 of 238) so
// the reliability line (NORMAL/DETAILED) and the compact loss flag both render.
StatsOverlay(
doubleArrayOf(
238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0,
238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 2.0,
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
2.0, 1.0, 5.0, 238.0,
),
verbosity = verbosity,
decoderLabel = "c2.qti.hevc.decoder · low-latency",
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
)
+43 -14
View File
@@ -493,10 +493,14 @@ fn decoder_supports_max_operating_rate(name_lower: &str) -> bool {
}
/// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat.
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat, and the
/// wall-clock instant the output callback fired — the spec's `decoded` point ("decoder output
/// frame available"), stamped at the callback so the event-channel hop + coalescing wait in the
/// loop never inflates the decode stage.
struct OutputReady {
index: usize,
pts_us: u64,
decoded_ns: i128,
}
/// Events the async decode loop reacts to. The codec's async-notify callbacks (which run on its
@@ -507,8 +511,12 @@ enum DecodeEvent {
Au(Frame),
/// An input buffer slot freed (index) — we can queue an AU into it.
InputAvailable(usize),
/// A decoded frame is ready (buffer index + echoed pts).
OutputAvailable { index: usize, pts_us: u64 },
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
OutputAvailable {
index: usize,
pts_us: u64,
decoded_ns: i128,
},
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
FormatChanged,
/// The codec reported an error; `fatal` when neither recoverable nor transient.
@@ -569,6 +577,10 @@ fn run_async(
let _ = out_tx.send(DecodeEvent::OutputAvailable {
index: idx,
pts_us: info.presentation_time_us().max(0) as u64,
// The `decoded` HUD point: stamp HERE, on the codec's looper thread, so the
// decode stage ends when the frame actually became available — not after the
// channel hop + whatever work the loop coalesces in front of presenting it.
decoded_ns: now_realtime_ns(),
});
})),
on_format_changed: Some(Box::new(move |_fmt| {
@@ -697,29 +709,30 @@ fn run_async(
};
let work_t0 = Instant::now();
let mut fmt_dirty = false;
let mut au_dropped = false;
let mut aus_dropped: u64 = 0;
if let Some(ev) = ev0 {
au_dropped |= dispatch_event(
aus_dropped += u64::from(dispatch_event(
ev,
&mut pending_aus,
&mut free_inputs,
&mut ready,
&mut fmt_dirty,
&mut fatal,
);
));
}
// Coalesce every other event already queued into this one work pass — correct newest-only
// presentation across a decode burst, and batched feeding.
while let Ok(ev) = ev_rx.try_recv() {
au_dropped |= dispatch_event(
aus_dropped += u64::from(dispatch_event(
ev,
&mut pending_aus,
&mut free_inputs,
&mut ready,
&mut fmt_dirty,
&mut fatal,
);
));
}
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
if fmt_dirty {
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
}
@@ -768,7 +781,7 @@ fn run_async(
// dropped a parked AU on overflow), throttled so a multi-frame recovery gap doesn't flood the
// control stream.
let dropped = client.frames_dropped();
if dropped > last_dropped || au_dropped {
if dropped > last_dropped || aus_dropped > 0 {
last_dropped = dropped;
let now = Instant::now();
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
@@ -863,7 +876,15 @@ fn dispatch_event(
}
}
DecodeEvent::InputAvailable(i) => free_inputs.push_back(i),
DecodeEvent::OutputAvailable { index, pts_us } => ready.push(OutputReady { index, pts_us }),
DecodeEvent::OutputAvailable {
index,
pts_us,
decoded_ns,
} => ready.push(OutputReady {
index,
pts_us,
decoded_ns,
}),
DecodeEvent::FormatChanged => *fmt_dirty = true,
DecodeEvent::Error { fatal: f } => {
if f {
@@ -935,15 +956,19 @@ fn present_ready(
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for o in ready.iter() {
note_decoded_pts(stats, &mut g, clock_offset, o.pts_us);
note_decoded_pts(stats, &mut g, clock_offset, o.pts_us, o.decoded_ns);
}
}
let last = ready.len() - 1;
let mut skipped: u64 = 0;
for (i, o) in ready.drain(..).enumerate() {
let render = i == last;
match codec.release_output_buffer_by_index(o.index, render) {
Ok(()) if render => *rendered += 1,
Ok(()) => *discarded += 1,
Ok(()) => {
*discarded += 1;
skipped += 1;
}
Err(e) => {
log::warn!(
"decode: release_output_buffer_by_index({}, {render}): {e}",
@@ -952,6 +977,7 @@ fn present_ready(
}
}
}
stats.note_skipped(skipped); // HUD `skipped` counter (newest-wins drops); no-op while hidden
}
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
@@ -1143,6 +1169,7 @@ fn drain(
log::warn!("decode: release_output_buffer(discard): {e}");
}
discarded += 1;
stats.note_skipped(1); // HUD `skipped` counter; no-op while hidden
}
}
Ok(DequeuedOutputBufferInfoResult::OutputFormatChanged) => {
@@ -1203,18 +1230,20 @@ fn note_decoded(
in_flight,
clock_offset,
buf.info().presentation_time_us().max(0) as u64,
now_realtime_ns(), // sync loop: the dequeue IS the availability instant
);
}
/// The [`note_decoded`] body keyed by the echoed `presentationTimeUs` directly — the async loop has
/// the pts (from the output callback's `BufferInfo`) but no borrowed `OutputBuffer`, so it calls this.
/// the pts (from the output callback's `BufferInfo`) but no borrowed `OutputBuffer`, so it calls
/// this with the `decoded` stamp taken in the output callback itself (the availability instant).
fn note_decoded_pts(
stats: &crate::stats::VideoStats,
in_flight: &mut VecDeque<(u64, i128)>,
clock_offset: i64,
pts_us: u64,
decoded_ns: i128,
) {
let decoded_ns = now_realtime_ns();
// Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go.
let mut received_ns = None;
while let Some(&(p, r)) = in_flight.front() {
+26 -7
View File
@@ -144,16 +144,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
}
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD
/// (unified stats spec, `design/stats-unification.md`). Returns 18 doubles
/// (unified stats spec, `design/stats-unification.md`). Returns 22 doubles
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
/// netP50Ms]`
/// (the two flags are 1.0/0.0; indexes 015 match the previous 16-double layout — 013 the original
/// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow]`
/// (the two flags are 1.0/0.0; indexes 017 match the previous 18-double layout — 013 the original
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
/// are the Phase-2 split of the `host+network` term from the per-AU 0xCF host timings — `host` =
/// the host's capture→sent, `network` = the remainder — both 0.0 when no timing matched this
/// window, i.e. an old host), or `null` when no decode thread is running. Poll ~1 Hz from the UI; each call
/// window, i.e. an old host; 1821 are the spec's per-window line-4 counters — `lost` =
/// unrecoverable drops this window, `skipped` = client newest-wins/pacing drops, `fec` = shards
/// recovered, `frames` = AUs received, so the HUD can compute `lost/(frames+lost)` — index 9 stays
/// the cumulative session total for older readers), or `null` when no decode thread is running.
/// Poll ~1 Hz from the UI; each call
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
/// the host build too (Kotlin only ever calls it on device).
#[no_mangle]
@@ -171,10 +175,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
if h.video.lock().unwrap().is_none() {
return std::ptr::null_mut(); // not streaming → no stats
}
let snap = h.stats.drain();
let snap = h
.stats
.drain(h.client.frames_dropped(), h.client.fec_recovered_shards());
let mode = h.client.mode();
let color = h.client.color;
let buf: [f64; 18] = [
let buf: [f64; 22] = [
snap.fps,
snap.mbps,
snap.e2e_p50_ms,
@@ -200,6 +206,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
// when no timing matched this window (old host) — the HUD keeps the combined term.
snap.host_p50_ms,
snap.net_p50_ms,
// Spec line-4 counters, per-window: lost (unrecoverable drops), skipped (client
// newest-wins/pacing drops), FEC shards recovered, and the received-AU count so the
// HUD computes the loss percentage `lost/(frames+lost)` exactly.
snap.lost as f64,
snap.skipped as f64,
snap.fec as f64,
snap.frames as f64,
];
let arr = match env.new_double_array(buf.len() as jsize) {
Ok(a) => a,
@@ -228,7 +241,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
if handle != 0 {
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.stats.set_enabled(enabled != 0);
// The current cumulative counters seed the window baselines, so the first snapshot's
// `lost`/`FEC` cover only time the HUD was actually up.
h.stats.set_enabled(
enabled != 0,
h.client.frames_dropped(),
h.client.fec_recovered_shards(),
);
}
})
}
+58 -4
View File
@@ -3,7 +3,10 @@
//! headline `end-to-end` = capture→decoded (p50/p95) tiled by `host+network` = capture→received
//! and `decode` = received→decoded (stage p50s). When the host emits per-AU 0xCF host timings, the
//! `host+network` term further splits into `host` + `network` (Phase 2, `note_host_split`); an old
//! host emits none and the combined term stands. The decode thread is the sole writer
//! host emits none and the combined term stands. The spec's line-4 counters are per-window too:
//! `lost` / `FEC` are windowed here from the connector's cumulative counters (the caller passes the
//! current totals into `set_enabled`/`drain`), `skipped` counts the client's own newest-wins drops
//! (`note_skipped`). The decode thread is the sole writer
//! (`note_received` per access unit at receipt, `note_decoded` per decoder output buffer); the JNI
//! accessor `nativeVideoStats` drains a snapshot ~1 Hz and resets the window. Sampling is gated on
//! the HUD actually being visible (`set_enabled`, driven by `nativeSetVideoStatsEnabled`) so the
@@ -54,6 +57,14 @@ struct Inner {
net_us: Vec<u64>,
/// `decode` stage = received→decoded samples, in microseconds (client-local, single clock).
decode_us: Vec<u64>,
/// Client-side newest-wins/pacing drops this window (decoded frames released without
/// rendering, or parked AUs dropped on overflow) — the spec's `skipped` counter.
skipped: u64,
/// Baselines for windowing the session-cumulative connector counters: the unrecoverable-drop
/// and FEC-recovered totals as of the last drain (or the enable that opened the window), so
/// each snapshot reports only THIS window's `lost` / `FEC` (spec line 4).
last_dropped_total: u64,
last_fec_total: u64,
/// Whether the host answered the clock-skew handshake (latency is cross-machine valid).
skew_corrected: bool,
}
@@ -76,6 +87,16 @@ pub struct Snapshot {
pub net_p50_ms: f64,
pub lat_valid: bool,
pub skew_corrected: bool,
/// Access units received this window (the count behind `fps`) — lets the HUD compute the
/// spec's loss percentage `lost / (received + lost)` exactly.
pub frames: u64,
/// Unrecoverable network frame drops this window (spec `lost`, windowed from the
/// session-cumulative connector counter).
pub lost: u64,
/// Client-side newest-wins/pacing drops this window (spec `skipped`).
pub skipped: u64,
/// FEC shards recovered this window (spec `FEC`, windowed from the cumulative counter).
pub fec: u64,
}
/// Percentile over a sorted-in-place µs sample vec, in ms. 0.0 when empty.
@@ -101,6 +122,9 @@ impl VideoStats {
host_us: Vec::with_capacity(256),
net_us: Vec::with_capacity(256),
decode_us: Vec::with_capacity(256),
skipped: 0,
last_dropped_total: 0,
last_fec_total: 0,
skew_corrected: false,
}),
}
@@ -115,8 +139,10 @@ impl VideoStats {
}
/// Toggle sampling. Enabling resets the window, so the first HUD poll after a show never mixes
/// in counters (or a window start) from before the overlay was visible.
pub fn set_enabled(&self, on: bool) {
/// in counters (or a window start) from before the overlay was visible. `dropped_total` /
/// `fec_total` are the connector's session-cumulative counters at this instant — they seed the
/// windowing baselines so the first snapshot's `lost` / `FEC` cover only time the HUD was up.
pub fn set_enabled(&self, on: bool, dropped_total: u64, fec_total: u64) {
let was = self.enabled.swap(on, Ordering::Relaxed);
if on && !was {
let mut g = self
@@ -131,6 +157,9 @@ impl VideoStats {
g.host_us.clear();
g.net_us.clear();
g.decode_us.clear();
g.skipped = 0;
g.last_dropped_total = dropped_total;
g.last_fec_total = fec_total;
}
}
@@ -206,6 +235,22 @@ impl VideoStats {
g.net_us.push(net_us);
}
/// Record client-side frame skips (spec `skipped`): decoded output buffers released without
/// rendering under the newest-wins policy, or parked AUs dropped on queue overflow.
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn note_skipped(&self, n: u64) {
if n == 0 || !self.enabled.load(Ordering::Relaxed) {
return; // HUD hidden — skip the lock
}
// Poison-proof for the same reason as `note_received`.
let mut g = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.skipped += n;
}
/// Record one decoded output frame: its capture→decoded `end-to-end` sample and its
/// received→decoded `decode` stage sample (either may be absent — e.g. the receipt stamp for
/// this pts predates the HUD being shown).
@@ -229,7 +274,9 @@ impl VideoStats {
}
/// Compute the window's rates + latency percentiles, then reset for the next window.
pub fn drain(&self) -> Snapshot {
/// `dropped_total` / `fec_total` are the connector's session-cumulative counters now; the
/// snapshot's `lost` / `fec` are their deltas since the last drain (or the enabling show).
pub fn drain(&self, dropped_total: u64, fec_total: u64) -> Snapshot {
// Poison-proof for the same reason as `note_received` — a poisoned window still drains
// fine.
let mut g = self
@@ -255,6 +302,10 @@ impl VideoStats {
net_p50_ms: pctl_ms(&g.net_us, 0.50),
lat_valid: !g.e2e_us.is_empty(),
skew_corrected: g.skew_corrected,
frames: g.frames,
lost: dropped_total.saturating_sub(g.last_dropped_total),
skipped: g.skipped,
fec: fec_total.saturating_sub(g.last_fec_total),
};
g.window_start = Instant::now();
g.frames = 0;
@@ -264,6 +315,9 @@ impl VideoStats {
g.host_us.clear();
g.net_us.clear();
g.decode_us.clear();
g.skipped = 0;
g.last_dropped_total = dropped_total;
g.last_fec_total = fec_total;
snap
}
}
@@ -422,14 +422,24 @@ struct ContentView: View {
host, launchID: launchID, allowTofu: allowTofu,
requestAccess: requestAccess, approvalReq: approvalReq)
}
// Asleep (not advertising) and we can wake it? Fire the magic packet and WAIT for it to come
// back online a cold box takes far longer to boot than a connect will sit showing the
// "Waking" overlay meanwhile. Then connect. Otherwise dial straight away.
// Not advertising and we can wake it? DIAL FIRST anyway no mDNS advert does NOT mean
// unreachable: a host reached over a routed network (Tailscale/VPN/another subnet) is
// mDNS-blind forever, and gating the dial on presence bricked exactly those reconnects
// (the host log shows no connection attempt at all; the tile pip and this gate share the
// LAN-only `advertises` predicate). `prepareWake` inside the dial already fires the magic
// packet up front, so a genuinely-asleep host is waking while the connect times out; only
// when that dial FAILS do we fall into the visible "Waking" wait a cold box takes far
// longer to boot than a connect will sit and redial once it's back on mDNS.
if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty, !discovery.advertises(host) {
discovery.start() // so we can observe it reappear
waker.start(
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
isOnline: { discovery.advertises(host) }, onOnline: go)
discovery.start() // so the wake-wait can observe it reappear
startSessionDirect(
host, launchID: launchID, allowTofu: allowTofu,
requestAccess: requestAccess, approvalReq: approvalReq,
onUnreachable: {
waker.start(
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
isOnline: { discovery.advertises(host) }, onOnline: go)
})
} else {
go()
}
@@ -437,10 +447,12 @@ struct ContentView: View {
/// The actual dial reached directly when the host is awake, or from the waker once a woken
/// host is back online. `prepareWake` still runs here to LEARN/refresh the MAC now that the host
/// is advertising (and is a harmless no-op otherwise).
/// is advertising (and is a harmless no-op otherwise). `onUnreachable` hands a plain connect
/// failure back to the caller (the wake-wait fallback) instead of the error alert.
private func startSessionDirect(
_ host: StoredHost, launchID: String? = nil,
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil,
onUnreachable: (@MainActor () -> Void)? = nil
) {
prepareWake(for: host)
// The delegated-approval wait prompt only makes sense once we're actually dialing set it
@@ -461,7 +473,8 @@ struct ContentView: View {
preferredCodec: preferredCodecByte,
launchID: launchID,
allowTofu: allowTofu,
requestAccess: requestAccess)
requestAccess: requestAccess,
onUnreachable: onUnreachable)
}
/// Learn-while-awake, wake-while-asleep run just before every connect:
@@ -135,6 +135,10 @@ final class SessionModel: ObservableObject {
/// successful connect streams directly (the approval IS the trust decision) the caller pins
/// the observed fingerprint as paired. `host.pinnedSHA256`, when set, pins the advertised cert
/// for the wait; nil = trust-on-first-use.
/// `onUnreachable`, when set, replaces the "could not connect" alert for a plain connect
/// failure: the caller takes over recovery (the Wake-on-LAN wait for a host that stopped
/// advertising). It never fires for the delegated-approval path, whose failure text carries
/// its own instructions.
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
compositor: PunktfunkConnection.Compositor = .auto,
gamepad: PunktfunkConnection.GamepadType = .auto,
@@ -145,7 +149,8 @@ final class SessionModel: ObservableObject {
launchID: String? = nil,
allowTofu: Bool = false,
autoTrust: Bool = false,
requestAccess: Bool = false) {
requestAccess: Bool = false,
onUnreachable: (@MainActor () -> Void)? = nil) {
guard phase == .idle else { return }
phase = .connecting
activeHost = host
@@ -241,7 +246,11 @@ final class SessionModel: ObservableObject {
case .failure:
self.phase = .idle
self.activeHost = nil
if requestAccess {
if let onUnreachable, !requestAccess {
// The caller owns recovery (wake-and-retry) no error alert here; its
// own overlay explains what's happening.
onUnreachable()
} else if requestAccess {
// The delegated-approval connect ended without being admitted: the
// operator didn't approve it before the host's park window elapsed (or
// the host was unreachable).
+28
View File
@@ -154,6 +154,30 @@ mod session_main {
}
}
/// Steam Deck / RADV: Mesa gates Vulkan Video decode — the `VK_KHR_video_decode_*`
/// extensions AND the decode-capable queue family — behind `RADV_PERFTEST=video_decode`.
/// Without it the presenter's device advertises no decode queue, so `Decoder::new`'s
/// `auto` path can't build the Vulkan decoder and the session silently falls back to
/// VAAPI (whose separate-plane dmabuf import shows chroma fringing — green/yellow specks
/// around the cursor — on VanGogh). We want the Vulkan path, so opt in here, before the
/// RADV driver loads (the Vulkan instance is created later, inside `run_session`).
///
/// RADV-only knob: ANV/NVIDIA/other drivers ignore `RADV_PERFTEST`, and a box where video
/// decode is already the default just no-ops. Append rather than clobber so a user's own
/// `RADV_PERFTEST` survives; `PUNKTFUNK_DECODER=vaapi` still overrides the decoder choice.
fn enable_radv_video_decode() {
const TOKEN: &str = "video_decode";
match std::env::var("RADV_PERFTEST") {
Ok(v) if v.split(',').any(|t| t == TOKEN) => return,
Ok(v) if !v.is_empty() => std::env::set_var("RADV_PERFTEST", format!("{v},{TOKEN}")),
_ => std::env::set_var("RADV_PERFTEST", TOKEN),
}
tracing::info!(
radv_perftest = %std::env::var("RADV_PERFTEST").unwrap_or_default(),
"opted into RADV Vulkan Video decode (Mesa gates it behind RADV_PERFTEST on the Deck)"
);
}
pub fn run() -> u8 {
// Logs to STDERR — stdout is the machine interface (ready/stats/error lines).
tracing_subscriber::fmt()
@@ -164,6 +188,10 @@ mod session_main {
)
.init();
// Before any Vulkan call: make RADV expose its video-decode queue + extensions so the
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
enable_radv_video_decode();
// Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming
// every pad Steam Input has virtualized; capturing the Deck's real built-in
// controller needs it cleared (same rationale as the GTK client's `app::run`).
+39 -3
View File
@@ -58,6 +58,11 @@ pub struct AppModel {
hosts: Controller<HostsPage>,
/// One session child at a time — connects while one runs are ignored.
busy: bool,
/// Armed by [`AppMsg::WakeConnect`] (a dial to a host that isn't advertising but has a
/// known MAC): if THAT dial's child exits with a connect failure, `SessionExited` falls
/// back into the visible wake-and-wait instead of an error. Consumed on the next exit and
/// matched against the exiting request, so it can never redirect an unrelated failure.
wake_fallback: Option<ConnectRequest>,
/// The request-access "waiting for approval" dialog, closed on the first child
/// event. A shared slot (not a message): dialogs are main-thread GTK objects and
/// `AppMsg` must stay `Send` for the session child's reader thread.
@@ -68,7 +73,9 @@ pub struct AppModel {
pub enum AppMsg {
/// The trust gate in front of every connect (rules 13, see `update`).
Connect(ConnectRequest),
/// Wake an offline saved host, poll until it advertises, then `Connect`.
/// Connect to a saved host that isn't advertising but has a known MAC: fire a wake
/// packet and DIAL IMMEDIATELY (mDNS absence ≠ unreachable — routed/Tailscale hosts
/// never advertise here); only a failed dial falls into the visible wake-and-wait.
WakeConnect(ConnectRequest),
/// The SPAKE2 PIN ceremony dialog.
Pair(ConnectRequest),
@@ -192,6 +199,7 @@ impl SimpleComponent for AppModel {
gamepad: init.gamepad,
hosts,
busy: false,
wake_fallback: None,
waiting: Rc::new(RefCell::new(None)),
};
install_actions(&model.window, &sender);
@@ -293,7 +301,15 @@ impl SimpleComponent for AppModel {
}
AppMsg::WakeConnect(req) => {
if !self.busy {
crate::ui_trust::wake_and_connect(&self.window, &sender, req);
// DIAL FIRST — no mDNS advert does NOT mean unreachable: a host reached over
// a routed network (Tailscale/VPN/another subnet) is mDNS-blind forever, and
// gating the dial on presence bricked exactly those reconnects. Fire the magic
// packet now (fire-and-forget — harmless if it's awake) so a genuinely-asleep
// box is already booting while the dial times out, arm the wake-wait fallback
// for THIS request, and connect immediately.
crate::wol::wake(&req.mac, req.addr.parse().ok());
self.wake_fallback = Some(req.clone());
sender.input(AppMsg::Connect(req));
}
}
AppMsg::Pair(req) => {
@@ -365,11 +381,23 @@ impl SimpleComponent for AppModel {
self.close_waiting();
self.busy = false;
self.hosts.emit(HostsMsg::SetConnecting(None));
// The dial-first wake fallback (armed by `WakeConnect`, consumed on every exit):
// a failed dial to the non-advertising host it was armed for falls into the
// visible wake-and-wait instead of an error alert. Matched by fingerprint (else
// address) so a stale armed request can never redirect another host's failure.
let wake_fb =
self.wake_fallback
.take()
.filter(|fb| match (&fb.fp_hex, &req.fp_hex) {
(Some(a), Some(b)) => a == b,
_ => fb.addr == req.addr && fb.port == req.port,
});
match (code, error, ended) {
(0, _, None) => {} // clean end — back on the hosts page, no noise
(0, _, Some(reason)) => self.hosts.emit(HostsMsg::ShowError(reason)),
(_, Some((_, true)), _) if !tofu => {
// The stored pin no longer matches (rotated cert or impostor).
// The stored pin no longer matches (rotated cert or impostor). The host
// ANSWERED — never the wake fallback.
self.toast("Host fingerprint changed — re-pair with a PIN to continue");
crate::ui_trust::pin_dialog(
&self.window,
@@ -378,10 +406,18 @@ impl SimpleComponent for AppModel {
req,
);
}
// A fingerprint mismatch means the host ANSWERED — reachable, so the plain
// error arms below handle it; only a genuine connect failure wakes.
(_, Some((_, false)), _) if wake_fb.is_some() => {
crate::ui_trust::wake_and_connect(&self.window, &sender, req)
}
(_, Some((msg, _)), _) => self
.hosts
.emit(HostsMsg::ShowError(format!("Couldn't connect — {msg}"))),
(-1, None, _) => {} // killed (request-access cancel) — already handled
(_, None, _) if wake_fb.is_some() => {
crate::ui_trust::wake_and_connect(&self.window, &sender, req)
}
(code, None, _) => self.hosts.emit(HostsMsg::ShowError(format!(
"Stream session failed (punktfunk-session exit {code})"
))),
+3 -1
View File
@@ -306,7 +306,9 @@ impl relm4::factory::FactoryComponent for HostCard {
}
overlay.add_controller(right_click);
// Auto-wake: offline + a known MAC routes to wake-and-wait.
// Auto-wake: not advertising + a known MAC routes to WakeConnect, which
// dials first (a routed/Tailscale host is mDNS-blind, not asleep) and only
// falls into the wake-and-wait when the dial fails.
let wake_first = !online && !req.mac.is_empty();
let sender = sender.clone();
returned.connect_activate(move |_| {
+6 -5
View File
@@ -11,11 +11,12 @@ use adw::prelude::*;
use gtk::glib;
use relm4::prelude::*;
/// Wake-and-wait: an **offline** saved host with a known MAC is sent a magic packet,
/// then we poll mDNS until it comes back online — re-sending every few seconds up to a
/// timeout — and route back into the trust gate, **re-keying the saved record if the
/// host woke on a new DHCP IP** (matched by fingerprint). A "Waking…" dialog lets the
/// user cancel. Mirrors the Apple/Android `HostWaker` (90 s budget, resend every 6 s).
/// Wake-and-wait: the FALLBACK after a failed dial to a non-advertising saved host with a
/// known MAC (`AppMsg::WakeConnect` dials first — mDNS absence ≠ unreachable). The host is
/// sent a magic packet, then we poll mDNS until it comes back online — re-sending every few
/// seconds up to a timeout — and route back into the trust gate, **re-keying the saved
/// record if the host woke on a new DHCP IP** (matched by fingerprint). A "Waking…" dialog
/// lets the user cancel. Mirrors the Apple/Android `HostWaker` (90 s budget, resend every 6 s).
pub fn wake_and_connect(
window: &adw::ApplicationWindow,
sender: &ComponentSender<AppModel>,
+6 -1
View File
@@ -113,7 +113,12 @@ for scene in "${SCENES[@]}"; do
"$BIN" >"$WORK/log" 2>&1 &
pid=$!
ready=0
for _ in $(seq 1 200); do # up to ~20s
# Up to ~60s: a warm scene signals in ~1-2s, but the FIRST launch pays a large one-time
# cold-start under software rendering (llvmpipe GL-shader + fontconfig cache build, plus
# first-run client-identity generation into the shared scratch HOME) that can run ~25s on a
# loaded CI runner. A 20s cap tripped exactly one scene (the first) even though every scene
# rendered fine — give it headroom instead of red-lighting the whole capture on cold start.
for _ in $(seq 1 600); do
if grep -q "PF_SHOT_READY" "$WORK/log"; then
ready=1
break
+57 -9
View File
@@ -23,6 +23,32 @@ pub(crate) fn initiate(
target: Target,
set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>,
) {
initiate_opts(ctx, target, set_screen, set_status, false)
}
/// Dial-first for a saved host that isn't advertising but has a known MAC: fire the magic packet
/// now (fire-and-forget — harmless if it's awake, and a genuinely-asleep box is already booting
/// while the dial times out) and dial IMMEDIATELY. mDNS absence does NOT mean unreachable — a
/// host reached over a routed network (Tailscale/VPN/another subnet) is mDNS-blind forever, and
/// gating the dial on presence bricked exactly those reconnects. Only a failed dial falls into
/// the visible [`wake_and_connect`] wait.
pub(crate) fn initiate_waking(
ctx: &Arc<AppCtx>,
target: Target,
set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>,
) {
crate::wol::wake(&target.mac, target.addr.parse().ok());
initiate_opts(ctx, target, set_screen, set_status, true)
}
fn initiate_opts(
ctx: &Arc<AppCtx>,
target: Target,
set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>,
wake_on_fail: bool,
) {
let known = KnownHosts::load();
let pin = target
@@ -36,10 +62,14 @@ pub(crate) fn initiate(
})
.and_then(|fp| trust::parse_hex32(&fp));
let opts = ConnectOpts {
wake_on_fail,
..ConnectOpts::default()
};
if let Some(pin) = pin {
connect(ctx, &target, Some(pin), set_screen, set_status);
connect_with(ctx, &target, Some(pin), set_screen, set_status, opts);
} else if target.pair_optional {
connect(ctx, &target, None, set_screen, set_status); // TOFU
connect_with(ctx, &target, None, set_screen, set_status, opts); // TOFU
} else {
*ctx.shared.target.lock().unwrap() = target;
set_screen.call(Screen::Pair);
@@ -141,6 +171,13 @@ pub(crate) struct ConnectOpts {
/// silently when the parked connect finally resolves — without touching a screen a new
/// session may already own.
cancel: Option<Arc<AtomicBool>>,
/// Fall into the Wake-on-LAN wait ([`wake_and_connect`]) when THIS dial fails with a plain
/// connect failure (not a trust rejection). Set by the dial-first path for a saved host that
/// isn't advertising but has a known MAC — the dial is attempted unconditionally (mDNS
/// absence ≠ unreachable: routed/Tailscale hosts never advertise here), and only a real
/// failure escalates to the visible "Waking…" wait. The wait's own redial clears the flag,
/// so it can't loop.
wake_on_fail: bool,
}
impl Default for ConnectOpts {
@@ -150,6 +187,7 @@ impl Default for ConnectOpts {
persist_paired: false,
awaiting_approval: false,
cancel: None,
wake_on_fail: false,
}
}
}
@@ -210,6 +248,8 @@ fn connect_with(
let tofu = pin.is_none();
let persist_paired = opts.persist_paired;
let cancel = opts.cancel;
let wake_on_fail = opts.wake_on_fail;
let ctx = ctx.clone();
let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone());
let (ss, st) = (set_screen.clone(), set_status.clone());
let target = target.clone();
@@ -264,8 +304,14 @@ fn connect_with(
gamepad.detach();
if trust_rejected {
// Pinned-fingerprint mismatch / pairing required → re-pair via the PIN screen.
// The host ANSWERED, so this never takes the wake fallback.
*shared.target.lock().unwrap() = target.clone();
ss.call(Screen::Pair);
} else if wake_on_fail {
// The dial-first attempt to a non-advertising host failed — it may genuinely
// be asleep. NOW wake and wait (its resolved redial uses default opts, so a
// second failure lands on the host list, not back here).
wake_and_connect(&ctx, target.clone(), &ss, &st);
} else {
ss.call(Screen::Hosts);
}
@@ -310,17 +356,19 @@ pub(crate) fn request_access(props: &Svc, target: &Target) {
persist_paired: true,
awaiting_approval: true,
cancel: Some(cancel),
wake_on_fail: false,
},
);
}
/// The Wake-on-LAN "wait until up" flow (mirrors the Apple `HostWaker`): the tapped saved host is
/// offline but has a MAC, so send a magic packet, show a cancelable "Waking…" screen, and POLL mDNS
/// for the host to reappear — re-sending the packet periodically — on a bounded deadline. A cold box
/// takes far longer to POST/boot/re-advertise than a connect attempt will sit, so we can't just
/// fire-and-dial. On reappearance we dial it (re-keying the saved host when it came back on a new
/// IP); on timeout or Cancel we return to the host list.
pub(crate) fn wake_and_connect(
/// The Wake-on-LAN "wait until up" flow (mirrors the Apple `HostWaker`): the FALLBACK after a
/// failed dial-first attempt ([`initiate_waking`]) to a non-advertising saved host with a MAC.
/// Send a magic packet, show a cancelable "Waking…" screen, and POLL mDNS for the host to
/// reappear — re-sending the packet periodically — on a bounded deadline (a cold box takes far
/// longer to POST/boot/re-advertise than a connect attempt will sit). On reappearance we dial it
/// (re-keying the saved host when it came back on a new IP); on timeout or Cancel we return to
/// the host list.
fn wake_and_connect(
ctx: &Arc<AppCtx>,
target: Target,
set_screen: &AsyncSetState<Screen>,
+90
View File
@@ -0,0 +1,90 @@
//! The Help screen: a short note on the in-stream capture model plus a reference of the keyboard
//! shortcuts — reached from the Help button on the host list. The Windows counterpart of the GTK
//! client's Keyboard Shortcuts window; the bindings themselves live in [`crate::input`], so both
//! clients document the same set.
use super::style::*;
use super::Screen;
use windows_reactor::*;
/// The in-stream keyboard shortcuts, in the GTK Shortcuts window's order: the chord, then what it
/// does. Read-only — the bindings themselves live in the input hook ([`crate::input`]).
const STREAM_SHORTCUTS: &[(&str, &str)] = &[
("F11", "Toggle fullscreen"),
(
"Ctrl+Alt+Shift+Q",
"Release captured input (click the stream to recapture)",
),
("Ctrl+Alt+Shift+D", "Disconnect"),
("Ctrl+Alt+Shift+S", "Toggle the statistics overlay"),
];
/// A subtle key-cap chip for the shortcuts reference — the chord on a filled, bordered pill.
fn key_chip(keys: &str) -> Element {
border(text_block(keys).font_size(12.0).semibold())
.background(ThemeRef::SubtleFill)
.border_brush(ThemeRef::CardStroke)
.border_thickness(uniform(1.0))
.corner_radius(6.0)
.padding(edges(8.0, 3.0, 8.0, 3.0))
.horizontal_alignment(HorizontalAlignment::Left)
.into()
}
/// A read-only reference card listing the in-stream keyboard shortcuts. One grid, chord chip then
/// action, so the actions line up across rows.
fn shortcuts_reference() -> Element {
let mut children: Vec<Element> = Vec::new();
for (i, (keys, action)) in STREAM_SHORTCUTS.iter().enumerate() {
let row = i as i32;
children.push(key_chip(keys).grid_row(row).grid_column(0));
let action_cell: Element = text_block(*action)
.foreground(ThemeRef::SecondaryText)
.vertical_alignment(VerticalAlignment::Center)
.into();
children.push(action_cell.grid_row(row).grid_column(1));
}
let table = grid(children)
.columns([GridLength::Auto, GridLength::Star(1.0)])
.rows(vec![GridLength::Auto; STREAM_SHORTCUTS.len()])
.column_spacing(12.0)
.row_spacing(6.0);
card(vstack((
text_block("In-stream keyboard shortcuts")
.semibold()
.margin(edges(0.0, 0.0, 0.0, 8.0)),
table,
)))
.into()
}
/// The Help screen: a `page`-column with a Back button to the host list, an intro card on the
/// capture model, and the shortcuts reference. Hook-free — called inline from `root` like the
/// other static screens.
pub(crate) fn help_page(set_screen: &AsyncSetState<Screen>) -> Element {
let back_btn = button("Back").accent().icon(Symbol::Back).on_click({
let ss = set_screen.clone();
move || ss.call(Screen::Hosts)
});
let intro = card(
vstack((
text_block("During a stream").font_size(15.0).semibold(),
text_block(
"Click the stream to capture your mouse and keyboard \u{2014} the shortcuts below \
then work while you play. Release capture to hand the cursor back to this \
computer, and click the stream again to retake it.",
)
.font_size(12.0)
.wrap()
.foreground(ThemeRef::SecondaryText),
))
.spacing(8.0),
);
page(vec![
page_header("Help", back_btn),
intro.into(),
shortcuts_reference(),
])
}
+10 -5
View File
@@ -2,7 +2,7 @@
//! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / rename /
//! forget) and a manual connect entry — the same card layout as the Linux and Apple clients.
use super::connect::{initiate, wake_and_connect};
use super::connect::{initiate, initiate_waking};
use super::speed::SpeedState;
use super::style::*;
use super::{Screen, Svc, Target};
@@ -277,6 +277,10 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
let sa = set_show_add.clone();
move || sa.call(true)
}),
button("Help").icon(Symbol::Help).on_click({
let ss = set_screen.clone();
move || ss.call(Screen::Help)
}),
button("Settings").icon(Symbol::Setting).on_click({
let ss = set_screen.clone();
move || ss.call(Screen::Settings)
@@ -386,11 +390,12 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
),
Some(menu),
Some(Box::new(move || {
// Offline saved host with a known MAC: wake it and WAIT for it to reappear on
// the network (re-sending periodically) before dialing — a cold box boots far
// slower than a connect will sit. An online host dials straight away.
// Saved host with a known MAC that isn't advertising: fire a wake packet and
// DIAL IMMEDIATELY — mDNS absence ≠ unreachable (a routed/Tailscale host never
// advertises here); only a failed dial falls into the "Waking…" wait. An
// online host dials straight away.
if can_wake {
wake_and_connect(&ctx2, target.clone(), &ss, &st);
initiate_waking(&ctx2, target.clone(), &ss, &st);
} else {
initiate(&ctx2, target.clone(), &ss, &st);
}
+9 -3
View File
@@ -8,7 +8,8 @@
//! * [`connect`] — the trust gate and session lifecycle glue (connect / request-access flows)
//! * [`pair`] — the SPAKE2 PIN pairing ceremony
//! * [`speed`] — the per-host network speed test (probe burst over the real data plane)
//! * [`settings`] — persisted preferences · [`licenses`] — the license notices screen
//! * [`settings`] — persisted preferences · [`licenses`] — the license notices screen ·
//! [`help`] — the in-stream keyboard-shortcuts reference (reached from the host list)
//! * [`stream`] — the live stream: `SwapChainPanel` + D3D11 presenter + HUD overlay
//! * [`style`] — the shared look (cards, pills, monograms), following the windows-reactor
//! gallery: Mica backdrop, a centred max-width column, theme brushes (`ThemeRef`)
@@ -23,6 +24,7 @@
//! present must not go through state/rerender.
mod connect;
mod help;
mod hosts;
mod licenses;
mod pair;
@@ -57,6 +59,9 @@ pub(crate) enum Screen {
Settings,
/// Open-source / third-party license notices (reached from Settings).
Licenses,
/// In-stream keyboard-shortcuts reference + capture help (reached from the host list's Help
/// button).
Help,
Pair,
/// Per-host network speed test (probe burst + recommended bitrate).
SpeedTest,
@@ -382,8 +387,8 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
set_hover,
},
),
// connecting_page / request_access_page / waking_page / settings_page / licenses_page use
// no hooks (they never touch `cx`), so calling them inline is sound.
// connecting_page / request_access_page / waking_page / settings_page / licenses_page /
// help_page use no hooks (they never touch `cx`), so calling them inline is sound.
Screen::Connecting => connect::connecting_page(ctx, &status),
Screen::RequestAccess => connect::request_access_page(ctx, &set_screen),
Screen::Waking => connect::waking_page(ctx, &set_screen),
@@ -395,6 +400,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
nav_progress,
),
Screen::Licenses => licenses::licenses_page(&set_screen),
Screen::Help => help::help_page(&set_screen),
Screen::Pair => component(pair::pair_page, svc),
Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }),
Screen::Stream => component(stream::stream_page, StreamProps { svc, hud }),
+5 -63
View File
@@ -109,59 +109,6 @@ fn settings_card(controls: Vec<Element>) -> Element {
card(vstack(controls).spacing(10.0)).into()
}
/// The in-stream keyboard shortcuts, in the GTK Shortcuts window's order: the chord, then what it
/// does. Read-only — the bindings themselves live in the input hook (`crate::input`); this is the
/// Windows analogue of that window, so both clients document the same set.
const STREAM_SHORTCUTS: &[(&str, &str)] = &[
("F11", "Toggle fullscreen"),
(
"Ctrl+Alt+Shift+Q",
"Release captured input (click the stream to recapture)",
),
("Ctrl+Alt+Shift+D", "Disconnect"),
("Ctrl+Alt+Shift+S", "Toggle the statistics overlay"),
];
/// A subtle key-cap chip for the shortcuts reference — the chord on a filled, bordered pill.
fn key_chip(keys: &str) -> Element {
border(text_block(keys).font_size(12.0).semibold())
.background(ThemeRef::SubtleFill)
.border_brush(ThemeRef::CardStroke)
.border_thickness(uniform(1.0))
.corner_radius(6.0)
.padding(edges(8.0, 3.0, 8.0, 3.0))
.horizontal_alignment(HorizontalAlignment::Left)
.into()
}
/// A read-only reference card listing the in-stream keyboard shortcuts — the Windows counterpart of
/// the GTK client's Keyboard Shortcuts window. One grid, chord chip then action, so the actions
/// line up across rows.
fn shortcuts_reference() -> Element {
let mut children: Vec<Element> = Vec::new();
for (i, (keys, action)) in STREAM_SHORTCUTS.iter().enumerate() {
let row = i as i32;
children.push(key_chip(keys).grid_row(row).grid_column(0));
let action_cell: Element = text_block(*action)
.foreground(ThemeRef::SecondaryText)
.vertical_alignment(VerticalAlignment::Center)
.into();
children.push(action_cell.grid_row(row).grid_column(1));
}
let table = grid(children)
.columns([GridLength::Auto, GridLength::Star(1.0)])
.rows(vec![GridLength::Auto; STREAM_SHORTCUTS.len()])
.column_spacing(12.0)
.row_spacing(6.0);
card(vstack((
text_block("In-stream keyboard shortcuts")
.semibold()
.margin(edges(0.0, 0.0, 0.0, 8.0)),
table,
)))
.into()
}
/// The settings screen: a stock WinUI `NavigationView` (the Windows-Settings sidebar pattern) —
/// one pane item per section, the section's card as the content, the built-in back arrow
/// returning to the host list. `section`/`set_section` are the selected pane tag, held in ROOT
@@ -399,16 +346,11 @@ pub(crate) fn settings_page(
),
"input" => (
"Input",
vstack((
settings_card(vec![
forward_combo.into(),
pad_combo.into(),
shortcuts_toggle.into(),
]),
shortcuts_reference(),
))
.spacing(14.0)
.into(),
settings_card(vec![
forward_combo.into(),
pad_combo.into(),
shortcuts_toggle.into(),
]),
),
"audio" => (
"Audio",
+87 -52
View File
@@ -179,7 +179,11 @@ pub fn uninstall() {
}
}
if let Some(mut st) = STATE.lock().unwrap().take() {
set_captured(&mut st, false); // hand the cursor back + flush held state
// Hand the cursor back + flush held state.
set_captured(&mut st, false);
// Fullscreen is a streaming-only mode: if F11 put us there, drop back to a normal window
// so the GUI (the host list) is never left borderless-fullscreen after the stream ends.
exit_fullscreen(HWND(st.hwnd as *mut _));
}
}
@@ -244,73 +248,104 @@ fn set_locked(st: &mut State, on: bool) {
st.locked = on;
}
/// Toggle borderless fullscreen for our top-level window (F11). The classic Win32 dance: entering,
/// save the window placement and strip `WS_OVERLAPPEDWINDOW`, then size the window to the whole
/// monitor; exiting, restore the style and the saved placement. The window's own style bit doubles
/// as the fullscreen flag, so no extra state beyond the saved placement is needed. windows-reactor
/// owns the WinUI window but exposes no fullscreen API, so we drive the HWND directly (parity with
/// the GTK client's F11). The SwapChainPanel follows the resulting `WM_SIZE` like any window resize.
fn toggle_fullscreen(hwnd: isize) {
/// The pre-fullscreen window placement, saved on entering fullscreen and restored on leaving it.
/// Module-level (not a `toggle_fullscreen`-local static) so the F11 toggle and the stream-stop exit
/// ([`uninstall`]) share the one saved placement, and its presence is also the "are we fullscreen?"
/// flag for [`exit_fullscreen`]. Only ever touched on the UI thread (the hook proc / the stream
/// page's unmount), but a Mutex keeps the static sound + `Sync`.
static SAVED_PLACEMENT: Mutex<Option<windows::Win32::UI::WindowsAndMessaging::WINDOWPLACEMENT>> =
Mutex::new(None);
/// Whether our top-level window is currently borderless-fullscreen. Entering strips
/// `WS_OVERLAPPEDWINDOW`, so its absence is the flag — no extra state beyond [`SAVED_PLACEMENT`].
fn is_fullscreen(hwnd: HWND) -> bool {
use windows::Win32::UI::WindowsAndMessaging::{
GetWindowLongPtrW, GWL_STYLE, WS_OVERLAPPEDWINDOW,
};
let overlapped = WS_OVERLAPPEDWINDOW.0 as isize;
unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) & overlapped == 0 }
}
/// Enter borderless fullscreen: remember the window placement, drop the frame
/// (`WS_OVERLAPPEDWINDOW`), and size the window to cover the whole monitor. windows-reactor owns
/// the WinUI window but exposes no fullscreen API, so we drive the HWND directly (parity with the
/// GTK client's F11). The SwapChainPanel follows the resulting `WM_SIZE` like any window resize.
fn enter_fullscreen(hwnd: HWND) {
use windows::Win32::Graphics::Gdi::{
GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTOPRIMARY,
};
use windows::Win32::UI::WindowsAndMessaging::{
GetWindowLongPtrW, GetWindowPlacement, SetWindowLongPtrW, SetWindowPlacement, SetWindowPos,
GWL_STYLE, SWP_FRAMECHANGED, SWP_NOMOVE, SWP_NOOWNERZORDER, SWP_NOSIZE, SWP_NOZORDER,
WINDOWPLACEMENT, WS_OVERLAPPEDWINDOW,
GetWindowLongPtrW, GetWindowPlacement, SetWindowLongPtrW, SetWindowPos, GWL_STYLE,
SWP_FRAMECHANGED, SWP_NOOWNERZORDER, SWP_NOZORDER, WINDOWPLACEMENT, WS_OVERLAPPEDWINDOW,
};
// The pre-fullscreen placement, so exiting restores the exact windowed size + position. Only
// ever touched on the UI thread (the hook proc), but a Mutex keeps the static sound + `Sync`.
static SAVED: Mutex<Option<WINDOWPLACEMENT>> = Mutex::new(None);
let hwnd = HWND(hwnd as *mut _);
let overlapped = WS_OVERLAPPEDWINDOW.0 as isize;
unsafe {
let style = GetWindowLongPtrW(hwnd, GWL_STYLE);
if style & overlapped != 0 {
// Windowed → fullscreen: remember where we were, drop the frame, cover the monitor.
let mut wp = WINDOWPLACEMENT {
length: std::mem::size_of::<WINDOWPLACEMENT>() as u32,
..Default::default()
};
let mut mi = MONITORINFO {
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
..Default::default()
};
let mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY);
if GetWindowPlacement(hwnd, &mut wp).is_ok() && GetMonitorInfoW(mon, &mut mi).as_bool()
{
*SAVED.lock().unwrap() = Some(wp);
SetWindowLongPtrW(hwnd, GWL_STYLE, style & !overlapped);
let r = mi.rcMonitor;
let _ = SetWindowPos(
hwnd,
None,
r.left,
r.top,
r.right - r.left,
r.bottom - r.top,
SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED,
);
}
} else {
// Fullscreen → windowed: restore the frame, then the saved placement.
SetWindowLongPtrW(hwnd, GWL_STYLE, style | overlapped);
if let Some(wp) = SAVED.lock().unwrap().take() {
let _ = SetWindowPlacement(hwnd, &wp);
}
let mut wp = WINDOWPLACEMENT {
length: std::mem::size_of::<WINDOWPLACEMENT>() as u32,
..Default::default()
};
let mut mi = MONITORINFO {
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
..Default::default()
};
let mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY);
if GetWindowPlacement(hwnd, &mut wp).is_ok() && GetMonitorInfoW(mon, &mut mi).as_bool() {
*SAVED_PLACEMENT.lock().unwrap() = Some(wp);
SetWindowLongPtrW(hwnd, GWL_STYLE, style & !overlapped);
let r = mi.rcMonitor;
let _ = SetWindowPos(
hwnd,
None,
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED,
r.left,
r.top,
r.right - r.left,
r.bottom - r.top,
SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED,
);
}
}
}
/// Leave borderless fullscreen: restore the frame style and the saved placement. A no-op when we
/// aren't fullscreen (nothing saved), so it's safe to call unconditionally on stream stop.
fn exit_fullscreen(hwnd: HWND) {
use windows::Win32::UI::WindowsAndMessaging::{
GetWindowLongPtrW, SetWindowLongPtrW, SetWindowPlacement, SetWindowPos, GWL_STYLE,
SWP_FRAMECHANGED, SWP_NOMOVE, SWP_NOOWNERZORDER, SWP_NOSIZE, SWP_NOZORDER,
WS_OVERLAPPEDWINDOW,
};
let Some(wp) = SAVED_PLACEMENT.lock().unwrap().take() else {
return; // never went fullscreen — nothing to restore
};
let overlapped = WS_OVERLAPPEDWINDOW.0 as isize;
unsafe {
let style = GetWindowLongPtrW(hwnd, GWL_STYLE);
SetWindowLongPtrW(hwnd, GWL_STYLE, style | overlapped);
let _ = SetWindowPlacement(hwnd, &wp);
let _ = SetWindowPos(
hwnd,
None,
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED,
);
}
}
/// Toggle borderless fullscreen for our top-level window (F11), the classic Win32 dance split into
/// [`enter_fullscreen`] / [`exit_fullscreen`] so the stream-stop path can force windowed too.
fn toggle_fullscreen(hwnd: isize) {
let hwnd = HWND(hwnd as *mut _);
if is_fullscreen(hwnd) {
exit_fullscreen(hwnd);
} else {
enter_fullscreen(hwnd);
}
}
fn send(c: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
let _ = c.send_input(&InputEvent {
kind,
+6 -3
View File
@@ -1227,8 +1227,11 @@ impl Worker {
/// Drain and render the feedback planes — rumble plus HID output (lightbar /
/// player LEDs / adaptive triggers) — on the active pad; this thread is their single
/// consumer. The host re-sends rumble state periodically, so a generous duration with
/// refresh-on-update is safe — a dropped stop heals within ~500 ms.
/// consumer. The host re-sends rumble state every ~500 ms, so the SDL duration only
/// needs to outlive a couple of refresh periods: long enough that one or two lost
/// refreshes don't gap a genuine long rumble, short enough that a stale nonzero state
/// (a stop lost host-side, a session torn down mid-buzz) dies on its own instead of
/// droning for seconds.
fn render_feedback(&mut self) {
let Some(connector) = self.attached.clone() else {
return;
@@ -1240,7 +1243,7 @@ impl Worker {
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
// host logs the send side on 0xCA, so the two together pinpoint host-game vs
// client-render.
if let Err(e) = p.set_rumble(low, high, 5_000) {
if let Err(e) = p.set_rumble(low, high, 1_500) {
tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed");
} else {
tracing::debug!(low, high, "rumble: rendered");
+30 -3
View File
@@ -530,7 +530,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
}
}
SessionEvent::Stats(s) => {
st.osd_text = stats_text(&st.mode_line, &s, &st.presented, st.hdr);
st.osd_text = stats_text(
&st.mode_line,
&s,
&st.presented,
st.hdr,
presenter.hdr_active(),
);
if print_stats {
println!("stats: {}", st.osd_text.replace('\n', " | "));
}
@@ -627,6 +633,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// --- Frames: drain to the newest, upload + present -------------------------------
let mut presented_video = false;
if let Some(st) = &mut stream {
// Mastering metadata (0xCE) → the presentation engine, ahead of the frame
// that needs it. Low-rate (session start + mastering changes / keyframes).
if let Some(c) = &st.connector {
while let Ok(m) = c.next_hdr_meta(Duration::ZERO) {
presenter.set_hdr_metadata(m);
}
}
let mut newest: Option<DecodedFrame> = None;
while let Ok(f) = st.handle.frames.try_recv() {
newest = Some(f);
@@ -785,13 +798,27 @@ const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift
/// The unified stats window (design/stats-unification.md) as OSD text — multi-line for
/// the console-UI panel; the stdout `stats:` line joins it with `|`.
fn stats_text(mode_line: &str, s: &Stats, p: &PresentedWindow, hdr: bool) -> String {
///
/// The HDR tag is honest about the display path: `HDR` only when the swapchain actually
/// runs HDR10 (`hdr_display`); a PQ stream tone-mapped onto an SDR surface (no HDR10
/// format offered, HDR off in the compositor) shows `HDR→SDR` instead.
fn stats_text(
mode_line: &str,
s: &Stats,
p: &PresentedWindow,
hdr_stream: bool,
hdr_display: bool,
) -> String {
let mut text = format!(
"{mode_line} · {:.0} fps · {:.1} Mb/s · {}{}",
s.fps,
s.mbps,
if s.decoder.is_empty() { "-" } else { s.decoder },
if hdr { " · HDR" } else { "" },
match (hdr_stream, hdr_display) {
(true, true) => " · HDR",
(true, false) => " · HDR→SDR",
_ => "",
},
);
text.push_str(&format!(
"\ne2e {:.1}/{:.1} ms (p50/p95)",
+121 -10
View File
@@ -9,10 +9,12 @@
//! proprietary by design) report `supports_dmabuf() == false` and the caller keeps the
//! decoder on software.
//!
//! Pacing: one frame in flight (the submit fence is waited before each record), FIFO by
//! default (`PUNKTFUNK_PRESENT_MODE=mailbox|immediate` if available). Present is
//! arrival-paced by the caller: a frame input on each decoded frame,
//! `FrameInput::Redraw` re-blits the retained video image (expose/resize redraws).
//! Pacing: one frame in flight (the submit fence is waited before each record), MAILBOX
//! when available, FIFO otherwise (`PUNKTFUNK_PRESENT_MODE=fifo|mailbox|immediate`
//! overrides — see `pick_present_mode` for why an arrival-paced presenter must not
//! block in FIFO's present queue). Present is arrival-paced by the caller: a frame
//! input on each decoded frame, `FrameInput::Redraw` re-blits the retained video image
//! (expose/resize redraws).
use crate::csc::{build_fullscreen_pipeline, csc_rows, CscPass};
use crate::dmabuf::{self, HwFrame};
@@ -340,6 +342,12 @@ pub struct Presenter {
hdr10_format: Option<vk::SurfaceFormatKHR>,
/// PQ frames are on screen and the swapchain is in HDR10 mode.
hdr_active: bool,
/// `VK_EXT_hdr_metadata` device fns when the driver offers them (gamescope/KDE do).
hdr_metadata_d: Option<ash::ext::hdr_metadata::Device>,
/// The host's latest ST.2086/CLL metadata (the 0xCE plane) — pushed to the
/// swapchain whenever HDR10 mode is live; `None` until the first datagram lands
/// (a generic HDR10 baseline is pushed meanwhile).
hdr_meta: Option<punktfunk_core::quic::HdrMeta>,
/// The video image / CSC attachment format for the current mode.
video_format: vk::Format,
present_mode: vk::PresentModeKHR,
@@ -432,6 +440,15 @@ impl Presenter {
unavailable"
);
}
// Static HDR metadata (ST.2086 mastering + CLL) to the presentation engine.
// Compositors key their "this app is HDR" signaling on the client pushing
// metadata via vkSetHdrMetadataEXT in addition to picking the HDR10 colorspace
// (gamescope's SteamOS HDR badge and per-app tone-map targets among them) —
// the colorspace alone leaves the app looking SDR to the shell.
let has_hdr_metadata = has(ash::ext::hdr_metadata::NAME);
if has_hdr_metadata {
dev_exts.push(ash::ext::hdr_metadata::NAME.as_ptr());
}
// --- Vulkan Video decode (the FFmpeg-on-our-device path) ---------------------
// Probed, never required: a capable stack gets the video extensions, a second
@@ -557,6 +574,8 @@ impl Presenter {
}
.context("vkCreateDevice")?;
let swap_d = ash::khr::swapchain::Device::new(&instance, &device);
let hdr_metadata_d =
has_hdr_metadata.then(|| ash::ext::hdr_metadata::Device::new(&instance, &device));
let queue = unsafe { device.get_device_queue(qfi, 0) };
let hw = if hw_capable {
Some(HwCtx {
@@ -578,6 +597,9 @@ impl Presenter {
device_extensions
.extend(dmabuf::DEVICE_EXTENSIONS.iter().map(|n| CString::from(*n)));
}
if has_hdr_metadata {
device_extensions.push(CString::from(ash::ext::hdr_metadata::NAME));
}
device_extensions.extend(video_ext_names.iter().map(|n| CString::from(*n)));
Some(pf_client_core::video::VulkanDecodeDevice {
get_instance_proc_addr: entry.static_fn().get_instance_proc_addr as usize,
@@ -603,7 +625,13 @@ impl Presenter {
let (format, hdr10_format) = pick_formats(&surface_i, pdev, surface, has_colorspace_ext)?;
let present_mode = pick_present_mode(&surface_i, pdev, surface)?;
tracing::info!(?format, ?hdr10_format, ?present_mode, "swapchain config");
tracing::info!(
?format,
?hdr10_format,
?present_mode,
hdr_metadata = has_hdr_metadata,
"swapchain config"
);
let overlay_pipe = OverlayPipe::new(&device, format.format)?;
let cmd_pool = unsafe {
@@ -651,6 +679,8 @@ impl Presenter {
format,
hdr10_format,
hdr_active: false,
hdr_metadata_d,
hdr_meta: None,
video_format: vk::Format::R8G8B8A8_UNORM,
present_mode,
swapchain: vk::SwapchainKHR::null(),
@@ -762,9 +792,74 @@ impl Presenter {
images = self.images.len(),
"swapchain (re)created"
);
// HDR metadata is per-swapchain state: a rebuilt HDR10 swapchain needs it pushed
// again (this also covers set_hdr_mode's entry into HDR10, which lands here).
if self.hdr_active {
self.apply_hdr_metadata();
}
Ok(())
}
/// Whether the swapchain is actually in HDR10/PQ mode — as opposed to a PQ stream
/// being tone-mapped onto an SDR surface. This, not the stream's own signaling, is
/// what user-facing "HDR" indicators should report.
pub fn hdr_active(&self) -> bool {
self.hdr_active
}
/// Record the host's ST.2086 mastering + content-light metadata (the 0xCE plane),
/// pushing it to the swapchain immediately when HDR10 mode is live. Cheap and
/// idempotent per distinct value — callers just drain the plane into it.
pub fn set_hdr_metadata(&mut self, meta: punktfunk_core::quic::HdrMeta) {
if self.hdr_meta == Some(meta) {
return;
}
self.hdr_meta = Some(meta);
if self.hdr_active {
self.apply_hdr_metadata();
}
}
/// Push the current metadata (the host's, or a generic HDR10 baseline until 0xCE
/// arrives) to the presentation engine via `vkSetHdrMetadataEXT`. Compositors gate
/// their HDR-app signaling on this — picking the HDR10 colorspace alone leaves
/// gamescope treating the app as SDR (no SteamOS HDR badge, no per-app tone-map
/// target). No-op where the driver lacks the extension.
fn apply_hdr_metadata(&self) {
let Some(ext) = &self.hdr_metadata_d else {
return;
};
// Same generic baseline as the Windows presenter: BT.2020 primaries + D65
// white, 1000-nit mastering display, MaxCLL 1000 / MaxFALL 400.
let m = self.hdr_meta.unwrap_or(punktfunk_core::quic::HdrMeta {
display_primaries: [[8500, 39850], [6550, 2300], [35400, 14600]],
white_point: [15635, 16450],
max_display_mastering_luminance: 10_000_000,
min_display_mastering_luminance: 1,
max_cll: 1000,
max_fall: 400,
});
// Protocol fields are HDR10 SEI fixed-point (chromaticity 1/50000, luminance
// 0.0001 cd/m², primaries in ST.2086 G,B,R order); Vulkan wants floats in
// 0..1 chromaticity and whole nits, primaries named R/G/B.
let xy = |p: [u16; 2]| vk::XYColorEXT {
x: p[0] as f32 / 50_000.0,
y: p[1] as f32 / 50_000.0,
};
let [g, b, r] = m.display_primaries;
let md = vk::HdrMetadataEXT::default()
.display_primary_red(xy(r))
.display_primary_green(xy(g))
.display_primary_blue(xy(b))
.white_point(xy(m.white_point))
.max_luminance(m.max_display_mastering_luminance as f32 / 10_000.0)
.min_luminance(m.min_display_mastering_luminance as f32 / 10_000.0)
.max_content_light_level(m.max_cll as f32)
.max_frame_average_light_level(m.max_fall as f32);
unsafe { ext.set_hdr_metadata(&[self.swapchain], &[md]) };
tracing::debug!(from_host = self.hdr_meta.is_some(), "HDR metadata pushed");
}
/// Whether the hardware (dmabuf) path exists on this device — callers keep the
/// decoder on software when it doesn't.
pub fn supports_dmabuf(&self) -> bool {
@@ -1687,8 +1782,13 @@ fn pick_formats(
Ok((sdr, hdr10))
}
/// FIFO unless overridden (`PUNKTFUNK_PRESENT_MODE=mailbox|immediate`) and available —
/// a streaming client defaults to tear-free.
/// MAILBOX when the surface offers it, FIFO otherwise (`PUNKTFUNK_PRESENT_MODE=
/// fifo|mailbox|immediate` overrides). Both are tear-free, but an arrival-paced
/// presenter must not block in FIFO's present queue: when the compositor holds images
/// for a vblank pass (gamescope's composite path) or arrival cadence drifts against
/// refresh, `acquire_next_image` stalls most of a refresh — a standing 11-13 ms added
/// to every frame at 60 Hz. MAILBOX never queues more than the newest frame, so the
/// pipeline stays at decode latency and a late frame is replaced, not waited for.
fn pick_present_mode(
surface_i: &ash::khr::surface::Instance,
pdev: vk::PhysicalDevice,
@@ -1696,9 +1796,9 @@ fn pick_present_mode(
) -> Result<vk::PresentModeKHR> {
let modes = unsafe { surface_i.get_physical_device_surface_present_modes(pdev, surface) }?;
let want = match std::env::var("PUNKTFUNK_PRESENT_MODE").ok().as_deref() {
Some("mailbox") => vk::PresentModeKHR::MAILBOX,
Some("fifo") => vk::PresentModeKHR::FIFO,
Some("immediate") => vk::PresentModeKHR::IMMEDIATE,
_ => vk::PresentModeKHR::FIFO,
_ => vk::PresentModeKHR::MAILBOX,
};
Ok(if modes.contains(&want) {
want
@@ -1795,6 +1895,17 @@ fn unlock_vkframe(f: &VkVideoFrame, sync: &VkFrameSync, submitted: bool, graphic
/// sharing) and transition it for sampling. `src_qf == dst_qf` (or IGNORED/CONCURRENT)
/// degrades to a plain layout transition. The matching decode-side acquire happens in
/// FFmpeg, keyed off the queue_family we write back after submission.
///
/// `srcStage` is FRAGMENT_SHADER — NOT TOP_OF_PIPE — deliberately: the submit waits the
/// frame's decode-complete timeline semaphore with `wait_dst_stage_mask =
/// FRAGMENT_SHADER`, and a semaphore wait only orders operations whose first sync scope
/// INTERSECTS that mask (the dependency-chain rule). With TOP_OF_PIPE the barrier's
/// layout transition (VIDEO_DECODE_DST/DPB → SHADER_READ_ONLY) formed no chain with the
/// wait and could execute while the decode queue was still writing the image. On RADV
/// that transition physically touches the image (metadata/decompression), so the race
/// showed as green/yellow block corruption exactly at freshly-decoded (damaged) regions
/// — the Steam Deck cursor-trail artifact. NVIDIA treats the transition as a no-op,
/// which is why the same code looked clean there.
fn vkframe_acquire_barrier(
device: &ash::Device,
cmd: vk::CommandBuffer,
@@ -1820,7 +1931,7 @@ fn vkframe_acquire_barrier(
unsafe {
device.cmd_pipeline_barrier(
cmd,
vk::PipelineStageFlags::TOP_OF_PIPE,
vk::PipelineStageFlags::FRAGMENT_SHADER,
vk::PipelineStageFlags::FRAGMENT_SHADER,
vk::DependencyFlags::empty(),
&[],
+20
View File
@@ -314,6 +314,11 @@ pub struct NativeClient {
/// a recovery keyframe under infinite GOP — the correct loss trigger, since unrecoverable loss
/// yields reference-missing frames the decoder silently conceals (a decode-error trigger misses them).
frames_dropped: Arc<AtomicU64>,
/// Cumulative count of FEC shards the reassembler recovered (parity repaired a lost data
/// packet), mirrored from the data-plane pump's `Session` like `frames_dropped`. Observability
/// for the client stats HUDs (the unified spec's per-window `FEC` counter — proof FEC is
/// earning its keep); readers window it by diffing successive reads.
fec_recovered: Arc<AtomicU64>,
/// Kernel ids of the client's latency-critical native threads: the internal data-plane pump
/// (UDP receive + FEC reassembly) plus any embedder plane threads registered via
/// [`NativeClient::register_hot_thread`]. The Android client feeds these to an ADPF hint session
@@ -490,6 +495,7 @@ impl NativeClient {
let mode_slot = Arc::new(std::sync::Mutex::new(mode));
let probe = Arc::new(Mutex::new(ProbeState::default()));
let frames_dropped = Arc::new(AtomicU64::new(0));
let fec_recovered = Arc::new(AtomicU64::new(0));
let hot_tids = Arc::new(Mutex::new(Vec::new()));
let host = host.to_string();
@@ -499,6 +505,7 @@ impl NativeClient {
let mode_slot_w = mode_slot.clone();
let probe_w = probe.clone();
let frames_dropped_w = frames_dropped.clone();
let fec_recovered_w = fec_recovered.clone();
let hot_tids_w = hot_tids.clone();
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
let worker = std::thread::Builder::new()
@@ -550,6 +557,7 @@ impl NativeClient {
mode_slot: mode_slot_w,
probe: probe_w,
frames_dropped: frames_dropped_w,
fec_recovered: fec_recovered_w,
hot_tids: hot_tids_w,
}));
})
@@ -592,6 +600,7 @@ impl NativeClient {
quit,
worker: Some(worker),
frames_dropped,
fec_recovered,
hot_tids,
mode: mode_slot,
host_fingerprint: fingerprint,
@@ -734,6 +743,14 @@ impl NativeClient {
self.frames_dropped.load(Ordering::Relaxed)
}
/// Cumulative FEC shards the host→client reassembler recovered (a parity shard repaired a lost
/// data packet — loss that never became a dropped frame). Monotonic for the session; a stats
/// HUD windows it by diffing successive reads, pairing it with
/// [`frames_dropped`](Self::frames_dropped) (the losses FEC could NOT absorb).
pub fn fec_recovered_shards(&self) -> u64 {
self.fec_recovered.load(Ordering::Relaxed)
}
/// Whether the underlying QUIC session has ended — the worker's connection-close watcher set the
/// shutdown flag (`conn.closed()` fired: a host suspend / crash / network drop idle-timed the
/// connection out, or the host closed it), or a deliberate [`disconnect_quit`](Self::disconnect_quit)
@@ -987,6 +1004,7 @@ struct WorkerArgs {
mode_slot: Arc<std::sync::Mutex<Mode>>,
probe: Arc<Mutex<ProbeState>>,
frames_dropped: Arc<AtomicU64>,
fec_recovered: Arc<AtomicU64>,
hot_tids: Arc<Mutex<Vec<i32>>>,
}
@@ -1024,6 +1042,7 @@ async fn worker_main(args: WorkerArgs) {
mode_slot,
probe,
frames_dropped,
fec_recovered,
hot_tids,
} = args;
let setup = async {
@@ -1362,6 +1381,7 @@ async fn worker_main(args: WorkerArgs) {
// through a total-loss drought where no AU completes. Cheap: a few relaxed atomic loads.
let st = session.stats();
frames_dropped.store(st.frames_dropped, Ordering::Relaxed);
fec_recovered.store(st.fec_recovered_shards, Ordering::Relaxed);
let probe_active = {
let mut p = pump_probe.lock().unwrap();
if p.active && !p.done {
+20 -5
View File
@@ -1349,8 +1349,10 @@ const RICH_TOUCHPAD_EX: u8 = 0x03;
/// kind decodes to `None` and is dropped).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RichInput {
/// One touchpad contact. `x`/`y` are normalized `0..=65535` (the host scales to the
/// DualSense touchpad resolution); `active = false` lifts the finger.
/// One touchpad contact. `x`/`y` are normalized `0..=65535` in SCREEN convention —
/// origin top-left, +y DOWN, exactly what SDL/Windows/Android capture APIs produce
/// (the host scales to the DualSense touchpad resolution); `active = false` lifts
/// the finger.
Touchpad {
pad: u8,
finger: u8,
@@ -1368,9 +1370,13 @@ pub enum RichInput {
/// A richer trackpad contact that also identifies *which* physical pad (Steam Controller / Deck
/// have two), carries a separate click vs touch state, and a pressure reading. `surface`:
/// `0` = the single / DualSense touchpad, `1` = the Steam left pad, `2` = the Steam right pad.
/// Coordinates are **signed** (centred at 0), matching the real Steam report; `pressure` is `0`
/// for a surface with no force sensor. New clients send this for every touch surface; the host
/// decodes both `Touchpad` (`0x01`) and `TouchpadEx` (`0x03`) indefinitely.
/// Coordinates are **signed** (centred at 0) in SCREEN convention — +x right, +y DOWN,
/// what every client capture API produces. Device-raw quirks are the HOST applier's job
/// (the Deck report is +y up: `steam_proto` flips it — the first live session shipped
/// clients that sent screen-y straight through, so the wire meaning is fixed as screen-y
/// and hosts translate). `pressure` is `0` for a surface with no force sensor. New clients
/// send this for every touch surface; the host decodes both `Touchpad` (`0x01`) and
/// `TouchpadEx` (`0x03`) indefinitely.
TouchpadEx {
pad: u8,
surface: u8,
@@ -1783,6 +1789,15 @@ pub mod endpoint {
quinn::IdleTimeout::try_from(idle).expect("clamped idle timeout is a valid QUIC value"),
));
t.keep_alive_interval(Some(keep_alive));
// The datagram planes (audio/rumble/hidout/host-timing host→client; mic/rich-input
// client→host) carry realtime state, not bulk data — but they are congestion-controlled,
// unlike video, which rides its own latest-wins UDP path. quinn's default 1 MiB datagram
// send buffer is a FIFO that only sheds oldest-first at the cap, so on a congested link
// (Wi-Fi under streaming load) it holds tens of seconds of Opus: audio and rumble build a
// standing delay that never drains while video stays live. Capping the buffer makes the
// plane latest-wins at the source — ~200 ms of stereo Opus (proportionally less at
// surround bitrates), so sustained congestion costs concealable drops, never lag.
t.datagram_send_buffer_size(4 * 1024);
Arc::new(t)
}
+4
View File
@@ -201,6 +201,10 @@ windows = { version = "0.62", features = [
# CoCreateInstance(PolicyConfigClient) — set the default audio playback/recording endpoints via the
# undocumented IPolicyConfig (audio/windows/audio_control.rs) so mic + desktop audio auto-wire.
"Win32_System_Com",
# SetUnhandledExceptionFilter + EXCEPTION_POINTERS — the last-resort native-crash logger
# (src/windows/crash.rs); Kernel gates the CONTEXT type EXCEPTION_POINTERS embeds.
"Win32_System_Diagnostics_Debug",
"Win32_System_Kernel",
] }
# The SCM plumbing for the `service` subcommand (define_windows_service! / dispatcher / control
# handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses
@@ -249,6 +249,7 @@ impl DualSenseManager {
s.touch = prev.touch;
s.gyro = prev.gyro;
s.accel = prev.accel;
s.touch_click = prev.touch_click;
self.state[idx] = s;
self.write(idx);
}
@@ -267,50 +268,9 @@ impl DualSenseManager {
if idx >= MAX_PADS || self.pads[idx].is_none() {
return;
}
match rich {
RichInput::Touchpad {
finger,
active,
x,
y,
..
} => {
// The DualSense touchpad carries two contacts; clamp to a valid slot and keep the
// reported contact id consistent with it (the wire `finger` is untrusted).
let slot = (finger as usize).min(1);
let t = &mut self.state[idx].touch[slot];
t.active = active;
t.id = slot as u8;
// Normalized 0..=65535 → the touchpad's coordinate range (0..=W-1 / 0..=H-1,
// what the kernel advertises as the ABS_MT extents).
t.x = ((x as u32 * (DS_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16;
t.y = ((y as u32 * (DS_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16;
}
RichInput::Motion { gyro, accel, .. } => {
self.state[idx].gyro = gyro;
self.state[idx].accel = accel;
}
RichInput::TouchpadEx {
surface,
finger,
touch,
x,
y,
..
} => {
// A Steam right/single pad maps onto the one DualSense touchpad (signed centre-0 →
// 0..=65535); surface 1 (the Steam left pad) has no DualSense equivalent.
if surface != 1 {
let slot = (finger as usize).min(1);
let n = |v: i16| ((v as i32) + 32768) as u32;
let t = &mut self.state[idx].touch[slot];
t.active = touch;
t.id = slot as u8;
t.x = (n(x) * (DS_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
t.y = (n(y) * (DS_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
}
}
}
// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam
// dual pads split the one touchpad left/right, pad clicks ride touch_click.
self.state[idx].apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
self.write(idx);
}
@@ -12,7 +12,7 @@
//! `src/uhid/include/uhid/ps5.hpp`), so `hid-playstation` (Linux) and `hidclass` (Windows) bind the
//! same as a real USB DualSense.
use punktfunk_core::quic::HidOutput;
use punktfunk_core::quic::{HidOutput, RichInput};
// Feature reports the host stack GET_REPORTs during init — without these replies the kernel
// (`hid-playstation`) never finishes calibration and creates no input devices. Verbatim from
@@ -125,6 +125,12 @@ pub struct DsState {
pub gyro: [i16; 3],
pub accel: [i16; 3],
pub touch: [Touch; 2],
/// Per-contact-slot click state from the rich plane (`TouchpadEx.click` — a Steam pad's
/// physical pad-click). The serializers OR any held slot into the touchpad-click button
/// bit: the DualSense has ONE clickable pad, so either Deck pad clicking counts. Lives
/// outside `buttons` because `from_gamepad` rebuilds those from every button frame —
/// managers must persist this across rebuilds like `touch`/`gyro`/`accel`.
pub touch_click: [bool; 2],
}
impl DsState {
@@ -235,6 +241,88 @@ impl DsState {
_ => 8,
};
}
/// Apply one rich client→host event (touchpad contact / motion sample) into this state —
/// the ONE mapping shared by every DualSense-family backend (Linux UHID, Windows UMDF,
/// DS4 both ways; `touch_w`/`touch_h` are the pad's advertised extents, 1920×1080 vs
/// 1920×942).
///
/// Wire touch coordinates are screen convention (+x right, +y down) — same as the
/// DualSense pad's own (top-left origin), so no flip here.
///
/// A Steam Deck / Steam Controller client sends TWO pads as `TouchpadEx` surfaces; the
/// DualSense has one pad with two contact slots, so the surfaces SPLIT it — left pad →
/// contact 0 on the left half, right pad → contact 1 on the right half. That mirrors the
/// physical thumb layout and lands exactly on the split-pad zones games and Steam Input
/// already use for the DS4/DualSense touchpad. Pad clicks ride `touch_click` (the
/// serializer ORs them into the touchpad-click button — one clickable pad, either
/// surface counts); dropping them was the "Deck pad click does nothing on a DualSense
/// host" gap.
pub fn apply_rich(&mut self, rich: RichInput, touch_w: u16, touch_h: u16) {
// Normalized position → pad extents. The kernel/driver advertises 0..=W-1 / 0..=H-1.
let scale = |n: u32, extent: u16| ((n * (extent - 1) as u32) / u16::MAX as u32) as u16;
match rich {
RichInput::Touchpad {
finger,
active,
x,
y,
..
} => {
// The DualSense touchpad carries two contacts; clamp to a valid slot and keep
// the reported contact id consistent with it (the wire `finger` is untrusted).
let slot = (finger as usize).min(1);
self.touch[slot] = Touch {
active,
id: slot as u8,
x: scale(x as u32, touch_w),
y: scale(y as u32, touch_h),
};
}
RichInput::Motion { gyro, accel, .. } => {
// The wire is already DualSense-convention units (20 LSB/°·s, 10000 LSB/g).
self.gyro = gyro;
self.accel = accel;
}
RichInput::TouchpadEx {
surface,
finger,
touch,
click,
x,
y,
..
} => {
let n = |v: i16| ((v as i32) + 32768) as u32; // signed centre-0 → 0..=65535
let half = touch_w / 2;
let (slot, tx) = match surface {
// The single / DualSense pad: full extent, slot by finger.
0 => ((finger as usize).min(1), scale(n(x), touch_w)),
// Steam LEFT pad → contact 0 on the left half.
1 => (0, scale(n(x), half)),
// Steam RIGHT pad (or anything newer) → contact 1 on the right half.
_ => (1, half + scale(n(x), half)),
};
self.touch[slot] = Touch {
active: touch,
id: slot as u8,
x: tx,
y: scale(n(y), touch_h),
};
self.touch_click[slot] = click;
}
}
}
/// `buttons[2]` as serialized: the live button frame plus the touchpad-click bit when a
/// rich-plane pad click is held (see [`DsState::touch_click`]).
pub fn buttons2_with_click(&self) -> u8 {
let mut b = self.buttons[2];
if self.touch_click.iter().any(|c| *c) {
b |= btn2::TOUCHPAD;
}
b
}
}
/// Serialize a full input report `0x01` (pure — unit-testable without a transport). Field
@@ -253,7 +341,7 @@ pub fn serialize_state(r: &mut [u8; DS_INPUT_REPORT_LEN], st: &DsState, seq: u8,
r[7] = seq; // seq_number (struct off 6)
r[8] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // off 7: dpad + face buttons
r[9] = st.buttons[1]; // off 8
r[10] = st.buttons[2]; // off 9
r[10] = st.buttons2_with_click(); // off 9 (PS/touchpad-click/mute; rich pad clicks OR in)
r[11] = st.buttons[3]; // off 10
for (i, v) in st.gyro.iter().enumerate() {
r[16 + i * 2..18 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 15
@@ -309,7 +397,12 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
// Motor rumble: high-frequency (small/right) motor at data[3], low-frequency (big/left) at
// data[4]. Scale 0..255 → 0..0xFFFF, same (low, high) convention as the uinput pad's mixer,
// and route to the universal rumble plane (0xCA).
if flag0 & 0x03 != 0 {
// Writers on firmware ≥ 2.24 signal rumble via COMPATIBLE_VIBRATION2 in valid_flag2
// (data[39] BIT2) instead of flag0 BIT0. Our feature report advertises 0x0154 so the
// kernel and SDL stay on the flag0 convention, but a writer that hardcodes v2 would
// otherwise have its rumble — including stops — silently ignored, and a missed stop
// buzzes for the rest of the session (the 500 ms refresh re-sends stale state forever).
if flag0 & 0x03 != 0 || data[39] & 0x04 != 0 {
let high = (data[3] as u16) << 8;
let low = (data[4] as u16) << 8;
fb.rumble = Some((low, high));
@@ -350,6 +443,127 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
mod tests {
use super::*;
/// The Steam dual-pad → DualSense touchpad SPLIT: left pad (surface 1) lands contact 0
/// on the left half, right pad (surface 2) contact 1 on the right half; y follows the
/// shared screen convention (top → 0) with no flip; pad clicks set the touchpad-click
/// button bit in the serialized report.
#[test]
fn steam_surfaces_split_the_touchpad() {
let mut s = DsState::neutral();
// Left pad, centre → middle of the LEFT half.
s.apply_rich(
RichInput::TouchpadEx {
pad: 0,
surface: 1,
finger: 0,
touch: true,
click: false,
x: 0,
y: 0,
pressure: 0,
},
DS_TOUCH_W,
DS_TOUCH_H,
);
assert!(s.touch[0].active);
assert_eq!(s.touch[0].id, 0);
assert_eq!(s.touch[0].x, (DS_TOUCH_W / 2 - 1) / 2); // centre of 0..=959
assert_eq!(s.touch[0].y, (DS_TOUCH_H - 1) / 2);
// Right pad, top-right corner → right edge of the RIGHT half, y = 0 (screen top).
s.apply_rich(
RichInput::TouchpadEx {
pad: 0,
surface: 2,
finger: 0,
touch: true,
click: true,
x: i16::MAX,
y: i16::MIN,
pressure: 0,
},
DS_TOUCH_W,
DS_TOUCH_H,
);
assert!(s.touch[1].active);
assert_eq!(s.touch[1].id, 1);
assert_eq!(s.touch[1].x, DS_TOUCH_W - 1);
assert_eq!(s.touch[1].y, 0);
// The right pad's click reaches the (single) touchpad-click button bit.
assert!(s.touch_click[1]);
assert_eq!(s.buttons2_with_click() & btn2::TOUCHPAD, btn2::TOUCHPAD);
let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, &s, 0, 0);
assert_eq!(r[10] & btn2::TOUCHPAD, btn2::TOUCHPAD);
// Releasing the click clears the bit again.
s.apply_rich(
RichInput::TouchpadEx {
pad: 0,
surface: 2,
finger: 0,
touch: true,
click: false,
x: 0,
y: 0,
pressure: 0,
},
DS_TOUCH_W,
DS_TOUCH_H,
);
assert_eq!(s.buttons2_with_click() & btn2::TOUCHPAD, 0);
}
/// The single-surface forms keep their full-pad mapping: unsigned `Touchpad` and
/// `TouchpadEx` surface 0 both span the whole touchpad, slot picked by finger.
#[test]
fn single_surface_spans_full_pad() {
let mut s = DsState::neutral();
s.apply_rich(
RichInput::Touchpad {
pad: 0,
finger: 0,
active: true,
x: 65535,
y: 65535,
},
DS_TOUCH_W,
DS_TOUCH_H,
);
assert_eq!(
(s.touch[0].x, s.touch[0].y),
(DS_TOUCH_W - 1, DS_TOUCH_H - 1)
);
s.apply_rich(
RichInput::TouchpadEx {
pad: 0,
surface: 0,
finger: 1,
touch: true,
click: false,
x: i16::MAX,
y: i16::MAX,
pressure: 0,
},
DS_TOUCH_W,
DS_TOUCH_H,
);
assert_eq!(
(s.touch[1].x, s.touch[1].y),
(DS_TOUCH_W - 1, DS_TOUCH_H - 1)
);
// Motion is unit-passthrough (wire is already DualSense convention).
s.apply_rich(
RichInput::Motion {
pad: 0,
gyro: [100, -200, 300],
accel: [-1000, 2000, -3000],
},
DS_TOUCH_W,
DS_TOUCH_H,
);
assert_eq!(s.gyro, [100, -200, 300]);
assert_eq!(s.accel, [-1000, 2000, -3000]);
}
/// A DualSense USB output report (`0x02`) with all valid-flags set parses into motor
/// rumble (0xCA), lightbar, player LEDs, and both adaptive-trigger blocks (0xCD) — with
/// the report's right-trigger-first layout mapped onto the wire's `which` (0 = L2).
@@ -53,7 +53,7 @@ pub fn serialize_state(r: &mut [u8; DS4_INPUT_REPORT_LEN], st: &DsState, counter
r[4] = st.ry;
r[5] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // dpad hat (low) + face buttons (high)
r[6] = st.buttons[1]; // L1/R1, L2/R2 digital, Share/Options, L3/R3
r[7] = (st.buttons[2] & 0x03) | ((counter & 0x3F) << 2); // PS + touchpad-click + report counter
r[7] = (st.buttons2_with_click() & 0x03) | ((counter & 0x3F) << 2); // PS + touchpad-click (incl. rich pad clicks) + report counter
r[8] = st.l2; // L2 analog (z)
r[9] = st.r2; // R2 analog (rz)
r[10..12].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 9)
@@ -236,13 +236,23 @@ impl SteamState {
/// wire [`RichInput::Touchpad`] maps to the **right** trackpad (the Deck pad analogous to the
/// DualSense touchpad); the left pad arrives via the M3 `TouchpadEx` surface. [`RichInput::Motion`]
/// passes gyro/accel straight through (raw i16; cross-device unit scaling is M3).
///
/// The wire's touch coordinates are SCREEN convention — +y DOWN, what SDL/Windows/Android
/// capture APIs all produce — but the Deck's raw trackpad fields are stick convention
/// (+y UP, centre origin), and Steam Input parses our report as real Deck hardware. Y is
/// therefore negated here, on the device boundary; leaving it through was the "both
/// trackpads inverted" bug the first live Deck-to-Deck session surfaced (2026-07-08).
pub fn apply_rich(&mut self, rich: RichInput) {
/// Screen-convention (+down) wire Y → Deck raw (+up), saturating (-32768 has no i16 negation).
fn flip_y(y: i16) -> i16 {
(y as i32).saturating_neg().clamp(-32768, 32767) as i16
}
match rich {
RichInput::Touchpad { active, x, y, .. } => {
self.press(btn::RPAD_TOUCH, active);
// Normalized 0..=65535 (centre 32768) → the pad's centred s16 range.
// Normalized 0..=65535 (centre 32768, +y down) → the pad's centred s16 range (+y up).
self.rpad_x = ((x as i32) - 32768) as i16;
self.rpad_y = ((y as i32) - 32768) as i16;
self.rpad_y = (32768 - (y as i32)).min(32767) as i16;
}
RichInput::Motion { gyro, accel, .. } => {
// The wire carries DualSense-convention units (what every client capture emits); the
@@ -259,18 +269,18 @@ impl SteamState {
y,
..
} => {
// Steam pads are natively signed (centre 0), so x/y map straight in. surface 1 =
// Signed centre-0 x maps straight in; y flips to the Deck's +up. surface 1 =
// left pad, anything else (0 single / 2 right) = right pad.
if surface == 1 {
self.press(btn::LPAD_TOUCH, touch);
self.press(btn::LPAD_CLICK, click);
self.lpad_x = x;
self.lpad_y = y;
self.lpad_y = flip_y(y);
} else {
self.press(btn::RPAD_TOUCH, touch);
self.press(btn::RPAD_CLICK, click);
self.rpad_x = x;
self.rpad_y = y;
self.rpad_y = flip_y(y);
}
}
}
@@ -557,9 +567,9 @@ mod tests {
});
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0);
assert_eq!(s.rpad_x, 32767); // 65535-32768
assert_eq!(s.rpad_y, -32768); // 0-32768
// Motion is rescaled from the wire (DualSense) convention into Deck units (gyro ×16/20,
// accel ×16384/10000) — see steam_remap::motion_wire_to_deck.
assert_eq!(s.rpad_y, 32767); // wire y=0 = TOP (screen conv) → Deck raw +up (clamped)
// Motion is rescaled from the wire (DualSense) convention into Deck units (gyro ×16/20,
// accel ×16384/10000) — see steam_remap::motion_wire_to_deck.
s.apply_rich(RichInput::Motion {
pad: 0,
gyro: [1000, -2000, 0],
@@ -570,7 +580,8 @@ mod tests {
}
/// M3: the wire back-button bits map to the four Deck grips + QAM, and `TouchpadEx` routes the
/// left / right surfaces to the matching pad (signed coords pass straight through).
/// left / right surfaces to the matching pad (x passes straight through; y flips from the
/// wire's screen convention (+down) to the Deck's raw +up — the live-verified direction).
#[test]
fn back_buttons_and_dual_trackpad_mapping() {
let s = SteamState::from_gamepad(
@@ -601,7 +612,7 @@ mod tests {
});
assert_ne!(s.buttons & btn::LPAD_TOUCH, 0);
assert_ne!(s.buttons & btn::LPAD_CLICK, 0);
assert_eq!((s.lpad_x, s.lpad_y), (-5000, 6000));
assert_eq!((s.lpad_x, s.lpad_y), (-5000, -6000));
s.apply_rich(RichInput::TouchpadEx {
pad: 0,
surface: 2,
@@ -613,7 +624,20 @@ mod tests {
pressure: 0,
});
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0);
assert_eq!((s.rpad_x, s.rpad_y), (7000, -8000));
assert_eq!((s.rpad_x, s.rpad_y), (7000, 8000));
// The i16 edge: wire y = -32768 (top-most) must clamp, not overflow.
s.apply_rich(RichInput::TouchpadEx {
pad: 0,
surface: 2,
finger: 0,
touch: true,
click: false,
x: 0,
y: -32768,
pressure: 0,
});
assert_eq!(s.rpad_y, 32767);
}
/// The serial reply carries the leading report-id byte the kernel strips, so the *stripped*
@@ -442,6 +442,7 @@ impl DualSenseWindowsManager {
s.touch = prev.touch;
s.gyro = prev.gyro;
s.accel = prev.accel;
s.touch_click = prev.touch_click;
self.state[idx] = s;
self.write(idx);
}
@@ -458,46 +459,9 @@ impl DualSenseWindowsManager {
if idx >= MAX_PADS || self.pads[idx].is_none() {
return;
}
match rich {
RichInput::Touchpad {
finger,
active,
x,
y,
..
} => {
let slot = (finger as usize).min(1);
let t = &mut self.state[idx].touch[slot];
t.active = active;
t.id = slot as u8;
t.x = ((x as u32 * (DS_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16;
t.y = ((y as u32 * (DS_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16;
}
RichInput::Motion { gyro, accel, .. } => {
self.state[idx].gyro = gyro;
self.state[idx].accel = accel;
}
RichInput::TouchpadEx {
surface,
finger,
touch,
x,
y,
..
} => {
// A Steam right/single pad maps onto the one DualSense touchpad (signed centre-0 →
// 0..=65535); surface 1 (the Steam left pad) has no DualSense equivalent.
if surface != 1 {
let slot = (finger as usize).min(1);
let n = |v: i16| ((v as i32) + 32768) as u32;
let t = &mut self.state[idx].touch[slot];
t.active = touch;
t.id = slot as u8;
t.x = (n(x) * (DS_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
t.y = (n(y) * (DS_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
}
}
}
// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam
// dual pads split the one touchpad left/right, pad clicks ride touch_click.
self.state[idx].apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
self.write(idx);
}
@@ -208,6 +208,7 @@ impl DualShock4WindowsManager {
s.touch = prev.touch;
s.gyro = prev.gyro;
s.accel = prev.accel;
s.touch_click = prev.touch_click;
self.state[idx] = s;
self.write(idx);
}
@@ -224,46 +225,9 @@ impl DualShock4WindowsManager {
if idx >= MAX_PADS || self.pads[idx].is_none() {
return;
}
match rich {
RichInput::Touchpad {
finger,
active,
x,
y,
..
} => {
let slot = (finger as usize).min(1);
let t = &mut self.state[idx].touch[slot];
t.active = active;
t.id = slot as u8;
t.x = ((x as u32 * (DS4_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16;
t.y = ((y as u32 * (DS4_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16;
}
RichInput::Motion { gyro, accel, .. } => {
self.state[idx].gyro = gyro;
self.state[idx].accel = accel;
}
RichInput::TouchpadEx {
surface,
finger,
touch,
x,
y,
..
} => {
// A Steam right/single pad maps onto the one DS4 touchpad (signed centre-0 →
// 0..=65535); surface 1 (the Steam left pad) has no DS4 equivalent.
if surface != 1 {
let slot = (finger as usize).min(1);
let n = |v: i16| ((v as i32) + 32768) as u32;
let t = &mut self.state[idx].touch[slot];
t.active = touch;
t.id = slot as u8;
t.x = (n(x) * (DS4_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
t.y = (n(y) * (DS4_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
}
}
}
// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam
// dual pads split the one touchpad left/right, pad clicks ride touch_click.
self.state[idx].apply_rich(rich, DS4_TOUCH_W, DS4_TOUCH_H);
self.write(idx);
}
+32
View File
@@ -25,6 +25,9 @@ mod discovery;
mod wol;
// Goal-1 stage 6: top-level platform-only modules live under `src/linux/` and `src/windows/`; `#[path]`
// keeps the `crate::*` module names flat (every existing path is unchanged).
#[cfg(target_os = "windows")]
#[path = "windows/crash.rs"]
mod crash;
#[cfg(target_os = "linux")]
#[path = "linux/dmabuf_fence.rs"]
mod dmabuf_fence;
@@ -111,6 +114,35 @@ fn main() {
.init();
}
// Tee every panic through `tracing` BEFORE the default hook: a panicking thread otherwise
// prints only to stderr — absent from the web console's Logs tab (the ring) and gone entirely
// when stderr is detached — so a field report reads "host died, zero errors in the logs".
// The default hook still runs afterwards for the usual stderr message/abort behavior.
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
// Manual payload downcast (`payload_as_str` needs Rust 1.91; workspace MSRV is 1.82).
let payload = info
.payload()
.downcast_ref::<&str>()
.copied()
.or_else(|| info.payload().downcast_ref::<String>().map(String::as_str))
.unwrap_or("<non-string panic payload>");
tracing::error!(
thread = std::thread::current().name().unwrap_or("<unnamed>"),
location = %info
.location()
.map(ToString::to_string)
.unwrap_or_else(|| "<unknown>".into()),
backtrace = %std::backtrace::Backtrace::force_capture(),
"PANIC: {payload}"
);
default_panic(info);
}));
// Native crashes (an access violation inside a GPU runtime/driver DLL) are logged by a
// last-resort SEH filter for the same reason — they otherwise kill the host with no trace.
#[cfg(target_os = "windows")]
crash::install();
if let Err(e) = real_main() {
tracing::error!("{e:#}");
std::process::exit(1);
+85 -16
View File
@@ -1159,14 +1159,20 @@ async fn serve_session(
// grant persists across sessions; this thread owns the session's virtual gamepads (uinput,
// per-session) and sends force feedback back over `conn`. It exits when the channel closes
// (datagram task ends on disconnect) — fresh gamepad state per session.
let (input_tx, input_rx) = std::sync::mpsc::channel::<InputEvent>();
let (rich_tx, rich_rx) = std::sync::mpsc::channel::<punktfunk_core::quic::RichInput>();
//
// ONE channel for both event kinds deliberately: rich input (gyro at the pad's report
// rate) used to ride a second channel that the thread only drained after the main
// channel's 4 ms recv timeout — every motion sample of a pure-gyro aim (no button
// traffic) ate up to 4 ms of added latency/jitter. A single channel wakes the thread on
// whichever arrives.
let (input_tx, input_rx) = std::sync::mpsc::channel::<ClientInput>();
let rich_tx = input_tx.clone();
let input_handle = {
let conn = conn.clone();
let gamepad = welcome.gamepad;
std::thread::Builder::new()
.name("punktfunk1-input".into())
.spawn(move || input_thread(input_rx, rich_rx, conn, inj_tx, gamepad))
.spawn(move || input_thread(input_rx, conn, inj_tx, gamepad))
.context("spawn input thread")?
};
// One reader for ALL client→host datagrams, demuxed by magic byte (two read_datagram loops
@@ -1185,7 +1191,7 @@ async fn serve_session(
let _ = mic_tx.try_send(opus.to_vec());
} else if let Some(rich) = punktfunk_core::quic::RichInput::decode(&d) {
rich_count += 1;
if rich_tx.send(rich).is_err() {
if rich_tx.send(ClientInput::Rich(rich)).is_err() {
break;
}
} else if let Some(mut ev) = InputEvent::decode(&d) {
@@ -1201,7 +1207,7 @@ async fn serve_session(
) {
ev.flags &= !crate::inject::KEY_FLAG_SEMANTIC_VK;
}
if input_tx.send(ev).is_err() {
if input_tx.send(ClientInput::Event(ev)).is_err() {
break;
}
}
@@ -1706,21 +1712,37 @@ impl PadBackend {
}
}
/// One client→host input item, both planes on ONE channel so the input thread wakes the
/// moment either arrives (a second rich channel drained after the 4 ms recv timeout cost
/// every pure-gyro motion sample up to 4 ms of quantization).
enum ClientInput {
/// The 0xC8 plane: pointer / keyboard / gamepad button+axis.
Event(InputEvent),
/// The 0xCC plane: touchpad contacts + motion samples.
Rich(punktfunk_core::quic::RichInput),
}
/// The per-session input thread: route pointer/keyboard events to the host-lifetime injector
/// service (`inj_tx`) and gamepad events to this session's [`PadBackend`] (`gamepad` — the
/// resolved Hello preference: uinput X-Box pads or virtual DualSense pads), with rich
/// client→host input (touchpad / motion, `rich_rx`) merged in and feedback pumped between
/// events — rumble on the universal datagram plane, DualSense LED/trigger feedback on the
/// HID-output plane. The gamepads are created and torn down with the session; the
/// pointer/keyboard injector (and its portal grant) lives in the service, across sessions.
/// client→host input (touchpad / motion, [`ClientInput::Rich`]) applied on arrival and
/// feedback pumped between events — rumble on the universal datagram plane, DualSense
/// LED/trigger feedback on the HID-output plane. The gamepads are created and torn down with
/// the session; the pointer/keyboard injector (and its portal grant) lives in the service,
/// across sessions.
fn input_thread(
rx: std::sync::mpsc::Receiver<InputEvent>,
rich_rx: std::sync::mpsc::Receiver<punktfunk_core::quic::RichInput>,
rx: std::sync::mpsc::Receiver<ClientInput>,
conn: quinn::Connection,
inj_tx: std::sync::mpsc::Sender<InputEvent>,
gamepad: GamepadPref,
) {
let mut pads = PadBackend::select(gamepad);
// Motion-cadence observability (debug level): inter-arrival percentiles per 5 s window,
// the measurement a "gyro feels floaty" report needs. Bounded: 5 s at even a 1 kHz pad
// is 5000 u32s.
let mut motion_gaps_us: Vec<u32> = Vec::new();
let mut last_motion: Option<std::time::Instant> = None;
let mut motion_window = std::time::Instant::now();
let mut pad_state = [PadState::default(); MAX_WIRE_PADS];
let mut pad_mask = 0u16;
// Rumble is idempotent state on a lossy channel (client-side overflow drops datagrams),
@@ -1745,7 +1767,39 @@ fn input_thread(
let mut held_keys: std::collections::HashSet<u32> = std::collections::HashSet::new();
loop {
match rx.recv_timeout(std::time::Duration::from_millis(4)) {
Ok(ev) => match ev.kind {
// Rich input (touchpad / motion) — applied the moment it arrives; the single
// channel means a gyro sample never waits out the 4 ms timeout behind an idle
// button plane.
Ok(ClientInput::Rich(rich)) => {
if matches!(rich, punktfunk_core::quic::RichInput::Motion { .. }) {
let now = std::time::Instant::now();
if let Some(prev) = last_motion.replace(now) {
let gap = now.duration_since(prev);
if gap < std::time::Duration::from_secs(1) {
motion_gaps_us.push(gap.as_micros() as u32);
}
}
if motion_window.elapsed() >= std::time::Duration::from_secs(5)
&& !motion_gaps_us.is_empty()
{
motion_gaps_us.sort_unstable();
let p = |q: f64| {
motion_gaps_us[(q * (motion_gaps_us.len() - 1) as f64) as usize]
};
tracing::debug!(
samples = motion_gaps_us.len() + 1,
gap_p50_us = p(0.5),
gap_p95_us = p(0.95),
gap_max_us = motion_gaps_us.last().copied().unwrap_or(0),
"motion cadence (client gyro inter-arrival, 5 s window)"
);
motion_gaps_us.clear();
motion_window = std::time::Instant::now();
}
}
pads.apply_rich(rich);
}
Ok(ClientInput::Event(ev)) => match ev.kind {
InputKind::GamepadButton | InputKind::GamepadAxis => {
// A bad index / unknown axis just doesn't update a pad — fall through (no
// `continue`) so the rich-input drain + feedback pump below still run every
@@ -1784,10 +1838,6 @@ fn input_thread(
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
// Drain rich client→host input (DualSense touchpad / motion) into the pad backend.
while let Ok(rich) = rich_rx.try_recv() {
pads.apply_rich(rich);
}
// Service feedback every iteration (≤4 ms latency; games block on EVIOCSFF, and the
// DualSense kernel handshake must be answered promptly). Rumble → the universal 0xCA
// plane; DualSense rich feedback (lightbar / player LEDs / adaptive triggers) → 0xCD.
@@ -2091,6 +2141,11 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
// Steam Deck: Linux UHID hid-steam. The classic Steam Controller's backend isn't built yet,
// so it folds to Xbox360 for now (Windows Steam devices are M7).
GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck,
// No virtual Deck on Windows (M7) — fold to DualSense, the closest rich pad: its
// backend keeps gyro + trackpads + pad-click alive (the Deck's dual pads split the
// DualSense touchpad left/right per DsState::apply_rich). Folding to Xbox360 dropped
// all of that silently.
GamepadPref::SteamDeck if windows => GamepadPref::DualSense,
_ => GamepadPref::Xbox360,
}
}
@@ -3107,6 +3162,13 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
// reapplies the client's saved per-monitor config (DPI scaling) on reconnect. No-op on Linux backends
// and for anonymous/GameStream clients (no fingerprint → the driver auto-allocates).
vd.set_client_identity(endpoint::peer_fingerprint(&conn));
// Deliberate-quit wiring (Windows pf-vdisplay; no-op elsewhere): every lease the backend mints —
// the retry-hold below AND the capturer's — carries the session's quit flag, so a user "stop"
// (⌘D → the QUIT close code) tears the virtual monitor down the moment the pipeline drops instead
// of lingering 10 s. The reconnect then finds the manager Idle and does a clean fresh ADD (with
// the user's think-time as driver settle) rather than the Lingering-preempt's REMOVE→ADD churn.
// `keep_alive = forever` (gaming-rig) outranks the quit — the monitor pins as before.
vd.set_quit_flag(quit.clone());
// Per-session launch (non-Windows): hand the resolved command to the backend instance so
// gamescope's bare spawn nests it — per-instance, no process-global env, so concurrent sessions
// can't stomp each other's launch target. The other backends' default `set_launch_command` is a
@@ -4180,6 +4242,13 @@ mod tests {
assert_eq!(pick_gamepad(XboxOne, None, true, false), XboxOne);
assert_eq!(pick_gamepad(Auto, Some("series"), true, false), XboxOne);
assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360);
// Steam Deck: native on Linux; folds to DualSense on Windows (keeps gyro + trackpads
// via the UMDF backend — Xbox360 would drop the whole rich plane); Xbox360 elsewhere.
assert_eq!(pick_gamepad(SteamDeck, None, true, false), SteamDeck);
assert_eq!(pick_gamepad(SteamDeck, None, false, true), DualSense);
assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), DualSense);
assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360);
}
#[test]
+8
View File
@@ -122,6 +122,14 @@ pub trait VirtualDisplay: Send {
/// Default: no-op — only the Windows pf-vdisplay backend uses it (Linux compositors own their virtual
/// output identity). `None` = anonymous/unpaired/GameStream → the backend's auto (slot-based) identity.
fn set_client_identity(&mut self, _fingerprint: Option<[u8; 32]>) {}
/// Hand the backend the session's deliberate-quit flag (set when the client closes with the QUIT
/// application code — a user "stop", not a network drop) so the last lease's drop can tear the
/// display down IMMEDIATELY, skipping the keep-alive linger — the Windows analogue of the Linux
/// registry's `Linger::Immediate` path. Carried on the backend instance; set once before
/// [`create`](Self::create). Default: no-op — only the Windows pf-vdisplay backend needs it (its
/// leases live in the `VirtualDisplayManager`, which the registry's quit plumbing does not reach;
/// Linux backends get the flag through `registry::acquire`).
fn set_quit_flag(&mut self, _quit: std::sync::Arc<std::sync::atomic::AtomicBool>) {}
/// The stable identity slot the backend resolved for the most recent [`create`](Self::create) —
/// the per-client id the identity policy assigned (`Some`), or `None` for shared/anonymous. The
/// registry reads it right after `create` to key the display's group **arrangement** (manual
+36 -9
View File
@@ -95,8 +95,9 @@ pub fn acquire(
}
#[cfg(not(target_os = "linux"))]
{
// Windows leases in the manager (its own linger); the deliberate-quit skip is not wired
// through there yet, so the flag is accepted but unused off Linux.
// Windows leases in the manager (its own linger); its deliberate-quit skip is wired through
// `VirtualDisplay::set_quit_flag` on the backend instance (set by the session before any
// `create`, so the retry-hold lease gets it too) — not through this parameter.
let _ = quit;
vd.create(mode)
}
@@ -602,18 +603,25 @@ mod linux {
Ok(output_for(node_id, preferred_mode, gen, quit, false))
}
/// The linger a releasing session actually gets. A deliberate quit (`force_immediate` — the
/// client closed with the quit code, a user "stop") downgrades a linger WINDOW to an immediate
/// teardown; a bare disconnect honors the policy. `keep_alive = forever` (the gaming-rig
/// preset) OUTRANKS the quit: its promise is "the screen stays alive", so a deliberate quit
/// still pins — only an explicit `/display/release` frees it.
fn effective_linger(force_immediate: bool, policy: Linger) -> Linger {
match (force_immediate, policy) {
(true, Linger::Forever) => Linger::Forever,
(true, _) => Linger::Immediate,
(false, l) => l,
}
}
/// The [`DisplayLease`] `Drop` path: release the session's hold on the pooled display. The
/// lifecycle machine decides linger / pin / teardown; a torn-down entry's keepalive drops *after*
/// the lock is released.
fn release(gen: u64, force_immediate: bool) {
let Some(r) = REG.get() else { return };
// A deliberate quit (the client closed with the quit code — a user "stop") tears the display
// down NOW, overriding the keep-alive linger; a bare disconnect honors the policy.
let linger = if force_immediate {
Linger::Immediate
} else {
linger()
};
let linger = effective_linger(force_immediate, linger());
let (torn_down, restore) = {
let mut es = r.entries.lock().unwrap();
let Some(idx) = es.iter().position(|e| e.gen == gen) else {
@@ -965,6 +973,25 @@ mod linux {
Box::new(move || f.store(true, Ordering::SeqCst))
}
#[test]
fn deliberate_quit_skips_the_linger_window_but_never_a_pin() {
use std::time::Duration;
// Quit downgrades a linger window (and a no-linger policy stays immediate)…
assert_eq!(
effective_linger(true, Linger::For(Duration::from_secs(10))),
Linger::Immediate
);
assert_eq!(effective_linger(true, Linger::Immediate), Linger::Immediate);
// …but never a pin: keep_alive=forever (gaming-rig) promises the screen stays alive.
assert_eq!(effective_linger(true, Linger::Forever), Linger::Forever);
// A bare disconnect honors the policy untouched.
assert_eq!(
effective_linger(false, Linger::For(Duration::from_secs(10))),
Linger::For(Duration::from_secs(10))
);
assert_eq!(effective_linger(false, Linger::Forever), Linger::Forever);
}
#[test]
fn topology_restore_floats_to_a_sibling_then_runs_on_the_last_teardown() {
let ran = Arc::new(AtomicBool::new(false));
@@ -395,6 +395,7 @@ impl VirtualDisplayManager {
&'static self,
mode: Mode,
client_fp: Option<[u8; 32]>,
quit: Option<Arc<AtomicBool>>,
) -> Result<VirtualOutput> {
self.ensure_linger_timer();
let mut state = self.state.lock().unwrap();
@@ -474,7 +475,7 @@ impl VirtualDisplayManager {
backend = self.driver.name(),
"virtual monitor reused (concurrent / reconfigure session)"
);
return Ok(self.output_for(mon));
return Ok(self.output_for(mon, quit));
}
// Idle or kept: repurpose a kept monitor / create a fresh one → Active{refs:1}. (In practice a
@@ -516,13 +517,14 @@ impl VirtualDisplayManager {
},
MgrState::Active { .. } => unreachable!("handled above"),
};
let out = self.output_for(&mon);
let out = self.output_for(&mon, quit);
*state = MgrState::Active { mon, refs: 1 };
Ok(out)
}
/// Build the [`VirtualOutput`] (preferred mode + capture target + a fresh gen-stamped lease) for `mon`.
fn output_for(&'static self, mon: &Monitor) -> VirtualOutput {
/// `quit` is the session's deliberate-quit flag, read by the lease `Drop` (see [`Self::release`]).
fn output_for(&'static self, mon: &Monitor, quit: Option<Arc<AtomicBool>>) -> VirtualOutput {
VirtualOutput {
node_id: 0,
preferred_mode: Some((mon.mode.width, mon.mode.height, mon.mode.refresh_hz)),
@@ -530,6 +532,7 @@ impl VirtualDisplayManager {
keepalive: Box::new(MonitorLease {
mgr: self,
gen: mon.gen,
quit,
}),
// The Windows manager owns the monitor lifecycle (refcount/linger/pin), so the registry
// (which delegates to it via `vd.create`) treats it as Owned.
@@ -760,6 +763,28 @@ impl VirtualDisplayManager {
/// # Safety
/// `dev` must be the live control handle.
unsafe fn teardown(&self, dev: HANDLE, mut mon: Monitor) {
// Wedge visibility: this runs synchronously — usually UNDER the `state` lock (linger timer,
// reconnect preempt, quit-skip), so a REMOVE/CCD-restore that never returns (field signature:
// Windows AMD reconnects going silently dead) blocks every future `acquire` with NOTHING in the
// log. One ERROR line after 10 s turns that silent wedge into a diagnosis.
let done = Arc::new(AtomicBool::new(false));
{
let done = done.clone();
let target = mon.target_id;
thread::Builder::new()
.name("vdisplay-teardown-watch".into())
.spawn(move || {
thread::sleep(Duration::from_secs(10));
if !done.load(Ordering::SeqCst) {
tracing::error!(
target_id = target,
"virtual-display teardown still running after 10s — the driver \
REMOVE/CCD restore looks WEDGED; new sessions will block until it returns"
);
}
})
.ok();
}
mon.stop.store(true, Ordering::Relaxed);
if let Some(j) = mon.pinger.take() {
let _ = j.join();
@@ -784,12 +809,20 @@ impl VirtualDisplayManager {
"virtual-display monitor removed"
);
}
done.store(true, Ordering::SeqCst);
}
/// Release a session's hold (the [`MonitorLease`] `Drop`): refcount-- ; the last session leaving
/// LINGERs before teardown. A STALE lease (its monitor was preempted + recreated under it) is a
/// no-op, so it can't tear down the CURRENT monitor.
fn release(&self, gen: u64) {
/// LINGERs before teardown — unless `quit_now` (the client closed with the QUIT code, a user
/// "stop"), which tears the monitor down IMMEDIATELY instead of lingering. That both restores
/// the physical displays at the moment the user quits and means a follow-up reconnect finds the
/// manager Idle — a clean fresh ADD with the user's think-time as driver settle — instead of
/// tripping the Lingering-preempt's back-to-back REMOVE→ADD. `keep_alive = forever` (the
/// gaming-rig preset) OUTRANKS the quit: its promise is "the screen stays alive", so a
/// deliberate quit still pins — only `/display/release` frees a pinned monitor. A STALE lease
/// (its monitor was preempted + recreated under it) is a no-op, so it can't tear down the
/// CURRENT monitor.
fn release(&self, gen: u64, quit_now: bool) {
let mut state = self.state.lock().unwrap();
let stale = match &*state {
MgrState::Active { mon, .. }
@@ -805,14 +838,41 @@ impl VirtualDisplayManager {
mon,
refs: refs - 1,
},
// Last session left: keep the monitor forever (Pinned) under `keep_alive = forever`,
// else linger for the policy window before the timer tears it down.
// Last session left: keep the monitor forever (Pinned) under `keep_alive = forever`
// checked BEFORE the quit, because the gaming-rig preset's contract is "the screen
// stays alive": a deliberate quit skips only the linger window, never the pin.
MgrState::Active { mon, .. } if keep_alive_forever() => {
tracing::info!(
"virtual-display: last session left — PINNED (keep_alive=forever); free via /display/release"
);
MgrState::Pinned { mon }
}
// Last session left on a deliberate quit: tear down NOW (linger skipped). Teardown
// runs UNDER the state lock — same shape as the linger timer, and for the same reason: a
// racing `acquire` must WAIT the teardown out rather than see Idle and ADD into the
// driver's in-flight REMOVE. `device_handle()` is only None if the control device was
// never opened — impossible with a monitor live — but fall back to Lingering (the timer
// retries) rather than leak the monitor.
MgrState::Active { mon, .. } if quit_now => match self.device_handle() {
Some(dev) => {
tracing::info!(
"virtual-display: last session left (deliberate quit) — tearing down now, linger skipped"
);
// SAFETY: `teardown` requires `dev` to be the live control handle; `dev` is the
// cached process-lifetime `OwnedHandle` from `device_handle()` (the `Some` checked
// above; cached handles are never closed — a dead one is retired, kept alive). `mon`
// was moved out of the `Active` state under the `state` lock, so it is exclusively
// owned here — no aliasing.
unsafe { self.teardown(dev, mon) };
MgrState::Idle
}
None => MgrState::Lingering {
mon,
until: Instant::now() + Duration::from_millis(linger_ms()),
},
},
// Last session left, no quit signal: linger for the policy window before the timer
// tears it down.
MgrState::Active { mon, .. } => {
let ms = linger_ms();
tracing::info!(
@@ -948,11 +1008,17 @@ impl VirtualDisplayManager {
struct MonitorLease {
mgr: &'static VirtualDisplayManager,
gen: u64,
/// The session's deliberate-quit flag (the client closed with the QUIT application code — a user
/// "stop", not a network drop). Read at drop time: a quit release tears the monitor down NOW
/// instead of lingering, mirroring the Linux registry's `Linger::Immediate`. `None` = no signal
/// (GameStream sessions, the mgmt reconfigure path) → the linger policy applies.
quit: Option<Arc<AtomicBool>>,
}
impl Drop for MonitorLease {
fn drop(&mut self) {
self.mgr.release(self.gen);
let quit_now = self.quit.as_ref().is_some_and(|q| q.load(Ordering::SeqCst));
self.mgr.release(self.gen, quit_now);
}
}
@@ -595,12 +595,19 @@ pub struct PfVdisplayDisplay {
/// The connecting client's cert fingerprint (`None` = anonymous/GameStream → the manager's auto id).
/// Set by [`set_client_identity`](VirtualDisplay::set_client_identity) before `create`.
client_fp: Option<[u8; 32]>,
/// The session's deliberate-quit flag (`None` = no signal → the linger policy applies). Set by
/// [`set_quit_flag`](VirtualDisplay::set_quit_flag) before `create`; rides into every lease this
/// backend mints so a user "stop" tears the monitor down immediately instead of lingering.
quit: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
}
impl PfVdisplayDisplay {
pub fn new() -> Result<Self> {
super::manager::init(Box::new(PfVdisplayDriver)).open_backend()?;
Ok(Self { client_fp: None })
Ok(Self {
client_fp: None,
quit: None,
})
}
}
@@ -613,8 +620,12 @@ impl VirtualDisplay for PfVdisplayDisplay {
self.client_fp = fingerprint;
}
fn set_quit_flag(&mut self, quit: std::sync::Arc<std::sync::atomic::AtomicBool>) {
self.quit = Some(quit);
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
super::manager::vdm().acquire(mode, self.client_fp)
super::manager::vdm().acquire(mode, self.client_fp, self.quit.clone())
}
}
+101
View File
@@ -0,0 +1,101 @@
//! Last-resort crash visibility (Windows): an unhandled-SEH filter that logs a native crash —
//! an access violation inside a driver/runtime DLL (amfrt64, the GPU user-mode driver, d3d11,
//! IddCx plumbing) — through `tracing` before the process dies.
//!
//! Motivated by the "host went Offline with zero errors in the logs" class of field report: a
//! native crash kills the process (dropping the mDNS advert → every client tile flips Offline)
//! while the log ring's last entry is a healthy INFO line, so the report arrives with no evidence
//! at all. One ERROR line naming the exception code and the faulting module turns that into a
//! diagnosis. The Rust-panic analogue (a panic hook that tees into `tracing`) lives in `main()`.
// Every `unsafe` block in this file carries a `// SAFETY:` proof (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use windows::Win32::Foundation::HMODULE;
use windows::Win32::System::Diagnostics::Debug::{
SetUnhandledExceptionFilter, EXCEPTION_CONTINUE_SEARCH, EXCEPTION_POINTERS,
};
use windows::Win32::System::LibraryLoader::{
GetModuleFileNameW, GetModuleHandleExW, GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS,
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
};
/// Install the process-wide unhandled-exception filter. Call once at startup, after logging init
/// (the filter reports through `tracing`, so installing it earlier would log into the void).
pub fn install() {
// SAFETY: registers a process-wide top-level exception filter; `on_unhandled` is a plain
// `extern "system"` fn with the LPTOP_LEVEL_EXCEPTION_FILTER signature and static lifetime.
// The returned previous filter is deliberately dropped — we are the first/only installer.
unsafe {
SetUnhandledExceptionFilter(Some(on_unhandled));
}
}
/// STATUS_ACCESS_VIOLATION — the overwhelmingly common native-crash code; its first two
/// `ExceptionInformation` slots carry the access kind (0 read / 1 write / 8 execute) and the
/// target address, which we surface because they distinguish a wild pointer from a guard page.
const STATUS_ACCESS_VIOLATION: i32 = 0xC0000005u32 as i32;
/// The filter itself. Best-effort by design: it formats and logs, which allocates — if the crash
/// is heap corruption this can fault again, in which case the OS terminates us exactly as it was
/// about to anyway. Returns `EXCEPTION_CONTINUE_SEARCH` so default handling (WER / a debugger /
/// the service supervisor seeing the exit) still runs.
unsafe extern "system" fn on_unhandled(info: *const EXCEPTION_POINTERS) -> i32 {
let mut code: i32 = 0;
let mut addr: usize = 0;
let mut av_kind: Option<usize> = None;
let mut av_target: Option<usize> = None;
// SAFETY: `info` (and `ExceptionRecord`) are supplied by the OS for the duration of this
// callback; both are checked non-null before the read, and only plain fields are copied out.
unsafe {
if !info.is_null() && !(*info).ExceptionRecord.is_null() {
let r = &*(*info).ExceptionRecord;
code = r.ExceptionCode.0;
addr = r.ExceptionAddress as usize;
if code == STATUS_ACCESS_VIOLATION && r.NumberParameters >= 2 {
av_kind = Some(r.ExceptionInformation[0]);
av_target = Some(r.ExceptionInformation[1]);
}
}
}
let module = module_at(addr);
tracing::error!(
code = %format!("0x{:08x}", code as u32),
address = %format!("0x{addr:016x}"),
module = %module.as_deref().unwrap_or("<unknown>"),
av_kind = av_kind.map(|k| match k {
0 => "read",
1 => "write",
8 => "execute",
_ => "other",
}),
av_target = av_target.map(|t| format!("0x{t:016x}")),
"FATAL: unhandled native exception — the host process is about to die"
);
EXCEPTION_CONTINUE_SEARCH
}
/// Resolve the module (DLL/EXE) containing `addr` — the smoking gun that separates "our bug" from
/// "the GPU runtime crashed under us" (amfrt64.dll, atiumd64.dll, nvwgf2umx.dll, d3d11.dll, …).
fn module_at(addr: usize) -> Option<String> {
if addr == 0 {
return None;
}
let mut hmod = HMODULE::default();
// SAFETY: FROM_ADDRESS reinterprets the "module name" parameter as an address inside the
// module — `addr as *const u16` is exactly that; UNCHANGED_REFCOUNT means no AddRef, so the
// returned HMODULE needs no FreeLibrary. `&mut hmod` is a live out-pointer for the call.
unsafe {
GetModuleHandleExW(
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
windows::core::PCWSTR(addr as *const u16),
&mut hmod,
)
.ok()?;
}
let mut buf = [0u16; 512];
// SAFETY: `hmod` is the module handle resolved above; `buf` is a live, writable slice for the
// duration of the call. Returns the number of UTF-16 units written (0 on failure).
let n = unsafe { GetModuleFileNameW(Some(hmod), &mut buf) } as usize;
(n > 0).then(|| String::from_utf16_lossy(&buf[..n.min(buf.len())]))
}
+2
View File
@@ -98,6 +98,8 @@ package_punktfunk-host() {
install -Dm0755 "$T/punktfunk-host" "$pkgdir/usr/bin/punktfunk-host"
# /dev/uinput + /dev/uhid -> input group (virtual gamepads + DualSense UHID)
install -Dm0644 "$R/scripts/60-punktfunk.rules" "$pkgdir/usr/lib/udev/rules.d/60-punktfunk.rules"
# vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads)
install -Dm0644 "$R/scripts/punktfunk-modules.conf" "$pkgdir/usr/lib/modules-load.d/punktfunk.conf"
# 32 MB UDP socket buffers (send-side headroom at high bitrate)
install -Dm0644 "$R/scripts/99-punktfunk-net.conf" "$pkgdir/usr/lib/sysctl.d/99-punktfunk-net.conf"
# systemd USER units (the host runs in the graphical session, not as root); repoint ExecStart.
+4
View File
@@ -77,6 +77,10 @@ post_merge() {
for f in /usr/lib/sysctl.d/99-punktfunk-net.conf /usr/lib/sysctl.d/99-punktfunk-client-net.conf; do
[ -f "$f" ] && sysctl -q -p "$f" 2>/dev/null || :
done
# vhci-hcd now, no reboot (modules-load.d/punktfunk.conf covers boot): the usbip transport
# that makes the virtual Steam Deck pad a real USB device Steam Input adopts. The udev add
# event fires the 60-punktfunk.rules vhci rule, opening the attach files to the input group.
[ -f /usr/lib/modules-load.d/punktfunk.conf ] && modprobe vhci-hcd 2>/dev/null || :
# The /etc payload a sysext can't carry. The gamescope-session drop-in is %config(noreplace):
# only seed it, never clobber a local edit. The tray autostart entry is not user config.
if [ -f "$ETC_SRC/gamescope-session-plus/sessions.d/steam" ] \
+2
View File
@@ -42,6 +42,8 @@ SHAREDIR="$STAGE/usr/share/$PKG"
# --- file layout (matches the RPM %install) ----------------------------------
install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG"
install -Dm0644 scripts/60-punktfunk.rules "$STAGE/usr/lib/udev/rules.d/60-punktfunk.rules"
# vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads).
install -Dm0644 scripts/punktfunk-modules.conf "$STAGE/usr/lib/modules-load.d/punktfunk.conf"
# UDP socket-buffer tuning (32 MB) — without it the kernel clamps the host's SO_SNDBUF to ~416 KB
# and high-bitrate frames overflow it (send-side packet loss). systemd-sysctl applies it at boot.
install -Dm0644 scripts/99-punktfunk-net.conf "$STAGE/usr/lib/sysctl.d/99-punktfunk-net.conf"
+5
View File
@@ -195,6 +195,10 @@ install -Dm0755 target/release/punktfunk-host %{buildroot}%{_bindir}/punktfunk-h
# udev rule — /dev/uinput access for virtual gamepads (input group).
install -Dm0644 scripts/60-punktfunk.rules %{buildroot}%{_udevrulesdir}/60-punktfunk.rules
# vhci-hcd autoload — the usbip transport that makes the virtual Steam Deck controller a
# real USB device (Steam Input only adopts those; the UHID fallback is invisible to Steam).
install -Dm0644 scripts/punktfunk-modules.conf %{buildroot}%{_prefix}/lib/modules-load.d/punktfunk.conf
# UDP socket-buffer tuning (32 MB) — without it the kernel clamps the host's SO_SNDBUF to ~416 KB
# and high-bitrate frames overflow it (send-side loss). systemd-sysctl applies it at boot.
install -Dm0644 scripts/99-punktfunk-net.conf %{buildroot}%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
@@ -309,6 +313,7 @@ install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punkt
%{_bindir}/punktfunk-host
%{_bindir}/punktfunk-tray
%{_udevrulesdir}/60-punktfunk.rules
%{_prefix}/lib/modules-load.d/punktfunk.conf
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
%{_prefix}/lib/firewalld/services/punktfunk-gamestream.xml
%{_prefix}/lib/firewalld/services/punktfunk-native.xml
+7
View File
@@ -10,3 +10,10 @@
# sudo udevadm control --reload-rules && sudo udevadm trigger
KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", GROUP="input", MODE="0660", TAG+="uaccess"
KERNEL=="uhid", SUBSYSTEM=="misc", OPTIONS+="static_node=uhid", GROUP="input", MODE="0660", TAG+="uaccess"
# usbip vhci attach/detach for the virtual Steam Deck controller. Steam Input only
# adopts the virtual Deck when it arrives as a USB device (usbip/vhci or raw_gadget);
# the UHID fallback has no USB interface and Steam ignores it. The sysfs attach files
# are root-only by default while the host runs as a user service — grant the `input`
# group write when vhci_hcd appears (module autoload: modules-load.d/punktfunk.conf).
ACTION=="add", SUBSYSTEM=="platform", KERNEL=="vhci_hcd.*", RUN+="/bin/sh -c 'chgrp input /sys%p/attach /sys%p/detach && chmod 0660 /sys%p/attach /sys%p/detach'"
+8
View File
@@ -0,0 +1,8 @@
# Kernel modules the punktfunk host needs at boot (installed as
# /usr/lib/modules-load.d/punktfunk.conf).
#
# vhci-hcd: the usbip virtual host controller — the transport that makes the virtual
# Steam Deck controller a real USB device so Steam Input adopts it (see
# inject/linux/steam_usbip.rs and the vhci rule in 60-punktfunk.rules). Without it the
# pad falls back to plain UHID, which Steam ignores ("no controller appears").
vhci-hcd
+11 -2
View File
@@ -55,10 +55,19 @@ powershell -ExecutionPolicy Bypass -File scripts\windows\build-web.ps1
this to iterate on the console against an installed host - `punktfunk-host.exe web setup` (or a
fresh install) is what creates the task in the first place.
## Rebuild + redeploy everything
```powershell
powershell -ExecutionPolicy Bypass -File scripts\windows\deploy-all.ps1
```
Thin wrapper: runs `deploy-host.ps1` then `build-web.ps1` in sequence. If the host build/start
fails, `deploy-host.ps1` rolls itself back and throws, which stops this script before the web
console step runs.
## Typical flow after pulling new code
```powershell
git pull
powershell -ExecutionPolicy Bypass -File scripts\windows\deploy-host.ps1
powershell -ExecutionPolicy Bypass -File scripts\windows\build-web.ps1
powershell -ExecutionPolicy Bypass -File scripts\windows\deploy-all.ps1
```
+15 -5
View File
@@ -28,8 +28,18 @@ Get-CimInstance Win32_Process -Filter "Name='bun.exe' OR Name='node.exe'" -Error
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
Start-Sleep 2
& schtasks /run /tn $task | Out-Null
Start-Sleep 5
try {
$r = Invoke-WebRequest 'http://127.0.0.1:47992/login' -UseBasicParsing -TimeoutSec 10
Write-Host "DONE - web /login -> HTTP $($r.StatusCode)"
} catch { Write-Warning "web restarted but /login check failed: $($_.Exception.Message)" }
# web-run.cmd serves HTTPS-only (PUNKTFUNK_UI_SECURE=1, the host's own cert) - probe with curl.exe
# (-k for the self-signed cert; Invoke-WebRequest under Windows PowerShell 5.1, which this script
# runs under, has no -SkipCertificateCheck), retrying while the task/bun cold-starts.
$code = $null
for ($i = 0; $i -lt 15; $i++) {
Start-Sleep 2
$code = & curl.exe -sk -o NUL -w '%{http_code}' --max-time 5 'https://127.0.0.1:47992/login' 2>$null
if ($code -eq '200') { break }
}
if ($code -eq '200') {
Write-Host "DONE - web /login -> HTTP $code"
} else {
Write-Warning "web restarted but /login check did not return 200 (last: $code)"
}
+26
View File
@@ -0,0 +1,26 @@
<#
Rebuild + redeploy everything: the Windows host service AND the web management console.
Thin wrapper around deploy-host.ps1 + build-web.ps1 - see scripts\windows\README.md for what
each one does on its own (rollback behavior, build env, etc).
powershell -ExecutionPolicy Bypass -File scripts\windows\deploy-all.ps1
Run from an elevated PowerShell. deploy-host.ps1 throws (and rolls itself back) on a failed
build/start, which stops this script before the web console step runs.
#>
$ErrorActionPreference = 'Stop'
$here = $PSScriptRoot
Write-Host "=========================================="
Write-Host " 1/2 host service"
Write-Host "=========================================="
& (Join-Path $here 'deploy-host.ps1')
Write-Host ""
Write-Host "=========================================="
Write-Host " 2/2 web console"
Write-Host "=========================================="
& (Join-Path $here 'build-web.ps1')
Write-Host ""
Write-Host "DONE - host + web console redeployed."
+22 -22
View File
@@ -6,40 +6,40 @@
"name": "punktfunk-web",
"dependencies": {
"@fontsource-variable/geist": "^5.2.9",
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.121.0",
"@tanstack/react-start": "^1.121.0",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-router": "^1.170.17",
"@tanstack/react-start": "^1.168.27",
"@unom/style": "^0.4.4",
"@unom/ui": "^0.8.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"motion": "^12.40.0",
"radix-ui": "^1.6.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.9.0",
"tailwind-merge": "^2.6.0",
"motion": "^12.42.2",
"radix-ui": "^1.6.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"recharts": "^3.9.2",
"tailwind-merge": "^2.6.1",
"zod": "^4.4.3",
},
"devDependencies": {
"@biomejs/biome": "^2.5.1",
"@inlang/paraglide-js": "^2.0.0",
"@biomejs/biome": "^2.5.2",
"@inlang/paraglide-js": "^2.20.2",
"@storybook/react-vite": "^10.4.6",
"@tailwindcss/vite": "^4.0.0",
"@tailwindcss/vite": "^4.3.2",
"@tanstack/nitro-v2-vite-plugin": "^1.155.0",
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5",
"orval": "^8.16.0",
"@types/node": "^22.20.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"orval": "^8.20.0",
"playwright": "^1.61.1",
"storybook": "^10.4.6",
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.2.0",
"typescript": "^5.7.0",
"vite": "^7.3.5",
"vite-tsconfig-paths": "^5.1.0",
"tailwindcss": "^4.3.2",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.3.6",
"vite-tsconfig-paths": "^5.1.4",
},
},
},