24 Commits

Author SHA1 Message Date
enricobuehler 49533ff90a style(touch): rustfmt the presenter finger dispatch + gesture engine
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 1m5s
apple / swift (push) Successful in 4m33s
ci / bench (push) Successful in 5m22s
android-screenshots / screenshots (push) Successful in 3m30s
ci / rust (push) Successful in 23m9s
decky / build-publish (push) Successful in 1m23s
release / apple (push) Successful in 24m48s
android / android (push) Successful in 12m54s
arch / build-publish (push) Successful in 13m16s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 55s
web-screenshots / screenshots (push) Successful in 2m51s
docker / deploy-docs (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 28s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 24s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 30s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 30s
flatpak / build-publish (push) Successful in 7m4s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m19s
linux-client-screenshots / screenshots (push) Successful in 7m45s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m47s
windows-host / package (push) Failing after 7m43s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m47s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m51s
deb / build-publish (push) Successful in 12m5s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m2s
apple / screenshots (push) Successful in 19m24s
Wrap the long dispatch_finger call args, Abs struct literals, and Act::Button/
Scroll/MoveRel pushes per rustfmt (the CI fmt check on pf-presenter). No behavior
change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:01:05 +02:00
enricobuehler 1b890ae919 chore(release): bump workspace version to 0.10.1
android-screenshots / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
audit / bun-audit (push) Successful in 10s
audit / cargo-audit (push) Has been cancelled
ci / rust (push) Failing after 40s
ci / docs-site (push) Successful in 48s
ci / web (push) Successful in 51s
decky / build-publish (push) Successful in 16s
ci / bench (push) Successful in 5m12s
linux-client-screenshots / screenshots (push) Successful in 8m48s
flatpak / build-publish (push) Successful in 6m17s
arch / build-publish (push) Successful in 12m42s
release / apple (push) Has been cancelled
deb / build-publish (push) Has been cancelled
web-screenshots / screenshots (push) Successful in 2m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m58s
windows-host / package (push) Successful in 13m48s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m54s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m33s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m16s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m50s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m41s
android / android (push) Failing after 15m19s
docker / deploy-docs (push) Successful in 25s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
Bumps [workspace.package] version 0.10.0 -> 0.10.1 (14 workspace crates) and
syncs Cargo.lock (versions-only). Apple MARKETING_VERSION / Android versionName
are set from the release tag by CI, so no client manifest changes; the nested
Windows-driver workspace keeps its independent 0.0.1 version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:58:35 +02:00
enricobuehler f88d0ae4dc feat(touch): cross-client touch-input modes on Linux + Windows
ci / web (push) Successful in 43s
ci / rust (push) Failing after 53s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
decky / build-publish (push) Successful in 16s
ci / docs-site (push) Successful in 1m5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m41s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m53s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 52s
apple / swift (push) Successful in 4m41s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 54s
ci / bench (push) Successful in 6m30s
docker / deploy-docs (push) Successful in 23s
flatpak / build-publish (push) Failing after 8m9s
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
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
deb / build-publish (push) Has been cancelled
Bring the SDL presenter (Linux/Deck + Windows) to parity with the Android and
Apple clients: a persisted TouchMode selects how a touchscreen drives the host —

  * Trackpad (default): relative cursor with pointer ballistics + the shared
    gesture vocabulary (tap = left click, two-finger tap = right click,
    two-finger drag = scroll, tap-then-drag = held left drag, three-finger tap =
    cycle the stats overlay).
  * Direct pointer: the cursor jumps to and follows the finger (absolute).
  * Touch passthrough: every finger is a real host touchscreen contact.

Previously the presenter had no finger handling, so SDL synthesized mouse events
from touch and — under the stream's relative-mouse lock — walked the host cursor
into the corner (the reported Deck bug). SDL touch->mouse synthesis is now off;
DIRECT touchscreens route through a new incremental gesture engine (a port of
Android TouchInput.kt / Apple TouchMouse.swift), while INDIRECT trackpads keep
driving the mouse. Fingers map through the aspect-fit letterbox onto the content
rect.

TouchMode lives in the shared trust::Settings (default trackpad, so passthrough
is opt-in like the other clients); the GTK and WinUI settings screens both gained
a "Touch input" picker. Gesture engine, letterbox mapping, and settings
back-compat are unit-tested (28 tests green); clippy -D warnings clean; full
Linux client + session build verified on-host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:51:29 +02:00
enricobuehler 94802795e7 Merge branch 'gamepad-apple-cleanup': cross-client + host gamepad review cleanup (G1–G25)
audit / bun-audit (push) Successful in 13s
ci / docs-site (push) Successful in 49s
ci / web (push) Successful in 53s
decky / build-publish (push) Successful in 21s
audit / cargo-audit (push) Successful in 2m27s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 41s
apple / swift (push) Successful in 4m22s
ci / bench (push) Successful in 5m54s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m1s
windows-host / package (push) Successful in 9m55s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m5s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9m58s
flatpak / build-publish (push) Successful in 6m18s
docker / deploy-docs (push) Successful in 27s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m14s
arch / build-publish (push) Successful in 16m1s
android / android (push) Successful in 16m36s
deb / build-publish (push) Successful in 16m58s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m46s
ci / rust (push) Successful in 22m42s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m19s
release / apple (push) Successful in 20m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m8s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m49s
apple / screenshots (push) Successful in 18m55s
48-finding cross-client + host gamepad audit (2026-07-13). Apple/Android/SDL-core
capture + feedback and the Linux/Windows host injectors: held-guide release, the
permanent broken-latch cliff (PadGate), Steam Deck trackpad clicks, DualSense mute,
Windows DS/DS4 paddle fold, uinput button re-sync, gamestream BTN_* dedup, the dead
Windows shell fork, legacy-Deck rumble ceiling, XUSB arrival, ARM64 fences, the
truncate-everywhere value convention, and more. See
punktfunk-planning/design/gamepad-review-cleanup.md.
2026-07-13 22:29:41 +02:00
enricobuehler 764b5d938b fix(gamepad): resolve the menu diagonal tie-break horizontally on all clients (G25)
The gamepad-UI navigation resolvers disagreed on which way a perfect 45-degree
stick push (|x| == |y|) resolves: the SDL core picked horizontal (`ax >= ay`)
while Apple (`abs(x) > abs(y)`) and Android (`abs(Y) >= abs(X)`) picked vertical.
Align Apple (`>` -> `>=`) and Android (`>=` -> `>`) to the SDL core so an exact
diagonal moves focus the same way on every client (horizontal wins). This is
client-local menu navigation only and never reaches the wire. Completes the last
deferred G25 sub-part.

Verified: Apple `swift build` + full suite (124 pass); Android `:app:compileDebugKotlin`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:24:03 +02:00
enricobuehler 1af11cc64d fix(inject/host/windows): order the pad change-detect fields with Release/Acquire (G21)
The XUSB `packet` publish and the XUSB `rumble_seq` / DualSense `out_seq` reads
used plain unaligned accesses with no fence, so a driver could observe a bumped
change-detect field over a torn body on a weakly-ordered core (ARM64). Publish
`packet` via a Release AtomicU32 store behind a Release fence, and Acquire-load
the seq fields, mirroring the gamepad_raii PadChannel seq-fence precedent. The
DualSense input report embeds its seq mid-report with no driver-gated
change-detect field, so it gets a Release fence after the copy and a documented
residual (a per-frame input generation is deferred). No-op on x86-TSO.

Verified: Windows .173 `cargo clippy -p punktfunk-host --all-targets -- -D warnings` (green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:05:13 +02:00
enricobuehler 2f214532d9 fix(inject/host/windows): eager-create the XUSB pad on Arrival + refresh last_active (G10)
The XUSB manager's `handle` dropped `GamepadEvent::Arrival` via a `let else`, so
the GameStream path never created the pad until the first `State` and missed the
first XInput poll. Match on the event and `ensure` eagerly on Arrival, mirroring
the DualSense backend. Also refresh `last_active` on create and unplug so a
freshly-created pad's residual-rumble idle clock starts fresh rather than
inheriting a stale Instant (which could force off a legitimate rumble at once).

Verified: Windows .173 `cargo clippy -p punktfunk-host --all-targets -- -D warnings` (green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:05:13 +02:00
enricobuehler 31bc863084 fix(inject/host/windows): free the per-section security descriptor (G19)
`sddl_sa` leaked the `LocalAlloc`'d PSECURITY_DESCRIPTOR that
ConvertStringSecurityDescriptorToSecurityDescriptorW returns, once per DATA
section and once per bootstrap mailbox create (amplifiable under pad-flap via
create_named's squat-retry loop). Wrap it in a `SecAttr` RAII owner that
`LocalFree`s on drop; it outlives every CreateFileMappingW (the section copies
the security info at create time), and create_named builds one and reuses it
across retries instead of re-allocating.

Verified: Windows .173 `cargo clippy -p punktfunk-host --all-targets -- -D warnings`
(green) -- confirms the LocalFree/HLOCAL signature at the pinned windows-rs rev.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:05:13 +02:00
enricobuehler 60af4de3ba docs(gamepad/android): document the two-motor vibratorIds ordering assumption (G20)
The two-motor split assumes ids[0] = light/right and ids[1] = heavy/left, an
ordering `VibratorManager.getVibratorIds()` does not guarantee. Record the
assumption and its tactile-only failure mode (a heavy-first pad inverts the feel
but nothing silences or crashes) at the call site. No behavior change: a per-pad
fix needs on-glass verification, and a blanket count-based fallback is unsafe
(extra ids may be DualSense trigger actuators that must stay silent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:05:13 +02:00
enricobuehler aedffc69dd fix(gamepad/client): bound legacy Steam Deck rumble on a lost stop-frame (G16)
Against a legacy (no-TTL) host, a held Deck rumble droned forever if the stop
datagram was lost: the 40 ms keep-alive re-kicked the actuator indefinitely and
only the v2 lease `deadline` ever bounded it. Add a per-slot `updated_at` clock
bumped ONLY by real host datagrams (never by the keep-alive re-kick, unlike
`last_at`), and in the legacy branch (`ttl_ms == 0`) issue a single (0, 0) once
it is stale past LEGACY_RUMBLE_CEILING_MS (1000 ms = 2x the host's flat 500 ms
legacy refresh). A genuinely-held legacy rumble refreshes every 500 ms and never
trips; the v2 `deadline` path is untouched and stays authoritative.

Verified: Windows .173 `cargo clippy -p pf-client-core -- -D warnings` (green).
On-glass owed: real Deck with an induced legacy stop-frame drop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:05:13 +02:00
enricobuehler 26cac9ce20 fix(gamepad): truncate stick/trigger axes uniformly across clients (G25)
Apple's GamepadCapture rounded axis values (`(v * scale).rounded()`) while
SDL-core and Android truncate, so a half-pressed control emitted 128 on Apple
vs 127 elsewhere. Drop `.rounded()` so `Int32(Float)` truncates toward zero on
Apple too; rails are unchanged (full deflection stays 255 / ±32767).

Also clamp SDL-core's LeftX/RightX to a symmetric -32767 like the Y axes and
the other clients already do, instead of letting the raw i16 reach -32768.

Verified: Apple `swift build` + full PunktfunkKit suite (124 pass); SDL half
on Windows .173 `cargo clippy -p pf-client-core -- -D warnings` (green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:05:13 +02:00
enricobuehler 48933dc405 fix(gamepad/android): batched HAT, rumble-duration floor, bind eviction, held exit chord (G4/G9/G18/G24)
Four Android gamepad fixes bringing the client to parity with SDL/Apple:

G4 — HAT batched history. Android batches joystick ACTION_MOVEs, so a
rapid d-pad tap (press+release within one batch) lived only in the event's
historical samples; onMotion read just the final getAxisValue and missed
it. Feed every historical HAT sample through the transition logic (new
`applyHat`) before the current one. Sticks/triggers stay latest-wins.

G9 — floor the rumble one-shot duration. A v2 lease can carry ttl_ms==0
with a nonzero amplitude (past the (0,0) stop guard); createOneShot throws
on a non-positive duration, and on the VibratorManager path the effect is
built outside the vibrate() runCatching, so the throw would kill the whole
rumble poll thread. `durationMs.coerceAtLeast(1)`.

G18 — evict feedback binds on disconnect. Rumble/light bindings were
cached by device id and freed only at session stop, so a controller
unplugged mid-session leaked its open LightsSession. Add
GamepadFeedback.onDeviceRemoved(deviceId) (closes the session, cancels
rumble), invoked from GamepadRouter's slot-close via a new onSlotClosed
callback wired in StreamScreen. The bind maps are now guarded by a lock
(the poll threads write them; eviction runs on the main thread).

G24 — held exit chord + releases. The emergency-exit chord (Select+Start+
L1+R1) quit the stream the instant it completed — an accidental brush
killed the session, and the four held buttons were never released
host-side. Now completing the chord ARMS a 1.5 s hold timer (matching
DISCONNECT_HOLD on SDL/Apple); onExitChord fires only if still held at
expiry, after releasing the held buttons + zeroing the axes on the
triggering pad(s). onButton no longer returns the exit bool (async now);
MainActivity + StreamScreen updated.

G25 (Android half): no change — Android's stick/trigger `.toInt()` already
truncates, the chosen cross-client convention. G23 (rich-input plane) stays
deferred to its own doc.

Verified on this Mac: :kit + :app compileDebugKotlin clean; kit lint
unchanged at its pre-existing baseline. On-glass on a real phone + pad
still owed (per the Android-regressions-only-show-on-hardware history):
watch batched d-pad taps, the 1.5 s exit hold, and a mid-session unplug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:04:30 +02:00
enricobuehler e5166c6e6e fix(host/steam): load vhci_hcd at boot on sysext hosts so the Deck pad is Steam-Input-promotable
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m9s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
decky / build-publish (push) Successful in 30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
apple / swift (push) Successful in 4m25s
ci / bench (push) Successful in 6m50s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5m49s
docker / deploy-docs (push) Successful in 26s
windows-host / package (push) Successful in 8m40s
arch / build-publish (push) Successful in 11m22s
deb / build-publish (push) Successful in 12m27s
android / android (push) Successful in 16m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m19s
apple / screenshots (push) Successful in 19m19s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m14s
ci / rust (push) Successful in 25m14s
The virtual Steam Deck pad only appears in the host's Game Mode (and is
navigable) when it arrives as a real USB device via the usbip/vhci_hcd
transport — Steam Input won't promote the UHID hid-steam fallback
(Interface: -1). The host runs as an unprivileged --user service, so it
cannot modprobe vhci_hcd itself; the module must be loaded at boot and the
vhci attach/detach sysfs files chgrp'd to the `input` group by the udev
rule.

Packaging ships modules-load.d/punktfunk.conf + 60-punktfunk.rules under
the sysext's /usr/lib, but a systemd-sysext image MERGES after
systemd-modules-load and early udev have already run, so on a plain reboot
of a sysext host (e.g. Bazzite) those files are read too late: vhci_hcd is
never loaded, usbip fails, and the pad silently degrades to non-promoted
UHID — the controller vanishes from Game Mode. (deb/arch/rpm are
unaffected: real /usr is present at early boot.)

Fix: sysext post_merge now mirrors BOTH files into real /etc (read at the
normal early-boot time, shadowing the /usr copies by filename; refreshed
every merge since neither is user config), then reloads udev, modprobes
vhci-hcd, and re-triggers the vhci platform device for the live session.
Also raise the UHID-fallback log INFO->WARN with an actionable hint.

Verified on the .41 sysext host: after the /etc mirror, unloading vhci_hcd
and restarting systemd-modules-load (the real reader of /etc/modules-load.d)
reloads the module; a udev coldplug trigger makes attach/detach root:input
0660; the unprivileged host user can then write attach — the exact working
precondition for the usbip transport, now durable across reboot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:41:51 +02:00
enricobuehler 59fc820226 perf(inject/host): dedup the DualSense HID-output feedback plane (G17)
A game's DualSense output report bundles rumble + lightbar + player-LEDs
+ adaptive-triggers into one report, so a pad that is merely rumbling
re-sends its unchanged lightbar / LED / trigger state on every output
report. The managers already dedup rumble, but forwarded every rich
`HidOutput` event verbatim — flooding the 0xCD feedback plane to the
client during continuous rumble.

Add a shared `HidoutDedup` (dualsense_proto, used by both the Linux UHID
and Windows UMDF managers) that forwards Led/PlayerLeds/Trigger only on a
value change (per side for the two triggers) and always forwards one-shot
TrackpadHaptic pulses — mirroring the rumble dedup two lines above and the
DS4 backend's lightbar dedup. Reset per pad on create/unplug.

Verified on Linux .21 (clippy -D warnings clean, new HidoutDedup unit
test + full suite green); Windows .173 with the rest of Phase 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:14:28 +02:00
enricobuehler d611645ffc refactor(inject/host/windows): hoist the shared SwCreateCtx into gamepad_raii (G14)
The `SwDeviceCreate` completion-callback context (`SwCreateCtx`, the
`sw_create_cb` extern callback, and the `instance_id()` accessor) was
copy-pasted byte-for-byte in the XUSB (`gamepad_windows.rs`) and
DualSense/DS4 (`dualsense_windows.rs`) backends. Hoist the one copy into
`gamepad_raii.rs` as `pub(super)`; both `create_swdevice` bodies now build
the shared type and pass the shared callback. Prunes the now-orphaned
HRESULT/SetEvent/HANDLE imports from the two siblings.

Pure move + dedup, no behavior change. Windows-verified with the rest of
Phase 3 (clippy --all-targets -D warnings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:08:00 +02:00
enricobuehler 17457cf4ba refactor(gamestream/host): source gamepad BTN_* from punktfunk_core + pin the wire bits (G13/G15)
`gamestream/gamepad.rs` hand-declared its own copy of the GameStream
buttonFlags/buttonFlags2 layout, which had drifted from the single source
of truth in `punktfunk_core::input::gamepad`: the click bits were named
`BTN_LS_CLK`/`BTN_RS_CLK` (vs core's `…_CLICK`). The two layouts are
bit-identical — GameStream/Limelight and the punktfunk/1 native wire are
one contract — so define the gamestream names as `pub const` aliases of
the core constants. Values now come solely from core (can't drift);
kept as `pub const` (not a `pub use` re-export) because on Windows the
only consumer — the Linux uinput button map — is cfg'd out, where an
unused re-export lints as an error but an unused pub const does not.

Rename the two injector call-sites (`inject/linux/gamepad.rs`) to the
canonical `BTN_LS_CLICK`/`BTN_RS_CLICK`.

G15 host half: replace the 3-bit gamestream-vs-core spot-check with an
exhaustive golden-value test (`gamepad_wire_bits_are_pinned`) that freezes
every button bit + axis id to its exact wire value, so renumbering a bit
in core — which would silently break every shipped client — fails a test
first. The host counterpart to the client-side C-ABI cross-checks.

Verified on Linux .21: clippy -D warnings clean, pin test + gamepad
suite green. (Windows verified together with the rest of Phase 3.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:08:00 +02:00
enricobuehler 6263108e15 fix(inject/host/windows): fold Steam back grips on the Windows DS/DS4 backends (G7)
The Windows DualSense and DualShock 4 managers passed the raw wire
buttons straight into `DsState::from_gamepad`, so a client's Steam back
grips (BTN_PADDLE1..4) were silently dropped and `PUNKTFUNK_STEAM_REMAP`
was ignored — the Linux DS/DS4 backends already fold them via
`steam_remap::fold_paddles`. Bring the Windows backends to parity: add a
`remap: steam_remap::RemapConfig` field (`::from_env()` in `new()`) to
both managers and fold the paddles before `from_gamepad`, exactly as
`linux/dualsense.rs` / `linux/dualshock4.rs`. Default policy stays Drop
(don't fire buttons the user didn't ask for); set the env to map the
grips onto stick-clicks or shoulders.

`steam_remap` was gated `target_os = "linux"`; widened to
`any(linux, windows)`. It's pure (only punktfunk_core + std::env); its
Linux-only Deck motion rescale is `pub` so it compiles clean on Windows
with no dead-code warning.

Verified: Linux .21 (clippy -D warnings clean, inject tests 32 pass / 0
fail — the gate widening is a no-op there); Windows .173 (clean-recheck
of punktfunk-host, cargo clippy --all-targets -D warnings EXITCODE 0,
steam_remap + both managers compiling on Windows for the first time).
On-glass with a real DualSense/DS4 + PUNKTFUNK_STEAM_REMAP still owed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:36:13 +02:00
enricobuehler 0c427cb3f1 fix(inject/host/linux): re-assert absolute gamepad button state each frame (G8)
The uinput gamepad backend emitted only XOR-changed button edges while
advancing `prev_buttons` unconditionally. Because `emit()` is best-effort
(a full kernel queue silently drops the write), a dropped EV_KEY edge was
never re-synced — the button stayed stuck (pressed-not-released, or vice
versa) until it next toggled. The axes never had this problem: they
re-emit their absolute value every frame.

Re-assert every mapped button's absolute state each frame, exactly like
the axes, and drop the now-unused `prev_buttons` field. Restating an
unchanged key is free downstream: the kernel input core discards an
EV_KEY whose value already matches the device's current state (no
duplicate event reaches consumers, and BTN_* keys don't autorepeat). The
`emit()` "next frame re-syncs state" comment is now honest for buttons
too.

Verified on the Linux host build (.21): cargo clippy -D warnings clean
(no dead-field warning), full punktfunk-host suite 277 passed / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:14:02 +02:00
enricobuehler 5109a4c80a refactor(inject/host): extract the shared PadGate create-retry policy + fix the permanent broken latch (G3/G12)
All seven virtual-pad managers (Linux uinput/uhid: gamepad, dualsense,
dualshock4, steam_controller; Windows XUSB/UMDF: gamepad, dualsense,
dualshock4) carried an identical copy-pasted `broken: bool` latch that
was set on the FIRST pad-creation error and never cleared — so a single
transient failure (a startup race on /dev/uinput, a momentary EBUSY, the
Windows companion driver not yet ready) permanently disabled EVERY
controller for the rest of the session, even after the cause cleared.

Extract that latch into one shared, unit-tested `PadGate`
(inject/pad_gate.rs) with the fix baked in: capped exponential backoff
(1s doubling to 30s) instead of a permanent kill. After a failure,
creation is blocked only until the backoff elapses — so the manager no
longer re-attempts (and re-logs) on every one of the 60–240 input
frames/sec — then a single retry is allowed; a success resets the
backoff. A genuinely broken setup therefore self-heals within one
backoff window of the fix (udev reload / driver install / next client
connect) with no host restart. The gate is manager-wide, matching the
old flag's semantics (these failures are systemic, not per-slot).

This folds G3 (broken latch) into G12 (dedup the manager skeleton): the
latch now lives in one place across all seven backends.

Verified on the Linux host build (.21): cargo clippy -D warnings clean,
full punktfunk-host suite 277 passed / 0 failed, 4 new PadGate tests
green. Windows managers verified separately on the x64 box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:33:49 +02:00
enricobuehler 43e52437c0 fix(gamepad/host): map BTN_MISC1 to the DualSense mute button (G6)
DsState::from_gamepad mapped GUIDE→PS and TOUCHPAD→TOUCHPAD into buttons[2] but
never handled BTN_MISC1, so the mic-mute / capture button clients send was inert
on every PlayStation-family virtual pad (DualSense/DualShock4), and btn2::MUTE
was dead code. Map BTN_MISC1 → btn2::MUTE (rebuilt from the wire bit each frame
like PS/TOUCHPAD, so no persistence gap) and drop the #[allow(dead_code)].

Test extended (from_gamepad_maps_touchpad_click); green on Linux (.21).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:04:59 +02:00
enricobuehler 2642ba6ad0 fix(gamepad/host): keep Steam Deck trackpad clicks across a button frame (G2)
SteamControllerManager::handle rebuilds `SteamState.buttons` from the gamepad
frame every tick via from_gamepad, preserving only the rich-plane TOUCH bits —
so a held trackpad CLICK (set on the rich plane by apply_rich, stored in
`buttons`) was wiped on the very next button/stick frame and only flickered
back on the next rich event. This is the exact trap the DualSense backend
already dodges by keeping click in a separate `touch_click` field.

Mirror that: add persisted `lpad_click`/`rpad_click` bools to SteamState set by
apply_rich (instead of pressing LPAD_CLICK/RPAD_CLICK into `buttons`), OR them
into the report's click bits in serialize_deck_state, and preserve them across
the rebuild in handle() like touch/coords/motion. RPAD_CLICK's other owner —
the DualSense touchpad-click wire button via from_gamepad — stays in `buttons`
and is OR'd at serialize, so the two sources release independently (a released
BTN_TOUCHPAD can't strand a rich click, and vice-versa).

Adds a regression test (rich_click_survives_a_buttons_rebuild). All 17
inject::{steam,dualsense,dualshock4}_proto tests pass on Linux (.21).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:04:59 +02:00
enricobuehler 236c59754b refactor(gamepad/windows): drop the dead shell fork, use pf-client-core's service
clients/windows/src/gamepad.rs was a 629-line near-verbatim fork of
pf-client-core's SDL gamepad service, frozen at an old single-pad design.
Commit 9822fc3b removed its attach/detach entry points but left the machinery,
so `Worker.attached` was initialized None and never set — ~300-400 lines
(button/axis/touchpad/motion forwarding, Ds5Feedback, the rumble/HID feedback
loop) were logically unreachable, never flagged because the guards read a
runtime Option the compiler can't prove is always None. The live remainder
(pad enumeration + pin persistence) had drifted from core: it opened every
device for metadata (vs core's no-open id-getters), force-enabled the Valve
HIDAPI drivers unconditionally, lacked the steam_virtual skip (so it could pin
Steam Input's virtual pad and kill gyro), and derived the pin key from an
opened handle — risking a cross-process byte-mismatch with the session, which
resolves the same key from id-getters.

The shell's only live job is enumerating pads for the Settings list and
persisting the pin; the spawned punktfunk-session already runs the full
pf-client-core service and does all real forwarding (session/main.rs). So
delete the fork and point the shell at pf_client_core::gamepad::GamepadService
directly — its start()/pads()/set_pinned()/clone() + PadInfo{key,name,
kind_label()} are a strict superset of what the shell uses. Idle, core's
service is hands-off the hardware (id-getter metadata, no device open, HIDAPI
off), which is the intended behavior and fixes the drift class above.

- delete clients/windows/src/gamepad.rs (-629) and `mod gamepad;`
- main.rs / app/mod.rs: use pf_client_core::gamepad::GamepadService
- drop the now-unused direct sdl3 dep (pf-client-core pulls it on Windows with
  the same build-from-source,hidapi features); sync Cargo.lock

Pre-checks (dev Mac): std mpsc Sender<T>: Sync confirmed on the pinned 1.96.0
(so core's GamepadService is Sync for the WinUI cross-thread sharing, no core
change needed); rustfmt clean; no dangling refs. Windows compile is deferred
to CI (windows-only crate, unbuildable on macOS).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 11:25:03 +02:00
enricobuehler 68b9f108ab feat(gamepad/apple): send Share/Create as BTN_MISC1 + pin wire bits to the C ABI
G5: buttonMask mapped the dedicated share/create/capture element onto BTN_BACK,
the same bit as View (buttonOptions). On an Xbox-Series pad those are two
distinct physical buttons, so Share was indistinguishable from View on the
host and never delivered the capture bit the host already decodes (DualSense
mute / Steam quick-access). Route it to BTN_MISC1 instead, matching the Rust
client's `Button::Misc1 => wire::BTN_MISC1`. Adds `misc1` to GamepadWire and
allButtons so a held capture button is released on flush like the others.
(On-glass verify owed on a real Xbox-Series pad; a clone pad that exposes one
button as both buttonOptions and Share now emits back+misc1 for it — harmless
on a plain xpad session and rare otherwise.)

G22 (partial): define paddle1..4 for wire completeness, but leave them out of
buttonMask/allButtons until the GameController paddleButton1..4 ↔ BTN_PADDLE
physical correspondence is confirmed on a real Elite pad.

G15: replace the 3-bit spot-check with an exhaustive assertion of every
GamepadWire button/axis constant against the generated C ABI header
(punktfunk_core.h), so any Swift-side drift from punktfunk_core::input::gamepad
fails CI.

swift build + full PunktfunkKit suite green (124 passed, 5 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 11:14:26 +02:00
enricobuehler 5cd66eca59 fix(gamepad/apple): stop releasing held guide on concurrent input
`sync()` XOR-diffs the full `GamepadWire.allButtons` set (which includes
guide) against `slot.buttons`, but `buttonMask` deliberately omits guide —
it's driven separately by the Home handler via `sendGuide`. So while guide
was physically held, the first stick/trigger/face-button move made `changed`
carry the guide bit and the diff loop emitted a spurious guide-UP (then the
real release was swallowed by `sendGuide`'s `guard now != slot.buttons`).

Effect: you could not hold PS/guide while doing anything else — e.g. holding
guide to keep the host's Steam overlay engaged released it the instant you
touched a stick. The Rust reference client folds guide through the same diff
as every other button and has no such split.

Fix: preserve the current held guide bit through the diff
(`buttonMask(g) | (slot.buttons & GamepadWire.guide)`) so guide is never seen
as "changed"; `sendGuide` stays the sole toggler and `flush`/`allButtons`
still release it on close/deactivation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 11:12:47 +02:00
41 changed files with 2095 additions and 979 deletions
Generated
+14 -15
View File
@@ -2145,7 +2145,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.10.0"
version = "0.10.1"
[[package]]
name = "lazy_static"
@@ -2277,7 +2277,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"punktfunk-core",
]
@@ -2756,7 +2756,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-client-core"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"anyhow",
"async-channel",
@@ -2778,7 +2778,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"anyhow",
"ash",
@@ -2799,7 +2799,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"ash",
"bindgen",
@@ -2808,7 +2808,7 @@ dependencies = [
[[package]]
name = "pf-presenter"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"anyhow",
"ash",
@@ -2992,7 +2992,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"android_logger",
"jni",
@@ -3008,7 +3008,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"anyhow",
"async-channel",
@@ -3024,7 +3024,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3039,14 +3039,13 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"async-channel",
"ffmpeg-next",
"mdns-sd",
"pf-client-core",
"punktfunk-core",
"sdl3",
"serde",
"serde_json",
"tracing",
@@ -3059,7 +3058,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"aes-gcm",
"bytes",
@@ -3090,7 +3089,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"aes",
"aes-gcm",
@@ -3162,7 +3161,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3176,7 +3175,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"anyhow",
"ksni",
+1 -1
View File
@@ -35,7 +35,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.10.0"
version = "0.10.1"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
@@ -241,7 +241,10 @@ private fun resolveDir(s: NavInputState): NavDir? {
if (s.hatY >= 0.5f) return NavDir.DOWN
if (s.hatX <= -0.5f) return NavDir.LEFT
if (s.hatX >= 0.5f) return NavDir.RIGHT
return if (abs(s.stickY) >= abs(s.stickX)) {
// Horizontal wins an exact |x| == |y| diagonal tie (Y must be strictly greater to take the
// vertical branch), matching the SDL core and Apple nav so a perfect 45° push resolves the
// same on every client.
return if (abs(s.stickY) > abs(s.stickX)) {
when {
s.stickY <= -STICK_HIGH -> NavDir.UP
s.stickY >= STICK_HIGH -> NavDir.DOWN
@@ -127,12 +127,12 @@ class MainActivity : ComponentActivity() {
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
val bit = Gamepad.buttonBit(event.keyCode)
if (bit != 0) {
// The router forwards the bit on this device's own wire pad index, tracks held
// state per pad, and reports when the emergency-exit chord (Select + Start + L1 +
// R1) completed on any one pad (a couch user has no keyboard/Back).
if (gamepadRouter?.onButton(event, bit) == true) {
requestStreamExit?.let { exit -> window.decorView.post { exit() } }
}
// The router forwards the bit on this device's own wire pad index and tracks held
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
// inside the router: holding it for ~1.5 s fires router.onExitChord (wired in
// StreamScreen), so a couch user with no keyboard/Back can still leave — but an
// accidental brush of the four buttons no longer quits instantly.
gamepadRouter?.onButton(event, bit)
return true // consumed
}
}
@@ -180,13 +180,19 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
val router = GamepadRouter(context, handle, initialSettings.gamepad)
activity?.gamepadRouter = router
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
// the keep-alive linger), unlike a host-ended / backgrounded drop.
// the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it
// (must be held ~1.5 s) and fires onExitChord on its main-thread timer, so leave the stream
// the same way the Back gesture does.
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
router.onExitChord = { activity?.requestStreamExit?.invoke() }
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
// index via the router; poll threads stopped + joined before the router is released and the
// session closed.
val feedback = GamepadFeedback(handle, router).also { it.start() }
// Free a disconnected controller's rumble/lights bindings promptly (else the open lights
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
router.onSlotClosed = feedback::onDeviceRemoved
onDispose {
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
@@ -219,14 +219,31 @@ object Gamepad {
),
)
// HAT → dpad button transitions (track previous, emit only the deltas).
val hx = sign(event.getAxisValue(MotionEvent.AXIS_HAT_X))
// HAT → dpad button transitions. Android BATCHES joystick ACTION_MOVEs, so a rapid d-pad
// tap (press+release inside one batch window) lives only in the historical samples — the
// final getAxisValue would show the HAT already back at rest and miss the tap entirely.
// Feed every historical HAT sample (oldest→newest) through the same transition logic
// before the current one, so each edge is emitted. (Sticks/triggers stay latest-wins:
// only the final value matters for an analog axis.)
for (h in 0 until event.historySize) {
applyHat(
sign(event.getHistoricalAxisValue(MotionEvent.AXIS_HAT_X, h)),
sign(event.getHistoricalAxisValue(MotionEvent.AXIS_HAT_Y, h)),
)
}
applyHat(
sign(event.getAxisValue(MotionEvent.AXIS_HAT_X)),
sign(event.getAxisValue(MotionEvent.AXIS_HAT_Y)),
)
}
/** Emit dpad button deltas for one HAT sample (`hx`/`hy` each 1/0/+1), tracking held state. */
private fun applyHat(hx: Int, hy: Int) {
if (hx != hatX) {
if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false)
if (hx < 0) btn(BTN_DPAD_LEFT, true) else if (hx > 0) btn(BTN_DPAD_RIGHT, true)
hatX = hx
}
val hy = sign(event.getAxisValue(MotionEvent.AXIS_HAT_Y))
if (hy != hatY) {
if (hatY < 0) btn(BTN_DPAD_UP, false) else if (hatY > 0) btn(BTN_DPAD_DOWN, false)
if (hy < 0) btn(BTN_DPAD_UP, true) else if (hy > 0) btn(BTN_DPAD_DOWN, true)
@@ -64,10 +64,16 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
private var rumbleThread: Thread? = null
private var hidoutThread: Thread? = null
// Per-controller bindings, keyed by device id, built lazily. rumbleBinds is touched ONLY by the
// rumble thread and lightBinds ONLY by the hidout thread while running; stop() reads both from the
// main thread AFTER joining those threads (join establishes the happens-before), so plain maps are
// race-free. A null value caches "this controller has no vibrator / no controllable lights".
// Per-controller bindings, keyed by device id, built lazily. rumbleBinds is written by the rumble
// thread and lightBinds by the hidout thread while running; [onDeviceRemoved] also evicts+closes
// from the MAIN thread on a hot-unplug, and stop() clears both from the main thread after joining
// the threads. That main-vs-poll concurrency is why every access goes through `bindsLock` (a plain
// HashMap can corrupt under a concurrent structural write, and ConcurrentHashMap can't hold the
// null value that caches "this controller has no vibrator / no controllable lights"). The lock
// guards only the map ops — rendering runs on the returned reference outside it; a stale reference
// is harmless (a closed LightsSession's requestLights and a cancelled Vibrator are runCatching'd
// no-ops). A null value caches the negative result so a pad with no hardware isn't re-probed.
private val bindsLock = Any()
private val rumbleBinds = HashMap<Int, RumbleBind?>()
private val lightBinds = HashMap<Int, LightBind?>()
@@ -122,13 +128,35 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
rumbleThread = null
hidoutThread = null
// Threads are dead — drop any held rumble and close every lights session.
for (b in rumbleBinds.values) b?.let {
runCatching { it.vm?.cancel() }
runCatching { it.legacy?.cancel() }
synchronized(bindsLock) {
for (b in rumbleBinds.values) b?.let {
runCatching { it.vm?.cancel() }
runCatching { it.legacy?.cancel() }
}
for (b in lightBinds.values) b?.let { runCatching { it.session.close() } }
rumbleBinds.clear()
lightBinds.clear()
}
}
/**
* Evict and release the bindings for a controller that just disconnected — invoked from
* [GamepadRouter]'s slot-close on the main thread (routed via `StreamScreen`). Closes its
* `LightsSession` and cancels any held rumble, so a hot-unplug mid-session frees the session
* immediately instead of leaking it until [stop]. A no-op for a device with no cached binding.
* The next feedback for that pad index rebinds against whatever controller now holds it.
*/
// Same runtime-guarded cleanup as [stop] (VIBRATE is app-declared; the light bind only exists
// under the SDK 33 guard) — suppress the module-isolation lint false positives it re-triggers.
@Suppress("MissingPermission", "NewApi")
fun onDeviceRemoved(deviceId: Int) {
synchronized(bindsLock) {
rumbleBinds.remove(deviceId)?.let {
runCatching { it.vm?.cancel() }
runCatching { it.legacy?.cancel() }
}
lightBinds.remove(deviceId)?.let { runCatching { it.session.close() } }
}
for (b in lightBinds.values) b?.let { runCatching { it.session.close() } }
rumbleBinds.clear()
lightBinds.clear()
}
// ---- Rumble ----
@@ -136,10 +164,12 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
/** The rumble binding for the controller on wire pad [pad], or null (no live pad / no vibrator). Cached by device id. */
private fun rumbleBindFor(pad: Int): RumbleBind? {
val dev = router?.deviceForPad(pad) ?: return null
if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id]
val bind = bindRumble(dev)
rumbleBinds[dev.id] = bind
return bind
synchronized(bindsLock) {
if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id]
val bind = bindRumble(dev)
rumbleBinds[dev.id] = bind
return bind
}
}
private fun bindRumble(dev: InputDevice): RumbleBind? {
@@ -184,7 +214,13 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
}
val combo = CombinedVibration.startParallel()
if (bind.amplitudeControlled && bind.ids.size >= 2) {
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
// Two-motor split — ASSUMPTION: ids[0] = light/right, ids[1] = heavy/left
// (XInput/Moonlight convention). Android does not guarantee the order of
// VibratorManager.getVibratorIds(), so a pad that enumerates heavy-first would
// invert the feel: the stronger amplitude drives the physically-lighter motor.
// Failure mode is tactile only — both motors still fire, nothing silences or
// crashes — so this stays the default pending per-pad on-glass verification (G20).
// ids beyond the first two (rare) are left alone here.
if (hi != 0) combo.addVibrator(bind.ids[0], oneShot(hi, durationMs))
if (lo != 0) combo.addVibrator(bind.ids[1], oneShot(lo, durationMs))
} else {
@@ -217,9 +253,13 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
}
// One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
// self-terminates on a lost stop; cancel on zero.
// self-terminates on a lost stop; cancel on zero. Floor the duration at 1 ms: `createOneShot`
// throws IllegalArgumentException on a non-positive duration, and a lease can carry ttl_ms==0
// (e.g. the legacy-Deck ceiling) with a nonzero amplitude — which reaches here past the (0,0)
// stop guard. On the VibratorManager path the effect is built OUTSIDE the vibrate() runCatching,
// so an uncaught throw here would kill the whole rumble poll thread.
private fun oneShot(amp: Int, durationMs: Long): VibrationEffect =
VibrationEffect.createOneShot(durationMs, amp)
VibrationEffect.createOneShot(durationMs.coerceAtLeast(1), amp)
// ---- HID output ----
@@ -268,10 +308,12 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
private fun lightBindFor(pad: Int): LightBind? {
if (Build.VERSION.SDK_INT < 33) return null
val dev = router?.deviceForPad(pad) ?: return null
if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id]
val bind = bindLights(dev)
lightBinds[dev.id] = bind
return bind
synchronized(bindsLock) {
if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id]
val bind = bindLights(dev)
lightBinds[dev.id] = bind
return bind
}
}
private fun bindLights(dev: InputDevice): LightBind? {
@@ -44,6 +44,23 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
/** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
private val slots = ConcurrentHashMap<Int, Slot>()
/**
* Invoked (main thread) with the deviceId whenever a slot closes — hot-unplug or session teardown.
* `StreamScreen` wires this to `GamepadFeedback.onDeviceRemoved` so a disconnected pad's rumble /
* lights bindings are released promptly instead of leaking until the feedback threads stop.
*/
var onSlotClosed: ((deviceId: Int) -> Unit)? = null
/**
* Invoked (main thread) when the emergency-exit chord has been HELD for [EXIT_HOLD_MS] — the caller
* leaves the stream. `StreamScreen` wires this to the deliberate-quit exit.
*/
var onExitChord: (() -> Unit)? = null
private val mainHandler = Handler(Looper.getMainLooper())
/** The pending exit-chord hold timer, or null when the chord isn't currently armed. */
private var pendingExit: Runnable? = null
private val inputManager = context.getSystemService(InputManager::class.java)
private val listener = object : InputManager.InputDeviceListener {
override fun onInputDeviceAdded(deviceId: Int) {
@@ -55,7 +72,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
}
init {
inputManager?.registerInputDeviceListener(listener, Handler(Looper.getMainLooper()))
inputManager?.registerInputDeviceListener(listener, mainHandler)
// Open a slot for every controller already connected when the session starts — the pads that
// will never fire onInputDeviceAdded during this session; their Arrival lands before any input.
for (id in InputDevice.getDeviceIds()) {
@@ -66,28 +83,55 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
/**
* One gamepad button transition for the device that produced [event] (already resolved to BTN_*
* bit [bit]). Opens the device's slot (declaring its type) if unseen, forwards the bit on the
* slot's pad index, tracks held state, and returns true when this press completed the emergency
* stream-exit chord (Select + Start + L1 + R1) on THIS pad — the caller then leaves the stream
* (mirrors the Linux client's escape chord: any one controller can leave).
* slot's pad index, and tracks held state. Completing the emergency stream-exit chord (Select +
* Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly;
* [onExitChord] fires only if the chord is still held at expiry (a brief accidental brush is
* ignored), matching `DISCONNECT_HOLD` on the SDL/Apple clients. Any controller can leave.
*/
fun onButton(event: KeyEvent, bit: Int): Boolean {
val slot = slotFor(event.device) ?: return false
fun onButton(event: KeyEvent, bit: Int) {
val slot = slotFor(event.device) ?: return
when (event.action) {
KeyEvent.ACTION_DOWN -> {
// repeatCount guard: don't re-send a held button as auto-repeat.
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
slot.held = slot.held or bit
if (slot.held and EXIT_CHORD == EXIT_CHORD) {
slot.held = 0
return true
}
// Full chord now held on this pad → start the hold countdown (idempotent while held).
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
}
KeyEvent.ACTION_UP -> {
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
slot.held = slot.held and bit.inv()
// A chord button lifted before the hold elapsed → cancel, unless another pad still
// holds the full chord.
if (bit and EXIT_CHORD != 0 && slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) {
disarmExit()
}
}
}
return false
}
/** Arm the exit-chord hold timer (once); on expiry, if the chord is still held, flush + leave. */
private fun armExit() {
if (pendingExit != null) return // already counting down
val r = Runnable {
pendingExit = null
// Fire only if the chord survived the full hold on some pad.
val held = slots.values.filter { it.held and EXIT_CHORD == EXIT_CHORD }
if (held.isNotEmpty()) {
// Release the held buttons + zero the axes on every triggering pad so nothing sticks
// host-side once we leave, then signal the deliberate exit.
for (s in held) releaseHeld(s)
onExitChord?.invoke()
}
}
pendingExit = r
mainHandler.postDelayed(r, EXIT_HOLD_MS)
}
/** Cancel a pending exit-chord hold timer. */
private fun disarmExit() {
pendingExit?.let { mainHandler.removeCallbacks(it) }
pendingExit = null
}
/**
@@ -124,6 +168,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
*/
fun release() {
inputManager?.unregisterInputDeviceListener(listener)
disarmExit() // drop any pending exit-chord timer so it can't fire after teardown
// Snapshot the ids first — closeSlot mutates the map.
for (id in slots.keys.toList()) closeSlot(id)
}
@@ -173,6 +218,10 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
val slot = slots.remove(deviceId) ?: return
releaseHeld(slot)
NativeBridge.nativeSendGamepadRemove(handle, slot.index)
// If this pad was mid-exit-chord, its removal may have left no pad holding it — drop the timer.
if (slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) disarmExit()
// Release this controller's feedback bindings (close its lights session / cancel rumble).
onSlotClosed?.invoke(deviceId)
}
/** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */
@@ -200,5 +249,8 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together (matches the legacy single-pad chord). */
const val EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
/** How long the exit chord must be held before the stream leaves — matches SDL/Apple `DISCONNECT_HOLD`. */
const val EXIT_HOLD_MS = 1500L
}
}
@@ -257,7 +257,12 @@ public final class GamepadCapture {
/// tagged with the slot's wire pad index.
private func sync(_ slot: Slot, _ g: GCExtendedGamepad) {
guard !suspended else { return }
let newButtons = Self.buttonMask(g)
// guide is driven separately (`sendGuide`, off the Home handler) and deliberately kept out
// of `buttonMask`. Preserve its current held state here so the XOR diff below never sees it
// as "changed" otherwise the first stick/button move after a guide press would emit a
// spurious guide-UP while the button is still physically held (and drop the bit from
// `slot.buttons`, swallowing the real release too). `flush`/`allButtons` still release it.
let newButtons = Self.buttonMask(g) | (slot.buttons & GamepadWire.guide)
let changed = newButtons ^ slot.buttons
if changed != 0 {
for bit in GamepadWire.allButtons where changed & bit != 0 {
@@ -266,12 +271,12 @@ public final class GamepadCapture {
slot.buttons = newButtons
}
let newAxes: [Int32] = [
Int32((g.leftThumbstick.xAxis.value * 32767).rounded()),
Int32((g.leftThumbstick.yAxis.value * 32767).rounded()),
Int32((g.rightThumbstick.xAxis.value * 32767).rounded()),
Int32((g.rightThumbstick.yAxis.value * 32767).rounded()),
Int32((g.leftTrigger.value * 255).rounded()),
Int32((g.rightTrigger.value * 255).rounded()),
Int32(g.leftThumbstick.xAxis.value * 32767),
Int32(g.leftThumbstick.yAxis.value * 32767),
Int32(g.rightThumbstick.xAxis.value * 32767),
Int32(g.rightThumbstick.yAxis.value * 32767),
Int32(g.leftTrigger.value * 255),
Int32(g.rightTrigger.value * 255),
]
for (i, v) in newAxes.enumerated() where v != slot.axes[i] {
connection.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
@@ -300,11 +305,15 @@ public final class GamepadCapture {
if g.dpad.right.isPressed { b |= GamepadWire.dpadRight }
if g.buttonMenu.isPressed { b |= GamepadWire.start }
if g.buttonOptions?.isPressed == true { b |= GamepadWire.back }
// The share/create/capture element (Xbox Series share, a clone pad's screenshot button
// e.g. the GameSir G8's, below its d-pad) folds into back/select too. On pads that expose
// the create button BOTH as buttonOptions and as the share element this OR is harmless
// same wire bit.
if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.back }
// The dedicated share/create/capture element (Xbox-Series Share, DualSense Create, a clone
// pad's screenshot button e.g. the GameSir G8's, below its d-pad) the wire's capture
// bit, matching the Rust client's `Button::Misc1 => wire::BTN_MISC1`. On an Xbox-Series pad
// this is a button physically DISTINCT from View (buttonOptions, above), so it must not
// collapse onto back the host reads MISC1 as its own control (DualSense mute / Steam
// quick-access). Caveat: a pad that surfaces ONE physical button as both buttonOptions and
// this share element now emits back+misc1 for it harmless on a plain xpad session (no
// misc button) and rare otherwise. NOTE: on-glass verify on a real Xbox-Series pad.
if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.misc1 }
if g.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick }
if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick }
if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder }
@@ -140,7 +140,9 @@ public final class GamepadMenuInput {
let stick = gamepad.leftThumbstick
let x = stick.xAxis.value
let y = stick.yAxis.value
if abs(x) > abs(y), abs(x) > deadzone {
// Horizontal wins an exact |x| == |y| diagonal tie (>=), matching the SDL core and Android
// nav so a perfect 45° push resolves to the same direction on every client.
if abs(x) >= abs(y), abs(x) > deadzone {
return x > 0 ? .right : .left
} else if abs(y) > deadzone {
return y > 0 ? .up : .down
@@ -26,11 +26,27 @@ public enum GamepadWire {
public static let y: UInt32 = 0x8000
/// DualSense touchpad click (Moonlight's extended-button bit position).
public static let touchpadClick: UInt32 = 0x10_0000
/// Misc / capture button Xbox-Series Share, DualSense Create, Steam-Deck quick-access
/// (Moonlight's extended-button namespace; `input::gamepad::BTN_MISC1`). The host routes it to
/// the DualSense mute / Steam quick-access menu; a plain virtual xpad has no such button.
public static let misc1: UInt32 = 0x0020_0000
/// Back-grip paddles (Xbox Elite P1P4 / DualSense Edge / Steam-Deck L4-L5-R4-R5), in
/// Moonlight's extended-button namespace (`input::gamepad::BTN_PADDLE1..4`, R4/L4/R5/L5).
/// Defined for wire completeness and pinned by the tests; `GamepadCapture.buttonMask` does not
/// read them yet the GameController `paddleButton1..4` BTN_PADDLE physical correspondence
/// needs confirming on a real Elite pad first (see the gamepad-review-cleanup plan, G22), so
/// they are intentionally absent from `allButtons` until that forwarding lands.
public static let paddle1: UInt32 = 0x0001_0000
public static let paddle2: UInt32 = 0x0002_0000
public static let paddle3: UInt32 = 0x0004_0000
public static let paddle4: UInt32 = 0x0008_0000
/// Every button `buttonMask`/`sendGuide` can set walked by `sync`'s transition diff and by
/// `flush` on release. Paddles are excluded until their capture lands (see above).
public static let allButtons: [UInt32] = [
dpadUp, dpadDown, dpadLeft, dpadRight, start, back,
leftStickClick, rightStickClick, leftShoulder, rightShoulder, guide,
a, b, x, y, touchpadClick,
a, b, x, y, touchpadClick, misc1,
]
public static let axisLSX: UInt32 = 0
@@ -27,11 +27,16 @@ final class GamepadWireTests: XCTestCase {
XCTAssertEqual(GamepadWire.x, 0x4000)
XCTAssertEqual(GamepadWire.y, 0x8000)
XCTAssertEqual(GamepadWire.touchpadClick, 0x10_0000)
XCTAssertEqual(GamepadWire.misc1, 0x0020_0000)
// Every button is enumerated exactly once (releaseAll walks this list).
let combined: UInt32 = GamepadWire.allButtons.reduce(0) { $0 | $1 }
XCTAssertEqual(combined, 0x0010_F7FF)
XCTAssertEqual(GamepadWire.allButtons.count, 16)
XCTAssertEqual(combined, 0x0030_F7FF)
XCTAssertEqual(GamepadWire.allButtons.count, 17)
XCTAssertEqual(GamepadWire.allButtons.count, Set(GamepadWire.allButtons).count)
// Paddles are defined but not yet forwarded, so they stay out of allButtons for now.
for paddle in [GamepadWire.paddle1, GamepadWire.paddle2, GamepadWire.paddle3, GamepadWire.paddle4] {
XCTAssertFalse(GamepadWire.allButtons.contains(paddle))
}
// Axis ids.
XCTAssertEqual(GamepadWire.axisLSX, 0)
XCTAssertEqual(GamepadWire.axisLSY, 1)
@@ -41,6 +46,42 @@ final class GamepadWireTests: XCTestCase {
XCTAssertEqual(GamepadWire.axisRT, 5)
}
func testButtonBitsMatchTheCABIVerbatim() {
// Assert EVERY wire constant against the generated C ABI header (punktfunk_core.h, the same
// source `punktfunk_core::input::gamepad` emits), so a Swift-side edit that drifts from the
// Rust contract fails CI not just the handful spot-checked above. (Cross-cutting review
// finding G15: the button values were re-declared per client with only a 3-of-19 check.)
XCTAssertEqual(GamepadWire.dpadUp, UInt32(PUNKTFUNK_BTN_DPAD_UP))
XCTAssertEqual(GamepadWire.dpadDown, UInt32(PUNKTFUNK_BTN_DPAD_DOWN))
XCTAssertEqual(GamepadWire.dpadLeft, UInt32(PUNKTFUNK_BTN_DPAD_LEFT))
XCTAssertEqual(GamepadWire.dpadRight, UInt32(PUNKTFUNK_BTN_DPAD_RIGHT))
XCTAssertEqual(GamepadWire.start, UInt32(PUNKTFUNK_BTN_START))
XCTAssertEqual(GamepadWire.back, UInt32(PUNKTFUNK_BTN_BACK))
XCTAssertEqual(GamepadWire.leftStickClick, UInt32(PUNKTFUNK_BTN_LS_CLICK))
XCTAssertEqual(GamepadWire.rightStickClick, UInt32(PUNKTFUNK_BTN_RS_CLICK))
XCTAssertEqual(GamepadWire.leftShoulder, UInt32(PUNKTFUNK_BTN_LB))
XCTAssertEqual(GamepadWire.rightShoulder, UInt32(PUNKTFUNK_BTN_RB))
XCTAssertEqual(GamepadWire.guide, UInt32(PUNKTFUNK_BTN_GUIDE))
XCTAssertEqual(GamepadWire.a, UInt32(PUNKTFUNK_BTN_A))
XCTAssertEqual(GamepadWire.b, UInt32(PUNKTFUNK_BTN_B))
XCTAssertEqual(GamepadWire.x, UInt32(PUNKTFUNK_BTN_X))
XCTAssertEqual(GamepadWire.y, UInt32(PUNKTFUNK_BTN_Y))
XCTAssertEqual(GamepadWire.touchpadClick, UInt32(PUNKTFUNK_BTN_TOUCHPAD))
XCTAssertEqual(GamepadWire.misc1, UInt32(PUNKTFUNK_GAMEPAD_BTN_MISC1))
XCTAssertEqual(GamepadWire.paddle1, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE1))
XCTAssertEqual(GamepadWire.paddle2, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE2))
XCTAssertEqual(GamepadWire.paddle3, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE3))
XCTAssertEqual(GamepadWire.paddle4, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE4))
// Axis ids and pad count share the same header.
XCTAssertEqual(GamepadWire.axisLSX, UInt32(PUNKTFUNK_AXIS_LS_X))
XCTAssertEqual(GamepadWire.axisLSY, UInt32(PUNKTFUNK_AXIS_LS_Y))
XCTAssertEqual(GamepadWire.axisRSX, UInt32(PUNKTFUNK_AXIS_RS_X))
XCTAssertEqual(GamepadWire.axisRSY, UInt32(PUNKTFUNK_AXIS_RS_Y))
XCTAssertEqual(GamepadWire.axisLT, UInt32(PUNKTFUNK_AXIS_LT))
XCTAssertEqual(GamepadWire.axisRT, UInt32(PUNKTFUNK_AXIS_RT))
XCTAssertEqual(GamepadWire.maxPads, Int(MAX_PADS))
}
func testPadIndexRidesFlagsOnEveryPerPadEvent() {
// The wire pad index is the low byte of `flags` (punktfunk_core::input) on button + axis.
let btn = PunktfunkInputEvent.gamepadButton(GamepadWire.a, down: true, pad: 3)
+20
View File
@@ -30,6 +30,10 @@ const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"]
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
const CODEC_LABELS: &[&str] = &["Automatic", "HEVC (H.265)", "H.264 (AVC)", "AV1"];
const DECODERS: &[&str] = &["auto", "vaapi", "software"];
/// Touch-input model values (persisted) paired with their display labels below — the
/// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet).
const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"];
const TOUCH_MODE_LABELS: &[&str] = &["Trackpad", "Direct pointer", "Touch passthrough"];
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
const APP_LICENSE: &str = concat!(
@@ -420,12 +424,21 @@ pub fn show(
"Steam Deck",
],
);
let touch_row = ChoiceRow::new(
&dialog,
inline,
"Touch input",
"How the touchscreen drives the host — Trackpad nudges a cursor (tap to click); \
Direct pointer jumps to your finger; Touch passthrough sends real touches",
TOUCH_MODE_LABELS,
);
let inhibit_row = adw::SwitchRow::builder()
.title("Capture system shortcuts")
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
.build();
input.add(forward_row.widget());
input.add(pad_row.widget());
input.add(touch_row.widget());
input.add(&inhibit_row);
let audio = adw::PreferencesGroup::builder().title("Audio").build();
@@ -488,6 +501,11 @@ pub fn show(
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0);
pad_row.set_selected(pad_i as u32);
let touch_i = TOUCH_MODES
.iter()
.position(|&t| t == s.touch_mode)
.unwrap_or(0);
touch_row.set_selected(touch_i as u32);
let comp_i = COMPOSITORS
.iter()
.position(|&c| c == s.compositor)
@@ -527,6 +545,8 @@ pub fn show(
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
s.touch_mode =
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
s.forward_pad = chosen_pin.borrow().clone();
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
.to_string();
+1
View File
@@ -140,6 +140,7 @@ pub fn run(target: Option<&str>) -> u8 {
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
v => v,
},
touch_mode: settings_at_start.touch_mode(),
json_status,
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
trust::touch_last_used(&trust::hex(&fingerprint));
+1
View File
@@ -358,6 +358,7 @@ mod session_main {
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
v => v,
},
touch_mode: settings.touch_mode(),
json_status: true,
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
// This host's card carries the accent bar in the desktop client now.
+4 -4
View File
@@ -62,10 +62,10 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63
# decode + present live in the spawned punktfunk-session binary.)
ffmpeg-next = "8"
# Gamepads: capture + feedback (full DualSense fidelity needs hidapi). SDL3 is cross-platform;
# built from source via the bundled CMake on Windows (no system SDL3).
sdl3 = { version = "0.18", features = ["build-from-source", "hidapi"] }
# Gamepad enumeration + pin persistence for Settings runs on pf-client-core's shared SDL service
# (see the `gamepad` field in app/); the spawned punktfunk-session does the actual forwarding. SDL3
# itself (built from source via the bundled CMake on Windows) is pulled transitively by
# pf-client-core with the same `build-from-source,hidapi` features, so it is not a direct dep here.
mdns-sd = "0.20"
async-channel = "2"
serde = { version = "1", features = ["derive"] }
+1 -1
View File
@@ -32,9 +32,9 @@ mod stream;
mod style;
use crate::discovery::{self, DiscoveredHost};
use crate::gamepad::GamepadService;
use crate::trust::{KnownHosts, Settings};
use hosts::HostsProps;
use pf_client_core::gamepad::GamepadService;
use punktfunk_core::client::NativeClient;
use speed::{SpeedProps, SpeedState};
use std::collections::HashMap;
+16
View File
@@ -55,6 +55,13 @@ const STATS_TIERS: &[(StatsVerbosity, &str)] = &[
(StatsVerbosity::Normal, "Normal"),
(StatsVerbosity::Detailed, "Detailed"),
];
/// Touch-input presets: `(stored value, display label)` — how a touchscreen's fingers drive
/// the host. The cross-client set (Android/Apple); only meaningful on a touchscreen device.
const TOUCH_MODES: &[(&str, &str)] = &[
("trackpad", "Trackpad"),
("pointer", "Direct pointer"),
("touch", "Touch passthrough"),
];
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
const COMPOSITORS: &[(&str, &str)] = &[
@@ -324,6 +331,14 @@ pub(crate) fn settings_page(
"The virtual pad the host creates. \u{201C}Automatic\u{201D} matches your physical \
controller.",
);
let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode);
let touch_combo = setting_combo(ctx, "Touch input", touch_names, touch_i, |s, i| {
s.touch_mode = TOUCH_MODES[i].0.to_string();
})
.tooltip(
"How a touchscreen drives the host: Trackpad nudges a cursor (tap to click), Direct \
pointer jumps to your finger, Touch passthrough sends real touches.",
);
let shortcuts_toggle = setting_toggle(
ctx,
"Capture system shortcuts (Alt+Tab, Win, \u{2026})",
@@ -405,6 +420,7 @@ pub(crate) fn settings_page(
settings_card(vec![
forward_combo.into(),
pad_combo.into(),
touch_combo.into(),
shortcuts_toggle.into(),
]),
),
-629
View File
@@ -1,629 +0,0 @@
//! App-lifetime gamepad service over SDL3 (mirrors the Swift/GTK clients' `GamepadManager` +
//! capture/feedback). Ported near-verbatim from the GTK Linux client — SDL3 is cross-platform,
//! so the only Windows change is the build (`sdl3` is compiled from source via the bundled
//! CMake, since there is no system SDL3).
//!
//! One worker thread owns SDL for the process lifetime: it tracks connected pads, selects the
//! ONE controller forwarded as pad 0 (user pin, else the most recently connected), and — while
//! a session is attached — forwards buttons/axes, DualSense touchpad contacts and motion
//! samples (0xCC), and renders feedback: rumble on every pad, lightbar via SDL, and on a real
//! DualSense the raw effects packet (adaptive-trigger blocks replayed verbatim, player LEDs).
//! Held state is zeroed on the wire when the active pad switches or the session detaches, so
//! nothing sticks down.
//!
//! This thread is also the single consumer of the rumble and HID-output pull planes.
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::GamepadPref;
use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind};
use punktfunk_core::quic::{HidOutput, RichInput};
use std::collections::HashMap;
use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// Motion scale constants, shared convention with the other clients (`GamepadWire`): derived
/// from hid-playstation's math over the host's fixed calibration blob. SDL hands us gyro in
/// rad/s and accel in m/s²; the DualSense report wants raw LSBs.
const GYRO_LSB_PER_RAD_S: f32 = 20.0 * 180.0 / std::f32::consts::PI;
const ACCEL_LSB_PER_G: f32 = 10_000.0;
const G: f32 = 9.80665;
#[derive(Clone, Debug)]
pub struct PadInfo {
/// Stable identity (`vid:pid:name`, the same format as `pf-client-core`'s `PadInfo::key`)
/// — persisted as `Settings::forward_pad` so the pin survives restarts AND reaches the
/// spawned session binary, whose own gamepad service applies the same key.
pub key: String,
pub name: String,
/// The virtual pad "Automatic" resolves to for this physical controller (DualSense → DualSense,
/// DS4 → DualShock 4, Xbox One/Series → Xbox One, else → Xbox 360).
pub pref: GamepadPref,
}
impl PadInfo {
/// True for a real DualSense — the only pad whose lightbar / player-LED / adaptive-trigger
/// feedback we replay as raw DS5 HID effect packets (a DS4 uses SDL's generic `set_led`).
fn is_dualsense(&self) -> bool {
self.pref == GamepadPref::DualSense
}
/// A short human label for the detected pad family, shown next to the name in the settings
/// GUI's controller list ("" for a generic pad the name already describes).
pub fn kind_label(&self) -> &'static str {
match self.pref {
GamepadPref::DualSense => "DualSense",
GamepadPref::DualShock4 => "DualShock 4",
GamepadPref::XboxOne => "Xbox One",
GamepadPref::SteamDeck => "Steam Deck",
GamepadPref::SteamController => "Steam Controller",
_ => "",
}
}
}
/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create.
fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
use sdl3::gamepad::GamepadType as T;
match t {
T::PS5 => GamepadPref::DualSense,
T::PS4 => GamepadPref::DualShock4,
T::XboxOne => GamepadPref::XboxOne,
_ => GamepadPref::Xbox360,
}
}
enum Ctl {
Pin(Option<String>),
}
#[derive(Clone)]
pub struct GamepadService {
pads: Arc<Mutex<Vec<PadInfo>>>,
// `Arc<Mutex<…>>` (not a bare `Sender`, which is `!Sync`) so the service is `Sync` — the
// WinUI app shares it across the UI thread and the settings-pin path.
ctl: Arc<Mutex<Sender<Ctl>>>,
}
impl GamepadService {
pub fn start() -> GamepadService {
let pads = Arc::new(Mutex::new(Vec::new()));
let (ctl, ctl_rx) = std::sync::mpsc::channel();
let p = pads.clone();
if let Err(e) = std::thread::Builder::new()
.name("punktfunk-gamepad".into())
.spawn(move || {
if let Err(e) = run(&p, &ctl_rx) {
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
}
})
{
tracing::warn!(error = %e, "gamepad service failed to start");
}
GamepadService {
pads,
ctl: Arc::new(Mutex::new(ctl)),
}
}
/// Connected controllers, most recently attached first (the settings GUI's list order).
pub fn pads(&self) -> Vec<PadInfo> {
self.pads.lock().unwrap().clone()
}
/// Pin the forwarded controller by stable key (`PadInfo::key`) — `None` = automatic.
/// The pin survives the pad disconnecting: it re-applies the moment a matching
/// controller shows up again (same semantics as `pf-client-core`'s service). The spawned
/// `punktfunk-session` binary owns the actual forwarding; this persists the selection.
pub fn set_pinned(&self, key: Option<String>) {
let _ = self.ctl.lock().unwrap().send(Ctl::Pin(key));
}
}
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32) {
let _ = connector.send_input(&InputEvent {
kind,
_pad: [0; 3],
code,
x,
y: 0,
flags: 0, // pad index 0 — single-pad model
});
}
fn button_bit(b: sdl3::gamepad::Button) -> Option<u32> {
use sdl3::gamepad::Button;
Some(match b {
Button::South => wire::BTN_A,
Button::East => wire::BTN_B,
Button::West => wire::BTN_X,
Button::North => wire::BTN_Y,
Button::Back => wire::BTN_BACK,
Button::Start => wire::BTN_START,
Button::Guide => wire::BTN_GUIDE,
Button::LeftStick => wire::BTN_LS_CLICK,
Button::RightStick => wire::BTN_RS_CLICK,
Button::LeftShoulder => wire::BTN_LB,
Button::RightShoulder => wire::BTN_RB,
Button::DPadUp => wire::BTN_DPAD_UP,
Button::DPadDown => wire::BTN_DPAD_DOWN,
Button::DPadLeft => wire::BTN_DPAD_LEFT,
Button::DPadRight => wire::BTN_DPAD_RIGHT,
Button::Touchpad => wire::BTN_TOUCHPAD,
// Back grips / paddles (Steam Deck L4/L5/R4/R5, Xbox Elite P1P4) + the misc/Share button.
// PADDLE1/2/3/4 = R4/L4/R5/L5 (see the host `input::gamepad`).
Button::RightPaddle1 => wire::BTN_PADDLE1,
Button::LeftPaddle1 => wire::BTN_PADDLE2,
Button::RightPaddle2 => wire::BTN_PADDLE3,
Button::LeftPaddle2 => wire::BTN_PADDLE4,
Button::Misc1 => wire::BTN_MISC1,
_ => return None,
})
}
/// SDL axis → (wire axis id, wire value). SDL sticks are +y = down; the wire (XInput
/// convention) is +y = up. SDL triggers span 0..32767; the wire wants 0..255.
fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
use sdl3::gamepad::Axis;
match axis {
Axis::LeftX => (wire::AXIS_LS_X, v as i32),
Axis::LeftY => (wire::AXIS_LS_Y, -(v as i32).max(-32767)),
Axis::RightX => (wire::AXIS_RS_X, v as i32),
Axis::RightY => (wire::AXIS_RS_Y, -(v as i32).max(-32767)),
Axis::TriggerLeft => (wire::AXIS_LT, (v as i32).clamp(0, 32767) >> 7),
Axis::TriggerRight => (wire::AXIS_RT, (v as i32).clamp(0, 32767) >> 7),
}
}
/// The DualSense effects packet (SDL `DS5EffectsState_t`, 47 bytes) — the same layout the host
/// parses off its virtual pad; the wire's 11-byte trigger blocks drop in verbatim. Enable bits
/// select only the fields each update touches, so rumble (driven separately through SDL) and
/// untouched fields keep their state.
#[derive(Default)]
struct Ds5Feedback;
impl Ds5Feedback {
const RIGHT_TRIGGER: usize = 10;
const LEFT_TRIGGER: usize = 21;
const PAD_LIGHTS: usize = 43;
const LED_RGB: usize = 44;
fn trigger_packet(which: u8, effect: &[u8]) -> [u8; 47] {
let mut p = [0u8; 47];
let (flag, off) = if which == 1 {
(0x04, Self::RIGHT_TRIGGER)
} else {
(0x08, Self::LEFT_TRIGGER)
};
p[0] = flag;
let n = effect.len().min(11);
p[off..off + n].copy_from_slice(&effect[..n]);
p
}
fn lightbar_packet(r: u8, g: u8, b: u8) -> [u8; 47] {
let mut p = [0u8; 47];
p[1] = 0x04; // lightbar enable
p[Self::LED_RGB] = r;
p[Self::LED_RGB + 1] = g;
p[Self::LED_RGB + 2] = b;
p
}
fn player_packet(bits: u8) -> [u8; 47] {
let mut p = [0u8; 47];
p[1] = 0x10; // player-LED enable
p[Self::PAD_LIGHTS] = bits & 0x1F;
p
}
}
struct Worker {
subsystem: sdl3::GamepadSubsystem,
opened: HashMap<u32, sdl3::gamepad::Gamepad>,
/// Connection order; the most recently connected is the auto selection.
order: Vec<u32>,
/// The user pin by stable key (`PadInfo::key`); resolved to an instance id per lookup
/// so it re-applies whenever a matching pad (re)connects.
pinned: Option<String>,
attached: Option<Arc<NativeClient>>,
/// Wire state of the active pad — zeroed on the wire at switch/detach.
last_axis: [i32; 6],
held_buttons: Vec<u32>,
/// Touchpad contacts the host believes are down, keyed by `(surface, finger)` — lifted on pad
/// switch / detach. surface 0 = the legacy single touchpad, 1/2 = a Steam left/right pad.
held_touches: std::collections::HashSet<(u8, u8)>,
last_accel: [i16; 3],
}
impl Worker {
fn active_id(&self) -> Option<u32> {
self.pinned
.as_deref()
.and_then(|key| {
self.order
.iter()
.rev() // prefer the most recently connected pad with this identity
.find(|&&id| self.pad_info(id).is_some_and(|p| p.key == key))
.copied()
})
.or_else(|| self.order.last().copied())
}
fn pad_info(&self, id: u32) -> Option<PadInfo> {
let pad = self.opened.get(&id)?;
let mut pref = pref_for_type(
self.subsystem
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
);
let (vid, pid) = (pad.vendor_id().unwrap_or(0), pad.product_id().unwrap_or(0));
// No SDL type for the Steam Deck / Steam Controller — detect Valve by VID/PID (Deck 0x1205,
// SC wired 0x1102, SC dongle 0x1142) so the host builds the virtual hid-steam pad.
if vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) {
pref = GamepadPref::SteamDeck;
}
let name = pad.name().unwrap_or_else(|| "Controller".into());
Some(PadInfo {
// Must match pf-client-core's `PadInfo::key` byte-for-byte — the persisted
// `forward_pad` is applied by BOTH services (this one and the session's).
key: format!("{vid:04x}:{pid:04x}:{name}"),
name,
pref,
})
}
/// Zero everything the host believes is held — on pad switch and detach.
fn flush_held(&mut self) {
if let Some(c) = &self.attached {
for b in self.held_buttons.drain(..) {
send(c, InputKind::GamepadButton, b, 0);
}
for (id, v) in self.last_axis.iter_mut().enumerate() {
if *v != 0 && *v != i32::MIN {
send(c, InputKind::GamepadAxis, id as u32, 0);
}
*v = i32::MIN;
}
for (surface, finger) in self.held_touches.drain() {
let rich = if surface == 0 {
RichInput::Touchpad {
pad: 0,
finger,
active: false,
x: 0,
y: 0,
}
} else {
RichInput::TouchpadEx {
pad: 0,
surface,
finger,
touch: false,
click: false,
x: 0,
y: 0,
pressure: 0,
}
};
let _ = c.send_rich_input(rich);
}
} else {
self.held_buttons.clear();
self.last_axis = [i32::MIN; 6];
self.held_touches.clear();
}
}
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
fn set_sensors(&mut self, enabled: bool) {
let Some(id) = self.active_id() else { return };
if let Some(pad) = self.opened.get_mut(&id) {
use sdl3::sensor::SensorType;
for s in [SensorType::Gyroscope, SensorType::Accelerometer] {
if unsafe { pad.has_sensor(s) } {
let _ = pad.sensor_set_enabled(s, enabled);
}
}
}
}
/// Forward one touchpad contact on the rich-input plane. A multi-touchpad pad (Steam Deck / Steam
/// Controller) sends `TouchpadEx` with the surface (SDL touchpad 0 = left → 1, 1 = right → 2) and
/// signed coordinates; a single-touchpad pad (DualSense) keeps the legacy `Touchpad` (unsigned).
fn forward_touch(
&mut self,
which: u32,
touchpad: u32,
finger: u8,
x: f32,
y: f32,
active: bool,
) {
let Some(c) = self.attached.as_ref() else {
return;
};
let multi = self
.opened
.get(&which)
.map(|p| p.touchpads_count() >= 2)
.unwrap_or(false);
let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
let surface = if multi { (touchpad as u8) + 1 } else { 0 };
let rich = if multi {
RichInput::TouchpadEx {
pad: 0,
surface,
finger,
touch: active,
click: false,
x: (cx * 65535.0 - 32768.0) as i16,
y: (cy * 65535.0 - 32768.0) as i16,
pressure: 0,
}
} else {
RichInput::Touchpad {
pad: 0,
finger,
active,
x: (cx * 65535.0) as u16,
y: (cy * 65535.0) as u16,
}
};
let _ = c.send_rich_input(rich);
if active {
self.held_touches.insert((surface, finger));
} else {
self.held_touches.remove(&(surface, finger));
}
}
}
#[allow(clippy::too_many_lines)]
fn run(pads_out: &Mutex<Vec<PadInfo>>, ctl: &Receiver<Ctl>) -> Result<(), String> {
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its own
// thread.
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
// Let SDL's HIDAPI drivers open Valve Steam Controller / Steam Deck devices directly, so the
// paddles, both trackpads, and gyro arrive as first-class SDL gamepad inputs.
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1");
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", "1");
let sdl = sdl3::init().map_err(|e| e.to_string())?;
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
let mut w = Worker {
subsystem,
opened: HashMap::new(),
order: Vec::new(),
pinned: None,
attached: None,
last_axis: [i32::MIN; 6],
held_buttons: Vec::new(),
held_touches: std::collections::HashSet::new(),
last_accel: [0; 3],
};
let publish = |w: &Worker| {
let mut list: Vec<PadInfo> = w.order.iter().filter_map(|&id| w.pad_info(id)).collect();
list.reverse(); // most recent first — the Settings list order
*pads_out.lock().unwrap() = list;
};
loop {
// Control plane from the UI thread.
loop {
match ctl.try_recv() {
Ok(Ctl::Pin(key)) => {
let before = w.active_id();
w.pinned = key;
if w.active_id() != before {
w.flush_held();
if w.attached.is_some() {
w.set_sensors(true);
}
}
publish(&w);
}
Err(std::sync::mpsc::TryRecvError::Empty) => break,
Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(()), // app gone
}
}
while let Some(event) = pump.poll_event() {
use sdl3::event::Event;
let active = w.active_id();
match event {
Event::ControllerDeviceAdded { which, .. } => {
if !w.opened.contains_key(&which) {
match w.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(which)) {
Ok(pad) => {
tracing::info!(
name = pad.name().unwrap_or_default(),
"gamepad attached"
);
w.opened.insert(which, pad);
w.order.push(which);
if w.attached.is_some() && w.active_id() == Some(which) {
w.set_sensors(true);
}
publish(&w);
}
Err(e) => tracing::warn!(error = %e, "gamepad open failed"),
}
}
}
Event::ControllerDeviceRemoved { which, .. } => {
if w.opened.remove(&which).is_some() {
w.order.retain(|&id| id != which);
if active == Some(which) {
w.flush_held();
}
tracing::info!("gamepad detached");
publish(&w);
}
}
Event::ControllerButtonDown { which, button, .. }
if active == Some(which) && w.attached.is_some() =>
{
if let Some(bit) = button_bit(button) {
w.held_buttons.push(bit);
send(
w.attached.as_ref().unwrap(),
InputKind::GamepadButton,
bit,
1,
);
}
}
Event::ControllerButtonUp { which, button, .. }
if active == Some(which) && w.attached.is_some() =>
{
if let Some(bit) = button_bit(button) {
w.held_buttons.retain(|&b| b != bit);
send(
w.attached.as_ref().unwrap(),
InputKind::GamepadButton,
bit,
0,
);
}
}
Event::ControllerAxisMotion {
which, axis, value, ..
} if active == Some(which) && w.attached.is_some() => {
let (id, v) = axis_value(axis, value);
if w.last_axis[id as usize] != v {
w.last_axis[id as usize] = v;
send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v);
}
}
// Touchpad contacts → the rich-input plane. One pad (DualSense) keeps the legacy
// `Touchpad`; two pads (Steam Deck / Steam Controller) send `TouchpadEx` per surface.
Event::ControllerTouchpadDown {
which,
touchpad,
finger,
x,
y,
..
}
| Event::ControllerTouchpadMotion {
which,
touchpad,
finger,
x,
y,
..
} if active == Some(which) && w.attached.is_some() => {
w.forward_touch(which, touchpad as u32, finger as u8, x, y, true);
}
Event::ControllerTouchpadUp {
which,
touchpad,
finger,
x,
y,
..
} if active == Some(which) && w.attached.is_some() => {
w.forward_touch(which, touchpad as u32, finger as u8, x, y, false);
}
// Motion: accel events update the cache; each gyro event ships a sample (the
// DualSense reports both at ~250 Hz). Scale convention shared with the other
// clients — sign/scale derived, not yet live-verified.
Event::ControllerSensorUpdated {
which,
sensor,
data,
..
} if active == Some(which) && w.attached.is_some() => {
use sdl3::sensor::SensorType;
match sensor {
SensorType::Accelerometer => {
for (i, v) in data.iter().enumerate() {
w.last_accel[i] =
(v / G * ACCEL_LSB_PER_G).clamp(-32768.0, 32767.0) as i16;
}
}
SensorType::Gyroscope => {
let mut gyro = [0i16; 3];
for (i, v) in data.iter().enumerate() {
gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16;
}
let _ =
w.attached
.as_ref()
.unwrap()
.send_rich_input(RichInput::Motion {
pad: 0,
gyro,
accel: w.last_accel,
});
}
_ => {}
}
}
_ => {}
}
}
// Feedback planes (this thread is their single consumer). Rumble arrives as
// self-terminating v2 envelopes: the host renews an active level and lets an abandoned one
// lapse, so the SDL duration is the host's TTL — a lost stop (or a dead host) self-silences
// at the lease instead of droning. A legacy host (`ttl == None`) sends no lease → keep the
// proven 5 s duration and rely on its periodic re-send as before.
if let Some(connector) = w.attached.clone() {
while let Ok((pad, low, high, ttl)) = connector.next_rumble_ttl(Duration::ZERO) {
if pad == 0 {
// Floor the lease so a jittered renewal can't gap the actuator between writes.
let dur_ms = ttl.map_or(5_000, |ms| (ms as u32).max(240));
if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) {
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in
// 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, dur_ms) {
tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed");
} else {
tracing::debug!(low, high, "rumble: rendered");
}
} else {
tracing::debug!(low, high, "rumble: received but no active pad to render");
}
}
}
while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
let Some(id) = w.active_id() else { continue };
let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense());
let Some(pad) = w.opened.get_mut(&id) else {
continue;
};
match hid {
HidOutput::Led { pad: 0, r, g, b } if is_ds => {
let _ = pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b));
}
HidOutput::Led { pad: 0, r, g, b } => {
let _ = pad.set_led(r, g, b);
}
HidOutput::PlayerLeds { pad: 0, bits } if is_ds => {
let _ = pad.send_effect(&Ds5Feedback::player_packet(bits));
}
HidOutput::Trigger {
pad: 0,
which,
ref effect,
} if is_ds => {
let _ = pad.send_effect(&Ds5Feedback::trigger_packet(which, effect));
}
_ => {}
}
}
}
std::thread::sleep(Duration::from_millis(if w.attached.is_some() {
2
} else {
30
}));
}
}
+5 -3
View File
@@ -24,8 +24,6 @@ mod app;
#[cfg(windows)]
mod discovery;
#[cfg(windows)]
mod gamepad;
#[cfg(windows)]
mod gpu;
#[cfg(windows)]
mod probe;
@@ -85,7 +83,11 @@ fn main() {
tracing::error!(error = %e, "Windows App SDK bootstrap failed");
std::process::exit(1);
}
let gamepad = gamepad::GamepadService::start();
// The shared SDL gamepad service (pf-client-core). The shell only enumerates pads (Settings
// list) and persists the pin; the spawned punktfunk-session runs the SAME service and does the
// actual forwarding — so, unlike the old shell fork, we never `attach()` here. Idle it stays
// hands-off the hardware (id-getter metadata, no device open, Valve HIDAPI drivers off).
let gamepad = pf_client_core::gamepad::GamepadService::start();
if let Err(e) = app::run(identity, gamepad) {
tracing::error!(error = %e, "WinUI app failed");
std::process::exit(1);
+32 -2
View File
@@ -71,6 +71,15 @@ const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
/// left untouched.
const DECK_RUMBLE_KEEPALIVE_MS: u64 = 40;
/// Ceiling on a *legacy* (no-TTL) host's Steam Deck rumble: silence the actuator once a real host
/// update has been absent this long. A legacy host re-sends the held level as a flat 500 ms refresh,
/// so a genuinely-held rumble refreshes the per-slot update clock (`RumbleState::updated_at`) every
/// 500 ms and never approaches this — only a lost *stop* datagram (the host went quiet entirely)
/// lets the 40 ms keep-alive drone on. 2× the 500 ms refresh bounds that lost stop to ~1 s,
/// mirroring the Windows host's `RUMBLE_IDLE_TIMEOUT` residual cutoff. The v2 path is bounded by its
/// lease `deadline` instead and never trips this (see [`Worker::render_feedback`]).
const LEGACY_RUMBLE_CEILING_MS: u64 = 1_000;
/// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple
/// `GamepadMenuInput` parity; menus want deliberate flicks, not drift).
const MENU_DEADZONE: u16 = 16384;
@@ -558,9 +567,9 @@ fn button_bit(b: sdl3::gamepad::Button) -> Option<u32> {
fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
use sdl3::gamepad::Axis;
match axis {
Axis::LeftX => (wire::AXIS_LS_X, v as i32),
Axis::LeftX => (wire::AXIS_LS_X, (v as i32).max(-32767)),
Axis::LeftY => (wire::AXIS_LS_Y, -(v as i32).max(-32767)),
Axis::RightX => (wire::AXIS_RS_X, v as i32),
Axis::RightX => (wire::AXIS_RS_X, (v as i32).max(-32767)),
Axis::RightY => (wire::AXIS_RS_Y, -(v as i32).max(-32767)),
Axis::TriggerLeft => (wire::AXIS_LT, (v as i32).clamp(0, 32767) >> 7),
Axis::TriggerRight => (wire::AXIS_RT, (v as i32).clamp(0, 32767) >> 7),
@@ -617,6 +626,12 @@ struct RumbleState {
/// drives the Steam Deck haptic keep-alive in [`Worker::render_feedback`].
last: (u16, u16),
last_at: Option<Instant>,
/// When the last *real* host rumble datagram landed on this slot — set only in the feedback
/// drain, never bumped by the Deck keep-alive re-kick (unlike `last_at`, which the keep-alive
/// refreshes every ~40 ms). A legacy host carries no lease, so this per-slot clock is what
/// bounds a lost stop-frame: once it is stale past `LEGACY_RUMBLE_CEILING_MS` the keep-alive
/// stops and issues one (0, 0). See [`Worker::render_feedback`].
updated_at: Option<Instant>,
/// Toggles the 1-LSB low-motor nudge that forces SDL past its identical-value dedupe on a
/// Deck keep-alive re-issue (see [`Worker::issue_rumble`]).
jitter: bool,
@@ -1492,6 +1507,10 @@ impl Worker {
}
_ => None,
};
// Mark this as a real host update. Unlike `last_at` (which the Deck keep-alive
// re-kick refreshes every ~40 ms), this clock advances only here, so a legacy
// lost-stop can be bounded by `LEGACY_RUMBLE_CEILING_MS` in the keep-alive below.
slot.rumble.updated_at = Some(Instant::now());
Self::issue_rumble(slot, low, high, deck);
}
}
@@ -1511,6 +1530,17 @@ impl Worker {
slot.rumble.deadline = None;
slot.rumble.ttl_ms = 0;
Self::issue_rumble(slot, 0, 0, true);
} else if slot.rumble.ttl_ms == 0
&& slot
.rumble
.updated_at
.is_some_and(|t| t.elapsed() >= Duration::from_millis(LEGACY_RUMBLE_CEILING_MS))
{
// Legacy host (no v2 lease): a held rumble refreshes `updated_at` every ~500 ms, so
// this only trips on a lost stop-frame the host never followed up — silence the
// actuator once instead of letting the 40 ms keep-alive drone forever. `issue_rumble`
// sets `last` to (0, 0), so the top-of-loop guard skips this slot on later ticks.
Self::issue_rumble(slot, 0, 0, true);
} else if slot
.rumble
.last_at
+80
View File
@@ -343,6 +343,53 @@ impl StatsVerbosity {
}
}
/// How a touchscreen's fingers drive the host — the cross-client touch-input model (Android
/// `TouchMode`, Apple `TouchInputMode`). Stored stringly in [`Settings::touch_mode`] so the
/// file stays readable; parsed with [`TouchMode::from_name`].
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TouchMode {
/// Relative cursor like a laptop touchpad: the cursor stays put on touch-down and moves
/// by the finger's delta (with mild acceleration), tap to click. The default — a cursor
/// is the universally workable model on a screen the host isn't sized for.
Trackpad,
/// Direct pointing: the cursor jumps to the finger and follows it (absolute).
Pointer,
/// Real multi-touch passthrough: every finger is a host touchscreen contact, no gesture
/// interpretation — only helps hosts/apps that actually understand touch.
Touch,
}
impl TouchMode {
/// Cycle/picker order (also the settings pickers' option order).
pub const ALL: [TouchMode; 3] = [TouchMode::Trackpad, TouchMode::Pointer, TouchMode::Touch];
/// Parse the persisted name, defaulting to `Trackpad` for unset/unknown values.
pub fn from_name(s: &str) -> TouchMode {
match s {
"pointer" => TouchMode::Pointer,
"touch" => TouchMode::Touch,
_ => TouchMode::Trackpad,
}
}
/// The persisted name (the inverse of [`from_name`](Self::from_name)).
pub fn as_name(self) -> &'static str {
match self {
TouchMode::Trackpad => "trackpad",
TouchMode::Pointer => "pointer",
TouchMode::Touch => "touch",
}
}
pub fn label(self) -> &'static str {
match self {
TouchMode::Trackpad => "Trackpad",
TouchMode::Pointer => "Direct pointer",
TouchMode::Touch => "Touch passthrough",
}
}
}
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
/// stays readable; parsed with `*Pref::from_name` at connect time.
#[derive(Clone, Serialize, Deserialize)]
@@ -363,6 +410,12 @@ pub struct Settings {
/// Which host compositor backend to request (advisory; the host falls back to
/// auto-detect when unavailable).
pub compositor: String,
/// How a touchscreen's fingers drive the host (Deck/tablet): a [`TouchMode`] name —
/// `"trackpad"` (default), `"pointer"`, or `"touch"`. Read at connect via
/// [`Settings::touch_mode`]; irrelevant on a mouse-only client. `default` so pre-existing
/// stores load as trackpad.
#[serde(default = "default_touch_mode")]
pub touch_mode: String,
/// Grab compositor shortcuts (Alt+Tab, Super…) while input is captured.
pub inhibit_shortcuts: bool,
/// Stream the default microphone to the host's virtual mic source.
@@ -425,6 +478,10 @@ fn default_codec() -> String {
"auto".into()
}
fn default_touch_mode() -> String {
"trackpad".into()
}
fn default_true() -> bool {
true
}
@@ -447,6 +504,11 @@ impl Settings {
self.show_stats = v != StatsVerbosity::Off;
}
/// The touch-input model for this session (parsed from the stored name).
pub fn touch_mode(&self) -> TouchMode {
TouchMode::from_name(&self.touch_mode)
}
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
pub fn preferred_codec(&self) -> u8 {
match self.codec.as_str() {
@@ -468,6 +530,7 @@ impl Default for Settings {
gamepad: "auto".into(),
forward_pad: String::new(),
compositor: "auto".into(),
touch_mode: "trackpad".into(),
inhibit_shortcuts: true,
mic_enabled: false,
audio_channels: 2,
@@ -519,6 +582,23 @@ impl Settings {
mod tests {
use super::*;
/// A settings file predating the touch-input model loads as `trackpad` (the shipped
/// default), and the name round-trips through the enum both ways.
#[test]
fn settings_touch_mode_defaults_trackpad() {
let old = r#"{"width":1280,"height":720,"gamepad":"auto","compositor":"auto"}"#;
let s: Settings = serde_json::from_str(old).unwrap();
assert_eq!(s.touch_mode, "trackpad");
assert_eq!(s.touch_mode(), TouchMode::Trackpad);
// Explicit values parse; an unknown name falls back to trackpad.
assert_eq!(TouchMode::from_name("pointer"), TouchMode::Pointer);
assert_eq!(TouchMode::from_name("touch"), TouchMode::Touch);
assert_eq!(TouchMode::from_name("bogus"), TouchMode::Trackpad);
for m in TouchMode::ALL {
assert_eq!(TouchMode::from_name(m.as_name()), m);
}
}
/// A pre-`forward_pad` settings file (≤ 0.5.0) loads with the pin on automatic.
#[test]
fn settings_forward_pad_defaults_empty() {
+163 -2
View File
@@ -16,11 +16,21 @@
//! otherwise send a datagram per event).
use crate::keymap_sdl;
use crate::touch::{Abs, Act, Gestures};
use pf_client_core::trust::TouchMode;
use punktfunk_core::client::NativeClient;
use punktfunk_core::input::{InputEvent, InputKind};
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
/// Which transition a forwarded touchscreen finger is (SDL delivers one finger per event).
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum FingerPhase {
Down,
Move,
Up,
}
pub struct Capture {
connector: Arc<NativeClient>,
captured: bool,
@@ -34,6 +44,16 @@ pub struct Capture {
/// Fractional wheel remainder per axis (x, y) in 120-unit WHEEL_DELTA space —
/// precision surfaces deliver sub-unit deltas; truncating each event drops the tail.
scroll_acc: (f64, f64),
/// Active touchscreen contacts: SDL finger id → the small wire touch id (slot) we
/// forward it under. SDL finger ids are opaque and large; the host wants compact,
/// per-contact-unique ids reusable after up (input.rs::TouchDown). Slots are freed on
/// up and flushed up on release so no contact stays pressed on the host. Only used in
/// [`TouchMode::Touch`]; the other modes drive `gestures` instead.
touch_slots: HashMap<u64, u32>,
/// The touchscreen input model for this session, and — for trackpad/pointer — the
/// gesture state machine finger events feed.
touch_mode: TouchMode,
gestures: Gestures,
}
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
@@ -48,7 +68,7 @@ fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, fl
}
impl Capture {
pub fn new(connector: Arc<NativeClient>) -> Capture {
pub fn new(connector: Arc<NativeClient>, touch_mode: TouchMode) -> Capture {
Capture {
connector,
captured: false,
@@ -57,6 +77,9 @@ impl Capture {
held_buttons: HashSet::new(),
pending_rel: (0, 0),
scroll_acc: (0.0, 0.0),
touch_slots: HashMap::new(),
touch_mode,
gestures: Gestures::new(touch_mode == TouchMode::Trackpad),
}
}
@@ -93,6 +116,12 @@ impl Capture {
for b in self.held_buttons.drain() {
send(&self.connector, InputKind::MouseButtonUp, b, 0, 0, 0);
}
for slot in self.touch_slots.drain().map(|(_, slot)| slot) {
send(&self.connector, InputKind::TouchUp, slot, 0, 0, 0);
}
// The gesture engine's held left button (a tap-drag in progress) rides in
// `held_buttons` above, so it was just flushed — here we only forget its state.
self.gestures.reset();
true
}
@@ -180,4 +209,136 @@ impl Capture {
}
self.scroll_acc = (ax, ay);
}
/// The compact wire touch id for an SDL finger — its existing slot, or the lowest free
/// one (contacts are few, so a linear scan is nothing). Held until the finger lifts.
fn touch_slot(&mut self, finger_id: u64) -> u32 {
if let Some(&slot) = self.touch_slots.get(&finger_id) {
return slot;
}
let used: HashSet<u32> = self.touch_slots.values().copied().collect();
let slot = (0u32..).find(|s| !used.contains(s)).unwrap_or(0);
self.touch_slots.insert(finger_id, slot);
slot
}
/// Touch flags pack the client surface size the coordinates are relative to, so the
/// host can rescale into its output — identical layout to Android's nativeSendTouch.
fn touch_flags(w: u32, h: u32) -> u32 {
((w & 0xffff) << 16) | (h & 0xffff)
}
/// A new touchscreen contact — `x`/`y` are absolute in the `w`×`h` content surface.
/// Ignored unless captured (the stream owns the glass; the menu is gamepad-driven).
pub fn on_touch_down(&mut self, finger_id: u64, x: i32, y: i32, w: u32, h: u32) {
if !self.captured {
return;
}
let slot = self.touch_slot(finger_id);
send(
&self.connector,
InputKind::TouchDown,
slot,
x,
y,
Self::touch_flags(w, h),
);
}
/// A contact moved. Only forwarded for a finger we already sent a down for — a move
/// with no live slot (capture engaged mid-touch) would have no matching host contact.
pub fn on_touch_move(&mut self, finger_id: u64, x: i32, y: i32, w: u32, h: u32) {
if !self.captured {
return;
}
if let Some(&slot) = self.touch_slots.get(&finger_id) {
send(
&self.connector,
InputKind::TouchMove,
slot,
x,
y,
Self::touch_flags(w, h),
);
}
}
/// A contact lifted — release its slot and the host contact. Forwarded even when not
/// captured: a `release()` may have already flushed it (then the slot is gone and this
/// no-ops), but a stray up must never strand a pressed contact on the host.
pub fn on_touch_up(&mut self, finger_id: u64) {
if let Some(slot) = self.touch_slots.remove(&finger_id) {
send(&self.connector, InputKind::TouchUp, slot, 0, 0, 0);
}
}
/// Route one forwarded touchscreen finger by the session's touch model. `wx`/`wy` are
/// physical window pixels (the trackpad ballistics + gesture geometry); `abs` is the same
/// finger mapped into the letterboxed content rect (pointer moves + raw passthrough). In
/// `Touch` mode fingers go on the wire as real contacts; in `Trackpad`/`Pointer` they
/// drive the gesture engine. Returns true when a three-finger tap asks to cycle the stats
/// overlay — the only signal the run loop must act on.
pub fn dispatch_finger(
&mut self,
phase: FingerPhase,
id: u64,
wx: f32,
wy: f32,
abs: Abs,
t_ms: f64,
) -> bool {
match self.touch_mode {
TouchMode::Touch => {
match phase {
FingerPhase::Down => self.on_touch_down(id, abs.x, abs.y, abs.w, abs.h),
FingerPhase::Move => self.on_touch_move(id, abs.x, abs.y, abs.w, abs.h),
FingerPhase::Up => self.on_touch_up(id),
}
false
}
TouchMode::Trackpad | TouchMode::Pointer => {
// Down/Move only while captured (the stream owns the glass); an Up always runs
// so a lift can conclude a gesture / release a held drag even if capture just
// dropped (focus loss mid-touch).
if !self.captured && phase != FingerPhase::Up {
return false;
}
let acts = match phase {
FingerPhase::Down => self.gestures.down(id, wx, wy, abs, t_ms),
FingerPhase::Move => self.gestures.motion(id, wx, wy, abs, t_ms),
FingerPhase::Up => self.gestures.up(id, t_ms),
};
let mut cycle_stats = false;
for act in acts {
cycle_stats |= self.apply_touch_act(act);
}
cycle_stats
}
}
}
/// Send one gesture [`Act`] on the wire, tracking button holds in `held_buttons` so a
/// capture release flushes them (a tap-drag's left button never sticks down). Returns
/// true for [`Act::CycleStats`], which is a run-loop signal, not a wire event.
fn apply_touch_act(&mut self, act: Act) -> bool {
match act {
Act::CycleStats => return true,
Act::Button { gs, down } => {
if down {
self.flush_motion(); // the press lands where the cursor now is
self.held_buttons.insert(gs);
send(&self.connector, InputKind::MouseButtonDown, gs, 0, 0, 0);
} else if self.held_buttons.remove(&gs) {
self.flush_motion();
send(&self.connector, InputKind::MouseButtonUp, gs, 0, 0, 0);
}
}
other => {
if let Some((kind, code, x, y, flags)) = other.wire() {
send(&self.connector, kind, code, x, y, flags);
}
}
}
false
}
}
+2
View File
@@ -29,6 +29,8 @@ pub mod overlay;
#[cfg(any(target_os = "linux", windows))]
mod run;
#[cfg(any(target_os = "linux", windows))]
pub mod touch;
#[cfg(any(target_os = "linux", windows))]
pub mod vk;
#[cfg(windows)]
mod win32;
+242 -20
View File
@@ -13,13 +13,14 @@
//! the stdout line always carries the full Detailed text so parsers see a stable
//! shape). Logs go to stderr (the binary configures tracing so).
use crate::input::Capture;
use crate::input::{Capture, FingerPhase};
use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
use crate::touch::Abs;
use crate::vk::{FrameInput, Presenter};
use anyhow::{Context as _, Result};
use pf_client_core::gamepad::GamepadService;
use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats};
use pf_client_core::trust::StatsVerbosity;
use pf_client_core::trust::{StatsVerbosity, TouchMode};
use pf_client_core::video::VulkanDecodeDevice;
use pf_client_core::video::{DecodedFrame, DecodedImage};
use punktfunk_core::client::NativeClient;
@@ -43,6 +44,10 @@ pub struct SessionOpts {
/// Stats overlay tier at start — gates the OSD panel AND the stdout `stats:` lines
/// (Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed live).
pub stats_verbosity: StatsVerbosity,
/// Touchscreen input model (Deck/tablet): `Trackpad` (relative cursor + gestures),
/// `Pointer` (absolute cursor), or `Touch` (real multi-touch passthrough). Latched per
/// session — a mouse-only client leaves this at the default and never sees a finger.
pub touch_mode: TouchMode,
/// Emit the `{"ready":true}` stdout line after the first presented frame.
pub json_status: bool,
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
@@ -204,6 +209,11 @@ struct StreamState {
/// Resize-in-progress overlay (scrim + spinner) — armed by [`resize_tick`] when it
/// requests a switch, cleared when a decoded frame reaches the target (or on timeout).
resize_overlay: ResizeIndicator,
/// The last presented frame's video dimensions — the source rect touch passthrough
/// maps a finger into (the video is letterboxed within the window, so a finger's
/// window-normalized position must be re-based onto the content rect). `None` until
/// the first frame; touches before then have nothing to map onto and are dropped.
last_video: Option<(u32, u32)>,
}
impl StreamState {
@@ -253,6 +263,7 @@ impl StreamState {
resize_requested: None,
shown_mode: None,
resize_overlay: ResizeIndicator::default(),
last_video: None,
}
}
@@ -299,6 +310,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
#[cfg(windows)]
crate::win32::set_app_user_model_id();
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
// A touchscreen (the Deck's glass) is forwarded as REAL touch passthrough below — so
// suppress SDL's default synthesis of mouse events from touch. Left on, every touch
// ALSO warps a synthetic mouse to the touch point, which under the stream's relative
// mouse lock becomes a large positive delta that walks the host cursor into the
// bottom-right corner (the reported bug). The menu/library is keyboard+gamepad-driven
// and consumes no mouse, so nothing wanted these synthetic events anyway.
sdl3::hint::set("SDL_TOUCH_MOUSE_EVENTS", "0");
let sdl = sdl3::init().context("SDL init")?;
let video = sdl.video().context("SDL video")?;
let events = sdl.event().context("SDL events")?;
@@ -512,24 +530,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
continue;
}
if chord && sc == Scancode::S {
stats_verbosity = stats_verbosity.next();
bump_stats_tier(&mut stats_verbosity, &mut stream, &presenter);
tracing::info!(tier = ?stats_verbosity, "chord: stats verbosity");
// Re-render the OSD from the last window immediately — waiting
// for the next Stats event would lag the keypress by up to 1 s.
if let Some(st) = &mut stream {
let text = match &st.last_stats {
Some(s) => stats_text(
stats_verbosity,
&st.mode_line,
s,
&st.presented,
st.hdr,
presenter.hdr_active(),
),
None => String::new(),
};
st.osd_text = text;
}
continue;
}
// F11 or Alt+Enter (some keyboards' Fn layer sends a media key for
@@ -581,6 +583,78 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
cap.on_wheel(x, y);
}
}
// Touchscreen fingers (the Deck's glass) → the session's touch model
// (Trackpad/Pointer mouse, or real Touch passthrough), routed by `Capture`.
// `x`/`y` are window-normalized (0..1); the dispatcher gets physical window
// pixels AND the letterbox mapping. Only DIRECT devices (touchscreens) — an
// INDIRECT trackpad drives the mouse and must not be mistaken for one. A
// three-finger tap returns `cycle` → bump the stats tier, same as Ctrl+⌥+⇧+S.
Event::FingerDown {
touch_id,
finger_id,
x,
y,
timestamp,
..
} => {
if is_direct_touch(touch_id)
&& dispatch_finger(
FingerPhase::Down,
&window,
&mut stream,
finger_id,
x,
y,
timestamp,
)
{
bump_stats_tier(&mut stats_verbosity, &mut stream, &presenter);
}
}
Event::FingerMotion {
touch_id,
finger_id,
x,
y,
timestamp,
..
} => {
if is_direct_touch(touch_id)
&& dispatch_finger(
FingerPhase::Move,
&window,
&mut stream,
finger_id,
x,
y,
timestamp,
)
{
bump_stats_tier(&mut stats_verbosity, &mut stream, &presenter);
}
}
Event::FingerUp {
touch_id,
finger_id,
x,
y,
timestamp,
..
} => {
if is_direct_touch(touch_id)
&& dispatch_finger(
FingerPhase::Up,
&window,
&mut stream,
finger_id,
x,
y,
timestamp,
)
{
bump_stats_tier(&mut stats_verbosity, &mut stream, &presenter);
}
}
// The wake forwarder's FrameWake (and any other user event): pure
// wake-up — the frame drain below runs this iteration either way.
Event::User { .. } => {}
@@ -713,7 +787,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
.ok();
gamepad.attach(c.clone());
st.clock_offset = Some(c.clock_offset_shared());
let mut cap = Capture::new(c.clone());
let mut cap = Capture::new(c.clone(), opts.touch_mode);
cap.engage(); // capture engages when the stream starts (ui_stream parity)
apply_capture(&mut window, &mouse, true);
st.capture = Some(cap);
@@ -887,6 +961,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// picture is here — lift the scrim. A no-op unless a switch is in flight.
let (fw, fh) = f.image.dimensions();
st.resize_overlay.decoded(fw, fh);
st.last_video = Some((fw, fh)); // touch passthrough's source rect
let DecodedFrame {
pts_ns,
decoded_ns,
@@ -1291,6 +1366,111 @@ fn apply_capture(window: &mut sdl3::video::Window, mouse: &sdl3::mouse::MouseUti
window.set_keyboard_grab(on);
}
/// Is this SDL touch device a real touchscreen (DIRECT, window-relative coordinates)?
/// Trackpads report INDIRECT and drive the mouse — their finger events must not be
/// forwarded as touch passthrough. An unknown/invalid id (INVALID) reads as not-direct.
fn is_direct_touch(touch_id: u64) -> bool {
use sdl3::sys::touch::{SDL_GetTouchDeviceType, SDL_TouchDeviceType, SDL_TouchID};
unsafe { SDL_GetTouchDeviceType(SDL_TouchID(touch_id)) == SDL_TouchDeviceType::DIRECT }
}
/// Route one SDL touchscreen finger into the active session's [`Capture`] per the touch
/// model. SDL delivers window-normalized `x`/`y` (0..1) and a nanosecond `timestamp`; the
/// dispatcher hands `Capture` physical window pixels (trackpad ballistics + gesture geometry)
/// AND the finger mapped into the letterboxed content rect (pointer moves + raw passthrough).
/// Returns whether a three-finger tap asked to cycle the stats tier. Down/Move before the
/// first decoded frame have nothing to map onto and are dropped; an Up always dispatches so a
/// lift can release a held contact/drag.
fn dispatch_finger(
phase: FingerPhase,
window: &sdl3::video::Window,
stream: &mut Option<StreamState>,
finger_id: u64,
x: f32,
y: f32,
timestamp: u64,
) -> bool {
let Some(st) = stream.as_mut() else {
return false;
};
let (pw, ph) = window.size_in_pixels();
let (wx, wy) = (x * pw as f32, y * ph as f32);
let abs = match st.last_video {
Some(video) => {
let (ax, ay, aw, ah) = finger_to_content((pw, ph), video, x, y);
Abs {
x: ax,
y: ay,
w: aw,
h: ah,
}
}
None if phase == FingerPhase::Up => Abs {
x: 0,
y: 0,
w: 0,
h: 0,
},
None => return false,
};
let Some(cap) = st.capture.as_mut() else {
return false;
};
cap.dispatch_finger(
phase,
finger_id,
wx,
wy,
abs,
timestamp as f64 / 1_000_000.0,
)
}
/// Advance the stats-overlay tier and re-render the OSD immediately from the last window
/// (waiting for the next Stats event would lag the trigger by up to 1 s). Shared by the
/// Ctrl+Alt+Shift+S chord and the three-finger touch tap.
fn bump_stats_tier(
verbosity: &mut StatsVerbosity,
stream: &mut Option<StreamState>,
presenter: &Presenter,
) {
*verbosity = verbosity.next();
if let Some(st) = stream {
st.osd_text = match &st.last_stats {
Some(s) => stats_text(
*verbosity,
&st.mode_line,
s,
&st.presented,
st.hdr,
presenter.hdr_active(),
),
None => String::new(),
};
}
}
/// The pure Contain-fit mapping (window pixels in, content pixels out) — split out so the
/// letterbox math is testable without a live SDL window. Mirrors
/// [`vk::letterbox`]; a finger in the letterbox bars clamps to the nearest content edge.
fn finger_to_content(
surface: (u32, u32),
video: (u32, u32),
x: f32,
y: f32,
) -> (i32, i32, u32, u32) {
let (pw, ph) = (f64::from(surface.0), f64::from(surface.1));
let (vw, vh) = video;
let scale = (pw / f64::from(vw.max(1))).min(ph / f64::from(vh.max(1)));
let dw = (f64::from(vw) * scale).max(1.0);
let dh = (f64::from(vh) * scale).max(1.0);
let ox = (pw - dw) / 2.0;
let oy = (ph - dh) / 2.0;
let cx = ((f64::from(x) * pw - ox) / dw).clamp(0.0, 1.0) * dw;
let cy = ((f64::from(y) * ph - oy) / dh).clamp(0.0, 1.0) * dh;
(cx.round() as i32, cy.round() as i32, dw as u32, dh as u32)
}
/// The presenter's share of the unified stats window — folded into each printed line.
#[derive(Default)]
struct PresentedWindow {
@@ -1614,4 +1794,46 @@ mod tests {
"120 fps · 24 Mb/s"
);
}
#[test]
fn finger_maps_across_a_perfectly_filled_surface() {
// Video exactly fills the window (no letterbox): normalized finger → content
// corners/center map straight through, and the surface size is the video size.
let video = (1920, 1080);
assert_eq!(
finger_to_content((1920, 1080), video, 0.0, 0.0),
(0, 0, 1920, 1080)
);
assert_eq!(
finger_to_content((1920, 1080), video, 1.0, 1.0),
(1920, 1080, 1920, 1080)
);
assert_eq!(
finger_to_content((1920, 1080), video, 0.5, 0.5),
(960, 540, 1920, 1080)
);
}
#[test]
fn finger_rebases_onto_the_letterboxed_content_rect() {
// 16:9 video in the Deck's 16:10 glass (1280×800) letterboxes: content is
// 1280×720, centered with 40px bars top/bottom. A finger at the window's vertical
// center is the content's vertical center; a finger inside the top bar clamps to
// the content's top edge (not a negative coordinate).
let surface = (1280, 800);
let video = (1920, 1080);
let (_, cy, w, h) = finger_to_content(surface, video, 0.5, 0.5);
assert_eq!((w, h), (1280, 720));
assert_eq!(cy, 360);
// y=0.01 → window pixel 8, above the 40px bar → clamps to content top (0).
assert_eq!(
finger_to_content(surface, video, 0.5, 0.01),
(640, 0, 1280, 720)
);
// Bottom-right corner of the video content.
assert_eq!(
finger_to_content(surface, video, 1.0, 1.0),
(1280, 720, 1280, 720)
);
}
}
+544
View File
@@ -0,0 +1,544 @@
//! Touchscreen fingers → host mouse for the `trackpad`/`pointer` touch-input models — an
//! incremental port of the Android client's gesture engine (clients/android
//! `TouchInput.kt`) and its Apple twin (`TouchMouse.swift`) so all three touch clients feel
//! identical. The third model, `touch`, never reaches here: those fingers go on the wire as
//! real multi-touch contacts (`Capture::on_touch_*`).
//!
//! Two mouse models share one gesture vocabulary:
//! * **trackpad** (default): the cursor STAYS PUT on touch-down and moves by the finger's
//! relative delta with mild acceleration — swipe to nudge, lift and re-swipe to walk it
//! across, tap to click where it is. What makes a cursor reachable on a small screen.
//! * **pointer**: the cursor jumps to the finger and follows it (absolute moves through the
//! aspect-fit letterbox) — direct pointing.
//!
//! Shared gestures: tap = left click · two-finger tap = right click · two-finger drag =
//! scroll · tap-then-press-and-drag = held left drag · three-finger tap = cycle the stats
//! overlay tier.
//!
//! Unlike the Android/Apple hosts (which hand the engine a whole event's worth of changed
//! touches at once), SDL delivers ONE finger transition per event, so this is a strictly
//! incremental state machine: it keeps every live finger's position and recomputes the
//! centroid itself. Positions are in physical window pixels (the caller multiplies SDL's
//! normalized 0..1 finger coordinates by the window's pixel size) so the pixel-based
//! ballistics constants port from Android 1:1; timestamps are milliseconds.
use punktfunk_core::input::InputKind;
use std::collections::HashMap;
// Gesture/ballistics tuning (physical px / ms), matching the Android reference exactly.
/// Movement under this (px) still counts as a tap, not a drag.
const TAP_SLOP: f32 = 12.0;
/// A new touch this soon (ms) after a tap, near it, starts a held left-button drag.
const TAP_DRAG_MS: f64 = 250.0;
/// Two-finger pan distance (px) per 120-unit wheel notch (smaller = faster scroll).
const SCROLL_DIV: f32 = 4.0;
/// Base finger-px → host-px gain (~1:1, never twitchy).
const POINTER_SENS: f32 = 1.3;
/// Above `ACCEL_SPEED_FLOOR` px/ms the gain ramps by `ACCEL_GAIN` per px/ms, capped at
/// `ACCEL_MAX` so a fast swipe can't fling the cursor uncontrollably.
const ACCEL_GAIN: f32 = 0.6;
const ACCEL_SPEED_FLOOR: f32 = 0.3;
const ACCEL_MAX: f32 = 3.0;
/// GameStream mouse button ids.
const BTN_LEFT: u32 = 1;
const BTN_RIGHT: u32 = 3;
/// A finger's position in the letterboxed video content rect (absolute host pixels + the
/// content surface size) — what `pointer` mode's absolute moves carry. Mirrors the
/// `MouseMoveAbs` packing the host rescales into its output.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Abs {
pub x: i32,
pub y: i32,
pub w: u32,
pub h: u32,
}
/// A wire intent the engine emits; the owner ([`Capture`](crate::input::Capture)) translates
/// each into an actual `send_input`, and folds [`CycleStats`](Act::CycleStats) back to the
/// run loop.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Act {
/// Relative cursor motion (`MouseMove`).
MoveRel { dx: i32, dy: i32 },
/// Absolute cursor position through the letterbox (`MouseMoveAbs`).
MoveAbs(Abs),
/// A mouse button transition (`gs` = GameStream id; `down` = press/release).
Button { gs: u32, down: bool },
/// A wheel step: `axis` 0 = vertical, 1 = horizontal; `delta` in WHEEL(120) units.
Scroll { axis: u32, delta: i32 },
/// Three-finger tap: cycle the stats-overlay verbosity tier (the run loop owns it).
CycleStats,
}
impl Act {
/// The `(InputKind, code, x, y, flags)` this intent sends. `Button`/`CycleStats` don't map
/// to a single motion send, so callers special-case them; this covers the motion/scroll
/// intents shared with the raw pointer path.
pub fn wire(self) -> Option<(InputKind, u32, i32, i32, u32)> {
match self {
Act::MoveRel { dx, dy } => Some((InputKind::MouseMove, 0, dx, dy, 0)),
Act::MoveAbs(a) => Some((
InputKind::MouseMoveAbs,
0,
a.x,
a.y,
((a.w & 0xffff) << 16) | (a.h & 0xffff),
)),
Act::Scroll { axis, delta } => Some((InputKind::MouseScroll, axis, delta, 0, 0)),
Act::Button { .. } | Act::CycleStats => None,
}
}
}
/// The trackpad/pointer gesture state machine. One per session; `trackpad` picks the model
/// (false = pointer). Fed only DIRECT touchscreen fingers.
pub struct Gestures {
trackpad: bool,
/// Live fingers → current window-pixel position (the centroid needs every finger, but a
/// move event only carries the one that changed).
positions: HashMap<u64, (f32, f32)>,
/// A gesture is in flight (≥ 1 finger down since the first touch).
active: bool,
start: (f32, f32),
max_fingers: usize,
moved: bool,
scrolling: bool,
/// Finger count the scroll centroid is anchored at — re-anchor on a count change so a
/// 2→3 transition isn't read as a scroll notch.
scroll_count: usize,
scroll_anchor: (f32, f32),
/// A tap-then-press-and-drag is holding the left button down for this whole gesture.
drag_held: bool,
// Trackpad relative-motion state: the tracked finger, its last position/time, and the
// sub-pixel remainder so a slow drag isn't lost to integer truncation.
track_id: Option<u64>,
prev: (f32, f32),
prev_t: f64,
carry: (f32, f32),
// Tap-drag arming: a quick tap leaves a window in which the next nearby touch drags.
last_tap_up: f64,
last_tap_pt: (f32, f32),
}
impl Gestures {
pub fn new(trackpad: bool) -> Gestures {
Gestures {
trackpad,
positions: HashMap::new(),
active: false,
start: (0.0, 0.0),
max_fingers: 0,
moved: false,
scrolling: false,
scroll_count: 0,
scroll_anchor: (0.0, 0.0),
drag_held: false,
track_id: None,
prev: (0.0, 0.0),
prev_t: 0.0,
carry: (0.0, 0.0),
last_tap_up: 0.0,
last_tap_pt: (0.0, 0.0),
}
}
/// A finger touched down. `abs` is its letterbox mapping (pointer mode jumps the cursor
/// there on the first finger). `t` is milliseconds.
pub fn down(&mut self, id: u64, wx: f32, wy: f32, abs: Abs, t: f64) -> Vec<Act> {
let mut acts = Vec::new();
let first = self.positions.is_empty() && !self.active;
self.positions.insert(id, (wx, wy));
if first {
self.active = true;
self.start = (wx, wy);
self.max_fingers = 0;
self.moved = false;
self.scrolling = false;
self.scroll_count = 0;
// A touch landing just after a quick tap nearby = tap-and-drag.
self.drag_held = t - self.last_tap_up < TAP_DRAG_MS
&& (wx - self.last_tap_pt.0).abs() < TAP_SLOP
&& (wy - self.last_tap_pt.1).abs() < TAP_SLOP;
self.last_tap_up = 0.0; // consume the arming either way
if !self.trackpad {
acts.push(Act::MoveAbs(abs)); // pointer: place the cursor before any press
}
if self.drag_held {
acts.push(Act::Button {
gs: BTN_LEFT,
down: true,
});
}
self.track_id = Some(id);
self.prev = (wx, wy);
self.prev_t = t;
self.carry = (0.0, 0.0);
}
self.max_fingers = self.max_fingers.max(self.positions.len());
acts
}
/// A finger moved.
pub fn motion(&mut self, id: u64, wx: f32, wy: f32, abs: Abs, t: f64) -> Vec<Act> {
if !self.active || !self.positions.contains_key(&id) {
return Vec::new();
}
self.positions.insert(id, (wx, wy));
if self.positions.len() >= 2 {
self.scroll_by_centroid()
} else if !self.scrolling {
// One finger, and the gesture never became a scroll (dropping back from two
// fingers to one must not jerk the cursor).
self.single_finger(id, wx, wy, abs, t)
} else {
Vec::new()
}
}
/// A finger lifted. Only when the LAST finger lifts does the gesture conclude (into a
/// click / drag-end / stats cycle). `t` is the up-time in milliseconds.
pub fn up(&mut self, id: u64, t: f64) -> Vec<Act> {
let mut acts = Vec::new();
self.positions.remove(&id);
if self.track_id == Some(id) {
self.track_id = None;
}
if !self.positions.is_empty() || !self.active {
return acts; // other fingers still down (or no live gesture)
}
self.active = false;
if self.drag_held {
self.drag_held = false;
acts.push(Act::Button {
gs: BTN_LEFT,
down: false,
}); // end the held drag
} else if !self.moved {
match self.max_fingers {
n if n >= 3 => acts.push(Act::CycleStats),
2 => {
acts.push(Act::Button {
gs: BTN_RIGHT,
down: true,
});
acts.push(Act::Button {
gs: BTN_RIGHT,
down: false,
});
}
_ => {
acts.push(Act::Button {
gs: BTN_LEFT,
down: true,
});
acts.push(Act::Button {
gs: BTN_LEFT,
down: false,
});
self.last_tap_up = t; // arm tap-drag
self.last_tap_pt = self.start;
}
}
}
acts
}
/// Forget all in-flight gesture state (capture release / session teardown). Any left
/// button the engine is holding is released by the owner's held-button flush, so this
/// only clears state — it never re-emits wire events.
pub fn reset(&mut self) {
self.positions.clear();
self.track_id = None;
self.active = false;
self.scrolling = false;
self.moved = false;
self.drag_held = false;
self.last_tap_up = 0.0;
}
/// Two (or more) fingers → scroll by the centroid delta; never move the cursor. Fires a
/// notch per `SCROLL_DIV` px of pan and re-anchors on fire; finger up scrolls up, finger
/// right scrolls right (the host WHEEL(120) convention).
fn scroll_by_centroid(&mut self) -> Vec<Act> {
let mut acts = Vec::new();
let n = self.positions.len() as f32;
let (mut sx, mut sy) = (0.0f32, 0.0f32);
for &(px, py) in self.positions.values() {
sx += px;
sy += py;
}
let (cx, cy) = (sx / n, sy / n);
// (Re-)anchor on scroll start AND whenever the finger count changes.
if !self.scrolling || self.positions.len() != self.scroll_count {
self.scrolling = true;
self.scroll_count = self.positions.len();
self.scroll_anchor = (cx, cy);
}
let notches_y = ((self.scroll_anchor.1 - cy) / SCROLL_DIV) as i32;
let notches_x = ((cx - self.scroll_anchor.0) / SCROLL_DIV) as i32;
if notches_y != 0 {
acts.push(Act::Scroll {
axis: 0,
delta: notches_y * 120,
});
self.scroll_anchor.1 = cy;
self.moved = true;
}
if notches_x != 0 {
acts.push(Act::Scroll {
axis: 1,
delta: notches_x * 120,
});
self.scroll_anchor.0 = cx;
self.moved = true;
}
acts
}
/// One finger, not scrolling: trackpad relative ballistics, or pointer absolute follow.
fn single_finger(&mut self, id: u64, wx: f32, wy: f32, abs: Abs, t: f64) -> Vec<Act> {
let mut acts = Vec::new();
if (wx - self.start.0).abs() > TAP_SLOP || (wy - self.start.1).abs() > TAP_SLOP {
self.moved = true;
}
if !self.trackpad {
acts.push(Act::MoveAbs(abs)); // the cursor follows the finger
return acts;
}
// Re-anchor (zero delta this frame) if the tracked finger changed, so lifting one of
// several fingers never jumps the cursor.
if self.track_id != Some(id) {
self.track_id = Some(id);
self.prev = (wx, wy);
self.prev_t = t;
return acts;
}
let dx = wx - self.prev.0;
let dy = wy - self.prev.1;
let dt_ms = (t - self.prev_t).max(1.0) as f32;
self.prev = (wx, wy);
self.prev_t = t;
let speed = dx.hypot(dy) / dt_ms; // finger px per ms
let accel = (1.0 + ACCEL_GAIN * (speed - ACCEL_SPEED_FLOOR).max(0.0)).min(ACCEL_MAX);
let gain = POINTER_SENS * accel;
self.carry.0 += dx * gain;
self.carry.1 += dy * gain;
let out_x = self.carry.0 as i32; // truncates toward zero → remainder kept with sign
let out_y = self.carry.1 as i32;
if out_x != 0 || out_y != 0 {
acts.push(Act::MoveRel {
dx: out_x,
dy: out_y,
});
self.carry.0 -= out_x as f32;
self.carry.1 -= out_y as f32;
}
acts
}
}
#[cfg(test)]
mod tests {
use super::*;
const ABS: Abs = Abs {
x: 100,
y: 200,
w: 1280,
h: 720,
};
fn abs_at(x: i32, y: i32) -> Abs {
Abs {
x,
y,
w: 1280,
h: 720,
}
}
#[test]
fn trackpad_tap_is_a_left_click_with_no_motion() {
let mut g = Gestures::new(true);
let mut acts = g.down(1, 50.0, 50.0, ABS, 0.0);
acts.extend(g.up(1, 40.0));
// A trackpad tap places no cursor and moves nothing — just a click.
assert_eq!(
acts,
vec![
Act::Button {
gs: BTN_LEFT,
down: true
},
Act::Button {
gs: BTN_LEFT,
down: false
},
]
);
}
#[test]
fn pointer_tap_places_the_cursor_then_clicks() {
let mut g = Gestures::new(false);
let mut acts = g.down(1, 50.0, 50.0, abs_at(640, 360), 0.0);
acts.extend(g.up(1, 40.0));
assert_eq!(
acts,
vec![
Act::MoveAbs(abs_at(640, 360)),
Act::Button {
gs: BTN_LEFT,
down: true
},
Act::Button {
gs: BTN_LEFT,
down: false
},
]
);
}
#[test]
fn two_finger_tap_is_a_right_click() {
let mut g = Gestures::new(true);
let mut acts = g.down(1, 50.0, 50.0, ABS, 0.0);
acts.extend(g.down(2, 80.0, 52.0, ABS, 5.0));
acts.extend(g.up(1, 40.0));
acts.extend(g.up(2, 42.0));
assert_eq!(
acts,
vec![
Act::Button {
gs: BTN_RIGHT,
down: true
},
Act::Button {
gs: BTN_RIGHT,
down: false
},
]
);
}
#[test]
fn three_finger_tap_cycles_stats() {
let mut g = Gestures::new(true);
let mut acts = g.down(1, 50.0, 50.0, ABS, 0.0);
acts.extend(g.down(2, 80.0, 50.0, ABS, 2.0));
acts.extend(g.down(3, 110.0, 50.0, ABS, 4.0));
acts.extend(g.up(1, 40.0));
acts.extend(g.up(2, 41.0));
acts.extend(g.up(3, 42.0));
assert_eq!(acts, vec![Act::CycleStats]);
}
#[test]
fn trackpad_drag_emits_relative_motion() {
let mut g = Gestures::new(true);
assert!(g.down(1, 100.0, 100.0, ABS, 0.0).is_empty());
// A big move over 16 ms — relative, with acceleration, so it should exceed 1:1.
let acts = g.motion(1, 140.0, 100.0, ABS, 16.0);
match acts.as_slice() {
[Act::MoveRel { dx, dy }] => {
assert!(*dx >= 40, "expected accelerated dx ≥ raw 40, got {dx}");
assert_eq!(*dy, 0);
}
other => panic!("expected one MoveRel, got {other:?}"),
}
// The gesture moved, so the lift is not a tap (no click).
assert!(g.up(1, 32.0).is_empty());
}
#[test]
fn pointer_motion_follows_the_finger_absolutely() {
let mut g = Gestures::new(false);
let _ = g.down(1, 100.0, 100.0, abs_at(300, 300), 0.0);
let acts = g.motion(1, 140.0, 120.0, abs_at(360, 340), 16.0);
assert_eq!(acts, vec![Act::MoveAbs(abs_at(360, 340))]);
}
#[test]
fn two_finger_pan_scrolls_by_the_centroid() {
let mut g = Gestures::new(true);
let _ = g.down(1, 100.0, 200.0, ABS, 0.0);
let _ = g.down(2, 120.0, 200.0, ABS, 2.0);
// Both fingers slide up 40 px → the centroid rises 40 px → +ve (finger-up) notches.
let a1 = g.motion(1, 100.0, 160.0, ABS, 10.0);
let a2 = g.motion(2, 120.0, 160.0, ABS, 12.0);
let scrolls: Vec<_> = a1.into_iter().chain(a2).collect();
assert!(
scrolls
.iter()
.any(|a| matches!(a, Act::Scroll { axis: 0, delta } if *delta > 0)),
"expected an upward vertical scroll, got {scrolls:?}"
);
}
#[test]
fn tap_then_press_drag_holds_the_left_button() {
let mut g = Gestures::new(true);
// Tap at (50,50), lifting at t=10.
let _ = g.down(1, 50.0, 50.0, ABS, 0.0);
let click = g.up(1, 10.0);
assert_eq!(
click,
vec![
Act::Button {
gs: BTN_LEFT,
down: true
},
Act::Button {
gs: BTN_LEFT,
down: false
},
]
);
// A new touch nearby within the window arms a held drag: button down on touch, and
// the whole gesture holds it until the lift.
let down2 = g.down(2, 52.0, 51.0, ABS, 120.0);
assert_eq!(
down2,
vec![Act::Button {
gs: BTN_LEFT,
down: true
}]
);
let _ = g.motion(2, 90.0, 51.0, ABS, 140.0); // drag
let end = g.up(2, 160.0);
assert_eq!(
end,
vec![Act::Button {
gs: BTN_LEFT,
down: false
}]
);
}
#[test]
fn reset_clears_a_drag_without_re_emitting() {
let mut g = Gestures::new(true);
let _ = g.down(1, 50.0, 50.0, ABS, 0.0);
let _ = g.up(1, 5.0); // arm
let _ = g.down(2, 51.0, 50.0, ABS, 50.0); // drag begins (left held)
g.reset();
// After a reset a fresh tap is an ordinary click (no stuck drag state).
let mut acts = g.down(3, 400.0, 400.0, ABS, 500.0);
acts.extend(g.up(3, 510.0));
assert_eq!(
acts,
vec![
Act::Button {
gs: BTN_LEFT,
down: true
},
Act::Button {
gs: BTN_LEFT,
down: false
},
]
);
}
}
+32 -23
View File
@@ -50,29 +50,38 @@ pub struct GamepadFrame {
pub rs_y: i16,
}
// buttonFlags bits (Limelight.h).
pub const BTN_DPAD_UP: u32 = 0x0001;
pub const BTN_DPAD_DOWN: u32 = 0x0002;
pub const BTN_DPAD_LEFT: u32 = 0x0004;
pub const BTN_DPAD_RIGHT: u32 = 0x0008;
pub const BTN_START: u32 = 0x0010;
pub const BTN_BACK: u32 = 0x0020;
pub const BTN_LS_CLK: u32 = 0x0040;
pub const BTN_RS_CLK: u32 = 0x0080;
pub const BTN_LB: u32 = 0x0100;
pub const BTN_RB: u32 = 0x0200;
pub const BTN_GUIDE: u32 = 0x0400;
pub const BTN_A: u32 = 0x1000;
pub const BTN_B: u32 = 0x2000;
pub const BTN_X: u32 = 0x4000;
pub const BTN_Y: u32 = 0x8000;
// Extended buttons in the `buttonFlags2 << 16` namespace (mirror `punktfunk_core::input::gamepad`):
// the four back-grip paddles. `decode` already merges `buttonFlags2 << 16` into `buttons`, but the
// injector map dropped these bits — Sunshine/Moonlight paddle clients were silently no-op'd.
pub const BTN_PADDLE1: u32 = 0x0001_0000;
pub const BTN_PADDLE2: u32 = 0x0002_0000;
pub const BTN_PADDLE3: u32 = 0x0004_0000;
pub const BTN_PADDLE4: u32 = 0x0008_0000;
// GameStream's `buttonFlags | buttonFlags2 << 16` layout (Limelight.h) is bit-identical to
// punktfunk's native gamepad wire, so source these from the single point of truth in `punktfunk_core`
// instead of re-declaring the values (the two drifted while separately hand-typed: the click bits
// were named `BTN_LS_CLK`/`BTN_RS_CLK` here vs the core `…_CLICK`). `decode` merges the two 16-bit
// halves into `buttons` raw; these names exist for the uinput injector's button map + hat math. The
// extended touchpad-click / Share bits (`BTN_TOUCHPAD` / `BTN_MISC1`) ride `buttons` too but are
// consumed straight from `punktfunk_core` by the DualSense/DS4 protos, so they aren't re-named here.
//
// These are `pub const` aliases rather than a `pub use` re-export on purpose: on Windows the sole
// consumer (the Linux uinput map) is cfg'd out, and an unused re-export lints as an error there,
// whereas an unused `pub const` does not. The values still come only from core, so they can't drift;
// the exact wire values are pinned by `punktfunk1.rs::gamepad_wire_bits_are_pinned`.
use punktfunk_core::input::gamepad as wire;
pub const BTN_DPAD_UP: u32 = wire::BTN_DPAD_UP;
pub const BTN_DPAD_DOWN: u32 = wire::BTN_DPAD_DOWN;
pub const BTN_DPAD_LEFT: u32 = wire::BTN_DPAD_LEFT;
pub const BTN_DPAD_RIGHT: u32 = wire::BTN_DPAD_RIGHT;
pub const BTN_START: u32 = wire::BTN_START;
pub const BTN_BACK: u32 = wire::BTN_BACK;
pub const BTN_LS_CLICK: u32 = wire::BTN_LS_CLICK;
pub const BTN_RS_CLICK: u32 = wire::BTN_RS_CLICK;
pub const BTN_LB: u32 = wire::BTN_LB;
pub const BTN_RB: u32 = wire::BTN_RB;
pub const BTN_GUIDE: u32 = wire::BTN_GUIDE;
pub const BTN_A: u32 = wire::BTN_A;
pub const BTN_B: u32 = wire::BTN_B;
pub const BTN_X: u32 = wire::BTN_X;
pub const BTN_Y: u32 = wire::BTN_Y;
pub const BTN_PADDLE1: u32 = wire::BTN_PADDLE1;
pub const BTN_PADDLE2: u32 = wire::BTN_PADDLE2;
pub const BTN_PADDLE3: u32 = wire::BTN_PADDLE3;
pub const BTN_PADDLE4: u32 = wire::BTN_PADDLE4;
/// Decode one decrypted control plaintext into a controller event, if it is one. Mouse,
/// keyboard, keepalives etc. yield `None` (they're handled by [`super::input::decode`]).
+8 -1
View File
@@ -506,6 +506,11 @@ pub mod gamepad;
#[cfg(target_os = "windows")]
#[path = "inject/windows/gamepad_raii.rs"]
mod gamepad_raii;
/// Shared virtual-pad creation-retry policy ([`pad_gate::PadGate`]) used by every backend manager on
/// both platforms — replaces the per-backend permanent `broken` latch with capped-backoff retry.
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/pad_gate.rs"]
pub mod pad_gate;
/// Linux: virtual Steam Deck via UHID — the kernel `hid-steam` driver binds it as a real Deck.
#[cfg(target_os = "linux")]
#[path = "inject/linux/steam_controller.rs"]
@@ -522,7 +527,9 @@ pub mod steam_gadget;
#[path = "inject/proto/steam_proto.rs"]
pub mod steam_proto;
/// Pure fallback-remap policy (Steam-only inputs onto a non-Steam backend) + the Deck motion rescale.
#[cfg(target_os = "linux")]
/// Shared by the Linux and Windows DualSense/DS4 backends (the slot-less pads that must fold the
/// Steam back grips); the Deck motion rescale is Linux-only but harmless to compile on Windows.
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/proto/steam_remap.rs"]
pub mod steam_remap;
/// Linux: virtual Steam Deck over **USB/IP** (`vhci_hcd`) — the shippable, Secure-Boot-clean,
@@ -13,11 +13,12 @@
//! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it.
use super::dualsense_proto::{
parse_ds_output, serialize_state, DsFeedback, DsState, DS_FEATURE_CALIBRATION,
parse_ds_output, serialize_state, DsFeedback, DsState, HidoutDedup, DS_FEATURE_CALIBRATION,
DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING, DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_TOUCH_H,
DS_TOUCH_W, DS_VENDOR, DUALSENSE_RDESC,
};
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use crate::inject::pad_gate::PadGate;
use anyhow::{Context, Result};
use punktfunk_core::quic::{HidOutput, RichInput};
use std::fs::{File, OpenOptions};
@@ -177,11 +178,15 @@ pub struct DualSenseManager {
state: Vec<DsState>,
/// Last rumble forwarded per pad, so a report that only changes the LED doesn't re-send it.
last_rumble: Vec<(u16, u16)>,
/// Last rich feedback (lightbar / player LEDs / adaptive triggers) forwarded per pad, so an
/// output report that only changed the rumble doesn't re-send unchanged 0xCD feedback.
hidout_dedup: Vec<HidoutDedup>,
/// When each pad last wrote an input report — drives [`DualSenseManager::heartbeat`], which
/// re-emits the current state during input silence so the kernel never sees the device go quiet.
last_write: Vec<Instant>,
/// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events.
broken: bool,
/// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of
/// permanently disabling every pad for the session.
gate: PadGate,
/// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button
/// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
remap: crate::inject::steam_remap::RemapConfig,
@@ -199,8 +204,9 @@ impl DualSenseManager {
pads: (0..MAX_PADS).map(|_| None).collect(),
state: vec![DsState::neutral(); MAX_PADS],
last_rumble: vec![(0, 0); MAX_PADS],
hidout_dedup: vec![HidoutDedup::default(); MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
broken: false,
gate: PadGate::new(),
remap: crate::inject::steam_remap::RemapConfig::from_env(),
}
}
@@ -224,6 +230,7 @@ impl DualSenseManager {
*slot = None;
self.state[i] = DsState::neutral();
self.last_rumble[i] = (0, 0);
self.hidout_dedup[i].clear();
}
}
if f.active_mask & (1 << idx) == 0 {
@@ -300,7 +307,7 @@ impl DualSenseManager {
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
return;
}
match DualSensePad::open(idx as u8) {
@@ -312,11 +319,13 @@ impl DualSenseManager {
self.pads[idx] = Some(p);
self.state[idx] = DsState::neutral();
self.last_rumble[idx] = (0, 0);
self.hidout_dedup[idx].clear();
self.last_write[idx] = Instant::now();
self.gate.on_success();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — controller input disabled");
self.broken = true;
tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — retrying with backoff");
self.gate.on_failure(Instant::now());
}
}
}
@@ -343,7 +352,11 @@ impl DualSenseManager {
}
}
for h in fb.hidout {
hidout(h);
// Skip rich feedback that repeats the last-forwarded value (the game's output report
// re-sends unchanged lightbar/LED/trigger state alongside every rumble update).
if self.hidout_dedup[i].should_forward(&h) {
hidout(h);
}
}
}
}
@@ -15,6 +15,7 @@
use super::dualsense_proto::{DsState, Touch};
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use crate::inject::pad_gate::PadGate;
use anyhow::{Context, Result};
use punktfunk_core::quic::{HidOutput, RichInput};
use std::fs::{File, OpenOptions};
@@ -365,8 +366,9 @@ pub struct DualShock4Manager {
last_led: Vec<Option<(u8, u8, u8)>>,
/// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat).
last_write: Vec<Instant>,
/// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events.
broken: bool,
/// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of
/// permanently disabling every pad for the session.
gate: PadGate,
/// Fallback policy for the Steam back grips a client may send (the DS4 has no back-button HID
/// slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
remap: crate::inject::steam_remap::RemapConfig,
@@ -386,7 +388,7 @@ impl DualShock4Manager {
last_rumble: vec![(0, 0); MAX_PADS],
last_led: vec![None; MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
broken: false,
gate: PadGate::new(),
remap: crate::inject::steam_remap::RemapConfig::from_env(),
}
}
@@ -522,7 +524,7 @@ impl DualShock4Manager {
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
return;
}
match DualShock4Pad::open(idx as u8) {
@@ -536,10 +538,11 @@ impl DualShock4Manager {
self.last_rumble[idx] = (0, 0);
self.last_led[idx] = None;
self.last_write[idx] = Instant::now();
self.gate.on_success();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — controller input disabled");
self.broken = true;
tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — retrying with backoff");
self.gate.on_failure(Instant::now());
}
}
}
@@ -19,6 +19,7 @@
#![deny(clippy::undocumented_unsafe_blocks)]
use crate::gamestream::gamepad::{self, GamepadFrame, MAX_PADS};
use crate::inject::pad_gate::PadGate;
use anyhow::{bail, Result};
use std::collections::HashMap;
use std::os::fd::{AsRawFd, OwnedFd};
@@ -88,8 +89,8 @@ const BUTTON_MAP: [(u32, u16); 15] = [
(gamepad::BTN_BACK, BTN_SELECT),
(gamepad::BTN_START, BTN_START),
(gamepad::BTN_GUIDE, BTN_MODE),
(gamepad::BTN_LS_CLK, BTN_THUMBL),
(gamepad::BTN_RS_CLK, BTN_THUMBR),
(gamepad::BTN_LS_CLICK, BTN_THUMBL),
(gamepad::BTN_RS_CLICK, BTN_THUMBR),
(gamepad::BTN_PADDLE1, BTN_TRIGGER_HAPPY5),
(gamepad::BTN_PADDLE2, BTN_TRIGGER_HAPPY6),
(gamepad::BTN_PADDLE3, BTN_TRIGGER_HAPPY7),
@@ -265,7 +266,6 @@ struct Effect {
/// One virtual X-Box-360 pad backed by a uinput device.
pub struct VirtualPad {
fd: OwnedFd,
prev_buttons: u32,
effects: HashMap<i16, Effect>,
next_effect_id: i16,
gain: u32,
@@ -369,7 +369,6 @@ impl VirtualPad {
Ok(VirtualPad {
fd,
prev_buttons: 0,
effects: HashMap::new(),
next_effect_id: 0,
gain: 0xFFFF,
@@ -412,15 +411,17 @@ impl VirtualPad {
};
}
/// Apply one decoded frame: button transitions, axes, D-pad hat, one SYN_REPORT.
/// Apply one decoded frame: button state, axes, D-pad hat, one SYN_REPORT.
pub fn apply(&mut self, f: &GamepadFrame) {
let changed = self.prev_buttons ^ f.buttons;
// Re-assert every mapped button's absolute state each frame — exactly like the axes below —
// instead of only writing XOR-changed edges. `emit` is best-effort (a full kernel queue drops
// the write), so an edge-only scheme would strand a dropped press/release until that button
// next toggles; re-asserting re-syncs it on the following frame. Restating an unchanged key is
// free downstream: the kernel input core discards an EV_KEY whose value already matches the
// device's current state (no duplicate event reaches consumers, and BTN_* keys don't autorepeat).
for (bit, key) in BUTTON_MAP {
if changed & bit != 0 {
self.emit(EV_KEY, key, ((f.buttons & bit) != 0) as i32);
}
self.emit(EV_KEY, key, ((f.buttons & bit) != 0) as i32);
}
self.prev_buttons = f.buttons;
// Moonlight: +Y = up; evdev: +Y = down → negate (i32 math avoids -(-32768) overflow).
self.emit(EV_ABS, ABS_X, f.ls_x as i32);
@@ -557,8 +558,9 @@ pub struct GamepadManager {
/// The USB identity every pad in this session presents (X-Box 360 by default, One/Series when
/// the client asked for `XboxOne`). All pads in a session share one identity.
identity: PadIdentity,
/// Pad creation failed (e.g. /dev/uinput permissions) — warn once, drop events.
broken: bool,
/// Create-retry gate: a transient `/dev/uinput` failure backs off and retries instead of
/// permanently disabling every pad for the session.
gate: PadGate,
}
impl GamepadManager {
@@ -572,7 +574,7 @@ impl GamepadManager {
GamepadManager {
pads: (0..MAX_PADS).map(|_| None).collect(),
identity,
broken: false,
gate: PadGate::new(),
}
}
@@ -608,14 +610,17 @@ impl GamepadManager {
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
return;
}
match VirtualPad::create(idx, self.identity) {
Ok(p) => self.pads[idx] = Some(p),
Ok(p) => {
self.pads[idx] = Some(p);
self.gate.on_success();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual gamepad creation failed — controller input disabled");
self.broken = true;
tracing::error!(error = %format!("{e:#}"), "virtual gamepad creation failed — retrying with backoff");
self.gate.on_failure(Instant::now());
}
}
}
@@ -24,6 +24,7 @@ use super::steam_proto::{
STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR,
};
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use crate::inject::pad_gate::PadGate;
use anyhow::{Context, Result};
use punktfunk_core::quic::{HidOutput, RichInput};
use std::fs::{File, OpenOptions};
@@ -339,11 +340,18 @@ fn open_transport(idx: u8) -> Result<DeckTransport> {
}
}
}
// 3. UHID — universal fallback (works everywhere; Steam Input won't promote it).
// 3. UHID — universal fallback (works everywhere; Steam Input won't promote it). This is a
// DEGRADED outcome, not a normal one: a UHID device has no USB interface number (Interface: -1),
// so Steam Input ignores it and the controller never appears in Game Mode / can't navigate.
// Reaching here almost always means `vhci_hcd` isn't loaded (the host runs unprivileged and
// can't modprobe it) — load it at boot (packaging ships modules-load.d/punktfunk.conf +
// 60-punktfunk.rules; on a systemd-sysext host `punktfunk-sysext` mirrors both into /etc).
let p = SteamDeckPad::open(idx)?;
tracing::info!(
tracing::warn!(
index = idx,
"virtual Steam Deck created (UHID hid-steam — not Steam-Input-promoted)"
"virtual Steam Deck created as UHID hid-steam — Steam Input WON'T promote it (no USB \
interface), so it won't appear in Game Mode. Load vhci_hcd (usbip) so the pad arrives as a \
real USB device: `sudo modprobe vhci_hcd`, and ensure it loads at boot."
);
Ok(DeckTransport::Uhid(p))
}
@@ -353,7 +361,9 @@ pub struct SteamControllerManager {
state: Vec<SteamState>,
last_rumble: Vec<(u16, u16)>,
last_write: Vec<Instant>,
broken: bool,
/// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of
/// permanently disabling every pad for the session.
gate: PadGate,
}
impl Default for SteamControllerManager {
@@ -369,7 +379,7 @@ impl SteamControllerManager {
state: vec![SteamState::neutral(); MAX_PADS],
last_rumble: vec![(0, 0); MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
broken: false,
gate: PadGate::new(),
}
}
@@ -415,6 +425,12 @@ impl SteamControllerManager {
s.gyro = prev.gyro;
s.accel = prev.accel;
s.buttons |= prev.buttons & (btn::RPAD_TOUCH | btn::LPAD_TOUCH);
// Trackpad CLICK arrives on the rich plane too and must survive a button-only frame,
// exactly like touch/coords/motion above. It lives in its own fields (not `buttons`,
// which `from_gamepad` just rebuilt) so preserving it can't strand the BTN_TOUCHPAD
// wire-button's RPAD_CLICK — the two are OR'd only at serialize.
s.lpad_click = prev.lpad_click;
s.rpad_click = prev.rpad_click;
self.state[idx] = s;
self.write(idx);
}
@@ -459,7 +475,7 @@ impl SteamControllerManager {
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
return;
}
match open_transport(idx as u8) {
@@ -468,10 +484,11 @@ impl SteamControllerManager {
self.state[idx] = SteamState::neutral();
self.last_rumble[idx] = (0, 0);
self.last_write[idx] = Instant::now();
self.gate.on_success();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual Steam Deck creation failed — controller input disabled");
self.broken = true;
tracing::error!(error = %format!("{e:#}"), "virtual Steam Deck creation failed — retrying with backoff");
self.gate.on_failure(Instant::now());
}
}
}
@@ -0,0 +1,122 @@
//! Shared virtual-pad creation-retry policy, used by every backend manager (Linux uinput/uhid,
//! Windows XUSB/UMDF). See [`PadGate`].
use std::time::{Duration, Instant};
/// Backoff after the first failed pad creation…
const FIRST_BACKOFF: Duration = Duration::from_secs(1);
/// …doubling on each consecutive failure, capped here so a persistently-broken host retries at most
/// this often (a negligible cost) while still self-healing within one window of the fix.
const MAX_BACKOFF: Duration = Duration::from_secs(30);
/// Create-retry gate shared by every virtual-pad manager.
///
/// Each backend used to carry a `broken: bool` that latched permanently on the FIRST pad-creation
/// error, so a single transient failure — a startup race on `/dev/uinput`, a momentary `EBUSY`, the
/// Windows companion driver not yet ready — disabled EVERY controller for the rest of the session,
/// even after the underlying cause cleared. `PadGate` replaces that latch with capped exponential
/// backoff:
///
/// * After a failure, creation is blocked only until the backoff elapses — so the manager does not
/// re-attempt (and re-log) on every one of the 60240 input frames a second — then a single
/// retry is permitted.
/// * A success clears the backoff, so the next failure starts fresh from [`FIRST_BACKOFF`].
/// * Consecutive failures widen the window, doubling up to [`MAX_BACKOFF`].
///
/// Even a genuinely broken setup (bad `/dev/uinput` permissions, missing Windows driver) therefore
/// self-heals within [`MAX_BACKOFF`] of the fix — a udev-rule reload, a driver install, the next
/// client connect — with no host restart, while costing at most one failed syscall plus one log
/// line per backoff window. The gate is manager-wide (not per slot), matching the old `broken`
/// flag: these failures are systemic (device-node permissions, absent driver), not per-controller.
#[derive(Debug, Default)]
pub struct PadGate {
/// When the current backoff ends. `None` = creation is allowed right now.
retry_at: Option<Instant>,
/// Current backoff length: `ZERO` until the first failure, then [`FIRST_BACKOFF`] doubling
/// toward [`MAX_BACKOFF`].
backoff: Duration,
}
impl PadGate {
/// A gate that permits creation immediately (no failures recorded yet).
pub fn new() -> PadGate {
PadGate::default()
}
/// May a pad be created at `now`? `true` unless a post-failure backoff is still in effect.
pub fn allow(&self, now: Instant) -> bool {
match self.retry_at {
None => true,
Some(t) => now >= t,
}
}
/// Record a successful pad creation — clear the backoff so the next failure starts fresh.
pub fn on_success(&mut self) {
self.retry_at = None;
self.backoff = Duration::ZERO;
}
/// Record a failed pad creation at `now` — arm the next retry a capped-exponential backoff out.
pub fn on_failure(&mut self, now: Instant) {
self.backoff = if self.backoff.is_zero() {
FIRST_BACKOFF
} else {
(self.backoff * 2).min(MAX_BACKOFF)
};
self.retry_at = Some(now + self.backoff);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fresh_gate_allows_creation() {
assert!(PadGate::new().allow(Instant::now()));
}
#[test]
fn failure_blocks_until_backoff_elapses_then_allows_one_retry() {
let t0 = Instant::now();
let mut g = PadGate::new();
g.on_failure(t0);
// Blocked for the whole first-backoff window…
assert!(!g.allow(t0));
assert!(!g.allow(t0 + FIRST_BACKOFF - Duration::from_millis(1)));
// …then a single retry is permitted.
assert!(g.allow(t0 + FIRST_BACKOFF));
}
#[test]
fn consecutive_failures_double_the_backoff_up_to_the_cap() {
let t0 = Instant::now();
let mut g = PadGate::new();
g.on_failure(t0); // window = 1s
g.on_failure(t0); // window = 2s
assert!(!g.allow(t0 + FIRST_BACKOFF)); // still blocked at 1s — the window is now 2s
assert!(g.allow(t0 + 2 * FIRST_BACKOFF));
// Drive well past the cap and confirm the window never exceeds MAX_BACKOFF.
for _ in 0..20 {
g.on_failure(t0);
}
assert!(!g.allow(t0 + MAX_BACKOFF - Duration::from_millis(1)));
assert!(g.allow(t0 + MAX_BACKOFF));
}
#[test]
fn success_resets_the_backoff() {
let t0 = Instant::now();
let mut g = PadGate::new();
g.on_failure(t0);
g.on_failure(t0); // window grown to 2s
g.on_success();
// Success clears the backoff: creation is immediately allowed again.
assert!(g.allow(t0));
// The next failure starts from FIRST_BACKOFF, not the grown value.
g.on_failure(t0);
assert!(!g.allow(t0 + FIRST_BACKOFF - Duration::from_millis(1)));
assert!(g.allow(t0 + FIRST_BACKOFF));
}
}
@@ -96,7 +96,7 @@ pub mod btn1 {
pub mod btn2 {
pub const PS: u8 = 0x01;
pub const TOUCHPAD: u8 = 0x02;
#[allow(dead_code)]
/// Mic-mute / capture button — set from the wire `BTN_MISC1` in `DsState::from_gamepad`.
pub const MUTE: u8 = 0x04;
}
@@ -223,6 +223,12 @@ impl DsState {
if on(gs::BTN_TOUCHPAD) {
s.buttons[2] |= btn2::TOUCHPAD;
}
// The mic-mute / capture button (Deck '…' QAM on the Steam path). Clients send it as
// BTN_MISC1; without this the DualSense mute button was inert on every PlayStation-family
// virtual pad. Rebuilt from the wire bit each frame like PS/TOUCHPAD, so no persistence gap.
if on(gs::BTN_MISC1) {
s.buttons[2] |= btn2::MUTE;
}
s
}
@@ -439,10 +445,119 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
}
}
/// Per-pad dedup for the DualSense HID-output feedback plane (0xCD). A game's DualSense output report
/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is
/// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report.
/// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger`) is deduped by
/// value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must fire).
#[derive(Clone, Default)]
pub struct HidoutDedup {
led: Option<(u8, u8, u8)>,
player_leds: Option<u8>,
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
trigger: [Option<Vec<u8>>; 2],
}
impl HidoutDedup {
/// Forget all remembered state — call when a pad is created or unplugged so the first feedback
/// after a (re)connect is always forwarded.
pub fn clear(&mut self) {
*self = HidoutDedup::default();
}
/// Whether `h` should be forwarded: `true` for a genuine change (remembering the new value) or a
/// one-shot pulse; `false` if it repeats the last-forwarded value for its kind.
pub fn should_forward(&mut self, h: &HidOutput) -> bool {
match h {
HidOutput::Led { r, g, b, .. } => {
let v = Some((*r, *g, *b));
if self.led == v {
false
} else {
self.led = v;
true
}
}
HidOutput::PlayerLeds { bits, .. } => {
let v = Some(*bits);
if self.player_leds == v {
false
} else {
self.player_leds = v;
true
}
}
HidOutput::Trigger { which, effect, .. } => {
let slot = (*which as usize).min(1);
if self.trigger[slot].as_deref() == Some(effect.as_slice()) {
false
} else {
self.trigger[slot] = Some(effect.clone());
true
}
}
// One-shot haptic pulse (Steam voice-coil) — state-less, always fires.
HidOutput::TrackpadHaptic { .. } => true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `HidoutDedup` forwards a value once, drops exact repeats, re-forwards a change, tracks the two
/// trigger sides independently, never dedups one-shot haptic pulses, and re-arms after `clear`.
#[test]
fn hidout_dedup_forwards_only_changes() {
let mut d = HidoutDedup::default();
let led = |r| HidOutput::Led {
pad: 0,
r,
g: 0,
b: 0,
};
// First value forwards; an exact repeat is dropped; a change forwards again.
assert!(d.should_forward(&led(10)));
assert!(!d.should_forward(&led(10)));
assert!(d.should_forward(&led(20)));
// Player LEDs dedup on their own field, independent of the lightbar.
let pl = |bits| HidOutput::PlayerLeds { pad: 0, bits };
assert!(d.should_forward(&pl(0b101)));
assert!(!d.should_forward(&pl(0b101)));
assert!(!d.should_forward(&led(20))); // lightbar still unchanged
// The two adaptive triggers (L2=0, R2=1) are tracked separately.
let trig = |which, byte| HidOutput::Trigger {
pad: 0,
which,
effect: vec![byte, 0, 0],
};
assert!(d.should_forward(&trig(0, 1)));
assert!(d.should_forward(&trig(1, 1))); // same bytes, other side → still forwards
assert!(!d.should_forward(&trig(0, 1)));
assert!(d.should_forward(&trig(0, 2))); // L2 effect changed
// One-shot haptic pulses are never deduped.
let haptic = HidOutput::TrackpadHaptic {
pad: 0,
side: 0,
amplitude: 1,
period: 2,
count: 3,
};
assert!(d.should_forward(&haptic));
assert!(d.should_forward(&haptic));
// `clear` re-arms every kind.
d.clear();
assert!(d.should_forward(&led(20)));
assert!(d.should_forward(&pl(0b101)));
assert!(d.should_forward(&trig(0, 2)));
}
/// 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
@@ -669,12 +784,16 @@ mod tests {
assert_eq!(r[53], 0x0A);
}
/// The wire touchpad-click bit (Moonlight's extended position) lands in `buttons[2]`.
/// The wire touchpad-click / guide / mute bits (Moonlight's extended positions) land in
/// `buttons[2]`.
#[test]
fn from_gamepad_maps_touchpad_click() {
use punktfunk_core::input::gamepad as gs;
let s = DsState::from_gamepad(gs::BTN_TOUCHPAD | gs::BTN_GUIDE, 0, 0, 0, 0, 0, 0);
assert_eq!(s.buttons[2], btn2::PS | btn2::TOUCHPAD);
// BTN_MISC1 → the mic-mute / capture button (G6: was previously dropped entirely).
let s = DsState::from_gamepad(gs::BTN_MISC1, 0, 0, 0, 0, 0, 0);
assert_eq!(s.buttons[2], btn2::MUTE);
let s = DsState::from_gamepad(gs::BTN_A, 0, 0, 0, 0, 0, 0);
assert_eq!(s.buttons[2], 0);
}
@@ -156,6 +156,15 @@ pub struct SteamState {
/// (with Z/RZ negated) on the separate sensors evdev.
pub accel: [i16; 3],
pub gyro: [i16; 3],
/// Trackpad CLICK from the rich plane ([`apply_rich`]), kept OUTSIDE `buttons` because
/// [`SteamControllerManager::handle`](super::super::linux::steam_controller::SteamControllerManager)
/// rebuilds `buttons` from the gamepad frame every tick — exactly why DualSense keeps
/// `touch_click` separate. Merged into the report's click bits in [`serialize_deck_state`]. The
/// DualSense touchpad-click WIRE button still sets `RPAD_CLICK` in `buttons` via
/// [`from_gamepad`](Self::from_gamepad); the two sources are OR'd at serialize, so each releases
/// independently (a released `BTN_TOUCHPAD` can't strand a rich click, and vice-versa).
pub lpad_click: bool,
pub rpad_click: bool,
}
impl SteamState {
@@ -273,12 +282,14 @@ impl SteamState {
// 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);
// Click lives in its own field, NOT `buttons` — `handle()` rebuilds `buttons`
// every gamepad frame and would otherwise wipe a held click (the bug this fixes).
self.lpad_click = click;
self.lpad_x = x;
self.lpad_y = flip_y(y);
} else {
self.press(btn::RPAD_TOUCH, touch);
self.press(btn::RPAD_CLICK, click);
self.rpad_click = click;
self.rpad_x = x;
self.rpad_y = flip_y(y);
}
@@ -297,7 +308,18 @@ pub fn serialize_deck_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq
r[2] = ID_CONTROLLER_DECK_STATE;
r[3] = 0x3C; // payload length; the kernel ignores it
r[4..8].copy_from_slice(&seq.to_le_bytes());
r[8..16].copy_from_slice(&st.buttons.to_le_bytes()); // bytes 8..16 (12+15 stay 0)
// Rich-plane trackpad clicks live in their own fields (see `SteamState`) so a button-only frame
// can't wipe them; merge them into the report's click bits here. RPAD_CLICK may ALSO come from
// the DualSense touchpad-click wire button via `from_gamepad` — OR both, so either source lights
// it and each releases independently.
let mut buttons = st.buttons;
if st.lpad_click {
buttons |= btn::LPAD_CLICK;
}
if st.rpad_click {
buttons |= btn::RPAD_CLICK;
}
r[8..16].copy_from_slice(&buttons.to_le_bytes()); // bytes 8..16 (12+15 stay 0)
r[16..18].copy_from_slice(&st.lpad_x.to_le_bytes());
r[18..20].copy_from_slice(&st.lpad_y.to_le_bytes());
r[20..22].copy_from_slice(&st.rpad_x.to_le_bytes());
@@ -611,7 +633,9 @@ mod tests {
pressure: 100,
});
assert_ne!(s.buttons & btn::LPAD_TOUCH, 0);
assert_ne!(s.buttons & btn::LPAD_CLICK, 0);
// Click now rides its own field (kept OUT of `buttons`, which handle() rebuilds each frame).
assert!(s.lpad_click);
assert_eq!(s.buttons & btn::LPAD_CLICK, 0);
assert_eq!((s.lpad_x, s.lpad_y), (-5000, -6000));
s.apply_rich(RichInput::TouchpadEx {
pad: 0,
@@ -624,6 +648,7 @@ mod tests {
pressure: 0,
});
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0);
assert!(!s.rpad_click); // click:false → field cleared
assert_eq!((s.rpad_x, s.rpad_y), (7000, 8000));
// The i16 edge: wire y = -32768 (top-most) must clamp, not overflow.
@@ -640,6 +665,34 @@ mod tests {
assert_eq!(s.rpad_y, 32767);
}
/// Regression (G2): a held trackpad click set on the rich plane must survive the per-frame
/// `buttons` rebuild that `SteamControllerManager::handle` performs via `from_gamepad`. Before
/// the fix, click lived in `buttons` and the rebuild wiped it every gamepad frame.
#[test]
fn rich_click_survives_a_buttons_rebuild() {
let mut held = SteamState::neutral();
held.apply_rich(RichInput::TouchpadEx {
pad: 0,
surface: 1,
finger: 0,
touch: true,
click: true,
x: 0,
y: 0,
pressure: 0,
});
assert!(held.lpad_click);
// A following button-only frame: from_gamepad rebuilds buttons (dropping the click bit),
// then handle() carries the rich fields over — the click must still reach the report.
let mut merged = SteamState::from_gamepad(0, 0, 0, 0, 0, 0, 0);
assert_eq!(merged.buttons & btn::LPAD_CLICK, 0); // the rebuild alone loses it (the old bug)
merged.lpad_click = held.lpad_click; // what handle() now preserves
let mut r = [0u8; STEAM_REPORT_LEN];
serialize_deck_state(&mut r, &merged, 0);
let serialized = u64::from_le_bytes(r[8..16].try_into().unwrap());
assert_ne!(serialized & btn::LPAD_CLICK, 0); // click lands in the report despite the rebuild
}
/// The serial reply carries the leading report-id byte the kernel strips, so the *stripped*
/// view (`reply[1..]`) is what `steam_get_serial` validates: `[0xAE, len, 0x01, ascii…]`.
#[test]
@@ -18,21 +18,23 @@
//! must already be installed; the installer stages it.)
use super::dualsense_proto::{
parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H,
DS_TOUCH_W,
parse_ds_output, serialize_state, DsFeedback, DsState, HidoutDedup, DS_INPUT_REPORT_LEN,
DS_TOUCH_H, DS_TOUCH_W,
};
use super::gamepad_raii::PadChannel;
use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx};
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use crate::inject::pad_gate::PadGate;
use anyhow::{anyhow, Result};
use punktfunk_core::quic::{HidOutput, RichInput};
use std::ffi::c_void;
use std::sync::atomic::{fence, AtomicU32, Ordering};
use std::time::{Duration, Instant};
use windows::core::{w, GUID, HRESULT, PCWSTR};
use windows::core::{w, GUID, PCWSTR};
use windows::Win32::Devices::Enumeration::Pnp::{
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
};
use windows::Win32::Foundation::{CloseHandle, E_FAIL, HANDLE, WAIT_OBJECT_0};
use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject};
use windows::Win32::Foundation::{CloseHandle, E_FAIL, WAIT_OBJECT_0};
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
/// Shared-section layout — the single source of truth is [`pf_driver_proto::gamepad::PadShm`] (offset
/// asserts pin every field; the `pf_dualsense` driver maps the same struct). Derive the size/offsets/magic
@@ -71,50 +73,6 @@ struct DsWinPad {
last_out_seq: u32,
}
/// Context for the `SwDeviceCreate` completion callback: an event to signal, the HRESULT it reports,
/// and the PnP instance id PnP assigned (captured for devnode health diagnostics).
#[repr(C)]
struct SwCreateCtx {
event: HANDLE,
result: HRESULT,
instance_id: [u16; 128],
}
/// `SwDeviceCreate` fires this once PnP has enumerated the device; stash the result and wake the
/// creator, which blocks on the event (so there's no concurrent access to `*ctx`).
unsafe extern "system" fn sw_create_cb(
_dev: HSWDEVICE,
result: HRESULT,
ctx: *const c_void,
id: PCWSTR,
) {
if !ctx.is_null() {
// SAFETY: ctx is the &mut SwCreateCtx the creator passed; it outlives this callback (the
// creator blocks on the event). `id` is a NUL-terminated string for the callback's duration.
unsafe {
let c = ctx as *mut SwCreateCtx;
(*c).result = result;
if !id.is_null() {
for i in 0..(*c).instance_id.len() - 1 {
let ch = *id.0.add(i);
(*c).instance_id[i] = ch;
if ch == 0 {
break;
}
}
}
let _ = SetEvent((*c).event);
}
}
}
impl SwCreateCtx {
fn instance_id(&self) -> Option<String> {
let len = self.instance_id.iter().position(|&c| c == 0)?;
(len > 0).then(|| String::from_utf16_lossy(&self.instance_id[..len]))
}
}
/// The PnP identity for a virtual controller devnode — varies by controller type so the same
/// [`create_swdevice`] builds a DualSense (`VID_054C&PID_0CE6`) or a DualShock 4
/// (`VID_054C&PID_09CC`). The fields map onto the `SW_DEVICE_CREATE_INFO` identity discussed below.
@@ -334,13 +292,24 @@ impl DsWinPad {
self.ts = self.ts.wrapping_add(1);
let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, st, self.seq, self.ts);
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. Unlike the
// XUSB `packet` / DualSense `out_seq` fields, the input path has NO driver-polled change-detect
// field to publish last: the `pf_dualsense` driver streams the whole `input` region to game
// READ_REPORTs on its ~125 Hz timer, and the report's own sequence counter (r[7], mid-report)
// is consumed by the game's HID stack, not the driver — so it cannot serve as a separable
// publish flag without a seqlock generation the driver `Acquire`-reads (a `PadShm` layout +
// driver change, deferred). The `Release` fence after the copy orders the report-body stores
// ahead of this pad's next `Release` publish (the bootstrap/seq stores in `channel.pump()`),
// giving the copy Release visibility on a weakly-ordered core (ARM64); on x86-TSO it is a
// no-op. Residual: absent a driver-side `Acquire` on a per-frame input generation, a torn
// single frame is still theoretically possible but self-heals on the next ~250 Hz write.
unsafe {
std::ptr::copy_nonoverlapping(
r.as_ptr(),
self.channel.data_base().add(OFF_INPUT),
r.len(),
)
);
fence(Ordering::Release);
};
}
@@ -356,9 +325,14 @@ impl DsWinPad {
std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
};
self.attach.observe(proto);
// SAFETY: base points at SHM_SIZE bytes.
// SAFETY: base points at SHM_SIZE bytes; `OFF_OUT_SEQ` (== 72) is 4-aligned off the
// page-aligned base, so the `AtomicU32` view is valid. The driver bumps `out_seq` AFTER
// writing the `output` report, so an `Acquire` load here orders the `output` copy below after
// it — a fresh seq guarantees a coherent snapshot of the output bytes on a weakly-ordered core
// (ARM64). On x86-TSO it is a plain load.
let seq = unsafe {
std::ptr::read_unaligned(self.channel.data_base().add(OFF_OUT_SEQ) as *const u32)
(*(self.channel.data_base().add(OFF_OUT_SEQ) as *const AtomicU32))
.load(Ordering::Acquire)
};
if seq != self.last_out_seq {
self.last_out_seq = seq;
@@ -384,8 +358,16 @@ pub struct DualSenseWindowsManager {
pads: Vec<Option<DsWinPad>>,
state: Vec<DsState>,
last_rumble: Vec<(u16, u16)>,
/// Last rich feedback (lightbar / player LEDs / adaptive triggers) forwarded per pad, so an
/// output report that only changed the rumble doesn't re-send unchanged 0xCD feedback.
hidout_dedup: Vec<HidoutDedup>,
last_write: Vec<Instant>,
broken: bool,
/// Create-retry gate: a transient UMDF-channel failure backs off and retries instead of
/// permanently disabling every pad for the session.
gate: PadGate,
/// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button
/// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. Parity with `linux/dualsense.rs`.
remap: crate::inject::steam_remap::RemapConfig,
}
impl Default for DualSenseWindowsManager {
@@ -400,8 +382,10 @@ impl DualSenseWindowsManager {
pads: (0..MAX_PADS).map(|_| None).collect(),
state: vec![DsState::neutral(); MAX_PADS],
last_rumble: vec![(0, 0); MAX_PADS],
hidout_dedup: vec![HidoutDedup::default(); MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
broken: false,
gate: PadGate::new(),
remap: crate::inject::steam_remap::RemapConfig::from_env(),
}
}
@@ -423,6 +407,7 @@ impl DualSenseWindowsManager {
*slot = None;
self.state[i] = DsState::neutral();
self.last_rumble[i] = (0, 0);
self.hidout_dedup[i].clear();
}
}
if f.active_mask & (1 << idx) == 0 {
@@ -430,8 +415,13 @@ impl DualSenseWindowsManager {
}
self.ensure(idx);
let prev = self.state[idx];
// Steam back grips have no DualSense slot — fold them onto standard buttons per the
// configured policy (default drop) so they aren't silently lost, exactly as
// `linux/dualsense.rs` does.
let buttons =
crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
let mut s = DsState::from_gamepad(
f.buttons,
buttons,
f.ls_x,
f.ls_y,
f.rs_x,
@@ -486,7 +476,7 @@ impl DualSenseWindowsManager {
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
return;
}
match DsWinPad::open(idx as u8) {
@@ -498,11 +488,13 @@ impl DualSenseWindowsManager {
self.pads[idx] = Some(p);
self.state[idx] = DsState::neutral();
self.last_rumble[idx] = (0, 0);
self.hidout_dedup[idx].clear();
self.last_write[idx] = Instant::now();
self.gate.on_success();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — controller input disabled until the next client connect (install/repair: punktfunk-host.exe driver install --gamepad)");
self.broken = true;
tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — retrying with backoff (install/repair: punktfunk-host.exe driver install --gamepad)");
self.gate.on_failure(Instant::now());
}
}
}
@@ -527,7 +519,11 @@ impl DualSenseWindowsManager {
}
}
for h in fb.hidout {
hidout(h);
// Skip rich feedback that repeats the last-forwarded value (the game's output report
// re-sends unchanged lightbar/LED/trigger state alongside every rumble update).
if self.hidout_dedup[i].should_forward(&h) {
hidout(h);
}
}
}
}
@@ -17,6 +17,7 @@ use super::dualshock4_proto::{
};
use super::gamepad_raii::PadChannel;
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use crate::inject::pad_gate::PadGate;
use anyhow::Result;
use punktfunk_core::quic::{HidOutput, RichInput};
use std::time::{Duration, Instant};
@@ -149,7 +150,12 @@ pub struct DualShock4WindowsManager {
last_rumble: Vec<(u16, u16)>,
last_led: Vec<Option<(u8, u8, u8)>>,
last_write: Vec<Instant>,
broken: bool,
/// Create-retry gate: a transient UMDF-channel failure backs off and retries instead of
/// permanently disabling every pad for the session.
gate: PadGate,
/// Fallback policy for the Steam back grips a client may send (the DS4 has no back-button HID
/// slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. Parity with `linux/dualshock4.rs`.
remap: crate::inject::steam_remap::RemapConfig,
}
impl Default for DualShock4WindowsManager {
@@ -166,7 +172,8 @@ impl DualShock4WindowsManager {
last_rumble: vec![(0, 0); MAX_PADS],
last_led: vec![None; MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
broken: false,
gate: PadGate::new(),
remap: crate::inject::steam_remap::RemapConfig::from_env(),
}
}
@@ -196,8 +203,13 @@ impl DualShock4WindowsManager {
}
self.ensure(idx);
let prev = self.state[idx];
// Steam back grips have no DS4 slot — fold them onto standard buttons per the
// configured policy (default drop) so they aren't silently lost, exactly as
// `linux/dualshock4.rs` does.
let buttons =
crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
let mut s = DsState::from_gamepad(
f.buttons,
buttons,
f.ls_x,
f.ls_y,
f.rs_x,
@@ -251,7 +263,7 @@ impl DualShock4WindowsManager {
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
return;
}
match Ds4WinPad::open(idx as u8) {
@@ -265,10 +277,11 @@ impl DualShock4WindowsManager {
self.last_rumble[idx] = (0, 0);
self.last_led[idx] = None;
self.last_write[idx] = Instant::now();
self.gate.on_success();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — controller input disabled until the next client connect (install/repair: punktfunk-host.exe driver install --gamepad)");
self.broken = true;
tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — retrying with backoff (install/repair: punktfunk-host.exe driver install --gamepad)");
self.gate.on_failure(Instant::now());
}
}
}
@@ -22,19 +22,20 @@
use anyhow::{anyhow, bail, Context, Result};
use pf_driver_proto::gamepad::{PadBootstrap, BOOT_MAGIC, GAMEPAD_PROTO_VERSION};
use std::ffi::c_void;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::sync::atomic::{fence, AtomicU32, AtomicU64, Ordering};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use windows::core::{w, HSTRING, PCWSTR};
use windows::core::{w, HRESULT, HSTRING, PCWSTR};
use windows::Win32::Devices::DeviceAndDriverInstallation::{
CM_Get_DevNode_Status, CM_Locate_DevNodeW, CM_DEVNODE_STATUS_FLAGS, CM_LOCATE_DEVNODE_NORMAL,
CM_PROB, CR_SUCCESS, DN_DRIVER_LOADED, DN_HAS_PROBLEM, DN_STARTED,
};
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE};
use windows::Win32::Foundation::{
DuplicateHandle, GetLastError, SetLastError, DUPLICATE_HANDLE_OPTIONS, ERROR_ALREADY_EXISTS,
HANDLE, INVALID_HANDLE_VALUE, WIN32_ERROR,
DuplicateHandle, GetLastError, LocalFree, SetLastError, DUPLICATE_HANDLE_OPTIONS,
ERROR_ALREADY_EXISTS, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, WIN32_ERROR,
};
use windows::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
@@ -45,7 +46,7 @@ use windows::Win32::System::Memory::{
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
};
use windows::Win32::System::Threading::{
GetCurrentProcess, OpenProcess, PROCESS_DUP_HANDLE, PROCESS_QUERY_LIMITED_INFORMATION,
GetCurrentProcess, OpenProcess, SetEvent, PROCESS_DUP_HANDLE, PROCESS_QUERY_LIMITED_INFORMATION,
};
/// Least access the pad driver needs on the duplicated DATA section: it only MAPS it read/write, so
@@ -65,11 +66,37 @@ pub(super) struct Shm {
view: MEMORY_MAPPED_VIEW_ADDRESS,
}
/// Build a `SECURITY_ATTRIBUTES` from an SDDL literal (`psd` is OS-allocated and leaked — acceptable
/// for the handful of pad channels a host creates; it must outlive the returned `SECURITY_ATTRIBUTES`).
fn sddl_sa(sddl: PCWSTR) -> Result<SECURITY_ATTRIBUTES> {
/// Owns an SDDL-derived `SECURITY_ATTRIBUTES` **and** the OS-allocated security descriptor its
/// `lpSecurityDescriptor` points at (`ConvertStringSecurityDescriptorToSecurityDescriptorW`
/// `LocalAlloc`s the descriptor). Drop `LocalFree`s it, so a `SecAttr` must outlive every
/// `CreateFileMappingW` that borrows its `sa`: the section copies the security info at create time, so
/// freeing after the create returns is safe — hence [`Shm::create_named`] builds one `SecAttr` before
/// its squat-retry loop and reuses it across attempts instead of re-allocating (and re-leaking) per
/// attempt.
struct SecAttr {
sa: SECURITY_ATTRIBUTES,
psd: PSECURITY_DESCRIPTOR,
}
impl Drop for SecAttr {
fn drop(&mut self) {
// SAFETY: `psd` is the descriptor `ConvertStringSecurityDescriptorToSecurityDescriptorW`
// allocated for us with `LocalAlloc`; release it with the matching `LocalFree`. Every
// `CreateFileMappingW` that borrowed `self.sa` has already returned (so has copied the
// security info into its section object), so no live `SECURITY_ATTRIBUTES` still points here.
unsafe {
let _ = LocalFree(Some(HLOCAL(self.psd.0)));
}
}
}
/// Build a [`SecAttr`] from an SDDL literal — a `SECURITY_ATTRIBUTES` plus the descriptor it borrows,
/// freed together on drop. The returned owner must outlive every `CreateFileMappingW` that borrows
/// its `sa` (see [`SecAttr`]).
fn sddl_sa(sddl: PCWSTR) -> Result<SecAttr> {
let mut psd = PSECURITY_DESCRIPTOR::default();
// SAFETY: the SDDL literal is valid; `psd` receives an OS-allocated descriptor (leaked — see above).
// SAFETY: the SDDL literal is valid; `psd` receives a `LocalAlloc`'d descriptor that `SecAttr`'s
// `Drop` `LocalFree`s once the section create that borrows it has returned.
unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
sddl,
@@ -78,10 +105,13 @@ fn sddl_sa(sddl: PCWSTR) -> Result<SECURITY_ATTRIBUTES> {
None,
)?;
}
Ok(SECURITY_ATTRIBUTES {
nLength: core::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: psd.0,
bInheritHandle: false.into(),
Ok(SecAttr {
sa: SECURITY_ATTRIBUTES {
nLength: core::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: psd.0,
bInheritHandle: false.into(),
},
psd,
})
}
@@ -93,7 +123,9 @@ impl Shm {
/// validated on-glass — `design/idd-push-security.md`).
pub(super) fn create_unnamed(size: usize) -> Result<Shm> {
let sa = sddl_sa(w!("D:P(A;;GA;;;SY)"))?;
Self::create_inner(&sa, PCWSTR::null(), size).context("create unnamed gamepad DATA section")
// `sa` owns the descriptor and lives to the end of this fn, so it outlives the create.
Self::create_inner(&sa.sa, PCWSTR::null(), size)
.context("create unnamed gamepad DATA section")
}
/// Create + zero a **named** `size`-byte section, mapped read/write — the bootstrap mailbox. SDDL
@@ -106,6 +138,8 @@ impl Shm {
/// poll tick), then fail loudly rather than run the handshake through an attacker-owned (or
/// another host instance's) mailbox.
pub(super) fn create_named(name: &HSTRING, size: usize) -> Result<Shm> {
// Build the descriptor ONCE and reuse it across the squat-retry loop — it (and the OS
// allocation it owns) lives to the end of this fn, so it outlives every create below.
let sa = sddl_sa(w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"))?;
for attempt in 0..5 {
if attempt > 0 {
@@ -113,7 +147,7 @@ impl Shm {
}
// SAFETY: clearing the thread error slot so ERROR_ALREADY_EXISTS below is unambiguous.
unsafe { SetLastError(WIN32_ERROR(0)) };
let shm = Self::create_inner(&sa, PCWSTR(name.as_ptr()), size)
let shm = Self::create_inner(&sa.sa, PCWSTR(name.as_ptr()), size)
.with_context(|| format!("create gamepad bootstrap mailbox {name}"))?;
// SAFETY: read immediately after the create; windows-rs only touches the error slot on
// failure, so a success here preserves CreateFileMappingW's ALREADY_EXISTS signal.
@@ -131,7 +165,8 @@ impl Shm {
fn create_inner(sa: &SECURITY_ATTRIBUTES, name: PCWSTR, size: usize) -> Result<Shm> {
// SAFETY: an anonymous (pagefile-backed) section of `size` bytes with the caller's SDDL; the
// descriptor behind `sa` outlives this call (leaked by `sddl_sa`).
// descriptor behind `sa` outlives this call (owned by the caller's `SecAttr`, freed only once
// every create that borrows it has returned).
let map = unsafe {
CreateFileMappingW(
INVALID_HANDLE_VALUE,
@@ -403,6 +438,53 @@ impl PadChannel {
}
}
/// Context for the `SwDeviceCreate` completion callback: an event to signal, the HRESULT it reports,
/// and the PnP instance id PnP assigned (captured for devnode health diagnostics). Shared by every
/// Windows companion backend (XUSB / DualSense / DS4): each `create_swdevice` builds one, hands it to
/// `SwDeviceCreate` alongside [`sw_create_cb`], and reads [`instance_id`](Self::instance_id) once the
/// callback has signalled.
#[repr(C)]
pub(super) struct SwCreateCtx {
pub(super) event: HANDLE,
pub(super) result: HRESULT,
pub(super) instance_id: [u16; 128],
}
/// `SwDeviceCreate` fires this once PnP has enumerated the device; stash the result and wake the
/// creator, which blocks on the event (so there's no concurrent access to `*ctx`).
pub(super) unsafe extern "system" fn sw_create_cb(
_dev: HSWDEVICE,
result: HRESULT,
ctx: *const c_void,
id: PCWSTR,
) {
if !ctx.is_null() {
// SAFETY: ctx is the &mut SwCreateCtx the creator passed; it outlives this callback (the
// creator blocks on the event). `id` is a NUL-terminated string for the callback's duration.
unsafe {
let c = ctx as *mut SwCreateCtx;
(*c).result = result;
if !id.is_null() {
for i in 0..(*c).instance_id.len() - 1 {
let ch = *id.0.add(i);
(*c).instance_id[i] = ch;
if ch == 0 {
break;
}
}
}
let _ = SetEvent((*c).event);
}
}
}
impl SwCreateCtx {
pub(super) fn instance_id(&self) -> Option<String> {
let len = self.instance_id.iter().position(|&c| c == 0)?;
(len > 0).then(|| String::from_utf16_lossy(&self.instance_id[..len]))
}
}
/// A `SwDeviceCreate`'d software devnode; drop removes it via `SwDeviceClose`. Replaces the manual
/// `SwDeviceClose` each backend used to call in its `Drop`.
pub(super) struct SwDevice(HSWDEVICE);
@@ -12,17 +12,19 @@
//! parses the `SET_STATE` packet into the shared section, and [`GamepadManager::pump_rumble`] relays
//! level changes to the client (the universal 0xCA plane), mirroring the Linux `EV_FF` read path.
use super::gamepad_raii::PadChannel;
use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx};
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use crate::inject::pad_gate::PadGate;
use anyhow::{anyhow, Result};
use std::ffi::c_void;
use std::sync::atomic::{fence, AtomicU32, Ordering};
use std::time::{Duration, Instant};
use windows::core::{w, GUID, HRESULT, PCWSTR};
use windows::core::{w, GUID, PCWSTR};
use windows::Win32::Devices::Enumeration::Pnp::{
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
};
use windows::Win32::Foundation::{CloseHandle, E_FAIL, HANDLE, WAIT_OBJECT_0};
use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject};
use windows::Win32::Foundation::{CloseHandle, E_FAIL, WAIT_OBJECT_0};
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
// Shared-section layout — the single source of truth is `pf_driver_proto::gamepad::XusbShm` (offset
// asserts pin every field; the `pf_xusb` driver maps the same struct). Derive the size/offsets/magic from
@@ -43,49 +45,6 @@ const OFF_RUMBLE: usize = core::mem::offset_of!(XusbShm, rumble_large); // large
const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(XusbShm, driver_proto);
const OFF_PAD_INDEX: usize = core::mem::offset_of!(XusbShm, pad_index);
/// Context for the `SwDeviceCreate` completion callback: an event to signal, the HRESULT it reports,
/// and the PnP instance id PnP assigned (captured for devnode health diagnostics).
#[repr(C)]
struct SwCreateCtx {
event: HANDLE,
result: HRESULT,
instance_id: [u16; 128],
}
/// `SwDeviceCreate` fires this once PnP has enumerated the device; stash the result + wake the creator.
unsafe extern "system" fn sw_create_cb(
_dev: HSWDEVICE,
result: HRESULT,
ctx: *const c_void,
id: PCWSTR,
) {
if !ctx.is_null() {
// SAFETY: ctx is the &mut SwCreateCtx the creator passed; it outlives this callback (the
// creator blocks on the event). `id` is a NUL-terminated string for the callback's duration.
unsafe {
let c = ctx as *mut SwCreateCtx;
(*c).result = result;
if !id.is_null() {
for i in 0..(*c).instance_id.len() - 1 {
let ch = *id.0.add(i);
(*c).instance_id[i] = ch;
if ch == 0 {
break;
}
}
}
let _ = SetEvent((*c).event);
}
}
}
impl SwCreateCtx {
fn instance_id(&self) -> Option<String> {
let len = self.instance_id.iter().position(|&c| c == 0)?;
(len > 0).then(|| String::from_utf16_lossy(&self.instance_id[..len]))
}
}
/// Spawn the `pf_xusb_<index>` companion devnode (hardware id `pf_xusb`, enumerator `punktfunk`). The
/// INF (System class) binds our UMDF driver, which registers the XUSB interface. Unlike the HID pads,
/// no USB compatible-ids are needed — XInput finds the device by the interface GUID, not VID/PID — but
@@ -235,7 +194,13 @@ impl XusbWinPad {
let base = self.channel.data_base();
// SAFETY: `base` is the start of the mapped section (`SHM_SIZE` bytes, owned by `Shm`); every
// `OFF_*` is a fixed in-range offset into it and `write_unaligned` handles the unaligned field
// writes. Single owner (`&mut self`), so no concurrent writer races these stores.
// writes. Single owner (`&mut self`), so no concurrent writer races these stores. `packet` (the
// field XInput reads to detect a new state) is published LAST: the `Release` fence orders the
// state-body stores above before the `Release` `AtomicU32` store of `packet`, so the driver —
// which `Acquire`-loads `packet` — never observes a bumped packet over a torn body on a
// weakly-ordered core (ARM64). On x86-TSO both are plain stores. `OFF_PACKET` (== 4) is
// 4-aligned off the page-aligned section base, so the `AtomicU32` view is valid (mirrors the
// seq-fenced publish in `gamepad_raii::PadChannel::create`).
unsafe {
std::ptr::write_unaligned(base.add(OFF_BUTTONS) as *mut u16, buttons);
*base.add(OFF_LT) = lt;
@@ -244,7 +209,8 @@ impl XusbWinPad {
std::ptr::write_unaligned(base.add(OFF_LY) as *mut i16, ly);
std::ptr::write_unaligned(base.add(OFF_RX) as *mut i16, rx);
std::ptr::write_unaligned(base.add(OFF_RY) as *mut i16, ry);
std::ptr::write_unaligned(base.add(OFF_PACKET) as *mut u32, self.packet);
fence(Ordering::Release);
(*(base.add(OFF_PACKET) as *const AtomicU32)).store(self.packet, Ordering::Release);
}
}
@@ -258,8 +224,13 @@ impl XusbWinPad {
// SAFETY: base points at SHM_SIZE bytes.
let proto = unsafe { std::ptr::read_unaligned(base.add(OFF_DRIVER_PROTO) as *const u32) };
self.attach.observe(proto);
// SAFETY: base points at SHM_SIZE bytes.
let seq = unsafe { std::ptr::read_unaligned(base.add(OFF_RUMBLE_SEQ) as *const u32) };
// SAFETY: base points at SHM_SIZE bytes; `OFF_RUMBLE_SEQ` (== 24) is 4-aligned off the
// page-aligned base, so the `AtomicU32` view is valid. The driver bumps `rumble_seq` AFTER
// writing the rumble bytes, so an `Acquire` load here orders the `rumble_large`/`rumble_small`
// reads below after it — a fresh seq guarantees a coherent snapshot of the rumble bytes on a
// weakly-ordered core (ARM64). On x86-TSO it is a plain load.
let seq =
unsafe { (*(base.add(OFF_RUMBLE_SEQ) as *const AtomicU32)).load(Ordering::Acquire) };
if seq == self.last_rumble_seq {
return None;
}
@@ -291,7 +262,9 @@ pub struct GamepadManager {
/// `last_rumble` older than [`RUMBLE_IDLE_TIMEOUT`] against this is a stale residual — see the
/// const's docs.
last_active: Vec<Instant>,
broken: bool,
/// Create-retry gate: a transient XUSB-companion failure backs off and retries instead of
/// permanently disabling every pad for the session.
gate: PadGate,
}
impl Default for GamepadManager {
@@ -306,12 +279,12 @@ impl GamepadManager {
pads: (0..MAX_PADS).map(|_| None).collect(),
last_rumble: vec![(0, 0); MAX_PADS],
last_active: (0..MAX_PADS).map(|_| Instant::now()).collect(),
broken: false,
gate: PadGate::new(),
}
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
return;
}
match XusbWinPad::open(idx as u8) {
@@ -322,44 +295,52 @@ impl GamepadManager {
);
self.pads[idx] = Some(p);
self.last_rumble[idx] = (0, 0);
self.last_active[idx] = Instant::now();
self.gate.on_success();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual Xbox 360 creation failed — controller input disabled until the next client connect (install/repair: punktfunk-host.exe driver install --gamepad)");
self.broken = true;
tracing::error!(error = %format!("{e:#}"), "virtual Xbox 360 creation failed — retrying with backoff (install/repair: punktfunk-host.exe driver install --gamepad)");
self.gate.on_failure(Instant::now());
}
}
}
pub fn handle(&mut self, ev: &GamepadEvent) {
let GamepadEvent::State(f) = ev else {
return; // Arrival metadata — the pad is created lazily on the first State
};
let idx = f.index.max(0) as usize;
if idx >= MAX_PADS {
return;
}
// Unplugs: drop any allocated pad whose mask bit cleared.
for (i, slot) in self.pads.iter_mut().enumerate() {
if slot.is_some() && f.active_mask & (1 << i) == 0 {
tracing::info!(index = i, "controller unplugged (Xbox 360/Windows)");
*slot = None;
self.last_rumble[i] = (0, 0);
match ev {
GamepadEvent::Arrival { index, kind, .. } => {
tracing::info!(index, kind, "controller arrival (Xbox 360/Windows)");
self.ensure(*index as usize);
}
GamepadEvent::State(f) => {
let idx = f.index.max(0) as usize;
if idx >= MAX_PADS {
return;
}
// Unplugs: drop any allocated pad whose mask bit cleared.
for (i, slot) in self.pads.iter_mut().enumerate() {
if slot.is_some() && f.active_mask & (1 << i) == 0 {
tracing::info!(index = i, "controller unplugged (Xbox 360/Windows)");
*slot = None;
self.last_rumble[i] = (0, 0);
self.last_active[i] = Instant::now();
}
}
if f.active_mask & (1 << idx) == 0 {
return;
}
self.ensure(idx);
if let Some(pad) = self.pads[idx].as_mut() {
pad.write_state(
(f.buttons & 0xffff) as u16,
f.left_trigger,
f.right_trigger,
f.ls_x,
f.ls_y,
f.rs_x,
f.rs_y,
);
}
}
}
if f.active_mask & (1 << idx) == 0 {
return;
}
self.ensure(idx);
if let Some(pad) = self.pads[idx].as_mut() {
pad.write_state(
(f.buttons & 0xffff) as u16,
f.left_trigger,
f.right_trigger,
f.ls_x,
f.ls_y,
f.rs_x,
f.rs_y,
);
}
}
+47 -4
View File
@@ -5335,11 +5335,54 @@ mod tests {
assert!(s.apply(&gp(InputKind::GamepadAxis, AXIS_LT, 9_999, 0)));
assert_eq!(s.left_trigger, 255);
assert!(!s.apply(&gp(InputKind::GamepadAxis, 42, 1, 0)));
}
// The punktfunk/1 button bits are the GameStream bits — one wire contract end to end.
assert_eq!(BTN_A, crate::gamestream::gamepad::BTN_A);
assert_eq!(BTN_GUIDE, crate::gamestream::gamepad::BTN_GUIDE);
assert_eq!(BTN_DPAD_UP, crate::gamestream::gamepad::BTN_DPAD_UP);
/// Freeze the gamepad wire contract: every button bit + axis id pinned to its exact value, read
/// through the GameStream namespace (`crate::gamestream::gamepad`, which re-exports
/// `punktfunk_core::input::gamepad` — the punktfunk/1 native wire and the GameStream/Limelight
/// wire are one and the same). Renumbering a bit in core, or dropping one from that re-export,
/// silently breaks every already-shipped client, so it must fail here first. This is the host
/// counterpart to the client-side C-ABI cross-checks in the Apple/Android gamepad tests.
#[test]
fn gamepad_wire_bits_are_pinned() {
use crate::gamestream::gamepad as gm;
use punktfunk_core::input::gamepad as pf;
// buttonFlags — low 16 bits, named via the GameStream re-export the injectors use.
assert_eq!(gm::BTN_DPAD_UP, 0x0000_0001);
assert_eq!(gm::BTN_DPAD_DOWN, 0x0000_0002);
assert_eq!(gm::BTN_DPAD_LEFT, 0x0000_0004);
assert_eq!(gm::BTN_DPAD_RIGHT, 0x0000_0008);
assert_eq!(gm::BTN_START, 0x0000_0010);
assert_eq!(gm::BTN_BACK, 0x0000_0020);
assert_eq!(gm::BTN_LS_CLICK, 0x0000_0040);
assert_eq!(gm::BTN_RS_CLICK, 0x0000_0080);
assert_eq!(gm::BTN_LB, 0x0000_0100);
assert_eq!(gm::BTN_RB, 0x0000_0200);
assert_eq!(gm::BTN_GUIDE, 0x0000_0400);
assert_eq!(gm::BTN_A, 0x0000_1000);
assert_eq!(gm::BTN_B, 0x0000_2000);
assert_eq!(gm::BTN_X, 0x0000_4000);
assert_eq!(gm::BTN_Y, 0x0000_8000);
// buttonFlags2 — high 16 bits: back-grip paddles (re-exported), plus the touchpad-click /
// Share bits the DualSense/DS4 protos consume straight from core.
assert_eq!(gm::BTN_PADDLE1, 0x0001_0000);
assert_eq!(gm::BTN_PADDLE2, 0x0002_0000);
assert_eq!(gm::BTN_PADDLE3, 0x0004_0000);
assert_eq!(gm::BTN_PADDLE4, 0x0008_0000);
assert_eq!(pf::BTN_TOUCHPAD, 0x0010_0000);
assert_eq!(pf::BTN_MISC1, 0x0020_0000);
// Axis ids — dense, 0-based.
assert_eq!(
[
pf::AXIS_LS_X,
pf::AXIS_LS_Y,
pf::AXIS_RS_X,
pf::AXIS_RS_Y,
pf::AXIS_LT,
pf::AXIS_RT,
],
[0, 1, 2, 3, 4, 5]
);
}
/// Pull and byte-verify `count` synthetic frames through the C ABI connection.
+20 -4
View File
@@ -77,10 +77,26 @@ 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 || :
# vhci-hcd: the usbip transport that makes the virtual Steam Deck pad a real USB device Steam
# Input adopts. Without it the pad falls back to plain UHID hid-steam, which Steam Input won't
# promote (Interface: -1) — so on a host in Game Mode the controller never appears and you can't
# navigate. Two things must be true at boot: the module loaded, and the vhci `attach`/`detach`
# sysfs files opened to the `input` group (the host runs unprivileged and can't modprobe/chown).
#
# A sysext CANNOT rely on its own /usr/lib/modules-load.d + /usr/lib/udev files for this: the
# image merges (systemd-sysext.service) AFTER systemd-modules-load and early udev have already
# run, so at a plain reboot vhci-hcd is never loaded and its rule never applied. Mirror BOTH into
# real /etc (read at the normal early-boot time, and shadowing the /usr copies by filename) so the
# module loads early and udev's coldplug trigger grants the group access. Refreshed every merge so
# a rule/module change in a new image propagates (neither is user-editable config). Then load +
# (re)apply now, no reboot, for this session.
install -Dm0644 /usr/lib/modules-load.d/punktfunk.conf /etc/modules-load.d/punktfunk.conf 2>/dev/null || :
install -Dm0644 /usr/lib/udev/rules.d/60-punktfunk.rules /etc/udev/rules.d/60-punktfunk.rules 2>/dev/null || :
udevadm control --reload 2>/dev/null || :
modprobe vhci-hcd 2>/dev/null || :
# Re-fire the vhci rule against the (possibly already-present) controller so attach/detach pick up
# the input-group ownership even when the module's original add event predated the reloaded rule.
udevadm trigger --subsystem-match=platform --sysname-match='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" ] \