Compare commits

25 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
34 changed files with 3657 additions and 1287 deletions
@@ -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"
} }
@@ -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
} }
} }
@@ -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
} }
} }
@@ -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
} }
} }
+24 -4
View File
@@ -264,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",
_ => "", _ => "",
} }
} }
@@ -297,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,
} }
} }
@@ -778,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)
@@ -1556,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));
+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).
+95
View File
@@ -427,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) ---------------------------------------
@@ -558,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.
@@ -574,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);
} }
@@ -597,6 +650,20 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
timestamp, 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) if is_direct_touch(touch_id)
&& dispatch_finger( && dispatch_finger(
FingerPhase::Down, FingerPhase::Down,
@@ -619,6 +686,20 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
timestamp, 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) if is_direct_touch(touch_id)
&& dispatch_finger( && dispatch_finger(
FingerPhase::Move, FingerPhase::Move,
@@ -641,6 +722,20 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
timestamp, 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) if is_direct_touch(touch_id)
&& dispatch_finger( && dispatch_finger(
FingerPhase::Up, FingerPhase::Up,
+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)
+31 -4
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,15 +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`]) used by every backend manager on /// Shared virtual-pad creation-retry policy ([`pad_gate::PadGate`]), driven by [`pad_slots`] for
/// both platforms — replaces the per-backend permanent `broken` latch with capped-backoff retry. /// every backend manager — replaces the per-backend permanent `broken` latch with capped-backoff
/// retry.
#[cfg(any(target_os = "linux", target_os = "windows"))] #[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/pad_gate.rs"] #[path = "inject/pad_gate.rs"]
pub mod pad_gate; 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`).
@@ -538,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,18 +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, HidoutDedup, 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 crate::inject::pad_gate::PadGate;
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).
@@ -45,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,
@@ -55,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)
@@ -66,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(())
} }
@@ -163,87 +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)>,
/// Last rich feedback (lightbar / player LEDs / adaptive triggers) forwarded per pad, so an
/// output report that only changed the rumble doesn't re-send unchanged 0xCD feedback.
hidout_dedup: Vec<HidoutDedup>,
/// When each pad last wrote an input report — drives [`DualSenseManager::heartbeat`], which
/// re-emits the current state during input silence so the kernel never sees the device go quiet.
last_write: Vec<Instant>,
/// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of
/// permanently disabling every pad for the session.
gate: PadGate,
/// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button /// 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],
hidout_dedup: vec![HidoutDedup::default(); MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
gate: PadGate::new(),
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);
self.hidout_dedup[i].clear();
}
}
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,
@@ -257,107 +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.gate.allow(Instant::now()) {
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.hidout_dedup[idx].clear();
self.last_write[idx] = Instant::now();
self.gate.on_success();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — retrying with backoff");
self.gate.on_failure(Instant::now());
}
}
}
/// 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 {
// Skip rich feedback that repeats the last-forwarded value (the game's output report
// re-sends unchanged lightbar/LED/trigger state alongside every rumble update).
if self.hidout_dedup[i].should_forward(&h) {
hidout(h);
}
}
} }
} }
} }
/// 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,20 +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::{
use crate::inject::pad_gate::PadGate; 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";
@@ -129,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);
@@ -265,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
@@ -349,83 +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>,
/// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of
/// permanently disabling every pad for the session.
gate: PadGate,
/// Fallback policy for the Steam back grips a client may send (the DS4 has no back-button HID /// 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],
gate: PadGate::new(),
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,
@@ -438,217 +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.gate.allow(Instant::now()) {
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();
self.gate.on_success();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — retrying with backoff");
self.gate.on_failure(Instant::now());
}
}
}
/// 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,7 +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_gate::PadGate; 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};
@@ -551,16 +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,
/// Create-retry gate: a transient `/dev/uinput` failure backs off and retries instead of }
/// permanently disabling every pad for the session.
gate: PadGate, impl Default for GamepadManager {
fn default() -> GamepadManager {
GamepadManager::new()
}
} }
impl GamepadManager { impl GamepadManager {
@@ -572,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,
gate: PadGate::new(),
} }
} }
@@ -583,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) => {
@@ -591,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);
} }
} }
@@ -610,30 +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.gate.allow(Instant::now()) { 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);
self.gate.on_success();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual gamepad creation failed — retrying with backoff");
self.gate.on_failure(Instant::now());
}
}
} }
/// 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,13 +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 crate::inject::pad_gate::PadGate;
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;
@@ -78,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.
@@ -90,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)
@@ -101,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);
@@ -126,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());
@@ -143,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
@@ -235,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),
@@ -356,59 +374,36 @@ fn open_transport(idx: u8) -> Result<DeckTransport> {
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.
/// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of #[derive(Default)]
/// permanently disabling every pad for the session. pub struct SteamProto;
gate: PadGate,
}
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],
gate: PadGate::new(),
}
} }
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,
@@ -431,86 +426,148 @@ impl SteamControllerManager {
// wire-button's RPAD_CLICK — the two are OR'd only at serialize. // wire-button's RPAD_CLICK — the two are OR'd only at serialize.
s.lpad_click = prev.lpad_click; s.lpad_click = prev.lpad_click;
s.rpad_click = prev.rpad_click; s.rpad_click = prev.rpad_click;
self.state[idx] = s; s
self.write(idx);
} }
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.gate.allow(Instant::now()) { 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();
self.gate.on_success();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual Steam Deck creation failed — retrying with backoff");
self.gate.on_failure(Instant::now());
}
}
}
/// 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::*;
@@ -620,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,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;
/// Mic-mute / capture button — set from the wire `BTN_MISC1` in `DsState::from_gamepad`. /// 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.
@@ -798,6 +870,51 @@ mod tests {
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
@@ -341,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`,
@@ -693,6 +816,72 @@ mod tests {
assert_ne!(serialized & btn::LPAD_CLICK, 0); // click lands in the report despite the rebuild 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>;
@@ -18,17 +18,16 @@
//! must already be installed; the installer stages it.) //! must already be installed; the installer stages it.)
use super::dualsense_proto::{ use super::dualsense_proto::{
parse_ds_output, serialize_state, DsFeedback, DsState, HidoutDedup, DS_INPUT_REPORT_LEN, parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H,
DS_TOUCH_H, DS_TOUCH_W, DS_TOUCH_W,
}; };
use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx}; 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 crate::inject::pad_gate::PadGate;
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::sync::atomic::{fence, AtomicU32, Ordering}; use std::sync::atomic::{fence, AtomicU32, Ordering};
use std::time::{Duration, Instant}; use std::time::Duration;
use windows::core::{w, GUID, 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,
@@ -56,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>,
@@ -227,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];
@@ -250,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)
} }
}; };
@@ -274,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,
@@ -287,7 +326,7 @@ 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];
@@ -317,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.
@@ -351,75 +390,50 @@ 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>>,
state: Vec<DsState>,
last_rumble: Vec<(u16, u16)>,
/// Last rich feedback (lightbar / player LEDs / adaptive triggers) forwarded per pad, so an
/// output report that only changed the rumble doesn't re-send unchanged 0xCD feedback.
hidout_dedup: Vec<HidoutDedup>,
last_write: Vec<Instant>,
/// Create-retry gate: a transient UMDF-channel failure backs off and retries instead of
/// permanently disabling every pad for the session.
gate: PadGate,
/// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button /// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button
/// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. Parity with `linux/dualsense.rs`. /// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. Parity with `linux/dualsense.rs`.
remap: crate::inject::steam_remap::RemapConfig, remap: crate::inject::steam_remap::RemapConfig,
} }
impl Default for DualSenseWindowsManager { impl Default for DsWinProto {
fn default() -> DualSenseWindowsManager { fn default() -> DsWinProto {
DualSenseWindowsManager::new() DsWinProto {
}
}
impl DualSenseWindowsManager {
pub fn new() -> DualSenseWindowsManager {
DualSenseWindowsManager {
pads: (0..MAX_PADS).map(|_| None).collect(),
state: vec![DsState::neutral(); MAX_PADS],
last_rumble: vec![(0, 0); MAX_PADS],
hidout_dedup: vec![HidoutDedup::default(); MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
gate: PadGate::new(),
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 DsWinProto {
pub fn handle(&mut self, ev: &GamepadEvent) { type Pad = DsWinPad;
match ev { type State = DsState;
GamepadEvent::Arrival { index, kind, .. } => { const LABEL: &'static str = "DualSense/Windows";
tracing::info!(index, kind, "controller arrival (DualSense/Windows)"); const DEVICE: &'static str = "DualSense";
self.ensure(*index as usize); 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())?;
tracing::info!(
index = idx,
"virtual DualSense created (Windows UMDF shm channel)"
);
Ok(p)
} }
GamepadEvent::State(f) => {
let idx = f.index as usize; fn neutral(&self) -> DsState {
if idx >= MAX_PADS { DsState::neutral()
return;
} }
for (i, slot) in self.pads.iter_mut().enumerate() {
if slot.is_some() && f.active_mask & (1 << i) == 0 { /// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (rich-
tracing::info!(index = i, "controller unplugged (DualSense/Windows)"); /// plane fields that must survive a button-only frame) — exactly as `linux/dualsense.rs` does.
*slot = None; fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
self.state[i] = DsState::neutral();
self.last_rumble[i] = (0, 0);
self.hidout_dedup[i].clear();
}
}
if f.active_mask & (1 << idx) == 0 {
return;
}
self.ensure(idx);
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, exactly as // configured policy (default drop) so they aren't silently lost.
// `linux/dualsense.rs` does. let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
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,
@@ -433,98 +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.gate.allow(Instant::now()) {
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.hidout_dedup[idx].clear();
self.last_write[idx] = Instant::now();
self.gate.on_success();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — retrying with backoff (install/repair: punktfunk-host.exe driver install --gamepad)");
self.gate.on_failure(Instant::now());
}
}
}
/// 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 {
// Skip rich feedback that repeats the last-forwarded value (the game's output report
// re-sends unchanged lightbar/LED/trigger state alongside every rumble update).
if self.hidout_dedup[i].should_forward(&h) {
hidout(h);
}
}
} }
} }
} }
/// **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,15 +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 crate::inject::pad_gate::PadGate;
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.
@@ -141,73 +142,53 @@ 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)>>,
last_write: Vec<Instant>,
/// Create-retry gate: a transient UMDF-channel failure backs off and retries instead of
/// permanently disabling every pad for the session.
gate: PadGate,
/// Fallback policy for the Steam back grips a client may send (the DS4 has no back-button HID /// Fallback policy for the Steam back grips a client may send (the DS4 has no back-button HID
/// slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. Parity with `linux/dualshock4.rs`. /// slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. Parity with `linux/dualshock4.rs`.
remap: crate::inject::steam_remap::RemapConfig, remap: crate::inject::steam_remap::RemapConfig,
} }
impl Default for DualShock4WindowsManager { impl Default for Ds4WinProto {
fn default() -> DualShock4WindowsManager { fn default() -> Ds4WinProto {
DualShock4WindowsManager::new() Ds4WinProto {
}
}
impl DualShock4WindowsManager {
pub fn new() -> DualShock4WindowsManager {
DualShock4WindowsManager {
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],
gate: PadGate::new(),
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 Ds4WinProto {
pub fn handle(&mut self, ev: &GamepadEvent) { type Pad = Ds4WinPad;
match ev { type State = DsState;
GamepadEvent::Arrival { index, kind, .. } => { const LABEL: &'static str = "DualShock 4/Windows";
tracing::info!(index, kind, "controller arrival (DualShock 4/Windows)"); const DEVICE: &'static str = "DualShock 4";
self.ensure(*index as usize); const CREATE_HINT: &'static str =
" (install/repair: punktfunk-host.exe driver install --gamepad)";
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)
} }
GamepadEvent::State(f) => {
let idx = f.index as usize; fn neutral(&self) -> DsState {
if idx >= MAX_PADS { DsState::neutral()
return;
} }
for (i, slot) in self.pads.iter_mut().enumerate() {
if slot.is_some() && f.active_mask & (1 << i) == 0 { /// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (rich-
tracing::info!(index = i, "controller unplugged (DualShock 4/Windows)"); /// plane fields that must survive a button-only frame) — exactly as `linux/dualshock4.rs` does.
*slot = None; fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
self.state[i] = DsState::neutral(); // Steam back grips have no DS4 slot — fold them onto standard buttons per the configured
self.last_rumble[i] = (0, 0); // policy (default drop) so they aren't silently lost.
self.last_led[i] = None; let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
}
}
if f.active_mask & (1 << idx) == 0 {
return;
}
self.ensure(idx);
let prev = self.state[idx];
// Steam back grips have no DS4 slot — fold them onto standard buttons per the
// configured policy (default drop) so they aren't silently lost, exactly as
// `linux/dualshock4.rs` does.
let buttons =
crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
let mut s = DsState::from_gamepad( let mut s = DsState::from_gamepad(
buttons, buttons,
f.ls_x, f.ls_x,
@@ -221,100 +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.gate.allow(Instant::now()) {
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();
self.gate.on_success();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — retrying with backoff (install/repair: punktfunk-host.exe driver install --gamepad)");
self.gate.on_failure(Instant::now());
}
}
}
/// 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>;
@@ -14,7 +14,7 @@
use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx}; 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_gate::PadGate; 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::sync::atomic::{fence, AtomicU32, Ordering};
@@ -256,15 +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>,
/// Create-retry gate: a transient XUSB-companion failure backs off and retries instead of
/// permanently disabling every pad for the session.
gate: PadGate,
} }
impl Default for GamepadManager { impl Default for GamepadManager {
@@ -276,32 +273,24 @@ 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(),
gate: PadGate::new(),
} }
} }
fn ensure(&mut self, idx: usize) { fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) { 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(); self.last_active[idx] = Instant::now();
self.gate.on_success();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual Xbox 360 creation failed — retrying with backoff (install/repair: punktfunk-host.exe driver install --gamepad)");
self.gate.on_failure(Instant::now());
}
} }
} }
@@ -312,15 +301,14 @@ impl GamepadManager {
self.ensure(*index as usize); self.ensure(*index as usize);
} }
GamepadEvent::State(f) => { GamepadEvent::State(f) => {
let idx = f.index.max(0) as usize; 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(); self.last_active[i] = Instant::now();
} }
@@ -329,7 +317,7 @@ impl GamepadManager {
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,
@@ -348,10 +336,7 @@ impl GamepadManager {
/// 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(),
+136 -4
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]
+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`.
@@ -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);
} }
} }
} }