Compare commits

49 Commits

Author SHA1 Message Date
enricobuehler 6241639042 debug(touch): mirror PUNKTFUNK_TOUCH_DEBUG lines to a data-dir file
The presenter runs in the spawned punktfunk-session binary, whose stderr
Steam's game-mode reaper swallows on the Deck — so also append the finger/
mouse debug lines to $XDG_DATA_HOME/punktfunk-touch-debug.log (host-visible
under ~/.var/app/io.unom.Punktfunk), which survives regardless of how the
client is launched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 12:59:46 +02:00
enricobuehler 7c72899a49 debug(touch): env-gated PUNKTFUNK_TOUCH_DEBUG finger/mouse logging
Logs every raw SDL Finger{Down,Motion,Up} (with the is_direct_touch
result) and MouseMotion/MouseButton event when PUNKTFUNK_TOUCH_DEBUG=1,
to diagnose why touchscreen input is dropped on the Steam Deck under
game-mode gamescope (both trackpad and touch-passthrough dead at once =
finger events not reaching the engine). Zero behavior change when unset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 12:07:40 +02:00
enricobuehler eb4bca11c5 feat(android): Switch 2 Pro Controller + Joy-Con 2 pair declare SwitchPro
ci / rust (push) Failing after 42s
ci / web (push) Successful in 53s
apple / swift (push) Successful in 4m31s
ci / docs-site (push) Successful in 59s
decky / build-publish (push) Successful in 19s
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 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
ci / bench (push) Successful in 5m19s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m40s
docker / deploy-docs (push) Successful in 10s
android / android (push) Successful in 15m28s
arch / build-publish (push) Successful in 13m31s
deb / build-publish (push) Successful in 13m55s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m38s
apple / screenshots (push) Successful in 19m37s
057E:2069 (Pro Controller 2) and 057E:2068 (Joy-Con 2 pair) are the
same full pad surface as the OG Pro and ride the same virtual
hid-nintendo pad. Mirrors SDL, which folds both to its public
NINTENDO_SWITCH_PRO type (the SDL clients bundle 3.4.10, whose switch2
hidapi driver already covers them end to end incl. gyro + GL/GR
paddles-as-paddle-buttons). :kit Kotlin compile green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 12:06:22 +02:00
enricobuehler 69f30f30b6 style(pf-dualsense): rustfmt the N4-spike additions (CI fmt gate)
windows-drivers / probe-and-proto (push) Successful in 18s
windows-drivers / driver-build (push) Successful in 1m37s
windows-host / package (push) Successful in 9m1s
ci / rust (push) Failing after 40s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m7s
decky / build-publish (push) Successful in 24s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
arch / build-publish (push) Successful in 10m21s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
ci / bench (push) Successful in 5m20s
android / android (push) Successful in 16m58s
apple / swift (push) Successful in 4m31s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7m19s
deb / build-publish (push) Successful in 11m39s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m8s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m27s
docker / deploy-docs (push) Successful in 10s
apple / screenshots (push) Successful in 20m45s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:55:47 +02:00
enricobuehler f7356d0820 Merge branch 'fix/android-tv-implied-features': Play TV compatibility
deb / build-publish (push) Has been cancelled
ci / rust (push) Failing after 42s
decky / build-publish (push) Successful in 27s
ci / web (push) Successful in 1m0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
ci / docs-site (push) Successful in 1m2s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
ci / bench (push) Successful in 5m24s
arch / build-publish (push) Successful in 11m39s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m25s
apple / swift (push) Successful in 4m34s
android / android (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m55s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m58s
docker / deploy-docs (push) Successful in 25s
apple / screenshots (push) Successful in 18m6s
RECORD_AUDIO / Wi-Fi-state permissions implied hard microphone + wifi
requirements, filtering mic-less TVs (reported: Philips OLED707) and
ethernet-only boxes as "not compatible" on Play; both are optional at
runtime and now declared required=false (aapt2-verified).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:54:06 +02:00
enricobuehler 51cdaea3f3 Merge branch 'feat/gamepad-new-types': DualSense Edge + Switch Pro + classic SC virtual pads
windows-drivers / probe-and-proto (push) Successful in 41s
ci / rust (push) Failing after 1m2s
ci / docs-site (push) Successful in 1m0s
ci / web (push) Successful in 1m8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
decky / build-publish (push) Successful in 29s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 44s
windows-drivers / driver-build (push) Failing after 1m31s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 48s
apple / swift (push) Successful in 4m29s
ci / bench (push) Successful in 6m29s
docker / deploy-docs (push) Successful in 27s
arch / build-publish (push) Has been cancelled
deb / build-publish (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
gamepad-new-types plan, all phases (7 commits, hashes preserved):
Phase 0 wire bytes 7/8; N1 DualSense Edge (Linux UHID + Windows UMDF
device_type 2 — all four wire paddles on native back/Fn slots); N2
Switch Pro (Linux UHID, full hid-nintendo probe conversation canned);
N3 classic Steam Controller (reserved slot 5 live, UHID); N4
Windows-Deck spike -> NO-GO documented (Steam wants interface 2, a
software devnode reads as 0); SDL/Apple/Android kind pickers.

Verified per commit: .21 clippy -D warnings + 304/0 host tests +
headless bind/probe/evdev smokes for all three new backends; .133
clippy -D warnings + WDK driver-workspace check. On-glass on .173:
Windows Edge identity confirmed live by Steam (HIDAPI claim as
054c:0df2) with the rebuilt signed driver (9.9.0714.1141) staged.
Remaining physical-pad debts tracked in the plan doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:51:25 +02:00
enricobuehler ea2e3578e2 feat(gamepad): dualsense-windows-test grows --edge (drives device_type 2, cycles R4/L4)
Used for the .173 on-glass verify: the Edge devnode enumerates
(SWD\PUNKTFUNK\PF_EDGE_1, driver pf_dualsenseedge attaches, proto 2),
and Steam's live controller.txt confirms the identity end to end —
'type: 054c 0df2', 'Product: DualSense Edge Wireless Controller',
'Controller using HIDAPI driver, vid=0x054c, pid=0x0df2' — with probe
lightbar/player-LED feedback flowing back on the 0xCD plane.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:50:06 +02:00
enricobuehler 8d8168b0e0 feat(gamepad): N4 spike kit — software-devnode Steam Deck probe for Windows
The gamepad-new-types §6 go/no-go rig, ready to run the moment .173 is
back (the box is currently down, so the observation itself is still
owed): does Steam Input on Windows promote a software-devnode HID Deck
(28DE:1205), or does it need a real USB bus identity (the documented
GameInput instance-path gap — the Linux 'Interface: -1' lesson)?

- Driver: scratch device_type=3 serves the Deck identity — the captured
  38-byte controller-interface descriptor, 28DE:1205 attributes, Valve
  strings, the Deck neutral frame, and the Steam 0x83/0xAE feature
  contract (SET_FEATURE latches the command, GET_FEATURE answers it —
  attribute blob + unit serial mirroring steam_proto::feature_reply).
  Never stamped by a session. INF gains pf_steamdeck.
- Host: deck_spike_hold() + the `deck-windows-spike` subcommand — stamps
  devtype 3, spawns the devnode under VID_28DE&PID_1205, streams the
  neutral frame, prints what to observe (Steam logs/controller.txt,
  controller settings) and logs any output reports Steam writes.

Run recipe (on .173, once the updated signed driver is staged): install
driver, start Steam, `punktfunk-host.exe deck-windows-spike`, watch
controller.txt. GO -> plan a proper N4 phase (the Deck codec is already
shared); NO-GO -> document next to the Linux Interface:-1 note and keep
the SteamDeck->DualSense Windows fold.

Verified: .133 clippy -D warnings + the driver workspace cargo check
(WDK) both green; .21 clippy + 304/0 tests unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:44:59 +02:00
enricobuehler 61c752e91e feat(gamepad): Apple + Android pickers declare DualSense Edge / Switch Pro
Plan 0.4 for the N1/N2 backends (SDL landed with them):

- Apple: GamepadType grows dualSenseEdge=7 / switchPro=8 (wire-byte
  parity + name parsing). padKind splits the Edge out of the shared
  GCDualSenseGamepad subclass by product category, and resolves Switch
  Pro / a paired Joy-Con set by category (GameController has no Nintendo
  subclass; single Joy-Cons stay on the Xbox 360 fallback — half a pad).
  The DualSense-only gates (adaptive-trigger feedback, player LEDs, the
  touchpad+motion rich capture) now include the Edge — same surfaces.
  Paddle CAPTURE stays gated on G22 (needs a real pad to pin the
  paddleButton1..4 correspondence); the declared identity is right
  meanwhile. swift build + 124 tests green.

- Android: PREF_DUALSENSEEDGE/PREF_SWITCHPRO wire bytes; the Sony PID
  table splits 0x0DF2 (Edge) out of DualSense; Nintendo 057E:2009
  declares Switch Pro; ControllersScreen labels the new kinds.
  :kit/:app Kotlin compile green (-PskipRustBuild).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:21:42 +02:00
enricobuehler 8c854e0a19 feat(gamepad): classic Steam Controller backend — Linux UHID via hid-steam (N3)
The reserved GamepadPref::SteamController = 5 slot goes live: the same
hid-steam driver under the wired-SC identity (28DE:1102,
ID_CONTROLLER_STATE), UHID-only in v1 (no captured SC USB interface
layout, so no Steam-Input promotion — the pre-usbip Deck state;
acceptable for discontinued hardware).

Layout pinned against the kernel's ID_CONTROLLER_STATE table: 24-bit
buttons at 8..11 (low bits shared with the Deck; grips at 9.7/10.0 =
the Deck's L5/R5 positions; right-pad click 10.2; joystick click 10.6),
u8 triggers at 11/12, the joystick/left-pad MULTIPLEX at 16..20 (a
left-pad contact shadows the stick, like real hardware's lpad_touched
flag), right pad at 20..24. Mapping: wire left stick -> SC stick; wire
right stick -> right-pad coords + touched bit (the SC's camera surface —
the second-stick loss is inherent); PADDLE1/2 -> the two grips (natively,
masked out of the fold input); PADDLE3/4 + MISC1 -> the remap policy.
The SC parser has NO gamepad_mode gate, so no mode-entry pulse.

SteamDeckPad grew a SteamModel (open_model); ScProto/SteamCtrlManager;
pick_gamepad flips SteamController -> itself on Linux (replacing the
Xbox360 fold); SDL picker splits Valve PIDs (Deck 1205 stays SteamDeck,
SC 1102/1142 now declare SteamController).

Verified: .21 clippy -D warnings + 304/0 tests + on-box UHID smoke
(hid-steam binds 1102, BTN_A + right-pad ABS_RX land on evdev, no mode
pulse); .133 clippy -D warnings green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:15:54 +02:00
enricobuehler 70a74b0d7c feat(gamepad): Switch Pro backend — Linux UHID via hid-nintendo (N2)
A virtual Pro Controller (057E:2009, BUS_USB, verbatim 203-byte USB
descriptor triple-cross-checked from real-device captures) bound by
hid-nintendo (>= 5.16): Nintendo-family client pads get correct glyphs +
POSITIONAL layout (wire south/east/west/north -> Switch B/A/Y/X, so the
physical-position <-> glyph relationship survives), live gyro/accel, and
HD-rumble feedback — instead of folding to Xbox360 (mirrored A/B + X/Y,
no motion).

- switch_proto: report-0x30/0x21/0x81 codec + the entire canned probe
  conversation, pinned line-by-line against hid-nintendo.c: 0x80-family
  USB acks, device info (type 0x03 + per-pad MAC), SPI-flash calibration
  blobs (user magics ABSENT -> factory path; sticks 2048 +/- 1400 with
  the left/right byte-order difference; IMU offsets 0 + the driver's own
  default scales so raw units pass 1:1), rumble amplitude decode through
  the driver's inverted joycon_rumble_amplitudes table, player lights ->
  0xCD PlayerLeds. 11 new pin tests.
- switch_pro: UHID backend answering the probe from the manager's
  service pass; SwitchProManager = UhidManager<SwitchProProto> (the 8 ms
  heartbeat doubles as the steady 0x30 stream the driver's post-probe
  rate limiter wants). switchpro-test CLI smoke.
- Router/fold: SwitchPro arms; pick_gamepad SwitchPro -> itself on Linux;
  degrade_if_no_uhid covers it. SDL picker: NintendoSwitchPro + JoyconPair
  declare SwitchPro.

Headless-validated on .21 (hid-nintendo 7.1): probe completes ('using
factory cal' for sticks + IMU, player-1 LED round-trips to the 0xCD
plane), gamepad + IMU input devices created, and an evdev capture pins
the positional swap (wire A/B -> BTN_SOUTH/BTN_EAST) + full-range stick
scaling. .21 clippy -D warnings + 303/0 tests; .133 clippy -D warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:05:28 +02:00
enricobuehler 41be73fbc6 fix(android): declare microphone + wifi features optional for Play TV filtering
RECORD_AUDIO implies android.hardware.microphone required=true and the
Wi-Fi state permissions imply android.hardware.wifi required=true unless
declared otherwise, so Google Play filtered the app as "not compatible"
on TVs that declare no microphone (reported on a Philips 65OLED707/12,
Android TV 11, closed-testing track) and would do the same on
ethernet-only boxes. Both capabilities are optional at runtime: the mic
uplink is runtime-requested and the Wi-Fi locks are best-effort hedges.

Verified via aapt2 dump badging: microphone + wifi now report
uses-feature-not-required and no implied hard requirements remain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:56:12 +02:00
enricobuehler 1830e095f8 feat(gamepad): DualSense Edge backend — Linux UHID + Windows UMDF (N1)
The plain-DualSense transport + report codec under the Edge USB identity
(054C:0DF2, verbatim 389-byte real-device descriptor cross-checked against
the raw usbmon capture + hhd's production virtual Edge), so the wire back
grips (BTN_PADDLE1..4: Deck L4/L5/R4/R5, Elite P1-P4) land on the Edge's
NATIVE buttons[2] bits instead of the fold/drop policy: PADDLE1/2 -> the
right/left back buttons, PADDLE3/4 -> the right/left Fn buttons (kernel
BTN_TRIGGER_HAPPY1..4 on >= 7.2; SDL/Steam read hidraw on any kernel).

- proto: Edge descriptor + btn2 bits + edge_paddle_bits(), pinned against
  hid-playstation DS_EDGE_BUTTONS_* and SDL_hidapi_ps5 (tests).
- Linux: DsUhidIdentity parameterizes the UHID create; DsEdgeLinuxProto /
  DualSenseEdgeManager. Headless-validated on .21 (7.1): driver=playstation
  binds 0DF2, all 4 input devices created, probe lightbar/player-LED
  feedback round-trips; dualsense-test grew --edge (cycles all 4 paddles).
- Windows: UMDF driver serves device_type=2 (Edge descriptor/attrs/strings,
  DS feature blobs); WinDsIdentity parameterizes the SwDevice profile +
  devtype stamp; DsEdgeWinProto / DualSenseEdgeWindowsManager; INF gains
  pf_dualsenseedge. Driver change => resign + reinstall before on-glass.
- Router: DualSenseEdge arms in route_handle/apply_rich/pump/heartbeat;
  pick_gamepad folds Edge -> itself on linux||windows; degrade_if_no_uhid
  covers it.
- Client (SDL): 054C:0DF2 declares DualSenseEdge (no distinct SDL type);
  Edge physical pads take the raw DS5 effects path; console-UI glyphs =
  Shapes. Apple/Android pickers follow separately.

Verified: .21 clippy -D warnings + 292/0 host tests + on-box UHID bind
smoke; .133 clippy pending in this push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:49:31 +02:00
enricobuehler 45bde370e2 feat(gamepad): GamepadPref wire bytes for DualSense Edge (7) + Switch Pro (8)
Phase 0 of gamepad-new-types: the two new kinds exist on the wire (enum,
to_u8/from_u8/from_name/as_str, C-ABI constants + header), and pick_gamepad
folds them to the closest EXISTING backend until their own backends land —
DualSenseEdge -> DualSense (keeps the rich planes; only the paddles go
through the fold policy), SwitchPro -> Xbox360. Wire round-trip pinned
0..=8 + unknown->Auto; fold table extended.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:20:24 +02:00
enricobuehler 57d89217fb Merge branch 'gamepad-g12-skeleton': G12/3.3 UhidManager skeleton extraction
ci / web (push) Successful in 43s
ci / docs-site (push) Successful in 1m19s
apple / swift (push) Successful in 4m28s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
decky / build-publish (push) Successful in 38s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
ci / bench (push) Successful in 6m20s
arch / build-publish (push) Successful in 11m25s
deb / build-publish (push) Successful in 13m3s
windows-host / package (push) Successful in 14m33s
android / android (push) Successful in 15m23s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m14s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m39s
ci / rust (push) Successful in 22m7s
apple / screenshots (push) Successful in 19m33s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
docker / deploy-docs (push) Successful in 23s
The deferred Phase 3.3 of the gamepad review (gamepad-review-cleanup.md
§3a): the seven virtual-pad managers' copy-pasted lifecycle (slot table,
active_mask unplug sweep, gate-checked create, rumble/hidout dedup,
heartbeat) extracted into shared PadSlots<P> + PadProto/UhidManager<B>;
each backend now supplies only its protocol half via a type alias, with
zero Pads-router edits. Includes the 3.3.0 pre-step fixing the drifted
Linux DS4 backend (rich-plane pad clicks + the Steam left pad were dead
on the DS4 kind).

10 commits, each verified as it landed: Linux .21 clippy -D warnings +
full host suite 290 pass / 0 fail + fmt; Windows CI VM .133 clippy
--all-targets -D warnings EXITCODE 0. On-glass kind-cycling smoke
(one real pad per platform) still owed post-merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 09:09:34 +02:00
enricobuehler 650acda334 chore(inject): post-extraction sweep (3.3)
Drop the vestigial Ds4Feedback.hidout field (parse_ds4_output never
filled it and neither DS4 manager read it — the lightbar rides the led
field, now converted to a HidOutput::Led by the protos) and its
now-unused HidOutput import; refresh the pad_gate module doc (managers
now drive it via pad_slots).

Verified: .21 clippy --all-targets -D warnings + full suite 290 pass /
0 fail + cargo fmt --check clean; .133 clippy --all-targets -D warnings
EXITCODE 0.

Part of G12/3.3 (§3a.4 commit 10) — extraction complete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 02:04:51 +02:00
enricobuehler 89aa52bc58 refactor(inject): uinput + XUSB managers onto PadSlots (3.3)
The two stateless backends keep their structs and special pumps (uinput
FF-effect mixing via pump_ff/last_mix; the XUSB stale-residual
RUMBLE_IDLE_TIMEOUT force-off) but delegate slot lifecycle — table,
unplug sweep, gate-checked create — to the shared PadSlots. XUSB resets
last_rumble/last_active on the swept indices and on fresh create exactly
as before (the G10/G16-adjacent semantics untouched).

Two accepted deltas, both flagged in the plan (§3a): the uinput
arrival/unplug log lines gain the pad-identity label every other backend
already has ("controller arrival (X-Box 360 pad)"), and XUSB's
f.index.max(0) clamp is replaced by the bounds check every other manager
uses — a negative wire index is now dropped instead of being treated as
pad 0.

Verified: .21 clippy --all-targets -D warnings clean + full suite 290
pass / 0 fail (uinput); .133 clippy --all-targets -D warnings EXITCODE 0
(XUSB).

Part of G12/3.3 (§3a.4 commit 9) — all seven managers now share the
PadSlots lifecycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:57:00 +02:00
enricobuehler 384fc30833 refactor(inject/linux/steam_controller): convert to UhidManager<SteamProto> (3.3)
The most hook-laden conversion: SteamControllerManager becomes a pub
type alias of UhidManager<SteamProto>. The Steam-specific pieces map
cleanly onto the trait — open() delegates to open_transport (usbip →
gadget → UHID fallback, which keeps its own per-transport logging, so no
extra success line, matching the old ensure), merge_frame preserves the
trackpad coords/touch-bits/clicks + motion across button-only frames
(the G2 fix, verbatim), and the gamepad-mode-entry pulse rides the
force_heartbeat hook. DeckTransport goes pub (type Pad in a public-trait
impl). Also un-fuses a doc-comment glitch where the manager's doc had
been merged onto the DeckTransport enum.

Verified on .21: clippy --all-targets -D warnings clean; full suite 290
pass / 0 fail.

Part of G12/3.3 (§3a.4 commit 8) — all five stateful managers now share
one skeleton.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:41:21 +02:00
enricobuehler 365d4bb8f1 refactor(inject/linux/dualshock4): convert to UhidManager<Ds4LinuxProto> (3.3)
DualShock4Manager becomes a pub type alias of UhidManager<Ds4LinuxProto>
(the same shape as the other three DS-family conversions); the bespoke
last_led lightbar dedup folds into the shared HidoutDedup exactly as the
Windows DS4 conversion did. With 3.3.0 already applied, the proto half
is byte-identical to Ds4WinProto except the transport open — the codec,
the mappers, and now the manager all shared.

Verified on .21: clippy --all-targets -D warnings clean; full suite 290
pass / 0 fail.

Part of G12/3.3 (§3a.4 commit 7).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:38:45 +02:00
enricobuehler f1efd3091e refactor(inject/windows/dualshock4): convert to UhidManager<Ds4WinProto> (3.3)
DualShock4WindowsManager becomes a pub type alias of
UhidManager<Ds4WinProto>. The bespoke last_led lightbar dedup folds into
the shared HidoutDedup: the proto's service() converts Ds4Feedback.led
into a HidOutput::Led, and HidoutDedup compares it against the
last-forwarded value with the same reset-on-create/unplug semantics the
Option<(u8,u8,u8)> vec had. Everything else mirrors the DualSense
conversion (same DsState mappers as linux/dualshock4.rs). Ds4WinPad goes
pub (type Pad in a public-trait impl, E0446 otherwise).

Verified on the Windows CI VM .133: cargo clippy -p punktfunk-host
--all-targets -- -D warnings EXITCODE 0 at this tip.

Part of G12/3.3 (§3a.4 commit 6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:36:45 +02:00
enricobuehler 446818eea6 refactor(inject/windows/dualsense): convert to UhidManager<DsWinProto> (3.3)
DualSenseWindowsManager becomes a pub type alias of
UhidManager<DsWinProto>; the proto supplies the UMDF sealed-channel open
(+ success log), the DsState mappers (identical to linux/dualsense.rs,
paddle fold included), and the section feedback poll. Lifecycle, dedup,
and heartbeat come from the shared skeleton — behavior-identical, same
log lines (LABEL DualSense/Windows + the driver-install hint).

DsWinPad goes pub (it appears as type Pad in the impl of the public
PadProto trait — E0446 otherwise; the Linux pads were already pub).

Verified on the Windows CI VM .133 (same pinned 1.96.0 MSVC toolchain +
Public-path FFmpeg/LLVM the runner uses): cargo clippy -p punktfunk-host
--all-targets -- -D warnings EXITCODE 0 at the DS4-conversion tip
(.173 was down; .133 carries the identical toolchain).

Part of G12/3.3 (§3a.4 commit 5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:36:45 +02:00
enricobuehler 4d6c2394dc refactor(inject/linux/dualsense): convert to UhidManager<DsLinuxProto> (3.3)
The first backend onto the shared skeleton: DualSenseManager becomes
pub type DualSenseManager = UhidManager<DsLinuxProto>, where DsLinuxProto
supplies only the protocol half (UHID open + success log, DsState
neutral/merge/apply_rich with the paddle fold, best-effort write, the
GET_REPORT-answering service pass). handle/apply_rich/heartbeat/pump and
the unplug sweep now come from uhid_manager — behavior-identical
(same log lines, same dedup + reset semantics), zero Pads-router edits.

Verified on .21: clippy --all-targets -D warnings clean; full suite 290
pass / 0 fail.

Part of G12/3.3 (§3a.4 commit 4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:10:42 +02:00
enricobuehler 2bea02b0ea feat(inject): generic PadProto + UhidManager<B> stateful manager (3.3 layer 2)
The shared skeleton of the five stateful UHID/UMDF managers (Linux
DualSense / DualShock 4 / Steam Deck, Windows DualSense / DualShock 4),
written once over PadSlots: event routing with the unplug sweep and
was-the-unplug early return, the merge-preserving frame fold, rich-input
application, the silence heartbeat (with a backend force hook for the
Steam mode-entry pulse), and the feedback pump with rumble dedup +
HidoutDedup. A backend supplies only its per-controller half via
PadProto: open / neutral / merge_frame / apply_rich / write_state /
service — exactly where the real protocol differences live.

Method surface (new/handle/apply_rich/pump/heartbeat) matches what the
punktfunk1.rs Pads router already drives, so each backend will convert
as a pub type alias with zero router edits.

Additive only — no backend converted yet. 8 mock-backend tests make the
manager lifecycle unit-testable for the first time; G2 (rich fields
survive a button-only frame) and G10 (Arrival eager-creates) are now
generic regression tests, plus removal-frame no-recreate, absent-pad
rich drop, create-backoff state tracking, rumble/hidout dedup + re-arm
on recreate, and heartbeat gap/force semantics.

Verified on .21: clippy --all-targets -D warnings clean; suite 293
pass / 0 fail (285 prior + 8 new).

Part of G12/3.3 (gamepad-review-cleanup.md §3a.3, commit 3 of the §3a.4
sequence).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:08:25 +02:00
enricobuehler 528a51d75c feat(inject): shared PadSlots<P> slot table + lifecycle (3.3 layer 1)
The Vec<Option<Pad>> slot table, active_mask unplug sweep, and PadGate-
checked create that all seven backend managers copy-paste, extracted into
one unit-tested inject/pad_slots.rs (cfg any(linux,windows), like
pad_gate). sweep() returns the swept indices as a bitmask and ensure()
returns fresh-create, so managers reset their per-index sibling state
(state / last_rumble / dedup / clocks) without closure gymnastics.
Lifecycle log lines are label/device/hint-parameterized to stay
byte-identical per backend; open() keeps the success line (it knows the
transport detail).

Additive only — no manager converted yet; first unit coverage for the
sweep/create lifecycle (5 tests: freshness, sweep-once semantics, gate
integration, recreate, pump iteration).

Verified on .21: clippy --all-targets -D warnings clean; suite 285
pass / 0 fail (280 prior + 5 new).

Part of G12/3.3 (gamepad-review-cleanup.md §3a.3, commit 2 of the §3a.4
sequence).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:04:22 +02:00
enricobuehler b597bb74bd fix(inject/linux/ds4): fold the Linux DS4 backend onto the shared proto codec (3.3.0)
The Linux DualShock 4 backend missed the G2-era shared-mapping work and
drifted from dualshock4_proto three ways, leaving two user-visible gaps
on the DS4 kind (Windows, written later against the proto, is correct):

- its serialize_state duplicated the proto's byte-for-byte EXCEPT byte 7:
  raw st.buttons[2] instead of buttons2_with_click(), so a rich-plane pad
  click never reached the report;
- its inline apply_rich never set touch_click and dropped the Steam LEFT
  pad entirely (surface 1 skipped), where the shared
  dualsense_proto::DsState::apply_rich splits the one touchpad left/right;
- handle() didn't preserve touch_click across button-only frames.

Net effect: Deck client -> Linux host on the DS4 kind = pad clicks and
the left pad dead.

Delete the local serialize_state/parse_ds4_output/Ds4Feedback/pack_touch
and touch-dim consts in favor of dualshock4_proto (dropping the proto's
keep-in-sync FIXME), route rich events through the shared
DsState::apply_rich, and preserve touch_click in the frame merge exactly
like the other three DS-family managers. The proto's serialize_offsets
test gains a touch_click case pinning byte 7 bit 1.

Verified on .21: cargo clippy -p punktfunk-host --all-targets -D warnings
clean; full suite 277 pass / 0 fail.

Pre-step 3.3.0 of the G12 skeleton extraction (gamepad-review-cleanup.md
§3a.2) — the behavior fix lands before the mechanical dedup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:01:37 +02:00
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
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
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
windows-host / package (push) Successful in 9m19s
release / apple (push) Successful in 27m34s
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
61 changed files with 5653 additions and 2167 deletions
Generated
+14 -15
View File
@@ -2145,7 +2145,7 @@ dependencies = [
[[package]] [[package]]
name = "latency-probe" name = "latency-probe"
version = "0.10.0" version = "0.10.1"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
@@ -2277,7 +2277,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "loss-harness" name = "loss-harness"
version = "0.10.0" version = "0.10.1"
dependencies = [ dependencies = [
"punktfunk-core", "punktfunk-core",
] ]
@@ -2756,7 +2756,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pf-client-core" name = "pf-client-core"
version = "0.10.0" version = "0.10.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -2778,7 +2778,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-console-ui" name = "pf-console-ui"
version = "0.10.0" version = "0.10.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2799,7 +2799,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-ffvk" name = "pf-ffvk"
version = "0.10.0" version = "0.10.1"
dependencies = [ dependencies = [
"ash", "ash",
"bindgen", "bindgen",
@@ -2808,7 +2808,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-presenter" name = "pf-presenter"
version = "0.10.0" version = "0.10.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2992,7 +2992,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-android" name = "punktfunk-client-android"
version = "0.10.0" version = "0.10.1"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni", "jni",
@@ -3008,7 +3008,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-linux" name = "punktfunk-client-linux"
version = "0.10.0" version = "0.10.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -3024,7 +3024,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-session" name = "punktfunk-client-session"
version = "0.10.0" version = "0.10.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-client-core", "pf-client-core",
@@ -3039,14 +3039,13 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-windows" name = "punktfunk-client-windows"
version = "0.10.0" version = "0.10.1"
dependencies = [ dependencies = [
"async-channel", "async-channel",
"ffmpeg-next", "ffmpeg-next",
"mdns-sd", "mdns-sd",
"pf-client-core", "pf-client-core",
"punktfunk-core", "punktfunk-core",
"sdl3",
"serde", "serde",
"serde_json", "serde_json",
"tracing", "tracing",
@@ -3059,7 +3058,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-core" name = "punktfunk-core"
version = "0.10.0" version = "0.10.1"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"bytes", "bytes",
@@ -3090,7 +3089,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-host" name = "punktfunk-host"
version = "0.10.0" version = "0.10.1"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
@@ -3162,7 +3161,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-probe" name = "punktfunk-probe"
version = "0.10.0" version = "0.10.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"mdns-sd", "mdns-sd",
@@ -3176,7 +3175,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-tray" name = "punktfunk-tray"
version = "0.10.0" version = "0.10.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ksni", "ksni",
+1 -1
View File
@@ -35,7 +35,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" } ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package] [workspace.package]
version = "0.10.0" version = "0.10.1"
edition = "2021" edition = "2021"
rust-version = "1.82" rust-version = "1.82"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
@@ -33,6 +33,13 @@
<uses-feature android:name="android.hardware.touchscreen" android:required="false" /> <uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.software.leanback" android:required="false" /> <uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.gamepad" android:required="false" /> <uses-feature android:name="android.hardware.gamepad" android:required="false" />
<!-- Neutralize Play's IMPLIED hard requirements, which filtered real TVs as "not compatible"
(reported on a Philips OLED707): RECORD_AUDIO implies android.hardware.microphone and the
Wi-Fi state permissions imply android.hardware.wifi, both required=true unless declared
otherwise. Some TVs declare no microphone (mic uplink is optional and runtime-gated) and
ethernet-only boxes declare no wifi (discovery/WifiLock are best-effort hedges there). -->
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<uses-feature android:name="android.hardware.wifi" android:required="false" />
<!-- appCategory="game": a game-streaming client IS a game as far as the SoC is concerned. <!-- appCategory="game": a game-streaming client IS a game as far as the SoC is concerned.
On Snapdragon devices (and other OEMs with a Game Mode / Game Dashboard) this makes the app On Snapdragon devices (and other OEMs with a Game Mode / Game Dashboard) this makes the app
@@ -387,6 +387,8 @@ private fun prefLabel(pref: Int): String = when (pref) {
Gamepad.PREF_DUALSHOCK4 -> "DualShock 4" Gamepad.PREF_DUALSHOCK4 -> "DualShock 4"
Gamepad.PREF_STEAMCONTROLLER -> "Steam Controller" Gamepad.PREF_STEAMCONTROLLER -> "Steam Controller"
Gamepad.PREF_STEAMDECK -> "Steam Deck" Gamepad.PREF_STEAMDECK -> "Steam Deck"
Gamepad.PREF_DUALSENSEEDGE -> "DualSense Edge"
Gamepad.PREF_SWITCHPRO -> "Switch Pro"
else -> "Automatic" else -> "Automatic"
} }
@@ -241,7 +241,10 @@ private fun resolveDir(s: NavInputState): NavDir? {
if (s.hatY >= 0.5f) return NavDir.DOWN if (s.hatY >= 0.5f) return NavDir.DOWN
if (s.hatX <= -0.5f) return NavDir.LEFT if (s.hatX <= -0.5f) return NavDir.LEFT
if (s.hatX >= 0.5f) return NavDir.RIGHT 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 { when {
s.stickY <= -STICK_HIGH -> NavDir.UP s.stickY <= -STICK_HIGH -> NavDir.UP
s.stickY >= STICK_HIGH -> NavDir.DOWN s.stickY >= STICK_HIGH -> NavDir.DOWN
@@ -127,12 +127,12 @@ class MainActivity : ComponentActivity() {
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) { if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
val bit = Gamepad.buttonBit(event.keyCode) val bit = Gamepad.buttonBit(event.keyCode)
if (bit != 0) { if (bit != 0) {
// The router forwards the bit on this device's own wire pad index, tracks held // The router forwards the bit on this device's own wire pad index and tracks held
// state per pad, and reports when the emergency-exit chord (Select + Start + L1 + // state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
// R1) completed on any one pad (a couch user has no keyboard/Back). // inside the router: holding it for ~1.5 s fires router.onExitChord (wired in
if (gamepadRouter?.onButton(event, bit) == true) { // StreamScreen), so a couch user with no keyboard/Back can still leave — but an
requestStreamExit?.let { exit -> window.decorView.post { exit() } } // accidental brush of the four buttons no longer quits instantly.
} gamepadRouter?.onButton(event, bit)
return true // consumed return true // consumed
} }
} }
@@ -180,13 +180,19 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
val router = GamepadRouter(context, handle, initialSettings.gamepad) val router = GamepadRouter(context, handle, initialSettings.gamepad)
activity?.gamepadRouter = router activity?.gamepadRouter = router
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips // 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() } activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
router.onExitChord = { activity?.requestStreamExit?.invoke() }
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate 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 // 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 // index via the router; poll threads stopped + joined before the router is released and the
// session closed. // session closed.
val feedback = GamepadFeedback(handle, router).also { it.start() } 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 { onDispose {
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it 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 feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
@@ -52,6 +52,8 @@ object Gamepad {
const val PREF_DUALSHOCK4 = 4 const val PREF_DUALSHOCK4 = 4
const val PREF_STEAMCONTROLLER = 5 const val PREF_STEAMCONTROLLER = 5
const val PREF_STEAMDECK = 6 const val PREF_STEAMDECK = 6
const val PREF_DUALSENSEEDGE = 7
const val PREF_SWITCHPRO = 8
// USB vendor ids of the controllers we can identify by VID/PID. // USB vendor ids of the controllers we can identify by VID/PID.
private const val VID_SONY = 0x054C private const val VID_SONY = 0x054C
@@ -59,10 +61,19 @@ object Gamepad {
private const val VID_VALVE = 0x28DE private const val VID_VALVE = 0x28DE
private const val VID_NINTENDO = 0x057E private const val VID_NINTENDO = 0x057E
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types. // Sony product ids. DualSense (PS5), DualSense Edge, and DualShock 4 (PS4) map to distinct
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2) // host pad types — the Edge's back paddles get native slots on the virtual Edge (Android
// forwards no paddle input yet, but the identity + rich planes match the physical pad).
private val PID_DUALSENSE = setOf(0x0CE6)
private val PID_DUALSENSEEDGE = setOf(0x0DF2)
private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC) private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC)
// Nintendo: Switch Pro Controller — the host builds the virtual hid-nintendo pad (correct
// glyphs + positional layout). The Switch 2 Pro Controller (0x2069) and a Joy-Con 2 pair
// (0x2068) are the same full pad surface and ride the same virtual pad (SDL folds them to
// its NINTENDO_SWITCH_PRO type too).
private val PID_SWITCHPRO = setOf(0x2009, 0x2069, 0x2068)
// Valve: Steam Deck built-in controller (0x1205); classic Steam Controller wired (0x1102) / // Valve: Steam Deck built-in controller (0x1205); classic Steam Controller wired (0x1102) /
// dongle (0x1142). The host builds the virtual hid-steam pad; rich-input capture (paddles / // dongle (0x1142). The host builds the virtual hid-steam pad; rich-input capture (paddles /
// trackpads / gyro) is out of scope on Android (no rich-input plane yet), so only the standard // trackpads / gyro) is out of scope on Android (no rich-input plane yet), so only the standard
@@ -91,10 +102,12 @@ object Gamepad {
val pid = dev.productId val pid = dev.productId
return when { return when {
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
vid == VID_SONY && pid in PID_DUALSENSEEDGE -> PREF_DUALSENSEEDGE
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4 vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK
vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER
vid == VID_NINTENDO && pid in PID_SWITCHPRO -> PREF_SWITCHPRO
else -> PREF_XBOX360 else -> PREF_XBOX360
} }
} }
@@ -219,14 +232,31 @@ object Gamepad {
), ),
) )
// HAT → dpad button transitions (track previous, emit only the deltas). // HAT → dpad button transitions. Android BATCHES joystick ACTION_MOVEs, so a rapid d-pad
val hx = sign(event.getAxisValue(MotionEvent.AXIS_HAT_X)) // 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 (hx != hatX) {
if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false) 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) if (hx < 0) btn(BTN_DPAD_LEFT, true) else if (hx > 0) btn(BTN_DPAD_RIGHT, true)
hatX = hx hatX = hx
} }
val hy = sign(event.getAxisValue(MotionEvent.AXIS_HAT_Y))
if (hy != hatY) { if (hy != hatY) {
if (hatY < 0) btn(BTN_DPAD_UP, false) else if (hatY > 0) btn(BTN_DPAD_DOWN, false) 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) 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 rumbleThread: Thread? = null
private var hidoutThread: Thread? = null private var hidoutThread: Thread? = null
// Per-controller bindings, keyed by device id, built lazily. rumbleBinds is touched ONLY by the // Per-controller bindings, keyed by device id, built lazily. rumbleBinds is written by the rumble
// rumble thread and lightBinds ONLY by the hidout thread while running; stop() reads both from the // thread and lightBinds by the hidout thread while running; [onDeviceRemoved] also evicts+closes
// main thread AFTER joining those threads (join establishes the happens-before), so plain maps are // from the MAIN thread on a hot-unplug, and stop() clears both from the main thread after joining
// race-free. A null value caches "this controller has no vibrator / no controllable lights". // 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 rumbleBinds = HashMap<Int, RumbleBind?>()
private val lightBinds = HashMap<Int, LightBind?>() private val lightBinds = HashMap<Int, LightBind?>()
@@ -122,6 +128,7 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
rumbleThread = null rumbleThread = null
hidoutThread = null hidoutThread = null
// Threads are dead — drop any held rumble and close every lights session. // Threads are dead — drop any held rumble and close every lights session.
synchronized(bindsLock) {
for (b in rumbleBinds.values) b?.let { for (b in rumbleBinds.values) b?.let {
runCatching { it.vm?.cancel() } runCatching { it.vm?.cancel() }
runCatching { it.legacy?.cancel() } runCatching { it.legacy?.cancel() }
@@ -130,17 +137,40 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
rumbleBinds.clear() rumbleBinds.clear()
lightBinds.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() } }
}
}
// ---- Rumble ---- // ---- Rumble ----
/** The rumble binding for the controller on wire pad [pad], or null (no live pad / no vibrator). Cached by device id. */ /** 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? { private fun rumbleBindFor(pad: Int): RumbleBind? {
val dev = router?.deviceForPad(pad) ?: return null val dev = router?.deviceForPad(pad) ?: return null
synchronized(bindsLock) {
if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id] if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id]
val bind = bindRumble(dev) val bind = bindRumble(dev)
rumbleBinds[dev.id] = bind rumbleBinds[dev.id] = bind
return bind return bind
} }
}
private fun bindRumble(dev: InputDevice): RumbleBind? { private fun bindRumble(dev: InputDevice): RumbleBind? {
if (Build.VERSION.SDK_INT >= 31) { if (Build.VERSION.SDK_INT >= 31) {
@@ -184,7 +214,13 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
} }
val combo = CombinedVibration.startParallel() val combo = CombinedVibration.startParallel()
if (bind.amplitudeControlled && bind.ids.size >= 2) { 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 (hi != 0) combo.addVibrator(bind.ids[0], oneShot(hi, durationMs))
if (lo != 0) combo.addVibrator(bind.ids[1], oneShot(lo, durationMs)) if (lo != 0) combo.addVibrator(bind.ids[1], oneShot(lo, durationMs))
} else { } 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 // 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 = private fun oneShot(amp: Int, durationMs: Long): VibrationEffect =
VibrationEffect.createOneShot(durationMs, amp) VibrationEffect.createOneShot(durationMs.coerceAtLeast(1), amp)
// ---- HID output ---- // ---- HID output ----
@@ -268,11 +308,13 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
private fun lightBindFor(pad: Int): LightBind? { private fun lightBindFor(pad: Int): LightBind? {
if (Build.VERSION.SDK_INT < 33) return null if (Build.VERSION.SDK_INT < 33) return null
val dev = router?.deviceForPad(pad) ?: return null val dev = router?.deviceForPad(pad) ?: return null
synchronized(bindsLock) {
if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id] if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id]
val bind = bindLights(dev) val bind = bindLights(dev)
lightBinds[dev.id] = bind lightBinds[dev.id] = bind
return bind return bind
} }
}
private fun bindLights(dev: InputDevice): LightBind? { private fun bindLights(dev: InputDevice): LightBind? {
val lm = dev.lightsManager val lm = dev.lightsManager
@@ -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]. */ /** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
private val slots = ConcurrentHashMap<Int, Slot>() 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 inputManager = context.getSystemService(InputManager::class.java)
private val listener = object : InputManager.InputDeviceListener { private val listener = object : InputManager.InputDeviceListener {
override fun onInputDeviceAdded(deviceId: Int) { override fun onInputDeviceAdded(deviceId: Int) {
@@ -55,7 +72,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
} }
init { 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 // 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. // will never fire onInputDeviceAdded during this session; their Arrival lands before any input.
for (id in InputDevice.getDeviceIds()) { 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_* * 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 * 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 * slot's pad index, and tracks held state. Completing the emergency stream-exit chord (Select +
* stream-exit chord (Select + Start + L1 + R1) on THIS pad — the caller then leaves the stream * Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly;
* (mirrors the Linux client's escape chord: any one controller can leave). * [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 { fun onButton(event: KeyEvent, bit: Int) {
val slot = slotFor(event.device) ?: return false val slot = slotFor(event.device) ?: return
when (event.action) { when (event.action) {
KeyEvent.ACTION_DOWN -> { KeyEvent.ACTION_DOWN -> {
// repeatCount guard: don't re-send a held button as auto-repeat. // repeatCount guard: don't re-send a held button as auto-repeat.
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index) if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
slot.held = slot.held or bit slot.held = slot.held or bit
if (slot.held and EXIT_CHORD == EXIT_CHORD) { // Full chord now held on this pad → start the hold countdown (idempotent while held).
slot.held = 0 if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
return true
}
} }
KeyEvent.ACTION_UP -> { KeyEvent.ACTION_UP -> {
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
slot.held = slot.held and bit.inv() 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() { fun release() {
inputManager?.unregisterInputDeviceListener(listener) 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. // Snapshot the ids first — closeSlot mutates the map.
for (id in slots.keys.toList()) closeSlot(id) 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 val slot = slots.remove(deviceId) ?: return
releaseHeld(slot) releaseHeld(slot)
NativeBridge.nativeSendGamepadRemove(handle, slot.index) 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). */ /** 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). */ /** 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 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
} }
} }
@@ -188,6 +188,14 @@ public final class PunktfunkConnection {
// exist so the resolved type round-trips and name parsing matches the host. // exist so the resolved type round-trips and name parsing matches the host.
case steamController = 5 case steamController = 5
case steamDeck = 6 case steamDeck = 6
/// DualSense Edge (Linux UHID / Windows UMDF hosts): the DualSense plus native back/Fn
/// buttons. GameController exposes the Edge as a `GCDualSenseGamepad` with its own
/// product category; paddle CAPTURE is still gated on G22, but the declared identity +
/// rich planes match the physical pad.
case dualSenseEdge = 7
/// Nintendo Switch Pro Controller (Linux UHID hid-nintendo hosts): correct Nintendo
/// glyphs + positional layout on the host side.
case switchPro = 8
/// Loose name parsing for env/dev hooks, mirroring the host's /// Loose name parsing for env/dev hooks, mirroring the host's
/// `GamepadPref::from_name`. /// `GamepadPref::from_name`.
@@ -200,6 +208,9 @@ public final class PunktfunkConnection {
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4 case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
case "steamdeck", "steam-deck", "deck": self = .steamDeck case "steamdeck", "steam-deck", "deck": self = .steamDeck
case "steamcontroller", "steam-controller", "steamcon": self = .steamController case "steamcontroller", "steam-controller", "steamcon": self = .steamController
case "dualsenseedge", "dualsense-edge", "edge", "dsedge": self = .dualSenseEdge
case "switchpro", "switch-pro", "switch", "procontroller", "pro-controller":
self = .switchPro
default: return nil default: return nil
} }
} }
@@ -257,7 +257,12 @@ public final class GamepadCapture {
/// tagged with the slot's wire pad index. /// tagged with the slot's wire pad index.
private func sync(_ slot: Slot, _ g: GCExtendedGamepad) { private func sync(_ slot: Slot, _ g: GCExtendedGamepad) {
guard !suspended else { return } 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 let changed = newButtons ^ slot.buttons
if changed != 0 { if changed != 0 {
for bit in GamepadWire.allButtons where changed & bit != 0 { for bit in GamepadWire.allButtons where changed & bit != 0 {
@@ -266,12 +271,12 @@ public final class GamepadCapture {
slot.buttons = newButtons slot.buttons = newButtons
} }
let newAxes: [Int32] = [ let newAxes: [Int32] = [
Int32((g.leftThumbstick.xAxis.value * 32767).rounded()), Int32(g.leftThumbstick.xAxis.value * 32767),
Int32((g.leftThumbstick.yAxis.value * 32767).rounded()), Int32(g.leftThumbstick.yAxis.value * 32767),
Int32((g.rightThumbstick.xAxis.value * 32767).rounded()), Int32(g.rightThumbstick.xAxis.value * 32767),
Int32((g.rightThumbstick.yAxis.value * 32767).rounded()), Int32(g.rightThumbstick.yAxis.value * 32767),
Int32((g.leftTrigger.value * 255).rounded()), Int32(g.leftTrigger.value * 255),
Int32((g.rightTrigger.value * 255).rounded()), Int32(g.rightTrigger.value * 255),
] ]
for (i, v) in newAxes.enumerated() where v != slot.axes[i] { for (i, v) in newAxes.enumerated() where v != slot.axes[i] {
connection.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad)) 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.dpad.right.isPressed { b |= GamepadWire.dpadRight }
if g.buttonMenu.isPressed { b |= GamepadWire.start } if g.buttonMenu.isPressed { b |= GamepadWire.start }
if g.buttonOptions?.isPressed == true { b |= GamepadWire.back } if g.buttonOptions?.isPressed == true { b |= GamepadWire.back }
// The share/create/capture element (Xbox Series share, a clone pad's screenshot button // The dedicated share/create/capture element (Xbox-Series Share, DualSense Create, a clone
// e.g. the GameSir G8's, below its d-pad) folds into back/select too. On pads that expose // pad's screenshot button e.g. the GameSir G8's, below its d-pad) the wire's capture
// the create button BOTH as buttonOptions and as the share element this OR is harmless // bit, matching the Rust client's `Button::Misc1 => wire::BTN_MISC1`. On an Xbox-Series pad
// same wire bit. // this is a button physically DISTINCT from View (buttonOptions, above), so it must not
if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.back } // 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.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick }
if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick } if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick }
if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder } if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder }
@@ -42,13 +42,14 @@ public final class GamepadManager: ObservableObject {
public let hasHaptics: Bool public let hasHaptics: Bool
public let hasMotion: Bool public let hasMotion: Bool
public let hasAdaptiveTriggers: Bool public let hasAdaptiveTriggers: Bool
/// Specifically a DualSense gates the DualSense-only feedback (adaptive triggers, /// Specifically a DualSense (incl. the Edge same feedback surface) gates the
/// player LEDs) and the PlayStation glyph in Settings. /// DualSense-only feedback (adaptive triggers, player LEDs) and the PlayStation glyph
public var isDualSense: Bool { kind == .dualSense } /// in Settings.
/// A PlayStation pad with a touchpad + motion (DualSense OR DualShock 4) gates public var isDualSense: Bool { kind == .dualSense || kind == .dualSenseEdge }
/// A PlayStation pad with a touchpad + motion (DualSense family OR DualShock 4) gates
/// rich-input CAPTURE (touchpad contacts + gyro/accel on plane 0xCC). /// rich-input CAPTURE (touchpad contacts + gyro/accel on plane 0xCC).
public var hasTouchpadAndMotion: Bool { public var hasTouchpadAndMotion: Bool {
kind == .dualSense || kind == .dualShock4 kind == .dualSense || kind == .dualSenseEdge || kind == .dualShock4
} }
/// 0...1, nil when the controller doesn't report a battery (e.g. wired). /// 0...1, nil when the controller doesn't report a battery (e.g. wired).
public let batteryLevel: Float? public let batteryLevel: Float?
@@ -227,7 +228,7 @@ public final class GamepadManager: ObservableObject {
private static func describe(_ c: GCController, id: String) -> DiscoveredController { private static func describe(_ c: GCController, id: String) -> DiscoveredController {
let extended = c.extendedGamepad let extended = c.extendedGamepad
let kind = padKind(extended) let kind = padKind(extended, productCategory: c.productCategory)
return DiscoveredController( return DiscoveredController(
id: id, id: id,
name: c.vendorName ?? c.productCategory, name: c.vendorName ?? c.productCategory,
@@ -237,28 +238,40 @@ public final class GamepadManager: ObservableObject {
hasLight: c.light != nil, hasLight: c.light != nil,
hasHaptics: c.haptics != nil, hasHaptics: c.haptics != nil,
hasMotion: c.motion != nil, hasMotion: c.motion != nil,
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration; the // GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration (the
// DualShock 4 has none. // Edge included); the DualShock 4 has none.
hasAdaptiveTriggers: kind == .dualSense, hasAdaptiveTriggers: kind == .dualSense || kind == .dualSenseEdge,
batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil }, batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil },
isCharging: c.battery?.batteryState == .charging, isCharging: c.battery?.batteryState == .charging,
controller: c) controller: c)
} }
/// Resolve a physical controller's matching virtual-pad type from its GameController /// Resolve a physical controller's matching virtual-pad type from its GameController
/// subclass. Detection order (all are `: GCExtendedGamepad`): DualSense first, then /// subclass (+ the product-category string where the subclass is shared). Detection order
/// DualShock 4, then any Xbox pad, else fall back to Xbox 360. A non-extended / absent /// (all are `: GCExtendedGamepad`): DualSense family first (the Edge is a
/// profile also falls back to `.xbox360` (it's never forwarded anyway). /// `GCDualSenseGamepad` too its distinct product category splits it out), then
/// DualShock 4, any Xbox pad, then Nintendo Switch pads by category (GameController has no
/// dedicated subclass for them). A non-extended / absent profile falls back to `.xbox360`
/// (it's never forwarded anyway).
private static func padKind( private static func padKind(
_ extended: GCExtendedGamepad? _ extended: GCExtendedGamepad?,
productCategory: String
) -> PunktfunkConnection.GamepadType { ) -> PunktfunkConnection.GamepadType {
guard let extended else { return .xbox360 } guard let extended else { return .xbox360 }
let category = productCategory.lowercased()
// Deployment floor (macOS 14 / iOS 17 / tvOS 17) clears every introduction version // Deployment floor (macOS 14 / iOS 17 / tvOS 17) clears every introduction version
// here, so no `@available` guard is needed matching the unguarded // here, so no `@available` guard is needed matching the unguarded
// `GCDualSenseGamepad` use elsewhere in the package. // `GCDualSenseGamepad` use elsewhere in the package.
if extended is GCDualSenseGamepad { return .dualSense } if extended is GCDualSenseGamepad {
return category.contains("edge") ? .dualSenseEdge : .dualSense
}
if extended is GCDualShockGamepad { return .dualShock4 } if extended is GCDualShockGamepad { return .dualShock4 }
if extended is GCXboxGamepad { return .xboxOne } if extended is GCXboxGamepad { return .xboxOne }
// Nintendo Switch Pro Controller / a paired Joy-Con set (a full pad surface). Single
// Joy-Cons ("Joy-Con (L)" / "(R)") stay on the Xbox 360 fallback half a pad.
if category.contains("switch pro") || category.contains("joy-con (l/r)") {
return .switchPro
}
return .xbox360 return .xbox360
} }
} }
@@ -140,7 +140,9 @@ public final class GamepadMenuInput {
let stick = gamepad.leftThumbstick let stick = gamepad.leftThumbstick
let x = stick.xAxis.value let x = stick.xAxis.value
let y = stick.yAxis.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 return x > 0 ? .right : .left
} else if abs(y) > deadzone { } else if abs(y) > deadzone {
return y > 0 ? .up : .down return y > 0 ? .up : .down
@@ -26,11 +26,27 @@ public enum GamepadWire {
public static let y: UInt32 = 0x8000 public static let y: UInt32 = 0x8000
/// DualSense touchpad click (Moonlight's extended-button bit position). /// DualSense touchpad click (Moonlight's extended-button bit position).
public static let touchpadClick: UInt32 = 0x10_0000 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] = [ public static let allButtons: [UInt32] = [
dpadUp, dpadDown, dpadLeft, dpadRight, start, back, dpadUp, dpadDown, dpadLeft, dpadRight, start, back,
leftStickClick, rightStickClick, leftShoulder, rightShoulder, guide, leftStickClick, rightStickClick, leftShoulder, rightShoulder, guide,
a, b, x, y, touchpadClick, a, b, x, y, touchpadClick, misc1,
] ]
public static let axisLSX: UInt32 = 0 public static let axisLSX: UInt32 = 0
@@ -27,11 +27,16 @@ final class GamepadWireTests: XCTestCase {
XCTAssertEqual(GamepadWire.x, 0x4000) XCTAssertEqual(GamepadWire.x, 0x4000)
XCTAssertEqual(GamepadWire.y, 0x8000) XCTAssertEqual(GamepadWire.y, 0x8000)
XCTAssertEqual(GamepadWire.touchpadClick, 0x10_0000) XCTAssertEqual(GamepadWire.touchpadClick, 0x10_0000)
XCTAssertEqual(GamepadWire.misc1, 0x0020_0000)
// Every button is enumerated exactly once (releaseAll walks this list). // Every button is enumerated exactly once (releaseAll walks this list).
let combined: UInt32 = GamepadWire.allButtons.reduce(0) { $0 | $1 } let combined: UInt32 = GamepadWire.allButtons.reduce(0) { $0 | $1 }
XCTAssertEqual(combined, 0x0010_F7FF) XCTAssertEqual(combined, 0x0030_F7FF)
XCTAssertEqual(GamepadWire.allButtons.count, 16) XCTAssertEqual(GamepadWire.allButtons.count, 17)
XCTAssertEqual(GamepadWire.allButtons.count, Set(GamepadWire.allButtons).count) 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. // Axis ids.
XCTAssertEqual(GamepadWire.axisLSX, 0) XCTAssertEqual(GamepadWire.axisLSX, 0)
XCTAssertEqual(GamepadWire.axisLSY, 1) XCTAssertEqual(GamepadWire.axisLSY, 1)
@@ -41,6 +46,42 @@ final class GamepadWireTests: XCTestCase {
XCTAssertEqual(GamepadWire.axisRT, 5) 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() { func testPadIndexRidesFlagsOnEveryPerPadEvent() {
// The wire pad index is the low byte of `flags` (punktfunk_core::input) on button + axis. // 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) 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 CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
const CODEC_LABELS: &[&str] = &["Automatic", "HEVC (H.265)", "H.264 (AVC)", "AV1"]; const CODEC_LABELS: &[&str] = &["Automatic", "HEVC (H.265)", "H.264 (AVC)", "AV1"];
const DECODERS: &[&str] = &["auto", "vaapi", "software"]; 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. /// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
const APP_LICENSE: &str = concat!( const APP_LICENSE: &str = concat!(
@@ -420,12 +424,21 @@ pub fn show(
"Steam Deck", "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() let inhibit_row = adw::SwitchRow::builder()
.title("Capture system shortcuts") .title("Capture system shortcuts")
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured") .subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
.build(); .build();
input.add(forward_row.widget()); input.add(forward_row.widget());
input.add(pad_row.widget()); input.add(pad_row.widget());
input.add(touch_row.widget());
input.add(&inhibit_row); input.add(&inhibit_row);
let audio = adw::PreferencesGroup::builder().title("Audio").build(); 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); bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0); let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0);
pad_row.set_selected(pad_i as u32); 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 let comp_i = COMPOSITORS
.iter() .iter()
.position(|&c| c == s.compositor) .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.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32; 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.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.forward_pad = chosen_pin.borrow().clone();
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)] s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
.to_string(); .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, trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
v => v, v => v,
}, },
touch_mode: settings_at_start.touch_mode(),
json_status, json_status,
on_connected: Some(Box::new(|fingerprint: [u8; 32]| { on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
trust::touch_last_used(&trust::hex(&fingerprint)); 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, trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
v => v, v => v,
}, },
touch_mode: settings.touch_mode(),
json_status: true, json_status: true,
on_connected: Some(Box::new(|fingerprint: [u8; 32]| { on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
// This host's card carries the accent bar in the desktop client now. // 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.) # decode + present live in the spawned punktfunk-session binary.)
ffmpeg-next = "8" ffmpeg-next = "8"
# Gamepads: capture + feedback (full DualSense fidelity needs hidapi). SDL3 is cross-platform; # Gamepad enumeration + pin persistence for Settings runs on pf-client-core's shared SDL service
# built from source via the bundled CMake on Windows (no system SDL3). # (see the `gamepad` field in app/); the spawned punktfunk-session does the actual forwarding. SDL3
sdl3 = { version = "0.18", features = ["build-from-source", "hidapi"] } # 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" mdns-sd = "0.20"
async-channel = "2" async-channel = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
+1 -1
View File
@@ -32,9 +32,9 @@ mod stream;
mod style; mod style;
use crate::discovery::{self, DiscoveredHost}; use crate::discovery::{self, DiscoveredHost};
use crate::gamepad::GamepadService;
use crate::trust::{KnownHosts, Settings}; use crate::trust::{KnownHosts, Settings};
use hosts::HostsProps; use hosts::HostsProps;
use pf_client_core::gamepad::GamepadService;
use punktfunk_core::client::NativeClient; use punktfunk_core::client::NativeClient;
use speed::{SpeedProps, SpeedState}; use speed::{SpeedProps, SpeedState};
use std::collections::HashMap; use std::collections::HashMap;
+16
View File
@@ -55,6 +55,13 @@ const STATS_TIERS: &[(StatsVerbosity, &str)] = &[
(StatsVerbosity::Normal, "Normal"), (StatsVerbosity::Normal, "Normal"),
(StatsVerbosity::Detailed, "Detailed"), (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 /// 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. /// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
const COMPOSITORS: &[(&str, &str)] = &[ 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 \ "The virtual pad the host creates. \u{201C}Automatic\u{201D} matches your physical \
controller.", 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( let shortcuts_toggle = setting_toggle(
ctx, ctx,
"Capture system shortcuts (Alt+Tab, Win, \u{2026})", "Capture system shortcuts (Alt+Tab, Win, \u{2026})",
@@ -405,6 +420,7 @@ pub(crate) fn settings_page(
settings_card(vec![ settings_card(vec![
forward_combo.into(), forward_combo.into(),
pad_combo.into(), pad_combo.into(),
touch_combo.into(),
shortcuts_toggle.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)] #[cfg(windows)]
mod discovery; mod discovery;
#[cfg(windows)] #[cfg(windows)]
mod gamepad;
#[cfg(windows)]
mod gpu; mod gpu;
#[cfg(windows)] #[cfg(windows)]
mod probe; mod probe;
@@ -85,7 +83,11 @@ fn main() {
tracing::error!(error = %e, "Windows App SDK bootstrap failed"); tracing::error!(error = %e, "Windows App SDK bootstrap failed");
std::process::exit(1); 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) { if let Err(e) = app::run(identity, gamepad) {
tracing::error!(error = %e, "WinUI app failed"); tracing::error!(error = %e, "WinUI app failed");
std::process::exit(1); std::process::exit(1);
+56 -6
View File
@@ -71,6 +71,15 @@ const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
/// left untouched. /// left untouched.
const DECK_RUMBLE_KEEPALIVE_MS: u64 = 40; 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 /// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple
/// `GamepadMenuInput` parity; menus want deliberate flicks, not drift). /// `GamepadMenuInput` parity; menus want deliberate flicks, not drift).
const MENU_DEADZONE: u16 = 16384; const MENU_DEADZONE: u16 = 16384;
@@ -255,9 +264,12 @@ impl PadInfo {
pub fn kind_label(&self) -> &'static str { pub fn kind_label(&self) -> &'static str {
match self.pref { match self.pref {
GamepadPref::DualSense => "DualSense", GamepadPref::DualSense => "DualSense",
GamepadPref::DualSenseEdge => "DualSense Edge",
GamepadPref::DualShock4 => "DualShock 4", GamepadPref::DualShock4 => "DualShock 4",
GamepadPref::XboxOne => "Xbox One", GamepadPref::XboxOne => "Xbox One",
GamepadPref::SteamDeck => "Steam Deck", GamepadPref::SteamDeck => "Steam Deck",
GamepadPref::SteamController => "Steam Controller",
GamepadPref::SwitchPro => "Switch Pro",
_ => "", _ => "",
} }
} }
@@ -288,6 +300,9 @@ fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
T::PS5 => GamepadPref::DualSense, T::PS5 => GamepadPref::DualSense,
T::PS4 => GamepadPref::DualShock4, T::PS4 => GamepadPref::DualShock4,
T::XboxOne => GamepadPref::XboxOne, T::XboxOne => GamepadPref::XboxOne,
// A paired Joy-Con set exposes the full Pro button surface through SDL, so it rides
// the same virtual pad; single Joy-Cons stay on the Xbox 360 fallback (half a pad).
T::NintendoSwitchPro | T::NintendoSwitchJoyconPair => GamepadPref::SwitchPro,
_ => GamepadPref::Xbox360, _ => GamepadPref::Xbox360,
} }
} }
@@ -558,9 +573,9 @@ fn button_bit(b: sdl3::gamepad::Button) -> Option<u32> {
fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) { fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
use sdl3::gamepad::Axis; use sdl3::gamepad::Axis;
match 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::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::RightY => (wire::AXIS_RS_Y, -(v as i32).max(-32767)),
Axis::TriggerLeft => (wire::AXIS_LT, (v as i32).clamp(0, 32767) >> 7), Axis::TriggerLeft => (wire::AXIS_LT, (v as i32).clamp(0, 32767) >> 7),
Axis::TriggerRight => (wire::AXIS_RT, (v as i32).clamp(0, 32767) >> 7), Axis::TriggerRight => (wire::AXIS_RT, (v as i32).clamp(0, 32767) >> 7),
@@ -617,6 +632,12 @@ struct RumbleState {
/// drives the Steam Deck haptic keep-alive in [`Worker::render_feedback`]. /// drives the Steam Deck haptic keep-alive in [`Worker::render_feedback`].
last: (u16, u16), last: (u16, u16),
last_at: Option<Instant>, 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 /// 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`]). /// Deck keep-alive re-issue (see [`Worker::issue_rumble`]).
jitter: bool, jitter: bool,
@@ -763,11 +784,20 @@ impl Worker {
self.subsystem.product_for_id(jid).unwrap_or(0), self.subsystem.product_for_id(jid).unwrap_or(0),
); );
// There is no SDL gamepad type for the Steam Deck / Steam Controller, so detect Valve by // There is no SDL gamepad type for the Steam Deck / Steam Controller, so detect Valve by
// VID/PID (Deck 0x1205, SC wired 0x1102, SC dongle 0x1142) — the host then builds the virtual // VID/PID — the host then builds the matching virtual hid-steam pad (grips + trackpads +
// hid-steam pad with the back grips + dual trackpads and the right glyph identity. // the right glyph identity): Deck 0x1205; classic SC wired 0x1102 / dongle 0x1142.
if vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) { if vid == 0x28DE && pid == 0x1205 {
pref = GamepadPref::SteamDeck; pref = GamepadPref::SteamDeck;
} }
if vid == 0x28DE && matches!(pid, 0x1102 | 0x1142) {
pref = GamepadPref::SteamController;
}
// The DualSense Edge has no distinct SDL gamepad type either (it reports PS5) — detect by
// VID/PID so the host builds the virtual Edge and this pad's back paddles land on native
// slots instead of the fold/drop policy.
if vid == 0x054C && pid == 0x0DF2 {
pref = GamepadPref::DualSenseEdge;
}
let name = self let name = self
.subsystem .subsystem
.name_for_id(jid) .name_for_id(jid)
@@ -1492,6 +1522,10 @@ impl Worker {
} }
_ => None, _ => 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); Self::issue_rumble(slot, low, high, deck);
} }
} }
@@ -1511,6 +1545,17 @@ impl Worker {
slot.rumble.deadline = None; slot.rumble.deadline = None;
slot.rumble.ttl_ms = 0; slot.rumble.ttl_ms = 0;
Self::issue_rumble(slot, 0, 0, true); 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 } else if slot
.rumble .rumble
.last_at .last_at
@@ -1526,7 +1571,12 @@ impl Worker {
let Some(slot) = self.slots.iter_mut().find(|s| s.index == idx) else { let Some(slot) = self.slots.iter_mut().find(|s| s.index == idx) else {
continue; continue;
}; };
let is_ds = slot.pref == GamepadPref::DualSense; // A physical Edge takes the same raw DS5 effects packets (SDL's DS5EffectsState_t
// layout is shared; SDL keys the enhanced path off the Edge PID itself).
let is_ds = matches!(
slot.pref,
GamepadPref::DualSense | GamepadPref::DualSenseEdge
);
match hid { match hid {
HidOutput::Led { r, g, b, .. } if is_ds => { HidOutput::Led { r, g, b, .. } if is_ds => {
let _ = slot.pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b)); let _ = slot.pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b));
+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 /// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
/// stays readable; parsed with `*Pref::from_name` at connect time. /// stays readable; parsed with `*Pref::from_name` at connect time.
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
@@ -363,6 +410,12 @@ pub struct Settings {
/// Which host compositor backend to request (advisory; the host falls back to /// Which host compositor backend to request (advisory; the host falls back to
/// auto-detect when unavailable). /// auto-detect when unavailable).
pub compositor: String, 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. /// Grab compositor shortcuts (Alt+Tab, Super…) while input is captured.
pub inhibit_shortcuts: bool, pub inhibit_shortcuts: bool,
/// Stream the default microphone to the host's virtual mic source. /// Stream the default microphone to the host's virtual mic source.
@@ -425,6 +478,10 @@ fn default_codec() -> String {
"auto".into() "auto".into()
} }
fn default_touch_mode() -> String {
"trackpad".into()
}
fn default_true() -> bool { fn default_true() -> bool {
true true
} }
@@ -447,6 +504,11 @@ impl Settings {
self.show_stats = v != StatsVerbosity::Off; 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). /// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
pub fn preferred_codec(&self) -> u8 { pub fn preferred_codec(&self) -> u8 {
match self.codec.as_str() { match self.codec.as_str() {
@@ -468,6 +530,7 @@ impl Default for Settings {
gamepad: "auto".into(), gamepad: "auto".into(),
forward_pad: String::new(), forward_pad: String::new(),
compositor: "auto".into(), compositor: "auto".into(),
touch_mode: "trackpad".into(),
inhibit_shortcuts: true, inhibit_shortcuts: true,
mic_enabled: false, mic_enabled: false,
audio_channels: 2, audio_channels: 2,
@@ -519,6 +582,23 @@ impl Settings {
mod tests { mod tests {
use super::*; 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. /// A pre-`forward_pad` settings file (≤ 0.5.0) loads with the pin on automatic.
#[test] #[test]
fn settings_forward_pad_defaults_empty() { fn settings_forward_pad_defaults_empty() {
+3 -1
View File
@@ -22,7 +22,9 @@ pub(crate) enum GlyphStyle {
impl GlyphStyle { impl GlyphStyle {
pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle { pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle {
match pref { match pref {
Some(GamepadPref::DualSense | GamepadPref::DualShock4) => GlyphStyle::Shapes, Some(
GamepadPref::DualSense | GamepadPref::DualSenseEdge | GamepadPref::DualShock4,
) => GlyphStyle::Shapes,
Some(_) => GlyphStyle::Letters, Some(_) => GlyphStyle::Letters,
None => GlyphStyle::Keyboard, None => GlyphStyle::Keyboard,
} }
+8 -1
View File
@@ -531,10 +531,17 @@ pub mod gamepad {
pub const PAD_MAGIC: u32 = 0x5046_4453; pub const PAD_MAGIC: u32 = 0x5046_4453;
/// `device_type` selector the `pf_dualsense` driver reads to pick its HID identity. The section is /// `device_type` selector the `pf_dualsense` driver reads to pick its HID identity. The section is
/// zeroed, so `0` = DualSense is the default; one driver serves either identity. /// zeroed, so `0` = DualSense is the default; one driver serves every identity.
pub const DEVTYPE_DUALSENSE: u8 = 0; pub const DEVTYPE_DUALSENSE: u8 = 0;
/// `device_type` = DualShock 4 (`VID_054C&PID_09CC` HID identity). /// `device_type` = DualShock 4 (`VID_054C&PID_09CC` HID identity).
pub const DEVTYPE_DUALSHOCK4: u8 = 1; pub const DEVTYPE_DUALSHOCK4: u8 = 1;
/// `device_type` = DualSense Edge (`VID_054C&PID_0DF2` HID identity — the DualSense report
/// codec plus the four native back/Fn button bits).
pub const DEVTYPE_DUALSENSE_EDGE: u8 = 2;
/// `device_type` = **N4-spike** Steam Deck identity (`VID_28DE&PID_1205`). Exists only for
/// the `deck-windows-spike` go/no-go probe (does Steam Input on Windows promote a
/// software-devnode HID Deck?) — never stamped by a session.
pub const DEVTYPE_STEAMDECK_SPIKE: u8 = 3;
/// The value a gamepad driver writes into its section's `driver_proto` field once it attaches — /// The value a gamepad driver writes into its section's `driver_proto` field once it attaches —
/// the host's positive "driver is alive on this section" signal (health check + version audit). /// the host's positive "driver is alive on this section" signal (health check + version audit).
+163 -2
View File
@@ -16,11 +16,21 @@
//! otherwise send a datagram per event). //! otherwise send a datagram per event).
use crate::keymap_sdl; use crate::keymap_sdl;
use crate::touch::{Abs, Act, Gestures};
use pf_client_core::trust::TouchMode;
use punktfunk_core::client::NativeClient; use punktfunk_core::client::NativeClient;
use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::input::{InputEvent, InputKind};
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
use std::sync::Arc; 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 { pub struct Capture {
connector: Arc<NativeClient>, connector: Arc<NativeClient>,
captured: bool, captured: bool,
@@ -34,6 +44,16 @@ pub struct Capture {
/// Fractional wheel remainder per axis (x, y) in 120-unit WHEEL_DELTA space — /// 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. /// precision surfaces deliver sub-unit deltas; truncating each event drops the tail.
scroll_acc: (f64, f64), 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) { 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 { impl Capture {
pub fn new(connector: Arc<NativeClient>) -> Capture { pub fn new(connector: Arc<NativeClient>, touch_mode: TouchMode) -> Capture {
Capture { Capture {
connector, connector,
captured: false, captured: false,
@@ -57,6 +77,9 @@ impl Capture {
held_buttons: HashSet::new(), held_buttons: HashSet::new(),
pending_rel: (0, 0), pending_rel: (0, 0),
scroll_acc: (0.0, 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() { for b in self.held_buttons.drain() {
send(&self.connector, InputKind::MouseButtonUp, b, 0, 0, 0); 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 true
} }
@@ -180,4 +209,136 @@ impl Capture {
} }
self.scroll_acc = (ax, ay); 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))] #[cfg(any(target_os = "linux", windows))]
mod run; mod run;
#[cfg(any(target_os = "linux", windows))] #[cfg(any(target_os = "linux", windows))]
pub mod touch;
#[cfg(any(target_os = "linux", windows))]
pub mod vk; pub mod vk;
#[cfg(windows)] #[cfg(windows)]
mod win32; mod win32;
+337 -20
View File
@@ -13,13 +13,14 @@
//! the stdout line always carries the full Detailed text so parsers see a stable //! the stdout line always carries the full Detailed text so parsers see a stable
//! shape). Logs go to stderr (the binary configures tracing so). //! 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::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
use crate::touch::Abs;
use crate::vk::{FrameInput, Presenter}; use crate::vk::{FrameInput, Presenter};
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use pf_client_core::gamepad::GamepadService; use pf_client_core::gamepad::GamepadService;
use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats}; 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::VulkanDecodeDevice;
use pf_client_core::video::{DecodedFrame, DecodedImage}; use pf_client_core::video::{DecodedFrame, DecodedImage};
use punktfunk_core::client::NativeClient; 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 /// Stats overlay tier at start — gates the OSD panel AND the stdout `stats:` lines
/// (Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed live). /// (Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed live).
pub stats_verbosity: StatsVerbosity, 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. /// Emit the `{"ready":true}` stdout line after the first presented frame.
pub json_status: bool, pub json_status: bool,
/// Called once on `Connected` with the host's fingerprint (trust persistence is the /// 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 /// 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). /// requests a switch, cleared when a decoded frame reaches the target (or on timeout).
resize_overlay: ResizeIndicator, 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 { impl StreamState {
@@ -253,6 +263,7 @@ impl StreamState {
resize_requested: None, resize_requested: None,
shown_mode: None, shown_mode: None,
resize_overlay: ResizeIndicator::default(), 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)] #[cfg(windows)]
crate::win32::set_app_user_model_id(); crate::win32::set_app_user_model_id();
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1"); 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 sdl = sdl3::init().context("SDL init")?;
let video = sdl.video().context("SDL video")?; let video = sdl.video().context("SDL video")?;
let events = sdl.event().context("SDL events")?; let events = sdl.event().context("SDL events")?;
@@ -409,6 +427,47 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// events on desktop, and the door Steam's on-screen keyboard types through under // events on desktop, and the door Steam's on-screen keyboard types through under
// gamescope). Toggled edge-wise — start/stop are not free on Wayland. // gamescope). Toggled edge-wise — start/stop are not free on Wayland.
let mut text_input_on = false; let mut text_input_on = false;
// One-shot on-glass touch diagnostics. Under the Deck's game-mode gamescope, Steam Input
// owns the physical touchscreen and by default emulates it as a virtual trackpad/mouse —
// so the app may see MouseMotion/MouseButton instead of the Finger* events the touch-mode
// engine feeds on (which kills BOTH trackpad and passthrough at once). Set
// `PUNKTFUNK_TOUCH_DEBUG=1` to log every raw finger AND mouse event: one run tells us
// whether native wl_touch is being delivered (Finger* with direct=true) or intercepted.
let touch_debug = std::env::var_os("PUNKTFUNK_TOUCH_DEBUG").is_some();
// Under the Deck's game-mode gamescope the session binary's stderr is swallowed by Steam's
// reaper, so ALSO mirror the debug lines to a file in the app data dir (host-visible at
// ~/.var/app/io.unom.Punktfunk/…), pulled over SSH after a run.
let mut touch_log: Option<std::fs::File> = touch_debug
.then(|| {
let dir = std::env::var_os("XDG_DATA_HOME")
.map(std::path::PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".local/share"))
})
.unwrap_or_else(|| std::path::PathBuf::from("."));
let path = dir.join("punktfunk-touch-debug.log");
match std::fs::OpenOptions::new().create(true).append(true).open(&path) {
Ok(f) => {
tracing::info!(path = %path.display(), "touch-debug: mirroring to file");
Some(f)
}
Err(e) => {
tracing::warn!(error = %e, "touch-debug: file sink open failed");
None
}
}
})
.flatten();
// Defined after `touch_log` so the literal identifier resolves to that local; a no-op when
// the sink is absent (env unset or open failed).
macro_rules! touch_file_log {
($($arg:tt)*) => {
if let Some(f) = touch_log.as_mut() {
use std::io::Write;
let _ = writeln!(f, $($arg)*);
}
};
}
let outcome = 'main: loop { let outcome = 'main: loop {
// --- SDL events (input, window, gamepads) --------------------------------------- // --- SDL events (input, window, gamepads) ---------------------------------------
@@ -512,24 +571,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
continue; continue;
} }
if chord && sc == Scancode::S { 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"); 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; continue;
} }
// F11 or Alt+Enter (some keyboards' Fn layer sends a media key for // F11 or Alt+Enter (some keyboards' Fn layer sends a media key for
@@ -556,11 +599,19 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
} }
} }
Event::MouseMotion { xrel, yrel, .. } => { Event::MouseMotion { xrel, yrel, .. } => {
if touch_debug {
tracing::info!(xrel, yrel, "touch-debug: MouseMotion");
touch_file_log!("MouseMotion xrel={xrel} yrel={yrel}");
}
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
cap.on_motion(xrel, yrel); cap.on_motion(xrel, yrel);
} }
} }
Event::MouseButtonDown { mouse_btn, .. } => { Event::MouseButtonDown { mouse_btn, .. } => {
if touch_debug {
tracing::info!(?mouse_btn, "touch-debug: MouseButtonDown");
touch_file_log!("MouseButtonDown mouse_btn={mouse_btn:?}");
}
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
if !cap.captured() { if !cap.captured() {
// The engaging click is suppressed toward the host. // The engaging click is suppressed toward the host.
@@ -572,6 +623,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
} }
} }
Event::MouseButtonUp { mouse_btn, .. } => { Event::MouseButtonUp { mouse_btn, .. } => {
if touch_debug {
tracing::info!(?mouse_btn, "touch-debug: MouseButtonUp");
touch_file_log!("MouseButtonUp mouse_btn={mouse_btn:?}");
}
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
cap.on_button_up(mouse_btn); cap.on_button_up(mouse_btn);
} }
@@ -581,6 +636,120 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
cap.on_wheel(x, y); 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 touch_debug {
tracing::info!(
touch_id,
finger_id,
x,
y,
direct = is_direct_touch(touch_id),
"touch-debug: FingerDown"
);
touch_file_log!(
"FingerDown touch_id={touch_id} finger_id={finger_id} x={x} y={y} direct={}",
is_direct_touch(touch_id)
);
}
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 touch_debug {
tracing::info!(
touch_id,
finger_id,
x,
y,
direct = is_direct_touch(touch_id),
"touch-debug: FingerMotion"
);
touch_file_log!(
"FingerMotion touch_id={touch_id} finger_id={finger_id} x={x} y={y} direct={}",
is_direct_touch(touch_id)
);
}
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 touch_debug {
tracing::info!(
touch_id,
finger_id,
x,
y,
direct = is_direct_touch(touch_id),
"touch-debug: FingerUp"
);
touch_file_log!(
"FingerUp touch_id={touch_id} finger_id={finger_id} x={x} y={y} direct={}",
is_direct_touch(touch_id)
);
}
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 // The wake forwarder's FrameWake (and any other user event): pure
// wake-up — the frame drain below runs this iteration either way. // wake-up — the frame drain below runs this iteration either way.
Event::User { .. } => {} Event::User { .. } => {}
@@ -713,7 +882,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
.ok(); .ok();
gamepad.attach(c.clone()); gamepad.attach(c.clone());
st.clock_offset = Some(c.clock_offset_shared()); 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) cap.engage(); // capture engages when the stream starts (ui_stream parity)
apply_capture(&mut window, &mouse, true); apply_capture(&mut window, &mouse, true);
st.capture = Some(cap); st.capture = Some(cap);
@@ -887,6 +1056,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. // picture is here — lift the scrim. A no-op unless a switch is in flight.
let (fw, fh) = f.image.dimensions(); let (fw, fh) = f.image.dimensions();
st.resize_overlay.decoded(fw, fh); st.resize_overlay.decoded(fw, fh);
st.last_video = Some((fw, fh)); // touch passthrough's source rect
let DecodedFrame { let DecodedFrame {
pts_ns, pts_ns,
decoded_ns, decoded_ns,
@@ -1291,6 +1461,111 @@ fn apply_capture(window: &mut sdl3::video::Window, mouse: &sdl3::mouse::MouseUti
window.set_keyboard_grab(on); 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. /// The presenter's share of the unified stats window — folded into each printed line.
#[derive(Default)] #[derive(Default)]
struct PresentedWindow { struct PresentedWindow {
@@ -1614,4 +1889,46 @@ mod tests {
"120 fps · 24 Mb/s" "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
},
]
);
}
}
+10 -2
View File
@@ -882,13 +882,19 @@ pub const PUNKTFUNK_GAMEPAD_XBOXONE: u32 = 3;
/// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux /// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux
/// hosts); otherwise the host falls back to X-Box 360. /// hosts); otherwise the host falls back to X-Box 360.
pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4; pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): dual trackpads, gyro, /// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual
/// two grip paddles. Reserved — currently folds to `XBOX360` until its backend lands. /// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360.
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5; pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5;
/// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`): full Deck gamepad incl. the /// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`): full Deck gamepad incl. the
/// four back grips, a right trackpad, and the IMU; re-grabbed by Steam Input with native glyphs when /// four back grips, a right trackpad, and the IMU; re-grabbed by Steam Input with native glyphs when
/// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360. /// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360.
pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6; pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6;
/// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
/// client's back paddles land on native slots. Folds to `DUALSENSE` until its backend lands.
pub const PUNKTFUNK_GAMEPAD_DUALSENSEEDGE: u32 = 7;
/// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo`): Nintendo glyphs +
/// positional layout, gyro/accel, HD rumble. Folds to `XBOX360` until its backend lands.
pub const PUNKTFUNK_GAMEPAD_SWITCHPRO: u32 = 8;
/// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips /// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips
/// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1P4) + the misc/capture button, in Moonlight's /// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1P4) + the misc/capture button, in Moonlight's
@@ -945,6 +951,8 @@ const _: () = {
assert!(PUNKTFUNK_GAMEPAD_DUALSHOCK4 == GamepadPref::DualShock4.to_u8() as u32); assert!(PUNKTFUNK_GAMEPAD_DUALSHOCK4 == GamepadPref::DualShock4.to_u8() as u32);
assert!(PUNKTFUNK_GAMEPAD_STEAMCONTROLLER == GamepadPref::SteamController.to_u8() as u32); assert!(PUNKTFUNK_GAMEPAD_STEAMCONTROLLER == GamepadPref::SteamController.to_u8() as u32);
assert!(PUNKTFUNK_GAMEPAD_STEAMDECK == GamepadPref::SteamDeck.to_u8() as u32); assert!(PUNKTFUNK_GAMEPAD_STEAMDECK == GamepadPref::SteamDeck.to_u8() as u32);
assert!(PUNKTFUNK_GAMEPAD_DUALSENSEEDGE == GamepadPref::DualSenseEdge.to_u8() as u32);
assert!(PUNKTFUNK_GAMEPAD_SWITCHPRO == GamepadPref::SwitchPro.to_u8() as u32);
// Extended button bits mirror the wire `input::gamepad` constants. // Extended button bits mirror the wire `input::gamepad` constants.
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE1 == g::BTN_PADDLE1); assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE1 == g::BTN_PADDLE1);
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE2 == g::BTN_PADDLE2); assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE2 == g::BTN_PADDLE2);
+24 -7
View File
@@ -138,8 +138,8 @@ impl CompositorPref {
/// honored only if that backend is available on the host (DualSense / DualShock 4 need Linux UHID); /// honored only if that backend is available on the host (DualSense / DualShock 4 need Linux UHID);
/// otherwise the host falls back and reports the real choice in `Welcome`. The wire form is a single /// otherwise the host falls back and reports the real choice in `Welcome`. The wire form is a single
/// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`, /// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
/// `5 = SteamController`, `6 = SteamDeck`), appended to `Hello`/`Welcome` — older peers simply /// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`), appended to
/// omit/ignore it (an unknown byte degrades to `Auto`). /// `Hello`/`Welcome` — older peers simply omit/ignore it (an unknown byte degrades to `Auto`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum GamepadPref { pub enum GamepadPref {
/// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). /// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360).
@@ -156,19 +156,26 @@ pub enum GamepadPref {
/// UHID DualShock 4 (kernel `hid-playstation`, ≥ 6.2) — lightbar, touchpad, motion, rumble. Like /// UHID DualShock 4 (kernel `hid-playstation`, ≥ 6.2) — lightbar, touchpad, motion, rumble. Like
/// `DualSense` minus adaptive triggers / player LEDs / mute. Needs Linux UHID on the host. /// `DualSense` minus adaptive triggers / player LEDs / mute. Needs Linux UHID on the host.
DualShock4, DualShock4,
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`) — dual trackpads, gyro, /// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`) — one stick + dual
/// two grip paddles, trackpad-only haptics. Needs Linux UHID. *(Reserved; its backend is not yet /// trackpads + two grip paddles. The wire right stick drives the right pad; a left-pad contact
/// built — currently folds to `Xbox360`; the Deck identity below is the implemented one.)* /// shadows the stick (hardware multiplex). Needs Linux UHID.
SteamController, SteamController,
/// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`) — full Deck gamepad incl. /// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`) — full Deck gamepad incl.
/// the four back grips (L4/L5/R4/R5), a right trackpad, and the IMU; re-grabbed by Steam Input /// the four back grips (L4/L5/R4/R5), a right trackpad, and the IMU; re-grabbed by Steam Input
/// with native glyphs when Steam runs on the host. Needs Linux UHID. /// with native glyphs when Steam runs on the host. Needs Linux UHID.
SteamDeck, SteamDeck,
/// DualSense Edge (Sony `054C:0DF2`, kernel `hid-playstation` ≥ 6.3 / Windows UMDF) — the
/// DualSense plus two back buttons + two Fn buttons, so a client's back paddles (Deck grips,
/// Elite P1P4) land on a native slot instead of the fold/drop policy.
DualSenseEdge,
/// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo` ≥ 5.16) —
/// correct Nintendo glyphs + positional layout, gyro/accel, HD rumble back. Needs Linux UHID.
SwitchPro,
} }
impl GamepadPref { impl GamepadPref {
/// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`, /// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
/// `5 = SteamController`, `6 = SteamDeck`. /// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`.
pub const fn to_u8(self) -> u8 { pub const fn to_u8(self) -> u8 {
match self { match self {
GamepadPref::Auto => 0, GamepadPref::Auto => 0,
@@ -178,6 +185,8 @@ impl GamepadPref {
GamepadPref::DualShock4 => 4, GamepadPref::DualShock4 => 4,
GamepadPref::SteamController => 5, GamepadPref::SteamController => 5,
GamepadPref::SteamDeck => 6, GamepadPref::SteamDeck => 6,
GamepadPref::DualSenseEdge => 7,
GamepadPref::SwitchPro => 8,
} }
} }
@@ -191,6 +200,8 @@ impl GamepadPref {
4 => GamepadPref::DualShock4, 4 => GamepadPref::DualShock4,
5 => GamepadPref::SteamController, 5 => GamepadPref::SteamController,
6 => GamepadPref::SteamDeck, 6 => GamepadPref::SteamDeck,
7 => GamepadPref::DualSenseEdge,
8 => GamepadPref::SwitchPro,
_ => GamepadPref::Auto, _ => GamepadPref::Auto,
} }
} }
@@ -208,12 +219,16 @@ impl GamepadPref {
"dualshock4" | "dualshock" | "ds4" | "ps4" => GamepadPref::DualShock4, "dualshock4" | "dualshock" | "ds4" | "ps4" => GamepadPref::DualShock4,
"steamdeck" | "steam-deck" | "deck" => GamepadPref::SteamDeck, "steamdeck" | "steam-deck" | "deck" => GamepadPref::SteamDeck,
"steamcontroller" | "steam-controller" | "steamcon" => GamepadPref::SteamController, "steamcontroller" | "steam-controller" | "steamcon" => GamepadPref::SteamController,
"dualsenseedge" | "dualsense-edge" | "edge" | "dsedge" => GamepadPref::DualSenseEdge,
"switchpro" | "switch-pro" | "switch" | "procontroller" | "pro-controller" => {
GamepadPref::SwitchPro
}
_ => return None, _ => return None,
}) })
} }
/// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`, /// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`,
/// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`). /// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`, `"dualsenseedge"`, `"switchpro"`).
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
GamepadPref::Auto => "auto", GamepadPref::Auto => "auto",
@@ -223,6 +238,8 @@ impl GamepadPref {
GamepadPref::DualShock4 => "dualshock4", GamepadPref::DualShock4 => "dualshock4",
GamepadPref::SteamController => "steamcontroller", GamepadPref::SteamController => "steamcontroller",
GamepadPref::SteamDeck => "steamdeck", GamepadPref::SteamDeck => "steamdeck",
GamepadPref::DualSenseEdge => "dualsenseedge",
GamepadPref::SwitchPro => "switchpro",
} }
} }
} }
+30 -3
View File
@@ -275,18 +275,45 @@ fn gamepad_pref_wire_and_names() {
GamepadPref::DualSense, GamepadPref::DualSense,
GamepadPref::XboxOne, GamepadPref::XboxOne,
GamepadPref::DualShock4, GamepadPref::DualShock4,
GamepadPref::SteamController,
GamepadPref::SteamDeck,
GamepadPref::DualSenseEdge,
GamepadPref::SwitchPro,
] { ] {
assert_eq!(GamepadPref::from_u8(p.to_u8()), p); assert_eq!(GamepadPref::from_u8(p.to_u8()), p);
assert_eq!(GamepadPref::from_name(p.as_str()), Some(p)); assert_eq!(GamepadPref::from_name(p.as_str()), Some(p));
} }
// Distinct wire bytes (forward-compat with peers that only know 0..=2). // Every wire byte 0..=8 is assigned, distinct, and pinned (forward-compat with peers
assert_eq!(GamepadPref::XboxOne.to_u8(), 3); // that only know a prefix of the range).
assert_eq!(GamepadPref::DualShock4.to_u8(), 4); for (v, p) in [
(0, GamepadPref::Auto),
(1, GamepadPref::Xbox360),
(2, GamepadPref::DualSense),
(3, GamepadPref::XboxOne),
(4, GamepadPref::DualShock4),
(5, GamepadPref::SteamController),
(6, GamepadPref::SteamDeck),
(7, GamepadPref::DualSenseEdge),
(8, GamepadPref::SwitchPro),
] {
assert_eq!(p.to_u8(), v);
assert_eq!(GamepadPref::from_u8(v), p);
}
// The next unassigned byte degrades to Auto today; assigning it later must update this.
assert_eq!(GamepadPref::from_u8(9), GamepadPref::Auto);
// Aliases + unknowns. // Aliases + unknowns.
assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense)); assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense));
assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360)); assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360));
assert_eq!(GamepadPref::from_name("ps4"), Some(GamepadPref::DualShock4)); assert_eq!(GamepadPref::from_name("ps4"), Some(GamepadPref::DualShock4));
assert_eq!(GamepadPref::from_name("DS4"), Some(GamepadPref::DualShock4)); assert_eq!(GamepadPref::from_name("DS4"), Some(GamepadPref::DualShock4));
assert_eq!(
GamepadPref::from_name("edge"),
Some(GamepadPref::DualSenseEdge)
);
assert_eq!(
GamepadPref::from_name("Switch-Pro"),
Some(GamepadPref::SwitchPro)
);
assert_eq!( assert_eq!(
GamepadPref::from_name("xbox-one"), GamepadPref::from_name("xbox-one"),
Some(GamepadPref::XboxOne) Some(GamepadPref::XboxOne)
+32 -23
View File
@@ -50,29 +50,38 @@ pub struct GamepadFrame {
pub rs_y: i16, pub rs_y: i16,
} }
// buttonFlags bits (Limelight.h). // GameStream's `buttonFlags | buttonFlags2 << 16` layout (Limelight.h) is bit-identical to
pub const BTN_DPAD_UP: u32 = 0x0001; // punktfunk's native gamepad wire, so source these from the single point of truth in `punktfunk_core`
pub const BTN_DPAD_DOWN: u32 = 0x0002; // instead of re-declaring the values (the two drifted while separately hand-typed: the click bits
pub const BTN_DPAD_LEFT: u32 = 0x0004; // were named `BTN_LS_CLK`/`BTN_RS_CLK` here vs the core `…_CLICK`). `decode` merges the two 16-bit
pub const BTN_DPAD_RIGHT: u32 = 0x0008; // halves into `buttons` raw; these names exist for the uinput injector's button map + hat math. The
pub const BTN_START: u32 = 0x0010; // extended touchpad-click / Share bits (`BTN_TOUCHPAD` / `BTN_MISC1`) ride `buttons` too but are
pub const BTN_BACK: u32 = 0x0020; // consumed straight from `punktfunk_core` by the DualSense/DS4 protos, so they aren't re-named here.
pub const BTN_LS_CLK: u32 = 0x0040; //
pub const BTN_RS_CLK: u32 = 0x0080; // These are `pub const` aliases rather than a `pub use` re-export on purpose: on Windows the sole
pub const BTN_LB: u32 = 0x0100; // consumer (the Linux uinput map) is cfg'd out, and an unused re-export lints as an error there,
pub const BTN_RB: u32 = 0x0200; // whereas an unused `pub const` does not. The values still come only from core, so they can't drift;
pub const BTN_GUIDE: u32 = 0x0400; // the exact wire values are pinned by `punktfunk1.rs::gamepad_wire_bits_are_pinned`.
pub const BTN_A: u32 = 0x1000; use punktfunk_core::input::gamepad as wire;
pub const BTN_B: u32 = 0x2000; pub const BTN_DPAD_UP: u32 = wire::BTN_DPAD_UP;
pub const BTN_X: u32 = 0x4000; pub const BTN_DPAD_DOWN: u32 = wire::BTN_DPAD_DOWN;
pub const BTN_Y: u32 = 0x8000; pub const BTN_DPAD_LEFT: u32 = wire::BTN_DPAD_LEFT;
// Extended buttons in the `buttonFlags2 << 16` namespace (mirror `punktfunk_core::input::gamepad`): pub const BTN_DPAD_RIGHT: u32 = wire::BTN_DPAD_RIGHT;
// the four back-grip paddles. `decode` already merges `buttonFlags2 << 16` into `buttons`, but the pub const BTN_START: u32 = wire::BTN_START;
// injector map dropped these bits — Sunshine/Moonlight paddle clients were silently no-op'd. pub const BTN_BACK: u32 = wire::BTN_BACK;
pub const BTN_PADDLE1: u32 = 0x0001_0000; pub const BTN_LS_CLICK: u32 = wire::BTN_LS_CLICK;
pub const BTN_PADDLE2: u32 = 0x0002_0000; pub const BTN_RS_CLICK: u32 = wire::BTN_RS_CLICK;
pub const BTN_PADDLE3: u32 = 0x0004_0000; pub const BTN_LB: u32 = wire::BTN_LB;
pub const BTN_PADDLE4: u32 = 0x0008_0000; 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, /// 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`]). /// keyboard, keepalives etc. yield `None` (they're handled by [`super::input::decode`]).
+37 -3
View File
@@ -482,11 +482,16 @@ pub mod dualsense_proto;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "inject/windows/dualsense_windows.rs"] #[path = "inject/windows/dualsense_windows.rs"]
pub mod dualsense_windows; pub mod dualsense_windows;
/// Windows: virtual DualSense **Edge** via the same UMDF minidriver + shared-memory channel
/// (device-type 2) — the wire back grips land on the Edge's native back/Fn buttons.
#[cfg(target_os = "windows")]
#[path = "inject/windows/dualsense_edge_windows.rs"]
pub mod dualsense_edge_windows;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/linux/dualshock4.rs"] #[path = "inject/linux/dualshock4.rs"]
pub mod dualshock4; pub mod dualshock4;
/// Transport-independent DualShock 4 HID codec used by the Windows UMDF-driver backend /// Transport-independent DualShock 4 HID codec, shared by the Linux UHID backend ([`dualshock4`])
/// ([`dualshock4_windows`]). (The Linux backend still carries its own copy — see the module FIXME.) /// and the Windows UMDF-driver backend ([`dualshock4_windows`]).
#[cfg(any(target_os = "linux", target_os = "windows"))] #[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/proto/dualshock4_proto.rs"] #[path = "inject/proto/dualshock4_proto.rs"]
pub mod dualshock4_proto; pub mod dualshock4_proto;
@@ -506,10 +511,31 @@ pub mod gamepad;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "inject/windows/gamepad_raii.rs"] #[path = "inject/windows/gamepad_raii.rs"]
mod gamepad_raii; mod gamepad_raii;
/// Shared virtual-pad creation-retry policy ([`pad_gate::PadGate`]), driven by [`pad_slots`] for
/// every backend manager — 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;
/// Shared virtual-pad slot table + creation lifecycle ([`pad_slots::PadSlots`]) — the
/// `Vec<Option<Pad>>` table, `active_mask` unplug sweep, and gate-checked create every backend
/// manager used to copy-paste (G12).
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/pad_slots.rs"]
pub mod pad_slots;
/// Linux: virtual Steam Deck via UHID — the kernel `hid-steam` driver binds it as a real Deck. /// Linux: virtual Steam Deck via UHID — the kernel `hid-steam` driver binds it as a real Deck.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/linux/steam_controller.rs"] #[path = "inject/linux/steam_controller.rs"]
pub mod steam_controller; pub mod steam_controller;
/// Linux: virtual Nintendo Switch Pro Controller via UHID (kernel `hid-nintendo`).
#[cfg(target_os = "linux")]
#[path = "inject/linux/switch_pro.rs"]
pub mod switch_pro;
/// Transport-independent Switch Pro Controller codec + the canned `hid-nintendo` handshake
/// replies, used by the Linux UHID backend ([`switch_pro`]).
#[cfg(target_os = "linux")]
#[path = "inject/proto/switch_proto.rs"]
pub mod switch_proto;
/// Linux: virtual Steam Deck via the USB gadget subsystem (`raw_gadget` + `dummy_hcd`) — the only /// Linux: virtual Steam Deck via the USB gadget subsystem (`raw_gadget` + `dummy_hcd`) — the only
/// virtual-Deck transport Steam Input promotes (presents the controller on USB interface 2). /// virtual-Deck transport Steam Input promotes (presents the controller on USB interface 2).
/// SteamOS-host only (needs `dummy_hcd` + `raw_gadget`). /// SteamOS-host only (needs `dummy_hcd` + `raw_gadget`).
@@ -522,7 +548,9 @@ pub mod steam_gadget;
#[path = "inject/proto/steam_proto.rs"] #[path = "inject/proto/steam_proto.rs"]
pub mod steam_proto; pub mod steam_proto;
/// Pure fallback-remap policy (Steam-only inputs onto a non-Steam backend) + the Deck motion rescale. /// 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"] #[path = "inject/proto/steam_remap.rs"]
pub mod steam_remap; pub mod steam_remap;
/// Linux: virtual Steam Deck over **USB/IP** (`vhci_hcd`) — the shippable, Secure-Boot-clean, /// Linux: virtual Steam Deck over **USB/IP** (`vhci_hcd`) — the shippable, Secure-Boot-clean,
@@ -531,6 +559,12 @@ pub mod steam_remap;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/linux/steam_usbip.rs"] #[path = "inject/linux/steam_usbip.rs"]
pub mod steam_usbip; pub mod steam_usbip;
/// The generic stateful virtual-pad manager ([`uhid_manager::UhidManager`]) — event routing, frame
/// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only
/// its per-controller protocol via [`uhid_manager::PadProto`] (G12).
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/uhid_manager.rs"]
pub mod uhid_manager;
/// Stub — virtual gamepads need Linux uinput or the Windows UMDF drivers; events are dropped elsewhere. /// Stub — virtual gamepads need Linux uinput or the Windows UMDF drivers; events are dropped elsewhere.
#[cfg(not(any(target_os = "linux", target_os = "windows")))] #[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub mod gamepad { pub mod gamepad {
@@ -13,17 +13,17 @@
//! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it. //! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it.
use super::dualsense_proto::{ use super::dualsense_proto::{
parse_ds_output, serialize_state, DsFeedback, DsState, DS_FEATURE_CALIBRATION, edge_paddle_bits, parse_ds_output, serialize_state, DsFeedback, DsState,
DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING, DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_TOUCH_H, DS_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING,
DS_TOUCH_W, DS_VENDOR, DUALSENSE_RDESC, DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC,
DUALSENSE_RDESC,
}; };
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use punktfunk_core::quic::{HidOutput, RichInput}; use punktfunk_core::quic::RichInput;
use std::fs::{File, OpenOptions}; use std::fs::{File, OpenOptions};
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
use std::time::{Duration, Instant};
// /dev/uhid event ABI (linux/uhid.h). `struct uhid_event` is __packed__: a u32 `type` then a // /dev/uhid event ABI (linux/uhid.h). `struct uhid_event` is __packed__: a u32 `type` then a
// union whose largest member is uhid_create2_req (128+64+64 + 2+2 + 4*4 + rd_data[4096] = 4372). // union whose largest member is uhid_create2_req (128+64+64 + 2+2 + 4*4 + rd_data[4096] = 4372).
@@ -44,9 +44,45 @@ fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated) ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
} }
/// A virtual DualSense backed by `/dev/uhid` (hand-rolled codec — no bindgen, mirroring the /// The UHID identity a [`DualSensePad`] is created with — the plain DualSense or the Edge (same
/// uinput pad's style). Dropping it destroys the device (the kernel tears down the bound /// driver, same report codec; the Edge differs by PID + descriptor and carries the four extra
/// `hid-playstation` interface). /// `buttons[2]` bits). Mirrors the uinput pad's `PadIdentity` shape.
pub struct DsUhidIdentity {
product: u32,
rdesc: &'static [u8],
/// Device name prefix ("Punktfunk <name> <index>").
name: &'static str,
/// Path token for the phys string ("punktfunk/<phys>/<index>").
phys: &'static str,
/// Short slug for the uniq string ("punktfunk-<slug>-<index>").
slug: &'static str,
}
impl DsUhidIdentity {
pub const fn dualsense() -> DsUhidIdentity {
DsUhidIdentity {
product: DS_PRODUCT,
rdesc: DUALSENSE_RDESC,
name: "DualSense",
phys: "dualsense",
slug: "ds",
}
}
pub const fn dualsense_edge() -> DsUhidIdentity {
DsUhidIdentity {
product: DS_EDGE_PRODUCT,
rdesc: DUALSENSE_EDGE_RDESC,
name: "DualSense Edge",
phys: "dualsense-edge",
slug: "dsedge",
}
}
}
/// A virtual DualSense / DualSense Edge backed by `/dev/uhid` (hand-rolled codec — no bindgen,
/// mirroring the uinput pad's style). Dropping it destroys the device (the kernel tears down the
/// bound `hid-playstation` interface).
pub struct DualSensePad { pub struct DualSensePad {
fd: File, fd: File,
seq: u8, seq: u8,
@@ -54,8 +90,9 @@ pub struct DualSensePad {
} }
impl DualSensePad { impl DualSensePad {
/// Create the UHID DualSense for pad `index` (used only to make the device name/uniq unique). /// Create the UHID pad for wire index `index` under `id`'s identity (`index` is used only to
pub fn open(index: u8) -> Result<DualSensePad> { /// make the device name/uniq unique).
pub fn open(index: u8, id: &DsUhidIdentity) -> Result<DualSensePad> {
let fd = OpenOptions::new() let fd = OpenOptions::new()
.read(true) .read(true)
.write(true) .write(true)
@@ -65,24 +102,24 @@ impl DualSensePad {
format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)") format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)")
})?; })?;
let mut ds = DualSensePad { fd, seq: 0, ts: 0 }; let mut ds = DualSensePad { fd, seq: 0, ts: 0 };
ds.send_create2(index).context("UHID_CREATE2 DualSense")?; ds.send_create2(index, id).context("UHID_CREATE2 DualSense")?;
Ok(ds) Ok(ds)
} }
fn send_create2(&mut self, index: u8) -> Result<()> { fn send_create2(&mut self, index: u8, id: &DsUhidIdentity) -> Result<()> {
let mut ev = [0u8; UHID_EVENT_SIZE]; let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes()); ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
// union (uhid_create2_req) starts at byte 4. // union (uhid_create2_req) starts at byte 4.
put_cstr(&mut ev, 4, 128, &format!("Punktfunk DualSense {index}")); // name[128] put_cstr(&mut ev, 4, 128, &format!("Punktfunk {} {index}", id.name)); // name[128]
put_cstr(&mut ev, 132, 64, &format!("punktfunk/dualsense/{index}")); // phys[64] put_cstr(&mut ev, 132, 64, &format!("punktfunk/{}/{index}", id.phys)); // phys[64]
put_cstr(&mut ev, 196, 64, &format!("punktfunk-ds-{index}")); // uniq[64] put_cstr(&mut ev, 196, 64, &format!("punktfunk-{}-{index}", id.slug)); // uniq[64]
ev[260..262].copy_from_slice(&(DUALSENSE_RDESC.len() as u16).to_ne_bytes()); // rd_size ev[260..262].copy_from_slice(&(id.rdesc.len() as u16).to_ne_bytes()); // rd_size
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus
ev[264..268].copy_from_slice(&DS_VENDOR.to_ne_bytes()); ev[264..268].copy_from_slice(&DS_VENDOR.to_ne_bytes());
ev[268..272].copy_from_slice(&DS_PRODUCT.to_ne_bytes()); ev[268..272].copy_from_slice(&id.product.to_ne_bytes());
ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
ev[280..280 + DUALSENSE_RDESC.len()].copy_from_slice(DUALSENSE_RDESC); // rd_data ev[280..280 + id.rdesc.len()].copy_from_slice(id.rdesc); // rd_data
self.fd.write_all(&ev).context("write UHID_CREATE2")?; self.fd.write_all(&ev).context("write UHID_CREATE2")?;
Ok(()) Ok(())
} }
@@ -162,81 +199,49 @@ impl Drop for DualSensePad {
} }
} }
/// All virtual DualSense pads of a session — the rich-controller analog of /// The DualSense-specific half of the shared stateful manager (see [`PadProto`]): UHID transport
/// [`GamepadManager`](super::gamepad::GamepadManager), selected with `PUNKTFUNK_GAMEPAD=dualsense`. /// open, the [`DsState`] mappers, and the kernel-handshake service pass. Everything lifecycle-
/// /// shaped (slot table, unplug sweep, heartbeat, feedback dedup) lives in [`UhidManager`].
/// Unlike the uinput pad, a DualSense carries touchpad + motion, which arrive on a *separate* pub struct DsLinuxProto {
/// rich-input plane ([`apply_rich`](Self::apply_rich)) from the button/stick frames
/// ([`handle`](Self::handle)). So the manager keeps each pad's full [`DsState`] and re-emits the
/// merged report whenever either source changes. [`pump`](Self::pump) services the kernel
/// handshake and routes a game's feedback back out: motor rumble on the universal plane, the rich
/// LED/player-LED/trigger feedback on the HID-output plane.
pub struct DualSenseManager {
pads: Vec<Option<DualSensePad>>,
/// Each pad's current full report — buttons/sticks merged with persisted touch + motion.
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)>,
/// 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,
/// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button /// 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. /// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
remap: crate::inject::steam_remap::RemapConfig, remap: crate::inject::steam_remap::RemapConfig,
} }
impl Default for DualSenseManager { impl Default for DsLinuxProto {
fn default() -> DualSenseManager { fn default() -> DsLinuxProto {
DualSenseManager::new() DsLinuxProto {
}
}
impl DualSenseManager {
pub fn new() -> DualSenseManager {
DualSenseManager {
pads: (0..MAX_PADS).map(|_| None).collect(),
state: vec![DsState::neutral(); MAX_PADS],
last_rumble: vec![(0, 0); MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
broken: false,
remap: crate::inject::steam_remap::RemapConfig::from_env(), remap: crate::inject::steam_remap::RemapConfig::from_env(),
} }
} }
}
/// Handle one decoded controller event (create/destroy by mask, then merge button/stick state). impl PadProto for DsLinuxProto {
pub fn handle(&mut self, ev: &GamepadEvent) { type Pad = DualSensePad;
match ev { type State = DsState;
GamepadEvent::Arrival { index, kind, .. } => { const LABEL: &'static str = "DualSense";
tracing::info!(index, kind, "controller arrival (DualSense)"); const DEVICE: &'static str = "DualSense";
self.ensure(*index as usize); const CREATE_HINT: &'static str = "";
fn open(&mut self, idx: u8) -> Result<DualSensePad> {
let p = DualSensePad::open(idx, &DsUhidIdentity::dualsense())?;
tracing::info!(
index = idx,
"virtual DualSense created (UHID hid-playstation)"
);
Ok(p)
} }
GamepadEvent::State(f) => {
let idx = f.index as usize; fn neutral(&self) -> DsState {
if idx >= MAX_PADS { DsState::neutral()
return;
} }
// Unplugs: drop any allocated pad whose mask bit cleared, resetting its state.
for (i, slot) in self.pads.iter_mut().enumerate() { /// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (those
if slot.is_some() && f.active_mask & (1 << i) == 0 { /// come on the rich-input plane and must survive a button-only frame).
tracing::info!(index = i, "controller unplugged (DualSense)"); fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
*slot = None;
self.state[i] = DsState::neutral();
self.last_rumble[i] = (0, 0);
}
}
if f.active_mask & (1 << idx) == 0 {
return; // this event WAS the unplug
}
self.ensure(idx);
// Merge buttons/sticks/triggers from the frame, preserving touch + motion (those
// come on the rich-input plane and must survive a button-only frame).
let prev = self.state[idx];
// Steam back grips have no DualSense slot — fold them onto standard buttons per the // Steam back grips have no DualSense slot — fold them onto standard buttons per the
// configured policy (default drop) so they aren't silently lost. // configured policy (default drop) so they aren't silently lost.
let buttons = let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
let mut s = DsState::from_gamepad( let mut s = DsState::from_gamepad(
buttons, buttons,
f.ls_x, f.ls_x,
@@ -250,101 +255,117 @@ impl DualSenseManager {
s.gyro = prev.gyro; s.gyro = prev.gyro;
s.accel = prev.accel; s.accel = prev.accel;
s.touch_click = prev.touch_click; s.touch_click = prev.touch_click;
self.state[idx] = s; s
self.write(idx);
}
}
} }
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad, /// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
/// preserving its button/stick state. Rich events never create a pad (a controller must have /// split the one touchpad left/right, pad clicks ride touch_click.
/// arrived first); they're dropped if the pad isn't present. fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
pub fn apply_rich(&mut self, rich: RichInput) { st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
let idx = match rich {
RichInput::Touchpad { pad, .. }
| RichInput::Motion { pad, .. }
| RichInput::TouchpadEx { pad, .. } => pad as usize,
};
if idx >= MAX_PADS || self.pads[idx].is_none() {
return;
}
// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam
// dual pads split the one touchpad left/right, pad clicks ride touch_click.
self.state[idx].apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
self.write(idx);
} }
fn write(&mut self, idx: usize) { fn write_state(&self, pad: &mut DualSensePad, st: &DsState) {
let st = self.state[idx]; let _ = pad.write_state(st);
if let Some(pad) = self.pads[idx].as_mut() {
let _ = pad.write_state(&st);
}
// Reset the heartbeat timer on every write (real input or heartbeat), so an actively-used
// pad emits no extra reports — the heartbeat only fills genuine input-silence gaps.
self.last_write[idx] = Instant::now();
} }
/// Re-emit each live pad's CURRENT report if it's been silent for `max_gap`. A real DualSense /// Answer the kernel's init handshake (it blocks `hid-playstation` init until its GET_REPORTs
/// streams report `0x01` continuously (~250 Hz); the kernel `hid-playstation` driver / Proton / /// are answered — call frequently) and parse a game's feedback: motor rumble on the universal
/// SDL treat a multi-second silence (a held-steady stick produces no wire events) as an /// 0xCA plane, the rich lightbar/player-LED/trigger events on the 0xCD plane.
/// unplugged controller — the "controller disconnected every few seconds" symptom. Re-sending fn service(&self, pad: &mut DualSensePad, idx: u8) -> PadFeedback {
/// the current state is idempotent (a stale-but-correct frame, never a phantom input); let fb = pad.service(idx);
/// `write_state` bumps the report's seq + timestamp, so each is a fresh, well-formed report. PadFeedback {
pub fn heartbeat(&mut self, max_gap: Duration) { rumble: fb.rumble,
let now = Instant::now(); hidout: fb.hidout,
for i in 0..self.pads.len() {
if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap {
self.write(i);
}
}
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
return;
}
match DualSensePad::open(idx as u8) {
Ok(p) => {
tracing::info!(
index = idx,
"virtual DualSense created (UHID hid-playstation)"
);
self.pads[idx] = Some(p);
self.state[idx] = DsState::neutral();
self.last_rumble[idx] = (0, 0);
self.last_write[idx] = Instant::now();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — controller input disabled");
self.broken = true;
}
}
}
/// Service every pad: answer the kernel's init handshake and parse a game's feedback. `rumble`
/// is invoked `(index, low, high)` only when the motor level *changes* (the universal 0xCA
/// plane — both backends use it); `hidout` is invoked for each DualSense-only rich feedback
/// event (lightbar / player LEDs / adaptive triggers — the 0xCD plane). Call frequently:
/// the kernel blocks `hid-playstation` init until its GET_REPORTs are answered.
pub fn pump(
&mut self,
mut rumble: impl FnMut(u16, u16, u16),
mut hidout: impl FnMut(HidOutput),
) {
for i in 0..self.pads.len() {
let Some(pad) = self.pads[i].as_mut() else {
continue;
};
let fb = pad.service(i as u8);
if let Some(r) = fb.rumble {
if self.last_rumble[i] != r {
self.last_rumble[i] = r;
rumble(i as u16, r.0, r.1);
}
}
for h in fb.hidout {
hidout(h);
}
} }
} }
} }
/// All virtual DualSense pads of a session — the rich-controller analog of
/// [`GamepadManager`](super::gamepad::GamepadManager), selected with `PUNKTFUNK_GAMEPAD=dualsense`.
///
/// Unlike the uinput pad, a DualSense carries touchpad + motion, which arrive on a *separate*
/// rich-input plane (`apply_rich`) from the button/stick frames (`handle`); the shared
/// [`UhidManager`] keeps each pad's full [`DsState`], re-emits the merged report whenever either
/// source changes, and heartbeats it through input silence (a real DualSense streams report `0x01`
/// continuously — `hid-playstation`/Proton/SDL treat a multi-second gap as an unplug).
pub type DualSenseManager = UhidManager<DsLinuxProto>;
/// The DualSense **Edge** half of the shared stateful manager: the plain-DualSense transport and
/// report codec under the Edge USB identity (`054C:0DF2` + the Edge descriptor), with the four
/// wire back-grip bits mapped onto the Edge's native `buttons[2]` slots instead of the
/// fold/drop policy — the whole point of this backend (a client's Deck grips / Elite paddles
/// stop vanishing). No remap config: every paddle has a native home.
///
/// Kernel note: `hid-playstation` binds the Edge PID since 6.1 (forced vibration-v2 output), but
/// only kernels ≥ 7.2 surface the Fn/back bits as evdev keys (`BTN_TRIGGER_HAPPY1..4`); SDL /
/// Steam Input read the report off hidraw and see them on any kernel.
#[derive(Default)]
pub struct DsEdgeLinuxProto;
impl PadProto for DsEdgeLinuxProto {
type Pad = DualSensePad;
type State = DsState;
const LABEL: &'static str = "DualSense Edge";
const DEVICE: &'static str = "DualSense Edge";
const CREATE_HINT: &'static str = "";
fn open(&mut self, idx: u8) -> Result<DualSensePad> {
let p = DualSensePad::open(idx, &DsUhidIdentity::dualsense_edge())?;
tracing::info!(
index = idx,
"virtual DualSense Edge created (UHID hid-playstation)"
);
Ok(p)
}
fn neutral(&self) -> DsState {
DsState::neutral()
}
/// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields — like the
/// plain DualSense, EXCEPT the wire paddles are not folded away: they land on the Edge's own
/// `buttons[2]` bits (rebuilt from every button frame, so no extra persistence).
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
let mut s = DsState::from_gamepad(
f.buttons,
f.ls_x,
f.ls_y,
f.rs_x,
f.rs_y,
f.left_trigger,
f.right_trigger,
);
s.buttons[2] |= edge_paddle_bits(f.buttons);
s.touch = prev.touch;
s.gyro = prev.gyro;
s.accel = prev.accel;
s.touch_click = prev.touch_click;
s
}
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
/// split the one touchpad left/right, pad clicks ride touch_click.
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
}
fn write_state(&self, pad: &mut DualSensePad, st: &DsState) {
let _ = pad.write_state(st);
}
/// Same kernel handshake + feedback parse as the plain DualSense — the Edge's GET_REPORT set
/// (calibration 0x05 / pairing 0x09 / firmware 0x20) and output report 0x02 are identical
/// (the Edge's rumble arrives via the vibration-v2 valid_flag2 bit, which
/// [`parse_ds_output`] already handles).
fn service(&self, pad: &mut DualSensePad, idx: u8) -> PadFeedback {
let fb = pad.service(idx);
PadFeedback {
rumble: fb.rumble,
hidout: fb.hidout,
}
}
}
/// All virtual DualSense Edge pads of a session — `PUNKTFUNK_GAMEPAD=edge`, or the per-pad kind a
/// client declares for a paddle-bearing physical controller.
pub type DualSenseEdgeManager = UhidManager<DsEdgeLinuxProto>;
@@ -8,19 +8,22 @@
//! It carries everything the DualSense does *except* adaptive triggers, player LEDs and the mute //! It carries everything the DualSense does *except* adaptive triggers, player LEDs and the mute
//! button (the DS4 hardware has none), so the only feedback it surfaces is motor rumble (universal //! button (the DS4 hardware has none), so the only feedback it surfaces is motor rumble (universal
//! 0xCA plane) and the lightbar (HID-output 0xCD `Led`). The button/stick/dpad/touchpad mapping is //! 0xCA plane) and the lightbar (HID-output 0xCD `Led`). The button/stick/dpad/touchpad mapping is
//! identical to the DualSense, so we reuse its pure [`DsState`] + [`DsState::from_gamepad`]; only the //! identical to the DualSense, so we reuse its pure [`DsState`] + [`DsState::from_gamepad`]; the
//! report *byte layout*, the report descriptor, the feature-report handshake and the touchpad //! report codec (input `0x01` serializer, output `0x05` parser, touch dims) is the pure
//! resolution differ. The report descriptor + struct offsets are the canonical real-DS4-USB layout //! [`super::dualshock4_proto`], shared with the Windows UMDF backend — this module is only the
//! the kernel `struct dualshock4_input_report_usb` / `_output_report_common` parse. //! `/dev/uhid` transport plus the report descriptor + feature-report handshake the kernel needs.
use super::dualsense_proto::{DsState, Touch}; use super::dualsense_proto::DsState;
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; use super::dualshock4_proto::{
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_PRODUCT, DS4_TOUCH_H,
DS4_TOUCH_W, DS4_VENDOR,
};
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use punktfunk_core::quic::{HidOutput, RichInput}; use punktfunk_core::quic::{HidOutput, RichInput};
use std::fs::{File, OpenOptions}; use std::fs::{File, OpenOptions};
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
use std::time::{Duration, Instant};
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`. // /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
const UHID_PATH: &str = "/dev/uhid"; const UHID_PATH: &str = "/dev/uhid";
@@ -128,96 +131,6 @@ const DS4_RDESC: &[u8] = &[
0xB1, 0x02, 0xC0, 0xB1, 0x02, 0xC0,
]; ];
const DS4_VENDOR: u32 = 0x054C; // Sony Interactive Entertainment
const DS4_PRODUCT: u32 = 0x09CC; // DualShock 4 v2 (CUH-ZCT2)
/// USB input report `0x01` is 64 bytes total (report id + 63-byte body).
const DS4_INPUT_REPORT_LEN: usize = 64;
/// The DualShock 4 touchpad resolution the kernel advertises (ABS_MT 0..1919 / 0..941). Narrower
/// than the DualSense's 1920×1080.
pub const DS4_TOUCH_W: u16 = 1920;
pub const DS4_TOUCH_H: u16 = 942;
/// Pack one touchpad contact into the DS4's 4-byte point (same bit layout as the DualSense's:
/// byte0 bit7 = NOT-active, bits0-6 = id; 12-bit X then 12-bit Y).
fn pack_touch(dst: &mut [u8], t: &Touch) {
dst[0] = (t.id & 0x7F) | if t.active { 0 } else { 0x80 };
// Never emit the extent itself — the kernel advertises 0..=W-1 / 0..=H-1.
let (x, y) = (t.x.min(DS4_TOUCH_W - 1), t.y.min(DS4_TOUCH_H - 1));
dst[1] = (x & 0xFF) as u8;
dst[2] = (((x >> 8) & 0x0F) as u8) | (((y & 0x0F) as u8) << 4);
dst[3] = ((y >> 4) & 0xFF) as u8;
}
/// Serialize a full DS4 input report `0x01` (pure — unit-testable without `/dev/uhid`). Field
/// offsets per the kernel's `struct dualshock4_input_report_usb` { report_id; common; num_touch;
/// touch[3]; rsvd[3] } where `common` = { x,y,rx,ry; buttons[3]; z,rz; sensor_ts le16; temp;
/// gyro[3] le16; accel[3] le16; rsvd[5]; status[2]; rsvd }. The report id is byte 0, so a `common`
/// field at struct offset N sits at report byte N+1.
fn serialize_state(r: &mut [u8; DS4_INPUT_REPORT_LEN], st: &DsState, counter: u8, ts: u16) {
r[0] = 0x01; // report id
r[1] = st.lx;
r[2] = st.ly;
r[3] = st.rx;
r[4] = st.ry;
r[5] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // dpad hat (low) + face buttons (high)
r[6] = st.buttons[1]; // L1/R1, L2/R2 digital, Share/Options, L3/R3
r[7] = (st.buttons[2] & 0x03) | ((counter & 0x3F) << 2); // PS + touchpad-click + report counter
r[8] = st.l2; // L2 analog (z)
r[9] = st.r2; // R2 analog (rz)
r[10..12].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 9)
// r[12] temperature stays 0
for (i, v) in st.gyro.iter().enumerate() {
r[13 + i * 2..15 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 12
}
for (i, v) in st.accel.iter().enumerate() {
r[19 + i * 2..21 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 18
}
// r[25..30] reserved2.
// status[0] (struct off 29 → r[30]): bit4 = cable/wired, low nibble = battery capacity. Report
// wired + full (0x1B) so SteamOS / the kernel never warn "low battery" on a virtual pad.
r[30] = 0x10 | 0x0B;
// r[31] status[1] = 0 (no headphone/mic), r[32] reserved3 = 0.
r[33] = 1; // num_touch_reports: one frame carrying the two contacts (a real DS4 always sends one)
r[34] = ts as u8; // touch_reports[0].timestamp
pack_touch(&mut r[35..39], &st.touch[0]); // touch point 0
pack_touch(&mut r[39..43], &st.touch[1]); // touch point 1
// remaining touch frames (r[43..61]) + reserved (r[61..64]) stay zero
}
/// What one [`DualShock4Pad::service`] pass extracted from the device's HID output reports. Rumble
/// rides the universal 0xCA plane; the lightbar rides the HID-output 0xCD plane (DS4 has no player
/// LEDs or adaptive triggers, so those never appear).
#[derive(Default)]
pub struct Ds4Feedback {
pub hidout: Vec<HidOutput>,
/// `(low, high)` motor levels (0..=0xFF00), if a report carried them.
pub rumble: Option<(u16, u16)>,
/// Lightbar RGB, if the report carried it (deduped by the manager).
pub led: Option<(u8, u8, u8)>,
}
/// Parse a DualShock 4 USB output report (`0x05`) into a [`Ds4Feedback`]. Layout per the kernel
/// `struct dualshock4_output_report_common`: valid_flag0 (bit0 motor, bit1 LED, bit2 blink) at [1],
/// valid_flag1 [2], reserved [3], motor_right (weak/small) [4], motor_left (strong/large) [5],
/// lightbar R/G/B [6..9], blink on/off [9..11]. Gated on the valid-flags so a rumble-only write
/// doesn't masquerade as a lightbar change.
fn parse_ds4_output(data: &[u8], fb: &mut Ds4Feedback) {
if data.first() != Some(&0x05) || data.len() < 11 {
return; // not the USB output report (BT 0x11 is shifted) / too short
}
let flag0 = data[1];
if flag0 & 0x01 != 0 {
// motor_left (strong/large/low-freq) at [5], motor_right (weak/small/high-freq) at [4];
// scale 0..255 → 0..0xFF00, same (low, high) convention as the other backends.
let low = (data[5] as u16) << 8;
let high = (data[4] as u16) << 8;
fb.rumble = Some((low, high));
}
if flag0 & 0x02 != 0 {
fb.led = Some((data[6], data[7], data[8]));
}
}
/// Copy a NUL-padded C string field into the event buffer. /// Copy a NUL-padded C string field into the event buffer.
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) { fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
let n = s.len().min(cap - 1); let n = s.len().min(cap - 1);
@@ -264,8 +177,8 @@ impl DualShock4Pad {
put_cstr(&mut ev, 196, 64, &format!("punktfunk-ds4-{index}")); // uniq[64] put_cstr(&mut ev, 196, 64, &format!("punktfunk-ds4-{index}")); // uniq[64]
ev[260..262].copy_from_slice(&(DS4_RDESC.len() as u16).to_ne_bytes()); // rd_size ev[260..262].copy_from_slice(&(DS4_RDESC.len() as u16).to_ne_bytes()); // rd_size
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus
ev[264..268].copy_from_slice(&DS4_VENDOR.to_ne_bytes()); ev[264..268].copy_from_slice(&(DS4_VENDOR as u32).to_ne_bytes());
ev[268..272].copy_from_slice(&DS4_PRODUCT.to_ne_bytes()); ev[268..272].copy_from_slice(&(DS4_PRODUCT as u32).to_ne_bytes());
ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
ev[280..280 + DS4_RDESC.len()].copy_from_slice(DS4_RDESC); // rd_data ev[280..280 + DS4_RDESC.len()].copy_from_slice(DS4_RDESC); // rd_data
@@ -348,82 +261,52 @@ impl Drop for DualShock4Pad {
} }
} }
/// All virtual DualShock 4 pads of a session — the PS4 analog of /// The DualShock-4-specific half of the shared stateful manager (see [`PadProto`]): UHID transport
/// [`DualSenseManager`](super::dualsense::DualSenseManager), selected with `PUNKTFUNK_GAMEPAD=ps4`. /// open, the [`DsState`] mappers, and the kernel-handshake service pass. Lifecycle (slot table,
/// Like the DualSense it keeps each pad's full [`DsState`] and re-emits the merged report whenever /// unplug sweep, heartbeat, dedup) lives in [`UhidManager`]; the lightbar dedup that used to be a
/// buttons/sticks ([`handle`](Self::handle)) or touchpad/motion ([`apply_rich`](Self::apply_rich)) /// bespoke `last_led` vec (the kernel bundles the lightbar into every output report, incl.
/// change. [`pump`](Self::pump) services the kernel handshake and routes a game's feedback back: /// rumble-only writes) now rides the shared `HidoutDedup` — identical semantics, `Led` compared
/// motor rumble on the universal plane, the lightbar on the HID-output plane. /// against the last-forwarded value and re-armed on create/unplug.
pub struct DualShock4Manager { pub struct Ds4LinuxProto {
pads: Vec<Option<DualShock4Pad>>,
/// Each pad's current full report — buttons/sticks merged with persisted touch + motion.
state: Vec<DsState>,
/// Last rumble forwarded per pad, so a report that only changes the lightbar doesn't re-send it.
last_rumble: Vec<(u16, u16)>,
/// Last lightbar RGB forwarded per pad — the kernel bundles the lightbar into every output
/// report (incl. rumble-only writes), so dedup here to avoid flooding the HID-output plane.
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,
/// Fallback policy for the Steam back grips a client may send (the DS4 has no back-button HID /// 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. /// slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
remap: crate::inject::steam_remap::RemapConfig, remap: crate::inject::steam_remap::RemapConfig,
} }
impl Default for DualShock4Manager { impl Default for Ds4LinuxProto {
fn default() -> DualShock4Manager { fn default() -> Ds4LinuxProto {
DualShock4Manager::new() Ds4LinuxProto {
}
}
impl DualShock4Manager {
pub fn new() -> DualShock4Manager {
DualShock4Manager {
pads: (0..MAX_PADS).map(|_| None).collect(),
state: vec![DsState::neutral(); MAX_PADS],
last_rumble: vec![(0, 0); MAX_PADS],
last_led: vec![None; MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
broken: false,
remap: crate::inject::steam_remap::RemapConfig::from_env(), remap: crate::inject::steam_remap::RemapConfig::from_env(),
} }
} }
}
/// Handle one decoded controller event (create/destroy by mask, then merge button/stick state). impl PadProto for Ds4LinuxProto {
pub fn handle(&mut self, ev: &GamepadEvent) { type Pad = DualShock4Pad;
match ev { type State = DsState;
GamepadEvent::Arrival { index, kind, .. } => { const LABEL: &'static str = "DualShock 4";
tracing::info!(index, kind, "controller arrival (DualShock 4)"); const DEVICE: &'static str = "DualShock 4";
self.ensure(*index as usize); const CREATE_HINT: &'static str = "";
fn open(&mut self, idx: u8) -> Result<DualShock4Pad> {
let p = DualShock4Pad::open(idx)?;
tracing::info!(
index = idx,
"virtual DualShock 4 created (UHID hid-playstation)"
);
Ok(p)
} }
GamepadEvent::State(f) => {
let idx = f.index as usize; fn neutral(&self) -> DsState {
if idx >= MAX_PADS { DsState::neutral()
return;
} }
// Unplugs: drop any allocated pad whose mask bit cleared, resetting its state.
for (i, slot) in self.pads.iter_mut().enumerate() { /// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (those
if slot.is_some() && f.active_mask & (1 << i) == 0 { /// arrive on the rich-input plane and must survive a button-only frame).
tracing::info!(index = i, "controller unplugged (DualShock 4)"); fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
*slot = None; // Steam back grips have no DS4 slot — fold them onto standard buttons per the configured
self.state[i] = DsState::neutral(); // policy (default drop) so they aren't silently lost.
self.last_rumble[i] = (0, 0); let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
self.last_led[i] = None;
}
}
if f.active_mask & (1 << idx) == 0 {
return; // this event WAS the unplug
}
self.ensure(idx);
// Merge buttons/sticks/triggers, preserving touch + motion (those arrive on the
// rich-input plane and must survive a button-only frame).
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.
let buttons =
crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
let mut s = DsState::from_gamepad( let mut s = DsState::from_gamepad(
buttons, buttons,
f.ls_x, f.ls_x,
@@ -436,216 +319,51 @@ impl DualShock4Manager {
s.touch = prev.touch; s.touch = prev.touch;
s.gyro = prev.gyro; s.gyro = prev.gyro;
s.accel = prev.accel; s.accel = prev.accel;
self.state[idx] = s; s.touch_click = prev.touch_click;
self.write(idx); s
}
}
} }
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad, /// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
/// preserving its button/stick state. Rich events never create a pad; they're dropped if the /// split the one touchpad left/right, pad clicks ride touch_click.
/// pad isn't present. fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
pub fn apply_rich(&mut self, rich: RichInput) { st.apply_rich(rich, DS4_TOUCH_W, DS4_TOUCH_H);
let idx = match rich {
RichInput::Touchpad { pad, .. }
| RichInput::Motion { pad, .. }
| RichInput::TouchpadEx { pad, .. } => pad as usize,
};
if idx >= MAX_PADS || self.pads[idx].is_none() {
return;
}
match rich {
RichInput::Touchpad {
finger,
active,
x,
y,
..
} => {
// The DS4 touchpad carries two contacts; clamp to a valid slot and keep the
// reported contact id consistent (the wire `finger` is untrusted).
let slot = (finger as usize).min(1);
let t = &mut self.state[idx].touch[slot];
t.active = active;
t.id = slot as u8;
// Normalized 0..=65535 → the DS4 touchpad range (0..=W-1 / 0..=H-1).
t.x = ((x as u32 * (DS4_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16;
t.y = ((y as u32 * (DS4_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16;
}
RichInput::Motion { gyro, accel, .. } => {
self.state[idx].gyro = gyro;
self.state[idx].accel = accel;
}
RichInput::TouchpadEx {
surface,
finger,
touch,
x,
y,
..
} => {
// A Steam right/single pad maps onto the one DS4 touchpad (signed centre-0 →
// 0..=65535); surface 1 (the Steam left pad) has no DS4 equivalent.
if surface != 1 {
let slot = (finger as usize).min(1);
let n = |v: i16| ((v as i32) + 32768) as u32;
let t = &mut self.state[idx].touch[slot];
t.active = touch;
t.id = slot as u8;
t.x = (n(x) * (DS4_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
t.y = (n(y) * (DS4_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
}
}
}
self.write(idx);
} }
fn write(&mut self, idx: usize) { fn write_state(&self, pad: &mut DualShock4Pad, st: &DsState) {
let st = self.state[idx]; let _ = pad.write_state(st);
if let Some(pad) = self.pads[idx].as_mut() {
let _ = pad.write_state(&st);
}
self.last_write[idx] = Instant::now();
} }
/// Re-emit each live pad's CURRENT report if it's been silent for `max_gap` — a real DS4 streams /// Answer the kernel's init handshake (it blocks `hid-playstation` init until its GET_REPORTs
/// report `0x01` continuously, and `hid-playstation` / SDL treat a multi-second silence (a /// are answered — call frequently) and parse a game's feedback: motor rumble on the universal
/// held-steady stick) as an unplugged controller. Idempotent (a stale-but-correct frame); /// 0xCA plane, the lightbar as a 0xCD `Led` event (a DS4 has no player LEDs / adaptive
/// `write_state` bumps the counter + timestamp so each is a fresh, well-formed report. /// triggers).
pub fn heartbeat(&mut self, max_gap: Duration) { fn service(&self, pad: &mut DualShock4Pad, idx: u8) -> PadFeedback {
let now = Instant::now();
for i in 0..self.pads.len() {
if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap {
self.write(i);
}
}
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
return;
}
match DualShock4Pad::open(idx as u8) {
Ok(p) => {
tracing::info!(
index = idx,
"virtual DualShock 4 created (UHID hid-playstation)"
);
self.pads[idx] = Some(p);
self.state[idx] = DsState::neutral();
self.last_rumble[idx] = (0, 0);
self.last_led[idx] = None;
self.last_write[idx] = Instant::now();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — controller input disabled");
self.broken = true;
}
}
}
/// Service every pad: answer the kernel's init handshake and parse a game's feedback. `rumble`
/// is invoked `(index, low, high)` only when the motor level *changes* (universal 0xCA plane);
/// `hidout` carries the lightbar (0xCD `Led`), deduped. Call frequently — the kernel blocks
/// `hid-playstation` init until its GET_REPORTs are answered.
pub fn pump(
&mut self,
mut rumble: impl FnMut(u16, u16, u16),
mut hidout: impl FnMut(HidOutput),
) {
for i in 0..self.pads.len() {
let Some(pad) = self.pads[i].as_mut() else {
continue;
};
let fb = pad.service(); let fb = pad.service();
if let Some(r) = fb.rumble { PadFeedback {
if self.last_rumble[i] != r { rumble: fb.rumble,
self.last_rumble[i] = r; hidout: fb
rumble(i as u16, r.0, r.1); .led
} .map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b })
} .into_iter()
if let Some(rgb) = fb.led { .collect(),
if self.last_led[i] != Some(rgb) {
self.last_led[i] = Some(rgb);
hidout(HidOutput::Led {
pad: i as u8,
r: rgb.0,
g: rgb.1,
b: rgb.2,
});
}
}
} }
} }
} }
/// All virtual DualShock 4 pads of a session — the PS4 analog of
/// [`DualSenseManager`](super::dualsense::DualSenseManager), selected with `PUNKTFUNK_GAMEPAD=ps4`.
/// Like the DualSense, the shared [`UhidManager`] keeps each pad's full [`DsState`], re-emits the
/// merged report whenever buttons/sticks or touchpad/motion change, and heartbeats it through
/// input silence (a real DS4 streams report `0x01` continuously — `hid-playstation`/SDL treat a
/// multi-second gap as an unplug).
pub type DualShock4Manager = UhidManager<Ds4LinuxProto>;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
/// Report 0x01 places sticks/buttons/triggers/motion/touch at the kernel's DS4 offsets. // The report 0x01 serializer + output 0x05 parser are covered in `dualshock4_proto` (the codec
#[test] // is shared with the Windows backend); only the UHID-transport-specific pieces are tested here.
fn serialize_offsets() {
use punktfunk_core::input::gamepad as gs;
let mut st = DsState::from_gamepad(
gs::BTN_A | gs::BTN_DPAD_UP | gs::BTN_LB,
16384, // lx (right)
0,
0,
-32768, // ry (down) — inverted to 0xFF
200, // L2
0,
);
st.gyro = [0x0102, 0x0304, 0x0506];
st.accel = [0x1112, 0x1314, 0x1516];
st.touch[0] = Touch {
active: true,
id: 0,
x: 100,
y: 200,
};
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
serialize_state(&mut r, &st, 0, 0);
assert_eq!(r[0], 0x01); // report id
assert_eq!(r[8], 200); // L2 analog at byte 8 (not the DualSense's byte 5)
assert_eq!(r[5] & 0x0F, 0); // dpad hat = N (up)
assert_eq!(r[5] & 0x20, 0x20); // Cross (A) face bit
assert_eq!(r[6] & 0x01, 0x01); // L1
// gyro le16 at 13..19, accel le16 at 19..25.
assert_eq!(&r[13..19], &[0x02, 0x01, 0x04, 0x03, 0x06, 0x05]);
assert_eq!(&r[19..25], &[0x12, 0x11, 0x14, 0x13, 0x16, 0x15]);
assert_eq!(r[33], 1); // one touch frame
assert_eq!(r[35] & 0x80, 0); // contact 0 active (bit7 clear)
assert_eq!(r[35] & 0x7F, 0); // contact id 0
assert_eq!(r[30] & 0x10, 0x10); // cable/wired bit set
}
/// A DS4 USB output report (`0x05`) with motor + LED flags parses into rumble (0xCA) and a
/// lightbar `Led` (0xCD); a rumble-only report (no LED flag) leaves the lightbar untouched.
#[test]
fn parse_output_rumble_and_lightbar() {
let mut report = [0u8; 32];
report[0] = 0x05;
report[1] = 0x01 | 0x02; // MOTOR | LED
report[4] = 0x40; // motor_right (weak/high)
report[5] = 0x80; // motor_left (strong/low)
report[6] = 0x11; // R
report[7] = 0x22; // G
report[8] = 0x33; // B
let mut fb = Ds4Feedback::default();
parse_ds4_output(&report, &mut fb);
assert_eq!(fb.rumble, Some((0x8000, 0x4000))); // (low=strong, high=weak)
assert_eq!(fb.led, Some((0x11, 0x22, 0x33)));
let mut motor_only = [0u8; 32];
motor_only[0] = 0x05;
motor_only[1] = 0x01; // MOTOR only
motor_only[5] = 0x10;
let mut fb2 = Ds4Feedback::default();
parse_ds4_output(&motor_only, &mut fb2);
assert!(fb2.rumble.is_some());
assert_eq!(fb2.led, None); // lightbar not asserted → no spurious change
}
/// Feature-report arrays carry the right report id + length the kernel expects. /// Feature-report arrays carry the right report id + length the kernel expects.
#[test] #[test]
@@ -19,6 +19,7 @@
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
use crate::gamestream::gamepad::{self, GamepadFrame, MAX_PADS}; use crate::gamestream::gamepad::{self, GamepadFrame, MAX_PADS};
use crate::inject::pad_slots::PadSlots;
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use std::collections::HashMap; use std::collections::HashMap;
use std::os::fd::{AsRawFd, OwnedFd}; use std::os::fd::{AsRawFd, OwnedFd};
@@ -88,8 +89,8 @@ const BUTTON_MAP: [(u32, u16); 15] = [
(gamepad::BTN_BACK, BTN_SELECT), (gamepad::BTN_BACK, BTN_SELECT),
(gamepad::BTN_START, BTN_START), (gamepad::BTN_START, BTN_START),
(gamepad::BTN_GUIDE, BTN_MODE), (gamepad::BTN_GUIDE, BTN_MODE),
(gamepad::BTN_LS_CLK, BTN_THUMBL), (gamepad::BTN_LS_CLICK, BTN_THUMBL),
(gamepad::BTN_RS_CLK, BTN_THUMBR), (gamepad::BTN_RS_CLICK, BTN_THUMBR),
(gamepad::BTN_PADDLE1, BTN_TRIGGER_HAPPY5), (gamepad::BTN_PADDLE1, BTN_TRIGGER_HAPPY5),
(gamepad::BTN_PADDLE2, BTN_TRIGGER_HAPPY6), (gamepad::BTN_PADDLE2, BTN_TRIGGER_HAPPY6),
(gamepad::BTN_PADDLE3, BTN_TRIGGER_HAPPY7), (gamepad::BTN_PADDLE3, BTN_TRIGGER_HAPPY7),
@@ -265,7 +266,6 @@ struct Effect {
/// One virtual X-Box-360 pad backed by a uinput device. /// One virtual X-Box-360 pad backed by a uinput device.
pub struct VirtualPad { pub struct VirtualPad {
fd: OwnedFd, fd: OwnedFd,
prev_buttons: u32,
effects: HashMap<i16, Effect>, effects: HashMap<i16, Effect>,
next_effect_id: i16, next_effect_id: i16,
gain: u32, gain: u32,
@@ -369,7 +369,6 @@ impl VirtualPad {
Ok(VirtualPad { Ok(VirtualPad {
fd, fd,
prev_buttons: 0,
effects: HashMap::new(), effects: HashMap::new(),
next_effect_id: 0, next_effect_id: 0,
gain: 0xFFFF, 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) { 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 { 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). // Moonlight: +Y = up; evdev: +Y = down → negate (i32 math avoids -(-32768) overflow).
self.emit(EV_ABS, ABS_X, f.ls_x as i32); self.emit(EV_ABS, ABS_X, f.ls_x as i32);
@@ -550,15 +551,20 @@ impl Drop for VirtualPad {
} }
} }
/// All virtual pads of a session, driven from decoded controller events. /// All virtual pads of a session, driven from decoded controller events. Stateless per frame
#[derive(Default)] /// (uinput/evdev holds last-known state kernel-side), so it rides [`PadSlots`] directly — no state
/// vec, heartbeat, or rich plane like the UHID managers.
pub struct GamepadManager { pub struct GamepadManager {
pads: Vec<Option<VirtualPad>>, slots: PadSlots<VirtualPad>,
/// The USB identity every pad in this session presents (X-Box 360 by default, One/Series when /// 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. /// the client asked for `XboxOne`). All pads in a session share one identity.
identity: PadIdentity, identity: PadIdentity,
/// Pad creation failed (e.g. /dev/uinput permissions) — warn once, drop events. }
broken: bool,
impl Default for GamepadManager {
fn default() -> GamepadManager {
GamepadManager::new()
}
} }
impl GamepadManager { impl GamepadManager {
@@ -570,9 +576,8 @@ impl GamepadManager {
/// A manager whose pads present `identity` (see [`PadIdentity::xbox_one`]). /// A manager whose pads present `identity` (see [`PadIdentity::xbox_one`]).
pub fn with_identity(identity: PadIdentity) -> GamepadManager { pub fn with_identity(identity: PadIdentity) -> GamepadManager {
GamepadManager { GamepadManager {
pads: (0..MAX_PADS).map(|_| None).collect(), slots: PadSlots::new(identity.log, "gamepad", ""),
identity, identity,
broken: false,
} }
} }
@@ -581,7 +586,7 @@ impl GamepadManager {
use crate::gamestream::gamepad::GamepadEvent; use crate::gamestream::gamepad::GamepadEvent;
match ev { match ev {
GamepadEvent::Arrival { index, kind, .. } => { GamepadEvent::Arrival { index, kind, .. } => {
tracing::info!(index, kind, "controller arrival"); tracing::info!(index, kind, "controller arrival ({})", self.slots.label());
self.ensure(*index as usize); self.ensure(*index as usize);
} }
GamepadEvent::State(f) => { GamepadEvent::State(f) => {
@@ -589,18 +594,14 @@ impl GamepadManager {
if idx >= MAX_PADS { if idx >= MAX_PADS {
return; return;
} }
// Unplugs: drop any allocated pad whose mask bit cleared. // Unplugs: drop any allocated pad whose mask bit cleared (no per-index sibling
for (i, slot) in self.pads.iter_mut().enumerate() { // state to reset — the pads mix rumble internally).
if slot.is_some() && f.active_mask & (1 << i) == 0 { self.slots.sweep(f.active_mask);
tracing::info!(index = i, "controller unplugged");
*slot = None;
}
}
if f.active_mask & (1 << idx) == 0 { if f.active_mask & (1 << idx) == 0 {
return; // this event WAS the unplug return; // this event WAS the unplug
} }
self.ensure(idx); self.ensure(idx);
if let Some(pad) = self.pads[idx].as_mut() { if let Some(pad) = self.slots.get_mut(idx) {
pad.apply(f); pad.apply(f);
} }
} }
@@ -608,27 +609,19 @@ impl GamepadManager {
} }
fn ensure(&mut self, idx: usize) { fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { let identity = self.identity;
return; // `VirtualPad::create` logs its own success line (it knows the identity + transport).
} self.slots
match VirtualPad::create(idx, self.identity) { .ensure(idx, |i| VirtualPad::create(i as usize, identity));
Ok(p) => self.pads[idx] = Some(p),
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual gamepad creation failed — controller input disabled");
self.broken = true;
}
}
} }
/// Service every pad's FF protocol; `send(index, low, high)` is invoked for each pad whose /// Service every pad's FF protocol; `send(index, low, high)` is invoked for each pad whose
/// mixed rumble level changed. Call frequently (games block in `EVIOCSFF` until answered). /// mixed rumble level changed. Call frequently (games block in `EVIOCSFF` until answered).
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) { pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
for (i, slot) in self.pads.iter_mut().enumerate() { for (i, pad) in self.slots.iter_mut() {
if let Some(pad) = slot {
if let Some((low, high)) = pad.pump_ff() { if let Some((low, high)) = pad.pump_ff() {
send(i as u16, low, high); send(i as u16, low, high);
} }
} }
} }
}
} }
@@ -20,12 +20,12 @@
//! command (the DualSense backend only services GET_REPORT + OUTPUT). //! command (the DualSense backend only services GET_REPORT + OUTPUT).
use super::steam_proto::{ use super::steam_proto::{
btn, parse_steam_output, serial_reply, serialize_deck_state, SteamState, STEAMDECK_PRODUCT, btn, parse_steam_output, sc_from_gamepad, serial_reply, serialize_deck_state,
STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR, serialize_sc_state, SteamModel, SteamState, STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR,
}; };
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use punktfunk_core::quic::{HidOutput, RichInput}; use punktfunk_core::quic::RichInput;
use std::fs::{File, OpenOptions}; use std::fs::{File, OpenOptions};
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
@@ -77,10 +77,12 @@ fn try_clear_lizard_mode() {
} }
} }
/// A virtual Steam Deck backed by `/dev/uhid`. Dropping it destroys the device (the kernel tears /// A virtual Steam Deck **or classic Steam Controller** backed by `/dev/uhid` (same driver, two
/// down the bound `hid-steam` interface + both evdevs). /// identities/report layouts — see [`SteamModel`]). Dropping it destroys the device (the kernel
/// tears down the bound `hid-steam` interface + its evdevs).
pub struct SteamDeckPad { pub struct SteamDeckPad {
fd: File, fd: File,
model: SteamModel,
seq: u32, seq: u32,
created: Instant, created: Instant,
/// When `b9.6` started being continuously held in our OUTPUT (anti-toggle guard); `None` = not. /// When `b9.6` started being continuously held in our OUTPUT (anti-toggle guard); `None` = not.
@@ -89,7 +91,16 @@ pub struct SteamDeckPad {
impl SteamDeckPad { impl SteamDeckPad {
pub fn open(index: u8) -> Result<SteamDeckPad> { pub fn open(index: u8) -> Result<SteamDeckPad> {
SteamDeckPad::open_model(index, SteamModel::Deck)
}
/// Open under a specific Steam identity. The classic Controller's `ID_CONTROLLER_STATE` path
/// has NO `gamepad_mode` gate in the kernel (only the Deck's parser early-returns under
/// lizard mode), so the SC skips the whole mode-entry machinery.
pub fn open_model(index: u8, model: SteamModel) -> Result<SteamDeckPad> {
if model == SteamModel::Deck {
try_clear_lizard_mode(); try_clear_lizard_mode();
}
let fd = OpenOptions::new() let fd = OpenOptions::new()
.read(true) .read(true)
.write(true) .write(true)
@@ -100,24 +111,29 @@ impl SteamDeckPad {
})?; })?;
let mut pad = SteamDeckPad { let mut pad = SteamDeckPad {
fd, fd,
model,
seq: 0, seq: 0,
created: Instant::now(), created: Instant::now(),
menu_hold_since: None, menu_hold_since: None,
}; };
pad.send_create2(index).context("UHID_CREATE2 Steam Deck")?; pad.send_create2(index).context("UHID_CREATE2 Steam pad")?;
Ok(pad) Ok(pad)
} }
fn send_create2(&mut self, index: u8) -> Result<()> { fn send_create2(&mut self, index: u8) -> Result<()> {
let (name, phys, uniq) = match self.model {
SteamModel::Deck => ("Steam Deck", "steam", "steam"),
SteamModel::Controller => ("Steam Controller", "steamctrl", "steamctrl"),
};
let mut ev = [0u8; UHID_EVENT_SIZE]; let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes()); ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
put_cstr(&mut ev, 4, 128, &format!("Punktfunk Steam Deck {index}")); // name[128] put_cstr(&mut ev, 4, 128, &format!("Punktfunk {name} {index}")); // name[128]
put_cstr(&mut ev, 132, 64, &format!("punktfunk/steam/{index}")); // phys[64] put_cstr(&mut ev, 132, 64, &format!("punktfunk/{phys}/{index}")); // phys[64]
put_cstr(&mut ev, 196, 64, &format!("punktfunk-steam-{index}")); // uniq[64] put_cstr(&mut ev, 196, 64, &format!("punktfunk-{uniq}-{index}")); // uniq[64]
ev[260..262].copy_from_slice(&(STEAMDECK_RDESC.len() as u16).to_ne_bytes()); // rd_size ev[260..262].copy_from_slice(&(STEAMDECK_RDESC.len() as u16).to_ne_bytes()); // rd_size
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus
ev[264..268].copy_from_slice(&STEAM_VENDOR.to_ne_bytes()); ev[264..268].copy_from_slice(&STEAM_VENDOR.to_ne_bytes());
ev[268..272].copy_from_slice(&STEAMDECK_PRODUCT.to_ne_bytes()); ev[268..272].copy_from_slice(&self.model.product().to_ne_bytes());
ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
ev[280..280 + STEAMDECK_RDESC.len()].copy_from_slice(STEAMDECK_RDESC); ev[280..280 + STEAMDECK_RDESC.len()].copy_from_slice(STEAMDECK_RDESC);
@@ -125,13 +141,19 @@ impl SteamDeckPad {
Ok(()) Ok(())
} }
/// Serialize `st` (with the gamepad-mode entry overlay + anti-toggle guard applied) and write it. /// Serialize `st` under this pad's model (Deck reports get the gamepad-mode entry overlay +
/// anti-toggle guard applied) and write it.
pub fn write_state(&mut self, st: &SteamState) -> Result<()> { pub fn write_state(&mut self, st: &SteamState) -> Result<()> {
self.seq = self.seq.wrapping_add(1); self.seq = self.seq.wrapping_add(1);
let mut r = [0u8; STEAM_REPORT_LEN];
match self.model {
SteamModel::Deck => {
let mut s = *st; let mut s = *st;
s.buttons = self.effective_buttons(st.buttons); s.buttons = self.effective_buttons(st.buttons);
let mut r = [0u8; STEAM_REPORT_LEN];
serialize_deck_state(&mut r, &s, self.seq); serialize_deck_state(&mut r, &s, self.seq);
}
SteamModel::Controller => serialize_sc_state(&mut r, st, self.seq),
}
let mut ev = [0u8; UHID_EVENT_SIZE]; let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes()); ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
@@ -142,8 +164,9 @@ impl SteamDeckPad {
} }
/// True while still pulsing the mode-switch at creation (the caller force-writes during this). /// True while still pulsing the mode-switch at creation (the caller force-writes during this).
/// Deck-only — the SC's kernel parser has no mode gate.
fn in_mode_entry(&self) -> bool { fn in_mode_entry(&self) -> bool {
self.created.elapsed() < MODE_ENTER self.model == SteamModel::Deck && self.created.elapsed() < MODE_ENTER
} }
/// During mode entry, force `b9.6` held (override). Afterwards, pass the real buttons through but /// During mode entry, force `b9.6` held (override). Afterwards, pass the real buttons through but
@@ -234,16 +257,12 @@ impl Drop for SteamDeckPad {
} }
} }
/// All virtual Steam Deck pads of a session — the Steam analogue of
/// [`DualSenseManager`](super::dualsense::DualSenseManager), selected with `PUNKTFUNK_GAMEPAD=steamdeck`.
/// Button/stick frames arrive via [`handle`](Self::handle); the right trackpad + motion via
/// [`apply_rich`](Self::apply_rich); [`pump`](Self::pump) services the kernel handshake + routes
/// rumble back; [`heartbeat`](Self::heartbeat) keeps the pad alive (and drives the mode-entry pulse).
/// The transport a manager pad drives. UHID is universal but Steam Input won't promote it (a UHID /// The transport a manager pad drives. UHID is universal but Steam Input won't promote it (a UHID
/// device has no USB interface number, `Interface: -1`); the USB **gadget** (`raw_gadget`, SteamOS) /// device has no USB interface number, `Interface: -1`); the USB **gadget** (`raw_gadget`, SteamOS)
/// and **usbip** (`vhci_hcd`, universal) both present the controller on USB interface 2, which Steam /// and **usbip** (`vhci_hcd`, universal) both present the controller on USB interface 2, which Steam
/// Input *does* promote. Selected per-pad by [`open_transport`]. /// Input *does* promote. Selected per-pad by [`open_transport`]. (`pub`: the type appears as
enum DeckTransport { /// `type Pad` in the `PadProto` impl, a public trait.)
pub enum DeckTransport {
Uhid(SteamDeckPad), Uhid(SteamDeckPad),
Gadget(crate::inject::steam_gadget::SteamDeckGadget), Gadget(crate::inject::steam_gadget::SteamDeckGadget),
Usbip(crate::inject::steam_usbip::SteamDeckUsbip), Usbip(crate::inject::steam_usbip::SteamDeckUsbip),
@@ -339,66 +358,52 @@ 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)?; let p = SteamDeckPad::open(idx)?;
tracing::info!( tracing::warn!(
index = idx, 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)) Ok(DeckTransport::Uhid(p))
} }
pub struct SteamControllerManager { /// The Steam-Deck-specific half of the shared stateful manager (see [`PadProto`]): the transport
pads: Vec<Option<DeckTransport>>, /// open (usbip → gadget → UHID fallback via [`open_transport`], which logs its own per-transport
state: Vec<SteamState>, /// outcome), the [`SteamState`] mappers, and the kernel-handshake service pass. Lifecycle (slot
last_rumble: Vec<(u16, u16)>, /// table, unplug sweep, heartbeat, rumble dedup) lives in [`UhidManager`]; the gamepad-mode-entry
last_write: Vec<Instant>, /// pulse rides the [`force_heartbeat`](PadProto::force_heartbeat) hook.
broken: bool, #[derive(Default)]
} pub struct SteamProto;
impl Default for SteamControllerManager { impl PadProto for SteamProto {
fn default() -> SteamControllerManager { type Pad = DeckTransport;
SteamControllerManager::new() type State = SteamState;
} const LABEL: &'static str = "Steam Deck";
} const DEVICE: &'static str = "Steam Deck";
const CREATE_HINT: &'static str = "";
impl SteamControllerManager { fn open(&mut self, idx: u8) -> Result<DeckTransport> {
pub fn new() -> SteamControllerManager { open_transport(idx)
SteamControllerManager {
pads: (0..MAX_PADS).map(|_| None).collect(),
state: vec![SteamState::neutral(); MAX_PADS],
last_rumble: vec![(0, 0); MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
broken: false,
}
} }
pub fn handle(&mut self, ev: &GamepadEvent) { fn neutral(&self) -> SteamState {
match ev { SteamState::neutral()
GamepadEvent::Arrival { index, kind, .. } => {
tracing::info!(index, kind, "controller arrival (Steam Deck)");
self.ensure(*index as usize);
} }
GamepadEvent::State(f) => {
let idx = f.index as usize; /// Merge buttons/sticks/triggers, preserving the rich-plane fields (trackpad + motion arrive
if idx >= MAX_PADS { /// separately and must survive a button-only frame).
return; fn merge_frame(
} &self,
for (i, slot) in self.pads.iter_mut().enumerate() { prev: &SteamState,
if slot.is_some() && f.active_mask & (1 << i) == 0 { f: &crate::gamestream::gamepad::GamepadFrame,
tracing::info!(index = i, "controller unplugged (Steam Deck)"); ) -> SteamState {
*slot = None;
self.state[i] = SteamState::neutral();
self.last_rumble[i] = (0, 0);
}
}
if f.active_mask & (1 << idx) == 0 {
return;
}
self.ensure(idx);
// Merge buttons/sticks/triggers, preserving the rich-plane fields (trackpad + motion
// arrive separately and must survive a button-only frame).
let prev = self.state[idx];
let mut s = SteamState::from_gamepad( let mut s = SteamState::from_gamepad(
f.buttons, f.buttons,
f.ls_x, f.ls_x,
@@ -415,85 +420,154 @@ impl SteamControllerManager {
s.gyro = prev.gyro; s.gyro = prev.gyro;
s.accel = prev.accel; s.accel = prev.accel;
s.buttons |= prev.buttons & (btn::RPAD_TOUCH | btn::LPAD_TOUCH); s.buttons |= prev.buttons & (btn::RPAD_TOUCH | btn::LPAD_TOUCH);
self.state[idx] = s; // Trackpad CLICK arrives on the rich plane too and must survive a button-only frame,
self.write(idx); // 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;
s
} }
fn apply_rich(&self, st: &mut SteamState, rich: RichInput) {
st.apply_rich(rich);
}
fn write_state(&self, pad: &mut DeckTransport, st: &SteamState) {
pad.write_state(st);
}
/// Answer the kernel handshake and forward rumble on the universal plane. The Steam Deck has
/// no rich host→client feedback plane (no lightbar / adaptive triggers), so `hidout` stays
/// empty.
fn service(&self, pad: &mut DeckTransport, _idx: u8) -> PadFeedback {
PadFeedback {
rumble: pad.service(),
hidout: Vec::new(),
} }
} }
/// Apply a rich client→host event (right trackpad / motion) to an existing pad. /// Force a steady stream while a pad is still pulsing its gamepad-mode entry (so the `b9.6`
pub fn apply_rich(&mut self, rich: RichInput) { /// toggle completes even with no game input).
let idx = match rich { fn force_heartbeat(&self, pad: &DeckTransport) -> bool {
RichInput::Touchpad { pad, .. } pad.in_mode_entry()
| RichInput::Motion { pad, .. }
| RichInput::TouchpadEx { pad, .. } => pad as usize,
};
if idx >= MAX_PADS || self.pads[idx].is_none() {
return;
}
self.state[idx].apply_rich(rich);
self.write(idx);
} }
}
fn write(&mut self, idx: usize) { /// All virtual Steam Deck pads of a session — the Steam analogue of
let st = self.state[idx]; /// [`DualSenseManager`](super::dualsense::DualSenseManager), selected with
if let Some(pad) = self.pads[idx].as_mut() { /// `PUNKTFUNK_GAMEPAD=steamdeck`. Button/stick frames arrive via `handle`; the trackpads + motion
pad.write_state(&st); /// via `apply_rich`; `pump` services the kernel handshake + routes rumble back; `heartbeat` keeps
} /// the pad alive (and drives the mode-entry pulse) — all from the shared [`UhidManager`].
self.last_write[idx] = Instant::now(); pub type SteamControllerManager = UhidManager<SteamProto>;
}
/// Re-emit each live pad's current report when silent past `max_gap`, and force a steady stream /// The **classic Steam Controller** half of the shared stateful manager: the same `hid-steam`
/// while a pad is still pulsing its gamepad-mode entry (so the `b9.6` toggle completes even with /// driver under the wired-SC identity (`28DE:1102`, `ID_CONTROLLER_STATE`), UHID-only in v1 —
/// no game input). /// the usbip/gadget transports present the Deck's captured 3-interface USB device, and the SC's
pub fn heartbeat(&mut self, max_gap: Duration) { /// wired interface layout hasn't been captured, so there is no Steam-Input promotion (the same
let now = Instant::now(); /// degraded-but-working state the Deck had pre-usbip; acceptable for discontinued hardware).
for i in 0..self.pads.len() { ///
let Some(pad) = self.pads[i].as_ref() else { /// Deltas vs the Deck (see [`sc_from_gamepad`]/[`serialize_sc_state`]): one stick + two pads +
continue; /// two grips — the wire right stick drives the right pad, a left-pad contact shadows the left
}; /// stick, wire PADDLE1/2 land on the two grips (3/4 fold via the remap policy), and the kernel
if pad.in_mode_entry() || now.duration_since(self.last_write[i]) >= max_gap { /// registers neither FF rumble nor a sensors evdev for this model (feedback stays empty).
self.write(i); pub struct ScProto {
} /// Fallback policy for the wire paddles beyond the SC's two grips (PADDLE3/4).
} remap: crate::inject::steam_remap::RemapConfig,
} }
fn ensure(&mut self, idx: usize) { impl Default for ScProto {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { fn default() -> ScProto {
return; ScProto {
} remap: crate::inject::steam_remap::RemapConfig::from_env(),
match open_transport(idx as u8) {
Ok(t) => {
self.pads[idx] = Some(t);
self.state[idx] = SteamState::neutral();
self.last_rumble[idx] = (0, 0);
self.last_write[idx] = Instant::now();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual Steam Deck creation failed — controller input disabled");
self.broken = true;
}
}
}
/// Service every pad: answer the kernel handshake and forward rumble on the universal plane.
/// `rumble` fires `(index, low, high)` only on a level change. The Steam Deck has no rich
/// host→client feedback plane (no lightbar / adaptive triggers), so `hidout` goes unused.
pub fn pump(&mut self, mut rumble: impl FnMut(u16, u16, u16), _hidout: impl FnMut(HidOutput)) {
for i in 0..self.pads.len() {
let Some(pad) = self.pads[i].as_mut() else {
continue;
};
if let Some(r) = pad.service() {
if self.last_rumble[i] != r {
self.last_rumble[i] = r;
rumble(i as u16, r.0, r.1);
}
}
} }
} }
} }
impl PadProto for ScProto {
type Pad = SteamDeckPad;
type State = SteamState;
const LABEL: &'static str = "Steam Controller";
const DEVICE: &'static str = "Steam Controller";
const CREATE_HINT: &'static str = "";
fn open(&mut self, idx: u8) -> Result<SteamDeckPad> {
let p = SteamDeckPad::open_model(idx, SteamModel::Controller)?;
tracing::info!(
index = idx,
"virtual Steam Controller created (UHID hid-steam)"
);
Ok(p)
}
fn neutral(&self) -> SteamState {
SteamState::neutral()
}
/// Merge buttons/sticks/triggers, preserving the rich-plane fields. PADDLE1/2 map natively to
/// the SC's two grips inside [`sc_from_gamepad`]; only 3/4 go through the fold policy — mask
/// the native pair out of the fold input so the policy can't double-fire them.
fn merge_frame(
&self,
prev: &SteamState,
f: &crate::gamestream::gamepad::GamepadFrame,
) -> SteamState {
use punktfunk_core::input::gamepad as gs;
let native = f.buttons & (gs::BTN_PADDLE1 | gs::BTN_PADDLE2);
let folded = crate::inject::steam_remap::fold_paddles(
f.buttons & !(gs::BTN_PADDLE1 | gs::BTN_PADDLE2),
self.remap.paddles,
);
let mut s = sc_from_gamepad(
folded | native,
f.ls_x,
f.ls_y,
f.rs_x,
f.rs_y,
f.left_trigger,
f.right_trigger,
);
s.lpad_x = prev.lpad_x;
s.lpad_y = prev.lpad_y;
s.gyro = prev.gyro;
s.accel = prev.accel;
s.buttons |= prev.buttons & btn::LPAD_TOUCH;
s.lpad_click = prev.lpad_click;
// The right pad carries the wire right stick each frame; a rich right-pad contact
// (TouchpadEx surface 2) overrides it only while the stick is centered — the stick is
// the primary camera surface on this mapping.
if f.rs_x == 0 && f.rs_y == 0 {
s.rpad_x = prev.rpad_x;
s.rpad_y = prev.rpad_y;
s.buttons |= prev.buttons & btn::RPAD_TOUCH;
s.rpad_click = prev.rpad_click;
}
s
}
fn apply_rich(&self, st: &mut SteamState, rich: RichInput) {
st.apply_rich(rich);
}
fn write_state(&self, pad: &mut SteamDeckPad, st: &SteamState) {
let _ = pad.write_state(st);
}
/// Answer the kernel handshake (serial GET_REPORT + settings SET_REPORTs). The kernel
/// registers no FF device for the classic SC, so rumble feedback can only arrive from a
/// hidraw client (`0xEB`) — surfaced if it ever does.
fn service(&self, pad: &mut SteamDeckPad, _idx: u8) -> PadFeedback {
PadFeedback {
rumble: pad.service(),
hidout: Vec::new(),
}
}
}
/// All virtual classic Steam Controllers of a session — `PUNKTFUNK_GAMEPAD=steamcontroller`, or
/// the per-pad kind a client declares for a physical SC.
pub type SteamCtrlManager = UhidManager<ScProto>;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -603,4 +677,40 @@ mod tests {
"device not torn down on drop" "device not torn down on drop"
); );
} }
/// On-box smoke for the classic-SC identity: binds `hid-steam` as `28DE:1102`, input flows
/// with NO mode-entry pulse (the SC parser has no gamepad_mode gate), a held A + right-stick
/// deflection land on the evdev (BTN_A + ABS_RX — the right PAD surface), and a grip lands
/// on BTN_GRIPR (0x2c5? — kernel BTN_GRIPR = 0x2c5 on new kernels / check via bitmap).
#[test]
#[ignore = "creates a real /dev/uhid device; needs hid-steam + the input group"]
fn sc_backend_binds_and_input_flows() {
use punktfunk_core::input::gamepad as gs;
const BTN_A: u16 = 0x130;
const ABS_RX: u16 = 0x03;
let mut pad = SteamDeckPad::open_model(0, SteamModel::Controller)
.expect("open SC pad (/dev/uhid + input group?)");
let st = sc_from_gamepad(gs::BTN_A | gs::BTN_PADDLE1, 0, 0, 9000, 0, 0, 0);
let start = Instant::now();
while start.elapsed() < Duration::from_millis(900) {
let _ = pad.service();
pad.write_state(&st).expect("write_state");
std::thread::sleep(Duration::from_millis(4));
}
let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
assert!(
devs.contains("Steam Controller"),
"SC gamepad evdev not created"
);
let node = find_node("Steam Controller").expect("SC evdev node");
assert!(
key_is_down(&node, BTN_A),
"BTN_A not down — SC serialize failed (no mode gate should apply)"
);
assert_eq!(
abs_value(&node, ABS_RX),
Some(9000),
"wire right stick did not land on the right pad (ABS_RX)"
);
}
} }
@@ -0,0 +1,304 @@
//! Virtual Nintendo Switch Pro Controller via UHID — bound by the kernel's `hid-nintendo`
//! (≥ 5.16), so a Nintendo-family client pad gets correct glyphs + positional layout, live
//! gyro/accel, and HD-rumble feedback, instead of folding to the Xbox 360 pad (mirrored A/B
//! + X/Y, no motion).
//!
//! Unlike `hid-playstation` (whose init is three GET_REPORTs), `hid-nintendo` runs a real
//! PROBE CONVERSATION against the device: the `0x80`-family USB commands, then ~a dozen
//! subcommands (device info, SPI-flash calibration reads, IMU/vibration enable, input mode,
//! player lights) — each a blocking send that must see its reply (input report `0x81`/`0x21`)
//! within 12 s or probe aborts and NO input devices appear. The whole codec + the canned
//! replies live in [`super::switch_proto`]; this module is the `/dev/uhid` plumbing that
//! answers them from the [`UhidManager`]'s frequent `service` pass (the same cadence that
//! already completes the DualSense handshake).
//!
//! Post-probe, the driver stalls every LED/rumble write for up to 250 ms unless input reports
//! are flowing — the shared manager's 8 ms silence heartbeat provides exactly that steady
//! `0x30` stream. On host suspend/resume the driver re-runs the whole init; the service pass
//! answers it identically (nothing probe-specific is latched).
use super::switch_proto::{
build_subcmd_reply, build_usb_ack, device_info_payload, parse_output, player_leds_bits,
serialize_report_0x30, spi_flash_read, switch_mac, SwitchOutput, SwitchState, PROCON_RDESC,
SWITCH_PRODUCT, SWITCH_REPORT_LEN, SWITCH_VENDOR,
};
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{Context, Result};
use punktfunk_core::quic::{HidOutput, RichInput};
use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt;
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
const UHID_PATH: &str = "/dev/uhid";
const UHID_DESTROY: u32 = 1;
const UHID_OUTPUT: u32 = 6;
const UHID_GET_REPORT: u32 = 9;
const UHID_GET_REPORT_REPLY: u32 = 10;
const UHID_CREATE2: u32 = 11;
const UHID_INPUT2: u32 = 12;
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
const BUS_USB: u16 = 0x03;
/// Copy a NUL-padded C string field into the event buffer.
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
let n = s.len().min(cap - 1);
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
}
/// A virtual Pro Controller backed by `/dev/uhid`. Dropping it destroys the device (the kernel
/// tears down the bound `hid-nintendo` interface).
pub struct SwitchProPad {
fd: File,
index: u8,
/// Rolling report timer (byte 1 of every input report).
timer: u8,
/// The last written state — subcommand replies embed the current input-state header, so the
/// probe conversation always reports coherent (neutral, at first) controller state.
state: SwitchState,
}
impl SwitchProPad {
/// Create the UHID Pro Controller for pad `index` (used for the name/uniq + the virtual MAC).
pub fn open(index: u8) -> Result<SwitchProPad> {
let fd = OpenOptions::new()
.read(true)
.write(true)
.custom_flags(libc::O_NONBLOCK)
.open(UHID_PATH)
.with_context(|| {
format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)")
})?;
let mut pad = SwitchProPad {
fd,
index,
timer: 0,
state: SwitchState::neutral(),
};
pad.send_create2(index).context("UHID_CREATE2 Switch Pro")?;
Ok(pad)
}
fn send_create2(&mut self, index: u8) -> Result<()> {
let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
// union (uhid_create2_req) starts at byte 4.
put_cstr(&mut ev, 4, 128, &format!("Punktfunk Switch Pro Controller {index}")); // name[128]
put_cstr(&mut ev, 132, 64, &format!("punktfunk/switchpro/{index}")); // phys[64]
put_cstr(&mut ev, 196, 64, &format!("punktfunk-swpro-{index}")); // uniq[64]
ev[260..262].copy_from_slice(&(PROCON_RDESC.len() as u16).to_ne_bytes()); // rd_size
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus (selects the driver's USB init path)
ev[264..268].copy_from_slice(&SWITCH_VENDOR.to_ne_bytes());
ev[268..272].copy_from_slice(&SWITCH_PRODUCT.to_ne_bytes());
ev[272..276].copy_from_slice(&0x0200u32.to_ne_bytes()); // version (bcdDevice 2.00)
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
ev[280..280 + PROCON_RDESC.len()].copy_from_slice(PROCON_RDESC); // rd_data
self.fd.write_all(&ev).context("write UHID_CREATE2")?;
Ok(())
}
/// Write one full input report to the kernel (UHID_INPUT2).
fn write_report(&mut self, r: &[u8; SWITCH_REPORT_LEN]) -> Result<()> {
let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
ev[4..6].copy_from_slice(&(r.len() as u16).to_ne_bytes()); // input2.size
ev[6..6 + r.len()].copy_from_slice(r); // input2.data
self.fd.write_all(&ev).context("write UHID_INPUT2")?;
Ok(())
}
/// Serialize the state into the standard `0x30` report and stream it.
pub fn write_state(&mut self, st: &SwitchState) -> Result<()> {
self.state = *st;
self.timer = self.timer.wrapping_add(1);
let r = serialize_report_0x30(st, self.timer);
self.write_report(&r)
}
/// Answer one subcommand from the driver with its canned `0x21` reply.
fn answer_subcmd(&mut self, id: u8, args: &[u8]) {
self.timer = self.timer.wrapping_add(1);
let st = self.state;
let reply = match id {
// Device info — the fatal one (probe aborts without it): type = Pro Controller +
// this pad's virtual MAC. Real hardware acks it with 0x82.
0x02 => build_subcmd_reply(&st, self.timer, 0x82, id, &device_info_payload(&switch_mac(self.index))),
// SPI flash read: echoed addr + len + the canned calibration bytes. An unmapped
// range answers zeroes (echoed header, zero data) — the driver then warns and uses
// its defaults instead of stalling through 2 × 1 s timeouts.
0x10 => {
let addr = args
.get(..4)
.map(|a| u32::from_le_bytes([a[0], a[1], a[2], a[3]]))
.unwrap_or(0);
let len = args.get(4).copied().unwrap_or(0);
let payload = spi_flash_read(addr, len).unwrap_or_else(|| {
tracing::debug!(addr = format!("{addr:#x}"), len, "unmapped SPI read — zero fill");
let mut p = Vec::with_capacity(5 + len as usize);
p.extend_from_slice(&addr.to_le_bytes());
p.push(len);
p.extend(std::iter::repeat_n(0u8, len as usize));
p
});
build_subcmd_reply(&st, self.timer, 0x90, id, &payload)
}
// Everything else the driver sends (input mode 0x03, IMU 0x40, vibration 0x48,
// player lights 0x30, home light 0x38, …) just needs the ack + echoed id.
_ => build_subcmd_reply(&st, self.timer, 0x80, id, &[]),
};
let _ = self.write_report(&reply);
}
/// Service the device, non-blocking: answer the driver's probe conversation (USB commands +
/// subcommands) and surface a game's rumble / player-lights feedback for pad `pad`. Call
/// frequently — each probe step blocks the driver until answered.
pub fn service(&mut self, pad: u8) -> PadFeedback {
let mut fb = PadFeedback::default();
let mut ev = [0u8; UHID_EVENT_SIZE];
while let Ok(n) = self.fd.read(&mut ev) {
if n < UHID_EVENT_SIZE {
break;
}
match u32::from_ne_bytes([ev[0], ev[1], ev[2], ev[3]]) {
UHID_OUTPUT => {
// uhid_output_req: data[4096] at [4..4100], size u16 at [4100..4102].
let size = u16::from_ne_bytes([ev[4100], ev[4101]]) as usize;
let end = 4 + size.min(HID_MAX_DESCRIPTOR_SIZE);
match parse_output(&ev[4..end]) {
Some(SwitchOutput::UsbCmd(cmd)) => {
// Ack every 0x80 command, incl. no-timeout (0x04) — the driver
// ignores that ack but replying skips its 2 × 100 ms wait.
let _ = self.write_report(&build_usb_ack(cmd));
}
Some(SwitchOutput::Subcmd { id, args, rumble }) => {
fb.rumble = Some(rumble);
if id == 0x30 {
// Player lights ride the subcommand itself; still ack it.
if let Some(&arg) = args.first() {
fb.hidout.push(HidOutput::PlayerLeds {
pad,
bits: player_leds_bits(arg),
});
}
}
self.answer_subcmd(id, &args);
}
Some(SwitchOutput::Rumble(r)) => fb.rumble = Some(r),
None => {}
}
}
UHID_GET_REPORT => {
// hid-nintendo never GET_REPORTs; answer EIO so nothing ever blocks on us.
let req_id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
let _ = self.reply_get_report_err(req_id);
}
_ => {} // Start/Stop/Open/Close/SetReport — ignore
}
}
fb
}
fn reply_get_report_err(&mut self, id: u32) -> Result<()> {
let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_GET_REPORT_REPLY.to_ne_bytes());
// uhid_get_report_reply_req: id u32 [4..8], err u16 [8..10], size u16 [10..12].
ev[4..8].copy_from_slice(&id.to_ne_bytes());
ev[8..10].copy_from_slice(&5u16.to_ne_bytes()); // EIO
self.fd
.write_all(&ev)
.context("write UHID_GET_REPORT_REPLY")?;
Ok(())
}
}
impl Drop for SwitchProPad {
fn drop(&mut self) {
let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_DESTROY.to_ne_bytes());
let _ = self.fd.write_all(&ev);
}
}
/// The Switch-Pro-specific half of the shared stateful manager (see [`PadProto`]): UHID
/// transport open, the [`SwitchState`] mappers, and the probe-conversation service pass.
/// Lifecycle (slot table, unplug sweep, heartbeat, dedup) lives in [`UhidManager`].
pub struct SwitchProProto {
/// Fallback policy for the Steam back grips a client may send (a Pro Controller has no
/// back-button slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
remap: crate::inject::steam_remap::RemapConfig,
}
impl Default for SwitchProProto {
fn default() -> SwitchProProto {
SwitchProProto {
remap: crate::inject::steam_remap::RemapConfig::from_env(),
}
}
}
impl PadProto for SwitchProProto {
type Pad = SwitchProPad;
type State = SwitchState;
const LABEL: &'static str = "Switch Pro";
const DEVICE: &'static str = "Switch Pro Controller";
const CREATE_HINT: &'static str = "";
fn open(&mut self, idx: u8) -> Result<SwitchProPad> {
let p = SwitchProPad::open(idx)?;
tracing::info!(
index = idx,
"virtual Switch Pro Controller created (UHID hid-nintendo)"
);
Ok(p)
}
fn neutral(&self) -> SwitchState {
SwitchState::neutral()
}
/// Merge buttons/sticks/triggers from the frame, preserving motion (it arrives on the rich
/// plane and must survive a button-only frame). Paddles fold via the configured policy.
fn merge_frame(
&self,
prev: &SwitchState,
f: &crate::gamestream::gamepad::GamepadFrame,
) -> SwitchState {
let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
let mut s = SwitchState::from_gamepad(
buttons,
f.ls_x,
f.ls_y,
f.rs_x,
f.rs_y,
f.left_trigger,
f.right_trigger,
);
s.gyro = prev.gyro;
s.accel = prev.accel;
s
}
/// Motion lands on the IMU sample frames; a Pro Controller has no touchpad, so touch events
/// are dropped (the client folds trackpads into stick/mouse modes itself).
fn apply_rich(&self, st: &mut SwitchState, rich: RichInput) {
if let RichInput::Motion { gyro, accel, .. } = rich {
st.apply_motion(gyro, accel);
}
}
fn write_state(&self, pad: &mut SwitchProPad, st: &SwitchState) {
let _ = pad.write_state(st);
}
/// Answer the driver's probe conversation (it blocks `hid-nintendo` init until every step is
/// answered — call frequently) and surface a game's feedback: HD-rumble amplitude on the
/// universal 0xCA plane, player lights on the 0xCD plane.
fn service(&self, pad: &mut SwitchProPad, idx: u8) -> PadFeedback {
pad.service(idx)
}
}
/// All virtual Switch Pro Controllers of a session — `PUNKTFUNK_GAMEPAD=switchpro`, or the
/// per-pad kind a client declares for a Nintendo-family physical pad.
pub type SwitchProManager = UhidManager<SwitchProProto>;
@@ -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));
}
}
@@ -0,0 +1,184 @@
//! Shared virtual-pad slot table + creation lifecycle, used by every backend manager (Linux
//! uinput/uhid, Windows XUSB/UMDF). See [`PadSlots`].
use crate::gamestream::gamepad::MAX_PADS;
use crate::inject::pad_gate::PadGate;
use anyhow::Result;
use std::time::Instant;
// The unplug sweep walks a u16 `active_mask` (the wire type); every slot must have a bit.
const _: () = assert!(MAX_PADS <= 16);
/// The slot table + lifecycle every virtual-pad manager repeats: `Vec<Option<P>>` keyed by wire pad
/// index, the `active_mask` unplug sweep, and the [`PadGate`]-guarded create. Extracted verbatim
/// from seven copy-pasted managers (G12) so a lifecycle fix lands once, not seven times.
///
/// Division of labor: `PadSlots` owns the pads' *existence* (create / sweep / lookup) and logs the
/// shared lifecycle lines (unplug, create-failure); the backend keeps everything per-controller —
/// its state model, feedback pump, and the success log inside `open` (which knows the transport
/// detail worth printing). Per-index sibling state (`state` / `last_rumble` / dedup / clocks) stays
/// in the manager, which resets it on the indices [`sweep`](Self::sweep) returns and on a `true`
/// from [`ensure`](Self::ensure).
pub struct PadSlots<P> {
pads: Vec<Option<P>>,
/// Create-retry gate: a transient backend failure backs off and retries instead of permanently
/// disabling every pad for the session.
gate: PadGate,
/// Backend tag in the shared lifecycle log lines, e.g. `"DualSense/Windows"` — keeps every
/// existing per-backend line byte-identical (ops greps survive the extraction).
label: &'static str,
/// Device name in the create-failure line ("virtual `<device>` creation failed …").
device: &'static str,
/// Suffix for the create-failure line — empty on Linux, the driver-install hint on Windows.
hint: &'static str,
}
impl<P> PadSlots<P> {
/// An empty table of [`MAX_PADS`] slots whose lifecycle log lines carry `label` / `device` /
/// `hint` (see the field docs).
pub fn new(label: &'static str, device: &'static str, hint: &'static str) -> PadSlots<P> {
PadSlots {
pads: (0..MAX_PADS).map(|_| None).collect(),
gate: PadGate::new(),
label,
device,
hint,
}
}
/// The backend tag this table logs with (for the manager's own arrival line).
pub fn label(&self) -> &'static str {
self.label
}
/// Drop every allocated pad whose `active_mask` bit cleared (the unplug sweep run on each state
/// frame), logging each. Returns the swept indices as a bitmask so the caller resets its
/// per-index sibling state; an index another manager owns is `None` here, so it is never swept.
pub fn sweep(&mut self, active_mask: u16) -> u16 {
let mut swept = 0u16;
for (i, slot) in self.pads.iter_mut().enumerate() {
if slot.is_some() && active_mask & (1 << i) == 0 {
tracing::info!(index = i, "controller unplugged ({})", self.label);
*slot = None;
swept |= 1 << i;
}
}
swept
}
/// Create the pad at `idx` via `open` if the slot is empty and the create gate allows it.
/// Returns `true` only on a fresh create (the caller resets its per-index sibling state);
/// `open` logs its own success line (it knows the transport detail), failure is logged here.
pub fn ensure(&mut self, idx: usize, open: impl FnOnce(u8) -> Result<P>) -> bool {
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
return false;
}
match open(idx as u8) {
Ok(p) => {
self.pads[idx] = Some(p);
self.gate.on_success();
true
}
Err(e) => {
tracing::error!(
error = %format!("{e:#}"),
"virtual {} creation failed — retrying with backoff{}",
self.device,
self.hint
);
self.gate.on_failure(Instant::now());
false
}
}
}
/// The live pad at `idx`, if any (out-of-range → `None`).
pub fn get(&self, idx: usize) -> Option<&P> {
self.pads.get(idx).and_then(|s| s.as_ref())
}
/// The live pad at `idx`, mutably, if any (out-of-range → `None`).
pub fn get_mut(&mut self, idx: usize) -> Option<&mut P> {
self.pads.get_mut(idx).and_then(|s| s.as_mut())
}
/// Iterate the live pads as `(index, &mut pad)` (the feedback-pump shape).
pub fn iter_mut(&mut self) -> impl Iterator<Item = (usize, &mut P)> {
self.pads
.iter_mut()
.enumerate()
.filter_map(|(i, s)| s.as_mut().map(|p| (i, p)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::bail;
fn slots() -> PadSlots<u32> {
PadSlots::new("Test", "test pad", "")
}
#[test]
fn ensure_creates_once_and_reports_freshness() {
let mut s = slots();
// Fresh create → true; the pad is live.
assert!(s.ensure(3, |i| Ok(i as u32 * 10)));
assert_eq!(s.get(3), Some(&30));
// Occupied slot → no re-open (the closure must not run), no reset signal.
assert!(!s.ensure(3, |_| panic!("re-opened an occupied slot")));
// Out of range → never opens.
assert!(!s.ensure(MAX_PADS, |_| panic!("opened out of range")));
assert_eq!(s.get(MAX_PADS), None);
}
#[test]
fn sweep_drops_only_cleared_bits_and_returns_them_once() {
let mut s = slots();
assert!(s.ensure(0, |_| Ok(0)));
assert!(s.ensure(2, |_| Ok(2)));
assert!(s.ensure(5, |_| Ok(5)));
// Mask keeps 2, clears 0 and 5; empty slots (1, 3, …) are untouched non-events.
let swept = s.sweep(0b0000_0100);
assert_eq!(swept, 0b0010_0001);
assert_eq!(s.get(0), None);
assert_eq!(s.get(2), Some(&2));
assert_eq!(s.get(5), None);
// A second identical sweep is a no-op: the indices were returned exactly once.
assert_eq!(s.sweep(0b0000_0100), 0);
}
#[test]
fn create_failure_arms_the_gate_and_success_heals_it() {
let mut s = slots();
assert!(!s.ensure(1, |_| bail!("transient")));
// Backoff in effect: the next attempt is blocked without even calling `open`.
assert!(!s.ensure(1, |_| panic!("open during backoff")));
// The gate is manager-wide (create failures are systemic), so other indices block too.
assert!(!s.ensure(2, |_| panic!("open during backoff")));
// …and a sweep-then-recreate of a *different* live pad is equally gated, but the table
// itself is intact: nothing was allocated.
assert_eq!(s.get(1), None);
}
#[test]
fn recreate_after_sweep_resets_freshness() {
let mut s = slots();
assert!(s.ensure(4, |_| Ok(1)));
s.sweep(0);
assert_eq!(s.get(4), None);
// The slot is free again → a fresh create (true) with a new value.
assert!(s.ensure(4, |_| Ok(2)));
assert_eq!(s.get(4), Some(&2));
}
#[test]
fn iter_mut_yields_live_pads_with_indices() {
let mut s = slots();
assert!(s.ensure(1, |_| Ok(10)));
assert!(s.ensure(6, |_| Ok(60)));
let seen: Vec<(usize, u32)> = s.iter_mut().map(|(i, p)| (i, *p)).collect();
assert_eq!(seen, vec![(1, 10), (6, 60)]);
}
}
@@ -66,8 +66,45 @@ pub const DUALSENSE_RDESC: &[u8] = &[
0xC0, 0xC0,
]; ];
/// Sony DualSense **Edge** USB HID report descriptor (389 bytes) — a verbatim real-device
/// capture (hid-recorder, hhd-dev/hwinfo `devices/ds5_edge`, cross-checked byte-for-byte against
/// the raw usbmon pcap in the same repo and the descriptor Handheld Daemon ships for ITS virtual
/// UHID Edge). vs the plain DS5 descriptor: output report `0x02` grows 47→63 bytes, feature
/// `0xF2` 15→52, and 19 vendor feature reports (`0x60..=0x7B`, the Edge profile slots) are
/// appended — input report `0x01` is bit-identical (the Edge's Fn/back buttons ride previously
/// reserved bits of `buttons[2]`, see [`btn2`]).
#[rustfmt::skip]
pub const DUALSENSE_EDGE_RDESC: &[u8] = &[
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x35,
0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x06, 0x81, 0x02, 0x06,
0x00, 0xFF, 0x09, 0x20, 0x95, 0x01, 0x81, 0x02, 0x05, 0x01, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07,
0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00, 0x05,
0x09, 0x19, 0x01, 0x29, 0x0F, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0F, 0x81, 0x02, 0x06,
0x00, 0xFF, 0x09, 0x21, 0x95, 0x0D, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x22, 0x15, 0x00, 0x26,
0xFF, 0x00, 0x75, 0x08, 0x95, 0x34, 0x81, 0x02, 0x85, 0x02, 0x09, 0x23, 0x95, 0x3F, 0x91, 0x02,
0x85, 0x05, 0x09, 0x33, 0x95, 0x28, 0xB1, 0x02, 0x85, 0x08, 0x09, 0x34, 0x95, 0x2F, 0xB1, 0x02,
0x85, 0x09, 0x09, 0x24, 0x95, 0x13, 0xB1, 0x02, 0x85, 0x0A, 0x09, 0x25, 0x95, 0x1A, 0xB1, 0x02,
0x85, 0x20, 0x09, 0x26, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x21, 0x09, 0x27, 0x95, 0x04, 0xB1, 0x02,
0x85, 0x22, 0x09, 0x40, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x80, 0x09, 0x28, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0x81, 0x09, 0x29, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x2A, 0x95, 0x09, 0xB1, 0x02,
0x85, 0x83, 0x09, 0x2B, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x84, 0x09, 0x2C, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0x85, 0x09, 0x2D, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xA0, 0x09, 0x2E, 0x95, 0x01, 0xB1, 0x02,
0x85, 0xE0, 0x09, 0x2F, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x30, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0xF1, 0x09, 0x31, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2, 0x09, 0x32, 0x95, 0x34, 0xB1, 0x02,
0x85, 0xF4, 0x09, 0x35, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF5, 0x09, 0x36, 0x95, 0x03, 0xB1, 0x02,
0x85, 0x60, 0x09, 0x41, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x61, 0x09, 0x42, 0xB1, 0x02, 0x85, 0x62,
0x09, 0x43, 0xB1, 0x02, 0x85, 0x63, 0x09, 0x44, 0xB1, 0x02, 0x85, 0x64, 0x09, 0x45, 0xB1, 0x02,
0x85, 0x65, 0x09, 0x46, 0xB1, 0x02, 0x85, 0x68, 0x09, 0x47, 0xB1, 0x02, 0x85, 0x70, 0x09, 0x48,
0xB1, 0x02, 0x85, 0x71, 0x09, 0x49, 0xB1, 0x02, 0x85, 0x72, 0x09, 0x4A, 0xB1, 0x02, 0x85, 0x73,
0x09, 0x4B, 0xB1, 0x02, 0x85, 0x74, 0x09, 0x4C, 0xB1, 0x02, 0x85, 0x75, 0x09, 0x4D, 0xB1, 0x02,
0x85, 0x76, 0x09, 0x4E, 0xB1, 0x02, 0x85, 0x77, 0x09, 0x4F, 0xB1, 0x02, 0x85, 0x78, 0x09, 0x50,
0xB1, 0x02, 0x85, 0x79, 0x09, 0x51, 0xB1, 0x02, 0x85, 0x7A, 0x09, 0x52, 0xB1, 0x02, 0x85, 0x7B,
0x09, 0x53, 0xB1, 0x02, 0xC0,
];
pub const DS_VENDOR: u32 = 0x054C; // Sony Interactive Entertainment pub const DS_VENDOR: u32 = 0x054C; // Sony Interactive Entertainment
pub const DS_PRODUCT: u32 = 0x0CE6; // DualSense Wireless Controller pub const DS_PRODUCT: u32 = 0x0CE6; // DualSense Wireless Controller
pub const DS_EDGE_PRODUCT: u32 = 0x0DF2; // DualSense Edge Wireless Controller
/// USB input report `0x01` is 64 bytes total (report id + 63-byte body). /// USB input report `0x01` is 64 bytes total (report id + 63-byte body).
pub const DS_INPUT_REPORT_LEN: usize = 64; pub const DS_INPUT_REPORT_LEN: usize = 64;
/// The DualSense touchpad's reported resolution (the kernel exposes it as ABS_MT 0..1920/1080). /// The DualSense touchpad's reported resolution (the kernel exposes it as ABS_MT 0..1920/1080).
@@ -92,12 +129,47 @@ pub mod btn1 {
pub const L3: u8 = 0x40; pub const L3: u8 = 0x40;
pub const R3: u8 = 0x80; pub const R3: u8 = 0x80;
} }
/// `buttons[2]`: PS, touchpad click, mute (+ a rolling counter in the high bits). /// `buttons[2]`: PS, touchpad click, mute — plus, on the DualSense **Edge**, the two Fn and two
/// back buttons in bits 47 (kernel `DS_EDGE_BUTTONS_*` / SDL `SDL_GAMEPAD_BUTTON_PS5_*`; the
/// plain DS5 leaves those bits reserved). The kernel maps them to `BTN_TRIGGER_HAPPY1..4`
/// (Fn-L, Fn-R, back-L, back-R) since 7.2; SDL/Steam read them off hidraw on any kernel.
pub mod btn2 { pub mod btn2 {
pub const PS: u8 = 0x01; pub const PS: u8 = 0x01;
pub const TOUCHPAD: u8 = 0x02; 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; pub const MUTE: u8 = 0x04;
/// Edge left Fn button (below the left stick).
pub const EDGE_FN_LEFT: u8 = 0x10;
/// Edge right Fn button.
pub const EDGE_FN_RIGHT: u8 = 0x20;
/// Edge left back button (rear paddle).
pub const EDGE_BACK_LEFT: u8 = 0x40;
/// Edge right back button (rear paddle).
pub const EDGE_BACK_RIGHT: u8 = 0x80;
}
/// Map the wire back-grip bits onto the DualSense Edge's `buttons[2]` bits — the reason the Edge
/// backend exists: all four client paddles (Deck grips L4/L5/R4/R5, Elite P1P4) land on native
/// slots instead of the fold/drop policy. Wire PADDLE1/2 = R4/L4 (the primary pair, Steam
/// convention) → the Edge's right/left BACK buttons; PADDLE3/4 = R5/L5 → the right/left Fn
/// buttons (real-HW Fn is profile-switch chrome, but on a virtual pad the bits reach consumers
/// as ordinary buttons — kernel `BTN_TRIGGER_HAPPY1/2`, SDL `LEFT/RIGHT_FUNCTION`).
pub fn edge_paddle_bits(buttons: u32) -> u8 {
use punktfunk_core::input::gamepad as gs;
let mut b = 0;
if buttons & gs::BTN_PADDLE1 != 0 {
b |= btn2::EDGE_BACK_RIGHT; // R4
}
if buttons & gs::BTN_PADDLE2 != 0 {
b |= btn2::EDGE_BACK_LEFT; // L4
}
if buttons & gs::BTN_PADDLE3 != 0 {
b |= btn2::EDGE_FN_RIGHT; // R5
}
if buttons & gs::BTN_PADDLE4 != 0 {
b |= btn2::EDGE_FN_LEFT; // L5
}
b
} }
/// One touchpad contact for the report. /// One touchpad contact for the report.
@@ -223,6 +295,12 @@ impl DsState {
if on(gs::BTN_TOUCHPAD) { if on(gs::BTN_TOUCHPAD) {
s.buttons[2] |= btn2::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 s
} }
@@ -439,10 +517,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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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 /// 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 /// 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 /// shared screen convention (top → 0) with no flip; pad clicks set the touchpad-click
@@ -669,16 +856,65 @@ mod tests {
assert_eq!(r[53], 0x0A); 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] #[test]
fn from_gamepad_maps_touchpad_click() { fn from_gamepad_maps_touchpad_click() {
use punktfunk_core::input::gamepad as gs; use punktfunk_core::input::gamepad as gs;
let s = DsState::from_gamepad(gs::BTN_TOUCHPAD | gs::BTN_GUIDE, 0, 0, 0, 0, 0, 0); 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); 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); let s = DsState::from_gamepad(gs::BTN_A, 0, 0, 0, 0, 0, 0);
assert_eq!(s.buttons[2], 0); assert_eq!(s.buttons[2], 0);
} }
/// The Edge paddle map, pinned against hid-playstation's `DS_EDGE_BUTTONS_*` masks (bits
/// 47 of `buttons[2]`) and SDL's `SDL_GAMEPAD_BUTTON_PS5_*` (same byte off hidraw):
/// PADDLE1/2 (R4/L4) → right/left BACK, PADDLE3/4 (R5/L5) → right/left Fn — and the mapped
/// bits land in the serialized report's byte 10 next to the ordinary buttons[2] bits.
#[test]
fn edge_paddles_map_to_native_bits() {
use punktfunk_core::input::gamepad as gs;
assert_eq!(edge_paddle_bits(0), 0);
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE1), btn2::EDGE_BACK_RIGHT);
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE2), btn2::EDGE_BACK_LEFT);
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE3), btn2::EDGE_FN_RIGHT);
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE4), btn2::EDGE_FN_LEFT);
// Exact kernel/SDL bit values (a one-bit slip ships dead paddles).
assert_eq!(btn2::EDGE_FN_LEFT, 0x10);
assert_eq!(btn2::EDGE_FN_RIGHT, 0x20);
assert_eq!(btn2::EDGE_BACK_LEFT, 0x40);
assert_eq!(btn2::EDGE_BACK_RIGHT, 0x80);
// All four + a non-paddle bit: paddles map, the rest is ignored here.
let all = gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4 | gs::BTN_A;
assert_eq!(edge_paddle_bits(all), 0xF0);
// Serialized: the Edge merge ORs into buttons[2]; byte 10 carries both the paddles and
// the ordinary bits (e.g. a simultaneous PS press).
let mut s = DsState::from_gamepad(gs::BTN_GUIDE, 0, 0, 0, 0, 0, 0);
s.buttons[2] |= edge_paddle_bits(gs::BTN_PADDLE2 | gs::BTN_PADDLE3);
let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, &s, 0, 0);
assert_eq!(r[10], btn2::PS | btn2::EDGE_BACK_LEFT | btn2::EDGE_FN_RIGHT);
}
/// The Edge descriptor is the real-device capture: exact length, the three deltas vs the
/// plain DS5 descriptor (output 0x02 count 63, feature 0xF2 count 52, the appended profile
/// feature reports), and an unchanged input-report prefix (report 0x01 is bit-identical —
/// the serializer needs no Edge variant).
#[test]
fn edge_descriptor_shape() {
assert_eq!(DUALSENSE_RDESC.len(), 273);
assert_eq!(DUALSENSE_EDGE_RDESC.len(), 389);
// Identical through the input-report + output-report-id prefix; the first delta is the
// output report 0x02's Report Count at offset 109 (47 → 63 bytes of payload).
assert_eq!(DUALSENSE_EDGE_RDESC[..109], DUALSENSE_RDESC[..109]);
assert_eq!(DUALSENSE_RDESC[109], 0x2F);
assert_eq!(DUALSENSE_EDGE_RDESC[109], 0x3F);
assert_eq!(*DUALSENSE_EDGE_RDESC.last().unwrap(), 0xC0);
}
/// A short / wrong-id report yields nothing. /// A short / wrong-id report yields nothing.
#[test] #[test]
fn parse_output_rejects_garbage() { fn parse_output_rejects_garbage() {
@@ -1,10 +1,6 @@
//! Transport-independent DualShock 4 HID contract — the pure report codec used by the Windows //! Transport-independent DualShock 4 HID contract — the pure report codec shared by the Windows
//! UMDF-driver backend ([`super::dualshock4_windows`]). //! UMDF-driver backend ([`super::dualshock4_windows`]) and the Linux UHID backend
//! //! ([`super::dualshock4`]).
//! FIXME(ds4-dedup): the Linux UHID backend ([`super::dualshock4`]) still carries its own byte-
//! identical copy of this codec (`serialize_state` / `parse_ds4_output` / `Ds4Feedback` / the touch
//! dims). Fold it onto this module once the Linux build can be re-validated (it is `cfg(linux)`, so
//! it can't be compile-checked from a Windows host). Keep the two in sync until then.
//! //!
//! The PS4 sibling of [`super::dualsense_proto`]: the pure report codec with no transport. The DS4 //! The PS4 sibling of [`super::dualsense_proto`]: the pure report codec with no transport. The DS4
//! reuses the DualSense [`DsState`] controller model + its `GameStream`/XInput mapper //! reuses the DualSense [`DsState`] controller model + its `GameStream`/XInput mapper
@@ -17,7 +13,6 @@
//! dualshock4_input_report_usb` / `_output_report_common` parse. //! dualshock4_input_report_usb` / `_output_report_common` parse.
use super::dualsense_proto::{DsState, Touch}; use super::dualsense_proto::{DsState, Touch};
use punktfunk_core::quic::HidOutput;
/// DualShock 4 v2 USB identity (Sony Interactive Entertainment / CUH-ZCT2). /// DualShock 4 v2 USB identity (Sony Interactive Entertainment / CUH-ZCT2).
pub const DS4_VENDOR: u16 = 0x054C; pub const DS4_VENDOR: u16 = 0x054C;
@@ -77,11 +72,10 @@ pub fn serialize_state(r: &mut [u8; DS4_INPUT_REPORT_LEN], st: &DsState, counter
} }
/// What one feedback pass extracted from the device's HID output reports. Rumble rides the universal /// What one feedback pass extracted from the device's HID output reports. Rumble rides the universal
/// 0xCA plane; the lightbar rides the HID-output 0xCD plane (DS4 has no player LEDs or adaptive /// 0xCA plane; the lightbar rides the HID-output 0xCD plane as a `Led` event (DS4 has no player LEDs
/// triggers, so those never appear). /// or adaptive triggers, so those never appear).
#[derive(Default)] #[derive(Default)]
pub struct Ds4Feedback { pub struct Ds4Feedback {
pub hidout: Vec<HidOutput>,
/// `(low, high)` motor levels (0..=0xFF00), if a report carried them. /// `(low, high)` motor levels (0..=0xFF00), if a report carried them.
pub rumble: Option<(u16, u16)>, pub rumble: Option<(u16, u16)>,
/// Lightbar RGB, if the report carried it (deduped by the manager). /// Lightbar RGB, if the report carried it (deduped by the manager).
@@ -149,6 +143,14 @@ mod tests {
assert_eq!(r[35] & 0x80, 0); // contact 0 active (bit7 clear) assert_eq!(r[35] & 0x80, 0); // contact 0 active (bit7 clear)
assert_eq!(r[35] & 0x7F, 0); // contact id 0 assert_eq!(r[35] & 0x7F, 0); // contact id 0
assert_eq!(r[30] & 0x10, 0x10); // cable/wired bit set assert_eq!(r[30] & 0x10, 0x10); // cable/wired bit set
// A rich-plane pad click (`touch_click`, no BTN_TOUCHPAD in the frame) rides the
// touchpad-click bit at byte 7 bit 1 via `buttons2_with_click` — the Linux backend used to
// serialize raw `buttons[2]` here and drop it.
assert_eq!(r[7] & 0x02, 0); // no click yet
st.touch_click[0] = true;
serialize_state(&mut r, &st, 0, 0);
assert_eq!(r[7] & 0x02, 0x02);
} }
/// A DS4 USB output report (`0x05`) with motor + LED flags parses into rumble (0xCA) and a /// A DS4 USB output report (`0x05`) with motor + LED flags parses into rumble (0xCA) and a
@@ -156,6 +156,15 @@ pub struct SteamState {
/// (with Z/RZ negated) on the separate sensors evdev. /// (with Z/RZ negated) on the separate sensors evdev.
pub accel: [i16; 3], pub accel: [i16; 3],
pub gyro: [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 { impl SteamState {
@@ -273,12 +282,14 @@ impl SteamState {
// left pad, anything else (0 single / 2 right) = right pad. // left pad, anything else (0 single / 2 right) = right pad.
if surface == 1 { if surface == 1 {
self.press(btn::LPAD_TOUCH, touch); 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_x = x;
self.lpad_y = flip_y(y); self.lpad_y = flip_y(y);
} else { } else {
self.press(btn::RPAD_TOUCH, touch); self.press(btn::RPAD_TOUCH, touch);
self.press(btn::RPAD_CLICK, click); self.rpad_click = click;
self.rpad_x = x; self.rpad_x = x;
self.rpad_y = flip_y(y); 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[2] = ID_CONTROLLER_DECK_STATE;
r[3] = 0x3C; // payload length; the kernel ignores it r[3] = 0x3C; // payload length; the kernel ignores it
r[4..8].copy_from_slice(&seq.to_le_bytes()); 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[16..18].copy_from_slice(&st.lpad_x.to_le_bytes());
r[18..20].copy_from_slice(&st.lpad_y.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()); r[20..22].copy_from_slice(&st.rpad_x.to_le_bytes());
@@ -319,6 +341,129 @@ pub fn serialize_deck_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq
r[58..60].copy_from_slice(&st.rpad_pressure.to_le_bytes()); r[58..60].copy_from_slice(&st.rpad_pressure.to_le_bytes());
} }
/// Map an `XInput`/GameStream pad frame into **classic Steam Controller** state. The SC's 24-bit
/// button field (report bytes 8..10) shares its low-bit layout with the Deck's (face/shoulder/
/// trigger-full byte 8; dpad/View/Steam/Menu byte 9 bits 06), so this reuses the [`btn`] masks —
/// with the SC-specific tail per the kernel's `ID_CONTROLLER_STATE` table:
/// - `9.7`/`10.0` are the SC's TWO grips (the bit positions the Deck calls L5/R5): wire
/// `BTN_PADDLE2`/`BTN_PADDLE1` (L4/R4, the primary pair) land there; fold PADDLE3/4 via
/// [`super::steam_remap`] BEFORE calling this.
/// - `10.2` = right-pad clicked (the SC has no right stick): wire `BTN_RS_CLICK` and the
/// DualSense `BTN_TOUCHPAD` click both land there.
/// - `10.6` = joystick clicked = wire `BTN_LS_CLICK` (the same bit the Deck calls L3).
/// - No QAM/misc slot — `BTN_MISC1` is dropped (fold it upstream if a policy wants it).
///
/// The wire right STICK drives the right-pad coordinates (`rpad_x/y` + the `10.4` touched bit
/// while deflected) — the SC's camera surface; the loss of a true second stick is inherent to
/// the hardware. The left stick rides the joystick fields; a left-pad `TouchpadEx` contact
/// (via [`SteamState::apply_rich`]) SHADOWS the joystick while touched (the report multiplexes
/// them at bytes 16..20, exactly like real hardware's `lpad_touched` flag).
pub fn sc_from_gamepad(
buttons: u32,
lx: i16,
ly: i16,
rx: i16,
ry: i16,
lt: u8,
rt: u8,
) -> SteamState {
let on = |bit: u32| buttons & bit != 0;
let mut s = SteamState {
lx,
ly,
rx: 0,
ry: 0,
lt: (lt as u16) * 128,
rt: (rt as u16) * 128,
// The wire right stick becomes a right-pad contact (see the doc above).
rpad_x: rx,
rpad_y: ry,
..SteamState::neutral()
};
let mut b = 0u64;
let set = |b: &mut u64, on: bool, m: u64| {
if on {
*b |= m;
}
};
set(&mut b, on(gs::BTN_A), btn::A);
set(&mut b, on(gs::BTN_B), btn::B);
set(&mut b, on(gs::BTN_X), btn::X);
set(&mut b, on(gs::BTN_Y), btn::Y);
set(&mut b, on(gs::BTN_LB), btn::LB);
set(&mut b, on(gs::BTN_RB), btn::RB);
set(&mut b, lt > 0, btn::LT_FULL);
set(&mut b, rt > 0, btn::RT_FULL);
set(&mut b, on(gs::BTN_BACK), btn::VIEW);
set(&mut b, on(gs::BTN_START), btn::MENU);
set(&mut b, on(gs::BTN_GUIDE), btn::STEAM);
set(&mut b, on(gs::BTN_DPAD_UP), btn::DPAD_UP);
set(&mut b, on(gs::BTN_DPAD_DOWN), btn::DPAD_DOWN);
set(&mut b, on(gs::BTN_DPAD_LEFT), btn::DPAD_LEFT);
set(&mut b, on(gs::BTN_DPAD_RIGHT), btn::DPAD_RIGHT);
// SC grips at the Deck's L5/R5 bit positions (9.7 / 10.0): the wire primary pair L4/R4.
set(&mut b, on(gs::BTN_PADDLE2), btn::L5); // left grip
set(&mut b, on(gs::BTN_PADDLE1), btn::R5); // right grip
// Joystick click (10.6 — the bit the Deck calls L3) + right-pad click (10.2).
set(&mut b, on(gs::BTN_LS_CLICK), btn::L3);
set(
&mut b,
on(gs::BTN_RS_CLICK) || on(gs::BTN_TOUCHPAD),
btn::RPAD_CLICK,
);
// Right-pad touched (10.4) while the wire stick is deflected — the coords are live then.
set(&mut b, rx != 0 || ry != 0, btn::RPAD_TOUCH);
s.buttons = b;
s
}
/// Serialize the classic Steam Controller input report (`ID_CONTROLLER_STATE`) into the 64-byte
/// unnumbered frame `steam_do_input_event` parses. Byte-exact against the kernel's message
/// table: 24-bit buttons at 8..11, **u8** triggers at 11/12 (the Deck uses u16 at 44/46),
/// the joystick/left-pad MULTIPLEX at 16..20 (left-pad coords shadow the joystick while the
/// `10.3` touched bit is set), the right pad at 20..24, and the (kernel-ignored, hidraw-visible)
/// accel/gyro at 28..39. The kernel negates both Y axes on top of these raw values.
pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq: u32) {
r.fill(0);
r[0] = 0x01;
r[1] = 0x00;
r[2] = ID_CONTROLLER_STATE;
r[3] = 0x3C;
r[4..8].copy_from_slice(&seq.to_le_bytes());
// Rich-plane pad clicks merge like the Deck path: left-pad clicked = 10.1 (hidraw-only —
// the kernel maps no key to it), right-pad clicked = 10.2.
let mut buttons = st.buttons;
if st.lpad_click {
buttons |= btn::LPAD_CLICK;
}
if st.rpad_click {
buttons |= btn::RPAD_CLICK;
}
r[8] = (buttons & 0xFF) as u8;
r[9] = ((buttons >> 8) & 0xFF) as u8;
r[10] = ((buttons >> 16) & 0xFF) as u8;
r[11] = (st.lt >> 7).min(255) as u8; // left trigger, u8
r[12] = (st.rt >> 7).min(255) as u8; // right trigger, u8
// Bytes 16..20 carry EITHER the joystick OR the left pad, per the 10.3 touched bit.
let (x, y) = if buttons & btn::LPAD_TOUCH != 0 {
(st.lpad_x, st.lpad_y)
} else {
(st.lx, st.ly)
};
r[16..18].copy_from_slice(&x.to_le_bytes());
r[18..20].copy_from_slice(&y.to_le_bytes());
r[20..22].copy_from_slice(&st.rpad_x.to_le_bytes());
r[22..24].copy_from_slice(&st.rpad_y.to_le_bytes());
// IMU: present in the frame (28..39) for hidraw readers, but the kernel maps none of it
// ("accelerator/gyro is disabled by default" — no sensors evdev for the SC).
r[28..30].copy_from_slice(&st.accel[0].to_le_bytes());
r[30..32].copy_from_slice(&st.accel[1].to_le_bytes());
r[32..34].copy_from_slice(&st.accel[2].to_le_bytes());
r[34..36].copy_from_slice(&st.gyro[0].to_le_bytes());
r[36..38].copy_from_slice(&st.gyro[1].to_le_bytes());
r[38..40].copy_from_slice(&st.gyro[2].to_le_bytes());
}
/// Build the `steam_get_serial` GET_REPORT reply. The Steam feature path is report-id-0 with a /// Build the `steam_get_serial` GET_REPORT reply. The Steam feature path is report-id-0 with a
/// leading report-id byte the kernel strips (`steam_recv_report` does `memcpy(data, buf+1, …)`), so /// leading report-id byte the kernel strips (`steam_recv_report` does `memcpy(data, buf+1, …)`), so
/// the wire is `[0x00, 0xAE, len, 0x01, ascii…]`; the kernel then validates `reply[0]==0xAE`, /// the wire is `[0x00, 0xAE, len, 0x01, ascii…]`; the kernel then validates `reply[0]==0xAE`,
@@ -611,7 +756,9 @@ mod tests {
pressure: 100, pressure: 100,
}); });
assert_ne!(s.buttons & btn::LPAD_TOUCH, 0); 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)); assert_eq!((s.lpad_x, s.lpad_y), (-5000, -6000));
s.apply_rich(RichInput::TouchpadEx { s.apply_rich(RichInput::TouchpadEx {
pad: 0, pad: 0,
@@ -624,6 +771,7 @@ mod tests {
pressure: 0, pressure: 0,
}); });
assert_ne!(s.buttons & btn::RPAD_TOUCH, 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)); assert_eq!((s.rpad_x, s.rpad_y), (7000, 8000));
// The i16 edge: wire y = -32768 (top-most) must clamp, not overflow. // The i16 edge: wire y = -32768 (top-most) must clamp, not overflow.
@@ -640,6 +788,100 @@ mod tests {
assert_eq!(s.rpad_y, 32767); 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 classic-SC frame, byte-exact against the kernel's `ID_CONTROLLER_STATE` table: 24-bit
/// buttons at 8..11, u8 triggers at 11/12, the joystick/left-pad multiplex at 16..20, right
/// pad at 20..24 — and the SC-specific button tail (grips at 9.7/10.0, right-pad click at
/// 10.2, joystick click at 10.6).
#[test]
fn sc_serialize_and_mapping() {
// Full mapping: face + grips + clicks + a deflected right stick.
let s = sc_from_gamepad(
gs::BTN_A
| gs::BTN_PADDLE1
| gs::BTN_PADDLE2
| gs::BTN_LS_CLICK
| gs::BTN_RS_CLICK,
1000,
-2000,
3000,
-4000,
255,
0,
);
assert_ne!(s.buttons & btn::A, 0);
assert_ne!(s.buttons & btn::R5, 0); // PADDLE1 → right grip (10.0)
assert_ne!(s.buttons & btn::L5, 0); // PADDLE2 → left grip (9.7)
assert_ne!(s.buttons & btn::L3, 0); // LS click → joystick clicked (10.6)
assert_ne!(s.buttons & btn::RPAD_CLICK, 0); // RS click → right-pad clicked (10.2)
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0); // deflected stick = touched pad (10.4)
assert_eq!((s.rpad_x, s.rpad_y), (3000, -4000)); // right stick rides the right pad
assert_eq!((s.rx, s.ry), (0, 0));
let mut r = [0u8; STEAM_REPORT_LEN];
serialize_sc_state(&mut r, &s, 0x0102_0304);
assert_eq!(&r[0..4], &[0x01, 0x00, 0x01, 0x3C]); // ID_CONTROLLER_STATE
assert_eq!(&r[4..8], &[0x04, 0x03, 0x02, 0x01]);
assert_eq!(r[8] & 0x80, 0x80); // A = 8.7
assert_eq!(r[9] & 0x80, 0x80); // left grip = 9.7
assert_eq!(r[10] & 0x01, 0x01); // right grip = 10.0
assert_eq!(r[10] & 0x04, 0x04); // right-pad clicked = 10.2
assert_eq!(r[10] & 0x40, 0x40); // joystick clicked = 10.6
assert_eq!(r[11], 255); // left trigger u8
assert_eq!(r[12], 0); // right trigger u8
assert_eq!(&r[16..18], &1000i16.to_le_bytes()); // joystick X (lpad untouched)
assert_eq!(&r[18..20], &(-2000i16).to_le_bytes());
assert_eq!(&r[20..22], &3000i16.to_le_bytes()); // right pad X
assert_eq!(&r[22..24], &(-4000i16).to_le_bytes());
// Left-pad multiplex: a TouchpadEx surface-1 contact shadows the joystick at 16..20
// and sets the 10.3 touched bit (+ the 10.1 click bit from the rich field).
let mut s = sc_from_gamepad(0, 1234, 0, 0, 0, 0, 0);
s.apply_rich(RichInput::TouchpadEx {
pad: 0,
surface: 1,
finger: 0,
touch: true,
click: true,
x: -5000,
y: 6000,
pressure: 0,
});
let mut r = [0u8; STEAM_REPORT_LEN];
serialize_sc_state(&mut r, &s, 0);
assert_eq!(r[10] & 0x08, 0x08); // left-pad touched = 10.3
assert_eq!(r[10] & 0x02, 0x02); // left-pad clicked = 10.1 (rich click merged)
assert_eq!(&r[16..18], &(-5000i16).to_le_bytes()); // lpad coords shadow the joystick
assert_eq!(&r[18..20], &(-6000i16).to_le_bytes()); // screen +down → raw +up (flip)
}
/// The serial reply carries the leading report-id byte the kernel strips, so the *stripped* /// 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…]`. /// view (`reply[1..]`) is what `steam_get_serial` validates: `[0xAE, len, 0x01, ascii…]`.
#[test] #[test]
@@ -0,0 +1,654 @@
//! Transport-independent Nintendo Switch Pro Controller contract — the report codec + canned
//! handshake replies the Linux UHID backend ([`super::switch_pro`]) drives `hid-nintendo` with.
//!
//! Everything here is pinned against the kernel driver source (drivers/hid/hid-nintendo.c —
//! the ONE consumer of these bytes; a virtual pad must answer its probe exactly or no input
//! devices appear):
//!
//! - **USB handshake**: 2-byte output reports `0x80 <cmd>` (handshake / baudrate / no-timeout),
//! each ACKed with an input report `0x81 <cmd>` (`joycon_send_usb` matches only those two
//! bytes).
//! - **Subcommands**: output report `0x01` (packet counter + 8 rumble bytes + subcommand id +
//! args), ACKed with input report `0x21` — a 12-byte input-state header, then ack byte /
//! echoed subcommand id / payload. The driver matches ONLY the echoed id (byte 14) and
//! requires ≥ 49 bytes; real hardware sends 64.
//! - **SPI flash reads** (subcommand `0x10`): the driver reads the user-calibration magics
//! (absent here → `0xFF 0xFF`, so it takes the factory path), the factory stick calibrations
//! (9-byte packed 12-bit triples — max/center/min order DIFFERS left vs right), and the
//! 24-byte factory IMU calibration. We serve blobs chosen so the math is clean: sticks
//! centered at [`STICK_CENTER`] ± [`STICK_RANGE`], IMU offsets 0 with the driver's default
//! scales (accel 16384, gyro 13371) so raw units pass through 1:1.
//! - **Input report `0x30`**: 3 button bytes (bit layout per `JC_BTN_*`), two packed 12-bit
//! stick triples, battery/connection, and 3 IMU sample frames (accel then gyro, i16 LE).
//! - **Rumble**: 4 encoded bytes per side in every `0x01`/`0x10` output; we decode the
//! amplitude through the driver's own `joycon_rumble_amplitudes` table (inverted) back to the
//! 0..=0xFFFF wire magnitudes it was scaled from (left = strong/low, right = weak/high).
//!
//! Wire-mapping subtleties (see the plan doc, gamepad-new-types §4):
//! - **Positional swap.** Wire `BTN_A` is the SOUTH button (GameStream convention); on a Switch
//! pad SOUTH is `B`. `from_gamepad` maps wire-south → the report's B bit (and X/Y likewise),
//! so the physical-position ↔ glyph relationship stays correct end-to-end.
//! - **Units.** Wire motion is DualSense-convention (20 LSB/°·s, 10000 LSB/g); the report wants
//! real-Pro-Controller raw units (≈14.247 LSB/°·s per `JC_IMU_GYRO_RES_PER_DPS`, 4096 LSB/g
//! per `JC_IMU_ACCEL_RES_PER_G`), which our calibration blobs make the driver consume 1:1.
use punktfunk_core::input::gamepad as gs;
pub const SWITCH_VENDOR: u32 = 0x057E; // Nintendo Co., Ltd
pub const SWITCH_PRODUCT: u32 = 0x2009; // Pro Controller
/// Nintendo Switch Pro Controller **USB** HID report descriptor (203 bytes) — a verbatim
/// real-device capture (usbhid-dump off a wired Pro Controller; three independent public
/// captures agree byte-for-byte: mzyy94's usbhid-dump, ToadKing's full USB capture, and
/// spacemeowx2's annotated dump). Declares exactly the report ids `hid-nintendo` exchanges
/// wired (inputs 0x30/0x21/0x81, outputs 0x01/0x10/0x80/0x82); the driver reads raw events,
/// so the descriptor only has to `hid_parse()` — but this is what real hardware presents.
/// NOT the Bluetooth descriptor (that one is ~170 bytes with a different report set).
#[rustfmt::skip]
pub const PROCON_RDESC: &[u8] = &[
0x05, 0x01, 0x15, 0x00, 0x09, 0x04, 0xA1, 0x01, 0x85, 0x30, 0x05, 0x01, 0x05, 0x09, 0x19, 0x01,
0x29, 0x0A, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0A, 0x55, 0x00, 0x65, 0x00, 0x81, 0x02,
0x05, 0x09, 0x19, 0x0B, 0x29, 0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x04, 0x81, 0x02,
0x75, 0x01, 0x95, 0x02, 0x81, 0x03, 0x0B, 0x01, 0x00, 0x01, 0x00, 0xA1, 0x00, 0x0B, 0x30, 0x00,
0x01, 0x00, 0x0B, 0x31, 0x00, 0x01, 0x00, 0x0B, 0x32, 0x00, 0x01, 0x00, 0x0B, 0x35, 0x00, 0x01,
0x00, 0x15, 0x00, 0x27, 0xFF, 0xFF, 0x00, 0x00, 0x75, 0x10, 0x95, 0x04, 0x81, 0x02, 0xC0, 0x0B,
0x39, 0x00, 0x01, 0x00, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75,
0x04, 0x95, 0x01, 0x81, 0x02, 0x05, 0x09, 0x19, 0x0F, 0x29, 0x12, 0x15, 0x00, 0x25, 0x01, 0x75,
0x01, 0x95, 0x04, 0x81, 0x02, 0x75, 0x08, 0x95, 0x34, 0x81, 0x03, 0x06, 0x00, 0xFF, 0x85, 0x21,
0x09, 0x01, 0x75, 0x08, 0x95, 0x3F, 0x81, 0x03, 0x85, 0x81, 0x09, 0x02, 0x75, 0x08, 0x95, 0x3F,
0x81, 0x03, 0x85, 0x01, 0x09, 0x03, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0x85, 0x10, 0x09, 0x04,
0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0x85, 0x80, 0x09, 0x05, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83,
0x85, 0x82, 0x09, 0x06, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0xC0,
];
/// Every input report we emit is the full USB size (the driver requires ≥ 49 for `0x21`).
pub const SWITCH_REPORT_LEN: usize = 64;
/// Stick raw center + full-deflection range of OUR virtual pad's calibration (12-bit axis).
/// The factory blobs below advertise exactly this, so the driver maps
/// `center ± range → ∓/± 32767` — one clean linear scale from the wire values.
pub const STICK_CENTER: u16 = 2048;
pub const STICK_RANGE: u16 = 1400;
/// `battery and connection info` byte (report byte 2): high 3 bits = level (4 = full),
/// BIT(4) = charging, BIT(0) = host powered — "full + charging + wired", so no low-battery
/// warnings ever.
pub const BAT_CON_FULL_WIRED: u8 = 0x91;
/// `vibrator_report` (report byte 12): must be non-zero or the driver stops pumping its rumble
/// queue (`joycon_ctlr_read_handler` gates on it). Real hardware sends 0x70-ish.
pub const VIBRATOR_READY: u8 = 0x70;
// Button bits of the 24-bit little-endian button field (report bytes 3..6), per the kernel's
// JC_BTN_* defines.
pub mod btn {
pub const Y: u32 = 1 << 0;
pub const X: u32 = 1 << 1;
pub const B: u32 = 1 << 2;
pub const A: u32 = 1 << 3;
pub const R: u32 = 1 << 6;
pub const ZR: u32 = 1 << 7;
pub const MINUS: u32 = 1 << 8;
pub const PLUS: u32 = 1 << 9;
pub const RSTICK: u32 = 1 << 10;
pub const LSTICK: u32 = 1 << 11;
pub const HOME: u32 = 1 << 12;
pub const CAPTURE: u32 = 1 << 13;
pub const DOWN: u32 = 1 << 16;
pub const UP: u32 = 1 << 17;
pub const RIGHT: u32 = 1 << 18;
pub const LEFT: u32 = 1 << 19;
pub const L: u32 = 1 << 22;
pub const ZL: u32 = 1 << 23;
}
/// Full Pro Controller state serialized into report `0x30` (and the `0x21` reply headers).
/// Sticks are the RAW 12-bit values ([`STICK_CENTER`]-centered); motion is raw IMU units.
#[derive(Clone, Copy)]
pub struct SwitchState {
/// 24-bit `JC_BTN_*` field.
pub buttons: u32,
pub lx: u16,
pub ly: u16,
pub rx: u16,
pub ry: u16,
/// Raw gyro (≈14.247 LSB/°·s) and accel (4096 LSB/g), driver axis order x/y/z.
pub gyro: [i16; 3],
pub accel: [i16; 3],
}
impl SwitchState {
/// Centered sticks, nothing pressed, flat at rest (1 g on +Z — a pad lying on the desk, so
/// SDL/games don't see a free-falling controller).
pub fn neutral() -> SwitchState {
SwitchState {
buttons: 0,
lx: STICK_CENTER,
ly: STICK_CENTER,
rx: STICK_CENTER,
ry: STICK_CENTER,
gyro: [0; 3],
accel: [0, 0, 4096],
}
}
/// Map a GameStream/XInput pad frame into Pro Controller state. Face buttons are mapped
/// **positionally** (wire A = south → Switch B, etc. — see the module doc); triggers are
/// digital on a Pro Controller, so any analog pull presses ZL/ZR. The wire paddles have no
/// Switch slot — fold them via [`super::steam_remap`] BEFORE calling this (like the
/// DualSense-family backends do).
pub fn from_gamepad(
buttons: u32,
lx: i16,
ly: i16,
rx: i16,
ry: i16,
lt: u8,
rt: u8,
) -> SwitchState {
let on = |bit: u32| buttons & bit != 0;
let mut b = 0u32;
// Positional: wire south/east/west/north → the Switch button at that position.
if on(gs::BTN_A) {
b |= btn::B; // south
}
if on(gs::BTN_B) {
b |= btn::A; // east
}
if on(gs::BTN_X) {
b |= btn::Y; // west
}
if on(gs::BTN_Y) {
b |= btn::X; // north
}
if on(gs::BTN_LB) {
b |= btn::L;
}
if on(gs::BTN_RB) {
b |= btn::R;
}
if lt > 0 {
b |= btn::ZL;
}
if rt > 0 {
b |= btn::ZR;
}
if on(gs::BTN_BACK) {
b |= btn::MINUS;
}
if on(gs::BTN_START) {
b |= btn::PLUS;
}
if on(gs::BTN_LS_CLICK) {
b |= btn::LSTICK;
}
if on(gs::BTN_RS_CLICK) {
b |= btn::RSTICK;
}
if on(gs::BTN_GUIDE) {
b |= btn::HOME;
}
if on(gs::BTN_MISC1) {
b |= btn::CAPTURE;
}
if on(gs::BTN_DPAD_UP) {
b |= btn::UP;
}
if on(gs::BTN_DPAD_DOWN) {
b |= btn::DOWN;
}
if on(gs::BTN_DPAD_LEFT) {
b |= btn::LEFT;
}
if on(gs::BTN_DPAD_RIGHT) {
b |= btn::RIGHT;
}
SwitchState {
buttons: b,
lx: stick_raw(lx),
ly: stick_raw(ly),
rx: stick_raw(rx),
ry: stick_raw(ry),
..SwitchState::neutral()
}
}
/// Apply a wire motion sample (DualSense-convention units) as raw IMU values. No axis flip:
/// both conventions are x-toward-triggers / z-up for a Pro Controller held like a DualSense,
/// and the driver applies no negation for the Pro (only the right Joy-Con negates).
pub fn apply_motion(&mut self, gyro: [i16; 3], accel: [i16; 3]) {
// gyro: wire 20 LSB/°·s → raw 14.247 LSB/°·s; accel: wire 10000 LSB/g → raw 4096 LSB/g.
self.gyro = gyro.map(|v| ((v as i32 * 14247) / 20000) as i16);
self.accel = accel.map(|v| ((v as i32 * 4096) / 10000) as i16);
}
}
/// Wire stick value (i16, +32767 = right/up) → raw 12-bit axis. The driver Y-negates BOTH the
/// wire's and evdev's conventions away: it computes `evdev_y = -scale(raw_y)`, and evdev's
/// gamepad convention is negative-up — so wire +y (up) maps to raw above-center, exactly like x.
pub fn stick_raw(v: i16) -> u16 {
let raw = STICK_CENTER as i32 + (v as i32 * STICK_RANGE as i32) / 32767;
raw.clamp(0, 0xFFF) as u16
}
/// Pack two 12-bit values into the 3-byte stick / calibration wire form
/// (`hid_field_extract` little-endian bitfield order).
pub fn pack12(a: u16, b: u16) -> [u8; 3] {
[
(a & 0xFF) as u8,
((a >> 8) & 0x0F) as u8 | ((b & 0x0F) << 4) as u8,
((b >> 4) & 0xFF) as u8,
]
}
/// Write the shared 13-byte input-state header (report id .. `vibrator_report`) that both the
/// `0x30` stream and every `0x21` subcommand reply carry.
fn write_header(r: &mut [u8; SWITCH_REPORT_LEN], id: u8, st: &SwitchState, timer: u8) {
r[0] = id;
r[1] = timer;
r[2] = BAT_CON_FULL_WIRED;
r[3] = (st.buttons & 0xFF) as u8;
r[4] = ((st.buttons >> 8) & 0xFF) as u8;
r[5] = ((st.buttons >> 16) & 0xFF) as u8;
r[6..9].copy_from_slice(&pack12(st.lx, st.ly));
r[9..12].copy_from_slice(&pack12(st.rx, st.ry));
r[12] = VIBRATOR_READY;
}
/// Serialize the full/standard input report `0x30`: state header + 3 IMU sample frames
/// (accel x/y/z then gyro x/y/z, i16 LE — `struct joycon_imu_data`). We repeat the current
/// sample across all three 5 ms sub-frames (we sample per report, not per sub-frame).
pub fn serialize_report_0x30(st: &SwitchState, timer: u8) -> [u8; SWITCH_REPORT_LEN] {
let mut r = [0u8; SWITCH_REPORT_LEN];
write_header(&mut r, 0x30, st, timer);
for frame in 0..3 {
let off = 13 + frame * 12;
for (i, v) in st.accel.iter().enumerate() {
r[off + i * 2..off + i * 2 + 2].copy_from_slice(&v.to_le_bytes());
}
for (i, v) in st.gyro.iter().enumerate() {
r[off + 6 + i * 2..off + 6 + i * 2 + 2].copy_from_slice(&v.to_le_bytes());
}
}
r
}
/// Build the `0x81 <cmd>` input report acknowledging a USB `0x80 <cmd>` command
/// (`joycon_send_usb` matches exactly those two bytes).
pub fn build_usb_ack(cmd: u8) -> [u8; SWITCH_REPORT_LEN] {
let mut r = [0u8; SWITCH_REPORT_LEN];
r[0] = 0x81;
r[1] = cmd;
r
}
/// Build a `0x21` subcommand reply: state header, then ack / echoed subcommand id / payload.
/// The driver matches on the echoed id only; the MSB-set ack byte mirrors real hardware
/// (`0x80` plain ack, `0x80 | data-type` when a payload follows).
pub fn build_subcmd_reply(
st: &SwitchState,
timer: u8,
ack: u8,
subcmd: u8,
payload: &[u8],
) -> [u8; SWITCH_REPORT_LEN] {
let mut r = [0u8; SWITCH_REPORT_LEN];
write_header(&mut r, 0x21, st, timer);
r[13] = ack;
r[14] = subcmd;
let n = payload.len().min(SWITCH_REPORT_LEN - 15);
r[15..15 + n].copy_from_slice(&payload[..n]);
r
}
/// The device-info payload (subcommand `0x02`): firmware 4.33, type `0x03` = **Pro Controller**
/// (`ctlr_type` — the value that selects the Pro button/stick/IMU paths), `0x02`, the 6-byte
/// MAC (parsed into `ctlr->mac_addr`, printed + used as the input devices' `uniq`), `0x01`,
/// and `0x01` = "colors in SPI" (not read by the driver).
pub fn device_info_payload(mac: &[u8; 6]) -> [u8; 12] {
let mut p = [0u8; 12];
p[0] = 0x04;
p[1] = 0x21;
p[2] = 0x03; // JOYCON_CTLR_TYPE_PRO
p[3] = 0x02;
p[4..10].copy_from_slice(mac);
p[10] = 0x01;
p[11] = 0x01;
p
}
/// A stable per-pad virtual MAC (Nintendo OUI + our index) — the driver requires one from
/// device info and keys the input devices' `uniq` off it.
pub fn switch_mac(index: u8) -> [u8; 6] {
[0x7C, 0xBB, 0x8A, 0xDF, 0x00, index]
}
/// The canned SPI-flash contents (subcommand `0x10`): reply payload = echoed LE address +
/// echoed length + the flash bytes. `None` for an unmapped range (the caller then replies with
/// zeroes — the driver falls back to defaults rather than aborting).
///
/// Served ranges:
/// - `0x8010`/`0x801B`/`0x8026` (user-cal magics, 2 B): NOT `0xB2 0xA1` → user cal absent, the
/// driver takes the factory path.
/// - `0x603D`/`0x6046` (factory stick cal, 9 B): [`STICK_CENTER`] ± [`STICK_RANGE`] on every
/// axis. **Byte order differs**: left = max-above ++ center ++ min-below; right = center ++
/// min-below ++ max-above (`joycon_read_stick_calibration`).
/// - `0x6020` (factory IMU cal, 24 B): offsets 0, accel scale 16384, gyro scale 13371 — the
/// driver's own defaults, making its per-sample math the identity (accel) / ×1000 (gyro).
pub fn spi_flash_read(addr: u32, len: u8) -> Option<Vec<u8>> {
let cal_pair = pack12(STICK_RANGE, STICK_RANGE);
let center_pair = pack12(STICK_CENTER, STICK_CENTER);
let data: Vec<u8> = match (addr, len) {
(0x8010 | 0x801B | 0x8026, 2) => vec![0xFF, 0xFF],
(0x603D, 9) => [cal_pair, center_pair, cal_pair].concat(),
(0x6046, 9) => [center_pair, cal_pair, cal_pair].concat(),
(0x6020, 24) => {
let mut v = Vec::with_capacity(24);
v.extend_from_slice(&[0u8; 6]); // accel offsets = 0
for _ in 0..3 {
v.extend_from_slice(&16384u16.to_le_bytes()); // accel scale (driver default)
}
v.extend_from_slice(&[0u8; 6]); // gyro offsets = 0
for _ in 0..3 {
v.extend_from_slice(&13371u16.to_le_bytes()); // gyro scale (driver default)
}
v
}
_ => return None,
};
let mut payload = Vec::with_capacity(5 + data.len());
payload.extend_from_slice(&addr.to_le_bytes());
payload.push(len);
payload.extend_from_slice(&data);
Some(payload)
}
/// One decoded host-bound output report from the driver.
pub enum SwitchOutput {
/// `0x80 <cmd>` USB command — answer with [`build_usb_ack`].
UsbCmd(u8),
/// `0x01` subcommand (with its rumble bytes) — answer with a `0x21` reply.
Subcmd {
id: u8,
/// Subcommand argument bytes (report bytes 11..).
args: Vec<u8>,
/// Decoded rumble `(low, high)` magnitudes.
rumble: (u16, u16),
},
/// `0x10` rumble-only report — no reply expected.
Rumble((u16, u16)),
}
/// Parse one output report from the driver. Returns `None` for anything unrecognized/short.
pub fn parse_output(data: &[u8]) -> Option<SwitchOutput> {
match *data.first()? {
0x80 => Some(SwitchOutput::UsbCmd(*data.get(1)?)),
0x01 if data.len() >= 11 => Some(SwitchOutput::Subcmd {
id: data[10],
args: data.get(11..).map(|s| s.to_vec()).unwrap_or_default(),
rumble: decode_rumble(&data[2..10]),
}),
0x10 if data.len() >= 10 => Some(SwitchOutput::Rumble(decode_rumble(&data[2..10]))),
_ => None,
}
}
/// The driver's `joycon_rumble_amplitudes` table, amplitude column only, indexed by
/// `amp_high / 2` (the encoded high-band amplitude byte is always even). Copied verbatim from
/// hid-nintendo.c; last entry = `joycon_max_rumble_amp` (1003).
#[rustfmt::skip]
const RUMBLE_AMPS: [u16; 101] = [
0, 10, 12, 14, 17, 20, 24, 28, 33, 40,
47, 56, 67, 80, 95, 112, 117, 123, 128, 134,
140, 146, 152, 159, 166, 173, 181, 189, 198, 206,
215, 225, 230, 235, 240, 245, 251, 256, 262, 268,
273, 279, 286, 292, 298, 305, 311, 318, 325, 332,
340, 347, 355, 362, 370, 378, 387, 395, 404, 413,
422, 431, 440, 450, 460, 470, 480, 491, 501, 512,
524, 535, 547, 559, 571, 584, 596, 609, 623, 636,
650, 665, 679, 694, 709, 725, 741, 757, 773, 790,
808, 825, 843, 862, 881, 900, 920, 940, 960, 981,
1003,
];
/// Invert the driver's per-side rumble encoding back to the 0..=0xFFFF magnitude it scaled
/// from: byte1's even bits carry the amplitude-table index × 2 (`data[1] = freq_high_lo +
/// amp.high`, where the freq contribution is only ever bit 0).
fn side_amplitude(side: &[u8]) -> u16 {
let idx = ((side[1] & 0xFE) / 2) as usize;
let amp = RUMBLE_AMPS[idx.min(RUMBLE_AMPS.len() - 1)] as u32;
// Driver: amp = magnitude * 1003 / 65535 — invert, saturating at full scale.
((amp * 65535) / 1003).min(65535) as u16
}
/// Decode the 8 rumble bytes (left side = strong → wire `low`, right side = weak → wire
/// `high`, per `joycon_play_effect`).
pub fn decode_rumble(bytes: &[u8]) -> (u16, u16) {
if bytes.len() < 8 {
return (0, 0);
}
(side_amplitude(&bytes[..4]), side_amplitude(&bytes[4..8]))
}
/// Decode a player-lights subcommand payload (`(flash << 4) | on`, one bit per LED) into the
/// wire `PlayerLeds` bits: a flashing LED counts as on.
pub fn player_leds_bits(arg: u8) -> u8 {
(arg & 0x0F) | (arg >> 4)
}
#[cfg(test)]
mod tests {
use super::*;
/// The positional swap, pinned: wire south/east/west/north land on the Switch B/A/Y/X bits
/// (the driver then maps them back to BTN_SOUTH/EAST/WEST/NORTH — position-correct
/// end-to-end), and the rest of the buttons land on their JC_BTN_* bits.
#[test]
fn positional_swap_and_button_bits() {
let st = SwitchState::from_gamepad(gs::BTN_A, 0, 0, 0, 0, 0, 0);
assert_eq!(st.buttons, btn::B);
let st = SwitchState::from_gamepad(gs::BTN_B, 0, 0, 0, 0, 0, 0);
assert_eq!(st.buttons, btn::A);
let st = SwitchState::from_gamepad(gs::BTN_X, 0, 0, 0, 0, 0, 0);
assert_eq!(st.buttons, btn::Y);
let st = SwitchState::from_gamepad(gs::BTN_Y, 0, 0, 0, 0, 0, 0);
assert_eq!(st.buttons, btn::X);
// Shoulders / sticks / meta / dpad / triggers-as-digital.
let st = SwitchState::from_gamepad(
gs::BTN_LB | gs::BTN_RB | gs::BTN_BACK | gs::BTN_START | gs::BTN_GUIDE | gs::BTN_MISC1,
0,
0,
0,
0,
255,
1,
);
assert_eq!(
st.buttons,
btn::L | btn::R | btn::MINUS | btn::PLUS | btn::HOME | btn::CAPTURE | btn::ZL | btn::ZR
);
let st = SwitchState::from_gamepad(gs::BTN_DPAD_UP | gs::BTN_DPAD_LEFT, 0, 0, 0, 0, 0, 0);
assert_eq!(st.buttons, btn::UP | btn::LEFT);
}
/// Sticks: wire full deflection → center ± range on the raw 12-bit axis, both axes the same
/// direction (the driver's own Y negation restores evdev's negative-up).
#[test]
fn stick_scaling() {
assert_eq!(stick_raw(0), STICK_CENTER);
assert_eq!(stick_raw(32767), STICK_CENTER + STICK_RANGE);
assert_eq!(stick_raw(-32767), STICK_CENTER - STICK_RANGE);
// Extreme min doesn't underflow past the 12-bit range.
assert!(stick_raw(i16::MIN) <= 0xFFF);
}
/// The 3-byte 12-bit packing matches `hid_field_extract`'s little-endian bitfield order:
/// value A at bit 0, value B at bit 12.
#[test]
fn pack12_layout() {
assert_eq!(pack12(0x578, 0x578), [0x78, 0x85, 0x57]); // 1400/1400 (the cal pair)
assert_eq!(pack12(0x800, 0x800), [0x00, 0x08, 0x80]); // 2048/2048 (the center pair)
// Extract back: a = b0 | (b1 & 0xF) << 8; b = (b1 >> 4) | b2 << 4.
let p = pack12(0xABC, 0x123);
let a = p[0] as u16 | ((p[1] as u16 & 0xF) << 8);
let b = ((p[1] as u16) >> 4) | ((p[2] as u16) << 4);
assert_eq!((a, b), (0xABC, 0x123));
}
/// Report 0x30 layout, pinned against `struct joycon_input_report` + `joycon_imu_data`:
/// header bytes, packed sticks, and the 3 × 12-byte IMU frames (accel then gyro, LE).
#[test]
fn report_0x30_layout() {
let mut st = SwitchState::neutral();
st.buttons = btn::B | btn::MINUS | btn::ZL;
st.gyro = [0x1122, -2, 3];
st.accel = [-1, 0x3344, 5];
let r = serialize_report_0x30(&st, 7);
assert_eq!(r[0], 0x30);
assert_eq!(r[1], 7);
assert_eq!(r[2], BAT_CON_FULL_WIRED);
assert_eq!(r[3], 0x04); // B = bit 2
assert_eq!(r[4], 0x01); // MINUS = bit 8
assert_eq!(r[5], 0x80); // ZL = bit 23
assert_eq!(&r[6..9], &pack12(STICK_CENTER, STICK_CENTER));
assert_eq!(&r[9..12], &pack12(STICK_CENTER, STICK_CENTER));
assert_eq!(r[12], VIBRATOR_READY);
// Frame 0 at byte 13: accel x/y/z then gyro x/y/z, i16 LE.
assert_eq!(&r[13..15], &(-1i16).to_le_bytes());
assert_eq!(&r[15..17], &0x3344u16.to_le_bytes());
assert_eq!(&r[19..21], &0x1122u16.to_le_bytes());
// Frames repeat identically at +12 and +24.
assert_eq!(&r[13..25], &r[25..37]);
assert_eq!(&r[13..25], &r[37..49]);
}
/// Subcommand replies: ≥ 49 bytes (we send 64), ack at byte 13, echoed id at byte 14 (the
/// ONLY byte the driver's matcher checks), payload from byte 15.
#[test]
fn subcmd_reply_layout() {
let st = SwitchState::neutral();
let r = build_subcmd_reply(&st, 3, 0x90, 0x10, &[0xAA, 0xBB]);
assert_eq!(r.len(), SWITCH_REPORT_LEN);
assert_eq!(r[0], 0x21);
assert_eq!(r[13], 0x90);
assert_eq!(r[14], 0x10);
assert_eq!(&r[15..17], &[0xAA, 0xBB]);
// USB ack: exactly the two bytes joycon_send_usb matches.
let a = build_usb_ack(0x02);
assert_eq!((a[0], a[1]), (0x81, 0x02));
}
/// SPI blobs: magics read as ABSENT (≠ B2 A1); the stick blobs put center strictly between
/// min and max on both axes in the driver's per-side byte order; the reply echoes addr+len.
#[test]
fn spi_blobs_valid() {
for addr in [0x8010u32, 0x801B, 0x8026] {
let p = spi_flash_read(addr, 2).unwrap();
assert_eq!(&p[..4], &addr.to_le_bytes());
assert_eq!(p[4], 2);
assert!(!(p[5] == 0xB2 && p[6] == 0xA1));
}
let unpack = |b: &[u8]| -> (u16, u16) {
let a = b[0] as u16 | ((b[1] as u16 & 0xF) << 8);
let y = ((b[1] as u16) >> 4) | ((b[2] as u16) << 4);
(a, y)
};
// Left: max-above ++ center ++ min-below.
let l = spi_flash_read(0x603D, 9).unwrap();
let (data, hdr) = (&l[5..], &l[..5]);
assert_eq!(hdr, &[0x3D, 0x60, 0, 0, 9]);
let (max_above, _) = unpack(&data[0..3]);
let (center, _) = unpack(&data[3..6]);
let (min_below, _) = unpack(&data[6..9]);
assert_eq!(center, STICK_CENTER);
assert!(center - min_below < center && center < center + max_above);
// Right: center ++ min-below ++ max-above.
let r = spi_flash_read(0x6046, 9).unwrap();
let (rc, _) = unpack(&r[5..8]);
assert_eq!(rc, STICK_CENTER);
// IMU: offsets 0, driver-default scales — the identity calibration.
let imu = spi_flash_read(0x6020, 24).unwrap();
let d = &imu[5..];
assert_eq!(&d[0..6], &[0; 6]);
assert_eq!(&d[6..8], &16384u16.to_le_bytes());
assert_eq!(&d[12..18], &[0; 6]);
assert_eq!(&d[18..20], &13371u16.to_le_bytes());
// Unmapped range → None.
assert!(spi_flash_read(0x6050, 12).is_none());
}
/// Motion unit conversion: wire (20 LSB/°·s, 10000 LSB/g) → raw (14.247 LSB/°·s, 4096 LSB/g).
#[test]
fn motion_units() {
let mut st = SwitchState::neutral();
// 100 °/s = wire 2000 → raw ≈ 1424; 1 g = wire 10000 → raw 4096.
st.apply_motion([2000, 0, -2000], [10000, -10000, 0]);
assert_eq!(st.gyro, [1424, 0, -1424]);
assert_eq!(st.accel, [4096, -4096, 0]);
}
/// Rumble decode inverts the driver's encoder: a neutral packet decodes to silence; the
/// max-amplitude packet decodes to full scale; left = low/strong, right = high/weak.
#[test]
fn rumble_decode() {
// Neutral per the driver's tables: freq defaults + amp 0.
let neutral = [0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40];
assert_eq!(decode_rumble(&neutral), (0, 0));
// Max amp (0xC8 → index 100 → 1003 → 65535) on the LEFT only → (low=full, high=0).
let left_max = [0x00, 0xC8, 0x40, 0x72, 0x00, 0x01, 0x40, 0x40];
assert_eq!(decode_rumble(&left_max), (65535, 0));
// Mid-table on the right: amp_high 0x20 → index 16 → 117 → 117*65535/1003 = 7644.
let right_mid = [0x00, 0x01, 0x40, 0x40, 0x00, 0x20, 0x48, 0x40];
assert_eq!(decode_rumble(&right_mid), (0, 7644));
// The freq bit riding data[1] bit0 must not disturb the amplitude index.
let with_freq_bit = [0x00, 0x21, 0x48, 0x40, 0x00, 0x01, 0x40, 0x40];
assert_eq!(decode_rumble(&with_freq_bit).0, 7644);
// Short slice → silence, not a panic.
assert_eq!(decode_rumble(&[0x10; 4]), (0, 0));
}
/// Output-report parse: the three shapes the driver sends.
#[test]
fn parse_output_shapes() {
assert!(matches!(
parse_output(&[0x80, 0x02]),
Some(SwitchOutput::UsbCmd(0x02))
));
let mut sub = vec![0x01, 0x05];
sub.extend_from_slice(&[0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40]);
sub.push(0x10); // subcmd id
sub.extend_from_slice(&[0x3D, 0x60, 0x00, 0x00, 0x09]); // SPI addr+len args
match parse_output(&sub) {
Some(SwitchOutput::Subcmd { id, args, rumble }) => {
assert_eq!(id, 0x10);
assert_eq!(&args[..5], &[0x3D, 0x60, 0x00, 0x00, 0x09]);
assert_eq!(rumble, (0, 0));
}
_ => panic!("expected subcmd"),
}
let mut rum = vec![0x10, 0x06];
rum.extend_from_slice(&[0x00, 0xC8, 0x40, 0x72, 0x00, 0x01, 0x40, 0x40]);
assert!(matches!(
parse_output(&rum),
Some(SwitchOutput::Rumble((65535, 0)))
));
assert!(parse_output(&[0x21]).is_none());
assert!(parse_output(&[]).is_none());
}
/// Player lights: solid + flashing nibbles both count as lit.
#[test]
fn player_lights() {
assert_eq!(player_leds_bits(0x01), 0b0001);
assert_eq!(player_leds_bits(0x10), 0b0001); // flashing LED 1
assert_eq!(player_leds_bits(0x23), 0b0011 | 0b0010);
}
/// Device info: type byte 0x03 (Pro Controller) at payload[2], MAC at [4..10].
#[test]
fn device_info_shape() {
let mac = switch_mac(3);
let p = device_info_payload(&mac);
assert_eq!(p[2], 0x03);
assert_eq!(&p[4..10], &mac);
assert_eq!(mac[5], 3);
}
}
@@ -0,0 +1,468 @@
//! The generic stateful virtual-pad manager ([`UhidManager`]) shared by the five backends that
//! keep a full per-pad report state (Linux UHID DualSense / DualShock 4 / Steam Deck, Windows UMDF
//! DualSense / DualShock 4): event routing, the frame merge, rich-input application, the silence
//! heartbeat, and the feedback pump with rumble + hidout dedup are written once here; a backend
//! supplies only its per-controller pieces via [`PadProto`]. The stateless backends (Linux uinput,
//! Windows XUSB) write frames straight through with no state vec / heartbeat / rich plane, so they
//! use [`PadSlots`] directly instead.
use crate::gamestream::gamepad::{GamepadEvent, GamepadFrame, MAX_PADS};
use crate::inject::dualsense_proto::HidoutDedup;
use crate::inject::pad_slots::PadSlots;
use anyhow::Result;
use punktfunk_core::quic::{HidOutput, RichInput};
use std::time::{Duration, Instant};
/// What one feedback pass extracted from a pad's driver/kernel channel. `rumble` rides the
/// universal 0xCA plane (deduped against the last-forwarded level); `hidout` carries the rich
/// 0xCD feedback events (lightbar / player LEDs / adaptive triggers), deduped via [`HidoutDedup`].
#[derive(Default)]
pub struct PadFeedback {
/// `(low, high)` motor levels (0..=0xFF00), if the pass saw a rumble report.
pub rumble: Option<(u16, u16)>,
pub hidout: Vec<HidOutput>,
}
/// The per-controller half of a stateful virtual-pad backend — everything [`UhidManager`] cannot
/// share because it differs per protocol: the transport open, the report-state model and its
/// GameStream/rich-input mappers, the state write, and the feedback poll.
///
/// The `&mut self` receivers let a backend carry configuration (the Steam-paddle remap policy, a
/// pad identity); most implementations are otherwise stateless.
pub trait PadProto {
/// The per-pad transport (a UHID fd, a UMDF shared-memory channel, the Deck transport enum).
type Pad;
/// The pad's full report state (`DsState`, `SteamState`) — `Copy` like both of those, so the
/// manager can hand a snapshot to [`write_state`](Self::write_state) without borrow gymnastics.
type State: Copy;
/// Backend tag in the shared lifecycle log lines, e.g. `"DualSense/Windows"`.
const LABEL: &'static str;
/// Device name in the create-failure line ("virtual `<DEVICE>` creation failed …").
const DEVICE: &'static str;
/// Suffix for the create-failure line — empty on Linux, the driver-install hint on Windows.
const CREATE_HINT: &'static str;
/// Open the virtual pad for wire index `idx`, logging its own success line (it knows the
/// transport detail worth printing); failures are logged by the manager's create gate.
fn open(&mut self, idx: u8) -> Result<Self::Pad>;
/// The all-neutral report state a fresh or unplugged pad (re)starts from.
fn neutral(&self) -> Self::State;
/// Fold one decoded button/stick frame into a new state, preserving from `prev` every field
/// that arrives on the rich plane instead (touch contacts / clicks, motion) — the G2 hook, in
/// one place per backend. Paddle remap policy is applied here too.
fn merge_frame(&self, prev: &Self::State, f: &GamepadFrame) -> Self::State;
/// Apply one rich client→host event (touchpad contact / motion sample) to the state.
fn apply_rich(&self, st: &mut Self::State, rich: RichInput);
/// Write the full state to the pad (best-effort; the next frame or heartbeat re-syncs).
fn write_state(&self, pad: &mut Self::Pad, st: &Self::State);
/// Poll the pad's driver/kernel channel: answer any pending handshake and return the feedback
/// it carried. `idx` is the wire pad index (the DualSense GET_REPORT replies need it).
fn service(&self, pad: &mut Self::Pad, idx: u8) -> PadFeedback;
/// Whether this pad needs a heartbeat write NOW regardless of the silence gap (the Steam
/// backend streams through its gamepad-mode-entry pulse).
fn force_heartbeat(&self, _pad: &Self::Pad) -> bool {
false
}
}
/// All virtual pads of one stateful backend, driven from decoded controller events — the shared
/// skeleton of the five UHID/UMDF managers. Method surface (`new` / `handle` / `apply_rich` /
/// `pump` / `heartbeat`) is exactly what the session input thread already drives, so each backend
/// re-exports itself as a `pub type … = UhidManager<…Proto>;` alias.
pub struct UhidManager<B: PadProto> {
backend: B,
slots: PadSlots<B::Pad>,
/// Each pad's current full report — buttons/sticks merged with persisted rich-plane fields.
state: Vec<B::State>,
/// Last rumble forwarded per pad, so a report that only changes rich feedback doesn't re-send it.
last_rumble: Vec<(u16, u16)>,
/// Last rich feedback forwarded per pad, so an output report that only changed the rumble
/// doesn't re-send unchanged lightbar/LED/trigger state.
hidout_dedup: Vec<HidoutDedup>,
/// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat).
last_write: Vec<Instant>,
}
impl<B: PadProto + Default> UhidManager<B> {
pub fn new() -> UhidManager<B> {
UhidManager::with_backend(B::default())
}
}
impl<B: PadProto + Default> Default for UhidManager<B> {
fn default() -> UhidManager<B> {
UhidManager::new()
}
}
impl<B: PadProto> UhidManager<B> {
pub fn with_backend(backend: B) -> UhidManager<B> {
let state = (0..MAX_PADS).map(|_| backend.neutral()).collect();
UhidManager {
backend,
slots: PadSlots::new(B::LABEL, B::DEVICE, B::CREATE_HINT),
state,
last_rumble: vec![(0, 0); MAX_PADS],
hidout_dedup: vec![HidoutDedup::default(); MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
}
}
/// Handle one decoded controller event (create/destroy by mask, then merge button/stick state).
pub fn handle(&mut self, ev: &GamepadEvent) {
match ev {
GamepadEvent::Arrival { index, kind, .. } => {
tracing::info!(index, kind, "controller arrival ({})", B::LABEL);
self.ensure(*index as usize);
}
GamepadEvent::State(f) => {
let idx = f.index as usize;
if idx >= MAX_PADS {
return;
}
// Unplugs: drop any allocated pad whose mask bit cleared, resetting its state.
let swept = self.slots.sweep(f.active_mask);
for i in 0..MAX_PADS {
if swept & (1 << i) != 0 {
self.reset_pad(i);
}
}
if f.active_mask & (1 << idx) == 0 {
return; // this event WAS the unplug
}
self.ensure(idx);
// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields
// (touch + motion arrive separately and must survive a button-only frame).
self.state[idx] = self.backend.merge_frame(&self.state[idx], f);
self.write(idx);
}
}
}
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad,
/// preserving its button/stick state. Rich events never create a pad (a controller must have
/// arrived first); they're dropped if the pad isn't present.
pub fn apply_rich(&mut self, rich: RichInput) {
let idx = match rich {
RichInput::Touchpad { pad, .. }
| RichInput::Motion { pad, .. }
| RichInput::TouchpadEx { pad, .. } => pad as usize,
};
if idx >= MAX_PADS || self.slots.get(idx).is_none() {
return;
}
self.backend.apply_rich(&mut self.state[idx], rich);
self.write(idx);
}
/// Re-emit each live pad's CURRENT report if it's been silent for `max_gap` (or the backend
/// forces a write). The UHID/UMDF drivers treat a multi-second input silence — a held-steady
/// stick produces no wire events — as an unplugged controller; re-sending the current state is
/// idempotent (a stale-but-correct frame, never a phantom input).
pub fn heartbeat(&mut self, max_gap: Duration) {
let now = Instant::now();
for i in 0..MAX_PADS {
let Some(pad) = self.slots.get(i) else {
continue;
};
if self.backend.force_heartbeat(pad)
|| now.duration_since(self.last_write[i]) >= max_gap
{
self.write(i);
}
}
}
/// Service every pad: answer any pending driver/kernel handshake and route a game's feedback
/// back out. `rumble` is invoked `(index, low, high)` only when the motor level *changes* (the
/// universal 0xCA plane); `hidout` is invoked per rich feedback event that isn't an exact
/// repeat of the last-forwarded value (the 0xCD plane). Call frequently — kernel/driver init
/// handshakes block until answered.
pub fn pump(
&mut self,
mut rumble: impl FnMut(u16, u16, u16),
mut hidout: impl FnMut(HidOutput),
) {
for i in 0..MAX_PADS {
let Some(pad) = self.slots.get_mut(i) else {
continue;
};
let fb = self.backend.service(pad, i as u8);
if let Some(r) = fb.rumble {
if self.last_rumble[i] != r {
self.last_rumble[i] = r;
rumble(i as u16, r.0, r.1);
}
}
for h in fb.hidout {
// Skip rich feedback that repeats the last-forwarded value (a 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);
}
}
}
}
/// Write the pad's current state (if it exists) and reset its heartbeat clock — on every write
/// (real input or heartbeat), so an actively-used pad emits no extra reports.
fn write(&mut self, idx: usize) {
let st = self.state[idx];
if let Some(pad) = self.slots.get_mut(idx) {
self.backend.write_state(pad, &st);
}
self.last_write[idx] = Instant::now();
}
/// Gate-checked create; a FRESH pad starts from neutral state + re-armed dedups.
fn ensure(&mut self, idx: usize) {
let backend = &mut self.backend;
if self.slots.ensure(idx, |i| backend.open(i)) {
self.reset_pad(idx);
}
}
/// Reset one pad's sibling state (on create and unplug) so the first frame/feedback after a
/// (re)connect starts from scratch and is always forwarded.
fn reset_pad(&mut self, idx: usize) {
self.state[idx] = self.backend.neutral();
self.last_rumble[idx] = (0, 0);
self.hidout_dedup[idx].clear();
self.last_write[idx] = Instant::now();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
/// Scripted mock: `open` fails while `fail_opens > 0`; `service` replays canned feedback;
/// `MockState` carries a marker for the frame-merge preserve check.
#[derive(Default)]
struct MockProto {
fail_opens: RefCell<u32>,
feedback: RefCell<Vec<PadFeedback>>,
force_hb: bool,
}
#[derive(Clone, Copy, Default, PartialEq, Debug)]
struct MockState {
buttons: u32,
/// Stands in for the rich-plane fields (touch/motion/clicks): set by `apply_rich`,
/// must survive `merge_frame`.
rich_marker: u16,
}
/// Per-pad transport stub recording every state write.
#[derive(Default)]
struct MockPad {
writes: RefCell<Vec<MockState>>,
}
impl PadProto for MockProto {
type Pad = MockPad;
type State = MockState;
const LABEL: &'static str = "Mock";
const DEVICE: &'static str = "mock pad";
const CREATE_HINT: &'static str = "";
fn open(&mut self, _idx: u8) -> Result<MockPad> {
let mut fails = self.fail_opens.borrow_mut();
if *fails > 0 {
*fails -= 1;
anyhow::bail!("scripted open failure");
}
Ok(MockPad::default())
}
fn neutral(&self) -> MockState {
MockState::default()
}
fn merge_frame(&self, prev: &MockState, f: &GamepadFrame) -> MockState {
MockState {
buttons: f.buttons,
rich_marker: prev.rich_marker, // the preserve-rich-fields contract
}
}
fn apply_rich(&self, st: &mut MockState, rich: RichInput) {
if let RichInput::Touchpad { x, .. } = rich {
st.rich_marker = x;
}
}
fn write_state(&self, pad: &mut MockPad, st: &MockState) {
pad.writes.borrow_mut().push(*st);
}
fn service(&self, _pad: &mut MockPad, _idx: u8) -> PadFeedback {
let mut fb = self.feedback.borrow_mut();
if fb.is_empty() {
PadFeedback::default()
} else {
fb.remove(0)
}
}
fn force_heartbeat(&self, _pad: &MockPad) -> bool {
self.force_hb
}
}
fn frame(idx: i16, mask: u16, buttons: u32) -> GamepadEvent {
GamepadEvent::State(GamepadFrame {
index: idx,
active_mask: mask,
buttons,
..Default::default()
})
}
fn touch(pad: u8, x: u16) -> RichInput {
RichInput::Touchpad {
pad,
finger: 0,
active: true,
x,
y: 0,
}
}
fn mgr() -> UhidManager<MockProto> {
UhidManager::new()
}
#[test]
fn arrival_eager_creates_the_pad() {
// G10 as a generic regression test: Arrival must build the device before the first frame.
let mut m = mgr();
m.handle(&GamepadEvent::Arrival {
index: 2,
kind: 1,
capabilities: 0,
});
assert!(m.slots.get(2).is_some());
}
#[test]
fn button_frame_preserves_rich_fields_and_writes_merged_state() {
// G2 as a generic regression test: rich-plane state must survive a button-only frame.
let mut m = mgr();
m.handle(&frame(0, 0b1, 0));
m.apply_rich(touch(0, 777));
m.handle(&frame(0, 0b1, 0xA));
let pad = m.slots.get(0).unwrap();
let writes = pad.writes.borrow();
let last = writes.last().unwrap();
assert_eq!(last.buttons, 0xA);
assert_eq!(last.rich_marker, 777); // preserved across the merge
}
#[test]
fn removal_frame_never_recreates_the_pad_it_swept() {
let mut m = mgr();
m.handle(&frame(1, 0b10, 0));
assert!(m.slots.get(1).is_some());
// Bit 1 cleared and the frame IS pad 1's removal — sweep, then early-return (no ensure).
m.handle(&frame(1, 0b00, 0));
assert!(m.slots.get(1).is_none());
}
#[test]
fn rich_event_for_an_absent_pad_is_dropped_and_never_creates() {
let mut m = mgr();
m.apply_rich(touch(3, 42));
assert!(m.slots.get(3).is_none());
// …and it left no state behind: a later create starts truly neutral.
m.handle(&frame(3, 0b1000, 0));
assert_eq!(m.state[3].rich_marker, 0);
}
#[test]
fn create_failure_backs_off_then_state_still_tracks() {
let mut m = mgr();
*m.backend.fail_opens.borrow_mut() = 1;
m.handle(&frame(0, 0b1, 0x1));
// Open failed: no pad, but the merged state is tracked (matching the old managers).
assert!(m.slots.get(0).is_none());
assert_eq!(m.state[0].buttons, 0x1);
// Next frame inside the backoff window: still no pad, no panic.
m.handle(&frame(0, 0b1, 0x3));
assert!(m.slots.get(0).is_none());
assert_eq!(m.state[0].buttons, 0x3);
}
#[test]
fn rumble_dedup_forwards_changes_only_and_rearms_on_recreate() {
let mut m = mgr();
m.handle(&frame(0, 0b1, 0));
let collect = |m: &mut UhidManager<MockProto>| {
let out = RefCell::new(Vec::new());
m.pump(|i, lo, hi| out.borrow_mut().push((i, lo, hi)), |_| {});
out.into_inner()
};
let rumble = |r| PadFeedback {
rumble: Some(r),
hidout: Vec::new(),
};
*m.backend.feedback.borrow_mut() = vec![rumble((100, 0)), rumble((100, 0)), rumble((7, 7))];
assert_eq!(collect(&mut m), vec![(0, 100, 0)]); // first value forwards
assert_eq!(collect(&mut m), vec![]); // exact repeat deduped
assert_eq!(collect(&mut m), vec![(0, 7, 7)]); // change forwards
// Unplug + recreate re-arms the dedup: the same level forwards again.
m.handle(&frame(0, 0b0, 0));
m.handle(&frame(0, 0b1, 0));
*m.backend.feedback.borrow_mut() = vec![rumble((7, 7))];
assert_eq!(collect(&mut m), vec![(0, 7, 7)]);
}
#[test]
fn hidout_dedup_drops_exact_repeats() {
let mut m = mgr();
m.handle(&frame(0, 0b1, 0));
let led = |r| HidOutput::Led {
pad: 0,
r,
g: 0,
b: 0,
};
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
rumble: None,
hidout: vec![led(10), led(10), led(20)],
}];
let out = RefCell::new(0u32);
m.pump(
|_, _, _| {},
|_| {
*out.borrow_mut() += 1;
},
);
assert_eq!(out.into_inner(), 2); // 10 forwarded once, 20 forwarded; the repeat dropped
}
#[test]
fn heartbeat_reemits_silent_pads_and_honors_force() {
let mut m = mgr();
m.handle(&frame(0, 0b1, 0x5));
let writes = |m: &UhidManager<MockProto>| m.slots.get(0).unwrap().writes.borrow().len();
let after_frame = writes(&m);
// A pad written just now is NOT re-emitted under a huge gap…
m.heartbeat(Duration::from_secs(3600));
assert_eq!(writes(&m), after_frame);
// …but a zero gap counts it as silent and re-emits the CURRENT state.
m.heartbeat(Duration::ZERO);
assert_eq!(writes(&m), after_frame + 1);
assert_eq!(
m.slots
.get(0)
.unwrap()
.writes
.borrow()
.last()
.unwrap()
.buttons,
0x5
);
// The backend's force flag overrides the gap entirely (the Steam mode-entry pulse).
m.backend.force_hb = true;
m.heartbeat(Duration::from_secs(3600));
assert_eq!(writes(&m), after_frame + 2);
}
}
@@ -0,0 +1,89 @@
//! Virtual Sony DualSense **Edge** on Windows via the UMDF minidriver — the Edge sibling of
//! [`super::dualsense_windows`]. Same transport ([`DsWinPad`]: a per-session `SwDeviceCreate`
//! devnode + the sealed shared-memory channel), same report codec ([`super::dualsense_proto`]);
//! the host stamps `device_type = 2` so the one UMDF driver serves the Edge descriptor /
//! `VID_054C&PID_0DF2` attributes, and the wire back-grip bits map onto the Edge's native
//! `buttons[2]` slots instead of the fold/drop policy — a client's Deck grips / Elite paddles
//! reach games as real buttons. Feedback is identical to the plain DualSense (rumble arrives with
//! the vibration-v2 flag, which [`parse_ds_output`](super::dualsense_proto::parse_ds_output)
//! already handles).
use super::dualsense_proto::{edge_paddle_bits, DsState, DS_TOUCH_H, DS_TOUCH_W};
use super::dualsense_windows::{DsWinPad, WinDsIdentity};
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::Result;
use punktfunk_core::quic::RichInput;
/// The Windows-Edge half of the shared stateful manager (see [`PadProto`]): the shared
/// [`DsWinPad`] transport under the Edge identity, with the Edge paddle mapping in `merge_frame`.
/// No remap config — every wire paddle has a native slot.
#[derive(Default)]
pub struct DsEdgeWinProto;
impl PadProto for DsEdgeWinProto {
type Pad = DsWinPad;
type State = DsState;
const LABEL: &'static str = "DualSense Edge/Windows";
const DEVICE: &'static str = "DualSense Edge";
const CREATE_HINT: &'static str =
" (install/repair: punktfunk-host.exe driver install --gamepad)";
fn open(&mut self, idx: u8) -> Result<DsWinPad> {
let p = DsWinPad::open(idx, &WinDsIdentity::dualsense_edge())?;
tracing::info!(
index = idx,
"virtual DualSense Edge created (Windows UMDF shm channel)"
);
Ok(p)
}
fn neutral(&self) -> DsState {
DsState::neutral()
}
/// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields — like the
/// plain DualSense, EXCEPT the wire paddles land on the Edge's own `buttons[2]` bits
/// (rebuilt from every button frame, so no extra persistence).
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
let mut s = DsState::from_gamepad(
f.buttons,
f.ls_x,
f.ls_y,
f.rs_x,
f.rs_y,
f.left_trigger,
f.right_trigger,
);
s.buttons[2] |= edge_paddle_bits(f.buttons);
s.touch = prev.touch;
s.gyro = prev.gyro;
s.accel = prev.accel;
s.touch_click = prev.touch_click;
s
}
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
/// split the one touchpad left/right, pad clicks ride touch_click.
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
}
fn write_state(&self, pad: &mut DsWinPad, st: &DsState) {
pad.write_state(st);
}
/// Poll the section for a game's feedback: motor rumble on the universal 0xCA plane, the rich
/// lightbar/player-LED/trigger events on the 0xCD plane.
fn service(&self, pad: &mut DsWinPad, idx: u8) -> PadFeedback {
let fb = pad.service(idx);
PadFeedback {
rumble: fb.rumble,
hidout: fb.hidout,
}
}
}
/// All virtual DualSense Edge pads of a session — the Windows analogue of
/// [`DualSenseEdgeManager`](crate::inject::dualsense::DualSenseEdgeManager), with the same method
/// surface (via the shared [`UhidManager`]) as the other Windows pad managers.
pub type DualSenseEdgeWindowsManager = UhidManager<DsEdgeWinProto>;
@@ -21,18 +21,19 @@ use super::dualsense_proto::{
parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H, parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H,
DS_TOUCH_W, 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::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use punktfunk_core::quic::{HidOutput, RichInput}; use punktfunk_core::quic::RichInput;
use std::ffi::c_void; use std::ffi::c_void;
use std::time::{Duration, Instant}; use std::sync::atomic::{fence, AtomicU32, Ordering};
use windows::core::{w, GUID, HRESULT, PCWSTR}; use std::time::Duration;
use windows::core::{w, GUID, PCWSTR};
use windows::Win32::Devices::Enumeration::Pnp::{ use windows::Win32::Devices::Enumeration::Pnp::{
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO, SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
}; };
use windows::Win32::Foundation::{CloseHandle, E_FAIL, HANDLE, WAIT_OBJECT_0}; use windows::Win32::Foundation::{CloseHandle, E_FAIL, WAIT_OBJECT_0};
use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject}; use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
/// Shared-section layout — the single source of truth is [`pf_driver_proto::gamepad::PadShm`] (offset /// 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 /// asserts pin every field; the `pf_dualsense` driver maps the same struct). Derive the size/offsets/magic
@@ -54,11 +55,14 @@ pub(super) const OFF_DRIVER_PROTO: usize =
pub(super) const OFF_PAD_INDEX: usize = pub(super) const OFF_PAD_INDEX: usize =
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, pad_index); core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, pad_index);
pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4; pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4;
pub(super) const DEVTYPE_DUALSENSE_EDGE: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSENSE_EDGE;
/// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_<index>` software devnode (the driver /// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_<index>` software devnode (the driver
/// loads on it and the HID DualSense appears to games) plus the sealed shared-memory channel. /// loads on it and the HID DualSense appears to games) plus the sealed shared-memory channel.
/// Dropping it removes the devnode (`SwDeviceClose`) and closes both sections. /// Dropping it removes the devnode (`SwDeviceClose`) and closes both sections.
struct DsWinPad { /// `pub`: the type appears as `type Pad` in the `PadProto` impl (a public trait), like the
/// Linux pads.
pub struct DsWinPad {
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop). /// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
/// `None` falls back to an out-of-band `pf_dualsense` devnode (installer/devgen). /// `None` falls back to an out-of-band `pf_dualsense` devnode (installer/devgen).
_sw: Option<super::gamepad_raii::SwDevice>, _sw: Option<super::gamepad_raii::SwDevice>,
@@ -71,50 +75,6 @@ struct DsWinPad {
last_out_seq: u32, 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 /// 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 /// [`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. /// (`VID_054C&PID_09CC`). The fields map onto the `SW_DEVICE_CREATE_INFO` identity discussed below.
@@ -269,20 +229,57 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
Ok((hsw, ctx.instance_id())) Ok((hsw, ctx.instance_id()))
} }
/// The identity a [`DsWinPad`] enumerates with — the plain DualSense or the Edge share the whole
/// transport (section layout, input report shape, output parse); only the `device_type` stamp and
/// the PnP identity differ. The DS4 differs in report codec too, so it keeps its own pad type.
pub(super) struct WinDsIdentity {
/// `device_type` stamped into the section (the driver picks its HID identity off it).
pub devtype: u8,
/// PnP instance-id prefix (`pf_pad` / `pf_edge`) — distinct namespaces per type.
pub instance_prefix: &'static str,
/// The INF-matched hardware id.
pub hwid: &'static str,
/// The USB VID&PID token for the synthesized bus identity.
pub usb_vid_pid: &'static str,
/// Device Manager description.
pub description: &'static str,
}
impl WinDsIdentity {
pub(super) const fn dualsense() -> WinDsIdentity {
WinDsIdentity {
devtype: 0,
instance_prefix: "pf_pad",
hwid: "pf_dualsense",
usb_vid_pid: "VID_054C&PID_0CE6",
description: "punktfunk Virtual DualSense",
}
}
pub(super) const fn dualsense_edge() -> WinDsIdentity {
WinDsIdentity {
devtype: DEVTYPE_DUALSENSE_EDGE,
instance_prefix: "pf_edge",
hwid: "pf_dualsenseedge",
usb_vid_pid: "VID_054C&PID_0DF2",
description: "punktfunk Virtual DualSense Edge",
}
}
}
impl DsWinPad { impl DsWinPad {
/// Create the sealed channel (unnamed DATA section + `Global\pfds-boot-<index>` mailbox), stamp /// Create the sealed channel (unnamed DATA section + `Global\pfds-boot-<index>` mailbox), stamp
/// the pad index + neutral report + the magic LAST, then spawn the `pf_pad_<index>` devnode (the /// the device type FIRST (so it's visible the moment magic is) + the pad index + a neutral
/// driver loads on it and receives the DATA handle over the bootstrap). The devnode lives for the /// report + the magic LAST, then spawn the devnode (the driver loads on it and receives the
/// pad's lifetime — dropping the pad removes it (`SwDeviceClose`). /// DATA handle over the bootstrap). The devnode lives for the pad's lifetime — dropping the pad
fn open(index: u8) -> Result<DsWinPad> { /// removes it (`SwDeviceClose`).
pub(super) fn open(index: u8, id: &WinDsIdentity) -> Result<DsWinPad> {
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index); let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?; let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
let base = channel.data_base(); let base = channel.data_base();
// Stamp the pad index (the driver validates it on attach) + the neutral input report, then // SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range.
// the magic LAST (the driver only accepts the section once magic is set). The device-type
// stays 0 (DualSense — the section arrives zeroed).
// SAFETY: base points at SHM_SIZE writable bytes; OFF_PAD_INDEX/OFF_INPUT are in range.
unsafe { unsafe {
*base.add(OFF_DEVTYPE) = id.devtype;
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32); std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], { std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], {
let mut r = [0u8; DS_INPUT_REPORT_LEN]; let mut r = [0u8; DS_INPUT_REPORT_LEN];
@@ -292,19 +289,19 @@ impl DsWinPad {
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
} }
// Spawn the per-session devnode via SwDeviceCreate; `SwDeviceClose` removes it on drop. On the // Spawn the per-session devnode via SwDeviceCreate; `SwDeviceClose` removes it on drop. On the
// rare failure we keep the section + data plane and fall back to an out-of-band `pf_dualsense` // rare failure we keep the section + data plane and fall back to an out-of-band devnode
// devnode (installer / dev-box devgen) — its persistent driver polls the same mailbox name. // (installer / dev-box devgen) — its persistent driver polls the same mailbox name.
let inst = format!("pf_pad_{index}"); let inst = format!("{}_{index}", id.instance_prefix);
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile { let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
instance: &inst, instance: &inst,
container_index: index, container_index: index,
hwid: "pf_dualsense", hwid: id.hwid,
usb_vid_pid: "VID_054C&PID_0CE6", usb_vid_pid: id.usb_vid_pid,
description: "punktfunk Virtual DualSense", description: id.description,
}) { }) {
Ok((h, id)) => (Some(h), id), Ok((h, i)) => (Some(h), i),
Err(e) => { Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; falling back to an out-of-band pf_dualsense devnode"); tracing::warn!(error = %format!("{e:#}"), hwid = id.hwid, "SwDeviceCreate failed; falling back to an out-of-band devnode");
(None, None) (None, None)
} }
}; };
@@ -316,8 +313,8 @@ impl DsWinPad {
_sw, _sw,
channel, channel,
attach: super::gamepad_raii::DriverAttach::new( attach: super::gamepad_raii::DriverAttach::new(
"pf_dualsense", id.hwid,
"pf_dualsense.inf", "pf_dualsense.inf", // one driver package serves every PS identity
"C:\\Users\\Public\\pfds-driver.log", "C:\\Users\\Public\\pfds-driver.log",
boot_name, boot_name,
instance_id, instance_id,
@@ -329,18 +326,29 @@ impl DsWinPad {
} }
/// Serialize `st` into report `0x01` and publish it to the section's input slot. /// Serialize `st` into report `0x01` and publish it to the section's input slot.
fn write_state(&mut self, st: &DsState) { pub(super) fn write_state(&mut self, st: &DsState) {
self.seq = self.seq.wrapping_add(1); self.seq = self.seq.wrapping_add(1);
self.ts = self.ts.wrapping_add(1); self.ts = self.ts.wrapping_add(1);
let mut r = [0u8; DS_INPUT_REPORT_LEN]; let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, st, self.seq, self.ts); 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 { unsafe {
std::ptr::copy_nonoverlapping( std::ptr::copy_nonoverlapping(
r.as_ptr(), r.as_ptr(),
self.channel.data_base().add(OFF_INPUT), self.channel.data_base().add(OFF_INPUT),
r.len(), r.len(),
) );
fence(Ordering::Release);
}; };
} }
@@ -348,7 +356,7 @@ impl DsWinPad {
/// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything /// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything
/// new. Also ticks the sealed-channel delivery and feeds the driver-attach health watcher (the /// new. Also ticks the sealed-channel delivery and feeds the driver-attach health watcher (the
/// driver's ~125 Hz timer stamps `driver_proto` while it has the section mapped). /// driver's ~125 Hz timer stamps `driver_proto` while it has the section mapped).
fn service(&mut self, pad: u8) -> DsFeedback { pub(super) fn service(&mut self, pad: u8) -> DsFeedback {
self.channel.pump(); self.channel.pump();
let mut fb = DsFeedback::default(); let mut fb = DsFeedback::default();
// SAFETY: base points at SHM_SIZE bytes. // SAFETY: base points at SHM_SIZE bytes.
@@ -356,9 +364,14 @@ impl DsWinPad {
std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32) std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
}; };
self.attach.observe(proto); 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 { 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 { if seq != self.last_out_seq {
self.last_out_seq = seq; self.last_out_seq = seq;
@@ -377,61 +390,52 @@ impl DsWinPad {
} }
} }
/// All virtual DualSense pads of a session — the Windows analogue of /// The Windows-DualSense half of the shared stateful manager (see [`PadProto`]): the UMDF
/// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface so the session input /// sealed-channel open, the same [`DsState`] mappers as `linux/dualsense.rs`, and the section
/// thread drives either backend identically. /// feedback poll. Lifecycle (slot table, unplug sweep, heartbeat, dedup) lives in [`UhidManager`].
pub struct DualSenseWindowsManager { pub struct DsWinProto {
pads: Vec<Option<DsWinPad>>, /// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button
state: Vec<DsState>, /// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. Parity with `linux/dualsense.rs`.
last_rumble: Vec<(u16, u16)>, remap: crate::inject::steam_remap::RemapConfig,
last_write: Vec<Instant>,
broken: bool,
} }
impl Default for DualSenseWindowsManager { impl Default for DsWinProto {
fn default() -> DualSenseWindowsManager { fn default() -> DsWinProto {
DualSenseWindowsManager::new() DsWinProto {
remap: crate::inject::steam_remap::RemapConfig::from_env(),
}
} }
} }
impl DualSenseWindowsManager { impl PadProto for DsWinProto {
pub fn new() -> DualSenseWindowsManager { type Pad = DsWinPad;
DualSenseWindowsManager { type State = DsState;
pads: (0..MAX_PADS).map(|_| None).collect(), const LABEL: &'static str = "DualSense/Windows";
state: vec![DsState::neutral(); MAX_PADS], const DEVICE: &'static str = "DualSense";
last_rumble: vec![(0, 0); MAX_PADS], const CREATE_HINT: &'static str =
last_write: vec![Instant::now(); MAX_PADS], " (install/repair: punktfunk-host.exe driver install --gamepad)";
broken: false,
} fn open(&mut self, idx: u8) -> Result<DsWinPad> {
let p = DsWinPad::open(idx, &WinDsIdentity::dualsense())?;
tracing::info!(
index = idx,
"virtual DualSense created (Windows UMDF shm channel)"
);
Ok(p)
} }
/// Handle one decoded controller event (create/destroy by mask, then merge button/stick state). fn neutral(&self) -> DsState {
pub fn handle(&mut self, ev: &GamepadEvent) { DsState::neutral()
match ev {
GamepadEvent::Arrival { index, kind, .. } => {
tracing::info!(index, kind, "controller arrival (DualSense/Windows)");
self.ensure(*index as usize);
} }
GamepadEvent::State(f) => {
let idx = f.index as usize; /// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (rich-
if idx >= MAX_PADS { /// plane fields that must survive a button-only frame) — exactly as `linux/dualsense.rs` does.
return; fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
} // Steam back grips have no DualSense slot — fold them onto standard buttons per the
for (i, slot) in self.pads.iter_mut().enumerate() { // configured policy (default drop) so they aren't silently lost.
if slot.is_some() && f.active_mask & (1 << i) == 0 { let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
tracing::info!(index = i, "controller unplugged (DualSense/Windows)");
*slot = None;
self.state[i] = DsState::neutral();
self.last_rumble[i] = (0, 0);
}
}
if f.active_mask & (1 << idx) == 0 {
return;
}
self.ensure(idx);
let prev = self.state[idx];
let mut s = DsState::from_gamepad( let mut s = DsState::from_gamepad(
f.buttons, buttons,
f.ls_x, f.ls_x,
f.ls_y, f.ls_y,
f.rs_x, f.rs_x,
@@ -443,92 +447,99 @@ impl DualSenseWindowsManager {
s.gyro = prev.gyro; s.gyro = prev.gyro;
s.accel = prev.accel; s.accel = prev.accel;
s.touch_click = prev.touch_click; s.touch_click = prev.touch_click;
self.state[idx] = s; s
self.write(idx);
}
}
} }
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad. /// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
pub fn apply_rich(&mut self, rich: RichInput) { /// split the one touchpad left/right, pad clicks ride touch_click.
let idx = match rich { fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
RichInput::Touchpad { pad, .. } st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
| RichInput::Motion { pad, .. }
| RichInput::TouchpadEx { pad, .. } => pad as usize,
};
if idx >= MAX_PADS || self.pads[idx].is_none() {
return;
}
// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam
// dual pads split the one touchpad left/right, pad clicks ride touch_click.
self.state[idx].apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
self.write(idx);
} }
fn write(&mut self, idx: usize) { fn write_state(&self, pad: &mut DsWinPad, st: &DsState) {
let st = self.state[idx]; pad.write_state(st);
if let Some(pad) = self.pads[idx].as_mut() {
pad.write_state(&st);
}
self.last_write[idx] = Instant::now();
} }
/// Re-emit each live pad's current report if it's been silent for `max_gap` (the driver's timer /// Poll the section for a game's feedback: motor rumble on the universal 0xCA plane, the rich
/// streams whatever's in the section, so this just keeps the section fresh / future-proofs parity /// lightbar/player-LED/trigger events on the 0xCD plane.
/// with the UHID backend's heartbeat). fn service(&self, pad: &mut DsWinPad, idx: u8) -> PadFeedback {
pub fn heartbeat(&mut self, max_gap: Duration) { let fb = pad.service(idx);
let now = Instant::now(); PadFeedback {
for i in 0..self.pads.len() { rumble: fb.rumble,
if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap { hidout: fb.hidout,
self.write(i);
}
}
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
return;
}
match DsWinPad::open(idx as u8) {
Ok(p) => {
tracing::info!(
index = idx,
"virtual DualSense created (Windows UMDF shm channel)"
);
self.pads[idx] = Some(p);
self.state[idx] = DsState::neutral();
self.last_rumble[idx] = (0, 0);
self.last_write[idx] = Instant::now();
}
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;
}
}
}
/// Service every pad: poll the section for a game's feedback. `rumble` fires `(index, low, high)`
/// only on change (universal 0xCA plane); `hidout` fires for each rich DualSense feedback event
/// (lightbar / player LEDs / adaptive triggers — 0xCD plane).
pub fn pump(
&mut self,
mut rumble: impl FnMut(u16, u16, u16),
mut hidout: impl FnMut(HidOutput),
) {
for i in 0..self.pads.len() {
let Some(pad) = self.pads[i].as_mut() else {
continue;
};
let fb = pad.service(i as u8);
if let Some(r) = fb.rumble {
if self.last_rumble[i] != r {
self.last_rumble[i] = r;
rumble(i as u16, r.0, r.1);
}
}
for h in fb.hidout {
hidout(h);
}
} }
} }
} }
/// **N4 spike** (gamepad-new-types §6, timeboxed): create a software-devnode HID **Steam Deck**
/// (`device_type = 3`, `VID_28DE&PID_1205`) and hold it for `secs`, streaming the neutral Deck
/// frame, so the go/no-go question — does Steam Input on Windows promote a software-devnode HID
/// Deck, or does it require a real USB bus identity (the documented GameInput instance-path
/// gap)? — can be answered by watching Steam's `logs/controller.txt` / controller settings
/// while this holds. Never used by a session; wired to the `deck-windows-spike` subcommand.
pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
let mut channel = PadChannel::create(boot_name, SHM_SIZE)?;
let base = channel.data_base();
// Neutral Deck input frame: [0x01, 0x00, ID_CONTROLLER_DECK_STATE=0x09, 0x3C], all released.
let mut neutral = [0u8; 64];
(neutral[0], neutral[2], neutral[3]) = (0x01, 0x09, 0x3C);
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range. Device-type
// FIRST, magic LAST — the same publish order the session pads use.
unsafe {
*base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK_SPIKE;
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; 64], neutral);
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
}
let inst = format!("pf_deckspike_{index}");
let (hsw, _) = create_swdevice(&SwDeviceProfile {
instance: &inst,
container_index: index,
hwid: "pf_steamdeck",
usb_vid_pid: "VID_28DE&PID_1205",
description: "punktfunk Virtual Steam Deck (spike)",
})?;
let _sw = super::gamepad_raii::SwDevice::new(hsw);
channel.deliver_eager(std::time::Duration::from_millis(1500));
println!(
"virtual Steam Deck devnode up (28DE:1205, device_type 3) — holding {secs}s.\n\
Observe: Get-PnpDevice -PresentOnly | findstr 1205; Steam logs\\controller.txt for a\n\
detect/promote line; Steam Settings > Controller for a 'Steam Deck' entry.\n\
GO = Steam lists/promotes it; NO-GO = it never appears (the Linux `Interface: -1` gap\n\
applies verbatim document and keep the SteamDeck->DualSense Windows fold)."
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(secs);
let mut last_out_seq = 0u32;
while std::time::Instant::now() < deadline {
channel.pump();
// Log any feature/output traffic Steam sends — each one is spike evidence.
// SAFETY: base points at SHM_SIZE bytes; OFF_OUT_SEQ is in range.
let seq = unsafe {
std::ptr::read_unaligned(channel.data_base().add(OFF_OUT_SEQ) as *const u32)
};
if seq != last_out_seq {
last_out_seq = seq;
let mut out = [0u8; 16];
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
unsafe {
std::ptr::copy_nonoverlapping(
channel.data_base().add(OFF_OUTPUT),
out.as_mut_ptr(),
16,
)
};
println!(" output report from a client (Steam?): {out:02x?}");
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
println!("deck-windows-spike: done (devnode removed on exit)");
Ok(())
}
/// All virtual DualSense pads of a session — the Windows analogue of
/// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface (via the shared
/// [`UhidManager`]) so the session input thread drives either backend identically. The heartbeat
/// keeps the section fresh (the driver's timer streams whatever's in it) — parity with the UHID
/// backend's silence heartbeat.
pub type DualSenseWindowsManager = UhidManager<DsWinProto>;
@@ -16,14 +16,16 @@ use super::dualshock4_proto::{
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W, parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W,
}; };
use super::gamepad_raii::PadChannel; use super::gamepad_raii::PadChannel;
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::Result; use anyhow::Result;
use punktfunk_core::quic::{HidOutput, RichInput}; use punktfunk_core::quic::{HidOutput, RichInput};
use std::time::{Duration, Instant}; use std::time::Duration;
/// A single virtual DualShock 4: the `SwDeviceCreate`'d `pf_ds4_<index>` devnode plus the sealed /// A single virtual DualShock 4: the `SwDeviceCreate`'d `pf_ds4_<index>` devnode plus the sealed
/// shared-memory channel. Dropping it removes the devnode and closes both sections. /// shared-memory channel. Dropping it removes the devnode and closes both sections.
struct Ds4WinPad { /// `pub`: the type appears as `type Pad` in the `PadProto` impl (a public trait), like the
/// Linux pads.
pub struct Ds4WinPad {
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop). /// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
_sw: Option<super::gamepad_raii::SwDevice>, _sw: Option<super::gamepad_raii::SwDevice>,
/// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery. /// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery.
@@ -140,64 +142,55 @@ impl Ds4WinPad {
} }
} }
/// All virtual DualShock 4 pads of a session — the Windows analogue of /// The Windows-DualShock-4 half of the shared stateful manager (see [`PadProto`]): the UMDF
/// [`DualShock4Manager`](super::dualshock4::DualShock4Manager), with the same method surface as the /// sealed-channel open (device-type 1), the same [`DsState`] mappers as `linux/dualshock4.rs`, and
/// Windows DualSense manager so the session input thread drives either backend identically. /// the section feedback poll. Lifecycle (slot table, unplug sweep, heartbeat, dedup) lives in
pub struct DualShock4WindowsManager { /// [`UhidManager`]; the lightbar dedup that used to be a bespoke `last_led` vec now rides the
pads: Vec<Option<Ds4WinPad>>, /// shared `HidoutDedup` (identical semantics — `Led` is compared against the last-forwarded value
state: Vec<DsState>, /// and re-armed on create/unplug).
last_rumble: Vec<(u16, u16)>, pub struct Ds4WinProto {
last_led: Vec<Option<(u8, u8, u8)>>, /// Fallback policy for the Steam back grips a client may send (the DS4 has no back-button HID
last_write: Vec<Instant>, /// slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. Parity with `linux/dualshock4.rs`.
broken: bool, remap: crate::inject::steam_remap::RemapConfig,
} }
impl Default for DualShock4WindowsManager { impl Default for Ds4WinProto {
fn default() -> DualShock4WindowsManager { fn default() -> Ds4WinProto {
DualShock4WindowsManager::new() Ds4WinProto {
remap: crate::inject::steam_remap::RemapConfig::from_env(),
}
} }
} }
impl DualShock4WindowsManager { impl PadProto for Ds4WinProto {
pub fn new() -> DualShock4WindowsManager { type Pad = Ds4WinPad;
DualShock4WindowsManager { type State = DsState;
pads: (0..MAX_PADS).map(|_| None).collect(), const LABEL: &'static str = "DualShock 4/Windows";
state: vec![DsState::neutral(); MAX_PADS], const DEVICE: &'static str = "DualShock 4";
last_rumble: vec![(0, 0); MAX_PADS], const CREATE_HINT: &'static str =
last_led: vec![None; MAX_PADS], " (install/repair: punktfunk-host.exe driver install --gamepad)";
last_write: vec![Instant::now(); MAX_PADS],
broken: false, fn open(&mut self, idx: u8) -> Result<Ds4WinPad> {
} let p = Ds4WinPad::open(idx)?;
tracing::info!(
index = idx,
"virtual DualShock 4 created (Windows UMDF shm channel)"
);
Ok(p)
} }
/// Handle one decoded controller event (create/destroy by mask, then merge button/stick state). fn neutral(&self) -> DsState {
pub fn handle(&mut self, ev: &GamepadEvent) { DsState::neutral()
match ev {
GamepadEvent::Arrival { index, kind, .. } => {
tracing::info!(index, kind, "controller arrival (DualShock 4/Windows)");
self.ensure(*index as usize);
} }
GamepadEvent::State(f) => {
let idx = f.index as usize; /// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (rich-
if idx >= MAX_PADS { /// plane fields that must survive a button-only frame) — exactly as `linux/dualshock4.rs` does.
return; fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
} // Steam back grips have no DS4 slot — fold them onto standard buttons per the configured
for (i, slot) in self.pads.iter_mut().enumerate() { // policy (default drop) so they aren't silently lost.
if slot.is_some() && f.active_mask & (1 << i) == 0 { let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
tracing::info!(index = i, "controller unplugged (DualShock 4/Windows)");
*slot = None;
self.state[i] = DsState::neutral();
self.last_rumble[i] = (0, 0);
self.last_led[i] = None;
}
}
if f.active_mask & (1 << idx) == 0 {
return;
}
self.ensure(idx);
let prev = self.state[idx];
let mut s = DsState::from_gamepad( let mut s = DsState::from_gamepad(
f.buttons, buttons,
f.ls_x, f.ls_x,
f.ls_y, f.ls_y,
f.rs_x, f.rs_x,
@@ -209,99 +202,36 @@ impl DualShock4WindowsManager {
s.gyro = prev.gyro; s.gyro = prev.gyro;
s.accel = prev.accel; s.accel = prev.accel;
s.touch_click = prev.touch_click; s.touch_click = prev.touch_click;
self.state[idx] = s; s
self.write(idx);
}
}
} }
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad. /// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
pub fn apply_rich(&mut self, rich: RichInput) { /// split the one touchpad left/right, pad clicks ride touch_click.
let idx = match rich { fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
RichInput::Touchpad { pad, .. } st.apply_rich(rich, DS4_TOUCH_W, DS4_TOUCH_H);
| RichInput::Motion { pad, .. }
| RichInput::TouchpadEx { pad, .. } => pad as usize,
};
if idx >= MAX_PADS || self.pads[idx].is_none() {
return;
}
// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam
// dual pads split the one touchpad left/right, pad clicks ride touch_click.
self.state[idx].apply_rich(rich, DS4_TOUCH_W, DS4_TOUCH_H);
self.write(idx);
} }
fn write(&mut self, idx: usize) { fn write_state(&self, pad: &mut Ds4WinPad, st: &DsState) {
let st = self.state[idx]; pad.write_state(st);
if let Some(pad) = self.pads[idx].as_mut() {
pad.write_state(&st);
}
self.last_write[idx] = Instant::now();
} }
/// Re-emit each live pad's current report if it's been silent for `max_gap` (parity with the /// Poll the section for a game's feedback: motor rumble on the universal 0xCA plane, the
/// other backends' heartbeat — keeps the section fresh). /// lightbar as a 0xCD `Led` event (a DS4 has no player LEDs / adaptive triggers).
pub fn heartbeat(&mut self, max_gap: Duration) { fn service(&self, pad: &mut Ds4WinPad, idx: u8) -> PadFeedback {
let now = Instant::now();
for i in 0..self.pads.len() {
if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap {
self.write(i);
}
}
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
return;
}
match Ds4WinPad::open(idx as u8) {
Ok(p) => {
tracing::info!(
index = idx,
"virtual DualShock 4 created (Windows UMDF shm channel)"
);
self.pads[idx] = Some(p);
self.state[idx] = DsState::neutral();
self.last_rumble[idx] = (0, 0);
self.last_led[idx] = None;
self.last_write[idx] = Instant::now();
}
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;
}
}
}
/// Service every pad: poll the section for a game's feedback. `rumble` fires `(index, low, high)`
/// only on change (universal 0xCA plane); `hidout` fires the lightbar (0xCD `Led`), deduped.
pub fn pump(
&mut self,
mut rumble: impl FnMut(u16, u16, u16),
mut hidout: impl FnMut(HidOutput),
) {
for i in 0..self.pads.len() {
let Some(pad) = self.pads[i].as_mut() else {
continue;
};
let fb = pad.service(); let fb = pad.service();
if let Some(r) = fb.rumble { PadFeedback {
if self.last_rumble[i] != r { rumble: fb.rumble,
self.last_rumble[i] = r; hidout: fb
rumble(i as u16, r.0, r.1); .led
} .map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b })
} .into_iter()
if let Some(rgb) = fb.led { .collect(),
if self.last_led[i] != Some(rgb) {
self.last_led[i] = Some(rgb);
hidout(HidOutput::Led {
pad: i as u8,
r: rgb.0,
g: rgb.1,
b: rgb.2,
});
}
}
} }
} }
} }
/// All virtual DualShock 4 pads of a session — the Windows analogue of
/// [`DualShock4Manager`](super::dualshock4::DualShock4Manager), with the same method surface (via
/// the shared [`UhidManager`]) as the Windows DualSense manager so the session input thread drives
/// either backend identically.
pub type DualShock4WindowsManager = UhidManager<Ds4WinProto>;
@@ -22,19 +22,20 @@
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use pf_driver_proto::gamepad::{PadBootstrap, BOOT_MAGIC, GAMEPAD_PROTO_VERSION}; 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::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::sync::atomic::{fence, AtomicU32, AtomicU64, Ordering}; use std::sync::atomic::{fence, AtomicU32, AtomicU64, Ordering};
use std::sync::OnceLock; use std::sync::OnceLock;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use windows::core::{w, HSTRING, PCWSTR}; use windows::core::{w, HRESULT, HSTRING, PCWSTR};
use windows::Win32::Devices::DeviceAndDriverInstallation::{ use windows::Win32::Devices::DeviceAndDriverInstallation::{
CM_Get_DevNode_Status, CM_Locate_DevNodeW, CM_DEVNODE_STATUS_FLAGS, CM_LOCATE_DEVNODE_NORMAL, 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, CM_PROB, CR_SUCCESS, DN_DRIVER_LOADED, DN_HAS_PROBLEM, DN_STARTED,
}; };
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE}; use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE};
use windows::Win32::Foundation::{ use windows::Win32::Foundation::{
DuplicateHandle, GetLastError, SetLastError, DUPLICATE_HANDLE_OPTIONS, ERROR_ALREADY_EXISTS, DuplicateHandle, GetLastError, LocalFree, SetLastError, DUPLICATE_HANDLE_OPTIONS,
HANDLE, INVALID_HANDLE_VALUE, WIN32_ERROR, ERROR_ALREADY_EXISTS, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, WIN32_ERROR,
}; };
use windows::Win32::Security::Authorization::{ use windows::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
@@ -45,7 +46,7 @@ use windows::Win32::System::Memory::{
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE, MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
}; };
use windows::Win32::System::Threading::{ 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 /// 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, view: MEMORY_MAPPED_VIEW_ADDRESS,
} }
/// Build a `SECURITY_ATTRIBUTES` from an SDDL literal (`psd` is OS-allocated and leaked — acceptable /// Owns an SDDL-derived `SECURITY_ATTRIBUTES` **and** the OS-allocated security descriptor its
/// for the handful of pad channels a host creates; it must outlive the returned `SECURITY_ATTRIBUTES`). /// `lpSecurityDescriptor` points at (`ConvertStringSecurityDescriptorToSecurityDescriptorW`
fn sddl_sa(sddl: PCWSTR) -> Result<SECURITY_ATTRIBUTES> { /// `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(); 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 { unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW( ConvertStringSecurityDescriptorToSecurityDescriptorW(
sddl, sddl,
@@ -78,10 +105,13 @@ fn sddl_sa(sddl: PCWSTR) -> Result<SECURITY_ATTRIBUTES> {
None, None,
)?; )?;
} }
Ok(SECURITY_ATTRIBUTES { Ok(SecAttr {
sa: SECURITY_ATTRIBUTES {
nLength: core::mem::size_of::<SECURITY_ATTRIBUTES>() as u32, nLength: core::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: psd.0, lpSecurityDescriptor: psd.0,
bInheritHandle: false.into(), bInheritHandle: false.into(),
},
psd,
}) })
} }
@@ -93,7 +123,9 @@ impl Shm {
/// validated on-glass — `design/idd-push-security.md`). /// validated on-glass — `design/idd-push-security.md`).
pub(super) fn create_unnamed(size: usize) -> Result<Shm> { pub(super) fn create_unnamed(size: usize) -> Result<Shm> {
let sa = sddl_sa(w!("D:P(A;;GA;;;SY)"))?; 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 /// 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 /// poll tick), then fail loudly rather than run the handshake through an attacker-owned (or
/// another host instance's) mailbox. /// another host instance's) mailbox.
pub(super) fn create_named(name: &HSTRING, size: usize) -> Result<Shm> { 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)"))?; let sa = sddl_sa(w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"))?;
for attempt in 0..5 { for attempt in 0..5 {
if attempt > 0 { if attempt > 0 {
@@ -113,7 +147,7 @@ impl Shm {
} }
// SAFETY: clearing the thread error slot so ERROR_ALREADY_EXISTS below is unambiguous. // SAFETY: clearing the thread error slot so ERROR_ALREADY_EXISTS below is unambiguous.
unsafe { SetLastError(WIN32_ERROR(0)) }; 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}"))?; .with_context(|| format!("create gamepad bootstrap mailbox {name}"))?;
// SAFETY: read immediately after the create; windows-rs only touches the error slot on // 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. // 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> { 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 // 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 { let map = unsafe {
CreateFileMappingW( CreateFileMappingW(
INVALID_HANDLE_VALUE, 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 /// A `SwDeviceCreate`'d software devnode; drop removes it via `SwDeviceClose`. Replaces the manual
/// `SwDeviceClose` each backend used to call in its `Drop`. /// `SwDeviceClose` each backend used to call in its `Drop`.
pub(super) struct SwDevice(HSWDEVICE); pub(super) struct SwDevice(HSWDEVICE);
@@ -12,17 +12,19 @@
//! parses the `SET_STATE` packet into the shared section, and [`GamepadManager::pump_rumble`] relays //! 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. //! 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::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use crate::inject::pad_slots::PadSlots;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use std::ffi::c_void; use std::ffi::c_void;
use std::sync::atomic::{fence, AtomicU32, Ordering};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use windows::core::{w, GUID, HRESULT, PCWSTR}; use windows::core::{w, GUID, PCWSTR};
use windows::Win32::Devices::Enumeration::Pnp::{ use windows::Win32::Devices::Enumeration::Pnp::{
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO, SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
}; };
use windows::Win32::Foundation::{CloseHandle, E_FAIL, HANDLE, WAIT_OBJECT_0}; use windows::Win32::Foundation::{CloseHandle, E_FAIL, WAIT_OBJECT_0};
use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject}; use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
// Shared-section layout — the single source of truth is `pf_driver_proto::gamepad::XusbShm` (offset // 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 // 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_DRIVER_PROTO: usize = core::mem::offset_of!(XusbShm, driver_proto);
const OFF_PAD_INDEX: usize = core::mem::offset_of!(XusbShm, pad_index); 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 /// 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, /// 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 /// 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(); let base = self.channel.data_base();
// SAFETY: `base` is the start of the mapped section (`SHM_SIZE` bytes, owned by `Shm`); every // 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 // `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 { unsafe {
std::ptr::write_unaligned(base.add(OFF_BUTTONS) as *mut u16, buttons); std::ptr::write_unaligned(base.add(OFF_BUTTONS) as *mut u16, buttons);
*base.add(OFF_LT) = lt; *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_LY) as *mut i16, ly);
std::ptr::write_unaligned(base.add(OFF_RX) as *mut i16, rx); 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_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. // SAFETY: base points at SHM_SIZE bytes.
let proto = unsafe { std::ptr::read_unaligned(base.add(OFF_DRIVER_PROTO) as *const u32) }; let proto = unsafe { std::ptr::read_unaligned(base.add(OFF_DRIVER_PROTO) as *const u32) };
self.attach.observe(proto); self.attach.observe(proto);
// SAFETY: base points at SHM_SIZE bytes. // SAFETY: base points at SHM_SIZE bytes; `OFF_RUMBLE_SEQ` (== 24) is 4-aligned off the
let seq = unsafe { std::ptr::read_unaligned(base.add(OFF_RUMBLE_SEQ) as *const u32) }; // 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 { if seq == self.last_rumble_seq {
return None; return None;
} }
@@ -285,13 +256,12 @@ impl XusbWinPad {
const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500); const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500);
pub struct GamepadManager { pub struct GamepadManager {
pads: Vec<Option<XusbWinPad>>, slots: PadSlots<XusbWinPad>,
last_rumble: Vec<(u8, u8)>, last_rumble: Vec<(u8, u8)>,
/// When the game last drove each pad (bumped `rumble_seq` via `SET_STATE`). A non-zero /// When the game last drove each pad (bumped `rumble_seq` via `SET_STATE`). A non-zero
/// `last_rumble` older than [`RUMBLE_IDLE_TIMEOUT`] against this is a stale residual — see the /// `last_rumble` older than [`RUMBLE_IDLE_TIMEOUT`] against this is a stale residual — see the
/// const's docs. /// const's docs.
last_active: Vec<Instant>, last_active: Vec<Instant>,
broken: bool,
} }
impl Default for GamepadManager { impl Default for GamepadManager {
@@ -303,54 +273,51 @@ impl Default for GamepadManager {
impl GamepadManager { impl GamepadManager {
pub fn new() -> GamepadManager { pub fn new() -> GamepadManager {
GamepadManager { GamepadManager {
pads: (0..MAX_PADS).map(|_| None).collect(), slots: PadSlots::new(
"Xbox 360/Windows",
"Xbox 360",
" (install/repair: punktfunk-host.exe driver install --gamepad)",
),
last_rumble: vec![(0, 0); MAX_PADS], last_rumble: vec![(0, 0); MAX_PADS],
last_active: (0..MAX_PADS).map(|_| Instant::now()).collect(), last_active: (0..MAX_PADS).map(|_| Instant::now()).collect(),
broken: false,
} }
} }
fn ensure(&mut self, idx: usize) { fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { if self.slots.ensure(idx, XusbWinPad::open) {
return;
}
match XusbWinPad::open(idx as u8) {
Ok(p) => {
tracing::info!( tracing::info!(
index = idx, index = idx,
"virtual Xbox 360 created (Windows XUSB companion)" "virtual Xbox 360 created (Windows XUSB companion)"
); );
self.pads[idx] = Some(p);
self.last_rumble[idx] = (0, 0); self.last_rumble[idx] = (0, 0);
} self.last_active[idx] = Instant::now();
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;
}
} }
} }
pub fn handle(&mut self, ev: &GamepadEvent) { pub fn handle(&mut self, ev: &GamepadEvent) {
let GamepadEvent::State(f) = ev else { match ev {
return; // Arrival metadata — the pad is created lazily on the first State GamepadEvent::Arrival { index, kind, .. } => {
}; tracing::info!(index, kind, "controller arrival (Xbox 360/Windows)");
let idx = f.index.max(0) as usize; self.ensure(*index as usize);
}
GamepadEvent::State(f) => {
let idx = f.index as usize;
if idx >= MAX_PADS { if idx >= MAX_PADS {
return; return;
} }
// Unplugs: drop any allocated pad whose mask bit cleared. // Unplugs: drop any allocated pad whose mask bit cleared.
for (i, slot) in self.pads.iter_mut().enumerate() { let swept = self.slots.sweep(f.active_mask);
if slot.is_some() && f.active_mask & (1 << i) == 0 { for i in 0..MAX_PADS {
tracing::info!(index = i, "controller unplugged (Xbox 360/Windows)"); if swept & (1 << i) != 0 {
*slot = None;
self.last_rumble[i] = (0, 0); self.last_rumble[i] = (0, 0);
self.last_active[i] = Instant::now();
} }
} }
if f.active_mask & (1 << idx) == 0 { if f.active_mask & (1 << idx) == 0 {
return; return;
} }
self.ensure(idx); self.ensure(idx);
if let Some(pad) = self.pads[idx].as_mut() { if let Some(pad) = self.slots.get_mut(idx) {
pad.write_state( pad.write_state(
(f.buttons & 0xffff) as u16, (f.buttons & 0xffff) as u16,
f.left_trigger, f.left_trigger,
@@ -362,15 +329,14 @@ impl GamepadManager {
); );
} }
} }
}
}
/// Relay any changed rumble level to the client. XUSB motors are 0..255; the wire carries /// Relay any changed rumble level to the client. XUSB motors are 0..255; the wire carries
/// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small` /// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small`
/// (high-frequency) → `high` — matching the other backends. /// (high-frequency) → `high` — matching the other backends.
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) { pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
for i in 0..self.pads.len() { for (i, pad) in self.slots.iter_mut() {
let Some(pad) = self.pads[i].as_mut() else {
continue;
};
if let Some((large, small)) = pad.service() { if let Some((large, small)) = pad.service() {
// The game drove the pad this poll (SET_STATE bumped the seq) — refresh the // The game drove the pad this poll (SET_STATE bumped the seq) — refresh the
// activity clock even when the level is unchanged, so a rumble it keeps asserting // activity clock even when the level is unchanged, so a rumble it keeps asserting
+118 -9
View File
@@ -255,19 +255,28 @@ fn real_main() -> Result<()> {
// Create a virtual DualSense via UHID and exercise it (validation, no streaming session): // Create a virtual DualSense via UHID and exercise it (validation, no streaming session):
// toggles the Cross button, sweeps the left stick, and prints any HID output the kernel // toggles the Cross button, sweeps the left stick, and prints any HID output the kernel
// sends back. Verify with `evtest` / `ls /dev/input/by-id/*Punktfunk*` / `wpctl status`. // sends back. Verify with `evtest` / `ls /dev/input/by-id/*Punktfunk*` / `wpctl status`.
// `--edge` creates a DualSense **Edge** (054C:0DF2) instead and additionally cycles the
// four back/Fn buttons (kernel ≥ 7.2 exposes them as BTN_TRIGGER_HAPPY1..4; on older
// kernels verify the bind + `hidraw` byte 10 instead).
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
Some("dualsense-test") => { Some("dualsense-test") => {
use inject::dualsense::DualSensePad; use inject::dualsense::{DsUhidIdentity, DualSensePad};
use inject::dualsense_proto::DsState; use inject::dualsense_proto::{edge_paddle_bits, DsState};
let secs: u64 = args let secs: u64 = args
.iter() .iter()
.skip_while(|a| *a != "--seconds") .skip_while(|a| *a != "--seconds")
.nth(1) .nth(1)
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.unwrap_or(20); .unwrap_or(20);
let edge = args.iter().any(|a| a == "--edge");
let (identity, label) = if edge {
(DsUhidIdentity::dualsense_edge(), "DualSense Edge")
} else {
(DsUhidIdentity::dualsense(), "DualSense")
};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
let mut pad = let mut pad = DualSensePad::open(0, &identity)
DualSensePad::open(0).context("create virtual DualSense via /dev/uhid")?; .with_context(|| format!("create virtual {label} via /dev/uhid"))?;
// Answer the kernel's init GET_REPORTs promptly so hid-playstation creates the input // Answer the kernel's init GET_REPORTs promptly so hid-playstation creates the input
// devices before we start streaming state. // devices before we start streaming state.
let init = Instant::now() + Duration::from_millis(800); let init = Instant::now() + Duration::from_millis(800);
@@ -276,7 +285,7 @@ fn real_main() -> Result<()> {
std::thread::sleep(Duration::from_millis(10)); std::thread::sleep(Duration::from_millis(10));
} }
println!( println!(
"virtual DualSense created — check `evtest`, `ls /dev/input/by-id/*Punktfunk*`, \ "virtual {label} created — check `evtest`, `ls /dev/input/by-id/*Punktfunk*`, \
`ls /sys/class/leds/`. Cycling Cross + sweeping LS for {secs}s." `ls /sys/class/leds/`. Cycling Cross + sweeping LS for {secs}s."
); );
let deadline = Instant::now() + Duration::from_secs(secs); let deadline = Instant::now() + Duration::from_secs(secs);
@@ -292,20 +301,106 @@ fn real_main() -> Result<()> {
if last_write.elapsed() >= Duration::from_millis(300) { if last_write.elapsed() >= Duration::from_millis(300) {
last_write = Instant::now(); last_write = Instant::now();
i += 1; i += 1;
let buttons = if i % 2 == 0 { let mut buttons = if i % 2 == 0 {
punktfunk_core::input::gamepad::BTN_A punktfunk_core::input::gamepad::BTN_A
} else { } else {
0 0
}; };
if edge {
// Cycle one paddle per beat (R4 → L4 → R5 → L5) so all four Edge slots
// are visible in evtest / hidraw.
buttons |= punktfunk_core::input::gamepad::BTN_PADDLE1 << (i % 4);
}
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
let st = DsState::from_gamepad(buttons, lx, 0, 0, 0, 0, 0); let mut st = DsState::from_gamepad(buttons, lx, 0, 0, 0, 0, 0);
pad.write_state(&st).context("write DualSense report")?; if edge {
st.buttons[2] |= edge_paddle_bits(buttons);
}
pad.write_state(&st).context("write report")?;
} }
std::thread::sleep(Duration::from_millis(15)); std::thread::sleep(Duration::from_millis(15));
} }
println!("dualsense-test: done"); println!("dualsense-test: done");
Ok(()) Ok(())
} }
// Create a virtual Switch Pro Controller via UHID and exercise it (validation, no
// streaming session): answers the full hid-nintendo probe conversation, then cycles the
// A/B buttons (positionally swapped) + sweeps the left stick, printing rumble / player-
// light feedback. Verify with `evtest` (hid-nintendo input devices), `dmesg | grep
// nintendo`, SDL identifying a "Nintendo Switch Pro Controller".
#[cfg(target_os = "linux")]
Some("switchpro-test") => {
use inject::switch_pro::SwitchProPad;
use inject::switch_proto::SwitchState;
let secs: u64 = args
.iter()
.skip_while(|a| *a != "--seconds")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(20);
use std::time::{Duration, Instant};
let mut pad = SwitchProPad::open(0)
.context("create virtual Switch Pro Controller via /dev/uhid")?;
// Answer the driver's probe conversation promptly — every step blocks hid-nintendo
// init until its reply lands; also stream neutral 0x30 reports like real hardware.
println!("virtual Switch Pro created — servicing the hid-nintendo probe…");
let init = Instant::now() + Duration::from_millis(2500);
let mut hb = Instant::now();
while Instant::now() < init {
let fb = pad.service(0);
for o in fb.hidout {
println!(" probe feedback: {o:?}");
}
if hb.elapsed() >= Duration::from_millis(15) {
hb = Instant::now();
let _ = pad.write_state(&SwitchState::neutral());
}
std::thread::sleep(Duration::from_millis(2));
}
println!("probe window over — cycling buttons + stick for {secs}s (check evtest)");
let deadline = Instant::now() + Duration::from_secs(secs);
let (mut i, mut last_write) = (0i32, Instant::now());
while Instant::now() < deadline {
let fb = pad.service(0);
if let Some((low, high)) = fb.rumble {
println!(" rumble from kernel/game: low={low} high={high}");
}
for o in fb.hidout {
println!(" hid output from kernel/game: {o:?}");
}
// ~15 ms cadence = the real controller's report rate (also keeps the driver's
// post-probe subcommand rate limiter fed).
if last_write.elapsed() >= Duration::from_millis(15) {
last_write = Instant::now();
i += 1;
let step = i / 20; // change the pressed button every ~300 ms
let buttons = if step % 2 == 0 {
punktfunk_core::input::gamepad::BTN_A
} else {
punktfunk_core::input::gamepad::BTN_B
};
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
let st = SwitchState::from_gamepad(buttons, lx, 0, 0, 0, 0, 0);
pad.write_state(&st).context("write Switch Pro report")?;
}
std::thread::sleep(Duration::from_millis(2));
}
println!("switchpro-test: done");
Ok(())
}
// Windows N4 SPIKE (gamepad-new-types §6): hold a software-devnode HID Steam Deck
// (28DE:1205 via device_type 3) and watch whether Steam Input promotes it. Needs the
// updated signed driver installed + Steam running. `--seconds N` (default 120).
#[cfg(target_os = "windows")]
Some("deck-windows-spike") => {
let secs: u64 = args
.iter()
.skip_while(|a| *a != "--seconds")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(120);
inject::dualsense_windows::deck_spike_hold(0, secs)
}
// Windows: create a virtual DualSense via the UMDF driver (SwDeviceCreate per-session devnode // Windows: create a virtual DualSense via the UMDF driver (SwDeviceCreate per-session devnode
// + the shared-memory channel) and hold it, pushing one fixed frame (Cross + LS-right). Drives // + the shared-memory channel) and hold it, pushing one fixed frame (Cross + LS-right). Drives
// the real DualSenseWindowsManager, so it validates the device lifecycle end to end. Verify // the real DualSenseWindowsManager, so it validates the device lifecycle end to end. Verify
@@ -332,6 +427,15 @@ fn real_main() -> Result<()> {
.unwrap_or(0); .unwrap_or(0);
let ds4 = args.iter().any(|a| a == "--ds4"); let ds4 = args.iter().any(|a| a == "--ds4");
let xbox = args.iter().any(|a| a == "--xbox"); let xbox = args.iter().any(|a| a == "--xbox");
// `--edge` drives the DualSense Edge backend (device_type 2) and additionally holds
// the R4/L4 paddles on the pressed beats, so a HID read shows the Edge bits in
// report byte 10 (0x80|0x40) next to Cross.
let edge = args.iter().any(|a| a == "--edge");
let extra_buttons: u32 = if edge {
punktfunk_core::input::gamepad::BTN_PADDLE1 | punktfunk_core::input::gamepad::BTN_PADDLE2
} else {
0
};
// Same drive loop for either backend (identical method surface): Arrival creates the pad, // Same drive loop for either backend (identical method surface): Arrival creates the pad,
// State pushes a cycling report, pump surfaces a game's rumble/lightbar feedback. // State pushes a cycling report, pump surfaces a game's rumble/lightbar feedback.
macro_rules! drive { macro_rules! drive {
@@ -360,7 +464,7 @@ fn real_main() -> Result<()> {
last = Instant::now(); last = Instant::now();
i += 1; i += 1;
let buttons = if i % 2 == 0 { let buttons = if i % 2 == 0 {
punktfunk_core::input::gamepad::BTN_A // Cross punktfunk_core::input::gamepad::BTN_A | extra_buttons // Cross (+ Edge paddles)
} else { } else {
0 0
}; };
@@ -425,6 +529,11 @@ fn real_main() -> Result<()> {
inject::dualshock4_windows::DualShock4WindowsManager::new(), inject::dualshock4_windows::DualShock4WindowsManager::new(),
"DualShock 4" "DualShock 4"
); );
} else if edge {
drive!(
inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new(),
"DualSense Edge"
);
} else { } else {
drive!( drive!(
inject::dualsense_windows::DualSenseWindowsManager::new(), inject::dualsense_windows::DualSenseWindowsManager::new(),
+183 -8
View File
@@ -1752,7 +1752,8 @@ const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_s
/// ///
/// - Xbox 360 / One — uinput on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager), /// - Xbox 360 / One — uinput on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager),
/// two identities), the XUSB companion driver (classic XInput) on Windows. /// two identities), the XUSB companion driver (classic XInput) on Windows.
/// - DualSense / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF minidriver. /// - DualSense / DualSense Edge / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF
/// minidriver (device-type 0/2/1).
/// - Steam Deck — Linux UHID `hid-steam`. /// - Steam Deck — Linux UHID `hid-steam`.
/// ///
/// [`resolve_pad_kind`] folds any kind a platform can't build into one it can, so this never /// [`resolve_pad_kind`] folds any kind a platform can't build into one it can, so this never
@@ -1771,12 +1772,20 @@ struct Pads {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
dualsense: Option<crate::inject::dualsense::DualSenseManager>, dualsense: Option<crate::inject::dualsense::DualSenseManager>,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
dualsense_edge: Option<crate::inject::dualsense::DualSenseEdgeManager>,
#[cfg(target_os = "linux")]
dualshock4: Option<crate::inject::dualshock4::DualShock4Manager>, dualshock4: Option<crate::inject::dualshock4::DualShock4Manager>,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
steamdeck: Option<crate::inject::steam_controller::SteamControllerManager>, steamdeck: Option<crate::inject::steam_controller::SteamControllerManager>,
#[cfg(target_os = "linux")]
switchpro: Option<crate::inject::switch_pro::SwitchProManager>,
#[cfg(target_os = "linux")]
steamctrl: Option<crate::inject::steam_controller::SteamCtrlManager>,
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>, dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
dualsense_edge_win: Option<crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager>,
#[cfg(target_os = "windows")]
dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>, dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>,
} }
@@ -1798,12 +1807,20 @@ impl Pads {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
dualsense: None, dualsense: None,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
dualsense_edge: None,
#[cfg(target_os = "linux")]
dualshock4: None, dualshock4: None,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
steamdeck: None, steamdeck: None,
#[cfg(target_os = "linux")]
switchpro: None,
#[cfg(target_os = "linux")]
steamctrl: None,
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
dualsense_win: None, dualsense_win: None,
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
dualsense_edge_win: None,
#[cfg(target_os = "windows")]
dualshock4_win: None, dualshock4_win: None,
} }
} }
@@ -1855,6 +1872,11 @@ impl Pads {
.get_or_insert_with(crate::inject::dualsense::DualSenseManager::new) .get_or_insert_with(crate::inject::dualsense::DualSenseManager::new)
.handle(ev), .handle(ev),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
GamepadPref::DualSenseEdge => self
.dualsense_edge
.get_or_insert_with(crate::inject::dualsense::DualSenseEdgeManager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::DualShock4 => self GamepadPref::DualShock4 => self
.dualshock4 .dualshock4
.get_or_insert_with(crate::inject::dualshock4::DualShock4Manager::new) .get_or_insert_with(crate::inject::dualshock4::DualShock4Manager::new)
@@ -1865,6 +1887,16 @@ impl Pads {
.get_or_insert_with(crate::inject::steam_controller::SteamControllerManager::new) .get_or_insert_with(crate::inject::steam_controller::SteamControllerManager::new)
.handle(ev), .handle(ev),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
GamepadPref::SwitchPro => self
.switchpro
.get_or_insert_with(crate::inject::switch_pro::SwitchProManager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::SteamController => self
.steamctrl
.get_or_insert_with(crate::inject::steam_controller::SteamCtrlManager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::XboxOne => self GamepadPref::XboxOne => self
.xboxone .xboxone
.get_or_insert_with(|| { .get_or_insert_with(|| {
@@ -1879,6 +1911,13 @@ impl Pads {
.get_or_insert_with(crate::inject::dualsense_windows::DualSenseWindowsManager::new) .get_or_insert_with(crate::inject::dualsense_windows::DualSenseWindowsManager::new)
.handle(ev), .handle(ev),
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
GamepadPref::DualSenseEdge => self
.dualsense_edge_win
.get_or_insert_with(
crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new,
)
.handle(ev),
#[cfg(target_os = "windows")]
GamepadPref::DualShock4 => self GamepadPref::DualShock4 => self
.dualshock4_win .dualshock4_win
.get_or_insert_with( .get_or_insert_with(
@@ -1920,6 +1959,12 @@ impl Pads {
} }
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
GamepadPref::DualSenseEdge => {
if let Some(m) = &mut self.dualsense_edge {
m.apply_rich(rich)
}
}
#[cfg(target_os = "linux")]
GamepadPref::DualShock4 => { GamepadPref::DualShock4 => {
if let Some(m) = &mut self.dualshock4 { if let Some(m) = &mut self.dualshock4 {
m.apply_rich(rich) m.apply_rich(rich)
@@ -1931,6 +1976,18 @@ impl Pads {
m.apply_rich(rich) m.apply_rich(rich)
} }
} }
#[cfg(target_os = "linux")]
GamepadPref::SwitchPro => {
if let Some(m) = &mut self.switchpro {
m.apply_rich(rich)
}
}
#[cfg(target_os = "linux")]
GamepadPref::SteamController => {
if let Some(m) = &mut self.steamctrl {
m.apply_rich(rich)
}
}
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
GamepadPref::DualSense => { GamepadPref::DualSense => {
if let Some(m) = &mut self.dualsense_win { if let Some(m) = &mut self.dualsense_win {
@@ -1938,6 +1995,12 @@ impl Pads {
} }
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
GamepadPref::DualSenseEdge => {
if let Some(m) = &mut self.dualsense_edge_win {
m.apply_rich(rich)
}
}
#[cfg(target_os = "windows")]
GamepadPref::DualShock4 => { GamepadPref::DualShock4 => {
if let Some(m) = &mut self.dualshock4_win { if let Some(m) = &mut self.dualshock4_win {
m.apply_rich(rich) m.apply_rich(rich)
@@ -1967,18 +2030,30 @@ impl Pads {
if let Some(m) = &mut self.dualsense { if let Some(m) = &mut self.dualsense {
m.pump(&mut rumble, &mut hidout); m.pump(&mut rumble, &mut hidout);
} }
if let Some(m) = &mut self.dualsense_edge {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.dualshock4 { if let Some(m) = &mut self.dualshock4 {
m.pump(&mut rumble, &mut hidout); m.pump(&mut rumble, &mut hidout);
} }
if let Some(m) = &mut self.steamdeck { if let Some(m) = &mut self.steamdeck {
m.pump(&mut rumble, &mut hidout); m.pump(&mut rumble, &mut hidout);
} }
if let Some(m) = &mut self.switchpro {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.steamctrl {
m.pump(&mut rumble, &mut hidout);
}
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
if let Some(m) = &mut self.dualsense_win { if let Some(m) = &mut self.dualsense_win {
m.pump(&mut rumble, &mut hidout); m.pump(&mut rumble, &mut hidout);
} }
if let Some(m) = &mut self.dualsense_edge_win {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.dualshock4_win { if let Some(m) = &mut self.dualshock4_win {
m.pump(&mut rumble, &mut hidout); m.pump(&mut rumble, &mut hidout);
} }
@@ -1996,12 +2071,21 @@ impl Pads {
if let Some(m) = &mut self.dualsense { if let Some(m) = &mut self.dualsense {
m.heartbeat(gap); m.heartbeat(gap);
} }
if let Some(m) = &mut self.dualsense_edge {
m.heartbeat(gap);
}
if let Some(m) = &mut self.dualshock4 { if let Some(m) = &mut self.dualshock4 {
m.heartbeat(gap); m.heartbeat(gap);
} }
if let Some(m) = &mut self.steamdeck { if let Some(m) = &mut self.steamdeck {
m.heartbeat(gap); m.heartbeat(gap);
} }
if let Some(m) = &mut self.switchpro {
m.heartbeat(gap);
}
if let Some(m) = &mut self.steamctrl {
m.heartbeat(gap);
}
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
@@ -2009,6 +2093,9 @@ impl Pads {
if let Some(m) = &mut self.dualsense_win { if let Some(m) = &mut self.dualsense_win {
m.heartbeat(gap); m.heartbeat(gap);
} }
if let Some(m) = &mut self.dualsense_edge_win {
m.heartbeat(gap);
}
if let Some(m) = &mut self.dualshock4_win { if let Some(m) = &mut self.dualshock4_win {
m.heartbeat(gap); m.heartbeat(gap);
} }
@@ -2692,14 +2779,22 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
// One/Series: a real, distinct uinput identity on Linux; folded into the 360 backend on // One/Series: a real, distinct uinput identity on Linux; folded into the 360 backend on
// Windows (XInput can't tell them apart anyway). // Windows (XInput can't tell them apart anyway).
GamepadPref::XboxOne if linux => GamepadPref::XboxOne, GamepadPref::XboxOne if linux => GamepadPref::XboxOne,
// Steam Deck: Linux UHID hid-steam. The classic Steam Controller's backend isn't built yet, // Steam Deck / classic Steam Controller: Linux UHID hid-steam (Windows Steam devices
// so it folds to Xbox360 for now (Windows Steam devices are M7). // are the N4 spike).
GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck, GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck,
GamepadPref::SteamController if linux => GamepadPref::SteamController,
// No virtual Deck on Windows (M7) — fold to DualSense, the closest rich pad: its // No virtual Deck on Windows (M7) — fold to DualSense, the closest rich pad: its
// backend keeps gyro + trackpads + pad-click alive (the Deck's dual pads split the // backend keeps gyro + trackpads + pad-click alive (the Deck's dual pads split the
// DualSense touchpad left/right per DsState::apply_rich). Folding to Xbox360 dropped // DualSense touchpad left/right per DsState::apply_rich). Folding to Xbox360 dropped
// all of that silently. // all of that silently.
GamepadPref::SteamDeck if windows => GamepadPref::DualSense, GamepadPref::SteamDeck if windows => GamepadPref::DualSense,
// DualSense Edge: Linux UHID hid-playstation / Windows UMDF (device-type 2) — the plain
// DualSense plus native back/Fn buttons, so the wire paddles stop hitting the fold/drop
// policy. Degrades to Xbox360 elsewhere like its siblings.
GamepadPref::DualSenseEdge if linux || windows => GamepadPref::DualSenseEdge,
// Switch Pro: Linux UHID hid-nintendo (≥ 5.16) — correct Nintendo glyphs + positional
// layout + gyro + HD rumble. No Windows backend; folds to Xbox360 there.
GamepadPref::SwitchPro if linux => GamepadPref::SwitchPro,
_ => GamepadPref::Xbox360, _ => GamepadPref::Xbox360,
} }
} }
@@ -2712,7 +2807,12 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref { fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
let needs_uhid = matches!( let needs_uhid = matches!(
chosen, chosen,
GamepadPref::DualSense | GamepadPref::DualShock4 | GamepadPref::SteamDeck GamepadPref::DualSense
| GamepadPref::DualSenseEdge
| GamepadPref::DualShock4
| GamepadPref::SteamDeck
| GamepadPref::SteamController
| GamepadPref::SwitchPro
); );
if needs_uhid if needs_uhid
&& std::fs::OpenOptions::new() && std::fs::OpenOptions::new()
@@ -5279,6 +5379,38 @@ mod tests {
assert_eq!(pick_gamepad(SteamDeck, None, false, true), DualSense); assert_eq!(pick_gamepad(SteamDeck, None, false, true), DualSense);
assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), DualSense); assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), DualSense);
assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360); assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360);
// Classic Steam Controller: native on Linux (UHID hid-steam); Xbox360 elsewhere.
assert_eq!(
pick_gamepad(SteamController, None, true, false),
SteamController
);
assert_eq!(
pick_gamepad(Auto, Some("steamcontroller"), true, false),
SteamController
);
assert_eq!(pick_gamepad(SteamController, None, false, true), Xbox360);
// DualSense Edge: native on Linux (UHID) AND Windows (UMDF device-type 2); Xbox360
// elsewhere.
assert_eq!(
pick_gamepad(DualSenseEdge, None, true, false),
DualSenseEdge
);
assert_eq!(
pick_gamepad(DualSenseEdge, None, false, true),
DualSenseEdge
);
assert_eq!(
pick_gamepad(Auto, Some("edge"), true, false),
DualSenseEdge
);
assert_eq!(pick_gamepad(DualSenseEdge, None, false, false), Xbox360);
// Switch Pro: native on Linux (UHID hid-nintendo); Xbox360 on Windows and elsewhere.
assert_eq!(pick_gamepad(SwitchPro, None, true, false), SwitchPro);
assert_eq!(pick_gamepad(Auto, Some("switchpro"), true, false), SwitchPro);
assert_eq!(pick_gamepad(Auto, Some("switch"), true, false), SwitchPro);
assert_eq!(pick_gamepad(SwitchPro, None, false, true), Xbox360);
assert_eq!(pick_gamepad(SwitchPro, None, false, false), Xbox360);
} }
#[test] #[test]
@@ -5335,11 +5467,54 @@ mod tests {
assert!(s.apply(&gp(InputKind::GamepadAxis, AXIS_LT, 9_999, 0))); assert!(s.apply(&gp(InputKind::GamepadAxis, AXIS_LT, 9_999, 0)));
assert_eq!(s.left_trigger, 255); assert_eq!(s.left_trigger, 255);
assert!(!s.apply(&gp(InputKind::GamepadAxis, 42, 1, 0))); assert!(!s.apply(&gp(InputKind::GamepadAxis, 42, 1, 0)));
}
// The punktfunk/1 button bits are the GameStream bits — one wire contract end to end. /// Freeze the gamepad wire contract: every button bit + axis id pinned to its exact value, read
assert_eq!(BTN_A, crate::gamestream::gamepad::BTN_A); /// through the GameStream namespace (`crate::gamestream::gamepad`, which re-exports
assert_eq!(BTN_GUIDE, crate::gamestream::gamepad::BTN_GUIDE); /// `punktfunk_core::input::gamepad` — the punktfunk/1 native wire and the GameStream/Limelight
assert_eq!(BTN_DPAD_UP, crate::gamestream::gamepad::BTN_DPAD_UP); /// 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. /// Pull and byte-verify `count` synthetic frames through the C ABI connection.
+1 -1
View File
@@ -95,7 +95,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
| Setting | Values | Meaning | | Setting | Values | Meaning |
|---|---|---| |---|---|---|
| `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualshock4` · `steamdeck` · `steamcontroller` (aliases: `ps5`, `ps4`, `deck`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. DualSense/DualShock 4/Steam Deck need Linux UHID; unsupported choices fold to Xbox 360. | | `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `switchpro` · `steamcontroller` (aliases: `ps5`, `edge`, `ps4`, `deck`, `switch`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. `dualsenseedge` gives the client's back paddles native buttons; `switchpro` gives Nintendo-family pads correct glyphs/layout + gyro. DualSense (Edge)/DualShock 4/Steam Deck/Switch Pro need Linux UHID; unsupported choices fold to Xbox 360. |
| `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. | | `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. |
## Audio / microphone ## Audio / microphone
+8
View File
@@ -121,6 +121,14 @@
// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360. // Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360.
#define PUNKTFUNK_GAMEPAD_STEAMDECK 6 #define PUNKTFUNK_GAMEPAD_STEAMDECK 6
// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
// client's back paddles land on native slots. Folds to `DUALSENSE` until its backend lands.
#define PUNKTFUNK_GAMEPAD_DUALSENSEEDGE 7
// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo`): Nintendo glyphs +
// positional layout, gyro/accel, HD rumble. Folds to `XBOX360` until its backend lands.
#define PUNKTFUNK_GAMEPAD_SWITCHPRO 8
// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips // Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips
// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1P4) + the misc/capture button, in Moonlight's // (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1P4) + the misc/capture button, in Moonlight's
// `buttonFlags2 << 16` namespace. Mirror `input::gamepad::BTN_PADDLE1..4` / `BTN_MISC1`. // `buttonFlags2 << 16` namespace. Mirror `input::gamepad::BTN_PADDLE1..4` / `BTN_MISC1`.
+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 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 || : [ -f "$f" ] && sysctl -q -p "$f" 2>/dev/null || :
done done
# vhci-hcd now, no reboot (modules-load.d/punktfunk.conf covers boot): the usbip transport # vhci-hcd: the usbip transport that makes the virtual Steam Deck pad a real USB device Steam
# that makes the virtual Steam Deck pad a real USB device Steam Input adopts. The udev add # Input adopts. Without it the pad falls back to plain UHID hid-steam, which Steam Input won't
# event fires the 60-punktfunk.rules vhci rule, opening the attach files to the input group. # promote (Interface: -1) — so on a host in Game Mode the controller never appears and you can't
[ -f /usr/lib/modules-load.d/punktfunk.conf ] && modprobe vhci-hcd 2>/dev/null || : # 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): # 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. # 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" ] \ if [ -f "$ETC_SRC/gamescope-session-plus/sessions.d/steam" ] \
@@ -27,10 +27,10 @@ pf_dualsense.dll=1
[pf.NT$ARCH$.10.0...22000] [pf.NT$ARCH$.10.0...22000]
; Hardware ids: `root\pf_dualsense` for a root-enumerated devnode (devgen/devcon tests); `pf_dualsense` ; Hardware ids: `root\pf_dualsense` for a root-enumerated devnode (devgen/devcon tests); `pf_dualsense`
; for the host's SwDeviceCreate'd DualSense (the `root\` prefix is reserved for root enumeration, so ; for the host's SwDeviceCreate'd DualSense (the `root\` prefix is reserved for root enumeration, so
; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` for the host's virtual DualShock 4 — the ; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` / `pf_dualsenseedge` for the host's
; same driver binds both and serves the DualSense or DS4 identity per the device_type byte the host ; virtual DualShock 4 / DualSense Edge — the same driver binds all of them and serves the matching
; stamps into shared memory. ; identity per the device_type byte the host stamps into shared memory.
%DeviceDesc%=pfDualSense, root\pf_dualsense, pf_dualsense, pf_dualshock4 %DeviceDesc%=pfDualSense, root\pf_dualsense, pf_dualsense, pf_dualshock4, pf_dualsenseedge, pf_steamdeck
[pfDualSense.NT] [pfDualSense.NT]
CopyFiles=UMDriverCopy CopyFiles=UMDriverCopy
+212 -68
View File
@@ -1,8 +1,8 @@
// punktfunk virtual DualSense / DualShock 4 — UMDF2 HID minidriver. // punktfunk virtual DualSense / DualShock 4 / DualSense Edge — UMDF2 HID minidriver.
// //
// A Rust port of the WDK `vhidmini2` UMDF2 sample, reconfigured to present a Sony DualSense // A Rust port of the WDK `vhidmini2` UMDF2 sample, reconfigured to present a Sony DualSense
// (VID 054C / PID 0CE6) or DualShock 4 (device_type=1) using the inputtino report descriptor + // (VID 054C / PID 0CE6), DualShock 4 (device_type=1) or DualSense Edge (device_type=2) using the
// feature blobs punktfunk already ships in `inject/{dualsense,dualshock4}.rs`. Games see a genuine // report descriptors + feature blobs punktfunk already ships in `inject/`. Games see a genuine
// HID PS controller; the host streams input in / reads output (rumble/lightbar/triggers) back. // HID PS controller; the host streams input in / reads output (rumble/lightbar/triggers) back.
// //
// No WDF object contexts: this is a singleton virtual device, so per-device state lives in statics. // No WDF object contexts: this is a singleton virtual device, so per-device state lives in statics.
@@ -63,6 +63,14 @@ const DS_PID: u16 = 0x0CE6;
const DS_VER: u16 = 0x0100; const DS_VER: u16 = 0x0100;
/// DualShock 4 v2 product id — served (same VID/version) when the host stamps device_type=1. /// DualShock 4 v2 product id — served (same VID/version) when the host stamps device_type=1.
const DS4_PID: u16 = 0x09CC; const DS4_PID: u16 = 0x09CC;
/// DualSense Edge product id — served (same VID/version) when the host stamps device_type=2.
const DS_EDGE_PID: u16 = 0x0DF2;
/// **N4 spike** (gamepad-new-types §6): the Steam Deck controller identity (Valve 28DE:1205),
/// served when the host stamps device_type=3. Exists ONLY to answer the go/no-go question "does
/// Steam Input on Windows promote a software-devnode HID Deck?" — the host never stamps 3
/// outside the `deck-windows-spike` subcommand.
const DECK_VID: u16 = 0x28DE;
const DECK_PID: u16 = 0x1205;
// Sony DualSense USB HID report descriptor (273 bytes), verbatim from inputtino (== inject/dualsense.rs). // Sony DualSense USB HID report descriptor (273 bytes), verbatim from inputtino (== inject/dualsense.rs).
// NOTE: inject/dualsense.rs comments this as "232 bytes" — that comment is wrong; it is 273. // NOTE: inject/dualsense.rs comments this as "232 bytes" — that comment is wrong; it is 273.
@@ -175,18 +183,72 @@ static DS4_FEATURE_FIRMWARE: [u8; 49] = [ // 0xa3 firmware/build info
0x00, 0x00,
]; ];
// ---- DualSense Edge assets (served when the host stamps device_type=2) ----
// Sony DualSense Edge USB HID report descriptor (389 bytes), verbatim from
// inject/proto/dualsense_proto.rs (a real-device capture; see the provenance note there). Input
// report 0x01 is bit-identical to the plain DualSense — the Edge's Fn/back buttons ride reserved
// bits of buttons[2]; output report 0x02 grows to 63 bytes and 19 profile feature reports are added.
#[rustfmt::skip]
static DS_EDGE_RDESC: [u8; 389] = [
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x35,
0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x06, 0x81, 0x02, 0x06,
0x00, 0xFF, 0x09, 0x20, 0x95, 0x01, 0x81, 0x02, 0x05, 0x01, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07,
0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00, 0x05,
0x09, 0x19, 0x01, 0x29, 0x0F, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0F, 0x81, 0x02, 0x06,
0x00, 0xFF, 0x09, 0x21, 0x95, 0x0D, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x22, 0x15, 0x00, 0x26,
0xFF, 0x00, 0x75, 0x08, 0x95, 0x34, 0x81, 0x02, 0x85, 0x02, 0x09, 0x23, 0x95, 0x3F, 0x91, 0x02,
0x85, 0x05, 0x09, 0x33, 0x95, 0x28, 0xB1, 0x02, 0x85, 0x08, 0x09, 0x34, 0x95, 0x2F, 0xB1, 0x02,
0x85, 0x09, 0x09, 0x24, 0x95, 0x13, 0xB1, 0x02, 0x85, 0x0A, 0x09, 0x25, 0x95, 0x1A, 0xB1, 0x02,
0x85, 0x20, 0x09, 0x26, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x21, 0x09, 0x27, 0x95, 0x04, 0xB1, 0x02,
0x85, 0x22, 0x09, 0x40, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x80, 0x09, 0x28, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0x81, 0x09, 0x29, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x2A, 0x95, 0x09, 0xB1, 0x02,
0x85, 0x83, 0x09, 0x2B, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x84, 0x09, 0x2C, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0x85, 0x09, 0x2D, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xA0, 0x09, 0x2E, 0x95, 0x01, 0xB1, 0x02,
0x85, 0xE0, 0x09, 0x2F, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x30, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0xF1, 0x09, 0x31, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2, 0x09, 0x32, 0x95, 0x34, 0xB1, 0x02,
0x85, 0xF4, 0x09, 0x35, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF5, 0x09, 0x36, 0x95, 0x03, 0xB1, 0x02,
0x85, 0x60, 0x09, 0x41, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x61, 0x09, 0x42, 0xB1, 0x02, 0x85, 0x62,
0x09, 0x43, 0xB1, 0x02, 0x85, 0x63, 0x09, 0x44, 0xB1, 0x02, 0x85, 0x64, 0x09, 0x45, 0xB1, 0x02,
0x85, 0x65, 0x09, 0x46, 0xB1, 0x02, 0x85, 0x68, 0x09, 0x47, 0xB1, 0x02, 0x85, 0x70, 0x09, 0x48,
0xB1, 0x02, 0x85, 0x71, 0x09, 0x49, 0xB1, 0x02, 0x85, 0x72, 0x09, 0x4A, 0xB1, 0x02, 0x85, 0x73,
0x09, 0x4B, 0xB1, 0x02, 0x85, 0x74, 0x09, 0x4C, 0xB1, 0x02, 0x85, 0x75, 0x09, 0x4D, 0xB1, 0x02,
0x85, 0x76, 0x09, 0x4E, 0xB1, 0x02, 0x85, 0x77, 0x09, 0x4F, 0xB1, 0x02, 0x85, 0x78, 0x09, 0x50,
0xB1, 0x02, 0x85, 0x79, 0x09, 0x51, 0xB1, 0x02, 0x85, 0x7A, 0x09, 0x52, 0xB1, 0x02, 0x85, 0x7B,
0x09, 0x53, 0xB1, 0x02, 0xC0,
];
// ---- N4-spike Steam Deck assets (served when the host stamps device_type=3) ----
// The Deck's captured CONTROLLER-interface report descriptor (38 bytes, interface 2 of a real
// 28DE:1205 — verbatim from inject/proto/steam_proto.rs RDESC_DECK_CTRL): one vendor-defined
// (page 0xFFFF) collection with a 64-byte input + 64-byte feature report.
#[rustfmt::skip]
static DECK_RDESC: [u8; 38] = [
0x06, 0xff, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x09, 0x02, 0x09, 0x03, 0x15, 0x00, 0x26, 0xff, 0x00,
0x75, 0x08, 0x95, 0x40, 0x81, 0x02, 0x09, 0x06, 0x09, 0x07, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75,
0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
];
// HID descriptor (9 bytes, packed): len, type=0x21, bcdHID=0x0100, country=0, numDesc=1, then // HID descriptor (9 bytes, packed): len, type=0x21, bcdHID=0x0100, country=0, numDesc=1, then
// {reportType=0x22, wReportLength}. DualSense = 273 (0x0111); DualShock 4 = 507 (0x01FB). // {reportType=0x22, wReportLength}. DualSense = 273 (0x0111); DualShock 4 = 507 (0x01FB);
// DualSense Edge = 389 (0x0185).
static HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x11, 0x01]; static HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x11, 0x01];
static DS4_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0xFB, 0x01]; static DS4_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0xFB, 0x01];
static EDGE_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x85, 0x01];
static DECK_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x26, 0x00]; // 38 bytes
// HID_DEVICE_ATTRIBUTES (32 bytes): Size(u32)=32, VendorID, ProductID, VersionNumber, Reserved[11]. // HID_DEVICE_ATTRIBUTES (32 bytes): Size(u32)=32, VendorID, ProductID, VersionNumber, Reserved[11].
// `ds4` selects the DualShock 4 product id (same VID/version). // `devtype` selects the identity: PS family (same Sony VID/version) or the N4-spike Deck.
fn hid_attrs(ds4: bool) -> [u8; 32] { fn hid_attrs(devtype: u8) -> [u8; 32] {
let (vid, pid) = match devtype {
1 => (DS_VID, DS4_PID),
2 => (DS_VID, DS_EDGE_PID),
3 => (DECK_VID, DECK_PID),
_ => (DS_VID, DS_PID),
};
let mut a = [0u8; 32]; let mut a = [0u8; 32];
a[0..4].copy_from_slice(&32u32.to_le_bytes()); a[0..4].copy_from_slice(&32u32.to_le_bytes());
a[4..6].copy_from_slice(&DS_VID.to_le_bytes()); a[4..6].copy_from_slice(&vid.to_le_bytes());
a[6..8].copy_from_slice(&(if ds4 { DS4_PID } else { DS_PID }).to_le_bytes()); a[6..8].copy_from_slice(&pid.to_le_bytes());
a[8..10].copy_from_slice(&DS_VER.to_le_bytes()); a[8..10].copy_from_slice(&DS_VER.to_le_bytes());
a a
} }
@@ -215,11 +277,20 @@ const DS4_NEUTRAL_REPORT: [u8; 64] = {
r[5] = 0x08; // buttons[0]: low nibble = dpad hat (8 = neutral), high nibble = face buttons (0) r[5] = 0x08; // buttons[0]: low nibble = dpad hat (8 = neutral), high nibble = face buttons (0)
r r
}; };
fn neutral_report(ds4: bool) -> [u8; 64] { // Neutral Steam Deck input frame (unnumbered): header [0x01, 0x00, ID_CONTROLLER_DECK_STATE=0x09,
if ds4 { // payload-len 0x3C], everything released.
DS4_NEUTRAL_REPORT const DECK_NEUTRAL_REPORT: [u8; 64] = {
} else { let mut r = [0u8; 64];
NEUTRAL_REPORT r[0] = 0x01;
r[2] = 0x09;
r[3] = 0x3C;
r
};
fn neutral_report(devtype: u8) -> [u8; 64] {
match devtype {
1 => DS4_NEUTRAL_REPORT,
3 => DECK_NEUTRAL_REPORT,
_ => NEUTRAL_REPORT, // DualSense and Edge share the report 0x01 shape
} }
} }
@@ -251,7 +322,8 @@ const OFF_PAD_INDEX: usize = core::mem::offset_of!(PadShm, pad_index);
/// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so /// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so
/// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`. /// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`.
static CHANNEL: ChannelClient = ChannelClient::new(); static CHANNEL: ChannelClient = ChannelClient::new();
/// The last observed `device_type` (0 = DualSense, 1 = DualShock 4) — the neutral-report shape when /// The last observed `device_type` (0 = DualSense, 1 = DualShock 4, 2 = DualSense Edge) — the
/// neutral-report shape when
/// the channel detaches, and the fallback identity while unattached. /// the channel detaches, and the fallback identity while unattached.
static LAST_DEVTYPE: AtomicU32 = AtomicU32::new(0); static LAST_DEVTYPE: AtomicU32 = AtomicU32::new(0);
/// device_type()'s bounded first-read wait fires at most once (see its docs). /// device_type()'s bounded first-read wait fires at most once (see its docs).
@@ -480,28 +552,25 @@ extern "C" fn evt_io_device_control(
} }
let status: NTSTATUS = match ioctl { let status: NTSTATUS = match ioctl {
IOCTL_HID_GET_DEVICE_DESCRIPTOR => request.copy_to_output(if device_type() == 1 { IOCTL_HID_GET_DEVICE_DESCRIPTOR => request.copy_to_output(match device_type() {
&DS4_HID_DESC 1 => &DS4_HID_DESC,
} else { 2 => &EDGE_HID_DESC,
&HID_DESC 3 => &DECK_HID_DESC,
_ => &HID_DESC,
}), }),
IOCTL_HID_GET_DEVICE_ATTRIBUTES => request.copy_to_output(&hid_attrs(device_type() == 1)), IOCTL_HID_GET_DEVICE_ATTRIBUTES => request.copy_to_output(&hid_attrs(device_type())),
IOCTL_HID_GET_REPORT_DESCRIPTOR => request.copy_to_output(if device_type() == 1 { IOCTL_HID_GET_REPORT_DESCRIPTOR => request.copy_to_output(match device_type() {
&DS4_RDESC[..] 1 => &DS4_RDESC[..],
} else { 2 => &DS_EDGE_RDESC[..],
&DUALSENSE_RDESC[..] 3 => &DECK_RDESC[..],
_ => &DUALSENSE_RDESC[..],
}), }),
IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => { IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => {
on_output_report(&request, ioctl) on_output_report(&request, ioctl)
} }
IOCTL_UMDF_HID_SET_FEATURE => { IOCTL_UMDF_HID_SET_FEATURE => on_set_feature(&request),
log("[pf-ds] SET_FEATURE (stub ok)");
STATUS_SUCCESS
}
IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(&request), IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(&request),
IOCTL_UMDF_HID_GET_INPUT_REPORT => { IOCTL_UMDF_HID_GET_INPUT_REPORT => request.copy_to_output(&neutral_report(device_type())),
request.copy_to_output(&neutral_report(device_type() == 1))
}
IOCTL_HID_GET_STRING => on_get_string(&request), IOCTL_HID_GET_STRING => on_get_string(&request),
_ => STATUS_NOT_IMPLEMENTED, _ => STATUS_NOT_IMPLEMENTED,
}; };
@@ -545,8 +614,85 @@ fn on_output_report(request: &Request, ioctl: ULONG) -> NTSTATUS {
STATUS_SUCCESS STATUS_SUCCESS
} }
// GET_FEATURE: report id from the input buffer; reply with the matching DualSense/DualShock 4 blob. /// N4 spike: the last SET_FEATURE payload (the Steam command byte + args, minus the report-id
/// prefix). Steam's Deck contract is command-in-SET_FEATURE → answer-in-GET_FEATURE on the one
/// unnumbered feature report; the PS identities ignore this (their SET_FEATUREs are fire-and-
/// forget) — acking them is all they need.
static LAST_SET_FEATURE: std::sync::Mutex<[u8; 64]> = std::sync::Mutex::new([0; 64]);
// SET_FEATURE: ack (the PS identities' contract), and latch the payload for the Deck's
// GET_FEATURE answer. Per the UMDF marshalling convention the report data is the input buffer.
fn on_set_feature(request: &Request) -> NTSTATUS {
if let Ok((bytes, _)) = request.input_bytes(64) {
// The wire carries [report-id 0, cmd, …] for the unnumbered Steam report; store the
// command-first view. (PS set-features carry their own report id first — harmless.)
let src: &[u8] = if bytes.first() == Some(&0x00) && bytes.len() > 1 {
&bytes[1..]
} else {
&bytes
};
if let Ok(mut g) = LAST_SET_FEATURE.lock() {
g.fill(0);
let n = src.len().min(64);
g[..n].copy_from_slice(&src[..n]);
}
}
dbglog!("[pf-ds] SET_FEATURE (acked, latched for GET)");
STATUS_SUCCESS
}
/// N4 spike: build the Deck's GET_FEATURE reply from the latched SET_FEATURE command — the
/// 0x83 GET_ATTRIBUTES 9-attribute blob (unit id keyed per pad) or the 0xAE unit serial, both
/// captured from a physical Deck (see inject/proto/steam_proto.rs feature_reply, the source of
/// truth this mirrors). Anything else echoes the latched command.
fn deck_feature_reply() -> [u8; 64] {
let last = LAST_SET_FEATURE.lock().map(|g| *g).unwrap_or([0u8; 64]);
let unit_id: u32 = 0x5046_0003; // "PF" + the spike's scratch index
let serial = b"PFDK50460003";
let mut r = [0u8; 64];
match last[0] {
0x83 => {
// GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9x (attr-id, value u32-LE)].
r[0] = 0x83;
r[1] = 0x2D;
let attrs: [(u8, u32); 9] = [
(0x01, 0x1205),
(0x02, 0),
(0x0A, unit_id),
(0x04, unit_id ^ 0x5555_5555),
(0x09, 0x2E),
(0x0B, 0x0FA0),
(0x0D, 0),
(0x0C, 0),
(0x0E, 0),
];
let mut o = 2;
for (id, val) in attrs {
r[o] = id;
r[o + 1..o + 5].copy_from_slice(&val.to_le_bytes());
o += 5;
}
}
0xAE => {
// GET_STRING_ATTRIBUTE: [0xAE, len, attr, ascii…].
let attr = if last[2] != 0 { last[2] } else { 0x01 };
r[0] = 0xAE;
r[1] = serial.len() as u8;
r[2] = attr;
r[3..3 + serial.len()].copy_from_slice(serial);
}
_ => r.copy_from_slice(&last),
}
r
}
// GET_FEATURE: report id from the input buffer; reply with the matching DualSense/DualShock 4 blob
// (the Deck identity instead answers the latched Steam command — its one feature report is
// unnumbered).
fn on_get_feature(request: &Request) -> NTSTATUS { fn on_get_feature(request: &Request) -> NTSTATUS {
if device_type() == 3 {
return request.copy_to_output(&deck_feature_reply());
}
let (bytes, _) = match request.input_bytes(1) { let (bytes, _) = match request.input_bytes(1) {
Ok(v) => v, Ok(v) => v,
Err(st) => return st, Err(st) => return st,
@@ -554,14 +700,16 @@ fn on_get_feature(request: &Request) -> NTSTATUS {
let Some(&report_id) = bytes.first() else { let Some(&report_id) = bytes.first() else {
return STATUS_INVALID_PARAMETER; return STATUS_INVALID_PARAMETER;
}; };
// DualSense uses feature ids 0x05/0x09/0x20; DualShock 4 uses 0x02/0x12/0xa3. // DualSense + Edge use feature ids 0x05/0x09/0x20 (same blobs — SDL forces enhanced-rumble
let blob: &[u8] = match (device_type() == 1, report_id) { // for the Edge PID regardless of the firmware version at 0x20[44..46]); DualShock 4 uses
(false, 0x05) => &DS_FEATURE_CALIBRATION, // 0x02/0x12/0xa3.
(false, 0x09) => &DS_FEATURE_PAIRING, let blob: &[u8] = match (device_type(), report_id) {
(false, 0x20) => &DS_FEATURE_FIRMWARE, (0 | 2, 0x05) => &DS_FEATURE_CALIBRATION,
(true, 0x02) => &DS4_FEATURE_CALIBRATION, (0 | 2, 0x09) => &DS_FEATURE_PAIRING,
(true, 0x12) => &DS4_FEATURE_PAIRING, (0 | 2, 0x20) => &DS_FEATURE_FIRMWARE,
(true, 0xA3) => &DS4_FEATURE_FIRMWARE, (1, 0x02) => &DS4_FEATURE_CALIBRATION,
(1, 0x12) => &DS4_FEATURE_PAIRING,
(1, 0xA3) => &DS4_FEATURE_FIRMWARE,
(_, other) => { (_, other) => {
dbglog!("[pf-ds] GET_FEATURE unknown report id 0x{other:02x}"); dbglog!("[pf-ds] GET_FEATURE unknown report id 0x{other:02x}");
return STATUS_INVALID_PARAMETER; return STATUS_INVALID_PARAMETER;
@@ -586,30 +734,26 @@ fn on_get_string(request: &Request) -> NTSTATUS {
0 0
}; };
let string_id = id_val & 0xFFFF; let string_id = id_val & 0xFFFF;
let ds4 = device_type() == 1; let devtype = device_type();
dbglog!("[pf-ds] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) ds4={ds4}"); dbglog!("[pf-ds] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) devtype={devtype}");
let s: &str = match string_id { let s: &str = match string_id {
0 | 0x000e => { 0 | 0x000e => match devtype {
if ds4 { 1 => "Sony Computer Entertainment",
"Sony Computer Entertainment" 3 => "Valve Software",
} else { _ => "Sony Interactive Entertainment",
"Sony Interactive Entertainment" },
} 2 | 0x0010 => match devtype {
} 1 => "DEADBEEF0001",
2 | 0x0010 => { 2 => "35533AD6E775",
if ds4 { 3 => "PFDK50460003",
"DEADBEEF0001" _ => "35533AD6E774",
} else { },
"35533AD6E774" _ => match devtype {
} 1 => "Wireless Controller",
} 2 => "DualSense Edge Wireless Controller",
_ => { 3 => "Steam Deck Controller",
if ds4 { _ => "DualSense Wireless Controller",
"Wireless Controller" },
} else {
"DualSense Wireless Controller"
}
}
}; };
let mut wide: Vec<u8> = Vec::with_capacity(s.len() * 2 + 2); let mut wide: Vec<u8> = Vec::with_capacity(s.len() * 2 + 2);
for u in s.encode_utf16() { for u in s.encode_utf16() {
@@ -620,11 +764,11 @@ fn on_get_string(request: &Request) -> NTSTATUS {
} }
/// The host's device-type selector from the sealed DATA section (`device_type` @140): 0 = DualSense /// The host's device-type selector from the sealed DATA section (`device_type` @140): 0 = DualSense
/// (default), 1 = DualShock 4. Read fresh on each enumeration query — cheap. If the channel hasn't /// (default), 1 = DualShock 4, 2 = DualSense Edge. Read fresh on each enumeration query — cheap. If
/// attached when hidclass first asks (the host stamps the section + eager-delivers before /// the channel hasn't attached when hidclass first asks (the host stamps the section + eager-delivers
/// `SwDeviceCreate` returns, but the handshake can be a few ms behind), pump the channel briefly — /// before `SwDeviceCreate` returns, but the handshake can be a few ms behind), pump the channel
/// ONCE — for the delivery: a DS4 pad must not enumerate with the default DualSense identity because /// briefly — ONCE — for the delivery: a DS4/Edge pad must not enumerate with the default DualSense
/// of a lost race. After that one bounded wait, fall back to the last observed type. /// identity because of a lost race. After that one bounded wait, fall back to the last observed type.
fn device_type() -> u8 { fn device_type() -> u8 {
if let Some(view) = CHANNEL.data() { if let Some(view) = CHANNEL.data() {
let t = view.read_u8(OFF_DEVICE_TYPE); let t = view.read_u8(OFF_DEVICE_TYPE);
@@ -672,7 +816,7 @@ extern "C" fn evt_timer(timer: WDFTIMER) {
// report instead of a frozen last state (matters for the persistent out-of-band devnode, // report instead of a frozen last state (matters for the persistent out-of-band devnode,
// which outlives host sessions). // which outlives host sessions).
if let Ok(mut g) = INPUT_REPORT.lock() { if let Ok(mut g) = INPUT_REPORT.lock() {
*g = neutral_report(LAST_DEVTYPE.load(Ordering::Relaxed) == 1); *g = neutral_report(LAST_DEVTYPE.load(Ordering::Relaxed) as u8);
} }
} }
} }