33 Commits

Author SHA1 Message Date
enricobuehler e2d4c40167 feat(android): HDR toggle, video-feed stats, landscape lock
apple / swift (push) Successful in 1m6s
android / android (push) Successful in 5m14s
ci / web (push) Successful in 49s
apple / screenshots (push) Successful in 5m19s
ci / docs-site (push) Successful in 1m0s
ci / bench (push) Successful in 4m36s
ci / rust (push) Successful in 13m31s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 10m9s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m50s
docker / deploy-docs (push) Failing after 1m11s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m19s
- HDR toggle in Settings → Display. Persisted (hdr_enabled, default on); the
  host is advertised HDR only when the toggle is on AND the panel can present
  HDR10 (displaySupportsHdr), so SDR panels never get PQ they'd mis-tone-map.
  The toggle is disabled/greyed on non-HDR displays (ToggleRow gained `enabled`).
- Extend the stats HUD with a video-feed line, e.g.
  "HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0". nativeVideoStats now returns 14
  doubles (appends bitDepth, CICP primaries/transfer, chroma_format_idc from the
  negotiated Welcome); older/shorter layouts just omit the line.
- Lock the stream to landscape while streaming (SENSOR_LANDSCAPE), restoring the
  prior orientation on exit. The activity declares configChanges=orientation, so
  it re-lays-out in place with no stream restart; harmless no-op on TV.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 22:16:04 +02:00
enricobuehler 580b1ea7a7 feat(host/steam): shippable usbip/vhci_hcd virtual Deck + client leave-shortcuts
apple / screenshots (push) Has been cancelled
android / android (push) Has been cancelled
apple / swift (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, 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
Steam Deck pass-through (design/steam-deck-passthrough-plan.md), code-complete +
all CI checks green on Linux + adversarially reviewed; on-glass validation pending:

- usbip/`vhci_hcd` virtual Deck transport (inject/linux/steam_usbip.rs) for
  non-SteamOS hosts (Bazzite/generic) — presents a real interface-2 USB Deck so
  Steam Input promotes it. In-process vhci attach (loopback OP_REQ_IMPORT handshake
  → sysfs attach) with a bounded `usbip`-CLI fallback; detach on drop.
- Backed by a vendored, libusb-free trim of the `usbip` crate
  (crates/punktfunk-host/vendor/usbip-sim, MIT + NOTICE; host/cdc/hid + rusb/nusb
  removed; interrupt-IN paced by bInterval).
- Selection ladder raw_gadget (SteamOS fast-path) → usbip (universal) → UHID,
  with PUNKTFUNK_STEAM_USBIP / PUNKTFUNK_USBIP_ATTACH knobs.
- Shared Deck descriptors + the 0x83/0xAE feature contract + a Steam-accepted
  serial consolidated into steam_proto.rs; the raw_gadget backend reuses them.
- Linux client leave-shortcuts: Ctrl+Alt+Shift+D + holding the escape chord
  (L1+R1+Start+Select) >=1.5s end the session (short press still exits
  fullscreen); the chord state resets across sessions.

Also bundles in-progress work already staged in the tree:
- host(kwin): xdg-output logical-geometry mapping so the KWin fake_input backend
  places absolute coordinates correctly under display scaling.
- docs: design/README index entries + design/controller-only-mode.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 831b37b4b7 build: exclude the usbip-poc from the workspace (standalone PoC, pulls libusb)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 4f0b4aa68f docs(steam): production plan for Deck client pass-through + shippable usbip host
Write design/steam-deck-passthrough-plan.md — the build plan to ship exact Steam
Deck pass-through from the Linux client (incl. the Steam + QAM buttons) plus a
virtual Deck on any Linux host. Key validated facts captured so the next session
doesn't re-investigate:

- Client capture is ALREADY correct: SDL3 maps Steam->Guide, QAM->Misc1; the
  client forwards BTN_GUIDE/BTN_MISC1; the host maps them to btn::STEAM/btn::QAM.
  Only precondition: Steam Input disabled on the client (the Decky UX).
- Shippable host transport = usbip + vhci_hcd (in-tree + signed everywhere, no
  module build, no MOK) — PROVEN on Bazzite: Steam promotes the usbip interface-2
  Deck (XInput slot + X-Box pad), identical to raw_gadget on SteamOS.
- Build steps: refactor steam_gadget.rs into shared Deck-logic + a transport
  trait; add the usbip transport (vendor-trim the usbip crate to drop rusb/libusb,
  in-process vhci attach); transport-select raw_gadget->usbip->UHID/DualSense;
  client leave-shortcut (controller chord + Ctrl+Alt+Shift+D); serial polish.

Also checks in the working usbip Deck PoC (packaging/linux/steam-deck-gadget/
usbip-poc/) for the next session to build on. Not pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 963c406f33 feat(host/steam): default the gadget Deck on for SteamOS (glass-confirmed)
The virtual Steam Deck is validated glass-to-glass on a Deck: it appears as a
distinct second Steam controller, a held A drives Steam's overlay ("Resume
Game"), and a button press registers in a real game (confirmed in-game).

gadget_preferred() now defaults ON for SteamOS hosts (/etc/os-release ID=steamos
or ID_LIKE), OFF elsewhere where the universal UHID path stays the default;
PUNKTFUNK_STEAM_GADGET=1/0 forces it. A Deck-as-host with a physical Deck never
reaches this path — resolve_gamepad's conflict gate degrades SteamDeck → DualSense
first, so the two-Deck case never happens in production (it was only a test-rig
confound on the dev Deck).

The feature is complete: a virtual Steam Deck that Steam Input recognizes +
promotes, churn-free, with input flowing to games. Workspace clippy/fmt/test
green. Not pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 7ab8acaf55 feat(host/steam): harden the gadget feature contract — fixes the evdev churn
The virtual Deck's gamepad evdev was churning (destroyed + recreated) because
Steam kept re-probing: GetControllerInfo reads HID feature reports, and the gadget
served zeros for them. Captured the real contract off a physical Deck
(packaging/linux/steam-deck-gadget/get_deck_attrs.c, hidraw HIDIOCGFEATURE — usbmon
truncates to 32B) and implemented it in steam_gadget.rs::feature_reply:

- 0x83 GET_ATTRIBUTES_VALUES: [83, 2d, 9×(attr-id, u32-LE)] — product id 0x1205, a
  per-instance unit serial (0x0a/0x04, so a gadget never collides with a real Deck
  or another gadget), and the capability attrs (0x09=0x2e, 0x0b=0x0fa0, rest 0).
- 0xAE GET_STRING_ATTRIBUTE: [ae, len, attr, ascii] — serial (attr 1) / board
  serial (attr 0).
- other commands (0x87 settings): echo the last write.

Validated on the Deck: 1 connect / 0 disconnect / 1 gamepad evdev (was constant
churn), Steam activates the gadget cleanly (no GetControllerInfo failed, no zombie)
and emits its X-Box 360 pad. usbmon on the gadget's bus confirms our state reports
(pressed button at byte 8) are delivered on the interrupt-IN and consumed by
hid-steam — so with M1/M2's byte-8→BTN_SOUTH decode the input chain is proven
end-to-end. Remaining: a foreground-game confirmation of Steam Input's XInput
mapping, then default the gadget on for SteamOS.

Workspace clippy/fmt/test green. Not pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler c8e19396e4 feat(host/steam): raw_gadget Deck host backend (Steam-Input path, opt-in)
Port the proven raw_gadget virtual Deck to a Rust host gamepad backend, the
SteamOS-only transport that gets Steam Input to actually promote the Deck.

- inject/linux/steam_gadget.rs (new): SteamDeckGadget — a userspace raw_gadget
  emulator of the real 3-interface USB Deck (mouse=0/keyboard=1/controller=2,
  28DE:1205) on a dummy_hcd loopback UDC, descriptors captured from a physical
  Deck, answering every control transfer incl. the HID feature reports. Driven by
  the same steam_proto::serialize_deck_state as the UHID pad; rumble feedback via
  parse_steam_output. The raw_gadget UAPI is funneled through 4 documented ioctl
  wrappers (the crate denies undocumented unsafe).
- inject/linux/steam_controller.rs: the manager pad is now a DeckTransport enum
  (Uhid | Gadget); ensure() prefers the gadget when PUNKTFUNK_STEAM_GADGET=1
  (best-effort modprobe dummy_hcd+raw_gadget), gracefully falling back to the
  universal UHID SteamDeckPad. write/pump/heartbeat dispatch through the enum.

Validated on a real Deck via a static musl harness that #[path]-includes the
module: enumerates, hid-steam binds + reads our serial + creates the Steam Deck +
Motion Sensors evdevs — identical to the C PoC. Caught a real portability bug:
raw_gadget's no-arg ioctls (RUN/CONFIGURE/EP0_STALL) reject a non-zero `value`
with EINVAL, and on musl an omitted ioctl vararg is a garbage register — so they
must pass an explicit 0.

Opt-in (default off) while the Steam GetControllerInfo feature contract is
hardened (to stop the gamepad-evdev churn). Workspace clippy/fmt/test green. Not pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 78020cd66c docs(steam): gadget input-flow status — reports delivered + format-validated
On the Deck, a pressa build shows hid-steam polls our interface-2 interrupt-IN
endpoint and our 64-byte state reports are delivered ("STREAM: first input report
delivered"). The report format is already validated (M1 serializer on-box + M2's
EVIOCGKEY/EVIOCGABS test on the same hid-steam decode). The "Steam Deck" gamepad
evdev forms but is transient (hid-steam recreates it as gamepad_mode toggles —
Steam keeps re-probing because the PoC serves the serial but not Steam's full
GetControllerInfo attribute set, on a heavily-churned test Deck), so a stable live
EVIOCGKEY catch of the held A wasn't obtained. Delivery + format proven; the
evdev transience is a feature-report-completeness gap the host backend resolves.
Doc §11. Not pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 8870e85233 feat(steam): raw_gadget virtual Deck — full Steam Input recognition (proven on Deck)
The interface-2 wall is climbed. packaging/linux/steam-deck-gadget/deck_raw_gadget.c
is a raw_gadget userspace emulator of a real 3-interface USB Steam Deck (28DE:1205,
mouse=0/keyboard=1/controller=2) on a dummy_hcd loopback UDC, with descriptors
captured verbatim from a physical Deck and full HID feature-report handling.

Live on a real Deck (SteamOS 3.8.11): hid-steam reads our serial (PFDECK000),
creates the Steam Deck + Motion Sensors evdevs, and Steam Input PROMOTES it —
controller.txt "Interface: 2 ... device opened ... reserving XInput slot 1" +
"input: Microsoft X-Box 360 pad 1". Stable (1 connect, 0 disconnects, no zombie);
the kernel Steam Deck evdev is then grabbed by Steam Input which exposes its own
X-Box pad, exactly like a real Deck. First time a virtual Deck is fully Steam-Input
promoted (UHID can't — it has no USB interface number, so Steam filters it).

Also includes the configfs f_hid variant (configfs_gadget_up/down.sh) — the minimal
reproducer that proved interface 2 makes Steam open+XInput-reserve the device, but
f_hid can't serve feature reports so Steam dropped it as a zombie.

Gotchas documented in the README: 7-byte vs 9-byte endpoint descriptor, no-data OUT
controls acked via zero-length EP0_READ (not WRITE, else error -110), streamer must
not start before SET_CONFIGURATION is acked. SteamOS-host only (needs dummy_hcd +
raw_gadget). Recognition proven; feeding real client reports + a host backend is next.
Not pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler a81f1304cd docs(steam): gadget PoC — interface 2 PROVEN (Steam opens + XInput-reserves it)
On the Deck (which ships dummy_hcd + raw_gadget + configfs f_hid), a pure-shell
configfs gadget stood up a real 3-interface USB Deck (kbd=0/mouse=1/controller=2,
28de:1205) on a dummy_hcd loopback UDC. hid-steam bound all 3 interfaces, and
crucially Steam PROMOTED the interface-2 controller: "Local Device Found ...
Interface: 2 ... Steam controller device opened for index 14 ... Steam Controller
reserving XInput slot 1" — exactly where the interface -1 UHID Deck was filtered.

It then failed only at feature-report exchange (f_hid can't serve HID GET_REPORT:
"steam_send_report: error -32", "couldn't get controller details ... zombie
controller"), and no gamepad evdev formed for the same reason. So interface 2 is
necessary AND sufficient for Steam to open+XInput-reserve the Deck; the remaining
piece is serving feature/output reports, which raw_gadget can (full control,
like UHID). Next: a raw_gadget 3-interface Deck emulator. Doc §11. Not pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler c75f39fd8e docs(steam): VALIDATED — virtual DualSense IS Steam-Input recognized; isolates the Deck wall to interface 2
Definitive hardware test (Bazzite running Steam): a virtual DualSense (UHID,
054c:0ce6, Interface: -1) is FULLY promoted by Steam — controller.txt logs
"Local Device Found 054c 0ce6 DualSense Wireless Controller", then "Controller
using HIDAPI driver vid=0x054c pid=0x0ce6" and loads configset_controller_ps5.vdf
(our calibration/pairing/firmware feature blobs read back). The SAME Interface:
-1 that the Deck is rejected at is accepted for the DualSense.

So the wall is specifically the Deck's MULTI-INTERFACE requirement (Steam must
pick interface 2 among kbd/mouse/controller), NOT a UHID limitation. The
DualSense path delivers real Steam Input (gyro + touchpad + glyphs + bindings)
for a streamed Deck/SC client; it loses only Deck glyphs, the 2nd trackpad, and
the 4 back grips as distinct Steam-Input paddles (M5 folds them to buttons).

Full Deck-identity Steam Input would need interface 2 -> a USB gadget (dummy_hcd
+ configfs HID, controller on interface 2). Feasible but heavy/non-portable:
dummy_hcd isn't built on Bazzite/Deck/dev-box, so it'd be a per-kernel build +
(on immutable SteamOS/Bazzite) a package-layer + reboot per host.

Doc-only (design §11). Not pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 37c3e2bed2 docs(steam): criterion-4 RESOLVED — the interface-2 ceiling; recommend dropping M7
Hardware finding (a SteamOS Deck @ .253 + a Bazzite host @ .41, both running
Steam, via a minimal C UHID probe on Bazzite): a UHID virtual Steam Deck binds
the kernel hid-steam and creates the evdevs (so kernel-evdev + SDL-hidapi
consumers see the full grips/trackpads/IMU surface), but Steam Input will NOT
manage it. Steam's controller.txt enumerates it ("Local Device Found, 28de 1205,
Product Punktfunk Steam Deck") but logs Interface: -1 and never promotes it (no
28de:11ff XInput pad). The physical Deck on the same logs is Interface: 2 — a
real Deck is a 3-interface USB device (kbd 0 / mouse 1 / controller 2) and Steam
binds the controller on interface 2; a single UHID device has no USB interface
number, so Steam reads -1 and filters it out. (The feared 0x83/0xA1 attribute
probes never fired — it's an interface filter, not a probe-reject.)

Consequences (design §11):
- The virtual Deck's value is non-Steam / SDL games on Linux (grips + trackpads
  + gyro via evdev / SDL HIDAPI), NOT Steam Input.
- The virtual DualSense stays the Steam-Input path everywhere (Steam recognizes
  a single-interface DualSense); M5's paddle-fold carries the back grips.
- M7 (a Windows UMDF virtual Deck) is NOT recommended: same interface filter,
  and Windows has no kernel-hid-steam evdev fallback, so nothing would consume
  it; the existing Windows virtual DualSense already covers that case.
- M0-M6 is not wasted: the protocol/wire + client capture feed the DualSense
  path too, and the virtual Deck is the best option for non-Steam Linux games.

Doc-only (design/steam-controller-deck-support.md): added §11, updated the status
+ pending-validation. Not pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 4f40fa3cb7 feat(host/steam): M6 — Steam-pad conflict gate (hardware-validated)
Don't present a virtual Steam (28DE) pad on a host that already has a physical
Steam controller — the host's own Steam Input would then manage two Decks and
confuse player assignment.

- physical_steam_controller_present(): scans /sys/bus/hid/devices for a 28DE HID
  device on a real (non-/virtual/) path.
- degrade_steam_on_conflict() in resolve_gamepad: a resolved SteamDeck /
  SteamController with a physical Steam controller attached degrades to DualSense
  (then the M5 uhid ladder); PUNKTFUNK_STEAM_FORCE=1 overrides (e.g. a remote-only
  box with no competing Steam Input).

Validated on real hardware (a SteamOS Steam Deck @ .253 + a Bazzite host @ .41,
both running Steam):
- Conflict confirmed: the Deck-as-host already has its physical 28DE:1205 AND
  Steam's 28DE:11FF XInput output pad live; a 2nd virtual 28DE = two Decks.
- Bind robustness: the virtual Deck binds hid-steam on a SECOND kernel (Bazzite
  6.17.7, vs the dev box 7.0) and the kernel accepted our serial (the M1 fix).
- Criterion-4 (running-Steam recognition) PARTIAL: a userspace consumer (Steam/
  SDL) engaged the virtual Deck (opened the hidraw, ran the lizard-disable +
  settings sequence the kernel's Deck path skips) but emitted NO 28DE:11FF XInput
  pad on the desktop — so Steam recognizes it enough to manage lizard mode but did
  not promote it to a managed XInput controller (likely needs a Big-Picture/game
  context, or a richer device; the 0x83/0xA1 attribute probes never fired, so it
  wasn't a probe-reject either).
- The heuristic itself checks TRUE on the Deck, FALSE on Bazzite.

Workspace clippy/fmt/test green. Not pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 486a292845 feat(host/steam): M5 — fallback remap, motion rescale, degrade ladder
Keep the rich Steam inputs from silently dropping when the resolved backend
isn't the virtual hid-steam device, and fix a cross-device motion-scale bug.

- inject/proto/steam_remap.rs (new, pure + unit-tested):
  * motion_wire_to_deck — the wire carries DualSense-convention units (20 LSB/
    deg.s gyro, 10000 LSB/g accel — what every client capture emits), but the
    Deck's hid-steam report wants 16 LSB/deg.s + 16384 LSB/g. The Deck backend
    now rescales (gyro x16/20, accel x16384/10000): a real Deck<->Deck gyro/
    accel correctness fix (the DualSense/DS4 backends consume the wire 1:1).
  * fold_paddles + RemapConfig (PUNKTFUNK_STEAM_REMAP=paddles=drop|stickclicks|
    shoulders, default drop) — the DualSense + DS4 managers fold a client's back
    grips onto standard buttons rather than dropping them (those pads have no
    back-button HID slot; the uinput Xbox pad already exposes them as Elite
    paddles BTN_TRIGGER_HAPPY5-8).

- resolve_gamepad: a runtime degrade ladder — a UHID backend (DualSense / DS4 /
  Steam Deck) on a host where /dev/uhid isn't writable now falls back to the
  uinput Xbox 360 pad instead of a dead controller (the device-create would
  just fail). Separate from pick_gamepad's compile-time platform check, so the
  existing pick_gamepad tests are untouched.

- Delete the throwaway M0/M1 spike (src/bin/steam_uhid_spike.rs) — M2's
  #[ignore]d backend test subsumes its validation, and removing it frees
  steam_proto to reference steam_remap cleanly.

On-box backend test still green; workspace clippy/fmt/test green (incl. the new
steam_remap tests). Deferred as optional RemapConfig growth: gyro->mouse /
trackpad->stick synthesis on an Xbox target (no slot — documented drop today).
Not pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler d8c254281e feat(steam): M4 complete — C-ABI send path, Decky UX, Apple/Android parity
Finish the client side of the Steam Controller / Steam Deck pipeline.

- C-ABI (core abi.rs): PunktfunkRichInputEx — a size-prefixed superset of
  PunktfunkRichInput that can express the second trackpad (surface), a distinct
  click vs touch, signed coords + pressure — plus
  punktfunk_connection_send_rich_input2 (the struct_size ABI-skew-guard
  precedent). The only way a C client (Apple/embedders) can emit a TouchpadEx;
  the legacy struct + send_rich_input stay byte-for-byte. punktfunk_core.h
  regenerated.

- Decky (clients/decky): a "Steam Deck" gamepad type in Settings + an unmissable
  Disable-Steam-Input instruction shown when it's selected (in Game Mode Steam
  Input holds 0x1205, so the SDL HIDAPI Steam driver can't open the Deck's
  controls until the user disables Steam Input for the shortcut). Plus a
  best-effort, feature-detected disableSteamInputForShortcut() in launchStream —
  never blocks/throws; the manual toggle is the documented source of truth.

- Apple parity (PunktfunkConnection.swift): GamepadType.steamController/steamDeck
  (wire 5/6) + name parsing, so the resolved type round-trips. Capture is blocked
  (GameController never surfaces a 0x28DE HID device).

- Android parity (Gamepad.kt): PREF_STEAMCONTROLLER/STEAMDECK + the Valve 0x28DE
  PIDs in prefFor(). Rich-input capture stays out of scope (no rich-input plane
  yet) — standard buttons/sticks resolve to the host's Steam Deck pad.

Rust workspace clippy/fmt/test green; Decky src/ typechecks clean (only a
pre-existing @decky/api dep resolution error remains); Swift/Kotlin compile on
their CI. The full pipeline is now BUILT; what remains is validation that needs
hardware we don't have (a running Steam on the host, a live Deck client, the
Moonlight paddle regression). Not pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler ae71e4628d feat(clients/steam): M4 — desktop SDL clients capture the rich Steam inputs
The Linux + Windows native clients (clients/{linux,windows}/src/gamepad.rs) now
capture and send the Steam Controller / Steam Deck rich inputs, so a real Deck
(off Steam Input) or a Steam Controller on a desktop client drives the host's
virtual hid-steam pad end-to-end:

- Set SDL's HIDAPI Steam hints (SDL_JOYSTICK_HIDAPI_STEAMDECK / _STEAM) before
  init so SDL opens Valve devices directly (paddles + both trackpads + gyro as
  first-class SDL gamepad inputs).
- Detect the Deck/SC by VID/PID (0x28DE + 0x1205 / 0x1102 / 0x1142) ->
  GamepadPref::SteamDeck (there is no SDL gamepad type for it), so the host
  builds the virtual Deck with the right identity.
- Map the SDL paddle + Misc1 buttons -> BTN_PADDLE1..4 / BTN_MISC1 (a free win
  for Xbox Elite paddles too).
- Route a SECOND touchpad -> RichInput::TouchpadEx (SDL touchpad 0 = left ->
  surface 1, 1 = right -> surface 2, signed coords); a single touchpad keeps the
  legacy Touchpad. New forward_touch() helper centralizes the choice.
- Track held touchpad contacts per (surface, finger) and lift them on pad
  switch/detach so a contact held at that moment can't stick.
- Sensor (gyro/accel) capture was already generic across pad types.

Linux client builds + clippy clean; the Windows client is a near-verbatim
mirror (windows CI compiles it). On a Deck in Game Mode, Steam Input still holds
the device — the user disables Steam Input for the client (the Decky UX, next);
on a desktop client (or a Deck with Steam Input off) the hints just work.

Remaining M4: Decky Disable-Steam-Input UX, Apple/Android parity, and the C-ABI
PunktfunkRichInputEx + send_rich_input2 (Apple/embedder send path). Not pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 01c55aed38 feat(proto/steam): M3 — rich Steam wire (back buttons + 2nd trackpad)
Carry the rich Steam Controller / Steam Deck inputs end-to-end on the wire —
strictly additive + forward-compatible (unknown kinds/bits drop on old peers).

Core (punktfunk-core):
- input.rs: BTN_PADDLE1..4 + BTN_MISC1 in Moonlight's buttonFlags2<<16 namespace
  (so the GameStream paddle path and native grips share one host injector map;
  Steam L4/L5/R4/R5 reuse the four Xbox-Elite paddle slots).
- quic.rs: RichInput::TouchpadEx (kind 0x03 — surface 0/1/2, touch+click, signed
  coords, pressure; the second trackpad the single Touchpad can't express) and
  HidOutput::TrackpadHaptic (kind 0x04 — the SC voice-coil pulse). Round-tripped.
- abi.rs: PUNKTFUNK_GAMEPAD_STEAMDECK=6 / _STEAMCONTROLLER=5, the paddle bits,
  RICH_TOUCHPAD_EX / HIDOUT_TRACKPAD_HAPTIC constants. from_hid packs
  TrackpadHaptic into the existing which + effect[0..6] — the legacy structs do
  NOT grow (guarded by new size_of==20/19 asserts); GamepadPref lockstep +
  paddle-bit lockstep asserts extended. include/punktfunk_core.h regenerated.

Host (punktfunk-host):
- steam_proto::from_gamepad maps the wire paddles -> the four Deck grips + QAM;
  apply_rich routes TouchpadEx left/right -> the matching pad.
- every DualSense/DS4 manager (Linux + Windows) gained a TouchpadEx arm
  (surface 0/2 -> its one touchpad; surface 1 ignored) so the variant compiles
  everywhere and a Steam client streaming to a DS host keeps its right pad.
- the xpad BUTTON_MAP finally consumes the GameStream paddle bits
  (BTN_TRIGGER_HAPPY5-8) — Sunshine/Moonlight paddle clients were silently
  no-op'd before (design §5.6).
- Android feedback: drop TrackpadHaptic (no coils; rumble rides 0xCA).

Validated on-box: the ignored backend test now drives the full wire path —
from_gamepad (BTN_A + the L4 grip) + apply_rich (a left-pad TouchpadEx) reach the
evdev as BTN_A + ABS_HAT0X=-8000. Wire round-trips + paddle/TouchpadEx mapping
unit-tested. Workspace clippy/fmt/test green. Not pushed.

Deferred to M4: the C-ABI PunktfunkRichInputEx + send_rich_input2 (only the
Apple/embedder *send* path needs it; the host decodes TouchpadEx today).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 95308d352b feat(host/steam): M2 — virtual Steam Deck as a wired PadBackend (Linux)
Make the virtual hid-steam device a selectable per-session host gamepad,
end-to-end on Linux: PUNKTFUNK_GAMEPAD=steamdeck now builds a
SteamControllerManager that creates a /dev/uhid 28DE:1205 Deck, enters
gamepad_mode, and feeds the byte-exact Deck report (M1).

- inject/linux/steam_controller.rs: SteamControllerManager / SteamDeckPad,
  mirroring dualsense.rs (open/create2, GET/SET_REPORT pump, heartbeat, RAII
  destroy). Two Steam-specific quirks beyond the DualSense path:
    * gamepad_mode entry — best-effort `lizard_mode=0` via sysfs, plus a b9.6
      creation pulse (MODE_ENTER) so steam_do_deck_input_event stops
      early-returning, plus an anti-toggle guard (MENU_HOLD_CAP) so a long
      in-game Start-hold can't flip gamepad_mode back off.
    * UHID_SET_REPORT answered err=0 (DualSense omits it; the kernel stalls
      ~5s/cmd otherwise); the 0xEB rumble report parsed onto the 0xCA plane.
- core config.rs: GamepadPref::SteamDeck (wire byte 6) + SteamController
  (byte 5, reserved — folds to Xbox360 until its backend lands); from_u8 /
  from_name / as_str. Forward-compatible (unknown byte -> Auto); the C-ABI
  PUNKTFUNK_GAMEPAD_* constants stay M3, so no generated-header drift.
- punktfunk1.rs: PadBackend::SteamDeck variant + select / handle / apply_rich
  / pump / heartbeat arms; pick_gamepad Linux arm.

On-box: an #[ignore]d backend test (backend_binds_and_input_flows) drives the
real SteamDeckPad — it binds hid-steam (gamepad + IMU evdevs), enters gamepad
mode, BTN_A reaches the evdev, and the device tears down on drop. Workspace
clippy/fmt/test green. Not pushed. Next: M3 (protocol/ABI wire) + M4 (client
capture).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 9ff7d41bfe feat(host/steam): M1 — byte-exact Deck input serializer, on-box validated
Flesh out inject/proto/steam_proto.rs into the full Steam Deck HID contract,
transcribed verbatim from the kernel steam_do_deck_input_event /
steam_do_deck_sensors_event and validated field-for-field against kernel 7.0:

- SteamState: the u64 button map (bytes 8..16), sticks/triggers/trackpads/IMU
  stored as raw little-endian report values; serialize_deck_state is a pure,
  byte-exact memcpy into the 64-byte unnumbered frame.
- from_gamepad (XInput frame -> Deck buttons/sticks/triggers) + apply_rich
  (RichInput touchpad -> right pad, motion -> IMU).
- parse_steam_output: the 0xEB ID_TRIGGER_RUMBLE_CMD feedback -> (low, high)
  for the universal rumble plane.
- serial_reply fixed: prepend the report-id-0 byte the kernel strips
  (steam_recv_report does memcpy(data, buf+1, ...)); M0's reply lacked it, so
  the kernel fell back to the "XXXXXXXXXX" serial.
- SteamModel (Deck now; classic Controller later), command/feature IDs.

The spike is repurposed as the M1 validator: it pulses the b9.6 mode-switch to
enter gamepad_mode (steam_do_deck_input_event early-returns under the default
lizard_mode otherwise), then holds a known test pattern. Reading both evdevs via
EVIOCGABS/EVIOCGKEY, every field matched: ABS_X/Y/RX/RY (incl. the kernel
Y-negation), both triggers, the touched right-pad HAT1X/Y, the IMU accel/gyro
(with ABS_Z/RZ negations), and the 6 expected buttons incl. the L4/R5 grips.

5 unit tests + workspace clippy/fmt/test green. Next: M2 (SteamControllerManager
UHID backend + PadBackend wiring). Not pushed — pipeline not yet shippable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 2b47d8cc28 feat(host/steam): M0 — virtual hid-steam UHID device binds + parses (Linux)
Greenfield virtual Steam Deck controller, the Steam analogue of the shipped
virtual DualSense. Proves the kernel hid-steam driver binds a /dev/uhid
28DE:1205 device, registers it as a real Steam Deck, and parses our input
reports — the go/no-go gate for the full Steam Controller/Deck pipeline.

- inject/proto/steam_proto.rs: keeper module — the vendor HID descriptor (one
  feature report, the sole thing steam_is_valve_interface() checks), the
  command/feature IDs, serialize_deck_state, and the serial GET_REPORT reply.
  Unit-tested.
- src/bin/steam_uhid_spike.rs: throwaway M0 spike (Linux-only) — opens
  /dev/uhid, creates the device, services the handshake including
  UHID_SET_REPORT (which the DualSense backend omits and which hid-steam
  stalls ~5s/cmd without), and heartbeats a neutral report.
- design/steam-controller-deck-support.md: full design + M0–M7 plan; the two
  walls (Steam Input capture ownership; virtual-Steam recognition) and the
  fidelity ceiling. Status: M0 GREEN.

On-box (headless Ubuntu 26.04, kernel 7.0, no Steam): journalctl -k shows
hid-steam binding the device (rebind off hid-generic), "Steam Controller
connected", and the kernel creating BOTH a "Steam Deck" gamepad evdev and a
"Steam Deck Motion Sensors" IMU evdev (INPUT_PROP_ACCELEROMETER). A
layout-agnostic mash-probe drove 23 distinct BTN_* codes through
hid-steam -> evdev, proving the input-report parse path. M1 line-checks the
exact per-bit report layout against the lab kernel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 7cd9364c9e style(host): rustfmt the #9/#13 pairing edits
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 3e498cd40d docs(security): #9/#13 fixed, S7 rationale corrected (red-team follow-up)
17/18 now fixed. A red-team of the three accepted findings showed #9 and #13
rested on a circular premise (each was the other's "safe fallback") and S7's
written rationale was wrong (signing exercises the same modexp Marvin targets).
#9/#13 closed; S7 accept retained for the corrected reasons + amplifier hardened.
See f0574a5, f6c9576.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 60de506f66 fix(host/gamestream): correct the rsa-Marvin (S7) rationale + cap pairing signatures
Red-team found the .cargo/audit.toml justification for RUSTSEC-2023-0071 was
materially wrong: it claimed "Marvin targets decryption, so the vulnerable path
isn't exercised" — but the advisory is a variable-time modexp of the secret
exponent, which RSA *signing* (signing_key.sign) also runs. The accept is still
correct, for the RIGHT reasons (no decryption/padding oracle; the signed
serversecret is host-random not attacker-chosen; signing is operator-PIN-gated;
GameStream is off by default and the native QUIC plane uses rustls, not rsa;
Moonlight mandates RSA-2048 so the GameStream key can't move off it). Rewrite
the rationale accordingly.

Also shut the timing-sample amplifier the review surfaced: the pairing session
was never marked after phase 3, so a peer past phase 1 could loop phase2/phase3
to harvest many RSA signing-time samples. Sign exactly once per ceremony
(reject a repeated serverchallengeresp).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 2865368771 fix(host/pairing): close native-pairing DoS findings #9 + #13 (red-team follow-up)
The accepts for #9 (PIN-window burn) and #13 (knock-queue flood) rested on a
circular premise — each cited the other as the safe fallback — and a re-review
showed one LAN attacker could defeat BOTH, denying all onboarding. Close them:

- #13 per-source-IP cap on the pending-knock queue (MAX_PENDING_PER_IP) so one
  host can't fill/evict the 32-slot queue (QUIC validates the source address);
  and eviction now NEVER drops a live *parked* knock (a held-open connection
  awaiting operator approval), so a cert-rotating flood can't evict the genuine
  device being onboarded. This makes the delegated-approval path genuinely
  flood-resistant — restoring the validity of #9's "use delegated approval on
  hostile LANs" fallback.

- #9 fingerprint-bindable PIN window: `NativePairing::arm_for(ttl, Some(fp))`
  binds the window to one operator-selected device; `pin_for_attempt` returns
  `BoundToOther` for any other fingerprint, which the QUIC pair path rejects
  WITHOUT consuming the window — so an unpaired peer can neither pair nor BURN a
  window armed for a specific device (it can't forge the bound fingerprint). The
  mgmt `POST /native/pair/arm` gains an optional `fingerprint` (from a pending
  knock); unbound arming keeps the legacy any-device behavior (trusted-LAN).
  (Web-console "pair this pending device with a PIN" UX is a follow-up; the
  flood-resistant knock path above is the immediate hostile-LAN onboarding path.)

+ regression tests (armed_pin_is_fingerprint_bindable,
  pending_per_ip_cap_and_parked_protection); api/openapi.json regenerated.
110 host tests + clippy + fmt green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 6e2e946bc9 docs(security): #5 fixed + on-box validated (RTX box, 2026-06-29)
15/18 now fixed; no finding remains open and actionable. SDDL scoped to
SYSTEM+LocalService, validated live (6943-frame DualSense+IDD session works;
non-SYSTEM OpenFileMapping now ACCESS_DENIED). See e59fa60.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler b5f02000d6 fix(host/security): scope Windows shared-section SDDL to SYSTEM+LocalService (close #5)
The gamepad host<->UMDF-driver shared sections (Global\pfds-shm-*, pfxusb-shm-*)
and the IDD-push frame ring/event (Global\pfvd-*) were created with
`D:(A;;GA;;;WD)` — GENERIC_ALL to **Everyone** — on the assumption the driver's
WUDFHost ran under a restricted token needing broad access. So any local
unprivileged user could OpenFileMapping the section to inject controller input,
tamper the trusted HID channel, or read captured screen frames
(security-review 2026-06-28 #5).

On-box validation (RTX box, 2026-06-29) disproved the restricted-token premise:
the WUDFHost token is NT AUTHORITY\LocalService (S-1-5-19), SYSTEM integrity,
with ZERO restricted SIDs. So the section only needs SYSTEM (the host creates +
writes it) and LocalService (the driver opens it). Scope both SDDL sites to
`D:(A;;GA;;;SY)(A;;GA;;;LS)`; rename the now-misnamed `permissive_sa` ->
`shared_object_sa`; correct the stale "restricted-token / Everyone" docs.

Validated live: a full DualSense + 1280x720x60 session — 6943 frames received,
HID output round-tripped, device status OK (pf_dualsense + pf_vdisplay WUDFHosts
both LocalService open the scoped sections fine), while OpenFileMapping from a
non-SYSTEM admin session now returns ACCESS_DENIED (was a granted handle under
WD). Host-only change (the SDDL is set when the host CREATES the section);
drivers unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler fe562f0562 chore(apple): rename app display name to "Punktfunk"
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
release / apple (push) Has been cancelled
android / android (push) Has been cancelled
ci / rust (push) Has been cancelled
decky / build-publish (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
CFBundleDisplayName was "Punktfunkempfänger" across all targets/configs; the
in-app title is already "Punktfunk", so make the home-screen name match. Built
iOS app resolves CFBundleDisplayName = "Punktfunk".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:34:16 +02:00
enricobuehler 4e00037a89 feat(apple): stage-2 default + pixel-perfect, decode robustness, UI/rumble polish
apple / swift (push) Successful in 1m4s
android / android (push) Successful in 4m33s
ci / rust (push) Successful in 5m4s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 59s
deb / build-publish (push) Successful in 3m12s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
release / apple (push) Successful in 8m30s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
ci / bench (push) Successful in 4m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m48s
apple / screenshots (push) Successful in 5m43s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m24s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m46s
Stream reliability
- Default to the stage-2 presenter (VTDecompressionSession + CAMetalLayer): it detects
  and recovers a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard
  on a lost HEVC reference frame with no app-side recovery (confirmed Apple limitation).
  Stage 1 is now a DEBUG-only presenter toggle, plus the automatic no-Metal fallback.
- Stage-2 pixel-perfect: render the drawable at the decoded size (shader stays 1:1 =
  identity) and let the layer's contentsGravity scale via the system compositor — the
  same path stage-1's videoGravity used — instead of scaling in-shader.
- Loss recovery in both pumps is now a persistent awaitingIDR want, retried until an IDR
  actually lands, so a keyframe request swallowed by the throttle can't strand a frozen
  frame; 100 ms keyframe throttle to match the Android path.
- Fix "Publishing changes from within view updates": defer the HostStore writes out of
  the .onChange(of: model.phase) callback.
- Move AVAudioSession setActive/setCategory off the main thread (async on a shared serial
  queue) to stop the UI-stall warning.

Controllers
- Rumble: capped-exponential backoff when the gamecontrollerd.haptics XPC breaks (-4811)
  so a transient server interruption self-heals instead of cascading; playsHapticsOnly so
  a controller engine doesn't join the always-active streaming audio session.
- Host cards: iPad pointer "magnet" hover effect; iPhone press scale + light haptic.

UI / design
- Ship Geist (SIL OFL 1.1) as the app font (bundled OTFs + registration), with the
  license surfaced in Acknowledgements.
- Restructure iOS/iPadOS Settings into a category NavigationSplitView; resolution wheel
  with custom-resolution entry; 10-bit HDR toggle in Display.
- Industrial host-card redesign (left-aligned, bold, brand monogram tiles).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:26:10 +02:00
enricobuehler 46b9aa8cf0 fix(windows-host): IDD activation — resolve-first, force-EXTEND only as fallback
apple / swift (push) Successful in 1m6s
android / android (push) Successful in 4m23s
ci / rust (push) Successful in 5m9s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 57s
apple / screenshots (push) Successful in 5m33s
windows-host / package (push) Successful in 8m46s
deb / build-publish (push) Successful in 3m13s
decky / build-publish (push) Successful in 27s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
ci / bench (push) Successful in 4m51s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m15s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m19s
docker / deploy-docs (push) Successful in 21s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m44s
force_extend_topology() was added before the resolve loop to de-clone a fresh IDD on
integrated-screen boxes (laptops), but its bare SDC_TOPOLOGY_EXTEND preset is
ACCESS_DENIED from the Session-0 service context on a HEADLESS box and broke the IDD
auto-activation there: resolve_gdi_name stayed None -> "not an active display path" ->
black screen. That regressed the headless/primary platform (live RTX box).

Revert to the proven e2c9bfd flow: resolve FIRST (Windows auto-activates the IDD as its
own extended path), and force-EXTEND only as the FALLBACK when resolve returns None (the
integrated-screen clone case, observed live to leave resolve None). The success path is
byte-identical to e2c9bfd (resolve -> set_active_mode -> isolate_displays_ccd).

Validated live: the headless RTX box streams again (probe: frames flow, driver attaches
to the ring, host/driver render LUIDs match).

Reviewed multi-agent + adversarial: no regression on the validated headless path or the
observed Optimus-laptop clone path (a cloned IddCx target resolves to None there, so the
is_none() fallback fires + de-clones). Known theoretical caveat, documented inline and
unobserved for IddCx but untested across GPU/driver/OS: a CCD clone that manifests as a
shared-source ACTIVE path would resolve to Some and bypass the is_none() gate. Follow-up:
widen the gate (a target_is_cloned helper) once an integrated-screen box is available to
validate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:15:48 +02:00
enricobuehler 372b27540b fix(apple): render Acknowledgements notices in lazy chunks
apple / swift (push) Successful in 1m11s
android / android (push) Successful in 4m19s
ci / web (push) Successful in 51s
ci / rust (push) Successful in 5m14s
ci / docs-site (push) Successful in 58s
release / apple (push) Successful in 7m54s
deb / build-publish (push) Successful in 4m0s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
ci / bench (push) Successful in 5m19s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
apple / screenshots (push) Successful in 5m37s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m26s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m35s
docker / deploy-docs (push) Successful in 18s
THIRD-PARTY-NOTICES.txt is ~885 KB / 16k lines; rendering it in a single
SwiftUI Text overshot the text-rendering height limit — it laid out for ages
and drew blank below the cutoff (only the small punktfunk licenses above it
showed). Split the notices into ~80 line-chunks (<=200 lines / <=18 KB each,
computed once as Licenses.thirdPartyNoticesChunks) and render them in a
top-level LazyVStack so only on-screen chunks lay out and no chunk is tall
enough to clip. Chunking is lossless — rejoining the chunks reproduces the
original byte-for-byte, so no notice text is dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:34:19 +02:00
enricobuehler db4d15bf8b fix(apple): stop the iOS/iPadOS Add Host sheet from scrolling
apple / swift (push) Successful in 1m5s
android / android (push) Successful in 4m15s
ci / rust (push) Successful in 4m56s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 58s
deb / build-publish (push) Successful in 3m11s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
release / apple (push) Successful in 8m32s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m40s
apple / screenshots (push) Successful in 5m44s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m29s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m31s
docker / deploy-docs (push) Successful in 17s
The add-host content is a SwiftUI Form (backed by a scrollable list), so it
bounced/scrolled inside the fixed .height(320) detent even though the three
rows + action button fit exactly. Lock it with .scrollDisabled(true) on iOS
(covers iPadOS); macOS (fixed-size panel) and tvOS (custom rows, no Form) are
untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:12:08 +02:00
enricobuehler 8e24ea9ed7 fix(ci): archive Apple release builds with Automatic signing
The in-app OSS license screens (7591425) added a `resources:` array to the
PunktfunkKit SwiftPM target, which makes SwiftPM emit a resource-bundle target
(PunktfunkKit_PunktfunkKit). A resource bundle is a product type that cannot
carry a provisioning profile, so the explicit PROVISIONING_PROFILE_SPECIFIER
each release.yml archive step set — global on macOS, sdk-scoped on iOS/tvOS —
now lands on it and fails the archive ("does not support provisioning profiles")
on all three platforms. (Before that commit there was no resource bundle, so the
profile was harmless.)

Switch all three archive steps to CODE_SIGN_STYLE=Automatic (development):
Automatic signing assigns a profile only to the app target and leaves the
resource bundle (and the macOS-host SwiftPM macro plugins) alone, and bakes the
sandbox entitlements in. No -allowProvisioningUpdates, so it stays offline and
never cloud-signs (the App-Manager ASC key can't). DISTRIBUTION signing is
unchanged — still manual, in the -exportArchive step (which maps the profile to
io.unom.punktfunk only). Drops the now-unneeded manual signing xcconfigs.

Requires the runner to have a development provisioning profile for
io.unom.punktfunk on each platform (now installed for macOS/iOS/tvOS).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:12:08 +02:00
enricobuehler 73c0125843 fix(mgmt): regenerate api/openapi.json for 0.3.0
apple / swift (push) Successful in 1m6s
android / android (push) Successful in 4m16s
ci / rust (push) Successful in 5m5s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 58s
apple / screenshots (push) Successful in 5m17s
deb / build-publish (push) Successful in 3m11s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
ci / bench (push) Successful in 4m43s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 26s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m25s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m28s
docker / deploy-docs (push) Successful in 17s
The OpenAPI 'info.version' tracks CARGO_PKG_VERSION; the 0.3.0 bump made the
checked-in spec stale (the openapi_document_is_complete_and_checked_in test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 07:54:30 +00:00
105 changed files with 9543 additions and 567 deletions
+20 -6
View File
@@ -13,11 +13,25 @@
[advisories] [advisories]
ignore = [ ignore = [
# rsa "Marvin Attack" a timing sidechannel in RSA *decryption* (PKCS#1 v1.5 padding oracle). # rsa "Marvin Attack" (RUSTSEC-2023-0071): a timing side-channel in the rsa crate's variable-time
# There is NO fixed rsa release (the constant-time rewrite is still unreleased upstream), and rsa # modular exponentiation of the SECRET exponent. IMPORTANT — this affects the RSA private-key op in
# is required for GameStream/Moonlight pairing. Crucially, the host uses rsa ONLY for PKCS#1 v1.5 # general, INCLUDING signing (m^d mod n), which the host DOES perform (gamestream/pairing.rs
# SIGNING / VERIFYING (gamestream/cert.rs + gamestream/pairing.rs: SigningKey / VerifyingKey / # `signing_key.sign(&serversecret)`). It is NOT, as an earlier version of this note wrongly claimed,
# Signer / Verifier) — it never performs RSA decryption, which is the operation Marvin targets. # limited to decryption — so "the vulnerable path isn't exercised" is false; signing exercises it.
# So the vulnerable code path is not exercised. Revisit if a fixed rsa ships or we add RSA decrypt. # We accept it because the attack is not practically reachable here, NOT because the path is unused:
# * No RSA decryption / PKCS#1v1.5 padding oracle exists anywhere (every `decrypt` in the tree is
# AES/AES-GCM), so the classic Bleichenbacher/Marvin chosen-ciphertext oracle is absent.
# * The only signed message (`serversecret`) is HOST-generated random, never attacker-chosen — so
# there's no adaptive chosen-input probing (the lever remote RSA-timing key recovery needs); and
# signing is gated behind the operator-entered pairing PIN, ONE signature per ceremony (a
# repeated phase-3 is rejected — gamestream/pairing.rs — to deny a passive timing-sample harvester).
# * GameStream is OFF by default (bare `serve` is native-only); the secure native QUIC plane uses
# rustls' constant-time backend, NOT the rsa crate. RSA is touched only on the opt-in,
# trusted-LAN GameStream/Moonlight pairing handshake. Moonlight mandates RSA-2048, so the
# GameStream identity cannot move to Ed25519/ECDSA (only the native identity could, and it
# already avoids the rsa crate).
# There is NO fixed rsa release (the constant-time rewrite is still unreleased upstream). Revisit if:
# a constant-time rsa ships (then drop this), the host ever signs an attacker-chosen message with
# this key, or any RSA decryption / key-transport using the private key is added.
"RUSTSEC-2023-0071", "RUSTSEC-2023-0071",
] ]
+40 -48
View File
@@ -207,10 +207,20 @@ jobs:
# (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store. # (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store.
continue-on-error: true continue-on-error: true
run: | run: |
# Separate archive from the Developer ID one above: App Store needs a profile-signed # Separate archive from the Developer ID one above: App Store needs a signed, entitled
# archive (manual signing), not the unsigned-then-codesign DMG path. Same App-Manager # archive that -exportArchive can re-sign for distribution, not the unsigned-then-codesign
# ASC-key constraint as iOS/tvOS — MANUAL signing, NOT -allowProvisioningUpdates # DMG path. Archive with AUTOMATIC signing (development). Why not a manually-specified
# (cloud signing the key can't do). Quit Xcode so it can't prune the dropped profile. # profile (as this step used to do): the in-app license screens added a SwiftPM resource
# bundle (PunktfunkKit_PunktfunkKit), and a resource bundle is a product type that cannot
# carry a provisioning profile — a global PROVISIONING_PROFILE_SPECIFIER (here) or an
# sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support
# provisioning profiles"). Automatic signing assigns a profile only to the app and leaves
# the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox
# entitlements in. No -allowProvisioningUpdates → it stays OFFLINE and never cloud-signs
# (the App-Manager ASC key can't), so the runner must have a macOS *development* profile
# for io.unom.punktfunk installed. DISTRIBUTION signing happens in the export step below
# (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store
# distribution profile that export needs.
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
pkill -x Xcode 2>/dev/null || true pkill -x Xcode 2>/dev/null || true
PROFILE="Punktfunk macOS App Store Distribution" PROFILE="Punktfunk macOS App Store Distribution"
@@ -218,11 +228,10 @@ jobs:
-project "$PROJECT" -scheme Punktfunk \ -project "$PROJECT" -scheme Punktfunk \
-destination 'generic/platform=macOS' \ -destination 'generic/platform=macOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \ -archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
-skipMacroValidation -skipPackagePluginValidation \
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
CODE_SIGN_STYLE=Manual \ CODE_SIGN_STYLE=Automatic \
CODE_SIGN_IDENTITY="Apple Distribution" \ DEVELOPMENT_TEAM="$TEAM_ID"
DEVELOPMENT_TEAM="$TEAM_ID" \
PROVISIONING_PROFILE_SPECIFIER="$PROFILE"
cat > "$RUNNER_TEMP/export-macos-appstore.plist" <<EOF cat > "$RUNNER_TEMP/export-macos-appstore.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -252,35 +261,27 @@ jobs:
# Best-effort until the App Store Connect app record for io.unom.punktfunk exists. # Best-effort until the App Store Connect app record for io.unom.punktfunk exists.
continue-on-error: true continue-on-error: true
run: | run: |
# MANUAL App Store signing: the local (valid) Apple Distribution identity + the App # Archive with AUTOMATIC signing (development) — see the macOS App Store step for the full
# Store provisioning profile. NOT -allowProvisioningUpdates — with an App-Manager-role # rationale. The SwiftPM resource bundle (PunktfunkKit_PunktfunkKit, added with the in-app
# ASC key that forces Xcode's CLOUD-managed signing, which the role can't do ("Cloud # license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER
# signing permission error"). The profile must be installed on the runner under # this step used to set matched it and failed the archive ("does not support provisioning
# ~/Library/Developer/Xcode/UserData/Provisioning Profiles/ (install it once with # profiles"). Automatic signing profiles only the app and leaves the resource bundle (and
# Xcode.app quit, or it prunes the manually-dropped distribution profile). # the macOS-host macro plugins) alone. No -allowProvisioningUpdates → OFFLINE, never
# A running Xcode.app prunes unrecognized profiles from that dir — quit it so the App # cloud-signs (the App-Manager ASC key can't), so the runner needs an iOS *development*
# Store profile survives this build; headless xcodebuild doesn't need the GUI app. # profile for io.unom.punktfunk installed. DISTRIBUTION signing is the export step below
# (manual, via the plist). A running Xcode.app prunes unrecognized profiles — quit it so the
# manually-installed App Store distribution profile survives for export.
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
pkill -x Xcode 2>/dev/null || true pkill -x Xcode 2>/dev/null || true
PROFILE="Punktfunk iOS App Store Distribution" PROFILE="Punktfunk iOS App Store Distribution"
# Scope signing to the iOS device SDK via an xcconfig — see the tvOS step below for the
# full rationale. A global (CLI) profile specifier would also be forced onto the shared
# macOS-host SwiftPM macro plugins, which reject it and fail the archive; [sdk=iphoneos*]
# in an xcconfig lands it on the app/framework slices only.
SIGN_XCCONFIG="$RUNNER_TEMP/sign-ios.xcconfig"
cat > "$SIGN_XCCONFIG" <<XCCONF
CODE_SIGN_STYLE = Manual
DEVELOPMENT_TEAM = $TEAM_ID
CODE_SIGN_IDENTITY[sdk=iphoneos*] = Apple Distribution
PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*] = $PROFILE
XCCONF
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \ DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
-project "$PROJECT" -scheme Punktfunk-iOS \ -project "$PROJECT" -scheme Punktfunk-iOS \
-destination 'generic/platform=iOS' \ -destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \ -archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
-skipMacroValidation -skipPackagePluginValidation \ -skipMacroValidation -skipPackagePluginValidation \
-xcconfig "$SIGN_XCCONFIG" \ MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="$TEAM_ID"
cat > "$RUNNER_TEMP/export-appstore.plist" <<EOF cat > "$RUNNER_TEMP/export-appstore.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -312,33 +313,24 @@ jobs:
# on the runner (xcodebuild -downloadPlatform tvOS). # on the runner (xcodebuild -downloadPlatform tvOS).
continue-on-error: true continue-on-error: true
run: | run: |
# Same manual App Store signing as iOS (the App-Manager ASC key can't cloud-sign). # Archive with AUTOMATIC signing (development) — see the macOS App Store step. The SwiftPM
# resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the
# sdk-scoped profile this step used to set; Automatic signing profiles only the app and
# leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/
# AssociationMacro) alone. No -allowProvisioningUpdates → OFFLINE, never cloud-signs (the
# App-Manager ASC key can't), so the runner needs a tvOS *development* profile for
# io.unom.punktfunk installed. DISTRIBUTION signing is the export step below (manual, plist).
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
pkill -x Xcode 2>/dev/null || true pkill -x Xcode 2>/dev/null || true
PROFILE="Punktfunk tvOS App Store Distribution" PROFILE="Punktfunk tvOS App Store Distribution"
# Scope signing to the tvOS device SDK via an xcconfig. A global (CLI) profile specifier
# hits EVERY target, including the shared SwiftPM macro plugins (OnceMacro/SwizzlingMacro/
# AssociationMacro) which build for the macOS host and reject a provisioning profile
# ("<macro> does not support provisioning profiles"), failing the archive. Conditionals
# work only in an xcconfig (xcodebuild mis-parses a CLI "SETTING[sdk=..]=val"), and a
# command-line -xcconfig outranks target settings, so [sdk=appletvos*] puts the profile on
# the app/framework slices only — the macosx-host macros get nothing. (The macOS archive
# above is immune: its host-SDK macros are CODE_SIGNING_ALLOWED=NO, so a global specifier
# is ignored there.)
SIGN_XCCONFIG="$RUNNER_TEMP/sign-tvos.xcconfig"
cat > "$SIGN_XCCONFIG" <<XCCONF
CODE_SIGN_STYLE = Manual
DEVELOPMENT_TEAM = $TEAM_ID
CODE_SIGN_IDENTITY[sdk=appletvos*] = Apple Distribution
PROVISIONING_PROFILE_SPECIFIER[sdk=appletvos*] = $PROFILE
XCCONF
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \ DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
-project "$PROJECT" -scheme Punktfunk-tvOS \ -project "$PROJECT" -scheme Punktfunk-tvOS \
-destination 'generic/platform=tvOS' \ -destination 'generic/platform=tvOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \ -archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
-skipMacroValidation -skipPackagePluginValidation \ -skipMacroValidation -skipPackagePluginValidation \
-xcconfig "$SIGN_XCCONFIG" \ MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="$TEAM_ID"
cat > "$RUNNER_TEMP/export-tvos.plist" <<EOF cat > "$RUNNER_TEMP/export-tvos.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
Generated
+24
View File
@@ -2331,6 +2331,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "num-integer" name = "num-integer"
version = "0.1.46" version = "0.1.46"
@@ -2839,12 +2850,14 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"ureq", "ureq",
"usbip-sim",
"utoipa", "utoipa",
"utoipa-axum", "utoipa-axum",
"utoipa-scalar", "utoipa-scalar",
"wasapi", "wasapi",
"wayland-backend", "wayland-backend",
"wayland-client", "wayland-client",
"wayland-protocols",
"wayland-protocols-misc", "wayland-protocols-misc",
"wayland-protocols-wlr", "wayland-protocols-wlr",
"wayland-scanner", "wayland-scanner",
@@ -4236,6 +4249,17 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "usbip-sim"
version = "0.8.0"
dependencies = [
"log",
"num-derive",
"num-traits",
"serde",
"tokio",
]
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
+3
View File
@@ -3,6 +3,7 @@ resolver = "2"
members = [ members = [
"crates/punktfunk-core", "crates/punktfunk-core",
"crates/punktfunk-host", "crates/punktfunk-host",
"crates/punktfunk-host/vendor/usbip-sim",
"crates/pf-driver-proto", "crates/pf-driver-proto",
"clients/probe", "clients/probe",
"clients/linux", "clients/linux",
@@ -11,6 +12,8 @@ members = [
"tools/latency-probe", "tools/latency-probe",
"tools/loss-harness", "tools/loss-harness",
] ]
# Standalone PoC (built on its own; pulls usbip/tokio/libusb we don't want in the workspace).
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
[workspace.package] [workspace.package]
version = "0.3.0" version = "0.3.0"
+9 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0", "name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0" "identifier": "MIT OR Apache-2.0"
}, },
"version": "0.0.1" "version": "0.3.0"
}, },
"paths": { "paths": {
"/api/v1/clients": { "/api/v1/clients": {
@@ -1354,6 +1354,14 @@
"type": "object", "type": "object",
"description": "Arm-native-pairing request body.", "description": "Arm-native-pairing request body.",
"properties": { "properties": {
"fingerprint": {
"type": [
"string",
"null"
],
"description": "Optional: bind the window to ONE device fingerprint (hex SHA-256, e.g. from a pending knock).\nWhen set, only a pairing attempt from that fingerprint consumes the window — so an unpaired\nLAN peer can neither pair nor burn a window armed for a specific device (security-review #9).\nOmit for an unbound window (any device may use the PIN — trusted-LAN only).",
"example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
},
"ttl_secs": { "ttl_secs": {
"type": [ "type": [
"integer", "integer",
@@ -175,9 +175,9 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
status = "Connecting to $targetHost:$targetPort" status = "Connecting to $targetHost:$targetPort"
discovery.stop() // free the Wi-Fi radio before the stream session discovery.stop() // free the Wi-Fi radio before the stream session
scope.launch { scope.launch {
// Advertise HDR only when this device's display can present it (else the host sends a // Advertise HDR only when the user enabled it AND this device's display can present it
// proper SDR stream rather than PQ the panel would mis-tone-map). // (else the host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
val hdrEnabled = displaySupportsHdr(context) val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID // "Automatic" resolves to a concrete pad type from the connected controller's VID/PID
// (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An // (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An
// explicit choice is passed through unchanged. // explicit choice is passed through unchanged.
@@ -224,7 +224,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
status = null status = null
discovery.stop() // free the Wi-Fi radio before the (parked) stream session discovery.stop() // free the Wi-Fi radio before the (parked) stream session
scope.launch { scope.launch {
val hdrEnabled = displaySupportsHdr(context) val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
val gamepadPref = Gamepad.resolvePref(settings.gamepad) val gamepadPref = Gamepad.resolvePref(settings.gamepad)
// Pin the advertised fingerprint for a discovered host (defence against an impostor while // Pin the advertised fingerprint for a discovered host (defence against an impostor while
// we wait); a manually-typed host has none, so trust-on-first-use. // we wait); a manually-typed host has none, so trust-on-first-use.
@@ -14,6 +14,13 @@ data class Settings(
val height: Int = 0, val height: Int = 0,
val hz: Int = 0, val hz: Int = 0,
val bitrateKbps: Int = 0, val bitrateKbps: Int = 0,
/**
* Advertise HDR (10-bit BT.2020 PQ) to the host. Default on, but only *effective* on a panel that
* can actually present HDR10 (see [displaySupportsHdr]) — on an SDR display HDR is never
* advertised regardless, so the host sends a proper 8-bit BT.709 stream rather than PQ the panel
* would mis-tone-map. Turning this off forces SDR even on a capable panel.
*/
val hdrEnabled: Boolean = true,
val compositor: Int = 0, val compositor: Int = 0,
val gamepad: Int = 0, val gamepad: Int = 0,
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it /** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
@@ -40,6 +47,7 @@ class SettingsStore(context: Context) {
height = prefs.getInt(K_H, 0), height = prefs.getInt(K_H, 0),
hz = prefs.getInt(K_HZ, 0), hz = prefs.getInt(K_HZ, 0),
bitrateKbps = prefs.getInt(K_BITRATE, 0), bitrateKbps = prefs.getInt(K_BITRATE, 0),
hdrEnabled = prefs.getBoolean(K_HDR, true),
compositor = prefs.getInt(K_COMPOSITOR, 0), compositor = prefs.getInt(K_COMPOSITOR, 0),
gamepad = prefs.getInt(K_GAMEPAD, 0), gamepad = prefs.getInt(K_GAMEPAD, 0),
audioChannels = prefs.getInt(K_AUDIO_CH, 2), audioChannels = prefs.getInt(K_AUDIO_CH, 2),
@@ -54,6 +62,7 @@ class SettingsStore(context: Context) {
.putInt(K_H, s.height) .putInt(K_H, s.height)
.putInt(K_HZ, s.hz) .putInt(K_HZ, s.hz)
.putInt(K_BITRATE, s.bitrateKbps) .putInt(K_BITRATE, s.bitrateKbps)
.putBoolean(K_HDR, s.hdrEnabled)
.putInt(K_COMPOSITOR, s.compositor) .putInt(K_COMPOSITOR, s.compositor)
.putInt(K_GAMEPAD, s.gamepad) .putInt(K_GAMEPAD, s.gamepad)
.putInt(K_AUDIO_CH, s.audioChannels) .putInt(K_AUDIO_CH, s.audioChannels)
@@ -68,6 +77,7 @@ class SettingsStore(context: Context) {
const val K_H = "height" const val K_H = "height"
const val K_HZ = "hz" const val K_HZ = "hz"
const val K_BITRATE = "bitrate_kbps" const val K_BITRATE = "bitrate_kbps"
const val K_HDR = "hdr_enabled"
const val K_COMPOSITOR = "compositor" const val K_COMPOSITOR = "compositor"
const val K_GAMEPAD = "gamepad" const val K_GAMEPAD = "gamepad"
const val K_AUDIO_CH = "audio_channels" const val K_AUDIO_CH = "audio_channels"
@@ -94,6 +94,22 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
options = BITRATE_OPTIONS, options = BITRATE_OPTIONS,
selected = s.bitrateKbps, selected = s.bitrateKbps,
) { kbps -> update(s.copy(bitrateKbps = kbps)) } ) { kbps -> update(s.copy(bitrateKbps = kbps)) }
// HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle
// is disabled (and HDR is never advertised regardless) so the host doesn't send PQ the
// panel would mis-tone-map. The capability is fixed for the device, so read it once.
val hdrCapable = remember { displaySupportsHdr(context) }
ToggleRow(
title = "HDR",
subtitle = if (hdrCapable) {
"Stream 10-bit HDR (BT.2020 PQ) when the host supports it"
} else {
"This display can't present HDR10 — streams stay SDR"
},
checked = s.hdrEnabled && hdrCapable,
enabled = hdrCapable,
onCheckedChange = { on -> update(s.copy(hdrEnabled = on)) },
)
} }
SettingsGroup("Host") { SettingsGroup("Host") {
@@ -181,24 +197,31 @@ private fun SettingsGroup(title: String, content: @Composable ColumnScope.() ->
} }
} }
/** A title + subtitle on the left, a Switch on the right. */ /** A title + subtitle on the left, a Switch on the right. [enabled] greys out the whole row. */
@Composable @Composable
private fun ToggleRow( private fun ToggleRow(
title: String, title: String,
subtitle: String, subtitle: String,
checked: Boolean, checked: Boolean,
onCheckedChange: (Boolean) -> Unit, onCheckedChange: (Boolean) -> Unit,
enabled: Boolean = true,
) { ) {
// Dim the labels when disabled so the row reads as inactive (the Switch dims itself).
val labelAlpha = if (enabled) 1f else 0.38f
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge) Text(
title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = labelAlpha),
)
Text( Text(
subtitle, subtitle,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = labelAlpha),
) )
} }
Switch(checked = checked, onCheckedChange = onCheckedChange) Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
} }
} }
@@ -1,6 +1,7 @@
package io.unom.punktfunk package io.unom.punktfunk
import android.Manifest import android.Manifest
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.view.SurfaceHolder import android.view.SurfaceHolder
import android.view.SurfaceView import android.view.SurfaceView
@@ -102,6 +103,13 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
it.hide(WindowInsetsCompat.Type.systemBars()) it.hide(WindowInsetsCompat.Type.systemBars())
} }
// Lock to landscape while streaming — the host streams a landscape desktop, so pin the device
// there (either landscape direction is fine) and stop it rotating to portrait mid-session. The
// activity declares configChanges=orientation, so this re-lays out the surface in place without
// recreating the activity (no stream restart). On TV (fixed landscape) it's a harmless no-op.
// The prior request is captured and restored on the way out.
val priorOrientation = activity?.requestedOrientation
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
activity?.streamHandle = handle // route hardware keys to this session activity?.streamHandle = handle // route hardware keys to this session
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close. // Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
@@ -114,6 +122,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
activity?.streamHandle = 0L activity?.streamHandle = 0L
controller?.show(WindowInsetsCompat.Type.systemBars()) controller?.show(WindowInsetsCompat.Type.systemBars())
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Release the landscape lock so the rest of the app follows the device/system again.
activity?.requestedOrientation =
priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
// Leaving the stream: stop the mic + audio + decode threads and tear down the session. // Leaving the stream: stop the mic + audio + decode threads and tear down the session.
NativeBridge.nativeStopMic(handle) NativeBridge.nativeStopMic(handle)
NativeBridge.nativeStopAudio(handle) NativeBridge.nativeStopAudio(handle)
@@ -314,9 +325,11 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
} }
/** /**
* The live stats overlay — mirrors the Apple client's HUD. Reads the 10-double layout from * The live stats overlay — mirrors the Apple client's HUD. Reads the 14-double layout from
* [NativeBridge.nativeVideoStats]: * [NativeBridge.nativeVideoStats]:
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skew, w, h, hz, dropped]`. * `[fps, mbps, latP50Ms, latP95Ms, latValid, skew, w, h, hz, dropped, bitDepth, colorPrimaries,
* colorTransfer, chromaFormatIdc]`. The trailing four (present on a current native lib) describe the
* negotiated video feed and render as a codec/depth/colour/chroma line; older layouts just omit it.
*/ */
@Composable @Composable
internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) { internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
@@ -338,6 +351,14 @@ internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 12.sp, fontSize = 12.sp,
) )
videoFeedLine(s)?.let { feed ->
Text(
feed,
color = Color.White,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
}
if (latValid) { if (latValid) {
val tag = if (skew) "" else " (same-host)" val tag = if (skew) "" else " (same-host)"
Text( Text(
@@ -357,3 +378,31 @@ internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
} }
} }
} }
/**
* Format the negotiated video-feed descriptor from the trailing four stats doubles
* `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
* `HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles)
* so the overlay simply omits the line. The codes are CICP / H.273: transfer 16 = PQ, 18 = HLG (else
* SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4. The
* Android decoder is always HEVC (`video/hevc`).
*/
private fun videoFeedLine(s: DoubleArray): String? {
if (s.size < 14) return null
val bitDepth = s[10].toInt()
val primaries = s[11].toInt()
val transfer = s[12].toInt()
val chromaIdc = s[13].toInt()
val depthLabel = if (bitDepth > 0) "$bitDepth-bit" else "8-bit"
val (dynamicRange, colorSpace) = when (transfer) {
16 -> "HDR" to "BT.2020 PQ"
18 -> "HDR" to "BT.2020 HLG"
else -> "SDR" to if (primaries == 9) "BT.2020" else "BT.709"
}
val chromaLabel = when (chromaIdc) {
3 -> "4:4:4"
2 -> "4:2:2"
else -> "4:2:0"
}
return "HEVC · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel"
}
@@ -186,9 +186,11 @@ internal fun StreamScene() {
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))), Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
), ),
) { ) {
// [fps, mbps, latP50, latP95, latValid, skew, w, h, hz, dropped] // [fps, mbps, latP50, latP95, latValid, skew, w, h, hz, dropped,
// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc] — the last four = a 10-bit
// BT.2020 PQ (HDR) 4:2:0 feed, so the HUD renders its video-feed line.
StatsOverlay( StatsOverlay(
doubleArrayOf(238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0), doubleArrayOf(238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0, 10.0, 9.0, 16.0, 1.0),
Modifier.align(Alignment.TopStart).padding(12.dp), Modifier.align(Alignment.TopStart).padding(12.dp),
) )
} }
@@ -50,15 +50,25 @@ object Gamepad {
const val PREF_DUALSENSE = 2 const val PREF_DUALSENSE = 2
const val PREF_XBOXONE = 3 const val PREF_XBOXONE = 3
const val PREF_DUALSHOCK4 = 4 const val PREF_DUALSHOCK4 = 4
const val PREF_STEAMCONTROLLER = 5
const val PREF_STEAMDECK = 6
// 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
private const val VID_MICROSOFT = 0x045E private const val VID_MICROSOFT = 0x045E
private const val VID_VALVE = 0x28DE
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types. // Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2) private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC) private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC)
// 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 /
// trackpads / gyro) is out of scope on Android (no rich-input plane yet), so only the standard
// buttons + sticks reach the host for now — parity with the desktop type resolution.
private val PID_STEAMDECK = setOf(0x1205)
private val PID_STEAMCONTROLLER = setOf(0x1102, 0x1142)
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All // Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte. // behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
private val PID_XBOXONE = setOf( private val PID_XBOXONE = setOf(
@@ -82,6 +92,8 @@ object Gamepad {
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
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_STEAMCONTROLLER -> PREF_STEAMCONTROLLER
else -> PREF_XBOX360 else -> PREF_XBOX360
} }
} }
@@ -103,9 +103,12 @@ object NativeBridge {
/** /**
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs. * Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
* Returns 10 doubles: * Returns 14 doubles:
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped]` * `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped,
* (the two flags are 1.0/0.0). Poll ~1 Hz; each call resets the measurement window. * bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`
* (the two flags are 1.0/0.0; the trailing four describe the negotiated video feed — bit depth
* 8/10, CICP primaries/transfer, and the HEVC chroma_format_idc 1=4:2:0 / 3=4:4:4). Poll ~1 Hz;
* each call resets the measurement window.
*/ */
external fun nativeVideoStats(handle: Long): DoubleArray? external fun nativeVideoStats(handle: Long): DoubleArray?
+5
View File
@@ -114,6 +114,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
out[2..n].copy_from_slice(&effect); out[2..n].copy_from_slice(&effect);
n n
} }
HidOutput::TrackpadHaptic { .. } => {
// Steam Controller trackpad-coil haptics — no Android equivalent; drop it (motor
// rumble already rides the universal 0xCA plane).
return -1;
}
}; };
n as jint n as jint
}) })
+17 -6
View File
@@ -409,11 +409,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
} }
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD. /// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD.
/// Returns 10 doubles /// Returns 14 doubles
/// `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped]` /// `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped,
/// (the two flags are 1.0/0.0), or `null` when no decode thread is running. Poll ~1 Hz from the UI; /// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`
/// each call resets the measurement window. Not android-gated — pure `jni` + connector reads, so it /// (the two flags are 1.0/0.0; the trailing four describe the negotiated video feed — see below), or
/// links on the host build too (Kotlin only ever calls it on device). /// `null` when no decode thread is running. Poll ~1 Hz from the UI; each call resets the measurement
/// window. Not android-gated — pure `jni` + connector reads, so it links on the host build too
/// (Kotlin only ever calls it on device).
#[no_mangle] #[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats( pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
env: JNIEnv, env: JNIEnv,
@@ -431,7 +433,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
None => return std::ptr::null_mut(), // not streaming → no stats None => return std::ptr::null_mut(), // not streaming → no stats
}; };
let mode = h.client.mode(); let mode = h.client.mode();
let buf: [f64; 10] = [ let color = h.client.color;
let buf: [f64; 14] = [
snap.fps, snap.fps,
snap.mbps, snap.mbps,
snap.lat_p50_ms, snap.lat_p50_ms,
@@ -442,6 +445,14 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
mode.height as f64, mode.height as f64,
mode.refresh_hz as f64, mode.refresh_hz as f64,
h.client.frames_dropped() as f64, h.client.frames_dropped() as f64,
// Video-feed properties the host resolved at the handshake (Welcome): encode bit depth
// (8 / 10), the CICP colour primaries + transfer code points (Kotlin maps these to a
// colour-space / HDR label — transfer 16 = PQ, 18 = HLG ⇒ HDR), and the HEVC
// chroma_format_idc (1 = 4:2:0, 3 = 4:4:4). Static for the session unless renegotiated.
h.client.bit_depth as f64,
color.primaries as f64,
color.transfer as f64,
h.client.chroma_format as f64,
]; ];
let arr = match env.new_double_array(buf.len() as jsize) { let arr = match env.new_double_array(buf.len() as jsize) {
Ok(a) => a, Ok(a) => a,
+3
View File
@@ -24,6 +24,9 @@ let package = Package(
.copy("Resources/THIRD-PARTY-NOTICES.txt"), .copy("Resources/THIRD-PARTY-NOTICES.txt"),
.copy("Resources/LICENSE-MIT.txt"), .copy("Resources/LICENSE-MIT.txt"),
.copy("Resources/LICENSE-APACHE.txt"), .copy("Resources/LICENSE-APACHE.txt"),
// Geist (SIL OFL 1.1) the brand typeface, shared with punktfunk-website.
// Registered with Core Text at first use; see BrandFont.swift.
.copy("Resources/Fonts"),
], ],
linkerSettings: [ linkerSettings: [
// Rust staticlib system deps. // Rust staticlib system deps.
@@ -364,7 +364,7 @@
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist; INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger"; INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
@@ -398,7 +398,7 @@
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist; INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger"; INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
@@ -429,7 +429,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist; INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger"; INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
@@ -468,7 +468,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist; INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger"; INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
@@ -506,7 +506,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist; INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger"; INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -536,7 +536,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist; INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger"; INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -10,12 +10,17 @@ struct AcknowledgementsView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
// Top-level LazyVStack so the third-party-notices chunks (Licenses.thirdPartyNoticesChunks,
// ~885 KB total) load lazily as they scroll into view a single Text that large overshoots
// the text-rendering height limit (blank below the limit + very slow). spacing 0 keeps the
// notice chunks visually continuous; the header block carries its own spacing + bottom pad.
LazyVStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
Text("punktfunk") Text("punktfunk")
.font(.title2).bold() .font(.geist(22, .bold, relativeTo: .title2))
if let version { if let version {
Text("Version \(version)") Text("Version \(version)")
.font(.caption) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Text(Licenses.appLicense) Text(Licenses.appLicense)
@@ -24,19 +29,41 @@ struct AcknowledgementsView: View {
Divider() Divider()
Text("Bundled font")
.font(.geist(17, .semibold, relativeTo: .headline))
Text("punktfunk ships the Geist typeface (Geist Sans), "
+ "© The Geist Project Authors / Vercel, used under the SIL Open Font "
+ "License 1.1.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
if !Licenses.fontLicense.isEmpty {
Text(Licenses.fontLicense)
.font(.caption2.monospaced())
.modifier(SelectableText())
}
Divider()
Text("Third-party software") Text("Third-party software")
.font(.headline) .font(.geist(17, .semibold, relativeTo: .headline))
Text( Text(
"punktfunk uses the open-source components below, each under its own license. " "punktfunk uses the open-source components below, each under its own license. "
+ "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ " + "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ "
+ "(dynamically linked, replaceable)." + "(dynamically linked, replaceable)."
) )
.font(.caption) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text(Licenses.thirdPartyNotices) }
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 18)
ForEach(Licenses.thirdPartyNoticesChunks.indices, id: \.self) { i in
Text(Licenses.thirdPartyNoticesChunks[i])
.font(.caption2.monospaced()) .font(.caption2.monospaced())
.frame(maxWidth: .infinity, alignment: .leading)
.modifier(SelectableText()) .modifier(SelectableText())
} }
}
.frame(maxWidth: 900, alignment: .leading) .frame(maxWidth: 900, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding() .padding()
@@ -80,6 +80,11 @@ struct AddHostSheet: View {
} }
#if !os(tvOS) #if !os(tvOS)
.formStyle(.grouped) .formStyle(.grouped)
#endif
#if os(iOS)
// The detent below is sized to fit all 3 rows + the action button exactly, so the
// Form must NOT scroll/bounce inside it lock it. (iOS 16+; safe at iOS 17.)
.scrollDisabled(true)
#endif #endif
#if os(macOS) #if os(macOS)
// macOS: UNCHANGED Cancel + Spacer + Add in an HStack, both wired to the // macOS: UNCHANGED Cancel + Spacer + Add in an HStack, both wired to the
@@ -120,8 +125,8 @@ struct AddHostSheet: View {
// Form + the full-width action row, instead of the half-screen .medium it used to rest // Form + the full-width action row, instead of the half-screen .medium it used to rest
// at. A single fixed detent is enough: the system keeps the content above the keyboard // at. A single fixed detent is enough: the system keeps the content above the keyboard
// when Address/Port is focused, and on iPadOS this renders as a short bottom sheet (not a // when Address/Port is focused, and on iPadOS this renders as a short bottom sheet (not a
// centered formSheet card). If Dynamic Type grows the rows past this height the Form just // centered formSheet card). The Form itself is .scrollDisabled (above) so it can't
// scrolls inside the detent nothing is clipped. (.height(_:) is iOS 16+, safe at iOS 17.) // bounce/scroll inside this fixed detent. (.height(_:) is iOS 16+, safe at iOS 17.)
.presentationDetents([.height(320)]) .presentationDetents([.height(320)])
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
#endif #endif
@@ -0,0 +1,39 @@
// App-wide brand chrome. SwiftUI has no single switch to put a custom font on every navigation
// title, so the iOS large/inline nav titles are themed through UINavigationBar's appearance proxy
// (set once at launch). Backgrounds are left at the system defaults transparent at the scroll
// edge (the large title floats on the content), blurred once scrolled so only the typeface
// changes: Geist, matching the cards and the website.
#if os(iOS)
import PunktfunkKit
import UIKit
enum BrandTheme {
static func apply() {
BrandFont.registerIfNeeded()
let scrollEdge = UINavigationBarAppearance()
scrollEdge.configureWithTransparentBackground()
applyFonts(to: scrollEdge)
let standard = UINavigationBarAppearance()
standard.configureWithDefaultBackground()
applyFonts(to: standard)
let proxy = UINavigationBar.appearance()
proxy.scrollEdgeAppearance = scrollEdge
proxy.standardAppearance = standard
proxy.compactAppearance = standard
}
/// Override only the title fonts; leave colors/backgrounds at the configured defaults.
private static func applyFonts(to appearance: UINavigationBarAppearance) {
if let large = UIFont(name: "Geist-Bold", size: 34) {
appearance.largeTitleTextAttributes[.font] = large
}
if let inline = UIFont(name: "Geist-SemiBold", size: 17) {
appearance.titleTextAttributes[.font] = inline
}
}
}
#endif
@@ -28,6 +28,7 @@ struct ContentView: View {
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0 @AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0 @AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2 @AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true @AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true @AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue @AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
@@ -68,15 +69,19 @@ struct ContentView: View {
// A session actually started remember it on the card ("Connected ago" // A session actually started remember it on the card ("Connected ago"
// plus the accent ring on the most recent host). // plus the accent ring on the most recent host).
guard let host = model.activeHost else { break } guard let host = model.activeHost else { break }
store.markConnected(host.id)
// Delegated approval just succeeded: the operator let this device in, so pin the // Delegated approval just succeeded: the operator let this device in, so pin the
// host's observed fingerprint and remember it as paired future connects are then // host's observed fingerprint and remember it as paired future connects are then
// silent (rule 1), exactly like after a PIN/TOFU success. Dismisses the wait prompt. // silent (rule 1), exactly like after a PIN/TOFU success. Dismisses the wait prompt.
if awaitingApproval?.host.id == host.id { let approvedFingerprint = awaitingApproval?.host.id == host.id
if let fp = model.connection?.hostFingerprint { ? model.connection?.hostFingerprint : nil
store.pin(host.id, fingerprint: fp) if awaitingApproval?.host.id == host.id { awaitingApproval = nil }
} // Persist on the next runloop tick: HostStore is an ObservableObject, and mutating
awaitingApproval = nil // its @Published from inside .onChange (a view-update callback) trips SwiftUI's
// "Publishing changes from within view updates". A one-tick delay is imperceptible.
let store = store
DispatchQueue.main.async {
store.markConnected(host.id)
if let approvedFingerprint { store.pin(host.id, fingerprint: approvedFingerprint) }
} }
case .idle: case .idle:
// The delegated-approval connect failed, timed out, or was cancelled drop the // The delegated-approval connect failed, timed out, or was cancelled drop the
@@ -333,6 +338,7 @@ struct ContentView: View {
rawValue: UInt32(clamping: gamepadType)) ?? .auto), rawValue: UInt32(clamping: gamepadType)) ?? .auto),
bitrateKbps: UInt32(clamping: bitrateKbps), bitrateKbps: UInt32(clamping: bitrateKbps),
audioChannels: UInt8(clamping: audioChannels), audioChannels: UInt8(clamping: audioChannels),
hdrEnabled: hdrEnabled,
launchID: launchID, launchID: launchID,
allowTofu: allowTofu, allowTofu: allowTofu,
requestAccess: requestAccess) requestAccess: requestAccess)
@@ -475,6 +481,7 @@ struct ContentView: View {
gamepad: pad, gamepad: pad,
bitrateKbps: bitrate, bitrateKbps: bitrate,
audioChannels: UInt8(clamping: audioChannels), audioChannels: UInt8(clamping: audioChannels),
hdrEnabled: hdrEnabled,
autoTrust: true) autoTrust: true)
} }
} }
@@ -54,7 +54,7 @@ struct ControllerTestView: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack { HStack {
Text("Test Controller").font(.headline) Text("Test Controller").font(.geist(17, .semibold, relativeTo: .headline))
Spacer() Spacer()
Button("Done") { dismiss() }.keyboardShortcut(.cancelAction) Button("Done") { dismiss() }.keyboardShortcut(.cancelAction)
} }
@@ -99,8 +99,8 @@ struct ControllerTestView: View {
.font(.title2) .font(.title2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(c.name).font(.headline) Text(c.name).font(.geist(17, .semibold, relativeTo: .headline))
Text(c.productCategory).font(.caption).foregroundStyle(.secondary) Text(c.productCategory).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
} }
Spacer() Spacer()
} }
@@ -209,7 +209,7 @@ struct ControllerTestView: View {
) -> some View { ) -> some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Touchpad\(tp.button.isPressed ? " — click" : "")") Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
.font(.caption2).foregroundStyle(.secondary) .font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
ZStack { ZStack {
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3)) RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
fingerDot(tp.primary, color: .accentColor) fingerDot(tp.primary, color: .accentColor)
@@ -230,7 +230,7 @@ struct ControllerTestView: View {
private func motionReadout(_ m: GCMotion) -> some View { private func motionReadout(_ m: GCMotion) -> some View {
let a = Self.totalAccel(m) let a = Self.totalAccel(m)
return VStack(alignment: .leading, spacing: 2) { return VStack(alignment: .leading, spacing: 2) {
Text("Motion").font(.caption2).foregroundStyle(.secondary) Text("Motion").font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
Text(String(format: "gyro %+.2f %+.2f %+.2f", Text(String(format: "gyro %+.2f %+.2f %+.2f",
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z)) m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
.font(.caption2.monospaced()) .font(.caption2.monospaced())
@@ -254,11 +254,11 @@ struct ControllerTestView: View {
Toggle("Heavy motor (left)", isOn: $heavyOn) Toggle("Heavy motor (left)", isOn: $heavyOn)
Toggle("Light motor (right)", isOn: $lightOn) Toggle("Light motor (right)", isOn: $lightOn)
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform") Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
.font(.caption).foregroundStyle(.secondary) .font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
Text("Toggle a motor to feel it. The host maps a game's low/high-frequency " Text("Toggle a motor to feel it. The host maps a game's low/high-frequency "
+ "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics " + "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics "
+ "can't reach its motors on macOS).") + "can't reach its motors on macOS).")
.font(.caption).foregroundStyle(.secondary) .font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
} }
.onChange(of: heavyOn) { _, _ in applyRumble() } .onChange(of: heavyOn) { _, _ in applyRumble() }
.onChange(of: lightOn) { _, _ in applyRumble() } .onChange(of: lightOn) { _, _ in applyRumble() }
@@ -289,11 +289,11 @@ struct ControllerTestView: View {
} }
} }
Text("Pick an effect, then pull L2/R2 to feel the resistance.") Text("Pick an effect, then pull L2/R2 to feel the resistance.")
.font(.caption).foregroundStyle(.secondary) .font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
} }
} else { } else {
Text("Adaptive triggers need a DualSense.") Text("Adaptive triggers need a DualSense.")
.font(.caption).foregroundStyle(.secondary) .font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
} }
} }
} }
@@ -348,7 +348,7 @@ struct ControllerTestView: View {
_ title: String, @ViewBuilder _ content: () -> Content _ title: String, @ViewBuilder _ content: () -> Content
) -> some View { ) -> some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text(title).font(.subheadline.weight(.semibold)) Text(title).font(.geist(15, .semibold, relativeTo: .subheadline))
content() content()
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -127,14 +127,13 @@ struct HomeView: View {
AddHostSheet { store.add($0) } AddHostSheet { store.add($0) }
} }
#if os(iOS) #if os(iOS)
// SettingsView owns its own NavigationSplitView (sidebar + detail) and Done button, so it
// is presented directly wrapping it in a NavigationStack here would nest a split view in
// a stack (double title bars). `settingsSheetSizing()` widens the sheet on iPad for the
// two-column layout.
.sheet(isPresented: $showSettings) { .sheet(isPresented: $showSettings) {
NavigationStack {
SettingsView() SettingsView()
.navigationTitle("Settings") .settingsSheetSizing()
.toolbar {
Button("Done") { showSettings = false }
}
}
} }
#endif #endif
#endif #endif
@@ -172,7 +171,7 @@ struct HomeView: View {
private var discoveredSection: some View { private var discoveredSection: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Label("On this network", systemImage: "antenna.radiowaves.left.and.right") Label("On this network", systemImage: "antenna.radiowaves.left.and.right")
.font(.headline) .font(.geist(15, .semibold, relativeTo: .headline))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.horizontal) .padding(.horizontal)
LazyVGrid(columns: gridColumns, spacing: gridSpacing) { LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
@@ -249,8 +248,10 @@ struct HomeView: View {
/// the width so the cards stay edge-aligned with the title and bars sized touch-first: one /// the width so the cards stay edge-aligned with the title and bars sized touch-first: one
/// column on iPhone portrait, 34 generous cards on iPad. /// column on iPhone portrait, 34 generous cards on iPad.
private var gridColumns: [GridItem] { private var gridColumns: [GridItem] {
// Wider than before: the monogram card is a horizontal module (tile + address line), so
// it needs room for a monospaced "IP:port" without truncating.
#if os(macOS) #if os(macOS)
[GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)] [GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)]
#elseif os(tvOS) #elseif os(tvOS)
[GridItem(.adaptive(minimum: 320), spacing: 48)] [GridItem(.adaptive(minimum: 320), spacing: 48)]
#else #else
@@ -1,26 +1,75 @@
// The host grid's cards: a saved host (tap to connect, context menu) and an mDNS-discovered // The host grid's cards: a saved host (tap to connect, context menu) and an mDNS-discovered
// host (tap to save + connect). Both share the same platform-tuned sizing. // host (tap to save + connect). Both share the "monogram module" look a squared brand-purple
// monogram tile + a left-aligned bold Geist name over monospaced technical metadata
// (address, status), framed by a hairline panel border. Industrial, not soft.
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
/// Shared host-card sizing touch-first on iOS, compact on macOS/tvOS. /// Shared host-card sizing touch-first on iOS, compact on macOS, roomy on tvOS.
private struct CardMetrics { private struct CardMetrics {
let iconSize: CGFloat let tile: CGFloat // monogram tile side
let iconBox: CGFloat let monogram: CGFloat // monogram letter point size
let cardPadding: CGFloat let name: CGFloat // host-name point size
let nameFont: Font let meta: CGFloat // address (mono) point size
let status: CGFloat // status-label (mono) point size
let padding: CGFloat
let spacing: CGFloat // tile text gap
let radius: CGFloat
static var current: CardMetrics { static var current: CardMetrics {
#if os(iOS) #if os(iOS)
CardMetrics(iconSize: 56, iconBox: 76, cardPadding: 28, nameFont: .title3.weight(.semibold)) CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11,
padding: 16, spacing: 14, radius: 12)
#elseif os(tvOS)
CardMetrics(tile: 64, monogram: 32, name: 24, meta: 16, status: 14,
padding: 18, spacing: 18, radius: 14)
#else #else
CardMetrics(iconSize: 42, iconBox: 56, cardPadding: 18, nameFont: .headline) CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5,
padding: 13, spacing: 12, radius: 10)
#endif #endif
} }
} }
/// A saved host. The accent ring marks the most-recently-connected one; the context menu /// First letter of a host name, uppercased the monogram glyph. Falls back to a bullet.
private func monogram(_ name: String) -> String {
guard let first = name.trimmingCharacters(in: .whitespacesAndNewlines).first else { return "" }
return String(first).uppercased()
}
/// The squared monogram tile. `filled` = a solid brand-purple chip (saved hosts); otherwise a
/// tinted outline (discovered hosts). Shows a spinner in place of the glyph while connecting.
private func monogramTile(_ letter: String, m: CardMetrics, connecting: Bool, filled: Bool) -> some View {
let shape = RoundedRectangle(cornerRadius: m.radius - 3, style: .continuous)
return ZStack {
shape.fill(filled
? AnyShapeStyle(LinearGradient(
colors: [Color.brand, Color.brand.opacity(0.72)],
startPoint: .top, endPoint: .bottom))
: AnyShapeStyle(Color.brand.opacity(0.14)))
if connecting {
ProgressView().tint(filled ? .white : Color.brand)
} else {
// Fixed size (not Dynamic Type): the glyph is pinned inside a fixed tile, so it must
// not scale up and spill out at large accessibility text sizes. minimumScaleFactor +
// the clip below are belt-and-suspenders for an unusually wide glyph.
Text(letter)
.font(.geistFixed(m.monogram, .bold))
.minimumScaleFactor(0.5)
.lineLimit(1)
.foregroundStyle(filled ? Color.white : Color.brand)
}
}
.frame(width: m.tile, height: m.tile)
.clipShape(shape)
.overlay {
if !filled {
shape.strokeBorder(Color.brand.opacity(0.45), lineWidth: 1)
}
}
}
/// A saved host. A left accent bar marks the most-recently-connected one; the context menu
/// pairs / speed-tests / forgets / removes. Disabled while a session is busy. /// pairs / speed-tests / forgets / removes. Disabled while a session is busy.
struct HostCardView: View { struct HostCardView: View {
let host: StoredHost let host: StoredHost
@@ -41,66 +90,44 @@ struct HostCardView: View {
var body: some View { var body: some View {
let m = CardMetrics.current let m = CardMetrics.current
return Button(action: onConnect) { return Button(action: onConnect) {
VStack(spacing: 10) { HStack(spacing: m.spacing) {
ZStack { monogramTile(monogram(host.displayName), m: m, connecting: isConnecting, filled: true)
Image(systemName: "play.display") VStack(alignment: .leading, spacing: 4) {
.font(.system(size: m.iconSize, weight: .light))
.foregroundStyle(.tint)
.opacity(isConnecting ? 0.3 : 1)
if isConnecting {
ProgressView()
}
}
.frame(height: m.iconBox)
VStack(spacing: 2) {
HStack(spacing: 6) {
// Presence dot: green = advertising on the LAN now; grey = not seen.
Circle()
.fill(isOnline ? Color.green : Color.secondary.opacity(0.35))
.frame(width: 7, height: 7)
.accessibilityLabel(isOnline ? "Online" : "Offline")
Text(host.displayName) Text(host.displayName)
.font(m.nameFont) .font(.geist(m.name, .bold, relativeTo: .title3))
.foregroundStyle(.primary)
.lineLimit(1) .lineLimit(1)
}
HStack(spacing: 4) {
if host.pinnedSHA256 != nil {
Image(systemName: "lock.fill")
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
Text("\(host.address):\(String(host.port))") Text("\(host.address):\(String(host.port))")
.font(.caption) .font(.geist(m.meta, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
statusRow(m)
} }
if let last = host.lastConnected { Spacer(minLength: 0)
Text("Connected \(last, format: .relative(presentation: .named))")
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
} }
} .padding(m.padding)
} .frame(maxWidth: .infinity, alignment: .leading)
.frame(maxWidth: .infinity)
.padding(.vertical, m.cardPadding)
.padding(.horizontal, 12)
#if !os(tvOS) #if !os(tvOS)
// tvOS: the .card button style owns platter + focus motion extra chrome // tvOS: the .card button style owns platter + focus motion; extra chrome mutes it.
// inside it mutes the grow/tilt. Material + accent ring are for pointer UIs. // Elsewhere: a flat material panel with a hairline border (industrial, not a soft blob),
// Deliberately .regularMaterial, not Liquid Glass: HIG keeps glass off content // and a brand accent bar down the leading edge for the most-recent host.
// tiles (it flattens hierarchy over an opaque grid) see GlassStyle.swift. .background(.regularMaterial)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) .overlay(alignment: .leading) {
.overlay {
if isMostRecent { if isMostRecent {
RoundedRectangle(cornerRadius: 14) Rectangle().fill(Color.brand).frame(width: 3)
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
} }
} }
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
.strokeBorder(.quaternary, lineWidth: 1)
}
#endif #endif
} }
#if os(tvOS) #if os(tvOS)
.buttonStyle(.card) .buttonStyle(.card)
#elseif os(iOS)
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
#else #else
.buttonStyle(.plain) .buttonStyle(.plain)
#endif #endif
@@ -119,10 +146,31 @@ struct HostCardView: View {
Button("Remove", role: .destructive, action: onRemove) Button("Remove", role: .destructive, action: onRemove)
} }
} }
/// Technical status line: a square presence pip + monospaced ONLINE/OFFLINE, and PAIRED when a
/// certificate is pinned (the lock state, spelled out).
@ViewBuilder private func statusRow(_ m: CardMetrics) -> some View {
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 1.5)
.fill(isOnline ? Color.green : Color.secondary.opacity(0.4))
.frame(width: 6, height: 6)
// The state is spelled out in the adjacent text, so the pip is decorative
// otherwise VoiceOver reads the status twice ("Online, ONLINE ").
.accessibilityHidden(true)
Text(isOnline ? "ONLINE" : "OFFLINE")
if host.pinnedSHA256 != nil {
Text("· PAIRED")
}
}
.font(.geist(m.status, .medium, relativeTo: .caption2))
.tracking(0.8)
.foregroundStyle(.secondary)
.lineLimit(1)
}
} }
/// A host found on the LAN but not yet saved. A dashed ring distinguishes it from saved cards; /// A host found on the LAN but not yet saved. A tinted-outline monogram + dashed panel border
/// tapping saves it and connects (or pairs, if the host requires it). /// distinguish it from saved cards; tapping saves it and connects (or pairs, if required).
struct DiscoveredCardView: View { struct DiscoveredCardView: View {
let discovered: DiscoveredHost let discovered: DiscoveredHost
let isBusy: Bool let isBusy: Bool
@@ -131,47 +179,77 @@ struct DiscoveredCardView: View {
var body: some View { var body: some View {
let m = CardMetrics.current let m = CardMetrics.current
return Button(action: onConnect) { return Button(action: onConnect) {
VStack(spacing: 10) { HStack(spacing: m.spacing) {
Image(systemName: "play.display") monogramTile(monogram(discovered.name), m: m, connecting: false, filled: false)
.font(.system(size: m.iconSize, weight: .light)) VStack(alignment: .leading, spacing: 4) {
.foregroundStyle(.tint)
.frame(height: m.iconBox)
VStack(spacing: 2) {
Text(discovered.name) Text(discovered.name)
.font(m.nameFont) .font(.geist(m.name, .bold, relativeTo: .title3))
.foregroundStyle(.primary)
.lineLimit(1) .lineLimit(1)
HStack(spacing: 4) {
Image(systemName: discovered.requiresPairing ? "lock.fill" : "wifi")
.font(.system(size: 9))
.foregroundStyle(.secondary)
Text("\(discovered.host):\(String(discovered.port))") Text("\(discovered.host):\(String(discovered.port))")
.font(.caption) .font(.geist(m.meta, relativeTo: .caption))
.foregroundStyle(.secondary)
.lineLimit(1)
HStack(spacing: 6) {
Image(systemName: discovered.requiresPairing
? "lock.fill" : "antenna.radiowaves.left.and.right")
.font(.system(size: m.status))
.accessibilityHidden(true) // decorative; the adjacent text says the state
Text(discovered.requiresPairing ? "PAIRING REQUIRED" : "DISCOVERED")
}
.font(.geist(m.status, .medium, relativeTo: .caption2))
.tracking(0.8)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
} }
Text(discovered.requiresPairing ? "Pairing required" : "Discovered") Spacer(minLength: 0)
.font(.caption2)
.foregroundStyle(.tertiary)
} }
} .padding(m.padding)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, m.cardPadding)
.padding(.horizontal, 12)
#if !os(tvOS) #if !os(tvOS)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) .background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
.overlay { .overlay {
RoundedRectangle(cornerRadius: 14) RoundedRectangle(cornerRadius: m.radius, style: .continuous)
.strokeBorder( .strokeBorder(
Color.secondary.opacity(0.25), Color.secondary.opacity(0.3),
style: StrokeStyle(lineWidth: 1, dash: [4, 3])) style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
} }
#endif #endif
} }
#if os(tvOS) #if os(tvOS)
.buttonStyle(.card) .buttonStyle(.card)
#elseif os(iOS)
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
#else #else
.buttonStyle(.plain) .buttonStyle(.plain)
#endif #endif
.disabled(isBusy) .disabled(isBusy)
} }
} }
#if os(iOS)
/// The iOS host-card press/hover treatment, one style for both idioms:
/// - iPhone: a subtle scale-down on press + a light impact haptic on press-down. (`hoverEffect` is
/// inert without a pointer.)
/// - iPad: the system pointer "magnet" the cursor morphs into a highlight that conforms to the
/// card's rounded rect on hover. (`sensoryFeedback` is inert without a Taptic Engine, and the
/// press scale doubles as click feedback.)
struct HostCardButtonStyle: ButtonStyle {
var cornerRadius: CGFloat
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.96 : 1)
.animation(.spring(response: 0.3, dampingFraction: 0.65), value: configuration.isPressed)
// Conform the pointer highlight to the card's rounded rect, not its square bounds.
.contentShape(.hoverEffect, RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.hoverEffect(.highlight)
// Light tap on press-down (nil on release so it fires once, on touch). No haptic
// hardware on iPad silently ignored there.
.sensoryFeedback(trigger: configuration.isPressed) { _, pressed in
pressed ? .impact(weight: .light) : nil
}
}
}
#endif
@@ -146,7 +146,7 @@ private struct GameCard: View {
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(alignment: .topLeading) { storeBadge } .overlay(alignment: .topLeading) { storeBadge }
Text(game.title) Text(game.title)
.font(.caption) .font(.geist(12, relativeTo: .caption))
.lineLimit(2) .lineLimit(2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -154,7 +154,7 @@ private struct GameCard: View {
private var storeBadge: some View { private var storeBadge: some View {
Text(game.isCustom ? "Custom" : "Steam") Text(game.isCustom ? "Custom" : "Steam")
.font(.caption2.weight(.semibold)) .font(.geist(11, .semibold, relativeTo: .caption2))
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 3) .padding(.vertical, 3)
.background(.ultraThinMaterial, in: Capsule()) .background(.ultraThinMaterial, in: Capsule())
@@ -193,7 +193,7 @@ private struct PosterImage: View {
ZStack { ZStack {
Rectangle().fill(.quaternary) Rectangle().fill(.quaternary)
Text(title) Text(title)
.font(.headline) .font(.geist(17, .semibold, relativeTo: .headline))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(8) .padding(8)
@@ -48,7 +48,7 @@ struct PairSheet: View {
+ "(http://<host>:3000 → Pairing). " + "(http://<host>:3000 → Pairing). "
+ "Pairing verifies both sides at once — no fingerprint comparison " + "Pairing verifies both sides at once — no fingerprint comparison "
+ "needed.") + "needed.")
.font(.callout) .font(.geist(16, relativeTo: .callout))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
TVFieldRow( TVFieldRow(
@@ -59,7 +59,7 @@ struct PairSheet: View {
) { editing = .clientName } ) { editing = .clientName }
if let errorText { if let errorText {
Text(errorText) Text(errorText)
.font(.callout) .font(.geist(16, relativeTo: .callout))
.foregroundStyle(.red) .foregroundStyle(.red)
} }
HStack(spacing: 32) { HStack(spacing: 32) {
@@ -121,13 +121,13 @@ struct PairSheet: View {
+ "(http://<host>:3000 → Pairing). " + "(http://<host>:3000 → Pairing). "
+ "Pairing verifies both sides at once — no fingerprint " + "Pairing verifies both sides at once — no fingerprint "
+ "comparison needed.") + "comparison needed.")
.font(.caption) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if let errorText { if let errorText {
Section { Section {
Text(errorText) Text(errorText)
.font(.callout) .font(.geist(16, relativeTo: .callout))
.foregroundStyle(.red) .foregroundStyle(.red)
} }
} }
@@ -12,8 +12,19 @@ struct PunktfunkClientApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
#endif #endif
init() {
#if os(iOS)
// Put Geist on the navigation titles before any bar is built.
BrandTheme.apply()
#endif
}
var body: some Scene { var body: some Scene {
WindowGroup("Punktfunk") { WindowGroup("Punktfunk") {
// Pin the whole app's tint to the brand purple explicitly the asset-catalog accent
// resolution is environment/timing-sensitive and can fall back to system blue. Wraps the
// screenshot harness too, so captured screens are on-brand.
Group {
#if DEBUG #if DEBUG
// PUNKTFUNK_SHOT_SCENE=<name> show that single mock-populated screen full-bleed for // PUNKTFUNK_SHOT_SCENE=<name> show that single mock-populated screen full-bleed for
// the App Store screenshot capture (tools/screenshots.sh). Normal launch otherwise; // the App Store screenshot capture (tools/screenshots.sh). Normal launch otherwise;
@@ -27,6 +38,11 @@ struct PunktfunkClientApp: App {
ContentView() ContentView()
#endif #endif
} }
.tint(.brand)
// Geist Sans is the app's typeface. This sets the default for unstyled text and the
// form row labels; views that pick an explicit size/weight use `.geist()` directly.
.font(.geist(17, relativeTo: .body))
}
// The Stream menu (Disconnect D, Show/Hide Statistics S) a real menu bar on // The Stream menu (Disconnect D, Show/Hide Statistics S) a real menu bar on
// macOS, hardware-keyboard shortcuts on iPad. tvOS has neither. // macOS, hardware-keyboard shortcuts on iPad. tvOS has neither.
#if !os(tvOS) #if !os(tvOS)
@@ -34,7 +50,10 @@ struct PunktfunkClientApp: App {
#endif #endif
#if os(macOS) #if os(macOS)
Settings { Settings {
// A separate scene `.tint` does not cross scene boundaries, so re-apply the brand
// tint here or the Preferences window falls back to the (unreliable) asset accent.
SettingsView() SettingsView()
.tint(.brand)
} }
#endif #endif
} }
@@ -103,11 +103,11 @@ private struct ShotSettings: View {
.shadow(radius: 40, y: 16) .shadow(radius: 40, y: 16)
} }
#elseif os(iOS) #elseif os(iOS)
NavigationStack { // SettingsView owns its NavigationSplitView (sidebar + detail) and Done button, so it is
SettingsView() // rendered directly a wrapping NavigationStack would nest a split view in a stack. Open
.navigationTitle("Settings") // on General so the shot lands on real controls (iPad: sidebar + General detail; iPhone:
.navigationBarTitleDisplayMode(.inline) // the General page) instead of the bare category list.
} SettingsView(initialCategory: .general)
#else #else
NavigationStack { SettingsView() } NavigationStack { SettingsView() }
#endif #endif
@@ -175,10 +175,10 @@ private struct ShotHUD: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
#if os(macOS) #if os(macOS)
Text("⌘⎋ releases the mouse") Text("⌘⎋ releases the mouse")
.font(.caption2).foregroundStyle(.secondary) .font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
#elseif os(tvOS) #elseif os(tvOS)
Text("Press Menu to disconnect") Text("Press Menu to disconnect")
.font(.caption).foregroundStyle(.secondary) .font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
#endif #endif
} }
.padding(10) .padding(10)
@@ -259,7 +259,7 @@ private struct ShotDesktopFrame: View {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "gamecontroller.fill") Image(systemName: "gamecontroller.fill")
Text("Streaming from Battlestation") Text("Streaming from Battlestation")
.font(.system(.callout, weight: .semibold)) .font(.geist(16, .semibold, relativeTo: .callout))
} }
.padding(.horizontal, 14).padding(.vertical, 9) .padding(.horizontal, 14).padding(.vertical, 9)
.glassBackground(Capsule()) .glassBackground(Capsule())
@@ -1,10 +1,12 @@
// App settings. The host creates a native virtual output at exactly the chosen size/refresh // App settings. The host creates a native virtual output at exactly the chosen size/refresh
// there is no scaling anywhere in the pipeline. // there is no scaling anywhere in the pipeline.
// //
// Navigation differs per platform: macOS uses a tabbed preferences window (the sections had // Navigation differs per platform, but all three group the same categories (General, Display,
// outgrown one scrolling pane); iOS uses a single grouped Form; tvOS uses a focus-native // Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
// pushed-picker layout. The individual sections (`streamModeSection`, `audioSection`, ) are // an adaptive NavigationSplitView a category sidebar + detail pane on iPad, auto-collapsing to
// shared across all three so a setting is defined exactly once. // a hierarchical push list on iPhone (the system Settings idiom on each); tvOS uses a
// focus-native pushed-picker layout. The individual sections (`streamModeSection`,
// `audioSection`, ) are shared across all three so a setting is defined exactly once.
#if os(macOS) #if os(macOS)
import AppKit import AppKit
@@ -21,7 +23,8 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.compositor) private var compositor = 0 @AppStorage(DefaultsKey.compositor) private var compositor = 0
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0 @AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0 @AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
@AppStorage(DefaultsKey.presenter) private var presenter = "stage1" @AppStorage(DefaultsKey.presenter) private var presenter = "stage2"
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true @AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true @AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
@@ -32,6 +35,21 @@ struct SettingsView: View {
#if DEBUG && !os(tvOS) #if DEBUG && !os(tvOS)
@State private var showControllerTest = false @State private var showControllerTest = false
#endif #endif
#if os(iOS)
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
// Width class decides the initial value: nil on iPhone (show the category list first),
// General on iPad (a two-column layout should never open with an empty detail).
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var settingsSelection: SettingsCategory?
// Tracked so the detail can show its own Done whenever the sidebar (and its Done) is off screen
// not just on iPhone, but on any iPad layout that collapses the sidebar to an overlay. Starts
// .doubleColumn so iPad reliably opens with the sidebar (and its Done) visible.
@State private var columnVisibility: NavigationSplitViewVisibility = .doubleColumn
// Sticky once the wheel lands on "Custom", so editing a width/height that briefly equals a
// preset doesn't snap the wheel back off Custom. A stored non-preset value reads as custom even
// when this is false (see `isCustomResolution`), so it survives relaunches without persisting.
@State private var customMode = false
#endif
#if os(macOS) #if os(macOS)
@AppStorage(DefaultsKey.speakerUID) private var speakerUID = "" @AppStorage(DefaultsKey.speakerUID) private var speakerUID = ""
@AppStorage(DefaultsKey.micUID) private var micUID = "" @AppStorage(DefaultsKey.micUID) private var micUID = ""
@@ -39,6 +57,15 @@ struct SettingsView: View {
@State private var inputDevices: [AudioDevice] = [] @State private var inputDevices: [AudioDevice] = []
#endif #endif
#if os(iOS)
/// `initialCategory` is nil in the app (the list opens un-selected on iPhone; iPad lands on
/// General via `onAppear`). The screenshot harness passes an explicit category so the captured
/// shot opens on a real settings page (a populated detail) rather than the bare category list.
init(initialCategory: SettingsCategory? = nil) {
_settingsSelection = State(initialValue: initialCategory)
}
#endif
var body: some View { var body: some View {
#if os(tvOS) #if os(tvOS)
// Native tv pattern: no inline text entry (typing numbers with a remote is // Native tv pattern: no inline text entry (typing numbers with a remote is
@@ -66,6 +93,7 @@ struct SettingsView: View {
Form { Form {
presenterSection presenterSection
hdrSection
windowSection windowSection
statisticsSection statisticsSection
} }
@@ -106,29 +134,115 @@ struct SettingsView: View {
} }
#endif #endif
// MARK: - iOS: one grouped Form // MARK: - iOS / iPadOS: adaptive split view
#if os(iOS) #if os(iOS)
private var iosBody: some View { private var iosBody: some View {
Form { NavigationSplitView(columnVisibility: $columnVisibility) {
streamModeSection List(selection: $settingsSelection) {
audioSection ForEach(SettingsCategory.allCases) { category in
compositorSection // On iPhone the split view collapses to a push list, but a selection List
presenterSection // draws no disclosure indicator of its own add one in compact width for the
statisticsSection // expected drill-in affordance. On iPad the selected row highlights instead, so
experimentalSection // the chevron is omitted there.
controllersSection HStack {
Section { Label(category.title, systemImage: category.symbol)
NavigationLink("Acknowledgements") { AcknowledgementsView() } if horizontalSizeClass == .compact {
Spacer()
Image(systemName: "chevron.forward")
.font(.footnote.weight(.semibold))
.foregroundStyle(.tertiary)
// Purely a drill-in affordance the row's button trait already
// conveys "opens"; keep it out of the VoiceOver announcement.
.accessibilityHidden(true)
}
}
.tag(category)
}
}
.navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
} detail: {
// NavigationSplitView hosts the detail in its own navigation context (its title bar),
// so no inner NavigationStack that would double the bar on iPad. On iPhone the split
// view collapses to one stack and pushes this when a row is tapped. `?? .general` only
// backs the brief pre-selection window; the list never auto-pushes on a nil selection.
settingsDetail(settingsSelection ?? .general)
// Keep a Done on the detail whenever the sidebar (and its Done) isn't on screen: the
// iPhone push, or any iPad layout that collapsed the sidebar to an overlay. When the
// sidebar is showing, its Done is the only one so this stays hidden to avoid two.
.toolbar {
if horizontalSizeClass == .compact || columnVisibility == .detailOnly {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
} }
} }
.formStyle(.grouped)
.onAppear { .onAppear {
if horizontalSizeClass == .regular, settingsSelection == nil {
settingsSelection = .general
}
gamepads.refresh() gamepads.refresh()
gamepads.startDiscovery() gamepads.startDiscovery()
} }
// A regularregular launch sets the default above; this catches a compactregular change
// (e.g. an iPad leaving narrow split-screen multitasking) so the detail pane fills in.
.onChange(of: horizontalSizeClass) { _, newValue in
if newValue == .regular, settingsSelection == nil {
settingsSelection = .general
}
}
.onDisappear { gamepads.stopDiscovery() } .onDisappear { gamepads.stopDiscovery() }
} }
@ViewBuilder
private func settingsDetail(_ category: SettingsCategory) -> some View {
switch category {
case .general:
Form {
streamModeSection
compositorSection
}
.formStyle(.grouped)
.navigationTitle("General")
.navigationBarTitleDisplayMode(.inline)
case .display:
Form {
presenterSection
hdrSection
statisticsSection
}
.formStyle(.grouped)
.navigationTitle("Display")
.navigationBarTitleDisplayMode(.inline)
case .audio:
Form { audioSection }
.formStyle(.grouped)
.navigationTitle("Audio")
.navigationBarTitleDisplayMode(.inline)
case .controllers:
Form { controllersSection }
.formStyle(.grouped)
.navigationTitle("Controllers")
.navigationBarTitleDisplayMode(.inline)
case .advanced:
Form { experimentalSection }
.formStyle(.grouped)
.navigationTitle("Advanced")
.navigationBarTitleDisplayMode(.inline)
case .about:
// Already a full scrollable view that sets its own "Acknowledgements" title; pin the
// display mode inline to match the five sibling detail pages (it would otherwise inherit
// the large title from the "Settings" sidebar root).
AcknowledgementsView()
.navigationBarTitleDisplayMode(.inline)
}
}
#endif #endif
// MARK: - tvOS // MARK: - tvOS
@@ -156,6 +270,10 @@ struct SettingsView: View {
Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" }) Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" })
} }
private var hdrEnabledTag: Binding<String> {
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" })
}
private var tvBody: some View { private var tvBody: some View {
let currentTag = "\(width)x\(height)x\(hz)" let currentTag = "\(width)x\(height)x\(hz)"
let bounds = UIScreen.main.nativeBounds let bounds = UIScreen.main.nativeBounds
@@ -186,20 +304,25 @@ struct SettingsView: View {
selection: $audioChannels) selection: $audioChannels)
if bitrateKbps > 1_000_000 { if bitrateKbps > 1_000_000 {
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill") Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
.font(.caption) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.orange) .foregroundStyle(.orange)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
TVSelectionRow( TVSelectionRow(
title: "Compositor", options: compositors, selection: $compositor) title: "Compositor", options: compositors, selection: $compositor)
#if DEBUG
TVSelectionRow( TVSelectionRow(
title: "Presenter", title: "Presenter (debug)",
options: [("Stage 1 (default)", "stage1"), ("Stage 2 (experimental)", "stage2")], options: [("Stage 2 (default)", "stage2"), ("Stage 1 (debug)", "stage1")],
selection: $presenter) selection: $presenter)
#endif
TVSelectionRow(
title: "10-bit HDR",
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
Text("The host creates a virtual output at exactly this mode — native " Text("The host creates a virtual output at exactly this mode — native "
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor " + "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
+ "is honored only if available on the host.") + "is honored only if available on the host.")
.font(.caption) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.top, 8) .padding(.top, 8)
@@ -219,7 +342,7 @@ struct SettingsView: View {
TVSelectionRow( TVSelectionRow(
title: "Controller type", options: Self.padTypes, selection: $gamepadType) title: "Controller type", options: Self.padTypes, selection: $gamepadType)
Text(Self.controllersFooter) Text(Self.controllersFooter)
.font(.caption) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.top, 8) .padding(.top, 8)
@@ -243,6 +366,63 @@ struct SettingsView: View {
@ViewBuilder private var streamModeSection: some View { @ViewBuilder private var streamModeSection: some View {
Section { Section {
#if os(iOS)
// Touch-first: a rotating wheel of common resolutions (this device's own mode first) and
// a segmented refresh-rate control the same family as the Clock/Timer pickers. The host
// renders a virtual output at exactly the chosen mode, so these are real pixel sizes. The
// last wheel row, "Custom", reveals width/height/refresh fields for an arbitrary mode.
VStack(alignment: .leading, spacing: 4) {
Text("Resolution")
.font(.geist(15, relativeTo: .subheadline))
.foregroundStyle(.secondary)
Picker("Resolution", selection: resolutionSelection) {
ForEach(resolutionChoices, id: \.tag) { choice in
Text(choice.label).tag(choice.tag)
}
}
.labelsHidden()
.pickerStyle(.wheel)
.frame(maxHeight: 140)
}
if isCustomResolution {
// Arbitrary entry: type the exact width × height (and refresh) the host should drive.
HStack {
TextField("Width", value: $width, format: .number.grouping(.never))
.keyboardType(.numberPad)
Text("×")
TextField("Height", value: $height, format: .number.grouping(.never))
.labelsHidden()
.keyboardType(.numberPad)
}
// A row built from an HStack of TextFields otherwise insets its bottom separator to
// the inner content, clipping the hairline under "Width"; pin it to the cell edge.
.alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
LabeledContent("Refresh rate") {
TextField("Hz", value: $hz, format: .number.grouping(.never))
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
}
} else if refreshChoices.count > 1 {
VStack(alignment: .leading, spacing: 6) {
Text("Refresh rate")
.font(.geist(15, relativeTo: .subheadline))
.foregroundStyle(.secondary)
Picker("Refresh rate", selection: $hz) {
ForEach(refreshChoices, id: \.self) { rate in
Text("\(rate) Hz").tag(rate)
}
}
.labelsHidden()
.pickerStyle(.segmented)
}
} else {
// A device with a single supported rate (e.g. 60 Hz) has nothing to pick.
LabeledContent("Refresh rate") {
Text("\(hz) Hz").foregroundStyle(.secondary)
}
}
Button("Use this display's mode") { fillFromMainScreen() }
#elseif os(macOS)
HStack { HStack {
TextField("Resolution", value: $width, format: .number.grouping(.never)) TextField("Resolution", value: $width, format: .number.grouping(.never))
Text("×") Text("×")
@@ -253,6 +433,7 @@ struct SettingsView: View {
LabeledContent("") { LabeledContent("") {
Button("Use this display's mode") { fillFromMainScreen() } Button("Use this display's mode") { fillFromMainScreen() }
} }
#endif
#if !os(tvOS) #if !os(tvOS)
Toggle("Automatic bitrate", isOn: automaticBitrate) Toggle("Automatic bitrate", isOn: automaticBitrate)
if bitrateKbps != 0 { if bitrateKbps != 0 {
@@ -267,7 +448,7 @@ struct SettingsView: View {
} }
if bitrateKbps > 1_000_000 { if bitrateKbps > 1_000_000 {
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill") Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
.font(.caption) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.orange) .foregroundStyle(.orange)
} }
} }
@@ -277,11 +458,85 @@ struct SettingsView: View {
} footer: { } footer: {
Text("The host creates a virtual output at exactly this mode — " Text("The host creates a virtual output at exactly this mode — "
+ "native resolution, no scaling. \(Self.bitrateFooter)") + "native resolution, no scaling. \(Self.bitrateFooter)")
.font(.caption) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
#if os(iOS)
// MARK: - Stream mode (iOS wheel)
/// Sentinel wheel tag for the "Custom" row. Real tags are "WxH" (digits + "x"), so this can't
/// collide with a resolution.
private static let customResolutionTag = "custom"
/// 16:9 then ultrawide presets; the device's native mode is prepended at runtime.
private static let resolutionPresets: [(name: String, w: Int, h: Int)] = [
("720p", 1280, 720),
("1080p", 1920, 1080),
("1440p", 2560, 1440),
("4K", 3840, 2160),
("Ultrawide 1080p", 2560, 1080),
("Ultrawide 1440p", 3440, 1440),
("Super ultrawide", 5120, 1440),
]
/// The non-custom wheel rows: this device's native mode first, then the presets, deduped by
/// dimensions (native wins a tie).
private var resolutionModes: [(name: String, w: Int, h: Int)] {
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels
let native = (w: Int(max(bounds.width, bounds.height)), h: Int(min(bounds.width, bounds.height)))
let all = [(name: "This device", w: native.w, h: native.h)] + Self.resolutionPresets
var seen = Set<String>()
return all.filter { seen.insert("\($0.w)x\($0.h)").inserted }
}
/// Wheel rows: the resolution modes, then a "Custom" row that reveals the numeric fields.
private var resolutionChoices: [(label: String, tag: String)] {
resolutionModes.map { (label: "\($0.name) · \($0.w) × \($0.h)", tag: "\($0.w)x\($0.h)") }
+ [(label: "Custom…", tag: Self.customResolutionTag)]
}
private var presetResolutionTags: Set<String> {
Set(resolutionModes.map { "\($0.w)x\($0.h)" })
}
/// True when the editable custom fields should show: the wheel is parked on "Custom" (sticky),
/// or the stored size simply isn't one of the presets (e.g. a value synced from a Mac) so a
/// non-preset mode stays editable across relaunches without a persisted flag.
private var isCustomResolution: Bool {
customMode || !presetResolutionTags.contains("\(width)x\(height)")
}
/// The wheel works in "WxH" tags so one selection drives both width and height; the custom
/// sentinel toggles `customMode` instead of writing a size.
private var resolutionSelection: Binding<String> {
Binding(
get: { isCustomResolution ? Self.customResolutionTag : "\(width)x\(height)" },
set: { tag in
if tag == Self.customResolutionTag {
customMode = true
return
}
customMode = false
let parts = tag.split(separator: "x").compactMap { Int($0) }
guard parts.count == 2 else { return }
width = parts[0]
height = parts[1]
})
}
/// Refresh rates the device can actually display (no point asking the host to render frames the
/// screen can't show), plus any stored custom value so it stays selectable.
private var refreshChoices: [Int] {
let maxHz = UIScreen.main.maximumFramesPerSecond
var rates = [60, 120, 240].filter { $0 <= maxHz }
if rates.isEmpty { rates = [maxHz] }
if !rates.contains(hz) { rates.append(hz) }
return rates.sorted()
}
#endif
@ViewBuilder private var audioSection: some View { @ViewBuilder private var audioSection: some View {
Section { Section {
Picker("Audio channels", selection: $audioChannels) { Picker("Audio channels", selection: $audioChannels) {
@@ -321,7 +576,7 @@ struct SettingsView: View {
Text("Host audio plays through the speaker; the microphone feeds the " Text("Host audio plays through the speaker; the microphone feeds the "
+ "host's virtual mic. System default follows macOS device changes. " + "host's virtual mic. System default follows macOS device changes. "
+ "Applies from the next session.") + "Applies from the next session.")
.font(.caption) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
@@ -341,7 +596,7 @@ struct SettingsView: View {
Text("Which compositor drives the virtual output on the host. A specific " Text("Which compositor drives the virtual output on the host. A specific "
+ "choice is honored only if that backend is available there — " + "choice is honored only if that backend is available there — "
+ "otherwise the host falls back to auto-detection.") + "otherwise the host falls back to auto-detection.")
.font(.caption) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
@@ -355,26 +610,47 @@ struct SettingsView: View {
} footer: { } footer: {
Text("Take the window fullscreen when a session starts and restore it on the host " Text("Take the window fullscreen when a session starts and restore it on the host "
+ "list, so only the stream is fullscreen — not the picker.") + "list, so only the stream is fullscreen — not the picker.")
.font(.caption) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
#endif #endif
} }
// Stage-2 (Metal/VTDecompressionSession) is the default and only user-visible presenter it
// recovers from a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a
// lost HEVC reference. Stage-1 is kept reachable as a DEBUG-only override for diagnostics, like
// the controller test. Empty in release builds (no presenter UI; stage-2 always).
@ViewBuilder private var presenterSection: some View { @ViewBuilder private var presenterSection: some View {
#if DEBUG
Section { Section {
Picker("Presenter", selection: $presenter) { Picker("Presenter", selection: $presenter) {
Text("Stage 1 (default)").tag("stage1") Text("Stage 2 (default)").tag("stage2")
Text("Stage 2 (experimental)").tag("stage2") Text("Stage 1 (debug)").tag("stage1")
} }
} header: { } header: {
Text("Video presenter") Text("Video presenter · debug")
} footer: { } footer: {
Text("Stage 1 feeds compressed video to the system display layer (known-good). " Text("Stage 2 (default) decodes explicitly and presents through Metal with a display "
+ "Stage 2 decodes explicitly and presents through Metal with a display " + "link — it adds a capture→present (glass-to-glass) latency line in the HUD and "
+ "link — it adds a capture→present (glass-to-glass) latency line in the HUD " + "self-recovers from decode stalls. Stage 1 feeds compressed video straight to the "
+ "and shortens the present tail. Applies from the next session.") + "system display layer; it freezes on a lost HEVC reference frame, so it's a debug "
.font(.caption) + "fallback only. Applies from the next session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
#endif
}
@ViewBuilder private var hdrSection: some View {
Section {
Toggle("10-bit HDR", isOn: $hdrEnabled)
} header: {
Text("HDR")
} footer: {
Text("Request a 10-bit BT.2020 PQ (HDR10) stream. It only engages when the host is "
+ "sending HDR content AND this display supports HDR — otherwise the stream stays "
+ "8-bit SDR. Applies from the next session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
@@ -392,7 +668,7 @@ struct SettingsView: View {
Text("Statistics") Text("Statistics")
} footer: { } footer: {
Text(Self.statisticsFooter) Text(Self.statisticsFooter)
.font(.caption) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
@@ -407,7 +683,7 @@ struct SettingsView: View {
+ "(Steam + custom) via the host's management API; tap a title to launch it. " + "(Steam + custom) via the host's management API; tap a title to launch it. "
+ "The host must expose that API on the LAN with a token " + "The host must expose that API on the LAN with a token "
+ "(serve --mgmt-bind 0.0.0.0 --mgmt-token …).") + "(serve --mgmt-bind 0.0.0.0 --mgmt-token …).")
.font(.caption) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
@@ -441,7 +717,7 @@ struct SettingsView: View {
Text("Controllers") Text("Controllers")
} footer: { } footer: {
Text(Self.controllersFooter) Text(Self.controllersFooter)
.font(.caption) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
@@ -593,13 +869,13 @@ struct SettingsView: View {
} }
} }
} }
.font(.caption2) .font(.geist(11, relativeTo: .caption2))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
if gamepads.active?.id == controller.id { if gamepads.active?.id == controller.id {
Text("In use") Text("In use")
.font(.caption2.weight(.semibold)) .font(.geist(11, .semibold, relativeTo: .caption2))
.padding(.horizontal, 8) .padding(.horizontal, 8)
.padding(.vertical, 3) .padding(.vertical, 3)
.background(Capsule().fill(.green.opacity(0.2))) .background(Capsule().fill(.green.opacity(0.2)))
@@ -621,6 +897,10 @@ struct SettingsView: View {
width = Int(max(bounds.width, bounds.height)) width = Int(max(bounds.width, bounds.height))
height = Int(min(bounds.width, bounds.height)) height = Int(min(bounds.width, bounds.height))
hz = UIScreen.main.maximumFramesPerSecond hz = UIScreen.main.maximumFramesPerSecond
#if os(iOS)
// The native mode is the "This device" wheel row, so leave Custom mode if it was on.
customMode = false
#endif
#endif #endif
} }
} }
@@ -631,3 +911,52 @@ extension Double {
Swift.min(Swift.max(self, lo), hi) Swift.min(Swift.max(self, lo), hi)
} }
} }
#if os(iOS)
/// The settings groups, mirroring the macOS preference tabs. On iPad each is a sidebar row that
/// drives the detail pane; on iPhone the same list collapses to pushed sub-pages. Internal (not
/// private) so the screenshot harness can open SettingsView on a specific category.
enum SettingsCategory: String, CaseIterable, Identifiable {
case general, display, audio, controllers, advanced, about
var id: Self { self }
var title: String {
switch self {
case .general: return "General"
case .display: return "Display"
case .audio: return "Audio"
case .controllers: return "Controllers"
case .advanced: return "Advanced"
case .about: return "About"
}
}
var symbol: String {
switch self {
case .general: return "gearshape"
case .display: return "display"
case .audio: return "speaker.wave.2"
case .controllers: return "gamecontroller"
case .advanced: return "slider.horizontal.3"
case .about: return "info.circle"
}
}
}
extension View {
/// Present the settings sheet large on iPad so the NavigationSplitView has room for its
/// sidebar + detail a default form sheet is too narrow and the split view would collapse to
/// the iPhone push list. No-op on iPhone (the standard sheet is already right) and on iOS 17
/// (no `presentationSizing` it falls back to the default sheet, which still degrades cleanly
/// to the push list).
@ViewBuilder
func settingsSheetSizing() -> some View {
if UIDevice.current.userInterfaceIdiom == .pad, #available(iOS 18, *) {
presentationSizing(.page)
} else {
self
}
}
}
#endif
@@ -52,7 +52,7 @@ struct SpeedTestSheet: View {
var body: some View { var body: some View {
VStack(spacing: 20) { VStack(spacing: 20) {
Label("Speed test — \(host.displayName)", systemImage: "gauge.with.needle") Label("Speed test — \(host.displayName)", systemImage: "gauge.with.needle")
.font(.headline) .font(.geist(17, .semibold, relativeTo: .headline))
.foregroundStyle(.tint) .foregroundStyle(.tint)
switch phase { switch phase {
@@ -73,7 +73,7 @@ struct SpeedTestSheet: View {
resultView(result) resultView(result)
case .failed(let message): case .failed(let message):
Text(message) Text(message)
.font(.callout) .font(.geist(16, relativeTo: .callout))
.foregroundStyle(.red) .foregroundStyle(.red)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
@@ -149,13 +149,13 @@ struct SpeedTestSheet: View {
if let rec = Self.recommendedKbps(result) { if let rec = Self.recommendedKbps(result) {
Text("Recommended bitrate: \(Self.mbpsLabel(kbps: rec)) " Text("Recommended bitrate: \(Self.mbpsLabel(kbps: rec)) "
+ "(~70% of measured, headroom for encoder bursts).") + "(~70% of measured, headroom for encoder bursts).")
.font(.caption) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} else { } else {
Text("Too little data made it through to recommend a bitrate — " Text("Too little data made it through to recommend a bitrate — "
+ "check the network and retry.") + "check the network and retry.")
.font(.caption) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
@@ -69,19 +69,19 @@ struct StreamHUDView: View {
Text(model.mouseCaptured Text(model.mouseCaptured
? "⌘⎋ releases the mouse" ? "⌘⎋ releases the mouse"
: "Click the stream to capture input") : "Click the stream to capture input")
.font(.caption2) .font(.geist(11, relativeTo: .caption2))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
// The client-side cursor (C) draws the local cursor over the stream instead of // The client-side cursor (C) draws the local cursor over the stream instead of
// capturing it the only accurate cursor for gamescope, whose capture has none. // capturing it the only accurate cursor for gamescope, whose capture has none.
Text("⌘⇧C toggles the on-screen cursor") Text("⌘⇧C toggles the on-screen cursor")
.font(.caption2) .font(.geist(11, relativeTo: .caption2))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
#elseif os(iOS) #elseif os(iOS)
// Touch always plays directly; (hardware keyboard) toggles kb/mouse. // Touch always plays directly; (hardware keyboard) toggles kb/mouse.
Text(model.mouseCaptured Text(model.mouseCaptured
? "⌘⎋ releases keyboard & mouse" ? "⌘⎋ releases keyboard & mouse"
: "⌘⎋ captures keyboard & mouse") : "⌘⎋ captures keyboard & mouse")
.font(.caption2) .font(.geist(11, relativeTo: .caption2))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
#endif #endif
#if os(tvOS) #if os(tvOS)
@@ -89,13 +89,13 @@ struct StreamHUDView: View {
// A press (the focus engine consumes it before the host sees it). Disconnect is // A press (the focus engine consumes it before the host sees it). Disconnect is
// the Siri Remote's Menu button (.onExitCommand on the stream) just hint it. // the Siri Remote's Menu button (.onExitCommand on the stream) just hint it.
Text("Press Menu to disconnect") Text("Press Menu to disconnect")
.font(.caption) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
#else #else
// D lives on the app's Stream menu (so it still works when the HUD is hidden); // D lives on the app's Stream menu (so it still works when the HUD is hidden);
// this button is the in-overlay, click-to-disconnect affordance. // this button is the in-overlay, click-to-disconnect affordance.
Button("Disconnect (⌘D)") { model.disconnect() } Button("Disconnect (⌘D)") { model.disconnect() }
.font(.caption) .font(.geist(12, relativeTo: .caption))
#endif #endif
} }
.padding(10) .padding(10)
@@ -3,6 +3,7 @@
// or drops this and runs the PIN pairing ceremony instead. // or drops this and runs the PIN pairing ceremony instead.
import Foundation import Foundation
import PunktfunkKit
import SwiftUI import SwiftUI
struct TrustCardView: View { struct TrustCardView: View {
@@ -18,11 +19,11 @@ struct TrustCardView: View {
.font(.system(size: 36, weight: .light)) .font(.system(size: 36, weight: .light))
.foregroundStyle(.tint) .foregroundStyle(.tint)
Text("Verify \(hostName)") Text("Verify \(hostName)")
.font(.title3.weight(.semibold)) .font(.geist(20, .semibold, relativeTo: .title3))
Text("First connection. Compare this fingerprint with the one " Text("First connection. Compare this fingerprint with the one "
+ "punktfunk-host logged at startup (\u{201C}clients pin this " + "punktfunk-host logged at startup (\u{201C}clients pin this "
+ "fingerprint\u{201D}):") + "fingerprint\u{201D}):")
.font(.callout) .font(.geist(16, relativeTo: .callout))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Text(Self.format(fingerprint: fingerprint)) Text(Self.format(fingerprint: fingerprint))
@@ -58,7 +59,7 @@ struct TrustCardView: View {
#else #else
.buttonStyle(.borderless) .buttonStyle(.borderless)
#endif #endif
.font(.callout) .font(.geist(16, relativeTo: .callout))
} }
.padding(28) .padding(28)
.frame(maxWidth: 440) .frame(maxWidth: 440)
@@ -0,0 +1,101 @@
// Geist the punktfunk brand typeface (the same family the website ships). Bundled as static
// OTF weights in this kit's resources and registered with Core Text at first use, so it works
// identically in the Xcode app and the `swift run` dev shell (Bundle.module resolves to the
// package resource bundle in both). Geist Sans carries titles/UI; Geist Mono carries the technical
// readouts host addresses, status labels, the stream-stats HUD for the industrial look.
//
// Licensed under the SIL Open Font License 1.1 (Resources/Fonts/Geist-OFL.txt).
import CoreText
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
public enum BrandFont {
public enum Weight {
case regular, medium, semibold, bold
}
/// PostScript names of the bundled faces (verified from each OTF's name table). Geist Sans only
/// Geist Mono is intentionally not shipped; the app's typeface is Geist Sans throughout.
private static let sansFaces = ["Geist-Regular", "Geist-Medium", "Geist-SemiBold", "Geist-Bold"]
/// Registered exactly once per process a static `let` initializer is run lazily and is
/// guaranteed thread-safe + run-at-most-once by the runtime.
private static let registered: Void = {
for face in sansFaces {
guard let url = Bundle.module.url(
forResource: face, withExtension: "otf", subdirectory: "Fonts") else {
#if DEBUG
print("BrandFont: bundled face \(face).otf not found — text will fall back to system")
#endif
continue
}
var error: Unmanaged<CFError>?
if !CTFontManagerRegisterFontsForURL(url as CFURL, .process, &error) {
#if DEBUG
let message = error?.takeRetainedValue().localizedDescription ?? "unknown error"
print("BrandFont: failed to register \(face): \(message)")
#endif
}
}
}()
/// Force registration before the first `Font.custom` lookup. Cheap to call repeatedly.
public static func registerIfNeeded() { _ = registered }
fileprivate static func sansFace(_ weight: Weight) -> String {
switch weight {
case .regular: return "Geist-Regular"
case .medium: return "Geist-Medium"
case .semibold: return "Geist-SemiBold"
case .bold: return "Geist-Bold"
}
}
}
public extension Color {
/// The punktfunk brand purple (the app-icon lens / website `--brand`). Defined explicitly,
/// independent of the asset-catalog accent `Color.accentColor` resolution is environment- and
/// timing-sensitive (it can fall back to system blue), and the brand mark must never drift.
/// Light: #6656F2, Dark: #8678F5 (the lighter violet reads better on dark surfaces).
static let brand: Color = {
#if canImport(UIKit)
return Color(UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
: UIColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
})
#elseif canImport(AppKit)
return Color(NSColor(name: nil) { appearance in
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
? NSColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
: NSColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
})
#else
// Non-Apple fallback: the light brand value, so all branches agree on a canonical color.
return Color(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255)
#endif
}()
}
public extension Font {
/// Geist Sans at an explicit point size, scaling with Dynamic Type relative to `textStyle`.
static func geist(
_ size: CGFloat, _ weight: BrandFont.Weight = .regular,
relativeTo textStyle: TextStyle = .body
) -> Font {
BrandFont.registerIfNeeded()
return .custom(BrandFont.sansFace(weight), size: size, relativeTo: textStyle)
}
/// Geist Sans at a FIXED point size that does not scale with Dynamic Type for glyphs pinned
/// inside a fixed-size container (e.g. the monogram tile), where a scaled letter would overflow.
static func geistFixed(_ size: CGFloat, _ weight: BrandFont.Weight = .regular) -> Font {
BrandFont.registerIfNeeded()
return .custom(BrandFont.sansFace(weight), fixedSize: size)
}
}
@@ -22,6 +22,9 @@ public enum DefaultsKey {
public static let speakerUID = "punktfunk.speakerUID" public static let speakerUID = "punktfunk.speakerUID"
public static let micUID = "punktfunk.micUID" public static let micUID = "punktfunk.micUID"
public static let presenter = "punktfunk.presenter" public static let presenter = "punktfunk.presenter"
/// Request a 10-bit BT.2020 PQ (HDR10) stream. On by default; only takes effect when the host
/// has HDR content AND this display supports HDR otherwise the stream stays 8-bit SDR.
public static let hdrEnabled = "punktfunk.hdrEnabled"
public static let hosts = "punktfunk.hosts" public static let hosts = "punktfunk.hosts"
/// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never". /// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never".
public static let cursorMode = "punktfunk.cursorMode" public static let cursorMode = "punktfunk.cursorMode"
@@ -68,6 +68,14 @@ private final class RumbleRenderer: @unchecked Sendable {
private var broken = false private var broken = false
/// Last logged active/silent state for a one-line transition log, not per-event spam. /// Last logged active/silent state for a one-line transition log, not per-event spam.
private var wasActive = false private var wasActive = false
// Backoff after an engine failure. A broken `gamecontrollerd.haptics` XPC connection (CoreHaptics
// -4811 "server connection broke") fails EVERY rebuild until the service relaunches and that
// break fires neither stoppedHandler nor resetHandler, so without a cooldown the next rumble
// update immediately rebuilds into the same dead connection, flooding the log and never
// recovering. Delay the next setup() growing 0.5124 s on repeated failure and clear it
// the moment a player runs cleanly (or the controller changes).
private var retryAfter = Date.distantPast
private var consecutiveFailures = 0
/// CHHapticEvent sharpness = actuator frequency. A DualSense's voice-coil motors need a /// CHHapticEvent sharpness = actuator frequency. A DualSense's voice-coil motors need a
/// defined frequency to move at all an intensity-only event (no sharpness) left them /// defined frequency to move at all an intensity-only event (no sharpness) left them
@@ -91,6 +99,8 @@ private final class RumbleRenderer: @unchecked Sendable {
self.closeHID() self.closeHID()
self.controller = c self.controller = c
self.broken = false self.broken = false
self.consecutiveFailures = 0
self.retryAfter = .distantPast
_ = self.openHIDIfDualSense(c) _ = self.openHIDIfDualSense(c)
onBackend?(self.backendNote(for: c)) onBackend?(self.backendNote(for: c))
} }
@@ -108,7 +118,7 @@ private final class RumbleRenderer: @unchecked Sendable {
// other pad (and for a DualSense whose HID device could not be opened). // other pad (and for a DualSense whose HID device could not be opened).
if self.hidRumble(low: lowAmp, high: highAmp) { return } if self.hidRumble(low: lowAmp, high: highAmp) { return }
guard !self.broken else { return } guard !self.broken else { return }
if active, self.low == nil, self.high == nil { if active, self.low == nil, self.high == nil, Date() >= self.retryAfter {
self.setup() self.setup()
} }
let ok: Bool let ok: Bool
@@ -124,8 +134,15 @@ private final class RumbleRenderer: @unchecked Sendable {
} }
// Rebuild on the next nonzero amplitude if an engine errored and tear down OUTSIDE // Rebuild on the next nonzero amplitude if an engine errored and tear down OUTSIDE
// the `inout` accesses above, so teardown() never mutates a motor that a `drive` call // the `inout` accesses above, so teardown() never mutates a motor that a `drive` call
// still holds an exclusive reference to. // still holds an exclusive reference to. Back off so a broken XPC isn't re-hit every
if !ok { self.teardown() } // update; once a player is actually running the path has recovered, so clear the backoff.
if !ok {
self.teardown()
self.scheduleRetryBackoff()
} else if self.low?.player != nil || self.high?.player != nil {
self.consecutiveFailures = 0
self.retryAfter = .distantPast
}
} }
} }
@@ -157,14 +174,29 @@ private final class RumbleRenderer: @unchecked Sendable {
low = makeMotor(haptics, .default) low = makeMotor(haptics, .default)
} }
if low == nil, high == nil { if low == nil, high == nil {
// Haptics present but no engine could be built right now (server busy / a transient // Haptics present but no engine could be built right now (server busy / XPC broken). Do
// error). Do NOT latch broken the next nonzero amplitude retries setup(). // NOT latch broken back off and the next nonzero amplitude past the cooldown retries.
log.warning("rumble: haptics present but engine setup failed — will retry on next rumble") log.warning("rumble: haptics present but engine setup failed — backing off, will retry")
scheduleRetryBackoff()
} }
} }
/// Push the next engine-build attempt out after a failure (capped exponential backoff), so a
/// broken `gamecontrollerd.haptics` connection gets time to relaunch instead of being re-hit on
/// every rumble update.
private func scheduleRetryBackoff() {
consecutiveFailures += 1
let shift = min(consecutiveFailures - 1, 4)
retryAfter = Date().addingTimeInterval(min(0.5 * Double(1 << shift), 4))
}
private func makeMotor(_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality) -> Motor? { private func makeMotor(_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality) -> Motor? {
guard let engine = haptics.createEngine(withLocality: locality) else { return nil } guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
// A controller's motors carry no audio, so keep this engine OUT of the app's audio session
// (the default is to join it). Streaming keeps an AVAudioSession active the whole time;
// letting a haptics-only engine join it is a needless coupling that can get its
// gamecontrollerd XPC connection interrupted (the repeated -4811 server-connection breaks).
engine.playsHapticsOnly = true
// The haptic server can stop or reset the engine out from under us app backgrounding, an // The haptic server can stop or reset the engine out from under us app backgrounding, an
// audio-session interruption (a call, Siri, another audio app), or a server crash. Left // audio-session interruption (a call, Siri, another audio app), or a server crash. Left
// unhandled the players go dead and every later rumble throws, latching rumble off for the // unhandled the players go dead and every later rumble throws, latching rumble off for the
@@ -27,10 +27,35 @@ public enum Licenses {
+ apache + apache
} }
/// The bundled brand typeface (Geist Sans + Geist Mono) SIL Open Font License 1.1. The
/// license file ships alongside the OTFs in `Resources/Fonts/`, satisfying the OFL's
/// distribution requirement; this surfaces it in the Acknowledgements screen too.
public static var fontLicense: String {
guard let url = Bundle.module.url(
forResource: "Geist-OFL", withExtension: "txt", subdirectory: "Fonts"),
let text = try? String(contentsOf: url, encoding: .utf8)
else { return "" }
return text
}
/// Third-party software notices for the linked Rust crates (generated by /// Third-party software notices for the linked Rust crates (generated by
/// `scripts/gen-third-party-notices.sh`). /// `scripts/gen-third-party-notices.sh`).
public static var thirdPartyNotices: String { public static var thirdPartyNotices: String {
let text = resource("THIRD-PARTY-NOTICES") let text = resource("THIRD-PARTY-NOTICES")
return text.isEmpty ? "Third-party notices unavailable." : text return text.isEmpty ? "Third-party notices unavailable." : text
} }
/// `thirdPartyNotices` pre-split into render-sized line chunks. The full notices are ~885 KB /
/// 16k lines; a single SwiftUI `Text` that large overshoots CoreText/CoreAnimation's max
/// renderable height it lays out for ages and draws blank past the limit so the
/// Acknowledgements screen renders these chunks in a `LazyVStack` (only on-screen chunks lay
/// out, and no chunk is tall enough to clip). Split at line boundaries and joined with "\n";
/// the inter-chunk break is the `LazyVStack` row boundary, so no text is lost. Computed once.
public static let thirdPartyNoticesChunks: [String] = {
let lines = thirdPartyNotices.split(separator: "\n", omittingEmptySubsequences: false)
let chunkSize = 200
return stride(from: 0, to: lines.count, by: chunkSize).map { start in
lines[start..<min(start + chunkSize, lines.count)].joined(separator: "\n")
}
}()
} }
@@ -11,6 +11,9 @@ import CoreGraphics
import CoreVideo import CoreVideo
import Metal import Metal
import QuartzCore import QuartzCore
import os
private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "presenter")
/// Runtime-compiled (no metallib build step needed in SwiftPM): a fullscreen triangle and a /// Runtime-compiled (no metallib build step needed in SwiftPM): a fullscreen triangle and a
/// BT.709 limited-range NV12RGB fragment shader. uv.y is flipped (1 - p.y) so the top-left- /// BT.709 limited-range NV12RGB fragment shader. uv.y is flipped (1 - p.y) so the top-left-
@@ -30,11 +33,44 @@ vertex VOut pf_vtx(uint vid [[vertex_id]]) {
return o; return o;
} }
// Bicubic (Catmull-Rom) sampling of the single-channel luma plane. When the drawable is larger
// than the decoded frame (a window/view bigger than the host's fixed mode), a bilinear upscale
// looks soft; Catmull-Rom keeps edges crisp — matching AVSampleBufferDisplayLayer's (stage-1)
// scaler — and reduces to the exact texel at 1:1, so a native-resolution present stays pixel-exact.
// Nine bilinear taps (TheRealMJP's optimisation of the 16-tap kernel); `s` MUST be a linear
// sampler. Luma carries the perceived detail, so only it gets bicubic; chroma stays bilinear.
float catmullRomLuma(texture2d<float> tex, sampler s, float2 uv) {
float2 texSize = float2(tex.get_width(), tex.get_height());
float2 samplePos = uv * texSize;
float2 tc1 = floor(samplePos - 0.5) + 0.5;
float2 f = samplePos - tc1;
float2 w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
float2 w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
float2 w2 = f * (0.5 + f * (2.0 - 1.5 * f));
float2 w3 = f * f * (-0.5 + 0.5 * f);
float2 w12 = w1 + w2;
float2 off12 = w2 / w12;
float2 tc0 = (tc1 - 1.0) / texSize;
float2 tc3 = (tc1 + 2.0) / texSize;
float2 tc12 = (tc1 + off12) / texSize;
float r = 0.0;
r += tex.sample(s, float2(tc0.x, tc0.y)).r * (w0.x * w0.y);
r += tex.sample(s, float2(tc12.x, tc0.y)).r * (w12.x * w0.y);
r += tex.sample(s, float2(tc3.x, tc0.y)).r * (w3.x * w0.y);
r += tex.sample(s, float2(tc0.x, tc12.y)).r * (w0.x * w12.y);
r += tex.sample(s, float2(tc12.x, tc12.y)).r * (w12.x * w12.y);
r += tex.sample(s, float2(tc3.x, tc12.y)).r * (w3.x * w12.y);
r += tex.sample(s, float2(tc0.x, tc3.y)).r * (w0.x * w3.y);
r += tex.sample(s, float2(tc12.x, tc3.y)).r * (w12.x * w3.y);
r += tex.sample(s, float2(tc3.x, tc3.y)).r * (w3.x * w3.y);
return r;
}
fragment float4 pf_frag(VOut in [[stage_in]], fragment float4 pf_frag(VOut in [[stage_in]],
texture2d<float> lumaTex [[texture(0)]], texture2d<float> lumaTex [[texture(0)]],
texture2d<float> chromaTex [[texture(1)]]) { texture2d<float> chromaTex [[texture(1)]]) {
constexpr sampler s(filter::linear, address::clamp_to_edge); constexpr sampler s(filter::linear, address::clamp_to_edge);
float y = lumaTex.sample(s, in.uv).r; float y = catmullRomLuma(lumaTex, s, in.uv);
float2 c = chromaTex.sample(s, in.uv).rg; float2 c = chromaTex.sample(s, in.uv).rg;
// BT.709, 8-bit limited (video) range → full-range RGB. // BT.709, 8-bit limited (video) range → full-range RGB.
y = (y - 16.0/255.0) * (255.0/219.0); y = (y - 16.0/255.0) * (255.0/219.0);
@@ -55,7 +91,7 @@ fragment float4 pf_frag_hdr(VOut in [[stage_in]],
texture2d<float> lumaTex [[texture(0)]], texture2d<float> lumaTex [[texture(0)]],
texture2d<float> chromaTex [[texture(1)]]) { texture2d<float> chromaTex [[texture(1)]]) {
constexpr sampler s(filter::linear, address::clamp_to_edge); constexpr sampler s(filter::linear, address::clamp_to_edge);
float y = lumaTex.sample(s, in.uv).r; float y = catmullRomLuma(lumaTex, s, in.uv);
float2 c = chromaTex.sample(s, in.uv).rg; float2 c = chromaTex.sample(s, in.uv).rg;
// BT.2020 10-bit limited (video) range → full-range PQ R'G'B'. // BT.2020 10-bit limited (video) range → full-range PQ R'G'B'.
y = (y - 64.0/1023.0) * (1023.0/876.0); y = (y - 64.0/1023.0) * (1023.0/876.0);
@@ -81,6 +117,11 @@ public final class MetalVideoPresenter {
private var textureCache: CVMetalTextureCache? private var textureCache: CVMetalTextureCache?
/// Current layer configuration switched lazily in `configure(hdr:)` when a frame's mode differs. /// Current layer configuration switched lazily in `configure(hdr:)` when a frame's mode differs.
private var hdrActive = false private var hdrActive = false
#if DEBUG
/// Last logged "decodeddrawable" signature, so the diagnostic logs only when a size changes
/// (on first frame, a resize, or a host Reconfigure) instead of every frame.
private var lastSizeSig = ""
#endif
/// nil if Metal is unavailable (no GPU / a headless CI) the caller falls back to stage-1. /// nil if Metal is unavailable (no GPU / a headless CI) the caller falls back to stage-1.
public init?() { public init?() {
@@ -113,6 +154,12 @@ public final class MetalVideoPresenter {
layer.pixelFormat = .bgra8Unorm layer.pixelFormat = .bgra8Unorm
layer.framebufferOnly = true layer.framebufferOnly = true
layer.isOpaque = true layer.isOpaque = true
// Render the drawable at the DECODED frame's resolution (set per-frame in `render`) and let
// the system compositor scale it to the layer's bounds the same `.resizeAspect` path
// stage-1's AVSampleBufferDisplayLayer (videoGravity) uses, so stage-2 matches its sharpness.
// A native-resolution present is then pixel-exact (1:1, no shader scaling), and any display
// scaling uses the system's high-quality scaler rather than the in-shader bicubic.
layer.contentsGravity = .resizeAspect
// Triple-buffer: more in-flight drawables before `nextDrawable()` (called on the // Triple-buffer: more in-flight drawables before `nextDrawable()` (called on the
// display-link / MAIN thread) has to block waiting for one to free. // display-link / MAIN thread) has to block waiting for one to free.
layer.maximumDrawableCount = 3 layer.maximumDrawableCount = 3
@@ -129,12 +176,6 @@ public final class MetalVideoPresenter {
self.layer = layer self.layer = layer
} }
/// Track the stream mode (the host can Reconfigure mid-stream). Size is in pixels.
public func setDrawableSize(_ size: CGSize) {
guard size.width > 0, size.height > 0 else { return }
if layer.drawableSize != size { layer.drawableSize = size }
}
/// Reconfigure the layer for SDR or HDR when the stream mode flips (HDR toggle). HDR uses an /// Reconfigure the layer for SDR or HDR when the stream mode flips (HDR toggle). HDR uses an
/// rgba16Float drawable + a BT.2020 PQ colour space + EDR, so the compositor PQ-maps to the /// rgba16Float drawable + a BT.2020 PQ colour space + EDR, so the compositor PQ-maps to the
/// display; SDR uses the plain 8-bit sRGB path. Main-thread only (called from `render`). /// display; SDR uses the plain 8-bit sRGB path. Main-thread only (called from `render`).
@@ -171,13 +212,33 @@ public final class MetalVideoPresenter {
let chroma = makeTexture(pixelBuffer, plane: 1, format: chromaFmt, cache: textureCache) let chroma = makeTexture(pixelBuffer, plane: 1, format: chromaFmt, cache: textureCache)
else { return false } else { return false }
// The hosting view owns drawableSize (aspect-fit to its bounds); skip until it's laid // Size the drawable to the decoded frame so the fullscreen triangle samples the texture 1:1
// out. The fullscreen triangle scales the decoded texture to fill the drawable. // (pixel-exact); the layer's contentsGravity then scales it to the on-screen bounds via the
guard layer.drawableSize.width > 0, layer.drawableSize.height > 0, // system compositor (matching stage-1). Re-set only on a change (first frame / Reconfigure).
let drawable = layer.nextDrawable(), let decodedSize = CGSize(
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
if layer.drawableSize != decodedSize { layer.drawableSize = decodedSize }
guard let drawable = layer.nextDrawable(),
let commandBuffer = queue.makeCommandBuffer() let commandBuffer = queue.makeCommandBuffer()
else { return false } else { return false }
#if DEBUG
// Diagnose sharpness: decoded should equal the drawable (the shader is 1:1); the layer's
// bounds may differ (the system scales). Logged only when a size changes.
let decodedW = Int(decodedSize.width)
let decodedH = Int(decodedSize.height)
let sig = "\(decodedW)x\(decodedH)|\(Int(layer.drawableSize.width))x\(Int(layer.drawableSize.height))"
if sig != lastSizeSig {
lastSizeSig = sig
let msg = "stage2: decoded \(decodedW)x\(decodedH) → drawable "
+ "\(Int(layer.drawableSize.width))x\(Int(layer.drawableSize.height)) "
+ "(texture \(drawable.texture.width)x\(drawable.texture.height), "
+ "contentsScale \(layer.contentsScale), "
+ "layerBounds \(Int(layer.bounds.width))x\(Int(layer.bounds.height)))"
presenterLog.info("\(msg, privacy: .public)")
}
#endif
let pass = MTLRenderPassDescriptor() let pass = MTLRenderPassDescriptor()
pass.colorAttachments[0].texture = drawable.texture pass.colorAttachments[0].texture = drawable.texture
pass.colorAttachments[0].loadAction = .clear pass.colorAttachments[0].loadAction = .clear
@@ -182,6 +182,11 @@ public final class PunktfunkConnection {
case dualSense = 2 case dualSense = 2
case xboxOne = 3 case xboxOne = 3
case dualShock4 = 4 case dualShock4 = 4
// Valve Steam Controller / Steam Deck (Linux UHID hid-steam hosts). Parity only on Apple
// GameController never surfaces a 0x28DE HID device, so the client can't capture one; these
// exist so the resolved type round-trips and name parsing matches the host.
case steamController = 5
case steamDeck = 6
/// 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`.
@@ -192,6 +197,8 @@ public final class PunktfunkConnection {
case "dualsense", "ds", "ds5", "ps5": self = .dualSense case "dualsense", "ds", "ds5", "ps5": self = .dualSense
case "xboxone", "xbox-one", "xboxseries", "series": self = .xboxOne case "xboxone", "xbox-one", "xboxseries", "series": self = .xboxOne
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4 case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
case "steamdeck", "steam-deck", "deck": self = .steamDeck
case "steamcontroller", "steam-controller", "steamcon": self = .steamController
default: return nil default: return nil
} }
} }
@@ -0,0 +1,93 @@
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -177,6 +177,16 @@ public final class SessionAudio {
private var playbackEngine: AVAudioEngine? private var playbackEngine: AVAudioEngine?
private var captureEngine: AVAudioEngine? private var captureEngine: AVAudioEngine?
private var drainStarted = false private var drainStarted = false
#if !os(macOS)
/// AVAudioSession `setCategory`/`setActive` are synchronous and block on the audio server, so
/// they must not run on the main thread (UI stall AVFoundation warns about it). PROCESS-WIDE
/// (static) so every SessionAudio shares one serial queue: the AVAudioSession is a process
/// singleton, and across a reconnect the old session's deactivate must be ordered before the
/// new session's activate (a per-instance queue would let them race and leave the new session's
/// audio deactivated). stop() enqueues its deactivate promptly so it lands before the next
/// session's activate.
private static let sessionQueue = DispatchQueue(label: "io.unom.punktfunk.audio.session")
#endif
public init(connection: PunktfunkConnection) { public init(connection: PunktfunkConnection) {
self.connection = connection self.connection = connection
@@ -189,37 +199,60 @@ public final class SessionAudio {
flag.stop() flag.stop()
} }
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system /// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system default
/// default device; on iOS the UIDs are ignored entirely (routes are /// device; on iOS the UIDs are ignored entirely (routes are AVAudioSession-managed). On macOS
/// AVAudioSession-managed). Main thread (engine setup); returns after the engines /// the engines start synchronously on the caller's (main) thread. On iOS/tvOS start() is
/// start the mic may start slightly later if the permission prompt is pending. /// ASYNCHRONOUS: it activates the AVAudioSession off the main thread, then starts the engines on
/// a later main-queue hop (gated by `!flag.isStopped`) so playback is live shortly after, not
/// on return. The mic may start later still if the permission prompt is pending.
public func start(speakerUID: String, micUID: String, micEnabled: Bool) { public func start(speakerUID: String, micUID: String, micEnabled: Bool) {
#if os(iOS) #if os(macOS)
// Route + policy live in the session, not per-engine: stereo playback, mic // No AVAudioSession on macOS start the engines directly (caller's thread, as before).
// capture when enabled, Bluetooth allowed. Failure is non-fatal (defaults). startEngines(speakerUID: speakerUID, micUID: micUID, micEnabled: micEnabled)
#else
// Configure + activate the session OFF the main thread (it blocks on the audio server),
// then start the engines back on the main thread once it's active engine routing/format
// depend on the active session. A stop() racing in between is caught by the flag guard.
Self.sessionQueue.async { [weak self] in
guard let self else { return }
self.activateAudioSession(micEnabled: micEnabled)
DispatchQueue.main.async { [weak self] in
guard let self, !self.flag.isStopped else { return }
self.startEngines(speakerUID: speakerUID, micUID: micUID, micEnabled: micEnabled)
}
}
#endif
}
#if !os(macOS)
/// Route + policy live in the session, not per-engine: stereo playback, mic capture when
/// enabled, Bluetooth allowed. Failure is non-fatal (defaults). Runs on `sessionQueue`.
private func activateAudioSession(micEnabled: Bool) {
let session = AVAudioSession.sharedInstance() let session = AVAudioSession.sharedInstance()
do { do {
#if os(iOS)
if micEnabled { if micEnabled {
// .defaultToSpeaker: .playAndRecord otherwise routes to the iPhone // .defaultToSpeaker: .playAndRecord otherwise routes to the iPhone EARPIECE; only
// EARPIECE; only affects the built-in route (headphones/BT still win). // affects the built-in route (headphones/BT still win).
try session.setCategory( try session.setCategory(
.playAndRecord, mode: .default, .playAndRecord, mode: .default,
options: [.allowBluetoothA2DP, .defaultToSpeaker]) options: [.allowBluetoothA2DP, .defaultToSpeaker])
} else { } else {
try session.setCategory(.playback, mode: .default) try session.setCategory(.playback, mode: .default)
} }
#else // tvOS no app-accessible mic
try session.setCategory(.playback, mode: .default)
#endif
try session.setActive(true) try session.setActive(true)
} catch { } catch {
log.warning("AVAudioSession setup failed: \(error.localizedDescription)") log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
} }
#elseif os(tvOS)
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
} }
#endif #endif
/// Build + start the playback engine (and the mic uplink when enabled + authorized). Main
/// thread (engine setup); on iOS/tvOS the session is already active by the time this runs.
private func startEngines(speakerUID: String, micUID: String, micEnabled: Bool) {
startPlayback(speakerUID: speakerUID) startPlayback(speakerUID: speakerUID)
#if os(tvOS) #if os(tvOS)
// No app-accessible microphone input on tvOS playback only. // No app-accessible microphone input on tvOS playback only.
@@ -258,19 +291,24 @@ public final class SessionAudio {
capture.stop() capture.stop()
} }
playback?.stop() playback?.stop()
if wasDraining {
_ = drainDone.wait(timeout: .now() + .milliseconds(400))
}
#if !os(macOS) #if !os(macOS)
// Release the session so audio we interrupted (Music, podcasts) gets its // Release the session so audio we interrupted (Music, podcasts) gets its resume cue. Like
// resume cue. // activation, setActive is synchronous/blocking run it on the shared serial session queue
// (off the main thread). Enqueued HERE engines already stopped, and BEFORE the drain wait
// below so across a reconnect it lands ahead of the next session's activate on the shared
// queue (otherwise a deferred deactivate could deactivate the new session). Fire-and-forget.
Self.sessionQueue.async {
do { do {
try AVAudioSession.sharedInstance().setActive( try AVAudioSession.sharedInstance().setActive(
false, options: .notifyOthersOnDeactivation) false, options: .notifyOthersOnDeactivation)
} catch { } catch {
log.warning("AVAudioSession deactivation failed: \(error.localizedDescription)") log.warning("AVAudioSession deactivation failed: \(error.localizedDescription)")
} }
}
#endif #endif
if wasDraining {
_ = drainDone.wait(timeout: .now() + .milliseconds(400))
}
} }
// MARK: - Playback (host speaker) // MARK: - Playback (host speaker)
@@ -4,7 +4,7 @@
// capturepresent. Mirrors StreamPump's lifecycle (one per start; cancel is permanent). // capturepresent. Mirrors StreamPump's lifecycle (one per start; cancel is permanent).
// //
// Threading: the pump runs on its own thread; the decoder callback on a VT thread; `renderTick` // Threading: the pump runs on its own thread; the decoder callback on a VT thread; `renderTick`
// + `setDrawableSize` + `start`/`stop` on the MAIN thread (the view's CADisplayLink fires there). // + `start`/`stop` on the MAIN thread (the view's CADisplayLink fires there).
// Only the ring + decoder cross threads and both are internally locked. // Only the ring + decoder cross threads and both are internally locked.
#if canImport(Metal) && canImport(QuartzCore) #if canImport(Metal) && canImport(QuartzCore)
@@ -60,7 +60,7 @@ private final class KeyframeRecovery: @unchecked Sendable {
func request() { func request() {
lock.lock() lock.lock()
let now = DispatchTime.now().uptimeNanoseconds let now = DispatchTime.now().uptimeNanoseconds
let due = lastNs == 0 || now &- lastNs > 250_000_000 // 250 ms since the last request let due = lastNs == 0 || now &- lastNs > 100_000_000 // 100 ms since the last request (matches Android)
if due { lastNs = now } if due { lastNs = now }
let conn = due ? connection : nil let conn = due ? connection : nil
lock.unlock() lock.unlock()
@@ -114,20 +114,24 @@ public final class Stage2Pipeline {
let thread = Thread { let thread = Thread {
var format: CMVideoFormatDescription? var format: CMVideoFormatDescription?
var lastFramesDropped = connection.framesDropped() var lastFramesDropped = connection.framesDropped()
// Persistent recovery WANT, not a one-shot edge (see StreamPump for the full rationale):
// the old code advanced lastFramesDropped on the same edge it called recovery.request(),
// so a request swallowed by the throttle (the lost recovery IDR being pruned within the
// window) was never re-sent and the picture stayed frozen. Keep asking until an IDR lands.
var awaitingIDR = false
while token.isLive { while token.isLive {
do { do {
// Loss recovery (the primary recovery path). The reassembler drops unrecoverable // Loss recovery (the primary path). The reassembler drops unrecoverable AUs
// AUs (framesDropped) and the decoder then conceals the reference-missing delta // (framesDropped) and the decoder conceals the reference-missing deltas that
// frames that follow often rendering them WITHOUT an error callback so the // follow often WITHOUT an error callback so key off the drop count climbing,
// onDecodeError trigger rarely fires after a real network blip. Ask the host for // then keep asking (awaitingIDR) until a fresh IDR re-anchors decode. Polled every
// a fresh IDR whenever the drop count climbs (throttled in KeyframeRecovery). // iteration so a total-loss drought recovers the moment packets resume.
// Polled every iteration so a total-loss drought recovers the moment packets
// resume and the reassembler counts the gap.
let dropped = connection.framesDropped() let dropped = connection.framesDropped()
if dropped > lastFramesDropped { if dropped > lastFramesDropped {
lastFramesDropped = dropped lastFramesDropped = dropped
recovery.request() awaitingIDR = true
} }
if awaitingIDR { recovery.request() }
// Drain any HDR mastering-metadata update (0xCE) and hand it to the decoder, which // Drain any HDR mastering-metadata update (0xCE) and hand it to the decoder, which
// attaches it to subsequent HDR frames. Non-blocking; only HDR sessions emit these. // attaches it to subsequent HDR frames. Non-blocking; only HDR sessions emit these.
if connection.isHDR, let meta = try? connection.nextHdrMeta(timeoutMs: 0) { if connection.isHDR, let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
@@ -137,14 +141,15 @@ public final class Stage2Pipeline {
onFrame?(au) onFrame?(au)
if let f = AnnexB.formatDescription(fromIDR: au.data) { if let f = AnnexB.formatDescription(fromIDR: au.data) {
format = f // refreshed on every IDR (mode changes included) format = f // refreshed on every IDR (mode changes included)
awaitingIDR = false // a fresh IDR re-anchored decode recovery complete
} }
guard let f = format, token.isLive else { continue } guard let f = format, token.isLive else { continue }
if !decoder.decode(au: au, format: f) { if !decoder.decode(au: au, format: f) {
// Submit/decoder error: drop the session and re-gate on the next IDR's // Submit/decoder error: drop the session and re-gate on the next IDR's
// in-band parameter sets (a delta frame can't recover) stage-1's policy // in-band parameter sets (a delta frame can't recover) stage-1's policy
// and ask the host for that IDR now (infinite GOP; throttled). // and keep asking for that IDR (infinite GOP) until one re-anchors decode.
decoder.reset() decoder.reset()
recovery.request() awaitingIDR = true
} }
} catch { } catch {
if token.isLive { onSessionEnd?() } if token.isLive { onSessionEnd?() }
@@ -166,11 +171,6 @@ public final class Stage2Pipeline {
presentMeter.record(ptsNs: frame.ptsNs, atNs: targetPresentNs, offsetNs: offsetNs) presentMeter.record(ptsNs: frame.ptsNs, atNs: targetPresentNs, offsetNs: offsetNs)
} }
/// MAIN thread. Keep the drawable matched to the negotiated mode (host can Reconfigure).
public func setDrawableSize(_ size: CGSize) {
presenter.setDrawableSize(size)
}
/// Stop the pump ( one poll timeout) and drop the decode session. Does not close the /// Stop the pump ( one poll timeout) and drop the decode session. Does not close the
/// connection. A restart needs a fresh Stage2Pipeline (cancel is permanent). /// connection. A restart needs a fresh Stage2Pipeline (cancel is permanent).
public func stop() { public func stop() {
@@ -6,6 +6,9 @@
import AVFoundation import AVFoundation
import Foundation import Foundation
import os
private let pumpLog = Logger(subsystem: "io.unom.punktfunk", category: "video")
/// Cancellation handle owned by exactly one pump thread a restart hands the old pump /// Cancellation handle owned by exactly one pump thread a restart hands the old pump
/// its own token, so it can never be revived by a newer start(). /// its own token, so it can never be revived by a newer start().
@@ -47,44 +50,74 @@ final class StreamPump {
var format: CMVideoFormatDescription? var format: CMVideoFormatDescription?
var lastKeyframeRequest = Date.distantPast var lastKeyframeRequest = Date.distantPast
var lastFramesDropped = connection.framesDropped() var lastFramesDropped = connection.framesDropped()
// Coalesced host keyframe request: the decode stays wedged for several frames until // Recovery is a persistent WANT, not a one-shot edge: set it on detected loss (or a
// the IDR lands, so requesting on every frame would flood the control stream. // decoder reset), retry the throttled request EVERY iteration, and clear it only when a
// fresh IDR actually re-anchors decode. The old code advanced `lastFramesDropped` on the
// same edge it fired the throttled request so a request swallowed by the throttle (a
// second drop within the window, e.g. the lost recovery IDR itself being pruned) was
// never re-sent: the counter went flat, the climb never re-fired, and the picture stayed
// frozen for good while audio kept playing. The iPhone's lossy Wi-Fi hits this where the
// Mac's Ethernet never does.
var awaitingIDR = false
var awaitingSince = Date.distantPast // when the current recovery began (for the resume log)
var wasFailed = false
// Coalesced host keyframe request. 100 ms throttle (matches the working Android path):
// fast enough that a lost recovery IDR is re-requested promptly, bounded so a sustained
// freeze can't flood the control stream.
func requestKeyframeThrottled() { func requestKeyframeThrottled() {
let now = Date() let now = Date()
if now.timeIntervalSince(lastKeyframeRequest) > 0.25 { if now.timeIntervalSince(lastKeyframeRequest) > 0.1 {
connection.requestKeyframe() connection.requestKeyframe()
lastKeyframeRequest = now lastKeyframeRequest = now
} }
} }
while token.isLive { while token.isLive {
do { do {
// Loss recovery (the primary recovery path). Under the host's infinite GOP the // Loss recovery (the primary path). Under the host's infinite GOP the only
// only recovery keyframe is one we request. The reassembler drops unrecoverable // recovery keyframe is one we request. The reassembler drops unrecoverable AUs
// AUs (framesDropped); the decoder then *conceals* the reference-missing delta // (framesDropped); the decoder then *conceals* the reference-missing deltas a
// frames that follow a frozen / garbage picture, WITHOUT flipping the layer to // frozen / garbage picture that never flips the layer to .failed so key off the
// .failed so the .failed check below rarely fires after a real network blip. // drop count climbing, then keep asking (awaitingIDR) until an IDR lands. Polled
// Ask the host for a fresh IDR whenever the drop count climbs. Polled every // every iteration so a total-loss drought still recovers when packets resume.
// iteration (not just per AU) so a total-loss drought still recovers the moment
// packets resume and the reassembler counts the gap.
let dropped = connection.framesDropped() let dropped = connection.framesDropped()
if dropped > lastFramesDropped { if dropped > lastFramesDropped {
lastFramesDropped = dropped // Log only on the falsetrue transition (once per recovery cycle), not per
requestKeyframeThrottled() // dropped AU, so heavy loss doesn't spam the log.
if !awaitingIDR {
awaitingSince = Date()
pumpLog.notice(
"video: unrecoverable drop (framesDropped=\(dropped, privacy: .public)) — requesting recovery IDR")
} }
lastFramesDropped = dropped
awaitingIDR = true
}
if awaitingIDR { requestKeyframeThrottled() }
guard let au = try connection.nextAU(timeoutMs: 100) else { continue } guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
onFrame?(au) onFrame?(au)
if let f = AnnexB.formatDescription(fromIDR: au.data) { let idrFormat = AnnexB.formatDescription(fromIDR: au.data)
if let f = idrFormat {
format = f // refreshed on every IDR (mode changes included) format = f // refreshed on every IDR (mode changes included)
if awaitingIDR {
let ms = Int(Date().timeIntervalSince(awaitingSince) * 1000)
pumpLog.notice("video: recovery IDR received — resumed after \(ms, privacy: .public) ms")
} }
if layer.status == .failed { awaitingIDR = false // a fresh IDR re-anchored decode recovery complete
}
let failed = layer.status == .failed
if failed {
// Decode wedged hard (the cold-first-connect case a lost/corrupt opening // Decode wedged hard (the cold-first-connect case a lost/corrupt opening
// IDR): flush and re-gate on the next in-band parameter sets (resuming with // IDR): flush and, unless THIS AU is the recovering IDR (re-anchored above),
// a delta frame can't recover), AND ask the host for a fresh IDR. Throttled: // re-gate on the next in-band parameter sets and keep asking enqueuing a
// the layer stays .failed across several polls until the IDR lands. // delta into a failed layer can't recover it.
if !wasFailed { pumpLog.warning("video: display layer .failed — flushing + re-anchoring") }
layer.flush() layer.flush()
format = AnnexB.formatDescription(fromIDR: au.data) if idrFormat == nil {
requestKeyframeThrottled() format = nil
awaitingIDR = true
} }
}
wasFailed = failed
guard let f = format, guard let f = format,
let sample = AnnexB.sampleBuffer(au: au, format: f), let sample = AnnexB.sampleBuffer(au: au, format: f),
token.isLive // don't enqueue a stale frame after a restart token.isLive // don't enqueue a stale frame after a restart
@@ -245,6 +245,15 @@ public final class StreamLayerView: NSView {
layoutMetalLayer() // keep the stage-2 sublayer aspect-fit to the view layoutMetalLayer() // keep the stage-2 sublayer aspect-fit to the view
} }
public override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
// `layout()` isn't guaranteed on a manual-frame (no-Auto-Layout) live resize, so the
// stage-2 metal sublayer's drawableSize could stay at the old size while the view grows
// the compositor then upscales a too-small drawable and the video turns blocky. Resize the
// drawable here too so it always tracks the window's pixel size (no stale upscale).
layoutMetalLayer()
}
// MARK: - Capture state machine // MARK: - Capture state machine
/// Clicking into the video engages capture; that click is local (engagement), so /// Clicking into the video engages capture; that click is local (engagement), so
@@ -549,10 +558,17 @@ public final class StreamLayerView: NSView {
cursorVisible = false cursorVisible = false
_ = connection.resolvedCompositor // (was: Auto gamescope; kept to document intent) _ = connection.resolvedCompositor // (was: Auto gamescope; kept to document intent)
// Presenter choice default stage-1 (the known-good AVSampleBufferDisplayLayer). Stage-2 // Presenter choice stage-2 is the DEFAULT (explicit VTDecompressionSession decode + a
// (`punktfunk.presenter == "stage2"`) takes explicit VTDecompressionSession decode + a // CAMetalLayer/display-link present): it can detect + recover a wedged decoder where
// CAMetalLayer/display-link present; it falls back here if Metal can't be set up. // stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-1 is
if UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage2", // reachable only via the DEBUG presenter toggle; release always takes stage-2 (the stage-1
// pump below stays the automatic fallback if Metal is missing).
#if DEBUG
let forceStage1 = UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage1"
#else
let forceStage1 = false
#endif
if !forceStage1,
let meter = presentMeter, let meter = presentMeter,
let pipeline = Stage2Pipeline(presentMeter: meter) { let pipeline = Stage2Pipeline(presentMeter: meter) {
startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
@@ -593,9 +609,11 @@ public final class StreamLayerView: NSView {
targetPresentNs: Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: link.targetTimestamp)) targetPresentNs: Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: link.targetTimestamp))
} }
/// Aspect-fit the metal sublayer in the view (the host streams at the client's native mode, /// Position the metal sublayer aspect-fit in the view (the host streams at the client's native
/// so this is usually the full bounds; it letterboxes a resized window). drawableSize is the /// mode, so this is usually the full bounds; it letterboxes a resized window). Only the layer
/// layer's pixel size the fullscreen-triangle shader scales the decoded texture to fill it. /// FRAME is set here the presenter sizes the drawable to the decoded frame and the layer's
/// contentsGravity (.resizeAspect) scales it to this frame via the system compositor, so a
/// resized window rescales through the system's filter (matching stage-1) instead of the shader.
private func layoutMetalLayer() { private func layoutMetalLayer() {
guard let metalLayer, let connection else { return } guard let metalLayer, let connection else { return }
let mode = connection.currentMode() let mode = connection.currentMode()
@@ -604,14 +622,12 @@ public final class StreamLayerView: NSView {
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)), aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
insideRect: bounds) insideRect: bounds)
: bounds : bounds
let scale = window?.backingScaleFactor ?? 1
// No implicit resize animation; refresh contentsScale on a retinanon-retina move. // No implicit resize animation; refresh contentsScale on a retinanon-retina move.
CATransaction.begin() CATransaction.begin()
CATransaction.setDisableActions(true) CATransaction.setDisableActions(true)
metalLayer.contentsScale = scale metalLayer.contentsScale = window?.backingScaleFactor ?? 1
metalLayer.frame = fit metalLayer.frame = fit
CATransaction.commit() CATransaction.commit()
stage2?.setDrawableSize(CGSize(width: fit.width * scale, height: fit.height * scale))
} }
public override func viewDidChangeBackingProperties() { public override func viewDidChangeBackingProperties() {
@@ -136,6 +136,13 @@ public final class StreamViewController: UIViewController {
public override func loadView() { public override func loadView() {
view = StreamLayerUIView() view = StreamLayerUIView()
// Re-size the stage-2 drawable if the display scale changes without a bounds change (e.g.
// moving to an external display at a different scale) the iOS analogue of macOS's
// viewDidChangeBackingProperties relayout. The handler takes the VC as its argument, so it
// doesn't capture self (no retain cycle with the registration).
registerForTraitChanges([UITraitDisplayScale.self]) { (vc: StreamViewController, _) in
vc.layoutMetalLayer()
}
#if os(iOS) #if os(iOS)
// Hide the iPadOS cursor while it hovers the video: the host renders its own // Hide the iPadOS cursor while it hovers the video: the host renders its own
// cursor from our deltas, so the local one only diverges from it. This hides the // cursor from our deltas, so the local one only diverges from it. This hides the
@@ -219,10 +226,17 @@ public final class StreamViewController: UIViewController {
inputCapture = capture inputCapture = capture
#endif #endif
// Presenter choice default stage-1 (the known-good AVSampleBufferDisplayLayer). Stage-2 // Presenter choice stage-2 is the DEFAULT (VTDecompressionSession decode + a
// (`punktfunk.presenter == "stage2"`) takes VTDecompressionSession decode + a // CAMetalLayer/display-link present): it can detect + recover a wedged decoder, where
// CAMetalLayer/display-link present; falls back here if Metal can't be set up. // stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference frame with no
if UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage2", // way to recover. Stage-1 is reachable only via the DEBUG presenter toggle; release always
// takes stage-2 (the stage-1 pump below stays the automatic fallback if Metal is missing).
#if DEBUG
let forceStage1 = UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage1"
#else
let forceStage1 = false
#endif
if !forceStage1,
let meter = presentMeter, let meter = presentMeter,
let pipeline = Stage2Pipeline(presentMeter: meter) { let pipeline = Stage2Pipeline(presentMeter: meter) {
startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
@@ -300,8 +314,8 @@ public final class StreamViewController: UIViewController {
onFrame: (@Sendable (AccessUnit) -> Void)?, onSessionEnd: (@Sendable () -> Void)? onFrame: (@Sendable (AccessUnit) -> Void)?, onSessionEnd: (@Sendable () -> Void)?
) { ) {
let metal = pipeline.layer let metal = pipeline.layer
metal.contentsScale = streamView.contentScaleFactor
// Composites OVER the idle (un-enqueued in stage-2) AVSampleBufferDisplayLayer base. // Composites OVER the idle (un-enqueued in stage-2) AVSampleBufferDisplayLayer base.
// (contentsScale + frame + drawableSize are all set by layoutMetalLayer() just below.)
streamView.layer.addSublayer(metal) streamView.layer.addSublayer(metal)
metalLayer = metal metalLayer = metal
stage2 = pipeline stage2 = pipeline
@@ -325,9 +339,20 @@ public final class StreamViewController: UIViewController {
layoutMetalLayer() layoutMetalLayer()
} }
/// Aspect-fit the metal sublayer in the view (the host streams at the client's native mode, /// The display scale to render the metal drawable at. `traitCollection.displayScale` is the
/// so this is usually the full bounds). drawableSize is the layer's pixel size; the shader's /// canonical render scale and is reliable once the controller is in the hierarchy;
/// fullscreen triangle scales the decoded texture to fill it. /// `view.contentScaleFactor` can read 1.0 before the view attaches to a window/screen, which
/// would size the drawable at point resolution a pixelated, upscaled mess. Falls back to the
/// main screen scale if the trait is still unspecified.
private var renderScale: CGFloat {
let s = traitCollection.displayScale
return s > 0 ? s : UIScreen.main.scale
}
/// Position the metal sublayer aspect-fit in the view (the host streams at the client's native
/// mode, so this is usually the full bounds). Only the layer FRAME is set here the presenter
/// sizes the drawable to the decoded frame and the layer's contentsGravity (.resizeAspect)
/// scales it to this frame via the system compositor (matching stage-1's videoGravity).
private func layoutMetalLayer() { private func layoutMetalLayer() {
guard let metalLayer, let connection else { return } guard let metalLayer, let connection else { return }
let mode = connection.currentMode() let mode = connection.currentMode()
@@ -337,13 +362,11 @@ public final class StreamViewController: UIViewController {
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)), aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
insideRect: bounds) insideRect: bounds)
: bounds : bounds
let scale = streamView.contentScaleFactor
CATransaction.begin() CATransaction.begin()
CATransaction.setDisableActions(true) // don't animate the resize CATransaction.setDisableActions(true) // don't animate the resize
metalLayer.contentsScale = scale metalLayer.contentsScale = renderScale
metalLayer.frame = fit metalLayer.frame = fit
CATransaction.commit() CATransaction.commit()
stage2?.setDrawableSize(CGSize(width: fit.width * scale, height: fit.height * scale))
} }
private func teardownStage2() { private func teardownStage2() {
@@ -0,0 +1,21 @@
import XCTest
#if canImport(Metal)
import Metal
@testable import PunktfunkKit
final class MetalPresenterTests: XCTestCase {
/// `MetalVideoPresenter.init?()` compiles the runtime Metal shaders (the BT.709/BT.2020 YUVRGB
/// fragment shaders plus the Catmull-Rom luma sampler). A `nil` result on a GPU-equipped host
/// means a shader failed to compile this catches a malformed shader before it silently
/// degrades stage-2 to a stage-1 fallback on device.
func testPresenterInitCompilesShaders() throws {
guard MTLCreateSystemDefaultDevice() != nil else {
throw XCTSkip("no Metal device available in this environment")
}
XCTAssertNotNil(
MetalVideoPresenter(),
"stage-2 Metal shaders failed to compile (presenter init returned nil)")
}
}
#endif
+14 -5
View File
@@ -294,7 +294,13 @@ const RESOLUTIONS: [number, number, string][] = [
[2560, 1440, "2560 × 1440"], [2560, 1440, "2560 × 1440"],
]; ];
const REFRESH = [0, 30, 60, 90, 120]; const REFRESH = [0, 30, 60, 90, 120];
const GAMEPADS = ["auto", "xbox360", "dualsense"]; const GAMEPADS = ["auto", "xbox360", "dualsense", "steamdeck"];
const GAMEPAD_LABELS: Record<string, string> = {
auto: "Automatic",
xbox360: "Xbox 360",
dualsense: "DualSense",
steamdeck: "Steam Deck",
};
const SettingsSection: FC = () => { const SettingsSection: FC = () => {
const [s, setS] = useState<StreamSettings | null>(null); const [s, setS] = useState<StreamSettings | null>(null);
@@ -355,14 +361,17 @@ const SettingsSection: FC = () => {
/> />
<Field label="Gamepad type" childrenContainerWidth="max"> <Field label="Gamepad type" childrenContainerWidth="max">
<Dropdown <Dropdown
rgOptions={GAMEPADS.map((g) => ({ rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
data: g,
label: g === "auto" ? "Automatic" : g === "xbox360" ? "Xbox 360" : "DualSense",
}))}
selectedOption={s.gamepad} selectedOption={s.gamepad}
onChange={(o) => patch({ gamepad: o.data as string })} onChange={(o) => patch({ gamepad: o.data as string })}
/> />
</Field> </Field>
{s.gamepad === "steamdeck" && (
<Field
label="⚠ Disable Steam Input"
description="Steam Deck mode forwards the paddles, both trackpads, and gyro to the host. For that, Steam Input must be OFF for punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
/>
)}
<ToggleField <ToggleField
label="Stream microphone" label="Stream microphone"
checked={s.mic_enabled} checked={s.mic_enabled}
+23
View File
@@ -113,12 +113,35 @@ async function ensureShortcut(): Promise<number> {
return appId; return appId;
} }
/**
* Best-effort: turn Steam Input OFF for our shortcut so SDL's HIDAPI Steam Deck driver can open the
* Deck's controls (paddles · trackpads · gyro) directly. There is no confirmed-stable SteamClient
* API for this, so it is feature-detected and MUST never block or throw into the launch — the manual
* toggle (game page → ⚙ → Controller Settings → Steam Input Off, surfaced in the plugin Settings) is
* the documented source of truth. No-op when the optional API is absent.
*/
function disableSteamInputForShortcut(appId: number): void {
try {
const input = (
SteamClient as unknown as {
Input?: { SetSteamInputEnabledForApp?: (appId: number, enabled: boolean) => void };
}
).Input;
input?.SetSteamInputEnabledForApp?.(appId, false);
} catch {
/* a controller tweak must never break the launch */
}
}
/** /**
* Launch a stream to `host:port` fullscreen in Gaming Mode. Encodes the target into the * Launch a stream to `host:port` fullscreen in Gaming Mode. Encodes the target into the
* shortcut's launch options (so one generic shortcut serves every host), then RunGame. * shortcut's launch options (so one generic shortcut serves every host), then RunGame.
*/ */
export async function launchStream(host: string, port: number): Promise<void> { export async function launchStream(host: string, port: number): Promise<void> {
const appId = await ensureShortcut(); const appId = await ensureShortcut();
// Best-effort so the Deck's rich controls reach the client; no-op if the API is absent (the user
// disables Steam Input manually — see the Settings instruction).
disableSteamInputForShortcut(appId);
const target = port && port !== 9777 ? `${host}:${port}` : host; const target = port && port !== 9777 ? `${host}:${port}` : host;
// KEY=value ... %command% — the wrapper reads PF_HOST from the environment. // KEY=value ... %command% — the wrapper reads PF_HOST from the environment.
SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command%`); SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command%`);
+1
View File
@@ -767,6 +767,7 @@ fn start_session_with(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>,
connector, connector,
frames.take().expect("Connected delivered once"), frames.take().expect("Connected delivered once"),
app.gamepad.escape_events(), app.gamepad.escape_events(),
app.gamepad.disconnect_events(),
handle.stop.clone(), handle.stop.clone(),
inhibit, inhibit,
&title, &title,
+186 -32
View File
@@ -18,7 +18,7 @@ use punktfunk_core::quic::{HidOutput, RichInput};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::mpsc::{Receiver, Sender}; use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::{Duration, Instant};
/// Motion scale constants, shared convention with the Swift client (`GamepadWire`): /// Motion scale constants, shared convention with the Swift client (`GamepadWire`):
/// derived from hid-playstation's math over the host's fixed calibration blob. SDL hands /// derived from hid-playstation's math over the host's fixed calibration blob. SDL hands
@@ -33,8 +33,15 @@ const G: f32 = 9.80665;
/// is the only way out. Four simultaneous buttons that no game uses as a deliberate /// is the only way out. Four simultaneous buttons that no game uses as a deliberate
/// combo, so it can't be triggered by normal play. Still forwarded to the host (the user /// combo, so it can't be triggered by normal play. Still forwarded to the host (the user
/// is leaving anyway); we only also raise the escape signal. /// is leaving anyway); we only also raise the escape signal.
///
/// **Escalation:** a quick press leaves fullscreen / releases capture; *holding* the same
/// chord for [`DISCONNECT_HOLD`] ends the session. Deliberately NOT the Steam / QAM buttons —
/// those are the marquee pass-through controls that now reach the host's game-mode UI.
const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wire::BTN_BACK]; const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wire::BTN_BACK];
/// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press).
const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct PadInfo { pub struct PadInfo {
pub id: u32, pub id: u32,
@@ -58,6 +65,7 @@ impl PadInfo {
GamepadPref::DualSense => "DualSense", GamepadPref::DualSense => "DualSense",
GamepadPref::DualShock4 => "DualShock 4", GamepadPref::DualShock4 => "DualShock 4",
GamepadPref::XboxOne => "Xbox One", GamepadPref::XboxOne => "Xbox One",
GamepadPref::SteamDeck => "Steam Deck",
_ => "", _ => "",
} }
} }
@@ -89,6 +97,9 @@ pub struct GamepadService {
/// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave /// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave
/// fullscreen + release capture. /// fullscreen + release capture.
escape_rx: async_channel::Receiver<()>, escape_rx: async_channel::Receiver<()>,
/// Fires once when the [`ESCAPE_CHORD`] is held past [`DISCONNECT_HOLD`]; the stream page
/// consumes it to end the session (the controller equivalent of Ctrl+Alt+Shift+D).
disconnect_rx: async_channel::Receiver<()>,
} }
impl GamepadService { impl GamepadService {
@@ -98,11 +109,12 @@ impl GamepadService {
let pinned = Arc::new(Mutex::new(None)); let pinned = Arc::new(Mutex::new(None));
let (ctl, ctl_rx) = std::sync::mpsc::channel(); let (ctl, ctl_rx) = std::sync::mpsc::channel();
let (escape_tx, escape_rx) = async_channel::unbounded(); let (escape_tx, escape_rx) = async_channel::unbounded();
let (disconnect_tx, disconnect_rx) = async_channel::unbounded();
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone()); let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone());
if let Err(e) = std::thread::Builder::new() if let Err(e) = std::thread::Builder::new()
.name("punktfunk-gamepad".into()) .name("punktfunk-gamepad".into())
.spawn(move || { .spawn(move || {
if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx) { if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx, &disconnect_tx) {
tracing::warn!(error = %e, "gamepad service ended — pads disabled"); tracing::warn!(error = %e, "gamepad service ended — pads disabled");
} }
}) })
@@ -115,6 +127,7 @@ impl GamepadService {
pinned, pinned,
ctl, ctl,
escape_rx, escape_rx,
disconnect_rx,
} }
} }
@@ -124,6 +137,12 @@ impl GamepadService {
self.escape_rx.clone() self.escape_rx.clone()
} }
/// A receiver that yields one `()` when the escape chord is held past [`DISCONNECT_HOLD`]
/// (controller disconnect). A fresh clone per call; the stream page spawns a future on it.
pub fn disconnect_events(&self) -> async_channel::Receiver<()> {
self.disconnect_rx.clone()
}
pub fn pads(&self) -> Vec<PadInfo> { pub fn pads(&self) -> Vec<PadInfo> {
self.pads.lock().unwrap().clone() self.pads.lock().unwrap().clone()
} }
@@ -188,6 +207,13 @@ fn button_bit(b: sdl3::gamepad::Button) -> Option<u32> {
Button::DPadLeft => wire::BTN_DPAD_LEFT, Button::DPadLeft => wire::BTN_DPAD_LEFT,
Button::DPadRight => wire::BTN_DPAD_RIGHT, Button::DPadRight => wire::BTN_DPAD_RIGHT,
Button::Touchpad => wire::BTN_TOUCHPAD, Button::Touchpad => wire::BTN_TOUCHPAD,
// Back grips / paddles (Steam Deck L4/L5/R4/R5, Xbox Elite P1P4) + the misc/Share button.
// PADDLE1/2/3/4 = R4/L4/R5/L5 (see the host `input::gamepad`).
Button::RightPaddle1 => wire::BTN_PADDLE1,
Button::LeftPaddle1 => wire::BTN_PADDLE2,
Button::RightPaddle2 => wire::BTN_PADDLE3,
Button::LeftPaddle2 => wire::BTN_PADDLE4,
Button::Misc1 => wire::BTN_MISC1,
_ => return None, _ => return None,
}) })
} }
@@ -259,11 +285,22 @@ struct Worker {
/// Wire state of the active pad — zeroed on the wire at switch/detach. /// Wire state of the active pad — zeroed on the wire at switch/detach.
last_axis: [i32; 6], last_axis: [i32; 6],
held_buttons: Vec<u32>, held_buttons: Vec<u32>,
/// Touchpad contacts the host believes are down, keyed by `(surface, finger)` — lifted on pad
/// switch / detach so a contact held at that moment doesn't stick. surface 0 = the legacy single
/// touchpad, 1/2 = a Steam left/right pad.
held_touches: std::collections::HashSet<(u8, u8)>,
last_accel: [i16; 3], last_accel: [i16; 3],
/// Raises the UI escape signal; the escape chord fires it once per press. /// Raises the UI escape signal; the escape chord fires it once per press.
escape_tx: async_channel::Sender<()>, escape_tx: async_channel::Sender<()>,
/// Raises the UI disconnect signal when the escape chord is held past [`DISCONNECT_HOLD`].
disconnect_tx: async_channel::Sender<()>,
/// The escape chord is fully held — latched so it fires once, not every poll. /// The escape chord is fully held — latched so it fires once, not every poll.
chord_armed: bool, chord_armed: bool,
/// When the escape chord became fully held (drives the hold-to-disconnect escalation); `None`
/// when the chord is broken.
chord_since: Option<Instant>,
/// The disconnect signal already fired for the current hold — latched so it fires once.
disconnect_fired: bool,
} }
impl Worker { impl Worker {
@@ -275,13 +312,22 @@ impl Worker {
fn pad_info(&self, id: u32) -> Option<PadInfo> { fn pad_info(&self, id: u32) -> Option<PadInfo> {
let pad = self.opened.get(&id)?; let pad = self.opened.get(&id)?;
let mut pref = pref_for_type(
self.subsystem
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
);
// 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
// hid-steam pad with the back grips + dual trackpads and the right glyph identity.
if pad.vendor_id() == Some(0x28DE)
&& matches!(pad.product_id(), Some(0x1205 | 0x1102 | 0x1142))
{
pref = GamepadPref::SteamDeck;
}
Some(PadInfo { Some(PadInfo {
id, id,
name: pad.name().unwrap_or_else(|| "Controller".into()), name: pad.name().unwrap_or_else(|| "Controller".into()),
pref: pref_for_type( pref,
self.subsystem
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
),
}) })
} }
@@ -297,32 +343,90 @@ impl Worker {
} }
*v = i32::MIN; *v = i32::MIN;
} }
// Lift any touchpad contact the host still believes is down (surface 0 = legacy pad).
for (surface, finger) in self.held_touches.drain() {
let rich = if surface == 0 {
RichInput::Touchpad {
pad: 0,
finger,
active: false,
x: 0,
y: 0,
}
} else {
RichInput::TouchpadEx {
pad: 0,
surface,
finger,
touch: false,
click: false,
x: 0,
y: 0,
pressure: 0,
}
};
let _ = c.send_rich_input(rich);
}
} else { } else {
self.held_buttons.clear(); self.held_buttons.clear();
self.last_axis = [i32::MIN; 6]; self.last_axis = [i32::MIN; 6];
self.held_touches.clear();
} }
// A held chord doesn't survive a flush (detach / pad-switch) — clear its latches too.
self.reset_chord();
} }
/// Raise the UI escape signal when the [`ESCAPE_CHORD`] just completed (latched so it /// Raise the UI escape signal when the [`ESCAPE_CHORD`] just completed (latched so it
/// fires once per press). Called after each button-down updates `held_buttons`. /// fires once per press) and start the hold-to-disconnect timer. Called after each
/// button-down updates `held_buttons`.
fn maybe_fire_escape(&mut self) { fn maybe_fire_escape(&mut self) {
if self.chord_armed { if self.chord_armed {
return; return;
} }
if ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) { if ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
self.chord_armed = true; self.chord_armed = true;
self.chord_since = Some(Instant::now());
let _ = self.escape_tx.try_send(()); let _ = self.escape_tx.try_send(());
tracing::info!("gamepad escape chord (L1+R1+Start+Select) — leaving fullscreen"); tracing::info!(
"gamepad escape chord (L1+R1+Start+Select) — leaving fullscreen (hold to disconnect)"
);
}
}
/// Fire the disconnect signal once the escape chord has been continuously held past
/// [`DISCONNECT_HOLD`]. Polled from the main loop so the hold completes without new events.
fn maybe_fire_disconnect(&mut self) {
if self.disconnect_fired {
return;
}
if let Some(since) = self.chord_since {
if since.elapsed() >= DISCONNECT_HOLD {
self.disconnect_fired = true;
let _ = self.disconnect_tx.try_send(());
tracing::info!("gamepad escape chord held — disconnecting");
}
} }
} }
/// Re-arm once the chord is broken (any of its buttons released). /// Re-arm once the chord is broken (any of its buttons released).
fn rearm_escape(&mut self) { fn rearm_escape(&mut self) {
if self.chord_armed && !ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) { if self.chord_armed && !ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
self.chord_armed = false; self.reset_chord();
} }
} }
/// Clear the escape/disconnect chord latches. Called at every session boundary
/// ([`flush_held`](Self::flush_held) on detach/pad-switch + on attach): the hold-to-disconnect
/// path *always* ends the session while the chord is still physically held, so the matching
/// button-up events arrive after detach (dropped by the `attached` guard) and `rearm_escape`
/// never runs — without this the latched state would leak into the next session and either
/// swallow its first chord press or fire a stale disconnect on connect.
fn reset_chord(&mut self) {
self.chord_armed = false;
self.chord_since = None;
self.disconnect_fired = false;
}
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth). /// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
fn set_sensors(&mut self, enabled: bool) { fn set_sensors(&mut self, enabled: bool) {
let Some(id) = self.active_id() else { return }; let Some(id) = self.active_id() else { return };
@@ -335,6 +439,56 @@ impl Worker {
} }
} }
} }
/// Forward one touchpad contact on the rich-input plane. A multi-touchpad pad (Steam Deck / Steam
/// Controller) sends `TouchpadEx` with the surface (SDL touchpad 0 = left → 1, 1 = right → 2) and
/// signed coordinates; a single-touchpad pad (DualSense) keeps the legacy `Touchpad` (unsigned).
fn forward_touch(
&mut self,
which: u32,
touchpad: u32,
finger: u8,
x: f32,
y: f32,
active: bool,
) {
let Some(c) = self.attached.as_ref() else {
return;
};
let multi = self
.opened
.get(&which)
.map(|p| p.touchpads_count() >= 2)
.unwrap_or(false);
let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
let surface = if multi { (touchpad as u8) + 1 } else { 0 };
let rich = if multi {
RichInput::TouchpadEx {
pad: 0,
surface,
finger,
touch: active,
click: false,
x: (cx * 65535.0 - 32768.0) as i16,
y: (cy * 65535.0 - 32768.0) as i16,
pressure: 0,
}
} else {
RichInput::Touchpad {
pad: 0,
finger,
active,
x: (cx * 65535.0) as u16,
y: (cy * 65535.0) as u16,
}
};
let _ = c.send_rich_input(rich);
if active {
self.held_touches.insert((surface, finger));
} else {
self.held_touches.remove(&(surface, finger));
}
}
} }
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
@@ -344,11 +498,18 @@ fn run(
pinned_out: &Mutex<Option<u32>>, pinned_out: &Mutex<Option<u32>>,
ctl: &Receiver<Ctl>, ctl: &Receiver<Ctl>,
escape_tx: &async_channel::Sender<()>, escape_tx: &async_channel::Sender<()>,
disconnect_tx: &async_channel::Sender<()>,
) -> Result<(), String> { ) -> Result<(), String> {
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its // Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
// own thread. // own thread.
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1"); sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1"); sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
// Let SDL's HIDAPI drivers open Valve Steam Controller / Steam Deck devices directly, so the
// paddles, both trackpads, and gyro arrive as first-class SDL gamepad inputs. On a Deck in Game
// Mode, Steam Input still holds the device — the user must disable Steam Input for this app (see
// the Decky UX); on a desktop client (or a Deck with Steam Input off) the hints just work.
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1");
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", "1");
let sdl = sdl3::init().map_err(|e| e.to_string())?; let sdl = sdl3::init().map_err(|e| e.to_string())?;
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?; let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?; let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
@@ -361,9 +522,13 @@ fn run(
attached: None, attached: None,
last_axis: [i32::MIN; 6], last_axis: [i32::MIN; 6],
held_buttons: Vec::new(), held_buttons: Vec::new(),
held_touches: std::collections::HashSet::new(),
last_accel: [0; 3], last_accel: [0; 3],
escape_tx: escape_tx.clone(), escape_tx: escape_tx.clone(),
disconnect_tx: disconnect_tx.clone(),
chord_armed: false, chord_armed: false,
chord_since: None,
disconnect_fired: false,
}; };
let publish = |w: &Worker| { let publish = |w: &Worker| {
@@ -381,6 +546,7 @@ fn run(
Ok(Ctl::Attach(c)) => { Ok(Ctl::Attach(c)) => {
w.attached = Some(c); w.attached = Some(c);
w.last_axis = [i32::MIN; 6]; w.last_axis = [i32::MIN; 6];
w.reset_chord(); // every session starts un-latched (Attach doesn't flush)
w.set_sensors(true); w.set_sensors(true);
} }
Ok(Ctl::Detach) => { Ok(Ctl::Detach) => {
@@ -474,9 +640,11 @@ fn run(
send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v); send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v);
} }
} }
// DualSense touchpad → the rich-input plane, normalized 0..=65535. // Touchpad contacts → the rich-input plane. One pad (DualSense) keeps the legacy
// `Touchpad`; two pads (Steam Deck / Steam Controller) send `TouchpadEx` per surface.
Event::ControllerTouchpadDown { Event::ControllerTouchpadDown {
which, which,
touchpad,
finger, finger,
x, x,
y, y,
@@ -484,41 +652,23 @@ fn run(
} }
| Event::ControllerTouchpadMotion { | Event::ControllerTouchpadMotion {
which, which,
touchpad,
finger, finger,
x, x,
y, y,
.. ..
} if active == Some(which) && w.attached.is_some() => { } if active == Some(which) && w.attached.is_some() => {
let _ = w w.forward_touch(which, touchpad as u32, finger as u8, x, y, true);
.attached
.as_ref()
.unwrap()
.send_rich_input(RichInput::Touchpad {
pad: 0,
finger: finger as u8,
active: true,
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
});
} }
Event::ControllerTouchpadUp { Event::ControllerTouchpadUp {
which, which,
touchpad,
finger, finger,
x, x,
y, y,
.. ..
} if active == Some(which) && w.attached.is_some() => { } if active == Some(which) && w.attached.is_some() => {
let _ = w w.forward_touch(which, touchpad as u32, finger as u8, x, y, false);
.attached
.as_ref()
.unwrap()
.send_rich_input(RichInput::Touchpad {
pad: 0,
finger: finger as u8,
active: false,
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
});
} }
// Motion: accel events update the cache; each gyro event ships a sample // Motion: accel events update the cache; each gyro event ships a sample
// (the DualSense reports both at ~250 Hz). Scale convention shared with // (the DualSense reports both at ~250 Hz). Scale convention shared with
@@ -559,6 +709,10 @@ fn run(
} }
} }
// Escalate a held escape chord to a disconnect (polled — the hold completes with no
// new button events; the chord itself is only detected while a session is attached).
w.maybe_fire_disconnect();
// Feedback planes (this thread is their single consumer). The host re-sends // Feedback planes (this thread is their single consumer). The host re-sends
// rumble state periodically, so a generous duration with refresh-on-update is // rumble state periodically, so a generous duration with refresh-on-update is
// safe — a dropped stop heals within ~500 ms. // safe — a dropped stop heals within ~500 ms.
+36 -3
View File
@@ -124,12 +124,13 @@ impl Capture {
} }
} }
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines, clippy::too_many_arguments)]
pub fn new( pub fn new(
window: &adw::ApplicationWindow, window: &adw::ApplicationWindow,
connector: Arc<NativeClient>, connector: Arc<NativeClient>,
frames: async_channel::Receiver<DecodedFrame>, frames: async_channel::Receiver<DecodedFrame>,
escape_rx: async_channel::Receiver<()>, escape_rx: async_channel::Receiver<()>,
disconnect_rx: async_channel::Receiver<()>,
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
inhibit_shortcuts: bool, inhibit_shortcuts: bool,
title: &str, title: &str,
@@ -152,7 +153,7 @@ pub fn new(
stats_label.set_margin_top(12); stats_label.set_margin_top(12);
let hint = gtk::Label::new(Some( let hint = gtk::Label::new(Some(
"Click the stream to capture input · Ctrl+Alt+Shift+Q releases", "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · Ctrl+Alt+Shift+D disconnects",
)); ));
hint.add_css_class("osd"); hint.add_css_class("osd");
hint.set_halign(gtk::Align::Center); hint.set_halign(gtk::Align::Center);
@@ -163,7 +164,9 @@ pub fn new(
// Flashed when entering fullscreen — the only exit affordances once the header bar is // Flashed when entering fullscreen — the only exit affordances once the header bar is
// hidden (F11 on a keyboard; the L1+R1+Start+Select chord on a controller, which is the // hidden (F11 on a keyboard; the L1+R1+Start+Select chord on a controller, which is the
// only way out on a Steam Deck). // only way out on a Steam Deck).
let fs_hint = gtk::Label::new(Some("F11 · L1 + R1 + Start + Select — exit fullscreen")); let fs_hint = gtk::Label::new(Some(
"F11 · L1 + R1 + Start + Select — exit fullscreen (hold to disconnect)",
));
fs_hint.add_css_class("osd"); fs_hint.add_css_class("osd");
fs_hint.set_halign(gtk::Align::Center); fs_hint.set_halign(gtk::Align::Center);
fs_hint.set_valign(gtk::Align::Start); fs_hint.set_valign(gtk::Align::Start);
@@ -297,6 +300,7 @@ pub fn new(
key.set_propagation_phase(gtk::PropagationPhase::Capture); key.set_propagation_phase(gtk::PropagationPhase::Capture);
let cap = capture.clone(); let cap = capture.clone();
let window_k = window.clone(); let window_k = window.clone();
let stop_kb = stop.clone();
key.connect_key_pressed(move |_, keyval, keycode, state| { key.connect_key_pressed(move |_, keyval, keycode, state| {
let chord = gdk::ModifierType::CONTROL_MASK let chord = gdk::ModifierType::CONTROL_MASK
| gdk::ModifierType::ALT_MASK | gdk::ModifierType::ALT_MASK
@@ -309,6 +313,13 @@ pub fn new(
} }
return glib::Propagation::Stop; return glib::Propagation::Stop;
} }
// Ctrl+Alt+Shift+D — leave the session. Now that Steam / QAM pass through to the host,
// the capture toggle alone can't end a stream, so this is the keyboard's explicit exit.
if state.contains(chord) && keyval.to_lower() == gdk::Key::d {
cap.release();
stop_kb.store(true, Ordering::SeqCst);
return glib::Propagation::Stop;
}
if keyval == gdk::Key::F11 { if keyval == gdk::Key::F11 {
if window_k.is_fullscreen() { if window_k.is_fullscreen() {
window_k.unfullscreen(); window_k.unfullscreen();
@@ -442,6 +453,24 @@ pub fn new(
}) })
}; };
// Controller disconnect (escape chord held past the hold threshold) → end the session, the
// controller equivalent of Ctrl+Alt+Shift+D. Setting `stop` ends the session pump, which pops
// this page (and fires `hidden` below). One-shot — the session is going away.
let disconnect_future = {
let window = window.clone();
let cap = capture.clone();
let stop_d = stop.clone();
glib::spawn_future_local(async move {
if disconnect_rx.recv().await.is_ok() {
cap.release();
if window.is_fullscreen() {
window.unfullscreen();
}
stop_d.store(true, Ordering::SeqCst);
}
})
};
// The page's `hidden` fires once navigation away completes (back button, pop on // The page's `hidden` fires once navigation away completes (back button, pop on
// session end) — NOT on the transient unmap/map cycle a NavigationView push performs. // session end) — NOT on the transient unmap/map cycle a NavigationView push performs.
{ {
@@ -449,6 +478,7 @@ pub fn new(
let stop_h = stop.clone(); let stop_h = stop.clone();
let handlers = RefCell::new(Some((fs_handler, active_handler))); let handlers = RefCell::new(Some((fs_handler, active_handler)));
let escape_future = RefCell::new(Some(escape_future)); let escape_future = RefCell::new(Some(escape_future));
let disconnect_future = RefCell::new(Some(disconnect_future));
page.connect_hidden(move |_| { page.connect_hidden(move |_| {
tracing::debug!("stream page hidden — ending session"); tracing::debug!("stream page hidden — ending session");
if let Some((fs, active)) = handlers.borrow_mut().take() { if let Some((fs, active)) = handlers.borrow_mut().take() {
@@ -458,6 +488,9 @@ pub fn new(
if let Some(f) = escape_future.borrow_mut().take() { if let Some(f) = escape_future.borrow_mut().take() {
f.abort(); f.abort();
} }
if let Some(f) = disconnect_future.borrow_mut().take() {
f.abort();
}
if window.is_fullscreen() { if window.is_fullscreen() {
window.unfullscreen(); window.unfullscreen();
} }
+108 -27
View File
@@ -169,6 +169,13 @@ fn button_bit(b: sdl3::gamepad::Button) -> Option<u32> {
Button::DPadLeft => wire::BTN_DPAD_LEFT, Button::DPadLeft => wire::BTN_DPAD_LEFT,
Button::DPadRight => wire::BTN_DPAD_RIGHT, Button::DPadRight => wire::BTN_DPAD_RIGHT,
Button::Touchpad => wire::BTN_TOUCHPAD, Button::Touchpad => wire::BTN_TOUCHPAD,
// Back grips / paddles (Steam Deck L4/L5/R4/R5, Xbox Elite P1P4) + the misc/Share button.
// PADDLE1/2/3/4 = R4/L4/R5/L5 (see the host `input::gamepad`).
Button::RightPaddle1 => wire::BTN_PADDLE1,
Button::LeftPaddle1 => wire::BTN_PADDLE2,
Button::RightPaddle2 => wire::BTN_PADDLE3,
Button::LeftPaddle2 => wire::BTN_PADDLE4,
Button::Misc1 => wire::BTN_MISC1,
_ => return None, _ => return None,
}) })
} }
@@ -240,6 +247,9 @@ struct Worker {
/// Wire state of the active pad — zeroed on the wire at switch/detach. /// Wire state of the active pad — zeroed on the wire at switch/detach.
last_axis: [i32; 6], last_axis: [i32; 6],
held_buttons: Vec<u32>, held_buttons: Vec<u32>,
/// Touchpad contacts the host believes are down, keyed by `(surface, finger)` — lifted on pad
/// switch / detach. surface 0 = the legacy single touchpad, 1/2 = a Steam left/right pad.
held_touches: std::collections::HashSet<(u8, u8)>,
last_accel: [i16; 3], last_accel: [i16; 3],
} }
@@ -252,13 +262,21 @@ impl Worker {
fn pad_info(&self, id: u32) -> Option<PadInfo> { fn pad_info(&self, id: u32) -> Option<PadInfo> {
let pad = self.opened.get(&id)?; let pad = self.opened.get(&id)?;
let mut pref = pref_for_type(
self.subsystem
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
);
// No SDL type for the Steam Deck / Steam Controller — detect Valve by VID/PID (Deck 0x1205,
// SC wired 0x1102, SC dongle 0x1142) so the host builds the virtual hid-steam pad.
if pad.vendor_id() == Some(0x28DE)
&& matches!(pad.product_id(), Some(0x1205 | 0x1102 | 0x1142))
{
pref = GamepadPref::SteamDeck;
}
Some(PadInfo { Some(PadInfo {
id, id,
name: pad.name().unwrap_or_else(|| "Controller".into()), name: pad.name().unwrap_or_else(|| "Controller".into()),
pref: pref_for_type( pref,
self.subsystem
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
),
}) })
} }
@@ -274,9 +292,33 @@ impl Worker {
} }
*v = i32::MIN; *v = i32::MIN;
} }
for (surface, finger) in self.held_touches.drain() {
let rich = if surface == 0 {
RichInput::Touchpad {
pad: 0,
finger,
active: false,
x: 0,
y: 0,
}
} else {
RichInput::TouchpadEx {
pad: 0,
surface,
finger,
touch: false,
click: false,
x: 0,
y: 0,
pressure: 0,
}
};
let _ = c.send_rich_input(rich);
}
} else { } else {
self.held_buttons.clear(); self.held_buttons.clear();
self.last_axis = [i32::MIN; 6]; self.last_axis = [i32::MIN; 6];
self.held_touches.clear();
} }
} }
@@ -292,6 +334,56 @@ impl Worker {
} }
} }
} }
/// Forward one touchpad contact on the rich-input plane. A multi-touchpad pad (Steam Deck / Steam
/// Controller) sends `TouchpadEx` with the surface (SDL touchpad 0 = left → 1, 1 = right → 2) and
/// signed coordinates; a single-touchpad pad (DualSense) keeps the legacy `Touchpad` (unsigned).
fn forward_touch(
&mut self,
which: u32,
touchpad: u32,
finger: u8,
x: f32,
y: f32,
active: bool,
) {
let Some(c) = self.attached.as_ref() else {
return;
};
let multi = self
.opened
.get(&which)
.map(|p| p.touchpads_count() >= 2)
.unwrap_or(false);
let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
let surface = if multi { (touchpad as u8) + 1 } else { 0 };
let rich = if multi {
RichInput::TouchpadEx {
pad: 0,
surface,
finger,
touch: active,
click: false,
x: (cx * 65535.0 - 32768.0) as i16,
y: (cy * 65535.0 - 32768.0) as i16,
pressure: 0,
}
} else {
RichInput::Touchpad {
pad: 0,
finger,
active,
x: (cx * 65535.0) as u16,
y: (cy * 65535.0) as u16,
}
};
let _ = c.send_rich_input(rich);
if active {
self.held_touches.insert((surface, finger));
} else {
self.held_touches.remove(&(surface, finger));
}
}
} }
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
@@ -305,6 +397,10 @@ fn run(
// thread. // thread.
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1"); sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1"); sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
// Let SDL's HIDAPI drivers open Valve Steam Controller / Steam Deck devices directly, so the
// paddles, both trackpads, and gyro arrive as first-class SDL gamepad inputs.
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1");
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", "1");
let sdl = sdl3::init().map_err(|e| e.to_string())?; let sdl = sdl3::init().map_err(|e| e.to_string())?;
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?; let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?; let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
@@ -317,6 +413,7 @@ fn run(
attached: None, attached: None,
last_axis: [i32::MIN; 6], last_axis: [i32::MIN; 6],
held_buttons: Vec::new(), held_buttons: Vec::new(),
held_touches: std::collections::HashSet::new(),
last_accel: [0; 3], last_accel: [0; 3],
}; };
@@ -426,9 +523,11 @@ fn run(
send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v); send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v);
} }
} }
// DualSense touchpad → the rich-input plane, normalized 0..=65535. // Touchpad contacts → the rich-input plane. One pad (DualSense) keeps the legacy
// `Touchpad`; two pads (Steam Deck / Steam Controller) send `TouchpadEx` per surface.
Event::ControllerTouchpadDown { Event::ControllerTouchpadDown {
which, which,
touchpad,
finger, finger,
x, x,
y, y,
@@ -436,41 +535,23 @@ fn run(
} }
| Event::ControllerTouchpadMotion { | Event::ControllerTouchpadMotion {
which, which,
touchpad,
finger, finger,
x, x,
y, y,
.. ..
} if active == Some(which) && w.attached.is_some() => { } if active == Some(which) && w.attached.is_some() => {
let _ = w w.forward_touch(which, touchpad as u32, finger as u8, x, y, true);
.attached
.as_ref()
.unwrap()
.send_rich_input(RichInput::Touchpad {
pad: 0,
finger: finger as u8,
active: true,
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
});
} }
Event::ControllerTouchpadUp { Event::ControllerTouchpadUp {
which, which,
touchpad,
finger, finger,
x, x,
y, y,
.. ..
} if active == Some(which) && w.attached.is_some() => { } if active == Some(which) && w.attached.is_some() => {
let _ = w w.forward_touch(which, touchpad as u32, finger as u8, x, y, false);
.attached
.as_ref()
.unwrap()
.send_rich_input(RichInput::Touchpad {
pad: 0,
finger: finger as u8,
active: false,
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
});
} }
// Motion: accel events update the cache; each gyro event ships a sample (the // Motion: accel events update the cache; each gyro event ships a sample (the
// DualSense reports both at ~250 Hz). Scale convention shared with the other // DualSense reports both at ~250 Hz). Scale convention shared with the other
+167
View File
@@ -492,6 +492,10 @@ pub const PUNKTFUNK_HIDOUT_LED: u8 = 1;
pub const PUNKTFUNK_HIDOUT_PLAYER_LEDS: u8 = 2; pub const PUNKTFUNK_HIDOUT_PLAYER_LEDS: u8 = 2;
/// `PunktfunkHidOutput::kind` — one adaptive-trigger effect (`which` + `effect`/`effect_len` valid). /// `PunktfunkHidOutput::kind` — one adaptive-trigger effect (`which` + `effect`/`effect_len` valid).
pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3; pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3;
/// `PunktfunkHidOutput::kind` — a trackpad haptic pulse (Steam Controller voice-coils). `which` =
/// side (0 = right pad, 1 = left pad); `effect[0..6]` packs `amplitude` / `period` / `count` as
/// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
pub const PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC: u8 = 4;
/// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block). /// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11; pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
@@ -559,6 +563,23 @@ impl PunktfunkHidOutput {
out.effect[..n].copy_from_slice(&effect[..n]); out.effect[..n].copy_from_slice(&effect[..n]);
out.effect_len = n as u8; out.effect_len = n as u8;
} }
HidOutput::TrackpadHaptic {
pad,
side,
amplitude,
period,
count,
} => {
// No new struct (PunktfunkHidOutput has no size guard): pack into the existing
// `which` (side) + `effect[0..6]` (amplitude/period/count LE), `effect_len = 6`.
out.kind = PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC;
out.pad = *pad;
out.which = *side;
out.effect[0..2].copy_from_slice(&amplitude.to_le_bytes());
out.effect[2..4].copy_from_slice(&period.to_le_bytes());
out.effect[4..6].copy_from_slice(&count.to_le_bytes());
out.effect_len = 6;
}
} }
out out
} }
@@ -618,6 +639,11 @@ impl PunktfunkHdrMeta {
pub const PUNKTFUNK_RICH_TOUCHPAD: u8 = 1; pub const PUNKTFUNK_RICH_TOUCHPAD: u8 = 1;
/// `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid). /// `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid).
pub const PUNKTFUNK_RICH_MOTION: u8 = 2; pub const PUNKTFUNK_RICH_MOTION: u8 = 2;
/// `RichInput::TouchpadEx` kind on the wire — an extended trackpad contact that identifies the
/// surface (0 single / 1 Steam-left / 2 Steam-right) and carries click + pressure. The host decodes
/// it today; *sending* it from a C client needs the size-prefixed `PunktfunkRichInputEx` +
/// `punktfunk_connection_send_rich_input2` (added with client capture).
pub const PUNKTFUNK_RICH_TOUCHPAD_EX: u8 = 3;
/// One rich client→host input for the host's virtual DualSense /// One rich client→host input for the host's virtual DualSense
/// ([`punktfunk_connection_send_rich_input`]): a touchpad contact or a motion sample. Set `kind` /// ([`punktfunk_connection_send_rich_input`]): a touchpad contact or a motion sample. Set `kind`
@@ -666,6 +692,77 @@ impl PunktfunkRichInput {
} }
} }
/// Forward-compatible superset of [`PunktfunkRichInput`] that can also express the rich Steam
/// surfaces: a *second* trackpad (`surface`), a distinct `click` vs touch, signed coordinates, and
/// pressure. Sent via [`punktfunk_connection_send_rich_input2`] — the only way a C client can emit a
/// `TouchpadEx`. The caller MUST set `struct_size = sizeof(PunktfunkRichInputEx)` (the ABI-skew
/// guard, like [`PunktfunkConfig`]); the legacy [`PunktfunkRichInput`] +
/// [`punktfunk_connection_send_rich_input`] stay byte-for-byte for existing callers.
#[cfg(feature = "quic")]
#[repr(C)]
#[derive(Clone, Copy)]
pub struct PunktfunkRichInputEx {
/// MUST equal `sizeof(PunktfunkRichInputEx)`.
pub struct_size: u32,
/// One of `PUNKTFUNK_RICH_*` (`TOUCHPAD` / `MOTION` / `TOUCHPAD_EX`).
pub kind: u8,
/// Gamepad index.
pub pad: u8,
/// Touchpad/TouchpadEx: contact id.
pub finger: u8,
/// Touchpad/TouchpadEx: 1 = finger down / touching, 0 = lifted.
pub active: u8,
/// TouchpadEx: which surface — 0 = single/DualSense, 1 = Steam left pad, 2 = Steam right pad.
pub surface: u8,
/// TouchpadEx: 1 = the pad is physically clicked (depressed), distinct from a touch contact.
pub click: u8,
/// Reserved for alignment; set to 0.
pub _reserved: [u8; 2],
/// TouchpadEx: x coordinate — **signed**, centred at 0 (the real Steam report convention). For a
/// legacy `TOUCHPAD` kind sent through this struct, store the unsigned `0..=65535` value's bits.
pub x: i16,
/// TouchpadEx: y coordinate — signed, centred at 0.
pub y: i16,
/// TouchpadEx: contact pressure (`0` if the surface has no force sensor).
pub pressure: u16,
/// Motion: gyro (pitch, yaw, roll), raw signed-16.
pub gyro: [i16; 3],
/// Motion: accelerometer (x, y, z), raw signed-16.
pub accel: [i16; 3],
}
#[cfg(feature = "quic")]
impl PunktfunkRichInputEx {
fn to_rich(self) -> Option<crate::quic::RichInput> {
use crate::quic::RichInput;
match self.kind {
PUNKTFUNK_RICH_TOUCHPAD_EX => Some(RichInput::TouchpadEx {
pad: self.pad,
surface: self.surface,
finger: self.finger,
touch: self.active != 0,
click: self.click != 0,
x: self.x,
y: self.y,
pressure: self.pressure,
}),
PUNKTFUNK_RICH_MOTION => Some(RichInput::Motion {
pad: self.pad,
gyro: self.gyro,
accel: self.accel,
}),
PUNKTFUNK_RICH_TOUCHPAD => Some(RichInput::Touchpad {
pad: self.pad,
finger: self.finger,
active: self.active != 0,
x: self.x as u16,
y: self.y as u16,
}),
_ => None,
}
}
}
/// Read an optional NUL-terminated UTF-8 string parameter; `Err` = invalid pointer/UTF-8. /// Read an optional NUL-terminated UTF-8 string parameter; `Err` = invalid pointer/UTF-8.
#[cfg(feature = "quic")] #[cfg(feature = "quic")]
unsafe fn opt_cstr<'a>(p: *const std::os::raw::c_char) -> std::result::Result<Option<&'a str>, ()> { unsafe fn opt_cstr<'a>(p: *const std::os::raw::c_char) -> std::result::Result<Option<&'a str>, ()> {
@@ -714,6 +811,22 @@ 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,
/// two grip paddles. Reserved — currently folds to `XBOX360` until its backend lands.
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5;
/// 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
/// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360.
pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6;
/// 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
/// `buttonFlags2 << 16` namespace. Mirror `input::gamepad::BTN_PADDLE1..4` / `BTN_MISC1`.
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE1: u32 = 0x0001_0000;
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE2: u32 = 0x0002_0000;
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE3: u32 = 0x0004_0000;
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE4: u32 = 0x0008_0000;
pub const PUNKTFUNK_GAMEPAD_BTN_MISC1: u32 = 0x0020_0000;
/// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`. /// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`.
/// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to /// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to
@@ -742,11 +855,28 @@ const _: () = {
// Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift). // Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift).
const _: () = { const _: () = {
use crate::config::GamepadPref; use crate::config::GamepadPref;
use crate::input::gamepad as g;
assert!(PUNKTFUNK_GAMEPAD_AUTO == GamepadPref::Auto.to_u8() as u32); assert!(PUNKTFUNK_GAMEPAD_AUTO == GamepadPref::Auto.to_u8() as u32);
assert!(PUNKTFUNK_GAMEPAD_XBOX360 == GamepadPref::Xbox360.to_u8() as u32); assert!(PUNKTFUNK_GAMEPAD_XBOX360 == GamepadPref::Xbox360.to_u8() as u32);
assert!(PUNKTFUNK_GAMEPAD_DUALSENSE == GamepadPref::DualSense.to_u8() as u32); assert!(PUNKTFUNK_GAMEPAD_DUALSENSE == GamepadPref::DualSense.to_u8() as u32);
assert!(PUNKTFUNK_GAMEPAD_XBOXONE == GamepadPref::XboxOne.to_u8() as u32); assert!(PUNKTFUNK_GAMEPAD_XBOXONE == GamepadPref::XboxOne.to_u8() as u32);
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_STEAMDECK == GamepadPref::SteamDeck.to_u8() as u32);
// Extended button bits mirror the wire `input::gamepad` constants.
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE1 == g::BTN_PADDLE1);
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE2 == g::BTN_PADDLE2);
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE3 == g::BTN_PADDLE3);
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE4 == g::BTN_PADDLE4);
assert!(PUNKTFUNK_GAMEPAD_BTN_MISC1 == g::BTN_MISC1);
};
// The additive M3 kinds (TouchpadEx / TrackpadHaptic) must never grow the legacy ABI structs —
// they have no `struct_size` guard, so a layout change would corrupt old-built callers' buffers.
#[cfg(feature = "quic")]
const _: () = {
assert!(core::mem::size_of::<PunktfunkRichInput>() == 20);
assert!(core::mem::size_of::<PunktfunkHidOutput>() == 19);
}; };
/// Trust: `pin_sha256` (NULL or 32 bytes) is the expected SHA-256 fingerprint of the host's /// Trust: `pin_sha256` (NULL or 32 bytes) is the expected SHA-256 fingerprint of the host's
@@ -1727,6 +1857,43 @@ pub unsafe extern "C" fn punktfunk_connection_send_rich_input(
}) })
} }
/// Send a rich client→host input via the forward-compatible [`PunktfunkRichInputEx`] — the only way
/// a C client can emit a `TouchpadEx` (a second trackpad / signed coords / pressure). Set
/// `rich->struct_size = sizeof(PunktfunkRichInputEx)`; a smaller (older-layout) value is rejected.
///
/// # Safety
/// `c` is a valid connection handle; `rich` is null or points to at least its declared
/// `struct_size` bytes.
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_send_rich_input2(
c: *mut PunktfunkConnection,
rich: *const PunktfunkRichInputEx,
) -> PunktfunkStatus {
guard(|| {
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
if rich.is_null() {
return PunktfunkStatus::NullPointer;
}
// Read only the 4-byte size prefix first to bound the subsequent full read (the
// `PunktfunkConfig` ABI-skew precedent).
let declared = unsafe { std::ptr::addr_of!((*rich).struct_size).read_unaligned() } as usize;
if declared < std::mem::size_of::<PunktfunkRichInputEx>() {
return PunktfunkStatus::InvalidArg;
}
match unsafe { *rich }.to_rich() {
Some(r) => match c.inner.send_rich_input(r) {
Ok(()) => PunktfunkStatus::Ok,
Err(e) => e.status(),
},
None => PunktfunkStatus::InvalidArg,
}
})
}
/// The currently active session mode — the Welcome's, until an accepted /// The currently active session mode — the Welcome's, until an accepted
/// [`punktfunk_connection_request_mode`] switches it. Safe any time after connect. /// [`punktfunk_connection_request_mode`] switches it. Safe any time after connect.
/// ///
+45 -4
View File
@@ -137,8 +137,9 @@ impl CompositorPref {
/// host decide (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). A concrete preference is /// host decide (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). A concrete preference is
/// 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`), appended to /// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
/// `Hello`/`Welcome` — older peers simply omit/ignore it (an unknown byte degrades to `Auto`). /// `5 = SteamController`, `6 = SteamDeck`), appended to `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).
@@ -155,10 +156,19 @@ 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,
/// two grip paddles, trackpad-only haptics. Needs Linux UHID. *(Reserved; its backend is not yet
/// built — currently folds to `Xbox360`; the Deck identity below is the implemented one.)*
SteamController,
/// 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
/// with native glyphs when Steam runs on the host. Needs Linux UHID.
SteamDeck,
} }
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`.
pub const fn to_u8(self) -> u8 { pub const fn to_u8(self) -> u8 {
match self { match self {
GamepadPref::Auto => 0, GamepadPref::Auto => 0,
@@ -166,6 +176,8 @@ impl GamepadPref {
GamepadPref::DualSense => 2, GamepadPref::DualSense => 2,
GamepadPref::XboxOne => 3, GamepadPref::XboxOne => 3,
GamepadPref::DualShock4 => 4, GamepadPref::DualShock4 => 4,
GamepadPref::SteamController => 5,
GamepadPref::SteamDeck => 6,
} }
} }
@@ -177,6 +189,8 @@ impl GamepadPref {
2 => GamepadPref::DualSense, 2 => GamepadPref::DualSense,
3 => GamepadPref::XboxOne, 3 => GamepadPref::XboxOne,
4 => GamepadPref::DualShock4, 4 => GamepadPref::DualShock4,
5 => GamepadPref::SteamController,
6 => GamepadPref::SteamDeck,
_ => GamepadPref::Auto, _ => GamepadPref::Auto,
} }
} }
@@ -192,12 +206,14 @@ impl GamepadPref {
GamepadPref::XboxOne GamepadPref::XboxOne
} }
"dualshock4" | "dualshock" | "ds4" | "ps4" => GamepadPref::DualShock4, "dualshock4" | "dualshock" | "ds4" | "ps4" => GamepadPref::DualShock4,
"steamdeck" | "steam-deck" | "deck" => GamepadPref::SteamDeck,
"steamcontroller" | "steam-controller" | "steamcon" => GamepadPref::SteamController,
_ => return None, _ => return None,
}) })
} }
/// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`, /// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`,
/// `"dualshock4"`). /// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`).
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
GamepadPref::Auto => "auto", GamepadPref::Auto => "auto",
@@ -205,6 +221,8 @@ impl GamepadPref {
GamepadPref::DualSense => "dualsense", GamepadPref::DualSense => "dualsense",
GamepadPref::XboxOne => "xboxone", GamepadPref::XboxOne => "xboxone",
GamepadPref::DualShock4 => "dualshock4", GamepadPref::DualShock4 => "dualshock4",
GamepadPref::SteamController => "steamcontroller",
GamepadPref::SteamDeck => "steamdeck",
} }
} }
} }
@@ -381,4 +399,27 @@ mod tests {
c.fec.fec_percent = 15; // 250 + ceil(250*15/100)=288 > 255 c.fec.fec_percent = 15; // 250 + ceil(250*15/100)=288 > 255
assert!(c.validate().is_err()); assert!(c.validate().is_err());
} }
#[test]
fn gamepad_pref_steam_roundtrip() {
use GamepadPref::*;
// Wire-byte round-trip for the Steam additions; an unknown byte still degrades to Auto.
for (p, b) in [(SteamController, 5u8), (SteamDeck, 6)] {
assert_eq!(p.to_u8(), b);
assert_eq!(GamepadPref::from_u8(b), p);
}
assert_eq!(GamepadPref::from_u8(99), Auto);
// Name parsing + canonical-name round-trip.
assert_eq!(GamepadPref::from_name("steamdeck"), Some(SteamDeck));
assert_eq!(GamepadPref::from_name("deck"), Some(SteamDeck));
assert_eq!(
GamepadPref::from_name("steamcontroller"),
Some(SteamController)
);
assert_eq!(SteamDeck.as_str(), "steamdeck");
assert_eq!(
GamepadPref::from_name(SteamController.as_str()),
Some(SteamController)
);
}
} }
+14
View File
@@ -66,10 +66,24 @@ pub mod gamepad {
pub const BTN_B: u32 = 0x2000; pub const BTN_B: u32 = 0x2000;
pub const BTN_X: u32 = 0x4000; pub const BTN_X: u32 = 0x4000;
pub const BTN_Y: u32 = 0x8000; pub const BTN_Y: u32 = 0x8000;
// Extended buttons in Moonlight's `buttonFlags2 << 16` namespace (see `gamestream/gamepad.rs`),
// so the GameStream paddle path and the native path share one host injector map. The four Steam
// Deck back grips (L4/L5/R4/R5) reuse the four GameStream/Xbox-Elite paddle slots — a semantic
// 1:1 for binding (the device identity carries the glyph distinction).
/// Back grip R4 — SDL `RightPaddle1` / GameStream `PADDLE1`.
pub const BTN_PADDLE1: u32 = 0x0001_0000;
/// Back grip L4 — SDL `LeftPaddle1` / GameStream `PADDLE2`.
pub const BTN_PADDLE2: u32 = 0x0002_0000;
/// Back grip R5 — SDL `RightPaddle2` / GameStream `PADDLE3`.
pub const BTN_PADDLE3: u32 = 0x0004_0000;
/// Back grip L5 — SDL `LeftPaddle2` / GameStream `PADDLE4`.
pub const BTN_PADDLE4: u32 = 0x0008_0000;
/// DualSense touchpad click. Moonlight's extended-button position (`buttonFlags2` /// DualSense touchpad click. Moonlight's extended-button position (`buttonFlags2`
/// merges in at `<< 16`, see `gamestream/gamepad.rs`), so GameStream clients land on /// merges in at `<< 16`, see `gamestream/gamepad.rs`), so GameStream clients land on
/// the same bit. Only the DualSense backend renders it; the xpad has no such button. /// the same bit. Only the DualSense backend renders it; the xpad has no such button.
pub const BTN_TOUCHPAD: u32 = 0x10_0000; pub const BTN_TOUCHPAD: u32 = 0x10_0000;
/// Misc / capture button — the Deck `…`/quick-access, Share/Capture / GameStream `MISC`.
pub const BTN_MISC1: u32 = 0x0020_0000;
/// Axis ids for `InputKind::GamepadAxis`. /// Axis ids for `InputKind::GamepadAxis`.
pub const AXIS_LS_X: u32 = 0; pub const AXIS_LS_X: u32 = 0;
+92 -1
View File
@@ -1218,6 +1218,7 @@ pub fn decode_mic_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
const RICH_TOUCHPAD: u8 = 0x01; const RICH_TOUCHPAD: u8 = 0x01;
const RICH_MOTION: u8 = 0x02; const RICH_MOTION: u8 = 0x02;
const RICH_TOUCHPAD_EX: u8 = 0x03;
/// A rich client→host controller input beyond the fixed [`InputEvent`](crate::input::InputEvent): /// A rich client→host controller input beyond the fixed [`InputEvent`](crate::input::InputEvent):
/// the DualSense touchpad and motion sensors. `pad` is the gamepad index. Wire form is /// the DualSense touchpad and motion sensors. `pad` is the gamepad index. Wire form is
@@ -1241,6 +1242,22 @@ pub enum RichInput {
gyro: [i16; 3], gyro: [i16; 3],
accel: [i16; 3], accel: [i16; 3],
}, },
/// A richer trackpad contact that also identifies *which* physical pad (Steam Controller / Deck
/// have two), carries a separate click vs touch state, and a pressure reading. `surface`:
/// `0` = the single / DualSense touchpad, `1` = the Steam left pad, `2` = the Steam right pad.
/// Coordinates are **signed** (centred at 0), matching the real Steam report; `pressure` is `0`
/// for a surface with no force sensor. New clients send this for every touch surface; the host
/// decodes both `Touchpad` (`0x01`) and `TouchpadEx` (`0x03`) indefinitely.
TouchpadEx {
pad: u8,
surface: u8,
finger: u8,
touch: bool,
click: bool,
x: i16,
y: i16,
pressure: u16,
},
} }
impl RichInput { impl RichInput {
@@ -1264,6 +1281,22 @@ impl RichInput {
out.extend_from_slice(&v.to_le_bytes()); out.extend_from_slice(&v.to_le_bytes());
} }
} }
RichInput::TouchpadEx {
pad,
surface,
finger,
touch,
click,
x,
y,
pressure,
} => {
let state = (touch as u8) | ((click as u8) << 1);
out.extend_from_slice(&[RICH_TOUCHPAD_EX, pad, surface, finger, state]);
out.extend_from_slice(&x.to_le_bytes());
out.extend_from_slice(&y.to_le_bytes());
out.extend_from_slice(&pressure.to_le_bytes());
}
} }
out out
} }
@@ -1288,6 +1321,16 @@ impl RichInput {
accel: [i16at(9), i16at(11), i16at(13)], accel: [i16at(9), i16at(11), i16at(13)],
}) })
} }
RICH_TOUCHPAD_EX if b.len() >= 12 => Some(RichInput::TouchpadEx {
pad: b[2],
surface: b[3],
finger: b[4],
touch: b[5] & 0x01 != 0,
click: b[5] & 0x02 != 0,
x: i16::from_le_bytes([b[6], b[7]]),
y: i16::from_le_bytes([b[8], b[9]]),
pressure: u16::from_le_bytes([b[10], b[11]]),
}),
_ => None, _ => None,
} }
} }
@@ -1296,6 +1339,7 @@ impl RichInput {
const HIDOUT_LED: u8 = 0x01; const HIDOUT_LED: u8 = 0x01;
const HIDOUT_PLAYER_LEDS: u8 = 0x02; const HIDOUT_PLAYER_LEDS: u8 = 0x02;
const HIDOUT_TRIGGER: u8 = 0x03; const HIDOUT_TRIGGER: u8 = 0x03;
const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04;
/// DualSense feedback flowing host → client (what a game wrote to the host's virtual pad). /// DualSense feedback flowing host → client (what a game wrote to the host's virtual pad).
/// Wire form `[0xCD][kind][pad][fields…]`. The rich analog of the fixed rumble datagram; /// Wire form `[0xCD][kind][pad][fields…]`. The rich analog of the fixed rumble datagram;
@@ -1309,6 +1353,16 @@ pub enum HidOutput {
/// One adaptive-trigger effect: `which` 0 = L2, 1 = R2; `effect` is the raw DualSense /// One adaptive-trigger effect: `which` 0 = L2, 1 = R2; `effect` is the raw DualSense
/// trigger parameter block (mode + params) for the client to replay on a real controller. /// trigger parameter block (mode + params) for the client to replay on a real controller.
Trigger { pad: u8, which: u8, effect: Vec<u8> }, Trigger { pad: u8, which: u8, effect: Vec<u8> },
/// A trackpad haptic pulse for a Steam Controller's voice-coil actuators (its only "rumble").
/// `side` 0 = right pad, 1 = left pad; `amplitude` + `period` (µs off-time) + `count` (pulses)
/// synthesize a buzz. A client without trackpad coils drops it (or maps it to ordinary rumble).
TrackpadHaptic {
pad: u8,
side: u8,
amplitude: u16,
period: u16,
count: u16,
},
} }
impl HidOutput { impl HidOutput {
@@ -1325,6 +1379,18 @@ impl HidOutput {
out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]); out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]);
out.extend_from_slice(effect); out.extend_from_slice(effect);
} }
HidOutput::TrackpadHaptic {
pad,
side,
amplitude,
period,
count,
} => {
out.extend_from_slice(&[HIDOUT_TRACKPAD_HAPTIC, *pad, *side]);
out.extend_from_slice(&amplitude.to_le_bytes());
out.extend_from_slice(&period.to_le_bytes());
out.extend_from_slice(&count.to_le_bytes());
}
} }
out out
} }
@@ -1349,6 +1415,13 @@ impl HidOutput {
which: b[3], which: b[3],
effect: b[4..].to_vec(), effect: b[4..].to_vec(),
}), }),
HIDOUT_TRACKPAD_HAPTIC if b.len() >= 10 => Some(HidOutput::TrackpadHaptic {
pad: b[2],
side: b[3],
amplitude: u16::from_le_bytes([b[4], b[5]]),
period: u16::from_le_bytes([b[6], b[7]]),
count: u16::from_le_bytes([b[8], b[9]]),
}),
_ => None, _ => None,
} }
} }
@@ -2486,6 +2559,16 @@ mod tests {
gyro: [-100, 200, -300], gyro: [-100, 200, -300],
accel: [16384, -8192, 1], accel: [16384, -8192, 1],
}, },
RichInput::TouchpadEx {
pad: 2,
surface: 1,
finger: 1,
touch: true,
click: false,
x: -12345,
y: 30000,
pressure: 4000,
},
] { ] {
let d = ev.encode(); let d = ev.encode();
assert_eq!(d[0], RICH_INPUT_MAGIC); assert_eq!(d[0], RICH_INPUT_MAGIC);
@@ -2494,7 +2577,8 @@ mod tests {
// Disjoint from the fixed input datagram (0xC8); unknown kind + truncation → None. // Disjoint from the fixed input datagram (0xC8); unknown kind + truncation → None.
assert!(RichInput::decode(&[crate::input::INPUT_MAGIC; 18]).is_none()); assert!(RichInput::decode(&[crate::input::INPUT_MAGIC; 18]).is_none());
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, 0x7F]).is_none()); // unknown kind assert!(RichInput::decode(&[RICH_INPUT_MAGIC, 0x7F]).is_none()); // unknown kind
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD, 0]).is_none()); assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD, 0]).is_none()); // short
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD_EX, 0, 0, 0, 0]).is_none());
// short // short
} }
@@ -2516,6 +2600,13 @@ mod tests {
which: 1, which: 1,
effect: vec![0x26, 0x90, 0xA0, 0xFF, 0x00, 0x00], effect: vec![0x26, 0x90, 0xA0, 0xFF, 0x00, 0x00],
}, },
HidOutput::TrackpadHaptic {
pad: 0,
side: 1,
amplitude: 0x1234,
period: 0x5678,
count: 9,
},
]; ];
for ev in &cases { for ev in &cases {
let d = ev.encode(); let d = ev.encode();
+7
View File
@@ -89,6 +89,9 @@ tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
wayland-client = "0.31" wayland-client = "0.31"
wayland-protocols-wlr = { version = "0.3", features = ["client"] } wayland-protocols-wlr = { version = "0.3", features = ["client"] }
wayland-protocols-misc = { version = "0.3", features = ["client"] } wayland-protocols-misc = { version = "0.3", features = ["client"] }
# `xdg-output` (zxdg_output_v1): the per-output *logical* geometry (post-scale size + global
# position), used by the KWin fake_input backend to map absolute coordinates under display scaling.
wayland-protocols = { version = "0.32", features = ["client"] }
# Codegen for KDE's `zkde_screencast_unstable_v1` (vendored in `protocols/`): create a KWin # Codegen for KDE's `zkde_screencast_unstable_v1` (vendored in `protocols/`): create a KWin
# virtual output sized to the client's resolution and get its PipeWire node (KRdp's path). # virtual output sized to the client's resolution and get its PipeWire node (KRdp's path).
# `wayland-backend` is referenced by the generated interface tables. # `wayland-backend` is referenced by the generated interface tables.
@@ -119,6 +122,10 @@ ash = "0.38"
# `libcuda.so.1` is dlopen'd at runtime (NOT link-time) so one Linux binary runs on NVIDIA # `libcuda.so.1` is dlopen'd at runtime (NOT link-time) so one Linux binary runs on NVIDIA
# (zero-copy via CUDA) AND on AMD/Intel (VAAPI, no NVIDIA driver present) — see `zerocopy::cuda`. # (zero-copy via CUDA) AND on AMD/Intel (VAAPI, no NVIDIA driver present) — see `zerocopy::cuda`.
libloading = "0.8" libloading = "0.8"
# Vendored + trimmed `usbip` server core (no libusb) — presents a virtual Steam Deck over USB/IP
# so the local `vhci_hcd` attaches it: the shippable, Secure-Boot-clean, Steam-Input-promotable
# virtual-Deck transport on non-SteamOS hosts (`inject/linux/steam_usbip.rs`). See the crate's NOTICE.
usbip-sim = { path = "vendor/usbip-sim" }
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]
# Windows host backends. `windows` covers the Win32/CCD APIs the SudoVDA virtual-display backend # Windows host backends. `windows` covers the Win32/CCD APIs the SudoVDA virtual-display backend
@@ -1,8 +1,8 @@
//! P2 direct frame push (kill DDA) — HOST side. The pf-vdisplay driver runs in a restricted WUDFHost //! P2 direct frame push (kill DDA) — HOST side. The pf-vdisplay driver's WUDFHost canNOT create named
//! token that canNOT create named kernel objects, so — exactly like the gamepad UMDF drivers //! kernel objects, so — exactly like the gamepad UMDF drivers (`inject/dualsense_windows.rs`) — the
//! (`inject/dualsense_windows.rs`) — the HOST (privileged) CREATES the shared header + frame-ready //! HOST (privileged) CREATES the shared header + frame-ready event + ring of keyed-mutex textures
//! event + ring of keyed-mutex textures (`Global\` names, permissive `D:(A;;GA;;;WD)` SDDL) on the //! (`Global\` names, scoped `D:(A;;GA;;;SY)(A;;GA;;;LS)` to SYSTEM + the driver's LocalService host —
//! discrete render GPU, and the driver only OPENS them and copies frames in. We then consume the ring //! see `shared_object_sa`) on the discrete render GPU, and the driver only OPENS them and copies frames in. We then consume the ring
//! straight into the zero-copy NVENC path — no DXGI Desktop Duplication, no `win32u` hook. Gated by //! straight into the zero-copy NVENC path — no DXGI Desktop Duplication, no `win32u` hook. Gated by
//! `PUNKTFUNK_IDD_PUSH`. Driver counterpart: `packaging/windows/drivers/pf-vdisplay/src/ //! `PUNKTFUNK_IDD_PUSH`. Driver counterpart: `packaging/windows/drivers/pf-vdisplay/src/
//! frame_transport.rs`. The shared `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the //! frame_transport.rs`. The shared `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the
@@ -236,13 +236,17 @@ pub struct IddPushCapturer {
// ownership to one thread at a time with NO concurrent access; we do not (and must not) claim `Sync`. // ownership to one thread at a time with NO concurrent access; we do not (and must not) claim `Sync`.
unsafe impl Send for IddPushCapturer {} unsafe impl Send for IddPushCapturer {}
/// Build a permissive (Everyone:GenericAll) `SECURITY_ATTRIBUTES` so the restricted WUDFHost driver /// Build a `SECURITY_ATTRIBUTES` granting GENERIC_ALL to **SYSTEM** (the host creates+publishes the
/// can OPEN the host-created objects — the same `D:(A;;GA;;;WD)` SDDL the gamepad shared section uses. /// shared event + texture ring) and **LocalService** (the account the pf_vdisplay WUDFHost runs under,
/// The returned `psd` backing must outlive `sa`; both are dropped when the process exits. /// which consumes them) — `D:(A;;GA;;;SY)(A;;GA;;;LS)`, the same scoping as the gamepad section. The
unsafe fn permissive_sa() -> Result<(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTOR)> { /// old SDDL granted **Everyone** (`WD`), which let any local user open the `Global\pfvd-*` objects and
/// read captured screen frames (security-review 2026-06-28 #5). Verified on the RTX box (2026-06-29):
/// the WUDFHost token is `S-1-5-19` (LocalService), SYSTEM integrity, zero restricted SIDs — so SY+LS
/// suffices for the driver and excludes normal user processes. `psd` must outlive `sa`.
unsafe fn shared_object_sa() -> Result<(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTOR)> {
let mut psd = PSECURITY_DESCRIPTOR::default(); let mut psd = PSECURITY_DESCRIPTOR::default();
ConvertStringSecurityDescriptorToSecurityDescriptorW( ConvertStringSecurityDescriptorToSecurityDescriptorW(
w!("D:(A;;GA;;;WD)"), w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"),
SDDL_REVISION_1, SDDL_REVISION_1,
&mut psd, &mut psd,
None, None,
@@ -269,7 +273,7 @@ impl IddPushCapturer {
h: u32, h: u32,
format: DXGI_FORMAT, format: DXGI_FORMAT,
) -> Result<Vec<HostSlot>> { ) -> Result<Vec<HostSlot>> {
let (sa, _psd) = permissive_sa()?; let (sa, _psd) = shared_object_sa()?;
let mut slots = Vec::new(); let mut slots = Vec::new();
for k in 0..RING_LEN { for k in 0..RING_LEN {
let desc = D3D11_TEXTURE2D_DESC { let desc = D3D11_TEXTURE2D_DESC {
@@ -375,7 +379,7 @@ impl IddPushCapturer {
// SAFETY: one block over the whole ring setup; every operation in it is sound: // SAFETY: one block over the whole ring setup; every operation in it is sound:
// - `set_advanced_color`/`advanced_color_enabled` are `unsafe fn`s taking only a copy of the plain // - `set_advanced_color`/`advanced_color_enabled` are `unsafe fn`s taking only a copy of the plain
// `u32` target id; they read/flip CCD display config and return owned values, borrowing nothing. // `u32` target id; they read/flip CCD display config and return owned values, borrowing nothing.
// - `CreateDXGIFactory1`, `EnumAdapterByLuid`, `make_device`, `permissive_sa`, `CreateFileMappingW`, // - `CreateDXGIFactory1`, `EnumAdapterByLuid`, `make_device`, `shared_object_sa`, `CreateFileMappingW`,
// `MapViewOfFile`, `CreateEventW`, and `create_ring_slots` are all `?`-checked, so every returned // `MapViewOfFile`, `CreateEventW`, and `create_ring_slots` are all `?`-checked, so every returned
// interface/handle/view is non-error before use; `&sa`/`&adapter`/`&device`/the `&HSTRING` names // interface/handle/view is non-error before use; `&sa`/`&adapter`/`&device`/the `&HSTRING` names
// are live borrows that outlive each synchronous call, and `sa.lpSecurityDescriptor` stays valid // are live borrows that outlive each synchronous call, and `sa.lpSecurityDescriptor` stays valid
@@ -421,7 +425,7 @@ impl IddPushCapturer {
.context("EnumAdapterByLuid(render adapter) for IDD push")?; .context("EnumAdapterByLuid(render adapter) for IDD push")?;
let (device, context) = make_device(&adapter).context("make_device for IDD push")?; let (device, context) = make_device(&adapter).context("make_device for IDD push")?;
let (sa, _psd) = permissive_sa()?; let (sa, _psd) = shared_object_sa()?;
let bytes = std::mem::size_of::<SharedHeader>().max(64); let bytes = std::mem::size_of::<SharedHeader>().max(64);
// Header. // Header.
@@ -66,6 +66,13 @@ pub const BTN_A: u32 = 0x1000;
pub const BTN_B: u32 = 0x2000; pub const BTN_B: u32 = 0x2000;
pub const BTN_X: u32 = 0x4000; pub const BTN_X: u32 = 0x4000;
pub const BTN_Y: u32 = 0x8000; pub const BTN_Y: u32 = 0x8000;
// Extended buttons in the `buttonFlags2 << 16` namespace (mirror `punktfunk_core::input::gamepad`):
// the four back-grip paddles. `decode` already merges `buttonFlags2 << 16` into `buttons`, but the
// injector map dropped these bits — Sunshine/Moonlight paddle clients were silently no-op'd.
pub const BTN_PADDLE1: u32 = 0x0001_0000;
pub const BTN_PADDLE2: u32 = 0x0002_0000;
pub const BTN_PADDLE3: u32 = 0x0004_0000;
pub const BTN_PADDLE4: u32 = 0x0008_0000;
/// Decode one decrypted control plaintext into a controller event, if it is one. Mouse, /// Decode one decrypted control plaintext into a controller event, if it is one. Mouse,
/// keyboard, keepalives etc. yield `None` (they're handled by [`super::input::decode`]). /// keyboard, keepalives etc. yield `None` (they're handled by [`super::input::decode`]).
@@ -101,6 +101,10 @@ struct Session {
server_challenge: [u8; 16], server_challenge: [u8; 16],
/// The client's phase-3 hash, recomputed + checked in phase 4. /// The client's phase-3 hash, recomputed + checked in phase 4.
client_hash: Vec<u8>, client_hash: Vec<u8>,
/// Set once phase 3 has produced the RSA-signed serversecret. A repeated phase 3 is refused so a
/// peer past phase 1 can't loop phase2/phase3 to harvest many signing-time samples (a passive
/// timing-oracle amplifier vs. the rsa-crate Marvin side-channel; see `.cargo/audit.toml`).
responded: bool,
} }
pub struct Pairing { pub struct Pairing {
@@ -155,6 +159,7 @@ impl Pairing {
serversecret: [0; 16], serversecret: [0; 16],
server_challenge: [0; 16], server_challenge: [0; 16],
client_hash: Vec::new(), client_hash: Vec::new(),
responded: false,
}, },
); );
tracing::info!( tracing::info!(
@@ -216,6 +221,14 @@ impl Pairing {
bail!("short challenge response"); bail!("short challenge response");
} }
s.client_hash = client_hash[..32].to_vec(); s.client_hash = client_hash[..32].to_vec();
// Sign the serversecret exactly ONCE per ceremony: refuse a repeated phase 3 so a peer that
// cleared phase 1 (operator PIN) can't replay it to collect many RSA signing-time samples
// (timing-oracle amplifier vs. RUSTSEC-2023-0071; see `.cargo/audit.toml`). A legit client
// signs once. The session stays for phase 4 (the cert-pin step) but won't re-sign.
if s.responded {
bail!("serverchallengeresp already answered for this pairing session");
}
s.responded = true;
let sig: Signature = id.signing_key.sign(&s.serversecret); let sig: Signature = id.signing_key.sign(&s.serversecret);
let mut secret = Vec::with_capacity(16 + 256); let mut secret = Vec::with_capacity(16 + 256);
secret.extend_from_slice(&s.serversecret); secret.extend_from_slice(&s.serversecret);
+25
View File
@@ -491,6 +491,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;
/// Linux: virtual Steam Deck via UHID — the kernel `hid-steam` driver binds it as a real Deck.
#[cfg(target_os = "linux")]
#[path = "inject/linux/steam_controller.rs"]
pub mod steam_controller;
/// 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).
/// SteamOS-host only (needs `dummy_hcd` + `raw_gadget`).
#[cfg(target_os = "linux")]
#[path = "inject/linux/steam_gadget.rs"]
pub mod steam_gadget;
/// Transport-independent Steam Controller / Steam Deck HID contract (descriptor, byte-exact Deck
/// serializer, XInput/rich mappers, rumble parser), used by the Linux UHID backend ([`steam_controller`]).
#[cfg(target_os = "linux")]
#[path = "inject/proto/steam_proto.rs"]
pub mod steam_proto;
/// Pure fallback-remap policy (Steam-only inputs onto a non-Steam backend) + the Deck motion rescale.
#[cfg(target_os = "linux")]
#[path = "inject/proto/steam_remap.rs"]
pub mod steam_remap;
/// Linux: virtual Steam Deck over **USB/IP** (`vhci_hcd`) — the shippable, Secure-Boot-clean,
/// Steam-Input-promotable virtual-Deck transport on non-SteamOS hosts (Bazzite/generic), where
/// `dummy_hcd`/`raw_gadget` aren't built. In-tree + signed; no module build, no MOK.
#[cfg(target_os = "linux")]
#[path = "inject/linux/steam_usbip.rs"]
pub mod steam_usbip;
/// 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 {
@@ -182,6 +182,9 @@ pub struct DualSenseManager {
last_write: Vec<Instant>, last_write: Vec<Instant>,
/// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events. /// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events.
broken: bool, broken: bool,
/// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button
/// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
remap: crate::inject::steam_remap::RemapConfig,
} }
impl Default for DualSenseManager { impl Default for DualSenseManager {
@@ -198,6 +201,7 @@ impl DualSenseManager {
last_rumble: vec![(0, 0); MAX_PADS], last_rumble: vec![(0, 0); MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS], last_write: vec![Instant::now(); MAX_PADS],
broken: false, broken: false,
remap: crate::inject::steam_remap::RemapConfig::from_env(),
} }
} }
@@ -229,8 +233,12 @@ impl DualSenseManager {
// Merge buttons/sticks/triggers from the frame, preserving touch + motion (those // Merge buttons/sticks/triggers from the frame, preserving touch + motion (those
// come on the rich-input plane and must survive a button-only frame). // come on the rich-input plane and must survive a button-only frame).
let prev = self.state[idx]; let prev = self.state[idx];
// Steam back grips have no DualSense slot — fold them onto standard buttons per the
// configured policy (default drop) so they aren't silently lost.
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(
f.buttons, buttons,
f.ls_x, f.ls_x,
f.ls_y, f.ls_y,
f.rs_x, f.rs_x,
@@ -252,7 +260,9 @@ impl DualSenseManager {
/// arrived first); they're dropped if the pad isn't present. /// arrived first); they're dropped if the pad isn't present.
pub fn apply_rich(&mut self, rich: RichInput) { pub fn apply_rich(&mut self, rich: RichInput) {
let idx = match rich { let idx = match rich {
RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize, RichInput::Touchpad { pad, .. }
| RichInput::Motion { pad, .. }
| RichInput::TouchpadEx { pad, .. } => pad as usize,
}; };
if idx >= MAX_PADS || self.pads[idx].is_none() { if idx >= MAX_PADS || self.pads[idx].is_none() {
return; return;
@@ -280,6 +290,26 @@ impl DualSenseManager {
self.state[idx].gyro = gyro; self.state[idx].gyro = gyro;
self.state[idx].accel = accel; self.state[idx].accel = accel;
} }
RichInput::TouchpadEx {
surface,
finger,
touch,
x,
y,
..
} => {
// A Steam right/single pad maps onto the one DualSense touchpad (signed centre-0 →
// 0..=65535); surface 1 (the Steam left pad) has no DualSense equivalent.
if surface != 1 {
let slot = (finger as usize).min(1);
let n = |v: i16| ((v as i32) + 32768) as u32;
let t = &mut self.state[idx].touch[slot];
t.active = touch;
t.id = slot as u8;
t.x = (n(x) * (DS_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
t.y = (n(y) * (DS_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
}
}
} }
self.write(idx); self.write(idx);
} }
@@ -367,6 +367,9 @@ pub struct DualShock4Manager {
last_write: Vec<Instant>, last_write: Vec<Instant>,
/// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events. /// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events.
broken: bool, broken: bool,
/// Fallback policy for the Steam back grips a client may send (the DS4 has no back-button HID
/// slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
remap: crate::inject::steam_remap::RemapConfig,
} }
impl Default for DualShock4Manager { impl Default for DualShock4Manager {
@@ -384,6 +387,7 @@ impl DualShock4Manager {
last_led: vec![None; MAX_PADS], last_led: vec![None; MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS], last_write: vec![Instant::now(); MAX_PADS],
broken: false, broken: false,
remap: crate::inject::steam_remap::RemapConfig::from_env(),
} }
} }
@@ -416,8 +420,12 @@ impl DualShock4Manager {
// Merge buttons/sticks/triggers, preserving touch + motion (those arrive on the // Merge buttons/sticks/triggers, preserving touch + motion (those arrive on the
// rich-input plane and must survive a button-only frame). // rich-input plane and must survive a button-only frame).
let prev = self.state[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.
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(
f.buttons, buttons,
f.ls_x, f.ls_x,
f.ls_y, f.ls_y,
f.rs_x, f.rs_x,
@@ -439,7 +447,9 @@ impl DualShock4Manager {
/// pad isn't present. /// pad isn't present.
pub fn apply_rich(&mut self, rich: RichInput) { pub fn apply_rich(&mut self, rich: RichInput) {
let idx = match rich { let idx = match rich {
RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize, RichInput::Touchpad { pad, .. }
| RichInput::Motion { pad, .. }
| RichInput::TouchpadEx { pad, .. } => pad as usize,
}; };
if idx >= MAX_PADS || self.pads[idx].is_none() { if idx >= MAX_PADS || self.pads[idx].is_none() {
return; return;
@@ -466,6 +476,26 @@ impl DualShock4Manager {
self.state[idx].gyro = gyro; self.state[idx].gyro = gyro;
self.state[idx].accel = accel; 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); self.write(idx);
} }
@@ -69,9 +69,16 @@ const BTN_START: u16 = 0x13b;
const BTN_MODE: u16 = 0x13c; const BTN_MODE: u16 = 0x13c;
const BTN_THUMBL: u16 = 0x13d; const BTN_THUMBL: u16 = 0x13d;
const BTN_THUMBR: u16 = 0x13e; const BTN_THUMBR: u16 = 0x13e;
// Xbox-Elite paddle codes (the xpad convention SDL / Steam Input recognize). A client's back grips —
// and the GameStream `buttonFlags2` paddle bits, which were silently dropped before — land here, so
// the virtual X-Box pad exposes paddles like an Elite controller. PADDLE1/2/3/4 = R4/L4/R5/L5.
const BTN_TRIGGER_HAPPY5: u16 = 0x2c4;
const BTN_TRIGGER_HAPPY6: u16 = 0x2c5;
const BTN_TRIGGER_HAPPY7: u16 = 0x2c6;
const BTN_TRIGGER_HAPPY8: u16 = 0x2c7;
/// `(GameStream button bit, evdev key code)` — D-pad is emitted as HAT axes instead. /// `(GameStream button bit, evdev key code)` — D-pad is emitted as HAT axes instead.
const BUTTON_MAP: [(u32, u16); 11] = [ const BUTTON_MAP: [(u32, u16); 15] = [
(gamepad::BTN_A, BTN_SOUTH), (gamepad::BTN_A, BTN_SOUTH),
(gamepad::BTN_B, BTN_EAST), (gamepad::BTN_B, BTN_EAST),
(gamepad::BTN_X, BTN_NORTH), (gamepad::BTN_X, BTN_NORTH),
@@ -83,6 +90,10 @@ const BUTTON_MAP: [(u32, u16); 11] = [
(gamepad::BTN_GUIDE, BTN_MODE), (gamepad::BTN_GUIDE, BTN_MODE),
(gamepad::BTN_LS_CLK, BTN_THUMBL), (gamepad::BTN_LS_CLK, BTN_THUMBL),
(gamepad::BTN_RS_CLK, BTN_THUMBR), (gamepad::BTN_RS_CLK, BTN_THUMBR),
(gamepad::BTN_PADDLE1, BTN_TRIGGER_HAPPY5),
(gamepad::BTN_PADDLE2, BTN_TRIGGER_HAPPY6),
(gamepad::BTN_PADDLE3, BTN_TRIGGER_HAPPY7),
(gamepad::BTN_PADDLE4, BTN_TRIGGER_HAPPY8),
]; ];
/// The USB identity a virtual uinput pad presents. SDL/Steam/Proton key their built-in mapping off /// The USB identity a virtual uinput pad presents. SDL/Steam/Proton key their built-in mapping off
@@ -7,9 +7,14 @@
//! which the libei/portal path cannot. We connect as an ordinary Wayland client on the KWin session's //! which the libei/portal path cannot. We connect as an ordinary Wayland client on the KWin session's
//! `$WAYLAND_DISPLAY` and translate events into fake-input requests; keyboard keys are raw Linux //! `$WAYLAND_DISPLAY` and translate events into fake-input requests; keyboard keys are raw Linux
//! evdev codes that KWin resolves through the session's own keymap (no keymap upload, unlike the wlr //! evdev codes that KWin resolves through the session's own keymap (no keymap upload, unlike the wlr
//! virtual-keyboard path), and absolute pointer/touch coordinates are global compositor space — which //! virtual-keyboard path), and absolute pointer/touch coordinates are global compositor space.
//! on a headless box (single per-session virtual output at the origin, scale 1) equals the streamed //!
//! output's pixels. //! Global compositor space is *logical* pixels (post display-scaling), which only equals the streamed
//! output's physical pixels at scale 1. Under a fractional/integer scale the logical edge sits at
//! `physical / scale`, so feeding the raw streamed pixel coordinate lands the cursor `scale×` too far
//! toward the bottom-right (top-left stays put). We therefore track each output's logical geometry
//! (position + size) via `xdg-output` and map the normalized client position into the matching
//! output's logical rectangle — the same shape the libei backend uses with its EI region.
#![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)] #![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
@@ -18,8 +23,14 @@
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector}; use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use punktfunk_core::input::InputKind; use punktfunk_core::input::InputKind;
use std::time::{Duration, Instant};
use wayland_client::protocol::wl_output::{self, WlOutput};
use wayland_client::protocol::wl_registry::{self, WlRegistry}; use wayland_client::protocol::wl_registry::{self, WlRegistry};
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle}; use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle, WEnum};
use wayland_protocols::xdg::xdg_output::zv1::client::{
zxdg_output_manager_v1::ZxdgOutputManagerV1,
zxdg_output_v1::{self, ZxdgOutputV1},
};
// Generate the client bindings for the vendored protocol XML inline (no build.rs), exactly like the // Generate the client bindings for the vendored protocol XML inline (no build.rs), exactly like the
// KWin virtual-output backend. Path is relative to CARGO_MANIFEST_DIR. // KWin virtual-output backend. Path is relative to CARGO_MANIFEST_DIR.
@@ -48,10 +59,39 @@ const AXIS_HORIZONTAL: u32 = 1;
/// `code` value marking a horizontal scroll event (mirrors `gamestream::input` / the wlr backend). /// `code` value marking a horizontal scroll event (mirrors `gamestream::input` / the wlr backend).
const SCROLL_HORIZONTAL: u32 = 1; const SCROLL_HORIZONTAL: u32 = 1;
/// One tracked output: its physical mode (to match the streamed resolution) and its logical geometry
/// (the global-compositor-space rectangle absolute coordinates are addressed in). `logical_w == 0`
/// means xdg-output hasn't reported its size yet.
struct OutputTrack {
/// Registry global id — also the dispatch user-data, so events route back to this entry.
name: u32,
wl_output: WlOutput,
xdg_output: Option<ZxdgOutputV1>,
/// Physical pixel mode from `wl_output.mode` (the `current` mode); matched against the streamed WxH.
mode_w: i32,
mode_h: i32,
/// Logical (post-scale) geometry from `xdg-output`.
logical_x: i32,
logical_y: i32,
logical_w: i32,
logical_h: i32,
}
/// Registry-bound globals (the Wayland dispatch state). /// Registry-bound globals (the Wayland dispatch state).
#[derive(Default)] #[derive(Default)]
struct State { struct State {
fake: Option<FakeInput>, fake: Option<FakeInput>,
xdg_mgr: Option<ZxdgOutputManagerV1>,
outputs: Vec<OutputTrack>,
}
impl State {
/// Create the `xdg_output` for a tracked output once both it and the manager exist.
fn ensure_xdg_output(o: &mut OutputTrack, mgr: &ZxdgOutputManagerV1, qh: &QueueHandle<State>) {
if o.xdg_output.is_none() {
o.xdg_output = Some(mgr.get_xdg_output(&o.wl_output, qh, o.name));
}
}
} }
impl Dispatch<WlRegistry, ()> for State { impl Dispatch<WlRegistry, ()> for State {
@@ -63,15 +103,57 @@ impl Dispatch<WlRegistry, ()> for State {
_: &Connection, _: &Connection,
qh: &QueueHandle<Self>, qh: &QueueHandle<Self>,
) { ) {
if let wl_registry::Event::Global { match event {
wl_registry::Event::Global {
name, name,
interface, interface,
version, version,
} = event } => match interface.as_str() {
{ "org_kde_kwin_fake_input" => {
if interface == "org_kde_kwin_fake_input" {
state.fake = Some(registry.bind(name, version.min(MAX_VERSION), qh, ())); state.fake = Some(registry.bind(name, version.min(MAX_VERSION), qh, ()));
} }
"wl_output" => {
// v1 carries `mode` (all we need); bind no higher than the proxy's max (4).
let wl_output: WlOutput = registry.bind(name, version.min(4), qh, name);
let mut o = OutputTrack {
name,
wl_output,
xdg_output: None,
mode_w: 0,
mode_h: 0,
logical_x: 0,
logical_y: 0,
logical_w: 0,
logical_h: 0,
};
if let Some(mgr) = state.xdg_mgr.clone() {
State::ensure_xdg_output(&mut o, &mgr, qh);
}
state.outputs.push(o);
}
"zxdg_output_manager_v1" => {
let mgr: ZxdgOutputManagerV1 = registry.bind(name, version.min(3), qh, ());
// Outputs bound before the manager have no xdg_output yet — create them now.
for o in state.outputs.iter_mut() {
State::ensure_xdg_output(o, &mgr, qh);
}
state.xdg_mgr = Some(mgr);
}
_ => {}
},
wl_registry::Event::GlobalRemove { name } => {
state.outputs.retain(|o| {
if o.name == name {
if let Some(x) = &o.xdg_output {
x.destroy();
}
false
} else {
true
}
});
}
_ => {}
} }
} }
} }
@@ -89,13 +171,86 @@ impl Dispatch<FakeInput, ()> for State {
} }
} }
impl Dispatch<WlOutput, u32> for State {
fn event(
state: &mut Self,
_: &WlOutput,
event: wl_output::Event,
name: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
// Only the *current* mode matters — a real monitor also advertises its other supported modes.
if let wl_output::Event::Mode {
flags: WEnum::Value(flags),
width,
height,
..
} = event
{
if flags.contains(wl_output::Mode::Current) {
if let Some(o) = state.outputs.iter_mut().find(|o| o.name == *name) {
o.mode_w = width;
o.mode_h = height;
}
}
}
}
}
impl Dispatch<ZxdgOutputV1, u32> for State {
fn event(
state: &mut Self,
_: &ZxdgOutputV1,
event: zxdg_output_v1::Event,
name: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
if let Some(o) = state.outputs.iter_mut().find(|o| o.name == *name) {
match event {
zxdg_output_v1::Event::LogicalPosition { x, y } => {
o.logical_x = x;
o.logical_y = y;
}
zxdg_output_v1::Event::LogicalSize { width, height } => {
o.logical_w = width;
o.logical_h = height;
}
_ => {}
}
}
}
}
// The manager has no events.
impl Dispatch<ZxdgOutputManagerV1, ()> for State {
fn event(
_: &mut Self,
_: &ZxdgOutputManagerV1,
_: <ZxdgOutputManagerV1 as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
pub struct KwinFakeInjector { pub struct KwinFakeInjector {
conn: Connection, conn: Connection,
queue: EventQueue<State>, queue: EventQueue<State>,
state: State, state: State,
fake: FakeInput, fake: FakeInput,
/// When output geometry was last re-read; throttles the per-event roundtrip (see `refresh_geometry`).
last_refresh: Option<Instant>,
} }
/// How often the fake_input backend re-reads output geometry from the compositor. Output add/remove
/// (a new session's virtual output) and live scale/resolution changes are infrequent, so a lazy
/// poll on the injector's own thread is plenty and adds at most one local-socket roundtrip twice a
/// second — versus a blocking roundtrip on every single mouse-move event.
const GEO_REFRESH: Duration = Duration::from_millis(500);
impl KwinFakeInjector { impl KwinFakeInjector {
pub fn open() -> Result<Self> { pub fn open() -> Result<Self> {
let conn = Connection::connect_to_env() let conn = Connection::connect_to_env()
@@ -122,13 +277,77 @@ impl KwinFakeInjector {
.context("fake_input authenticate roundtrip")?; .context("fake_input authenticate roundtrip")?;
conn.flush().ok(); conn.flush().ok();
tracing::info!("KWin fake_input ready (headless keyboard/mouse/touch — no portal)"); // Settle output geometry (wl_output + xdg-output were bound during the registry roundtrip
Ok(Self { // above; their logical_size arrives on a follow-up roundtrip). Best-effort — falls back to
// scale-1 mapping if xdg-output is absent.
let mut injector = Self {
conn, conn,
queue, queue,
state, state,
fake, fake,
}) last_refresh: None,
};
injector.refresh_geometry();
tracing::info!(
outputs = injector.state.outputs.len(),
"KWin fake_input ready (headless keyboard/mouse/touch — no portal)"
);
Ok(injector)
}
/// Re-read output geometry, throttled to [`GEO_REFRESH`]. A `roundtrip` both flushes any pending
/// `get_xdg_output` requests and reads the geometry events back. A wl_output that *appeared* this
/// round only gets its xdg_output created mid-dispatch, so its `logical_size` lands on a later
/// roundtrip — keep going (bounded) until every output is settled.
fn refresh_geometry(&mut self) {
let now = Instant::now();
if let Some(t) = self.last_refresh {
if now.duration_since(t) < GEO_REFRESH {
return;
}
}
self.last_refresh = Some(now);
for _ in 0..3 {
if self.queue.roundtrip(&mut self.state).is_err() {
return;
}
let pending =
self.state.xdg_mgr.is_some() && self.state.outputs.iter().any(|o| o.logical_w == 0);
if !pending {
break;
}
}
}
/// Resolve the logical (global-compositor-space) rectangle to map a normalized client position
/// into. Prefer the output whose physical mode matches the streamed `phys_w`×`phys_h` (the
/// per-session virtual output); fall back to the sole output, then — if xdg-output is unavailable
/// — to the streamed pixels at the origin (the pre-scaling behavior, correct at scale 1).
fn logical_target(&self, phys_w: i32, phys_h: i32) -> (f64, f64, f64, f64) {
let usable = || {
self.state
.outputs
.iter()
.filter(|o| o.logical_w > 0 && o.logical_h > 0)
};
let chosen = usable()
.find(|o| o.mode_w == phys_w && o.mode_h == phys_h)
.or_else(|| {
let mut it = usable();
match (it.next(), it.next()) {
(Some(only), None) => Some(only),
_ => None,
}
});
match chosen {
Some(o) => (
o.logical_x as f64,
o.logical_y as f64,
o.logical_w as f64,
o.logical_h as f64,
),
None => (0.0, 0.0, phys_w as f64, phys_h as f64),
}
} }
} }
@@ -139,12 +358,17 @@ impl InputInjector for KwinFakeInjector {
self.fake.pointer_motion(event.x as f64, event.y as f64); self.fake.pointer_motion(event.x as f64, event.y as f64);
} }
InputKind::MouseMoveAbs => { InputKind::MouseMoveAbs => {
let w = (event.flags >> 16) & 0xffff; let w = ((event.flags >> 16) & 0xffff) as i32;
let h = event.flags & 0xffff; let h = (event.flags & 0xffff) as i32;
if w > 0 && h > 0 { if w > 0 && h > 0 {
let x = event.x.clamp(0, w as i32) as f64; self.refresh_geometry();
let y = event.y.clamp(0, h as i32) as f64; let (lx, ly, lw, lh) = self.logical_target(w, h);
self.fake.pointer_motion_absolute(x, y); // Normalize in the streamed (physical) pixel space, then place inside the output's
// logical rectangle — so display scaling no longer offsets the cursor.
let nx = (event.x as f64 / w as f64).clamp(0.0, 1.0);
let ny = (event.y as f64 / h as f64).clamp(0.0, 1.0);
self.fake
.pointer_motion_absolute(lx + nx * lw, ly + ny * lh);
} }
} }
InputKind::MouseButtonDown | InputKind::MouseButtonUp => { InputKind::MouseButtonDown | InputKind::MouseButtonUp => {
@@ -179,11 +403,15 @@ impl InputInjector for KwinFakeInjector {
// Touch: id = event.code, coords in the client surface w×h packed into flags (same // Touch: id = event.code, coords in the client surface w×h packed into flags (same
// absolute mapping as MouseMoveAbs). Each event is its own frame. // absolute mapping as MouseMoveAbs). Each event is its own frame.
InputKind::TouchDown | InputKind::TouchMove => { InputKind::TouchDown | InputKind::TouchMove => {
let w = (event.flags >> 16) & 0xffff; let w = ((event.flags >> 16) & 0xffff) as i32;
let h = event.flags & 0xffff; let h = (event.flags & 0xffff) as i32;
if w > 0 && h > 0 { if w > 0 && h > 0 {
let x = event.x.clamp(0, w as i32) as f64; self.refresh_geometry();
let y = event.y.clamp(0, h as i32) as f64; let (lx, ly, lw, lh) = self.logical_target(w, h);
let nx = (event.x as f64 / w as f64).clamp(0.0, 1.0);
let ny = (event.y as f64 / h as f64).clamp(0.0, 1.0);
let x = lx + nx * lw;
let y = ly + ny * lh;
if event.kind == InputKind::TouchDown { if event.kind == InputKind::TouchDown {
self.fake.touch_down(event.code, x, y); self.fake.touch_down(event.code, x, y);
} else { } else {
@@ -0,0 +1,575 @@
//! Virtual Steam Deck controller via UHID — the Steam analogue of the virtual DualSense
//! ([`super::dualsense`]). A UHID device with Valve VID `28DE` / Deck PID `1205` is bound by the
//! kernel `hid-steam` driver, which exposes a full Steam Deck gamepad evdev (incl. the four back
//! grips) **plus** a separate IMU evdev, and — when Steam runs on the host — is re-grabbed by Steam
//! Input with native glyphs + trackpad/gyro/back-button bindings.
//!
//! The transport-independent contract (descriptor, byte-exact serializer, the `XInput`/rich
//! mappers, the rumble parser) lives in [`super::steam_proto`]; this module is the `/dev/uhid`
//! plumbing + the two Steam-specific lifecycle quirks the DualSense path lacks:
//!
//! 1. **`gamepad_mode` entry.** `steam_do_deck_input_event` early-returns under the default
//! `lizard_mode` until `gamepad_mode` is toggled on — which the kernel only does when the `b9.6`
//! Steam/menu-right button is held ~450 ms with no hidraw client open. So on the first pad we
//! best-effort clear `lizard_mode` via sysfs (needs root; bypasses the gate entirely) AND every
//! pad pulses `b9.6` for [`MODE_ENTER`] at creation. After that an **anti-toggle guard** caps any
//! continuous `b9.6` (a long in-game Start-hold) below the kernel's 450 ms threshold so play can
//! never accidentally flip `gamepad_mode` back off.
//! 2. **`UHID_SET_REPORT`.** Steam feedback (`0xEB` rumble) + the kernel's settings/serial writes
//! arrive as FEATURE set-reports that MUST be answered `err = 0`, or the kernel stalls ~5 s per
//! command (the DualSense backend only services GET_REPORT + OUTPUT).
use super::steam_proto::{
btn, parse_steam_output, serial_reply, serialize_deck_state, SteamState, STEAMDECK_PRODUCT,
STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR,
};
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
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;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
// /dev/uhid event ABI — same layout as the DualSense backend.
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 UHID_SET_REPORT: u32 = 13;
const UHID_SET_REPORT_REPLY: u32 = 14;
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
const UHID_EVENT_SIZE: usize = 4 + 4372;
const BUS_USB: u16 = 0x03;
/// Hold the `b9.6` mode-switch this long at creation to toggle `gamepad_mode` on (the kernel needs
/// ~450 ms continuous; give margin).
const MODE_ENTER: Duration = Duration::from_millis(650);
/// Cap continuous `b9.6` (Start) below the kernel's 450 ms mode-switch threshold: after this long
/// we insert a one-frame release so an in-game long-Start-hold can't toggle `gamepad_mode` off.
const MENU_HOLD_CAP: Duration = Duration::from_millis(350);
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]);
}
/// Best-effort, once per process: clear `hid_steam`'s `lizard_mode` so `steam_do_deck_input_event`
/// stops gating on `gamepad_mode` (gamepad events then always flow). Needs root; on failure the
/// per-pad `b9.6` pulse + guard handle it instead.
fn try_clear_lizard_mode() {
static TRIED: AtomicBool = AtomicBool::new(false);
if TRIED.swap(true, Ordering::Relaxed) {
return;
}
match std::fs::write("/sys/module/hid_steam/parameters/lizard_mode", "N") {
Ok(()) => {
tracing::info!("cleared hid_steam lizard_mode (Steam Deck gamepad events always flow)")
}
Err(e) => tracing::debug!(
error = %e,
"could not clear hid_steam lizard_mode (no root?) — using the gamepad_mode pulse + guard"
),
}
}
/// A virtual Steam Deck backed by `/dev/uhid`. Dropping it destroys the device (the kernel tears
/// down the bound `hid-steam` interface + both evdevs).
pub struct SteamDeckPad {
fd: File,
seq: u32,
created: Instant,
/// When `b9.6` started being continuously held in our OUTPUT (anti-toggle guard); `None` = not.
menu_hold_since: Option<Instant>,
}
impl SteamDeckPad {
pub fn open(index: u8) -> Result<SteamDeckPad> {
try_clear_lizard_mode();
let fd = OpenOptions::new()
.read(true)
.write(true)
.custom_flags(libc::O_NONBLOCK)
.open(UHID_PATH)
.with_context(|| {
format!("open {UHID_PATH} (is the uhid udev rule installed + are you in 'input'?)")
})?;
let mut pad = SteamDeckPad {
fd,
seq: 0,
created: Instant::now(),
menu_hold_since: None,
};
pad.send_create2(index).context("UHID_CREATE2 Steam Deck")?;
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());
put_cstr(&mut ev, 4, 128, &format!("Punktfunk Steam Deck {index}")); // name[128]
put_cstr(&mut ev, 132, 64, &format!("punktfunk/steam/{index}")); // phys[64]
put_cstr(&mut ev, 196, 64, &format!("punktfunk-steam-{index}")); // uniq[64]
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[264..268].copy_from_slice(&STEAM_VENDOR.to_ne_bytes());
ev[268..272].copy_from_slice(&STEAMDECK_PRODUCT.to_ne_bytes());
ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
ev[280..280 + STEAMDECK_RDESC.len()].copy_from_slice(STEAMDECK_RDESC);
self.fd.write_all(&ev).context("write UHID_CREATE2")?;
Ok(())
}
/// Serialize `st` (with the gamepad-mode entry overlay + anti-toggle guard applied) and write it.
pub fn write_state(&mut self, st: &SteamState) -> Result<()> {
self.seq = self.seq.wrapping_add(1);
let mut s = *st;
s.buttons = self.effective_buttons(st.buttons);
let mut r = [0u8; STEAM_REPORT_LEN];
serialize_deck_state(&mut r, &s, self.seq);
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(())
}
/// True while still pulsing the mode-switch at creation (the caller force-writes during this).
fn in_mode_entry(&self) -> bool {
self.created.elapsed() < MODE_ENTER
}
/// During mode entry, force `b9.6` held (override). Afterwards, pass the real buttons through but
/// drop `b9.6` for one frame whenever it's been continuously held past [`MENU_HOLD_CAP`].
fn effective_buttons(&mut self, mut buttons: u64) -> u64 {
if self.in_mode_entry() {
return btn::STEAM_MENU_RIGHT;
}
if buttons & btn::MENU != 0 {
let now = Instant::now();
match self.menu_hold_since {
None => self.menu_hold_since = Some(now),
Some(since) if now.duration_since(since) >= MENU_HOLD_CAP => {
buttons &= !btn::MENU; // one-frame release resets the kernel's mode-switch timer
self.menu_hold_since = None;
}
Some(_) => {}
}
} else {
self.menu_hold_since = None;
}
buttons
}
/// Service the device, non-blocking: answer the kernel's GET_REPORT (serial) + SET_REPORT
/// (settings / rumble — ack `err=0`) and parse any rumble feedback (`0xEB`, on either the
/// SET_REPORT or OUTPUT path) into `(low, high)` for the universal rumble plane.
pub fn service(&mut self) -> Option<(u16, u16)> {
let mut rumble = None;
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 => {
let size = u16::from_ne_bytes([ev[4100], ev[4101]]) as usize;
let end = 4 + size.min(HID_MAX_DESCRIPTOR_SIZE);
if let Some(r) = parse_steam_output(&ev[4..end]).rumble {
rumble = Some(r);
}
}
UHID_GET_REPORT => {
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
let _ = self.reply_get_report(id, &serial_reply("PUNKTFUNK01"));
}
UHID_SET_REPORT => {
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
// SET_REPORT data: [report-id 0, cmd, …] at ev[12..]. Surface rumble, then ack.
let end = (12 + 16).min(UHID_EVENT_SIZE);
if let Some(r) = parse_steam_output(&ev[12..end]).rumble {
rumble = Some(r);
}
let _ = self.reply_set_report(id);
}
_ => {} // Start/Stop/Open/Close — ignore
}
}
rumble
}
fn reply_get_report(&mut self, id: u32, data: &[u8]) -> Result<()> {
let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_GET_REPORT_REPLY.to_ne_bytes());
ev[4..8].copy_from_slice(&id.to_ne_bytes());
ev[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0
ev[10..12].copy_from_slice(&(data.len() as u16).to_ne_bytes());
ev[12..12 + data.len()].copy_from_slice(data);
self.fd.write_all(&ev).context("UHID_GET_REPORT_REPLY")?;
Ok(())
}
fn reply_set_report(&mut self, id: u32) -> Result<()> {
let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_SET_REPORT_REPLY.to_ne_bytes());
ev[4..8].copy_from_slice(&id.to_ne_bytes());
ev[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0 (ack)
self.fd.write_all(&ev).context("UHID_SET_REPORT_REPLY")?;
Ok(())
}
}
impl Drop for SteamDeckPad {
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);
}
}
/// 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
/// 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
/// Input *does* promote. Selected per-pad by [`open_transport`].
enum DeckTransport {
Uhid(SteamDeckPad),
Gadget(crate::inject::steam_gadget::SteamDeckGadget),
Usbip(crate::inject::steam_usbip::SteamDeckUsbip),
}
impl DeckTransport {
fn write_state(&mut self, st: &SteamState) {
match self {
DeckTransport::Uhid(p) => {
let _ = p.write_state(st);
}
DeckTransport::Gadget(g) => g.write_state(st),
DeckTransport::Usbip(u) => u.write_state(st),
}
}
fn service(&mut self) -> Option<(u16, u16)> {
match self {
DeckTransport::Uhid(p) => p.service(),
DeckTransport::Gadget(g) => g.service().rumble,
DeckTransport::Usbip(u) => u.service().rumble,
}
}
fn in_mode_entry(&self) -> bool {
match self {
// Only the UHID pad needs the gamepad-mode entry pulse: the promoted transports are
// read raw via hidraw by Steam Input, which bypasses the kernel's evdev mode gate.
DeckTransport::Uhid(p) => p.in_mode_entry(),
DeckTransport::Gadget(_) | DeckTransport::Usbip(_) => false,
}
}
}
/// Open the best Steam-Input-promotable Deck transport available, in preference order:
/// **`raw_gadget` (SteamOS validated fast-path) → `usbip`/`vhci_hcd` (universal, Secure-Boot-clean)
/// → UHID (universal, but `Interface: -1` so Steam Input won't promote it).** Each rung degrades to
/// the next on failure, so a host lacking the gadget modules still gets a *promotable* Deck via
/// usbip, and one lacking both still gets a working (if non-promoted) UHID pad.
fn open_transport(idx: u8) -> Result<DeckTransport> {
use crate::inject::{steam_gadget, steam_usbip};
// 1. raw_gadget — the validated SteamOS fast-path (default on there).
if steam_gadget::gadget_preferred() {
steam_gadget::ensure_modules();
match steam_gadget::SteamDeckGadget::open(idx) {
Ok(g) => {
tracing::info!(
index = idx,
"virtual Steam Deck created (USB gadget — Steam Input recognizes it)"
);
return Ok(DeckTransport::Gadget(g));
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "USB-gadget Deck unavailable — trying usbip")
}
}
}
// 2. usbip/vhci_hcd — the universal, in-tree, Secure-Boot-clean transport (default on elsewhere).
if steam_usbip::usbip_preferred() {
match steam_usbip::SteamDeckUsbip::open(idx) {
Ok(u) => return Ok(DeckTransport::Usbip(u)),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "usbip Deck unavailable — falling back to UHID")
}
}
}
// 3. UHID — universal fallback (works everywhere; Steam Input won't promote it).
let p = SteamDeckPad::open(idx)?;
tracing::info!(
index = idx,
"virtual Steam Deck created (UHID hid-steam — not Steam-Input-promoted)"
);
Ok(DeckTransport::Uhid(p))
}
pub struct SteamControllerManager {
pads: Vec<Option<DeckTransport>>,
state: Vec<SteamState>,
last_rumble: Vec<(u16, u16)>,
last_write: Vec<Instant>,
broken: bool,
}
impl Default for SteamControllerManager {
fn default() -> SteamControllerManager {
SteamControllerManager::new()
}
}
impl SteamControllerManager {
pub fn new() -> SteamControllerManager {
SteamControllerManager {
pads: (0..MAX_PADS).map(|_| None).collect(),
state: vec![SteamState::neutral(); MAX_PADS],
last_rumble: vec![(0, 0); MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
broken: false,
}
}
pub fn handle(&mut self, ev: &GamepadEvent) {
match ev {
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;
if idx >= MAX_PADS {
return;
}
for (i, slot) in self.pads.iter_mut().enumerate() {
if slot.is_some() && f.active_mask & (1 << i) == 0 {
tracing::info!(index = i, "controller unplugged (Steam Deck)");
*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(
f.buttons,
f.ls_x,
f.ls_y,
f.rs_x,
f.rs_y,
f.left_trigger,
f.right_trigger,
);
s.rpad_x = prev.rpad_x;
s.rpad_y = prev.rpad_y;
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::RPAD_TOUCH | btn::LPAD_TOUCH);
self.state[idx] = s;
self.write(idx);
}
}
}
/// Apply a rich client→host event (right trackpad / motion) to an existing pad.
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.pads[idx].is_none() {
return;
}
self.state[idx].apply_rich(rich);
self.write(idx);
}
fn write(&mut self, idx: usize) {
let st = self.state[idx];
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 when silent past `max_gap`, and force a steady stream
/// while a pad is still pulsing its gamepad-mode entry (so the `b9.6` toggle completes even with
/// no game input).
pub fn heartbeat(&mut self, max_gap: Duration) {
let now = Instant::now();
for i in 0..self.pads.len() {
let Some(pad) = self.pads[i].as_ref() else {
continue;
};
if pad.in_mode_entry() || now.duration_since(self.last_write[i]) >= max_gap {
self.write(i);
}
}
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
return;
}
match open_transport(idx as u8) {
Ok(t) => {
self.pads[idx] = Some(t);
self.state[idx] = SteamState::neutral();
self.last_rumble[idx] = (0, 0);
self.last_write[idx] = Instant::now();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual Steam Deck creation failed — controller input disabled");
self.broken = true;
}
}
}
/// Service every pad: answer the kernel handshake and forward rumble on the universal plane.
/// `rumble` fires `(index, low, high)` only on a level change. The Steam Deck has no rich
/// host→client feedback plane (no lightbar / adaptive triggers), so `hidout` goes unused.
pub fn pump(&mut self, mut rumble: impl FnMut(u16, u16, u16), _hidout: impl FnMut(HidOutput)) {
for i in 0..self.pads.len() {
let Some(pad) = self.pads[i].as_mut() else {
continue;
};
if let Some(r) = pad.service() {
if self.last_rumble[i] != r {
self.last_rumble[i] = r;
rumble(i as u16, r.0, r.1);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Find the evdev node for a kernel input device by exact name (e.g. `"Steam Deck"`).
fn find_node(name: &str) -> Option<String> {
let devs = std::fs::read_to_string("/proc/bus/input/devices").ok()?;
for block in devs.split("\n\n") {
if !block
.lines()
.any(|l| l.trim() == format!("N: Name=\"{name}\""))
{
continue;
}
for l in block.lines() {
if let Some(h) = l.strip_prefix("H: Handlers=") {
if let Some(ev) = h.split_whitespace().find(|t| t.starts_with("event")) {
return Some(format!("/dev/input/{ev}"));
}
}
}
}
None
}
/// Read the evdev's current key bitmap (`EVIOCGKEY`) and test whether `code` is down.
fn key_is_down(node: &str, code: u16) -> bool {
use std::os::unix::io::AsRawFd;
let Ok(f) = std::fs::File::open(node) else {
return false;
};
let mut bits = [0u8; 96];
const EVIOCGKEY: libc::c_ulong = (2 << 30) | (96 << 16) | (0x45 << 8) | 0x18;
// SAFETY: EVIOCGKEY copies the current key-state bitmap of the evdev behind the valid fd
// `f` into `bits`; 96 bytes covers KEY_MAX/8, so the kernel never writes past the buffer.
let rc = unsafe { libc::ioctl(f.as_raw_fd(), EVIOCGKEY, bits.as_mut_ptr()) };
rc >= 0 && (bits[(code / 8) as usize] >> (code % 8)) & 1 == 1
}
/// Read the current value of an absolute axis (`EVIOCGABS`) — the first `i32` of `input_absinfo`.
fn abs_value(node: &str, abs: u16) -> Option<i32> {
use std::os::unix::io::AsRawFd;
let f = std::fs::File::open(node).ok()?;
let mut info = [0u8; 24]; // struct input_absinfo { value, min, max, fuzz, flat, resolution }
let req: libc::c_ulong =
(2 << 30) | (24 << 16) | (0x45 << 8) | (0x40 + abs as libc::c_ulong);
// SAFETY: EVIOCGABS fills the 24-byte input_absinfo for the valid evdev fd `f`; we read only
// the leading i32 `value`. The buffer is exactly sizeof(input_absinfo), so no overflow.
let rc = unsafe { libc::ioctl(f.as_raw_fd(), req, info.as_mut_ptr()) };
(rc >= 0).then(|| i32::from_ne_bytes([info[0], info[1], info[2], info[3]]))
}
/// On-box smoke test for the real backend: a `SteamDeckPad` must bind `hid-steam` (creating both
/// the gamepad + IMU evdevs), enter `gamepad_mode` via the creation pulse, and land a held button
/// on the evdev (`BTN_A`, code 0x130) — proving the entry overlay + byte-exact serialize path —
/// then tear the device down on drop. Touches `/dev/uhid`, so it is `#[ignore]`d in CI; run on a
/// box with `hid-steam` + `input`-group access: `cargo test -p punktfunk-host -- --ignored`.
#[test]
#[ignore = "creates a real /dev/uhid device; needs hid-steam + the input group"]
fn backend_binds_and_input_flows() {
use punktfunk_core::input::gamepad as gs;
const BTN_A: u16 = 0x130;
const ABS_HAT0X: u16 = 0x10; // left trackpad X
let mut pad = SteamDeckPad::open(0).expect("open SteamDeckPad (/dev/uhid + input group?)");
// Drive the full M3 wire path: build state through `from_gamepad` (BTN_A + the L4 back grip)
// and `apply_rich` (a left-pad TouchpadEx contact), then hold it past MODE_ENTER (the b9.6
// pulse), servicing the handshake.
let mut st = SteamState::from_gamepad(gs::BTN_A | gs::BTN_PADDLE2, 0, 0, 0, 0, 0, 0);
st.apply_rich(RichInput::TouchpadEx {
pad: 0,
surface: 1,
finger: 0,
touch: true,
click: false,
x: -8000,
y: 9000,
pressure: 0,
});
let start = Instant::now();
while start.elapsed() < Duration::from_millis(1200) {
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 Deck"), "gamepad evdev not created");
assert!(
devs.contains("Steam Deck Motion Sensors"),
"IMU evdev not created"
);
let node = find_node("Steam Deck").expect("gamepad evdev node");
assert!(
key_is_down(&node, BTN_A),
"BTN_A not down — gamepad_mode entry or serialize failed"
);
// The left trackpad contact (TouchpadEx surface 1, gated on LPAD_TOUCH) reaches ABS_HAT0X.
assert_eq!(
abs_value(&node, ABS_HAT0X),
Some(-8000),
"left trackpad (TouchpadEx surface 1) did not reach ABS_HAT0X"
);
drop(pad);
std::thread::sleep(Duration::from_millis(200));
let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
assert!(
!devs.contains("Steam Deck Motion Sensors"),
"device not torn down on drop"
);
}
}
@@ -0,0 +1,558 @@
//! Virtual Steam Deck via the USB **gadget** subsystem (`raw_gadget` + `dummy_hcd`) — the only
//! virtual-Deck transport Steam Input recognizes.
//!
//! The UHID [`super::steam_controller::SteamDeckPad`] binds the kernel `hid-steam` driver, but Steam's
//! own controller driver filters the Deck's controller to USB **interface 2**, and a UHID device has no
//! USB interface number (`Interface: -1`), so Steam enumerates it but never promotes it. This backend
//! instead presents a *real* 3-interface USB Deck (mouse = interface 0, keyboard = 1, **controller =
//! 2**) on a `dummy_hcd` loopback UDC, driven from userspace via `/dev/raw-gadget` so we can answer
//! every control transfer (including the HID feature reports `f_hid` can't). Proven on a real Deck:
//! hid-steam binds it, Steam reserves an XInput slot and emits an X-Box pad. Descriptors are captured
//! verbatim from a physical Deck; see `packaging/linux/steam-deck-gadget/` for the original PoC + the
//! USB-stack gotchas. **SteamOS-host only** (needs `dummy_hcd` + `raw_gadget`, which SteamOS ships).
//!
//! The transport here is self-contained (libc + std); the report bytes it streams are produced by
//! [`super::steam_proto`] in the wrapping backend.
use anyhow::{bail, Context, Result};
use std::mem::size_of;
use std::os::fd::RawFd;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
// ---- raw_gadget UAPI (mirrors linux/usb/raw_gadget.h; inlined like the C PoC) ----
const UDC_NAME_MAX: usize = 128;
#[repr(C)]
struct UsbRawInit {
driver_name: [u8; UDC_NAME_MAX],
device_name: [u8; UDC_NAME_MAX],
speed: u8,
}
// usb_raw_event { u32 type; u32 length; u8 data[]; } — we read it into a fixed buffer.
const EVENT_HDR: usize = 8; // type + length
const EVENT_BUF: usize = EVENT_HDR + 64; // setup packet (8) fits easily
// usb_raw_ep_io { u16 ep; u16 flags; u32 length; u8 data[]; }
const EPIO_HDR: usize = 8;
// usb_endpoint_descriptor is 9 bytes in the kernel (audio bRefresh/bSynchAddress); EP_ENABLE wants it.
#[repr(C, packed)]
#[derive(Clone, Copy, Default)]
struct UsbEndpointDescriptor {
b_length: u8,
b_descriptor_type: u8,
b_endpoint_address: u8,
bm_attributes: u8,
w_max_packet_size: u16,
b_interval: u8,
b_refresh: u8,
b_synch_address: u8,
}
const fn ioc(dir: u64, nr: u64, size: usize) -> libc::c_ulong {
((dir << 30) | ((size as u64) << 16) | ((b'U' as u64) << 8) | nr) as libc::c_ulong
}
const IOCTL_INIT: libc::c_ulong = ioc(1, 0, size_of::<UsbRawInit>());
const IOCTL_RUN: libc::c_ulong = ioc(0, 1, 0);
const IOCTL_EVENT_FETCH: libc::c_ulong = ioc(2, 2, EVENT_HDR); // size is the header; kernel copies more
const IOCTL_EP0_WRITE: libc::c_ulong = ioc(1, 3, EPIO_HDR);
const IOCTL_EP0_READ: libc::c_ulong = ioc(2 | 1, 4, EPIO_HDR); // _IOWR
const IOCTL_EP_ENABLE: libc::c_ulong = ioc(1, 5, size_of::<UsbEndpointDescriptor>());
const IOCTL_EP_WRITE: libc::c_ulong = ioc(1, 7, EPIO_HDR);
const IOCTL_CONFIGURE: libc::c_ulong = ioc(0, 9, 0);
const IOCTL_VBUS_DRAW: libc::c_ulong = ioc(1, 10, 4);
const IOCTL_EP0_STALL: libc::c_ulong = ioc(0, 12, 0);
const USB_RAW_EVENT_CONNECT: u32 = 1;
const USB_RAW_EVENT_CONTROL: u32 = 2;
const USB_SPEED_HIGH: u8 = 3;
// Captured-from-hardware Deck descriptors + the `0x83`/`0xAE` feature contract live in the shared
// [`super::steam_proto`] module (single source of truth, also used by the usbip transport).
use super::steam_proto::{
deck_serial, deck_unit_id, feature_reply, neutral_deck_report, RDESC_DECK_CTRL as RDESC_CTRL,
RDESC_DECK_KBD as RDESC_KBD, RDESC_DECK_MOUSE as RDESC_MOUSE,
};
const DEV_DESC: [u8; 18] = [
18, 1, 0x00, 0x02, // bLength, DEVICE, bcdUSB 2.00
0, 0, 0, 64, // class/sub/proto, bMaxPacketSize0
0xDE, 0x28, 0x05, 0x12, // idVendor 28DE, idProduct 1205
0x00, 0x03, // bcdDevice 3.00
1, 2, 3, 1, // iManufacturer, iProduct, iSerial, bNumConfigurations
];
const HID_DT: u8 = 0x21;
const HID_RPT_DT: u8 = 0x22;
/// Assemble the 84-byte config descriptor: config + 3×(interface + HID + 7-byte endpoint).
fn build_config() -> Vec<u8> {
let mut c = Vec::with_capacity(84);
// config descriptor (wTotalLength patched after)
c.extend_from_slice(&[9, 2, 84, 0, 3, 1, 0, 0x80, 250]);
// helper closures
let iface = |n: u8, sub: u8, proto: u8| [9u8, 4, n, 0, 1, 3, sub, proto, 0];
let hid = |rlen: u16, country: u8| {
[
9u8,
HID_DT,
0x10,
0x01,
country,
1,
HID_RPT_DT,
(rlen & 0xff) as u8,
(rlen >> 8) as u8,
]
};
let ep = |addr: u8, mps: u16| [7u8, 5, addr, 0x03, (mps & 0xff) as u8, (mps >> 8) as u8, 4];
// interface 0: mouse, EP 0x81
c.extend_from_slice(&iface(0, 0, 2));
c.extend_from_slice(&hid(RDESC_MOUSE.len() as u16, 0));
c.extend_from_slice(&ep(0x81, 8));
// interface 1: keyboard (boot), EP 0x82
c.extend_from_slice(&iface(1, 1, 1));
c.extend_from_slice(&hid(RDESC_KBD.len() as u16, 0));
c.extend_from_slice(&ep(0x82, 8));
// interface 2: controller, EP 0x83, bCountryCode 33
c.extend_from_slice(&iface(2, 0, 0));
c.extend_from_slice(&hid(RDESC_CTRL.len() as u16, 33));
c.extend_from_slice(&ep(0x83, 64));
debug_assert_eq!(c.len(), 84);
c
}
fn string_desc(idx: u8, serial: &str) -> Vec<u8> {
if idx == 0 {
return vec![4, 3, 0x09, 0x04]; // LANGID en-US
}
let s: &str = match idx {
1 => "Valve Software",
2 => "Steam Deck Controller",
3 => serial,
_ => "",
};
let mut v = vec![(2 + s.len() * 2) as u8, 3];
for ch in s.encode_utf16() {
v.push((ch & 0xff) as u8);
v.push((ch >> 8) as u8);
}
v
}
// ---- ioctl wrappers (the only unsafe surface for the raw_gadget UAPI; documented once) ----
fn ioctl_ptr<T>(fd: RawFd, req: libc::c_ulong, arg: *const T) -> i32 {
// SAFETY: `fd` is our open /dev/raw-gadget descriptor; `arg` points to a correctly-sized,
// initialized argument for `req` (a raw_gadget UAPI struct or an owned usb_raw_ep_io buffer)
// that lives for the duration of the call. `ioctl` is variadic, so passing a thin pointer is ABI-correct.
unsafe { libc::ioctl(fd, req as _, arg) as i32 }
}
fn ioctl_mut<T>(fd: RawFd, req: libc::c_ulong, arg: *mut T) -> i32 {
// SAFETY: as `ioctl_ptr`, but `arg` is a writable buffer the kernel fills for `req` (EVENT_FETCH / EP0_READ).
unsafe { libc::ioctl(fd, req as _, arg) as i32 }
}
fn ioctl_val(fd: RawFd, req: libc::c_ulong, val: libc::c_ulong) -> i32 {
// SAFETY: `req` (VBUS_DRAW) takes an integer argument by value; `fd` is our descriptor.
unsafe { libc::ioctl(fd, req as _, val) as i32 }
}
fn ioctl_none(fd: RawFd, req: libc::c_ulong) -> i32 {
// SAFETY: `req` (RUN / CONFIGURE / EP0_STALL) takes no argument, but raw_gadget rejects a non-zero
// `value` with EINVAL — pass an explicit 0 (an omitted vararg would be an indeterminate register).
unsafe { libc::ioctl(fd, req as _, 0) as i32 }
}
// ---- low-level ep0 helpers (operate on the shared fd) ----
fn ep0_write(fd: RawFd, data: &[u8]) -> i32 {
let mut buf = vec![0u8; EPIO_HDR + data.len()];
buf[0..2].copy_from_slice(&0u16.to_ne_bytes()); // ep 0
buf[4..8].copy_from_slice(&(data.len() as u32).to_ne_bytes());
buf[EPIO_HDR..].copy_from_slice(data);
ioctl_ptr(fd, IOCTL_EP0_WRITE, buf.as_ptr())
}
fn ep0_read(fd: RawFd, len: usize) -> (i32, Vec<u8>) {
let mut buf = vec![0u8; EPIO_HDR + len.max(1)];
buf[4..8].copy_from_slice(&(len as u32).to_ne_bytes());
let r = ioctl_mut(fd, IOCTL_EP0_READ, buf.as_mut_ptr());
let n = if r > 0 { r as usize } else { 0 };
(r, buf[EPIO_HDR..EPIO_HDR + n.min(len.max(1))].to_vec())
}
/// Complete a no-data OUT control (status stage is an IN, handled by a zero-length read).
fn ep0_ack(fd: RawFd) {
ep0_read(fd, 0);
}
fn ep0_stall(fd: RawFd) {
ioctl_none(fd, IOCTL_EP0_STALL);
}
/// Owns the `/dev/raw-gadget` fd; closing it tears the device down.
struct GadgetFd(RawFd);
impl Drop for GadgetFd {
fn drop(&mut self) {
// SAFETY: `self.0` is the fd we opened in `SteamDeckGadget::open` and own uniquely here.
unsafe { libc::close(self.0) };
}
}
/// A virtual Steam Deck presented over the USB gadget subsystem. Dropping it stops the threads and
/// closes the gadget (the kernel tears down the device).
pub struct SteamDeckGadget {
report: Arc<Mutex<[u8; 64]>>,
feedback: Arc<Mutex<super::steam_proto::SteamFeedback>>,
running: Arc<AtomicBool>,
threads: Vec<JoinHandle<()>>,
_fd: Arc<GadgetFd>,
seq: u32,
}
impl SteamDeckGadget {
/// Bind a virtual Deck on a fresh `dummy_hcd` UDC. `index` only varies the serial. Requires
/// `dummy_hcd` + `raw_gadget` loaded and write access to `/dev/raw-gadget` (root on SteamOS).
pub fn open(index: u8) -> Result<SteamDeckGadget> {
// SAFETY: opening a constant NUL-terminated device path with O_RDWR; returns a fd or -1.
let fd = unsafe { libc::open(c"/dev/raw-gadget".as_ptr(), libc::O_RDWR) };
if fd < 0 {
bail!(
"open /dev/raw-gadget ({}) — is raw_gadget+dummy_hcd loaded and are we root?",
std::io::Error::last_os_error()
);
}
let fd = Arc::new(GadgetFd(fd));
let raw = fd.0;
// INIT against the dummy UDC, then RUN.
// SAFETY: `UsbRawInit` is a plain-old-data struct (byte arrays + u8); all-zero is a valid value.
let mut init: UsbRawInit = unsafe { std::mem::zeroed() };
copy_cstr(&mut init.driver_name, "dummy_udc");
copy_cstr(&mut init.device_name, "dummy_udc.0");
init.speed = USB_SPEED_HIGH;
if ioctl_ptr(raw, IOCTL_INIT, &init as *const _) < 0 {
bail!("raw_gadget INIT: {}", std::io::Error::last_os_error());
}
if ioctl_none(raw, IOCTL_RUN) < 0 {
bail!("raw_gadget RUN: {}", std::io::Error::last_os_error());
}
let serial = deck_serial(index);
let unit_id = deck_unit_id(index); // "PF" + index — a synthetic per-instance device id
let report = Arc::new(Mutex::new(neutral_deck_report()));
let feedback = Arc::new(Mutex::new(Default::default()));
let running = Arc::new(AtomicBool::new(true));
let ctrl_ep = Arc::new(std::sync::atomic::AtomicI32::new(-1));
let configured = Arc::new(AtomicBool::new(false));
// Control thread: enumerate + answer every control transfer.
let control = {
let fd = fd.clone();
let running = running.clone();
let ctrl_ep = ctrl_ep.clone();
let configured = configured.clone();
let feedback = feedback.clone();
std::thread::Builder::new()
.name("pf-deck-gadget-ctrl".into())
.spawn(move || {
control_loop(fd, running, ctrl_ep, configured, feedback, serial, unit_id)
})
.context("spawn gadget control thread")?
};
// Stream thread: push the current report on the controller interrupt-IN endpoint.
let stream = {
let fd = fd.clone();
let running = running.clone();
let ctrl_ep = ctrl_ep.clone();
let configured = configured.clone();
let report = report.clone();
std::thread::Builder::new()
.name("pf-deck-gadget-stream".into())
.spawn(move || stream_loop(fd, running, ctrl_ep, configured, report))
.context("spawn gadget stream thread")?
};
Ok(SteamDeckGadget {
report,
feedback,
running,
threads: vec![control, stream],
_fd: fd,
seq: 0,
})
}
/// Serialize `st` into the 64-byte Deck state report streamed to the kernel.
pub fn write_state(&mut self, st: &super::steam_proto::SteamState) {
self.seq = self.seq.wrapping_add(1);
let mut r = [0u8; 64];
super::steam_proto::serialize_deck_state(&mut r, st, self.seq);
if let Ok(mut g) = self.report.lock() {
*g = r;
}
}
/// Drain any feedback (rumble) the kernel/Steam wrote to the device.
pub fn service(&mut self) -> super::steam_proto::SteamFeedback {
self.feedback
.lock()
.map(|mut f| std::mem::take(&mut *f))
.unwrap_or_default()
}
}
impl Drop for SteamDeckGadget {
fn drop(&mut self) {
self.running.store(false, Ordering::SeqCst);
for t in self.threads.drain(..) {
let _ = t.join();
}
}
}
fn copy_cstr(dst: &mut [u8], s: &str) {
let n = s.len().min(dst.len() - 1);
dst[..n].copy_from_slice(&s.as_bytes()[..n]);
}
fn control_loop(
fd: Arc<GadgetFd>,
running: Arc<AtomicBool>,
ctrl_ep: Arc<std::sync::atomic::AtomicI32>,
configured: Arc<AtomicBool>,
feedback: Arc<Mutex<super::steam_proto::SteamFeedback>>,
serial: String,
unit_id: u32,
) {
let raw = fd.0;
let cfg = build_config();
let mut last_set: Vec<u8> = Vec::new();
let mut evbuf = [0u8; EVENT_BUF];
while running.load(Ordering::SeqCst) {
// EVENT_FETCH: type(4) length(4) data[].
evbuf[4..8].copy_from_slice(&(8u32).to_ne_bytes()); // request setup-sized payload
let r = ioctl_mut(raw, IOCTL_EVENT_FETCH, evbuf.as_mut_ptr());
if r < 0 {
if running.load(Ordering::SeqCst) {
// transient; brief backoff
std::thread::sleep(std::time::Duration::from_millis(2));
}
continue;
}
let etype = u32::from_ne_bytes([evbuf[0], evbuf[1], evbuf[2], evbuf[3]]);
match etype {
USB_RAW_EVENT_CONNECT => {}
USB_RAW_EVENT_CONTROL => {
let s = &evbuf[EVENT_HDR..EVENT_HDR + 8];
let ctrl = Setup {
bm_request_type: s[0],
b_request: s[1],
w_value: u16::from_le_bytes([s[2], s[3]]),
w_index: u16::from_le_bytes([s[4], s[5]]),
w_length: u16::from_le_bytes([s[6], s[7]]),
};
handle_control(
raw,
&ctrl,
&cfg,
&serial,
unit_id,
&ctrl_ep,
&configured,
&mut last_set,
&feedback,
);
}
_ => {}
}
}
}
struct Setup {
bm_request_type: u8,
b_request: u8,
w_value: u16,
w_index: u16,
w_length: u16,
}
#[allow(clippy::too_many_arguments)]
fn handle_control(
raw: RawFd,
ctrl: &Setup,
cfg: &[u8],
serial: &str,
unit_id: u32,
ctrl_ep: &std::sync::atomic::AtomicI32,
configured: &AtomicBool,
last_set: &mut Vec<u8>,
feedback: &Mutex<super::steam_proto::SteamFeedback>,
) {
let idx = (ctrl.w_index & 0xff) as u8;
let type_class = ctrl.bm_request_type & 0x60;
let wl = ctrl.w_length as usize;
if type_class == 0x00 {
// standard
match ctrl.b_request {
0x06 => {
// GET_DESCRIPTOR
let dt = (ctrl.w_value >> 8) as u8;
let di = (ctrl.w_value & 0xff) as u8;
let resp: Vec<u8> = match dt {
1 => DEV_DESC.to_vec(),
2 => cfg.to_vec(),
3 => string_desc(di, serial),
HID_RPT_DT => match idx {
0 => RDESC_MOUSE.to_vec(),
1 => RDESC_KBD.to_vec(),
_ => RDESC_CTRL.to_vec(),
},
HID_DT => {
// re-emit the interface's HID descriptor from the config blob (best effort)
hid_desc_for(cfg, idx)
}
_ => {
ep0_stall(raw);
return;
}
};
let n = resp.len().min(wl);
ep0_write(raw, &resp[..n]);
}
0x09 => {
// SET_CONFIGURATION
ioctl_val(raw, IOCTL_VBUS_DRAW, 0x32);
ioctl_none(raw, IOCTL_CONFIGURE);
enable_endpoints(raw, ctrl_ep);
ep0_ack(raw);
configured.store(true, Ordering::SeqCst);
}
0x0b => ep0_ack(raw), // SET_INTERFACE
0x00 => {
let st = 0u16;
ep0_write(raw, &st.to_le_bytes());
}
_ => ep0_stall(raw),
}
} else if type_class == 0x20 {
// HID class
match ctrl.b_request {
0x01 => {
// GET_REPORT — serve the Deck feature reply for the last requested command.
let resp = feature_reply(last_set, serial, unit_id);
let n = resp.len().min(wl);
ep0_write(raw, &resp[..n]);
}
0x09 => {
// SET_REPORT — read the host's data; remember it + extract feedback.
let (r, data) = ep0_read(raw, wl);
if r > 0 {
*last_set = data.clone();
// parse_steam_output expects [report-id(0), cmd, …]; EP0 OUT data is [cmd, …].
let mut framed = Vec::with_capacity(data.len() + 1);
framed.push(0);
framed.extend_from_slice(&data);
let fb = super::steam_proto::parse_steam_output(&framed);
if fb.rumble.is_some() {
if let Ok(mut g) = feedback.lock() {
*g = fb;
}
}
}
}
0x0a | 0x0b => ep0_ack(raw), // SET_IDLE / SET_PROTOCOL
0x03 => {
ep0_write(raw, &[0u8]);
} // GET_PROTOCOL
_ => ep0_stall(raw),
}
} else {
ep0_stall(raw);
}
}
fn hid_desc_for(cfg: &[u8], idx: u8) -> Vec<u8> {
// The HID descriptors live right after each interface descriptor in the config blob.
// Offsets: cfg(9) | i0(9) h0(9) e0(7) | i1(9) h1(9) e1(7) | i2(9) h2(9) e2(7)
let off = match idx {
0 => 9 + 9,
1 => 9 + 25 + 9,
_ => 9 + 50 + 9,
};
cfg.get(off..off + 9)
.map(|s| s.to_vec())
.unwrap_or_default()
}
fn enable_endpoints(raw: RawFd, ctrl_ep: &std::sync::atomic::AtomicI32) {
let mk = |addr: u8, mps: u16| UsbEndpointDescriptor {
b_length: 7,
b_descriptor_type: 5,
b_endpoint_address: addr,
bm_attributes: 0x03,
w_max_packet_size: mps,
b_interval: 4,
..Default::default()
};
let e0 = mk(0x81, 8);
let e1 = mk(0x82, 8);
let e2 = mk(0x83, 64);
ioctl_ptr(raw, IOCTL_EP_ENABLE, &e0 as *const _);
ioctl_ptr(raw, IOCTL_EP_ENABLE, &e1 as *const _);
let h2 = ioctl_ptr(raw, IOCTL_EP_ENABLE, &e2 as *const _);
ctrl_ep.store(h2, Ordering::SeqCst);
}
fn stream_loop(
fd: Arc<GadgetFd>,
running: Arc<AtomicBool>,
ctrl_ep: Arc<std::sync::atomic::AtomicI32>,
configured: Arc<AtomicBool>,
report: Arc<Mutex<[u8; 64]>>,
) {
let raw = fd.0;
while running.load(Ordering::SeqCst) {
let ep = ctrl_ep.load(Ordering::SeqCst);
if configured.load(Ordering::SeqCst) && ep >= 0 {
let r = report
.lock()
.map(|g| *g)
.unwrap_or_else(|_| neutral_deck_report());
let mut buf = [0u8; EPIO_HDR + 64];
buf[0..2].copy_from_slice(&(ep as u16).to_ne_bytes());
buf[4..8].copy_from_slice(&(64u32).to_ne_bytes());
buf[EPIO_HDR..].copy_from_slice(&r);
// Blocks until the host polls the interrupt-IN endpoint; that's fine on its own thread.
ioctl_ptr(raw, IOCTL_EP_WRITE, buf.as_ptr());
}
std::thread::sleep(std::time::Duration::from_millis(8));
}
}
/// Best-effort load of the gadget modules (SteamOS ships `dummy_hcd` + `raw_gadget`). Failures are
/// ignored — the caller falls back to UHID if `/dev/raw-gadget` is then still unusable.
pub fn ensure_modules() {
for m in ["dummy_hcd", "raw_gadget"] {
let _ = std::process::Command::new("modprobe").arg(m).status();
}
}
/// Whether to prefer the USB-gadget Deck over the UHID `SteamDeckPad` — the only transport Steam Input
/// promotes (validated glass-to-glass on a Deck). Defaults **on for SteamOS** hosts (which ship the
/// gadget modules + run Steam Input); off elsewhere, where the universal UHID path stays the default.
/// `PUNKTFUNK_STEAM_GADGET=1`/`0` forces it on/off. A Deck-as-host with a *physical* Deck never reaches
/// here: `resolve_gamepad`'s conflict gate degrades `SteamDeck` → DualSense before the manager is built.
pub fn gadget_preferred() -> bool {
if let Ok(v) = std::env::var("PUNKTFUNK_STEAM_GADGET") {
return v == "1" || v.eq_ignore_ascii_case("true");
}
is_steamos()
}
/// True on SteamOS-class hosts (`/etc/os-release` `ID=steamos`, or `ID_LIKE` naming it).
fn is_steamos() -> bool {
std::fs::read_to_string("/etc/os-release")
.map(|s| {
s.lines()
.any(|l| l == "ID=steamos" || (l.starts_with("ID_LIKE=") && l.contains("steamos")))
})
.unwrap_or(false)
}
@@ -0,0 +1,733 @@
//! Virtual Steam Deck over **USB/IP** (`vhci_hcd`) — the shippable, Secure-Boot-clean, universal
//! alternative to [`super::steam_gadget`] (`raw_gadget` + `dummy_hcd`, SteamOS-only).
//!
//! Like the gadget, this presents a *real* 3-interface USB Steam Deck (mouse = interface 0, keyboard
//! = 1, **controller = 2**) — the interface-2 layout Steam's own driver filters on, so Steam Input
//! promotes it (a UHID Deck, `Interface: -1`, never is). Unlike the gadget it needs no out-of-tree
//! module: `vhci_hcd` is in-tree + signed on SteamOS, Bazzite, and ~every distro, loads under Secure
//! Boot, and needs no MOK. A userspace [`usbip_sim`] server emulates the Deck; the local `vhci_hcd`
//! attaches it. **Validated on Bazzite**: `vhci_hcd` enumerates the 3-interface Deck, `hid-steam`
//! binds it, and Steam reserves an XInput slot — identical recognition to the gadget.
//!
//! The device model + the USB/IP protocol come from the vendored [`usbip_sim`] crate (the upstream
//! `usbip` crate trimmed of its libusb host mode); the captured descriptors + the `0x83`/`0xAE`
//! feature contract come from the shared [`super::steam_proto`] (one source of truth with the gadget).
//!
//! **Attach** is in-process by default (no external `usbip` CLI dependency — the production goal): we
//! run the emulation server on a loopback TCP port, connect to it ourselves, perform the
//! `OP_REQ_IMPORT` handshake, then hand the connected socket fd to `vhci_hcd` via its sysfs `attach`
//! file. If anything in that path fails we fall back to the widely-packaged `usbip` CLI; if *that*
//! also fails, [`open`](SteamDeckUsbip::open) returns `Err` and the caller degrades to UHID.
use super::steam_proto::{
deck_serial, deck_unit_id, feature_reply, neutral_deck_report, parse_steam_output,
SteamFeedback, SteamState, RDESC_DECK_CTRL, RDESC_DECK_KBD, RDESC_DECK_MOUSE,
};
use anyhow::{bail, Context, Result};
use std::any::Any;
use std::collections::HashSet;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::os::fd::AsRawFd;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use usbip_sim::{
Direction, SetupPacket, UsbDevice, UsbEndpoint, UsbInterface, UsbInterfaceHandler, UsbIpServer,
Version,
};
const STEAM_VENDOR: u16 = 0x28DE;
const STEAMDECK_PRODUCT: u16 = 0x1205;
/// The single device's USB/IP bus id (one device per server, so the fixed default is fine).
const BUS_ID: &str = "0-0-0";
/// The usbip default TCP port — the server must listen here for the `usbip` CLI fallback to attach.
const USBIP_TCP_PORT: u16 = 3240;
/// Build the 9-byte HID class descriptor inserted between the interface and endpoint descriptors.
fn hid_desc(report_len: usize, country: u8) -> Vec<u8> {
let l = report_len as u16;
#[rustfmt::skip]
let d = vec![0x09, 0x21, 0x10, 0x01, country, 1, 0x22, (l & 0xff) as u8, (l >> 8) as u8];
d
}
/// The Deck **controller** interface (vendor HID, interface 2): answers the HID feature reports
/// (descriptor / `0x83` attributes / `0xAE` serial), streams the current 64-byte state on the
/// interrupt-IN endpoint, and surfaces rumble written via SET_REPORT.
#[derive(Debug)]
struct ControllerHandler {
/// The current 64-byte Deck input report, shared with [`SteamDeckUsbip::write_state`].
report: Arc<Mutex<[u8; 64]>>,
/// Rumble extracted from the kernel's SET_REPORTs, drained by [`SteamDeckUsbip::service`].
feedback: Arc<Mutex<SteamFeedback>>,
/// The host's last SET_REPORT command (drives [`feature_reply`]).
last_set: Vec<u8>,
serial: String,
unit_id: u32,
}
impl UsbInterfaceHandler for ControllerHandler {
fn get_class_specific_descriptor(&self) -> Vec<u8> {
hid_desc(RDESC_DECK_CTRL.len(), 33)
}
fn handle_urb(
&mut self,
_interface: &UsbInterface,
ep: UsbEndpoint,
_len: u32,
setup: SetupPacket,
req: &[u8],
) -> std::io::Result<Vec<u8>> {
if ep.is_ep0() {
Ok(match (setup.request_type, setup.request) {
// GET report descriptor (standard, interface recipient).
(0x81, 0x06) if (setup.value >> 8) == 0x22 => RDESC_DECK_CTRL.to_vec(),
// HID GET_REPORT (feature) — the Deck `0x83`/`0xAE` contract.
(0xA1, 0x01) => feature_reply(&self.last_set, &self.serial, self.unit_id).to_vec(),
// HID SET_REPORT — remember the command (for the next feature reply) + surface rumble.
(0x21, 0x09) => {
self.last_set = req.to_vec();
// `parse_steam_output` expects `[report-id(0), cmd, …]`; EP0 OUT data is `[cmd, …]`.
let mut framed = Vec::with_capacity(req.len() + 1);
framed.push(0);
framed.extend_from_slice(req);
let fb = parse_steam_output(&framed);
if fb.rumble.is_some() {
if let Ok(mut g) = self.feedback.lock() {
*g = fb;
}
}
vec![]
}
(0x21, 0x0A) | (0x21, 0x0B) => vec![], // SET_IDLE / SET_PROTOCOL
_ => vec![],
})
} else if let Direction::In = ep.direction() {
// Interrupt-IN poll: return the current report. The vendored sim paces interrupt-IN by
// bInterval (vhci_hcd does NOT throttle the server side), so this isn't a busy spin.
let r = self
.report
.lock()
.map(|g| *g)
.unwrap_or_else(|_| neutral_deck_report());
Ok(r.to_vec())
} else {
Ok(vec![])
}
}
fn as_any(&mut self) -> &mut dyn Any {
self
}
}
/// A minimal idle HID interface (mouse / keyboard) — serves only its report descriptor.
#[derive(Debug)]
struct IdleHidHandler {
report_desc: Vec<u8>,
}
impl UsbInterfaceHandler for IdleHidHandler {
fn get_class_specific_descriptor(&self) -> Vec<u8> {
hid_desc(self.report_desc.len(), 0)
}
fn handle_urb(
&mut self,
_i: &UsbInterface,
ep: UsbEndpoint,
_l: u32,
setup: SetupPacket,
_req: &[u8],
) -> std::io::Result<Vec<u8>> {
if ep.is_ep0() && setup.request == 0x06 && (setup.value >> 8) == 0x22 {
Ok(self.report_desc.clone())
} else {
Ok(vec![])
}
}
fn as_any(&mut self) -> &mut dyn Any {
self
}
}
fn boxed(
h: impl UsbInterfaceHandler + Send + 'static,
) -> Arc<Mutex<Box<dyn UsbInterfaceHandler + Send>>> {
Arc::new(Mutex::new(Box::new(h)))
}
fn ep(addr: u8, mps: u16) -> UsbEndpoint {
UsbEndpoint {
address: addr,
attributes: 0x03, // interrupt
max_packet_size: mps,
interval: 4,
}
}
/// Assemble the simulated 3-interface USB Deck. The controller handler shares `report` + `feedback`
/// with the owning [`SteamDeckUsbip`].
fn build_device(
index: u8,
report: &Arc<Mutex<[u8; 64]>>,
feedback: &Arc<Mutex<SteamFeedback>>,
) -> UsbDevice {
let mut dev = UsbDevice::new(0); // one device per server; bus_id stays the default "0-0-0".
dev.vendor_id = STEAM_VENDOR;
dev.product_id = STEAMDECK_PRODUCT;
dev.usb_version = Version::from(0x0200u16); // bcdUSB 2.00
dev.device_bcd = Version::from(0x0300u16); // bcdDevice 3.00 (matches the gadget)
dev.set_manufacturer_name("Valve Software");
dev.set_product_name("Steam Deck Controller");
dev.set_serial_number(&deck_serial(index));
dev.with_interface(
0x03,
0x00,
0x02,
Some("mouse"),
vec![ep(0x81, 8)],
boxed(IdleHidHandler {
report_desc: RDESC_DECK_MOUSE.to_vec(),
}),
)
.with_interface(
0x03,
0x01,
0x01,
Some("keyboard"),
vec![ep(0x82, 8)],
boxed(IdleHidHandler {
report_desc: RDESC_DECK_KBD.to_vec(),
}),
)
.with_interface(
0x03,
0x00,
0x00,
Some("controller"),
vec![ep(0x83, 64)],
boxed(ControllerHandler {
report: report.clone(),
feedback: feedback.clone(),
last_set: vec![],
serial: deck_serial(index),
unit_id: deck_unit_id(index),
}),
)
}
/// Owns the emulation-server thread (a dedicated current-thread tokio runtime) and stops it on drop.
/// Run on its own thread so `SteamDeckUsbip::open` works whether or not the caller is inside a tokio
/// runtime (creating a runtime inside one would panic).
struct ServerThread {
stop: Arc<tokio::sync::Notify>,
join: Option<JoinHandle<()>>,
}
impl ServerThread {
/// Spawn the server on `listener`, serving exactly the one simulated `dev`.
fn spawn(listener: std::net::TcpListener, dev: UsbDevice) -> Result<ServerThread> {
let stop = Arc::new(tokio::sync::Notify::new());
let stop_t = stop.clone();
let join = std::thread::Builder::new()
.name("pf-deck-usbip".into())
.spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
tracing::error!(error = %e, "usbip server runtime build failed");
return;
}
};
rt.block_on(run_server(
listener,
Arc::new(UsbIpServer::new_simulated(vec![dev])),
stop_t,
));
})
.context("spawn usbip server thread")?;
Ok(ServerThread {
stop,
join: Some(join),
})
}
}
impl Drop for ServerThread {
fn drop(&mut self) {
self.stop.notify_one();
if let Some(j) = self.join.take() {
let _ = j.join();
}
}
}
/// Accept loop: serve each USB/IP connection with the vendored `usbip_sim::handler` until stopped.
async fn run_server(
listener: std::net::TcpListener,
server: Arc<UsbIpServer>,
stop: Arc<tokio::sync::Notify>,
) {
let listener = match tokio::net::TcpListener::from_std(listener) {
Ok(l) => l,
Err(e) => {
tracing::error!(error = %e, "usbip TcpListener::from_std failed");
return;
}
};
loop {
tokio::select! {
_ = stop.notified() => break,
r = listener.accept() => match r {
Ok((mut sock, _)) => {
let server = server.clone();
tokio::spawn(async move {
let _ = usbip_sim::handler(&mut sock, server).await;
});
}
Err(e) => {
tracing::warn!(error = %e, "usbip accept error");
break;
}
}
}
}
}
/// A virtual Steam Deck presented over USB/IP. Dropping it detaches the `vhci_hcd` port (the device
/// disappears, Steam releases its slot) and stops the emulation server.
pub struct SteamDeckUsbip {
report: Arc<Mutex<[u8; 64]>>,
feedback: Arc<Mutex<SteamFeedback>>,
/// The `vhci_hcd` port we attached to — written to the sysfs `detach` file on drop.
vhci_port: u16,
/// Kept alive so the connected socket fd we handed to `vhci_hcd` stays valid (in-process attach
/// only; the CLI hands its own fd to the kernel and exits, so this is `None` there).
_client_sock: Option<TcpStream>,
/// Emulation-server thread; dropped (stopped) after the detach.
_server: ServerThread,
seq: u32,
}
impl SteamDeckUsbip {
/// Bind a virtual Deck and attach it locally via `vhci_hcd`. `index` varies only the serial.
/// Requires `vhci_hcd` loaded and root (the sysfs attach / the CLI both need it). Tries the
/// in-process sysfs attach first, then the `usbip` CLI; `PUNKTFUNK_USBIP_ATTACH=inproc|cli`
/// pins one path (for debugging).
pub fn open(index: u8) -> Result<SteamDeckUsbip> {
ensure_modules();
if vhci_base().is_none() {
bail!(
"vhci_hcd unavailable (no /sys/devices/platform/vhci_hcd*/status) — is it loaded?"
);
}
let mode = std::env::var("PUNKTFUNK_USBIP_ATTACH").ok();
if mode.as_deref() != Some("cli") {
match Self::open_in_process(index) {
Ok(d) => return Ok(d),
Err(e) if mode.as_deref() == Some("inproc") => return Err(e),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "in-process vhci attach failed — trying the usbip CLI")
}
}
}
Self::open_via_cli(index)
}
/// In-process attach: emulate on a loopback port, do the import handshake ourselves, hand the
/// connected socket to `vhci_hcd` via sysfs. No external dependency.
fn open_in_process(index: u8) -> Result<SteamDeckUsbip> {
let report = Arc::new(Mutex::new(neutral_deck_report()));
let feedback = Arc::new(Mutex::new(SteamFeedback::default()));
let dev = build_device(index, &report, &feedback);
// An ephemeral loopback port (avoids contending the usbip default with another pad).
let listener =
std::net::TcpListener::bind(("127.0.0.1", 0)).context("bind loopback usbip server")?;
let port = listener
.local_addr()
.context("usbip server local_addr")?
.port();
listener
.set_nonblocking(true)
.context("usbip listener set_nonblocking")?;
let server = ServerThread::spawn(listener, dev)?;
// Connect to our own server and run the OP_REQ_IMPORT handshake.
let mut sock = connect_loopback(port).context("connect to usbip server")?;
let (devid, speed) = import_handshake(&mut sock).context("usbip import handshake")?;
// Hand the connected socket to vhci_hcd. Clear BOTH timeouts first: the kernel's vhci rx/tx
// threads honour SO_RCVTIMEO/SO_SNDTIMEO on this socket, so the 3s handshake timeouts would
// otherwise tear the device down after 3s idle (rx) or a 3s-blocked send (tx).
let vhci_port = vhci_find_free_port(speed).context("find a free vhci port")?;
sock.set_read_timeout(None).ok();
sock.set_write_timeout(None).ok();
vhci_attach(vhci_port, sock.as_raw_fd(), devid, speed).context("write vhci_hcd attach")?;
tracing::info!(
index,
vhci_port,
"virtual Steam Deck attached via usbip (in-process — Steam Input recognizes it)"
);
Ok(SteamDeckUsbip {
report,
feedback,
vhci_port,
_client_sock: Some(sock),
_server: server,
seq: 0,
})
}
/// Fallback: emulate on the usbip default port and let the `usbip` CLI attach (it picks the vhci
/// port itself; we recover it by diffing the sysfs status).
fn open_via_cli(index: u8) -> Result<SteamDeckUsbip> {
let report = Arc::new(Mutex::new(neutral_deck_report()));
let feedback = Arc::new(Mutex::new(SteamFeedback::default()));
let dev = build_device(index, &report, &feedback);
let listener = std::net::TcpListener::bind(("127.0.0.1", USBIP_TCP_PORT))
.with_context(|| format!("bind usbip default port {USBIP_TCP_PORT} for CLI attach"))?;
listener
.set_nonblocking(true)
.context("usbip listener set_nonblocking")?;
let server = ServerThread::spawn(listener, dev)?;
let before = vhci_used_ports();
usbip_attach_cli().context("usbip CLI attach")?;
let vhci_port = wait_for_new_port(&before)
.context("could not determine the vhci port the usbip CLI attached to")?;
tracing::info!(
index,
vhci_port,
"virtual Steam Deck attached via usbip (CLI — Steam Input recognizes it)"
);
Ok(SteamDeckUsbip {
report,
feedback,
vhci_port,
_client_sock: None,
_server: server,
seq: 0,
})
}
/// Serialize `st` into the 64-byte Deck report streamed on the controller interrupt-IN endpoint.
pub fn write_state(&mut self, st: &SteamState) {
self.seq = self.seq.wrapping_add(1);
let mut r = [0u8; 64];
super::steam_proto::serialize_deck_state(&mut r, st, self.seq);
if let Ok(mut g) = self.report.lock() {
*g = r;
}
}
/// Drain any rumble feedback the kernel/Steam wrote to the device.
pub fn service(&mut self) -> SteamFeedback {
self.feedback
.lock()
.map(|mut f| std::mem::take(&mut *f))
.unwrap_or_default()
}
}
impl Drop for SteamDeckUsbip {
fn drop(&mut self) {
// Detach the vhci port first (the kernel closes its end of the socket + tears down the
// device); `_client_sock` + `_server` then drop, closing our side + stopping the server.
if let Err(e) = vhci_detach(self.vhci_port) {
tracing::debug!(port = self.vhci_port, error = %e, "vhci detach failed (device may already be gone)");
}
}
}
// ---- USB/IP import handshake (we act as the usbip *client* before handing the fd to the kernel) ----
const USBIP_VERSION: u16 = 0x0111;
const OP_REQ_IMPORT: u16 = 0x8003;
/// Connect to our own loopback server, retrying briefly while the server thread comes up.
fn connect_loopback(port: u16) -> Result<TcpStream> {
let addr = ("127.0.0.1", port);
let mut last = None;
for _ in 0..50 {
match TcpStream::connect(addr) {
Ok(s) => {
s.set_nodelay(true).ok();
return Ok(s);
}
Err(e) => {
last = Some(e);
std::thread::sleep(Duration::from_millis(10));
}
}
}
Err(anyhow::anyhow!(
"connect 127.0.0.1:{port}: {}",
last.map(|e| e.to_string()).unwrap_or_default()
))
}
/// Send `OP_REQ_IMPORT` for [`BUS_ID`] and read `OP_REP_IMPORT`, returning `(devid, speed)` parsed
/// from the device record (the same `devid = bus_num<<16 | dev_num` + speed `vhci_hcd` wants). The
/// whole 320-byte reply MUST be consumed here so the socket starts clean at the kernel's first
/// `USBIP_CMD_SUBMIT`.
fn import_handshake(sock: &mut TcpStream) -> Result<(u32, u32)> {
// Bounded so a non-responsive server can't head-block the per-session input thread (this talks
// to our own in-process loopback server, so a working handshake completes in well under a ms).
sock.set_read_timeout(Some(Duration::from_secs(1))).ok();
sock.set_write_timeout(Some(Duration::from_secs(1))).ok();
let mut req = Vec::with_capacity(40);
req.extend_from_slice(&USBIP_VERSION.to_be_bytes());
req.extend_from_slice(&OP_REQ_IMPORT.to_be_bytes());
req.extend_from_slice(&0u32.to_be_bytes()); // status
let mut busid = [0u8; 32];
let b = BUS_ID.as_bytes();
busid[..b.len()].copy_from_slice(b);
req.extend_from_slice(&busid);
sock.write_all(&req).context("send OP_REQ_IMPORT")?;
// Reply: version(2) code(2) status(4), then the 312-byte device record on success.
let mut header = [0u8; 8];
sock.read_exact(&mut header)
.context("read OP_REP_IMPORT header")?;
let status = u32::from_be_bytes([header[4], header[5], header[6], header[7]]);
if status != 0 {
bail!("OP_REP_IMPORT refused (status={status}) — device {BUS_ID} not exported?");
}
let mut dev = [0u8; 312];
sock.read_exact(&mut dev)
.context("read OP_REP_IMPORT device record")?;
// Device record layout: path[256], bus_id[32], bus_num(4 BE)@288, dev_num(4 BE)@292, speed(4)@296.
let be = |o: usize| u32::from_be_bytes([dev[o], dev[o + 1], dev[o + 2], dev[o + 3]]);
let bus_num = be(288);
let dev_num = be(292);
let speed = be(296);
Ok(((bus_num << 16) | dev_num, speed))
}
// ---- vhci_hcd sysfs plumbing ----
/// Best-effort load of `vhci_hcd` (in-tree + signed on SteamOS/Bazzite/most distros).
pub fn ensure_modules() {
let _ = Command::new("modprobe").arg("vhci_hcd").status();
}
/// Run `usbip attach -r 127.0.0.1 -b 0-0-0`, bounded by a deadline so a hung CLI can't head-block
/// the per-session input thread indefinitely (the caller runs this inline on that thread).
fn usbip_attach_cli() -> Result<()> {
let mut child = Command::new("usbip")
.args(["attach", "-r", "127.0.0.1", "-b", BUS_ID])
.spawn()
.context("spawn `usbip attach` (is usbip-utils installed?)")?;
let deadline = Instant::now() + Duration::from_secs(6);
loop {
match child.try_wait().context("wait on `usbip attach`")? {
Some(st) if st.success() => return Ok(()),
Some(st) => bail!("`usbip attach` exited with {st}"),
None if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
bail!("`usbip attach` timed out (>6s) — killed");
}
None => std::thread::sleep(Duration::from_millis(20)),
}
}
}
/// Whether a usbip attach should be attempted at all. Default on (the universal Steam-promotable
/// transport on non-SteamOS hosts); `PUNKTFUNK_STEAM_USBIP=0` forces it off, `=1` forces it on.
/// [`open`](SteamDeckUsbip::open) still degrades gracefully if `vhci_hcd` turns out to be absent.
pub fn usbip_preferred() -> bool {
!matches!(
std::env::var("PUNKTFUNK_STEAM_USBIP").ok().as_deref(),
Some("0") | Some("false")
)
}
/// The `vhci_hcd.0` (or legacy `vhci_hcd`) platform sysfs directory, if present.
fn vhci_base() -> Option<PathBuf> {
for p in [
"/sys/devices/platform/vhci_hcd.0",
"/sys/devices/platform/vhci_hcd",
] {
let base = Path::new(p);
if base.join("status").exists() {
return Some(base.to_path_buf());
}
}
None
}
fn read_status() -> Result<String> {
let base = vhci_base().context("vhci_hcd sysfs not present")?;
std::fs::read_to_string(base.join("status")).context("read vhci_hcd status")
}
/// One parsed `status` row: `(port, hub_is_superspeed, sta)`. Handles both the modern
/// `hub port sta …` and the legacy `port sta …` column layouts; returns `None` for header/blank rows.
fn parse_status_row(line: &str) -> Option<(u16, bool, u32)> {
let t: Vec<&str> = line.split_whitespace().collect();
if t.is_empty() {
return None;
}
let (hub_ss, port_str, sta_str) = if t[0] == "hs" || t[0] == "ss" {
(Some(t[0] == "ss"), *t.get(1)?, *t.get(2)?)
} else if t[0].chars().all(|c| c.is_ascii_digit()) {
(None, t[0], *t.get(1)?) // legacy: port sta …
} else {
return None; // header ("hub"/"prt"/"port" …)
};
let port = port_str.parse::<u16>().ok()?;
let sta = sta_str.parse::<u32>().ok()?;
Some((port, hub_ss.unwrap_or(false), sta))
}
/// `sta == 4` is `VDEV_ST_NULL` (a free port).
const VDEV_ST_NULL: u32 = 4;
/// Pick a free `vhci_hcd` port matching the device speed (`usbip_speed >= 5` ⇒ SuperSpeed hub).
fn vhci_find_free_port(usbip_speed: u32) -> Result<u16> {
let want_ss = usbip_speed >= 5;
let status = read_status()?;
for line in status.lines() {
if let Some((port, is_ss, sta)) = parse_status_row(line) {
if sta == VDEV_ST_NULL && is_ss == want_ss {
return Ok(port);
}
}
}
// Speed-class match failed (legacy single-hub status): take any free port.
for line in status.lines() {
if let Some((port, _, sta)) = parse_status_row(line) {
if sta == VDEV_ST_NULL {
return Ok(port);
}
}
}
bail!("no free vhci_hcd port (all ports in use?)")
}
/// Ports currently in use (`sta != VDEV_ST_NULL`) — snapshotted around a CLI attach to recover its port.
fn vhci_used_ports() -> HashSet<u16> {
read_status()
.unwrap_or_default()
.lines()
.filter_map(parse_status_row)
.filter(|&(_, _, sta)| sta != VDEV_ST_NULL)
.map(|(port, _, _)| port)
.collect()
}
/// Poll the status file (briefly) for a port that became used since `before` — the one the CLI attached.
fn wait_for_new_port(before: &HashSet<u16>) -> Result<u16> {
let deadline = Instant::now() + Duration::from_secs(2);
loop {
if let Some(p) = vhci_used_ports().difference(before).copied().min() {
return Ok(p);
}
if Instant::now() >= deadline {
bail!("no newly-attached vhci port appeared after `usbip attach`");
}
std::thread::sleep(Duration::from_millis(50));
}
}
fn vhci_attach(port: u16, sockfd: i32, devid: u32, speed: u32) -> Result<()> {
let base = vhci_base().context("vhci_hcd sysfs not present")?;
let line = format!("{port} {sockfd} {devid} {speed}");
std::fs::write(base.join("attach"), line)
.with_context(|| format!("write vhci_hcd attach (port {port}) — root?"))
}
fn vhci_detach(port: u16) -> Result<()> {
let base = vhci_base().context("vhci_hcd sysfs not present")?;
std::fs::write(base.join("detach"), format!("{port}")).context("write vhci_hcd detach")
}
#[cfg(test)]
mod tests {
use super::*;
/// The `status` parser handles the modern `hub port sta …` layout, the legacy `port sta …`
/// layout, and skips header/blank lines — a slip here would mean attaching to a busy port.
#[test]
fn status_parser_handles_both_layouts() {
// modern
assert_eq!(
parse_status_row("hs 0000 004 000 00000000 000000 0-0"),
Some((0, false, 4))
);
assert_eq!(
parse_status_row("ss 0008 006 000 00000000 000000 0-0"),
Some((8, true, 6))
);
// legacy (no hub column)
assert_eq!(
parse_status_row("0001 004 000 00000000 000000 0-0"),
Some((1, false, 4))
);
// header / blank
assert_eq!(
parse_status_row("hub port sta spd dev sockfd local_busid"),
None
);
assert_eq!(parse_status_row(""), None);
}
/// A free HS port is preferred for an HS device; a free SS port for an SS device.
#[test]
fn free_port_selection_matches_speed() {
let status = "hub port sta spd dev sockfd local_busid\n\
hs 0000 006 000 00000000 000000 0-0\n\
hs 0001 004 000 00000000 000000 0-0\n\
ss 0008 004 000 00000000 000000 0-0\n";
// Reuse the parser directly (vhci_find_free_port reads sysfs; test the selection logic).
let hs = status
.lines()
.filter_map(parse_status_row)
.find(|&(_, is_ss, sta)| sta == VDEV_ST_NULL && !is_ss)
.map(|(p, _, _)| p);
let ss = status
.lines()
.filter_map(parse_status_row)
.find(|&(_, is_ss, sta)| sta == VDEV_ST_NULL && is_ss)
.map(|(p, _, _)| p);
assert_eq!(hs, Some(1));
assert_eq!(ss, Some(8));
}
/// On-box smoke test (needs root + `vhci_hcd`): attach a virtual Deck, confirm `hid-steam` binds
/// it (the `Steam Deck` evdev appears) and that it tears down on drop. `#[ignore]`d in CI.
#[test]
#[ignore = "attaches a real vhci_hcd device; needs root + vhci_hcd"]
fn usbip_deck_binds_and_tears_down() {
ensure_modules();
let mut pad = SteamDeckUsbip::open(0).expect("open SteamDeckUsbip (root + vhci_hcd?)");
let st = SteamState::from_gamepad(punktfunk_core::input::gamepad::BTN_A, 0, 0, 0, 0, 0, 0);
let start = Instant::now();
while start.elapsed() < Duration::from_millis(800) {
pad.write_state(&st);
let _ = pad.service();
std::thread::sleep(Duration::from_millis(8));
}
let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
assert!(
devs.contains("Steam Deck"),
"hid-steam did not bind the usbip Deck"
);
drop(pad);
std::thread::sleep(Duration::from_millis(300));
let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
assert!(
!devs.contains("Steam Deck Motion Sensors"),
"device not torn down on drop"
);
}
}
@@ -0,0 +1,684 @@
//! Transport-independent Steam Controller / Steam Deck HID contract — the Steam analogue of
//! [`super::dualsense_proto`]. The report descriptor, the command/feature IDs, the byte-exact
//! Deck input-report serializer, the `XInput`/rich-input → state mappers, and the rumble-feedback
//! parser. Pure logic, shared by the Linux UHID backend and (later) a Windows UMDF backend.
//!
//! **Layout source of truth:** the kernel `drivers/hid/hid-steam.c` `steam_do_deck_input_event`
//! (+ `steam_do_deck_sensors_event`) — every offset/bit/sign below is transcribed verbatim from
//! it and on-box-validated against kernel 7.0 (see `design/steam-controller-deck-support.md`).
//! M0 proved the device binds + parses; M1 (here) makes the serializer byte-exact.
//!
//! Three load-bearing details the DualSense path does NOT have:
//! * **report id 0 / unnumbered**: input reports are the raw 64 bytes starting `[0x01,0x00,0x09]`
//! (no report-id prefix); FEATURE get/set reports DO carry a leading `0x00` report-id byte
//! (`steam_send_report` does `memcpy(buf+1, cmd, …)`, `steam_recv_report` strips `buf[0]`).
//! * **`gamepad_mode` gate**: `steam_do_deck_input_event` early-returns when
//! `!gamepad_mode && lizard_mode` (the module param, default on). `gamepad_mode` starts false
//! and TOGGLES when [`btn::STEAM_MENU_RIGHT`] (`b9.6`, the mode-switch) is held ~450 ms while
//! no hidraw client is open. The backend enters gamepad mode at session start (pulse that bit,
//! or load `hid_steam lizard_mode=0`) — see the backend, not this module.
//! * **the `UHID_SET_REPORT` handshake** must be answered (DualSense omits it).
#![allow(dead_code)] // Some of the full model is consumed only once the M2 backend + M3 wire land.
use punktfunk_core::input::gamepad as gs;
use punktfunk_core::quic::RichInput;
/// Valve. `hid-steam` matches purely by VID/PID over `BUS_USB`.
pub const STEAM_VENDOR: u32 = 0x28DE;
/// Steam Deck built-in controller (same PID on LCD + OLED).
pub const STEAMDECK_PRODUCT: u32 = 0x1205;
/// Classic Steam Controller, wired (report id 1 / `ID_CONTROLLER_STATE`; a later model).
pub const STEAMCTRL_WIRED_PRODUCT: u32 = 0x1102;
/// The Steam HID state/command report is a fixed 64-byte, **unnumbered** (report-id-0) frame.
pub const STEAM_REPORT_LEN: usize = 64;
// Command IDs (drivers/hid/hid-steam.c), confirmed against the kernel source.
pub const ID_CLEAR_DIGITAL_MAPPINGS: u8 = 0x81;
pub const ID_GET_ATTRIBUTES_VALUES: u8 = 0x83;
pub const ID_SET_SETTINGS_VALUES: u8 = 0x87;
pub const ID_LOAD_DEFAULT_SETTINGS: u8 = 0x8E;
pub const ID_GET_DEVICE_INFO: u8 = 0xA1;
pub const ID_GET_STRING_ATTRIBUTE: u8 = 0xAE;
pub const ATTRIB_STR_UNIT_SERIAL: u8 = 0x01;
/// Host→client feedback: `steam_haptic_rumble` emits report `[0xEB, 9, …]` (FF_RUMBLE → trackpad
/// actuators / Deck motors). The Deck's rumble path; the classic SC also has `0x8F` pad pulses.
pub const ID_TRIGGER_RUMBLE_CMD: u8 = 0xEB;
pub const ID_TRIGGER_HAPTIC_PULSE: u8 = 0x8F;
/// Input report message types: SC = `ID_CONTROLLER_STATE`, Deck = `ID_CONTROLLER_DECK_STATE`.
pub const ID_CONTROLLER_STATE: u8 = 0x01;
pub const ID_CONTROLLER_DECK_STATE: u8 = 0x09;
/// Which Steam device identity to present. M1 implements the Deck fully; the classic Controller
/// (dual trackpads, report id 1, trackpad-only haptics) is a later identity behind the same path.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SteamModel {
Deck,
Controller,
}
impl SteamModel {
pub fn product(self) -> u32 {
match self {
SteamModel::Deck => STEAMDECK_PRODUCT,
SteamModel::Controller => STEAMCTRL_WIRED_PRODUCT,
}
}
}
/// Minimal vendor-defined HID report descriptor: one application collection with a 64-byte input
/// report and a 64-byte feature report, both UNNUMBERED (report id 0). `hid-steam` is a raw-event
/// driver, so the field layout is cosmetic — but `steam_probe` requires `hid_parse` to succeed AND
/// a non-empty FEATURE report list (`steam_is_valve_interface`), so the feature item is mandatory.
#[rustfmt::skip]
pub const STEAMDECK_RDESC: &[u8] = &[
0x06, 0x00, 0xFF, // Usage Page (Vendor-Defined 0xFF00)
0x09, 0x01, // Usage (0x01)
0xA1, 0x01, // Collection (Application)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8 bits)
0x95, 0x40, // Report Count (64)
0x09, 0x01, // Usage (0x01)
0x81, 0x02, // Input (Data,Var,Abs) — the 64-byte state report
0x09, 0x01, // Usage (0x01)
0x95, 0x40, // Report Count (64)
0xB1, 0x02, // Feature (Data,Var,Abs) — makes steam_is_valve_interface() true
0xC0, // End Collection
];
/// Deck button bits, indexed in the `u64` packed across report bytes 8..16 — bit `(byte-8)*8 + bit`,
/// transcribed verbatim from `steam_do_deck_input_event` (bytes 12 + 15 carry no buttons). Naming
/// follows the physical Deck control; the trailing comment is the kernel `BTN_*` it maps to.
pub mod btn {
// byte 8
pub const RT_FULL: u64 = 1 << 0; // BTN_TR2 — right trigger fully pressed
pub const LT_FULL: u64 = 1 << 1; // BTN_TL2 — left trigger fully pressed
pub const RB: u64 = 1 << 2; // BTN_TR — right shoulder
pub const LB: u64 = 1 << 3; // BTN_TL — left shoulder
pub const Y: u64 = 1 << 4;
pub const B: u64 = 1 << 5;
pub const X: u64 = 1 << 6;
pub const A: u64 = 1 << 7;
// byte 9
pub const DPAD_UP: u64 = 1 << 8;
pub const DPAD_RIGHT: u64 = 1 << 9;
pub const DPAD_LEFT: u64 = 1 << 10;
pub const DPAD_DOWN: u64 = 1 << 11;
pub const VIEW: u64 = 1 << 12; // BTN_SELECT — "menu left" (View / Back)
pub const STEAM: u64 = 1 << 13; // BTN_MODE — Steam logo button
pub const MENU: u64 = 1 << 14; // BTN_START — "menu right" (Start / Options)
pub const L5: u64 = 1 << 15; // BTN_GRIPL2 — left BOTTOM back grip
// byte 10
pub const R5: u64 = 1 << 16; // BTN_GRIPR2 — right BOTTOM back grip
pub const LPAD_CLICK: u64 = 1 << 17; // BTN_THUMB — left pad pressed (click)
pub const RPAD_CLICK: u64 = 1 << 18; // BTN_THUMB2 — right pad pressed (click)
pub const LPAD_TOUCH: u64 = 1 << 19; // gates ABS_HAT0 (left pad coords)
pub const RPAD_TOUCH: u64 = 1 << 20; // gates ABS_HAT1 (right pad coords)
pub const L3: u64 = 1 << 22; // BTN_THUMBL — left joystick click
// byte 11
pub const R3: u64 = 1 << 26; // BTN_THUMBR — right joystick click
// byte 13
pub const L4: u64 = 1 << 41; // BTN_GRIPL — left TOP back grip
pub const R4: u64 = 1 << 42; // BTN_GRIPR — right TOP back grip
pub const LJOY_TOUCH: u64 = 1 << 46;
pub const RJOY_TOUCH: u64 = 1 << 47;
// byte 14
pub const QAM: u64 = 1 << 50; // BTN_BASE — quick-access (…) button
/// `b9.6` doubles as the mode-switch: held ~450 ms (no hidraw client) it toggles `gamepad_mode`.
pub const STEAM_MENU_RIGHT: u64 = MENU;
}
/// Full virtual Steam Deck controller state. All analog fields are stored as the RAW little-endian
/// report values the kernel reads (so [`serialize_deck_state`] is a pure memcpy); the kernel applies
/// its own sign conventions on top (`ABS_Y = -raw`, etc.) — see [`SteamState::from_gamepad`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SteamState {
/// Packed button bits (see [`btn`]); occupies report bytes 8..16.
pub buttons: u64,
/// Left / right joystick, raw s16 (report 48/50/52/54). The kernel negates the Y axes.
pub lx: i16,
pub ly: i16,
pub rx: i16,
pub ry: i16,
/// Left / right analog trigger, raw u16 (report 44/46 → ABS_HAT2Y/X).
pub lt: u16,
pub rt: u16,
/// Left / right trackpad position, raw s16, centred 0 (report 16/18/20/22). Only surfaced by
/// the kernel while the matching `*PAD_TOUCH` button bit is set.
pub lpad_x: i16,
pub lpad_y: i16,
pub rpad_x: i16,
pub rpad_y: i16,
pub lpad_pressure: u16,
pub rpad_pressure: u16,
/// IMU, raw s16. `accel`/`gyro` are `[X, Y, Z]`; the kernel maps them to ABS_X/Z/Y + ABS_RX/RZ/RY
/// (with Z/RZ negated) on the separate sensors evdev.
pub accel: [i16; 3],
pub gyro: [i16; 3],
}
impl SteamState {
pub fn neutral() -> SteamState {
SteamState::default()
}
/// Set/clear a button (or group) by its [`btn`] mask.
pub fn press(&mut self, mask: u64, down: bool) {
if down {
self.buttons |= mask;
} else {
self.buttons &= !mask;
}
}
/// Map an `XInput`/GameStream pad frame (button bitmask + i16 sticks + u8 triggers) into the Deck
/// state. Sticks pass through (the kernel negates Y, which yields the conventional direction —
/// validated on-box); triggers scale u8 0..255 → u16 0..32640 and set the full-pull bit when
/// pressed. Trackpad + motion + the back grips arrive separately ([`apply_rich`], the M3 wire).
pub fn 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,
ry,
lt: (lt as u16) * 128,
rt: (rt as u16) * 128,
..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_LS_CLICK), btn::L3);
set(&mut b, on(gs::BTN_RS_CLICK), btn::R3);
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);
// The DualSense touchpad-click wire bit maps to the Deck's RIGHT pad click (the pad that
// stands in for the DualSense touchpad — see apply_rich).
set(&mut b, on(gs::BTN_TOUCHPAD), btn::RPAD_CLICK);
// Back grips (the whole reason for the Deck identity): the wire paddle bits map to the four
// Deck grips — PADDLE1/2/3/4 = R4/L4/R5/L5 (see `input::gamepad`); MISC1 = the QAM '…' button.
set(&mut b, on(gs::BTN_PADDLE1), btn::R4);
set(&mut b, on(gs::BTN_PADDLE2), btn::L4);
set(&mut b, on(gs::BTN_PADDLE3), btn::R5);
set(&mut b, on(gs::BTN_PADDLE4), btn::L5);
set(&mut b, on(gs::BTN_MISC1), btn::QAM);
s.buttons = b;
s
}
/// Apply one rich client→host event into this state, preserving everything else. The single-pad
/// wire [`RichInput::Touchpad`] maps to the **right** trackpad (the Deck pad analogous to the
/// DualSense touchpad); the left pad arrives via the M3 `TouchpadEx` surface. [`RichInput::Motion`]
/// passes gyro/accel straight through (raw i16; cross-device unit scaling is M3).
pub fn apply_rich(&mut self, rich: RichInput) {
match rich {
RichInput::Touchpad { active, x, y, .. } => {
self.press(btn::RPAD_TOUCH, active);
// Normalized 0..=65535 (centre 32768) → the pad's centred s16 range.
self.rpad_x = ((x as i32) - 32768) as i16;
self.rpad_y = ((y as i32) - 32768) as i16;
}
RichInput::Motion { gyro, accel, .. } => {
// The wire carries DualSense-convention units (what every client capture emits); the
// Deck's hid-steam report wants 16 LSB/°·s + 16384 LSB/g, so rescale here.
let (g, a) = super::steam_remap::motion_wire_to_deck(gyro, accel);
self.gyro = g;
self.accel = a;
}
RichInput::TouchpadEx {
surface,
touch,
click,
x,
y,
..
} => {
// Steam pads are natively signed (centre 0), so x/y map straight in. surface 1 =
// left pad, anything else (0 single / 2 right) = right pad.
if surface == 1 {
self.press(btn::LPAD_TOUCH, touch);
self.press(btn::LPAD_CLICK, click);
self.lpad_x = x;
self.lpad_y = y;
} else {
self.press(btn::RPAD_TOUCH, touch);
self.press(btn::RPAD_CLICK, click);
self.rpad_x = x;
self.rpad_y = y;
}
}
}
}
}
/// Serialize the full Deck input report (`ID_CONTROLLER_DECK_STATE`) into the 64-byte unnumbered
/// frame `hid-steam` parses. Pure + byte-exact against `steam_do_deck_input_event`; the report-id
/// constant is `data[0]=0x01` (NOT a HID report id — this report is unnumbered).
pub fn serialize_deck_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq: u32) {
r.fill(0);
r[0] = 0x01;
r[1] = 0x00;
r[2] = ID_CONTROLLER_DECK_STATE;
r[3] = 0x3C; // payload length; the kernel ignores it
r[4..8].copy_from_slice(&seq.to_le_bytes());
r[8..16].copy_from_slice(&st.buttons.to_le_bytes()); // bytes 8..16 (12+15 stay 0)
r[16..18].copy_from_slice(&st.lpad_x.to_le_bytes());
r[18..20].copy_from_slice(&st.lpad_y.to_le_bytes());
r[20..22].copy_from_slice(&st.rpad_x.to_le_bytes());
r[22..24].copy_from_slice(&st.rpad_y.to_le_bytes());
r[24..26].copy_from_slice(&st.accel[0].to_le_bytes()); // accel X → IMU ABS_X
r[26..28].copy_from_slice(&st.accel[1].to_le_bytes()); // accel Y → IMU ABS_Z (kernel negates)
r[28..30].copy_from_slice(&st.accel[2].to_le_bytes()); // accel Z → IMU ABS_Y
r[30..32].copy_from_slice(&st.gyro[0].to_le_bytes()); // gyro X → IMU ABS_RX
r[32..34].copy_from_slice(&st.gyro[1].to_le_bytes()); // gyro Y → IMU ABS_RZ (kernel negates)
r[34..36].copy_from_slice(&st.gyro[2].to_le_bytes()); // gyro Z → IMU ABS_RY
// 36..44 quaternion — left 0 (optional; the kernel does not surface it)
r[44..46].copy_from_slice(&st.lt.to_le_bytes()); // left trigger → ABS_HAT2Y
r[46..48].copy_from_slice(&st.rt.to_le_bytes()); // right trigger → ABS_HAT2X
r[48..50].copy_from_slice(&st.lx.to_le_bytes()); // left joystick X → ABS_X
r[50..52].copy_from_slice(&st.ly.to_le_bytes()); // left joystick Y → ABS_Y (kernel negates)
r[52..54].copy_from_slice(&st.rx.to_le_bytes()); // right joystick X → ABS_RX
r[54..56].copy_from_slice(&st.ry.to_le_bytes()); // right joystick Y → ABS_RY (kernel negates)
r[56..58].copy_from_slice(&st.lpad_pressure.to_le_bytes());
r[58..60].copy_from_slice(&st.rpad_pressure.to_le_bytes());
}
/// 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
/// the wire is `[0x00, 0xAE, len, 0x01, ascii…]`; the kernel then validates `reply[0]==0xAE`,
/// `1<=reply[1]<=21`, `reply[2]==0x01`. Non-fatal (a bad reply → the `"XXXXXXXXXX"` fallback).
pub fn serial_reply(serial: &str) -> [u8; STEAM_REPORT_LEN] {
let mut buf = [0u8; STEAM_REPORT_LEN];
let bytes = serial.as_bytes();
let len = bytes.len().clamp(1, 21);
buf[0] = 0x00; // report id 0 — stripped by steam_recv_report
buf[1] = ID_GET_STRING_ATTRIBUTE;
buf[2] = len as u8;
buf[3] = ATTRIB_STR_UNIT_SERIAL;
buf[4..4 + len].copy_from_slice(&bytes[..len]);
buf
}
/// One service pass's extracted feedback. Rumble rides the universal 0xCA plane (so any client
/// feels it); the classic SC's trackpad-pulse haptics (`0x8F`) are a later, model-specific add.
#[derive(Default, Debug, PartialEq, Eq)]
pub struct SteamFeedback {
/// `(low, high)` motor levels (left/strong, right/weak), if a rumble report carried them.
pub rumble: Option<(u16, u16)>,
}
/// Parse a feature/output report the kernel wrote to our device. The Steam feedback path is a
/// FEATURE `SET_REPORT` whose wire data is `[0x00 report-id, cmd, len, …]`; `cmd == 0xEB`
/// (`steam_haptic_rumble`) carries `[…, 0, intensity(2), left_speed(2), right_speed(2), gains(2)]`.
/// We surface `(left_speed, right_speed)` as `(low, high)` for the 0xCA rumble plane.
pub fn parse_steam_output(data: &[u8]) -> SteamFeedback {
let mut fb = SteamFeedback::default();
// data[0] is the stripped report-id byte (0); the command id follows.
if data.len() >= 10 && data[1] == ID_TRIGGER_RUMBLE_CMD {
let le = |o: usize| u16::from_le_bytes([data[o], data[o + 1]]);
let left = le(6); // left_speed (report[5..7]) → low / strong motor
let right = le(8); // right_speed (report[7..9]) → high / weak motor
fb.rumble = Some((left, right));
}
fb
}
// ===========================================================================================
// Real-USB Deck device contract (the gadget + usbip transports present a *real* 3-interface USB
// Deck so Steam Input promotes it; the UHID path above uses the minimal [`STEAMDECK_RDESC`]).
//
// These descriptors are captured verbatim from a physical Steam Deck (28DE:1205): mouse =
// interface 0, keyboard = interface 1, **controller = interface 2** (the interface number Steam's
// own driver filters on — the reason a UHID Deck, `Interface: -1`, is never promoted). The
// `0x83`/`0xAE` feature contract is what stops Steam re-probing (the gamepad-evdev churn). Shared
// by [`super::super::steam_gadget`] (raw_gadget) and [`super::super::steam_usbip`] (usbip/vhci).
// ===========================================================================================
/// Captured Deck **mouse** report descriptor (interface 0, EP 0x81).
#[rustfmt::skip]
pub const RDESC_DECK_MOUSE: &[u8] = &[
0x05,0x01,0x09,0x02,0xa1,0x01,0x09,0x01,0xa1,0x00,0x05,0x09,0x19,0x01,0x29,0x02,
0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x02,0x81,0x02,0x75,0x06,0x95,0x01,0x81,0x01,
0x05,0x01,0x09,0x30,0x09,0x31,0x15,0x81,0x25,0x7f,0x75,0x08,0x95,0x02,0x81,0x06,
0x95,0x01,0x09,0x38,0x81,0x06,0x05,0x0c,0x0a,0x38,0x02,0x95,0x01,0x81,0x06,0xc0,0xc0];
/// Captured Deck **keyboard** (boot) report descriptor (interface 1, EP 0x82).
#[rustfmt::skip]
pub const RDESC_DECK_KBD: &[u8] = &[
0x05,0x01,0x09,0x06,0xa1,0x01,0x05,0x07,0x19,0xe0,0x29,0xe7,0x15,0x00,0x25,0x01,
0x75,0x01,0x95,0x08,0x81,0x02,0x81,0x01,0x19,0x00,0x29,0x65,0x15,0x00,0x25,0x65,
0x75,0x08,0x95,0x06,0x81,0x00,0xc0];
/// Captured Deck **controller** report descriptor (interface 2, EP 0x83; Usage Page `0xFFFF`,
/// `bCountryCode 33`). The vendor-defined report the `hid-steam` driver binds.
#[rustfmt::skip]
pub const RDESC_DECK_CTRL: &[u8] = &[
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];
/// Per-instance Deck unit id stamped into the `0x83` GET_ATTRIBUTES device-id attrs (`0x0a`/`0x04`)
/// so a virtual Deck never collides with a real one or another instance. `"PF"` high word + index.
pub fn deck_unit_id(index: u8) -> u32 {
0x5046_0000 | index as u32
}
/// A Steam-accepted alphanumeric unit serial (a real Deck's is e.g. `"FVZZ4200469B"`; Steam rejects
/// a too-short/oddly-formatted one as "Invalid or missing unit serial number" and substitutes its
/// own — benign, but we present a clean 12-char one). Derived from [`deck_unit_id`] so the `0xAE`
/// serial reply and the `0x83` unit-id attrs stay consistent.
pub fn deck_serial(index: u8) -> String {
format!("PFDK{:08X}", deck_unit_id(index))
}
/// The neutral 64-byte Deck input report (header only, all controls released) — the report the
/// real-USB transports stream until the first [`serialize_deck_state`] call updates it.
pub fn neutral_deck_report() -> [u8; STEAM_REPORT_LEN] {
let mut r = [0u8; STEAM_REPORT_LEN];
r[0] = 0x01;
r[2] = ID_CONTROLLER_DECK_STATE;
r[3] = 0x3C;
r
}
/// Build the HID feature GET_REPORT reply for the host's last SET_REPORT command, for the *real-USB*
/// Deck (gadget + usbip). Steam's `GetControllerInfo` reads the `0x83` attributes + the `0xAE`
/// serial; **serving the real `0x83` blob is what stops Steam re-probing** (the gamepad-evdev churn).
/// The 9-attribute `0x83` layout + the `0xAE` string format were captured from a physical Deck via
/// hidraw. `unit_id` (see [`deck_unit_id`]) stamps a per-instance value into the device-id attrs.
///
/// Note this is the raw 64-byte EP0 feature payload (command id first, no report-id prefix) — the USB
/// control path, distinct from [`serial_reply`] which carries the UHID report-id byte the kernel
/// strips.
pub fn feature_reply(last_set: &[u8], serial: &str, unit_id: u32) -> [u8; STEAM_REPORT_LEN] {
let cmd = last_set.first().copied().unwrap_or(ID_GET_STRING_ATTRIBUTE);
let mut r = [0u8; STEAM_REPORT_LEN];
match cmd {
ID_GET_ATTRIBUTES_VALUES => {
// GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9× (attr-id, value u32-LE)].
r[0] = ID_GET_ATTRIBUTES_VALUES;
r[1] = 0x2d;
let attrs: [(u8, u32); 9] = [
(0x01, 0x1205), // product id
(0x02, 0),
(0x0a, unit_id), // unit serial number (per-instance)
(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;
}
}
ID_GET_STRING_ATTRIBUTE => {
// GET_STRING_ATTRIBUTE: [0xAE, len, attr, ascii…]. The kernel validates the serial (attr
// 0x01) wants reply[2]==0x01 and 1<=len<=21; for other attrs we echo the requested id.
let attr = last_set.get(2).copied().unwrap_or(ATTRIB_STR_UNIT_SERIAL);
let b = serial.as_bytes();
let len = b.len().clamp(1, 20);
r[0] = ID_GET_STRING_ATTRIBUTE;
r[1] = len as u8;
r[2] = attr;
r[3..3 + len].copy_from_slice(&b[..len]);
}
_ => {
// Settings read-back (e.g. 0x87): echo the host's last command + data.
let n = last_set.len().min(STEAM_REPORT_LEN);
r[..n].copy_from_slice(&last_set[..n]);
}
}
r
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn descriptor_declares_input_and_feature_reports() {
assert!(
STEAMDECK_RDESC.contains(&0xB1),
"missing Feature main item — steam_is_valve_interface() would fail"
);
assert!(STEAMDECK_RDESC.contains(&0x81), "missing Input main item");
assert_eq!(
*STEAMDECK_RDESC.last().unwrap(),
0xC0,
"unterminated collection"
);
}
/// Every analog field lands at the exact offset `steam_do_deck_input_event` reads, the header is
/// what `steam_raw_event` requires, and the buttons pack into bytes 8..16 (12+15 zero). A
/// one-byte slip here turns the whole controller into noise.
#[test]
fn serialize_is_byte_exact() {
let mut st = SteamState::neutral();
st.buttons = btn::A | btn::L4 | btn::R5 | btn::QAM;
st.lx = 0x1122;
st.ly = 0x3344;
st.rx = 0x5566;
st.ry = 0x778;
st.lt = 0xABCD;
st.rt = 0xEF01;
st.lpad_x = 0x0A0B;
st.lpad_y = 0x0C0D;
st.rpad_x = 0x0E0F;
st.rpad_y = 0x1011;
st.accel = [0x0102, 0x0304, 0x0506];
st.gyro = [0x0708, 0x090A, 0x0B0C];
st.lpad_pressure = 0x1314;
st.rpad_pressure = 0x1516;
let mut r = [0u8; STEAM_REPORT_LEN];
serialize_deck_state(&mut r, &st, 0xAABB_CCDD);
assert_eq!(&r[0..4], &[0x01, 0x00, 0x09, 0x3C]);
assert_eq!(&r[4..8], &[0xDD, 0xCC, 0xBB, 0xAA]); // seq LE
// buttons: A=bit7 (byte8), L4=bit41 (byte13.1), R5=bit16 (byte10.0), QAM=bit50 (byte14.2).
assert_eq!(r[8], 0x80); // A
assert_eq!(r[10], 0x01); // R5
assert_eq!(r[12], 0x00); // unused button byte
assert_eq!(r[13], 0x02); // L4 (bit 1)
assert_eq!(r[14], 0x04); // QAM (bit 2)
assert_eq!(r[15], 0x00); // unused button byte
assert_eq!(&r[16..18], &0x0A0Bi16.to_le_bytes()); // lpad X
assert_eq!(&r[20..22], &0x0E0Fi16.to_le_bytes()); // rpad X
assert_eq!(&r[24..26], &0x0102i16.to_le_bytes()); // accel X
assert_eq!(&r[26..28], &0x0304i16.to_le_bytes()); // accel Y
assert_eq!(&r[28..30], &0x0506i16.to_le_bytes()); // accel Z
assert_eq!(&r[30..32], &0x0708i16.to_le_bytes()); // gyro X
assert_eq!(&r[44..46], &0xABCDu16.to_le_bytes()); // left trigger
assert_eq!(&r[46..48], &0xEF01u16.to_le_bytes()); // right trigger
assert_eq!(&r[48..50], &0x1122i16.to_le_bytes()); // left joy X
assert_eq!(&r[50..52], &0x3344i16.to_le_bytes()); // left joy Y
assert_eq!(&r[52..54], &0x5566i16.to_le_bytes()); // right joy X
assert_eq!(&r[56..58], &0x1314u16.to_le_bytes()); // left pad pressure
assert_eq!(&r[58..60], &0x1516u16.to_le_bytes()); // right pad pressure
}
/// `from_gamepad` sets the right Deck bits + scales triggers, and a touched flag is merged when
/// a trackpad contact arrives via `apply_rich`.
#[test]
fn from_gamepad_and_rich_mapping() {
let s = SteamState::from_gamepad(
gs::BTN_A | gs::BTN_START | gs::BTN_GUIDE | gs::BTN_LB,
1000,
-2000,
0,
0,
255,
0,
);
assert_ne!(s.buttons & btn::A, 0);
assert_ne!(s.buttons & btn::MENU, 0);
assert_ne!(s.buttons & btn::STEAM, 0);
assert_ne!(s.buttons & btn::LB, 0);
assert_ne!(s.buttons & btn::LT_FULL, 0); // lt=255 → full-pull bit
assert_eq!(s.lt, 255 * 128);
assert_eq!(s.lx, 1000);
assert_eq!(s.ly, -2000);
let mut s = SteamState::neutral();
s.apply_rich(RichInput::Touchpad {
pad: 0,
finger: 0,
active: true,
x: 65535,
y: 0,
});
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0);
assert_eq!(s.rpad_x, 32767); // 65535-32768
assert_eq!(s.rpad_y, -32768); // 0-32768
// Motion is rescaled from the wire (DualSense) convention into Deck units (gyro ×16/20,
// accel ×16384/10000) — see steam_remap::motion_wire_to_deck.
s.apply_rich(RichInput::Motion {
pad: 0,
gyro: [1000, -2000, 0],
accel: [10000, -5000, 0],
});
assert_eq!(s.gyro, [800, -1600, 0]);
assert_eq!(s.accel, [16384, -8192, 0]);
}
/// M3: the wire back-button bits map to the four Deck grips + QAM, and `TouchpadEx` routes the
/// left / right surfaces to the matching pad (signed coords pass straight through).
#[test]
fn back_buttons_and_dual_trackpad_mapping() {
let s = SteamState::from_gamepad(
gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4 | gs::BTN_MISC1,
0,
0,
0,
0,
0,
0,
);
assert_ne!(s.buttons & btn::R4, 0); // PADDLE1 = R4
assert_ne!(s.buttons & btn::L4, 0); // PADDLE2 = L4
assert_ne!(s.buttons & btn::R5, 0); // PADDLE3 = R5
assert_ne!(s.buttons & btn::L5, 0); // PADDLE4 = L5
assert_ne!(s.buttons & btn::QAM, 0); // MISC1 = QAM
let mut s = SteamState::neutral();
s.apply_rich(RichInput::TouchpadEx {
pad: 0,
surface: 1,
finger: 0,
touch: true,
click: true,
x: -5000,
y: 6000,
pressure: 100,
});
assert_ne!(s.buttons & btn::LPAD_TOUCH, 0);
assert_ne!(s.buttons & btn::LPAD_CLICK, 0);
assert_eq!((s.lpad_x, s.lpad_y), (-5000, 6000));
s.apply_rich(RichInput::TouchpadEx {
pad: 0,
surface: 2,
finger: 0,
touch: true,
click: false,
x: 7000,
y: -8000,
pressure: 0,
});
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0);
assert_eq!((s.rpad_x, s.rpad_y), (7000, -8000));
}
/// The serial reply carries the leading report-id byte the kernel strips, so the *stripped*
/// view (`reply[1..]`) is what `steam_get_serial` validates: `[0xAE, len, 0x01, ascii…]`.
#[test]
fn serial_reply_has_stripped_prefix() {
let r = serial_reply("PUNKTFUNK01");
assert_eq!(r[0], 0x00); // report id, stripped by steam_recv_report
assert_eq!(r[1], ID_GET_STRING_ATTRIBUTE); // becomes reply[0] after strip
assert!((1..=21).contains(&r[2]));
assert_eq!(r[3], ATTRIB_STR_UNIT_SERIAL);
assert_eq!(&r[4..4 + r[2] as usize], b"PUNKTFUNK01");
}
/// A `0xEB` rumble feature report parses to `(left_speed, right_speed)`; other commands don't.
#[test]
fn parse_rumble_feedback() {
// [report-id 0, 0xEB, len 9, 0, intensity(2), left(2), right(2), gains(2)]
let mut d = vec![0u8; 12];
d[1] = ID_TRIGGER_RUMBLE_CMD;
d[2] = 9;
d[6..8].copy_from_slice(&0x8000u16.to_le_bytes()); // left_speed
d[8..10].copy_from_slice(&0x4000u16.to_le_bytes()); // right_speed
assert_eq!(parse_steam_output(&d).rumble, Some((0x8000, 0x4000)));
let mut d = vec![0u8; 12];
d[1] = ID_SET_SETTINGS_VALUES; // a settings write — no rumble
assert_eq!(parse_steam_output(&d).rumble, None);
}
/// The shared real-USB Deck feature contract (gadget + usbip): the `0x83` GET_ATTRIBUTES reply
/// carries the 9-attribute blob with the per-instance unit id, and the `0xAE` reply carries the
/// Steam-accepted serial — both keyed off the host's last SET_REPORT command. A slip here is the
/// gamepad-evdev churn (Steam re-probing).
#[test]
fn deck_feature_reply_contract() {
let serial = deck_serial(0);
let unit_id = deck_unit_id(0);
assert_eq!(serial, "PFDK50460000"); // 12-char alphanumeric, derived from the unit id
assert_eq!(serial.len(), 12);
// 0x83 GET_ATTRIBUTES_VALUES: header + (0x0a, unit_id) at the 3rd attribute slot.
let r = feature_reply(&[ID_GET_ATTRIBUTES_VALUES], &serial, unit_id);
assert_eq!(r[0], ID_GET_ATTRIBUTES_VALUES);
assert_eq!(r[1], 0x2d);
assert_eq!(r[12], 0x0a); // 3rd attr id (slots at 2,7,12,…)
assert_eq!(
u32::from_le_bytes([r[13], r[14], r[15], r[16]]),
unit_id,
"unit serial attribute must carry the per-instance unit id"
);
// 0xAE GET_STRING_ATTRIBUTE: [0xAE, len, attr(0x01), ascii serial…].
let r = feature_reply(
&[ID_GET_STRING_ATTRIBUTE, 0, ATTRIB_STR_UNIT_SERIAL],
&serial,
unit_id,
);
assert_eq!(r[0], ID_GET_STRING_ATTRIBUTE);
assert_eq!(r[1] as usize, serial.len());
assert_eq!(r[2], ATTRIB_STR_UNIT_SERIAL);
assert_eq!(&r[3..3 + serial.len()], serial.as_bytes());
// Distinct pad indices get distinct unit ids + serials (no collision between virtual Decks).
assert_ne!(deck_unit_id(0), deck_unit_id(1));
assert_ne!(deck_serial(0), deck_serial(1));
}
}
@@ -0,0 +1,149 @@
//! Pure fallback-remap policy for the Steam Controller / Steam Deck rich inputs when the resolved
//! host backend is **not** the virtual `hid-steam` device (DualSense / DualShock 4 / Xbox), so a
//! client's Steam-only inputs aren't silently dropped — plus the cross-device motion rescale the
//! Deck backend itself needs.
//!
//! Driven by the host's `PUNKTFUNK_STEAM_REMAP` env (`key=value`, `,`/`;`-separated, e.g.
//! `paddles=stickclicks`). No I/O beyond [`RemapConfig::from_env`]; everything else is pure +
//! unit-testable. The uinput Xbox pad already exposes the back grips as Elite paddles
//! (`BTN_TRIGGER_HAPPY5-8`), so only the slot-less DualSense / DS4 backends fold them.
use punktfunk_core::input::gamepad as gs;
/// Where the four Steam back grips go on a backend with no native back-button HID slot.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PaddleFallback {
/// Drop them — the back buttons are simply absent on this pad. The honest default: don't fire
/// buttons the user didn't ask for. Set the env to map them instead.
#[default]
Drop,
/// L4/L5 → left-stick click, R4/R5 → right-stick click.
StickClicks,
/// L4/L5 → left bumper, R4/R5 → right bumper.
Shoulders,
}
/// Fallback-remap knobs parsed from `PUNKTFUNK_STEAM_REMAP`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct RemapConfig {
pub paddles: PaddleFallback,
}
impl RemapConfig {
/// Parse the host's `PUNKTFUNK_STEAM_REMAP` env (absent / unrecognized → defaults).
pub fn from_env() -> RemapConfig {
std::env::var("PUNKTFUNK_STEAM_REMAP")
.map(|s| RemapConfig::parse(&s))
.unwrap_or_default()
}
/// Pure parse of the `key=value[,key=value…]` string (the testable core of [`from_env`]).
pub fn parse(s: &str) -> RemapConfig {
let mut cfg = RemapConfig::default();
for kv in s.split([',', ';']) {
let mut it = kv.splitn(2, '=');
if let (Some(k), Some(v)) = (it.next(), it.next()) {
if k.trim().eq_ignore_ascii_case("paddles") {
cfg.paddles = match v.trim().to_ascii_lowercase().as_str() {
"stickclicks" | "l3r3" | "sticks" => PaddleFallback::StickClicks,
"shoulders" | "lbrb" | "bumpers" => PaddleFallback::Shoulders,
_ => PaddleFallback::Drop,
};
}
}
}
cfg
}
}
/// Fold the wire back-grip bits (`BTN_PADDLE1..4`) into standard buttons per `policy` for a pad with
/// no native back-button slot, clearing the paddle bits. Pure. PADDLE1/2/3/4 = R4/L4/R5/L5.
pub fn fold_paddles(mut buttons: u32, policy: PaddleFallback) -> u32 {
let left = buttons & (gs::BTN_PADDLE2 | gs::BTN_PADDLE4) != 0; // L4 | L5
let right = buttons & (gs::BTN_PADDLE1 | gs::BTN_PADDLE3) != 0; // R4 | R5
buttons &= !(gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4);
let (lbit, rbit) = match policy {
PaddleFallback::Drop => return buttons,
PaddleFallback::StickClicks => (gs::BTN_LS_CLICK, gs::BTN_RS_CLICK),
PaddleFallback::Shoulders => (gs::BTN_LB, gs::BTN_RB),
};
if left {
buttons |= lbit;
}
if right {
buttons |= rbit;
}
buttons
}
// Motion rescale. The wire uses the DualSense convention (20 LSB/°·s gyro, 10000 LSB/g accel — the
// scale every client capture applies). The Steam Deck's `hid-steam` report wants 16 LSB/°·s and
// 16384 LSB/g, so the Deck backend rescales; the DualSense / DS4 backends consume the wire 1:1.
const GYRO_NUM: i32 = 16;
const GYRO_DEN: i32 = 20;
const ACCEL_NUM: i32 = 16384;
const ACCEL_DEN: i32 = 10000;
fn scale(v: i16, num: i32, den: i32) -> i16 {
((v as i32 * num) / den).clamp(i16::MIN as i32, i16::MAX as i32) as i16
}
/// Rescale a wire (DualSense-convention) motion sample into the Steam Deck's `hid-steam` units.
pub fn motion_wire_to_deck(gyro: [i16; 3], accel: [i16; 3]) -> ([i16; 3], [i16; 3]) {
(
gyro.map(|g| scale(g, GYRO_NUM, GYRO_DEN)),
accel.map(|a| scale(a, ACCEL_NUM, ACCEL_DEN)),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_paddle_policy() {
assert_eq!(RemapConfig::parse("").paddles, PaddleFallback::Drop);
assert_eq!(
RemapConfig::parse("paddles=stickclicks").paddles,
PaddleFallback::StickClicks
);
assert_eq!(
RemapConfig::parse("foo=bar; paddles = Shoulders").paddles,
PaddleFallback::Shoulders
);
assert_eq!(
RemapConfig::parse("paddles=nonsense").paddles,
PaddleFallback::Drop
);
}
#[test]
fn fold_paddles_maps_and_clears() {
// All four grips set + a real A button.
let b = gs::BTN_A | gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4;
// Drop: paddle bits cleared, A preserved, nothing added.
assert_eq!(fold_paddles(b, PaddleFallback::Drop), gs::BTN_A);
// StickClicks: left grips → L3, right grips → R3.
assert_eq!(
fold_paddles(b, PaddleFallback::StickClicks),
gs::BTN_A | gs::BTN_LS_CLICK | gs::BTN_RS_CLICK
);
// Only a left grip (L4 = PADDLE2) → only the left bumper under Shoulders.
assert_eq!(
fold_paddles(gs::BTN_PADDLE2, PaddleFallback::Shoulders),
gs::BTN_LB
);
}
#[test]
fn motion_rescale_to_deck_units() {
// gyro × 16/20 = 0.8; accel × 16384/10000 = 1.6384.
let (g, a) = motion_wire_to_deck([1000, -2000, 0], [10000, -5000, 0]);
assert_eq!(g, [800, -1600, 0]);
assert_eq!(a, [16384, -8192, 0]);
// Saturates rather than wraps.
let (_, a) = motion_wire_to_deck([0; 3], [32767, i16::MIN, 0]);
assert_eq!(a[0], i16::MAX);
assert_eq!(a[1], i16::MIN);
}
}
@@ -385,7 +385,9 @@ impl DualSenseWindowsManager {
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad. /// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad.
pub fn apply_rich(&mut self, rich: RichInput) { pub fn apply_rich(&mut self, rich: RichInput) {
let idx = match rich { let idx = match rich {
RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize, RichInput::Touchpad { pad, .. }
| RichInput::Motion { pad, .. }
| RichInput::TouchpadEx { pad, .. } => pad as usize,
}; };
if idx >= MAX_PADS || self.pads[idx].is_none() { if idx >= MAX_PADS || self.pads[idx].is_none() {
return; return;
@@ -409,6 +411,26 @@ impl DualSenseWindowsManager {
self.state[idx].gyro = gyro; self.state[idx].gyro = gyro;
self.state[idx].accel = accel; self.state[idx].accel = accel;
} }
RichInput::TouchpadEx {
surface,
finger,
touch,
x,
y,
..
} => {
// A Steam right/single pad maps onto the one DualSense touchpad (signed centre-0 →
// 0..=65535); surface 1 (the Steam left pad) has no DualSense equivalent.
if surface != 1 {
let slot = (finger as usize).min(1);
let n = |v: i16| ((v as i32) + 32768) as u32;
let t = &mut self.state[idx].touch[slot];
t.active = touch;
t.id = slot as u8;
t.x = (n(x) * (DS_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
t.y = (n(y) * (DS_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
}
}
} }
self.write(idx); self.write(idx);
} }
@@ -186,7 +186,9 @@ impl DualShock4WindowsManager {
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad. /// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad.
pub fn apply_rich(&mut self, rich: RichInput) { pub fn apply_rich(&mut self, rich: RichInput) {
let idx = match rich { let idx = match rich {
RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize, RichInput::Touchpad { pad, .. }
| RichInput::Motion { pad, .. }
| RichInput::TouchpadEx { pad, .. } => pad as usize,
}; };
if idx >= MAX_PADS || self.pads[idx].is_none() { if idx >= MAX_PADS || self.pads[idx].is_none() {
return; return;
@@ -210,6 +212,26 @@ impl DualShock4WindowsManager {
self.state[idx].gyro = gyro; self.state[idx].gyro = gyro;
self.state[idx].accel = accel; 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); self.write(idx);
} }
@@ -21,10 +21,18 @@ use windows::Win32::System::Memory::{
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE, MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
}; };
/// A named, anonymous (pagefile-backed) shared section + its mapped read/write view, created with the /// A named, anonymous (pagefile-backed) shared section + its mapped read/write view. RAII: drop unmaps
/// permissive `D:(A;;GA;;;WD)` SDDL the restricted-token driver needs to open it. RAII: drop unmaps the /// the view, then the [`OwnedHandle`] closes the section handle (in that order). Replaces the three
/// view, then the [`OwnedHandle`] closes the section handle (in that order). Replaces the three backends' /// backends' hand-duplicated `CreateFileMappingW` + `MapViewOfFile` + manual `Drop`.
/// hand-duplicated `CreateFileMappingW` + `MapViewOfFile` + manual `Drop`. ///
/// SDDL `D:(A;;GA;;;SY)(A;;GA;;;LS)`: GENERIC_ALL to **SYSTEM** (the host creates the section and
/// writes the live HID input report into it) and **LocalService** (the account the UMDF driver's
/// WUDFHost runs under, which reads it). The old SDDL granted **Everyone** (`WD`) — on the (mistaken)
/// assumption the driver needed a restricted token's broad access — letting any local user
/// `OpenFileMapping` the section to inject controller input or tamper the trusted channel
/// (security-review 2026-06-28 #5). Verified on the RTX box (2026-06-29): the WUDFHost token is
/// `S-1-5-19` (LocalService), SYSTEM integrity, with **zero restricted SIDs** — so scoping to SY+LS is
/// sufficient for the driver and excludes normal (medium-IL, non-service) user processes.
pub(super) struct Shm { pub(super) struct Shm {
/// Owns the section handle (closed on drop). Held only for ownership — never read after construction. /// Owns the section handle (closed on drop). Held only for ownership — never read after construction.
_handle: OwnedHandle, _handle: OwnedHandle,
@@ -40,7 +48,7 @@ impl Shm {
// exit — acceptable for a host-lifetime object). // exit — acceptable for a host-lifetime object).
unsafe { unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW( ConvertStringSecurityDescriptorToSecurityDescriptorW(
w!("D:(A;;GA;;;WD)"), w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"),
SDDL_REVISION_1, SDDL_REVISION_1,
&mut psd, &mut psd,
None, None,
+23 -4
View File
@@ -394,6 +394,12 @@ struct ArmNativePairing {
/// Window length in seconds (default 120; clamped to 15600). /// Window length in seconds (default 120; clamped to 15600).
#[schema(example = 120)] #[schema(example = 120)]
ttl_secs: Option<u32>, ttl_secs: Option<u32>,
/// Optional: bind the window to ONE device fingerprint (hex SHA-256, e.g. from a pending knock).
/// When set, only a pairing attempt from that fingerprint consumes the window — so an unpaired
/// LAN peer can neither pair nor burn a window armed for a specific device (security-review #9).
/// Omit for an unbound window (any device may use the PIN — trusted-LAN only).
#[schema(example = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")]
fingerprint: Option<String>,
} }
/// A paired native (punktfunk/1) client. /// A paired native (punktfunk/1) client.
@@ -879,8 +885,21 @@ async fn arm_native_pairing(
); );
}; };
let ttl = req.ttl_secs.unwrap_or(120).clamp(15, 600); let ttl = req.ttl_secs.unwrap_or(120).clamp(15, 600);
let _pin = np.arm(std::time::Duration::from_secs(ttl as u64)); // A bound window (operator selected a specific device) is DoS-proof: only that fingerprint can
tracing::info!(ttl_secs = ttl, "management API: native pairing armed"); // consume it (#9). An unbound window (no fingerprint) keeps the legacy any-device behavior.
let bound = req
.fingerprint
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_ascii_lowercase());
let bound_to_device = bound.is_some();
let _pin = np.arm_for(std::time::Duration::from_secs(ttl as u64), bound);
tracing::info!(
ttl_secs = ttl,
bound_to_device,
"management API: native pairing armed"
);
Json(native_status(&st)).into_response() Json(native_status(&st)).into_response()
} }
@@ -1975,8 +1994,8 @@ mod tests {
assert_eq!(b.as_array().unwrap().len(), 0); assert_eq!(b.as_array().unwrap().len(), 0);
// Two devices knock (what the QUIC gate records); they appear in the list. // Two devices knock (what the QUIC gate records); they appear in the list.
np.note_pending("Enrico's MacBook", "aa11"); np.note_pending("Enrico's MacBook", "aa11", None);
np.note_pending("device bb22cc33", "bb22"); np.note_pending("device bb22cc33", "bb22", None);
let (_, b) = send(&app, get_req("/api/v1/native/pending")).await; let (_, b) = send(&app, get_req("/api/v1/native/pending")).await;
assert_eq!(b.as_array().unwrap().len(), 2); assert_eq!(b.as_array().unwrap().len(), 2);
assert_eq!(b[0]["name"], "Enrico's MacBook"); assert_eq!(b[0]["name"], "Enrico's MacBook");
+204 -21
View File
@@ -8,6 +8,7 @@
//! armed on demand for a short window — rather than accepting one. //! armed on demand for a short window — rather than accepting one.
use anyhow::Result; use anyhow::Result;
use std::net::IpAddr;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Mutex; use std::sync::Mutex;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -42,10 +43,29 @@ struct PairedState {
/// The current arming window. `pin == None` ⇒ disarmed. `expires_at == None` ⇒ armed with no /// The current arming window. `pin == None` ⇒ disarmed. `expires_at == None` ⇒ armed with no
/// expiry (the CLI `--allow-pairing` flag); `Some(t)` ⇒ a web-armed window that auto-disarms. /// expiry (the CLI `--allow-pairing` flag); `Some(t)` ⇒ a web-armed window that auto-disarms.
///
/// `bound_fp == Some(fp)` ⇒ the window is **bound to one operator-selected device fingerprint**:
/// only a pairing attempt from that fingerprint may consume it (security-review 2026-06-28 #9). This
/// closes the window-burn DoS — an unpaired LAN peer cannot consume a window armed for a specific
/// device, because the QUIC client-auth proves cert possession (it can't forge the bound fingerprint).
/// `None` ⇒ unbound (the CLI flag / a console "arm open"): any well-formed attempt consumes it (the
/// legacy behavior, retaining the window-burn DoS — acceptable only on a trusted LAN).
#[derive(Default)] #[derive(Default)]
struct Armed { struct Armed {
pin: Option<String>, pin: Option<String>,
expires_at: Option<Instant>, expires_at: Option<Instant>,
bound_fp: Option<String>,
}
/// The result of resolving the armed PIN for a specific client fingerprint ([`NativePairing::pin_for_attempt`]).
pub enum PinAttempt {
/// No window is armed (disarmed/expired) — reject; do not run the ceremony.
Disarmed,
/// A window IS armed but **bound to a different fingerprint** — reject WITHOUT consuming it, so
/// an unrelated (attacker) fingerprint can't burn the operator's armed window (#9).
BoundToOther,
/// Proceed: the PIN to run the ceremony with (the window is unbound, or bound to this fingerprint).
Pin(String),
} }
/// An unpaired (but identified) device that knocked on a pairing-required host — held for /// An unpaired (but identified) device that knocked on a pairing-required host — held for
@@ -57,6 +77,13 @@ struct Pending {
name: String, name: String,
fp_hex: String, fp_hex: String,
requested_at: Instant, requested_at: Instant,
/// QUIC-validated source address of the knock — used for the per-source cap (#13), so one host
/// can't fill the queue. `None` if unknown (e.g. tests / a caller that doesn't supply it).
src_ip: Option<IpAddr>,
/// True while a connection is held open in [`NativePairing::wait_for_decision`] for this knock.
/// A live parked knock is a genuine device waiting for the operator — eviction skips it unless
/// every entry is parked, so a cert-rotating flood can't evict the device being onboarded (#13).
parked: bool,
} }
#[derive(Default)] #[derive(Default)]
@@ -94,6 +121,10 @@ pub enum PairingDecision {
const PENDING_TTL: Duration = Duration::from_secs(10 * 60); const PENDING_TTL: Duration = Duration::from_secs(10 * 60);
/// Cap on the pending list — a LAN scanner must not grow it unboundedly. Oldest entries drop. /// Cap on the pending list — a LAN scanner must not grow it unboundedly. Oldest entries drop.
const PENDING_CAP: usize = 32; const PENDING_CAP: usize = 32;
/// Max pending knocks one source IP may occupy, so a single host can't fill the whole queue and hide
/// / evict a genuine device's knock (security-review 2026-06-28 #13). The QUIC path is address-
/// validated, so the source IP isn't off-path spoofable; an attacker would need that many real hosts.
const MAX_PENDING_PER_IP: usize = 4;
/// Shared native-pairing state: the arming PIN window + the persistent trust store + the /// Shared native-pairing state: the arming PIN window + the persistent trust store + the
/// pending-approval queue. /// pending-approval queue.
@@ -209,6 +240,7 @@ impl NativePairing {
Armed { Armed {
pin: Some(fixed_pin.unwrap_or_else(random_pin)), pin: Some(fixed_pin.unwrap_or_else(random_pin)),
expires_at: None, expires_at: None,
bound_fp: None,
} }
} else { } else {
Armed::default() Armed::default()
@@ -221,16 +253,43 @@ impl NativePairing {
}) })
} }
/// Arm pairing with a fresh random PIN, valid for `ttl`. Returns the PIN to display. /// Arm pairing with a fresh random PIN, valid for `ttl`, **unbound** (any well-formed attempt
/// consumes it). Returns the PIN to display. Prefer [`Self::arm_for`] with a specific device
/// fingerprint on untrusted LANs — an unbound window is burnable by any peer (#9).
pub fn arm(&self, ttl: Duration) -> String { pub fn arm(&self, ttl: Duration) -> String {
self.arm_for(ttl, None)
}
/// Arm pairing with a fresh random PIN, valid for `ttl`. If `bound_fp` is `Some`, the window is
/// bound to that device fingerprint: only a pairing attempt from it consumes the window, so an
/// unrelated (attacker) fingerprint can neither pair nor burn the window (#9). Returns the PIN.
pub fn arm_for(&self, ttl: Duration, bound_fp: Option<String>) -> String {
let pin = random_pin(); let pin = random_pin();
*self.arm.lock().unwrap() = Armed { *self.arm.lock().unwrap() = Armed {
pin: Some(pin.clone()), pin: Some(pin.clone()),
expires_at: Some(Instant::now() + ttl), expires_at: Some(Instant::now() + ttl),
bound_fp,
}; };
pin pin
} }
/// Resolve the PIN for an attempt from `client_fp_hex`, honoring fingerprint binding (#9):
/// `Disarmed` if no window is armed; `BoundToOther` if a window is armed but bound to a different
/// fingerprint (the caller MUST reject without consuming it); else `Pin` to run the ceremony.
pub fn pin_for_attempt(&self, client_fp_hex: &str) -> PinAttempt {
let mut arm = self.arm.lock().unwrap();
Self::expire(&mut arm);
match &arm.pin {
None => PinAttempt::Disarmed,
Some(pin) => match &arm.bound_fp {
Some(bound) if !bound.eq_ignore_ascii_case(client_fp_hex) => {
PinAttempt::BoundToOther
}
_ => PinAttempt::Pin(pin.clone()),
},
}
}
/// Disarm pairing (no new ceremonies accepted). /// Disarm pairing (no new ceremonies accepted).
pub fn disarm(&self) { pub fn disarm(&self) {
*self.arm.lock().unwrap() = Armed::default(); *self.arm.lock().unwrap() = Armed::default();
@@ -342,11 +401,30 @@ impl NativePairing {
.retain(|p| p.requested_at.elapsed() < PENDING_TTL); .retain(|p| p.requested_at.elapsed() < PENDING_TTL);
} }
/// Record an unpaired device's knock for delegated approval. Re-knocks from the same /// Pick the entry to evict, optionally restricted to a single source IP: the least-recently-active
/// fingerprint refresh the existing entry in place (same id; a connect-retry loop must not spam /// **non-parked** entry (a live parked knock is a genuine device awaiting the operator — never
/// the list); a fresh fingerprint gets a new id, evicting the **least-recently-active** entry /// evict it under load); only if every candidate is parked does it fall back to the oldest of
/// past [`PENDING_CAP`]. The name is sanitized (untrusted; see [`sanitize_device_name`]). /// those (#13). Returns the index, or `None` if there's nothing to evict.
pub fn note_pending(&self, name: &str, fp_hex: &str) { fn evict_index(items: &[Pending], only_ip: Option<IpAddr>) -> Option<usize> {
let pick = |allow_parked: bool| {
items
.iter()
.enumerate()
.filter(|(_, p)| only_ip.is_none_or(|ip| p.src_ip == Some(ip)))
.filter(|(_, p)| allow_parked || !p.parked)
.min_by_key(|(_, p)| p.requested_at)
.map(|(i, _)| i)
};
pick(false).or_else(|| pick(true))
}
/// Record an unpaired device's knock for delegated approval. Re-knocks from the same fingerprint
/// refresh the existing entry in place (same id; a connect-retry loop must not spam the list). A
/// fresh fingerprint gets a new id; the queue is bounded two ways so a flood can't crowd out a
/// genuine knock (#13): a **per-source-IP cap** ([`MAX_PENDING_PER_IP`]) means one host can hold at
/// most a few slots, and the global [`PENDING_CAP`] evicts the least-recently-active **non-parked**
/// entry (never a live, held-open parked knock). The name is sanitized (untrusted).
pub fn note_pending(&self, name: &str, fp_hex: &str, src_ip: Option<IpAddr>) {
let name = sanitize_device_name(name, fp_hex); let name = sanitize_device_name(name, fp_hex);
let mut pending = self.pending.lock().unwrap(); let mut pending = self.pending.lock().unwrap();
Self::expire_pending(&mut pending); Self::expire_pending(&mut pending);
@@ -357,19 +435,31 @@ impl NativePairing {
{ {
p.requested_at = Instant::now(); p.requested_at = Instant::now();
p.name = name; p.name = name;
if p.src_ip.is_none() {
p.src_ip = src_ip;
}
return; return;
} }
if pending.items.len() >= PENDING_CAP { // Per-source-IP cap: a single host can't occupy more than MAX_PENDING_PER_IP slots — evict its
// Evict the least-recently-active entry. NOT index 0: the in-place refresh above means // own oldest entry first so it can't crowd out other devices' knocks (#13).
// Vec order no longer tracks recency, so pick the minimum `requested_at` explicitly. if let Some(ip) = src_ip {
if let Some(at) = pending if pending
.items .items
.iter() .iter()
.enumerate() .filter(|p| p.src_ip == Some(ip))
.min_by_key(|(_, p)| p.requested_at) .count()
.map(|(i, _)| i) >= MAX_PENDING_PER_IP
{ {
pending.items.remove(at); if let Some(i) = Self::evict_index(&pending.items, Some(ip)) {
pending.items.remove(i);
}
}
}
// Global cap: evict the least-recently-active non-parked entry (Vec order no longer tracks
// recency after in-place refreshes, so pick explicitly).
if pending.items.len() >= PENDING_CAP {
if let Some(i) = Self::evict_index(&pending.items, None) {
pending.items.remove(i);
} }
} }
let id = pending.next_id; let id = pending.next_id;
@@ -379,9 +469,24 @@ impl NativePairing {
name, name,
fp_hex: fp_hex.to_string(), fp_hex: fp_hex.to_string(),
requested_at: Instant::now(), requested_at: Instant::now(),
src_ip,
parked: false,
}); });
} }
/// Mark/unmark the pending entry for `fp_hex` as having a live parked waiter (no-op if it's gone).
/// A parked entry is protected from eviction under load (#13).
fn set_parked(&self, fp_hex: &str, parked: bool) {
let mut pending = self.pending.lock().unwrap();
if let Some(p) = pending
.items
.iter_mut()
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
{
p.parked = parked;
}
}
/// The devices currently awaiting approval (for the management API). /// The devices currently awaiting approval (for the management API).
pub fn pending(&self) -> Vec<PendingRequest> { pub fn pending(&self) -> Vec<PendingRequest> {
let mut pending = self.pending.lock().unwrap(); let mut pending = self.pending.lock().unwrap();
@@ -462,6 +567,23 @@ impl NativePairing {
/// to keep the knocking connection open until a human clicks Approve — so the device pairs and /// to keep the knocking connection open until a human clicks Approve — so the device pairs and
/// streams with no reconnect (delegated approval, roadmap §8b-1). /// streams with no reconnect (delegated approval, roadmap §8b-1).
pub async fn wait_for_decision(&self, fp_hex: &str, timeout: Duration) -> PairingDecision { pub async fn wait_for_decision(&self, fp_hex: &str, timeout: Duration) -> PairingDecision {
// Mark this knock parked so a cert-rotating flood can't evict the genuine, held-open
// connection out of the pending queue while the operator decides (#13). Cleared on every
// exit path by the guard's Drop.
self.set_parked(fp_hex, true);
struct ParkGuard<'a> {
np: &'a NativePairing,
fp: &'a str,
}
impl Drop for ParkGuard<'_> {
fn drop(&mut self) {
self.np.set_parked(self.fp, false);
}
}
let _park = ParkGuard {
np: self,
fp: fp_hex,
};
let deadline = tokio::time::Instant::now() + timeout; let deadline = tokio::time::Instant::now() + timeout;
loop { loop {
// Arm the wakeup BEFORE re-reading state, and `enable()` it, so an approve/deny that // Arm the wakeup BEFORE re-reading state, and `enable()` it, so an approve/deny that
@@ -548,8 +670,8 @@ mod tests {
// A knock appears; a re-knock from the same fingerprint refreshes (same id, new name) // A knock appears; a re-knock from the same fingerprint refreshes (same id, new name)
// instead of duplicating. // instead of duplicating.
np.note_pending("device aa11", "AA11"); np.note_pending("device aa11", "AA11", None);
np.note_pending("Bedroom TV", "aa11"); np.note_pending("Bedroom TV", "aa11", None);
let pend = np.pending(); let pend = np.pending();
assert_eq!(pend.len(), 1, "re-knock dedups by fingerprint"); assert_eq!(pend.len(), 1, "re-knock dedups by fingerprint");
assert_eq!(pend[0].name, "Bedroom TV"); assert_eq!(pend[0].name, "Bedroom TV");
@@ -562,7 +684,7 @@ mod tests {
assert!(!np.is_paired("aa11")); assert!(!np.is_paired("aa11"));
// Approve pairs the fingerprint (operator label wins) and clears the entry. // Approve pairs the fingerprint (operator label wins) and clears the entry.
np.note_pending("device bb22", "BB22"); np.note_pending("device bb22", "BB22", None);
let id = np.pending()[0].id; let id = np.pending()[0].id;
assert!( assert!(
np.approve_pending(9999, None).unwrap().is_none(), np.approve_pending(9999, None).unwrap().is_none(),
@@ -578,8 +700,11 @@ mod tests {
assert_eq!(np.list()[0].name, "Living Room"); assert_eq!(np.list()[0].name, "Living Room");
// The cap evicts the oldest knock. // The cap evicts the oldest knock.
// Flood from many DISTINCT source IPs (so the per-IP cap doesn't kick in) → the global cap
// holds at PENDING_CAP, evicting the oldest non-parked entries first.
for i in 0..(PENDING_CAP + 3) { for i in 0..(PENDING_CAP + 3) {
np.note_pending("flood", &format!("f{i:03}")); let ip = IpAddr::from([10, 0, (i / 256) as u8, (i % 256) as u8]);
np.note_pending("flood", &format!("f{i:03}"), Some(ip));
} }
let pend = np.pending(); let pend = np.pending();
assert_eq!(pend.len(), PENDING_CAP); assert_eq!(pend.len(), PENDING_CAP);
@@ -610,7 +735,7 @@ mod tests {
let p = temp(); let p = temp();
let _ = std::fs::remove_file(&p); let _ = std::fs::remove_file(&p);
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap(); let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
np.note_pending("Knocker", "cc44"); np.note_pending("Knocker", "cc44", None);
assert_eq!(np.pending().len(), 1); assert_eq!(np.pending().len(), 1);
// Pairing the same fingerprint (e.g. via the PIN ceremony) drops the stale pending entry. // Pairing the same fingerprint (e.g. via the PIN ceremony) drops the stale pending entry.
np.add("Knocker", "CC44").unwrap(); np.add("Knocker", "CC44").unwrap();
@@ -656,7 +781,7 @@ mod tests {
let np = Arc::new(NativePairing::load_with(Some(p.clone()), None, false).unwrap()); let np = Arc::new(NativePairing::load_with(Some(p.clone()), None, false).unwrap());
// TimedOut: a parked knock with no decision returns TimedOut; the entry survives. // TimedOut: a parked knock with no decision returns TimedOut; the entry survives.
np.note_pending("Knocker", "ab01"); np.note_pending("Knocker", "ab01", None);
let d = np let d = np
.wait_for_decision("ab01", Duration::from_millis(80)) .wait_for_decision("ab01", Duration::from_millis(80))
.await; .await;
@@ -681,7 +806,7 @@ mod tests {
assert!(np.is_paired("ab01")); assert!(np.is_paired("ab01"));
// Denied: denying WHILE parked wakes the waiter with Denied (not held until timeout). // Denied: denying WHILE parked wakes the waiter with Denied (not held until timeout).
np.note_pending("Knock2", "cd02"); np.note_pending("Knock2", "cd02", None);
let np3 = np.clone(); let np3 = np.clone();
let waiter = let waiter =
tokio::spawn( tokio::spawn(
@@ -703,4 +828,62 @@ mod tests {
assert_eq!(d, PairingDecision::Approved); assert_eq!(d, PairingDecision::Approved);
let _ = std::fs::remove_file(&p); let _ = std::fs::remove_file(&p);
} }
/// #9: a window can be bound to one operator-selected fingerprint, so an unrelated (attacker)
/// fingerprint can neither pair nor BURN the window (it's rejected without a PIN).
#[test]
fn armed_pin_is_fingerprint_bindable() {
let p = temp();
let _ = std::fs::remove_file(&p);
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
// Unbound: any fingerprint resolves to the PIN (legacy behavior).
let pin = np.arm(Duration::from_secs(60));
assert!(matches!(np.pin_for_attempt("aa11"), PinAttempt::Pin(x) if x == pin));
assert!(matches!(np.pin_for_attempt("bb22"), PinAttempt::Pin(_)));
// Bound to AA11: only that fp (case-insensitive) gets the PIN; another fp is BoundToOther —
// the caller rejects it WITHOUT consuming the window.
let pin = np.arm_for(Duration::from_secs(60), Some("AA11".into()));
assert!(matches!(np.pin_for_attempt("aa11"), PinAttempt::Pin(x) if x == pin));
assert!(matches!(
np.pin_for_attempt("bb22"),
PinAttempt::BoundToOther
));
np.disarm();
assert!(matches!(np.pin_for_attempt("aa11"), PinAttempt::Disarmed));
let _ = std::fs::remove_file(&p);
}
/// #13: one source IP can't exceed the per-IP cap, and a parked (held-open) genuine knock is
/// never evicted by a flood — even one that fills the global cap from many distinct IPs.
#[test]
fn pending_per_ip_cap_and_parked_protection() {
let p = temp();
let _ = std::fs::remove_file(&p);
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
// Per-IP cap: one source flooding distinct fingerprints holds at most MAX_PENDING_PER_IP.
let attacker = IpAddr::from([192, 168, 1, 66]);
for i in 0..20 {
np.note_pending("flood", &format!("atk{i:03}"), Some(attacker));
}
assert_eq!(
np.pending().len(),
MAX_PENDING_PER_IP,
"one IP can't exceed the per-IP cap"
);
// A genuine knock from a different IP, parked (a live held-open connection), survives a flood
// from many distinct IPs that fills the global cap.
let legit = IpAddr::from([192, 168, 1, 50]);
np.note_pending("Living Room", "legit01", Some(legit));
np.set_parked("legit01", true);
for i in 0..(PENDING_CAP * 2) {
let ip = IpAddr::from([10, 0, (i / 256) as u8, (i % 256) as u8]);
np.note_pending("flood2", &format!("g{i:04}"), Some(ip));
}
assert!(
np.pending_contains("legit01"),
"a parked, held-open knock is never evicted by a flood"
);
assert!(np.pending().len() <= PENDING_CAP, "global cap still holds");
let _ = std::fs::remove_file(&p);
}
} }
+130 -5
View File
@@ -532,10 +532,25 @@ async fn serve_session(
.await .await
.map_err(|_| anyhow!("first message timeout"))??; .map_err(|_| anyhow!("first message timeout"))??;
if let Ok(req) = PairRequest::decode(&first) { if let Ok(req) = PairRequest::decode(&first) {
// Read the live arming PIN per attempt, so a window that lapsed no longer pairs. // The client fingerprint (cert possession is proven by the QUIC handshake) is needed to honor
let pin = np // a fingerprint-bound PIN window (#9): a window the operator armed for a SPECIFIC device must
.current_pin() // not be consumable — or burnable — by any other fingerprint.
.context("pairing not armed (arm it in the console, or start with --allow-pairing)")?; let client_fp = endpoint::peer_fingerprint(&conn)
.ok_or_else(|| anyhow!("pairing requires the client to present a certificate"))?;
let client_fp_hex = fingerprint_hex(&client_fp);
// Resolve the live arming PIN per attempt (so a lapsed window no longer pairs), honoring any
// fingerprint binding.
let pin = match np.pin_for_attempt(&client_fp_hex) {
crate::native_pairing::PinAttempt::Pin(pin) => pin,
crate::native_pairing::PinAttempt::Disarmed => anyhow::bail!(
"pairing not armed (arm it in the console, or start with --allow-pairing)"
),
// Armed for a DIFFERENT device — reject without running the ceremony, so this attempt does
// NOT consume (burn) the operator's window for the device they actually selected (#9).
crate::native_pairing::PinAttempt::BoundToOther => anyhow::bail!(
"pairing is armed for a different device — this attempt does not consume the window"
),
};
{ {
let mut last = last_pairing.lock().unwrap(); let mut last = last_pairing.lock().unwrap();
if let Some(t) = *last { if let Some(t) = *last {
@@ -589,7 +604,9 @@ async fn serve_session(
); );
tracing::info!(name = %label, fingerprint = %fp_hex, tracing::info!(name = %label, fingerprint = %fp_hex,
"unpaired device knocked — parking connection for delegated approval in the console"); "unpaired device knocked — parking connection for delegated approval in the console");
np.note_pending(&label, &fp_hex); // Record the QUIC-validated source IP so the pending queue's per-source cap can stop one
// host from flooding/evicting genuine knocks (#13).
np.note_pending(&label, &fp_hex, Some(peer.ip()));
// Free the session slot while a human decides — a parked knock must not hold an NVENC // Free the session slot while a human decides — a parked knock must not hold an NVENC
// permit (a handful of parked knocks would otherwise block every real session). // permit (a handful of parked knocks would otherwise block every real session).
drop(permit); drop(permit);
@@ -1382,6 +1399,8 @@ enum PadBackend {
DualSense(crate::inject::dualsense::DualSenseManager), DualSense(crate::inject::dualsense::DualSenseManager),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
DualShock4(crate::inject::dualshock4::DualShock4Manager), DualShock4(crate::inject::dualshock4::DualShock4Manager),
#[cfg(target_os = "linux")]
SteamDeck(crate::inject::steam_controller::SteamControllerManager),
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
DualSenseWindows(crate::inject::dualsense_windows::DualSenseWindowsManager), DualSenseWindows(crate::inject::dualsense_windows::DualSenseWindowsManager),
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -1403,6 +1422,12 @@ impl PadBackend {
tracing::info!("gamepad backend: virtual DualShock 4 (UHID hid-playstation)"); tracing::info!("gamepad backend: virtual DualShock 4 (UHID hid-playstation)");
return PadBackend::DualShock4(crate::inject::dualshock4::DualShock4Manager::new()); return PadBackend::DualShock4(crate::inject::dualshock4::DualShock4Manager::new());
} }
GamepadPref::SteamDeck => {
tracing::info!("gamepad backend: virtual Steam Deck (UHID hid-steam)");
return PadBackend::SteamDeck(
crate::inject::steam_controller::SteamControllerManager::new(),
);
}
GamepadPref::XboxOne => { GamepadPref::XboxOne => {
tracing::info!("gamepad backend: uinput X-Box One/Series pad"); tracing::info!("gamepad backend: uinput X-Box One/Series pad");
return PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::with_identity( return PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::with_identity(
@@ -1438,6 +1463,8 @@ impl PadBackend {
PadBackend::DualSense(m) => m.handle(ev), PadBackend::DualSense(m) => m.handle(ev),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
PadBackend::DualShock4(m) => m.handle(ev), PadBackend::DualShock4(m) => m.handle(ev),
#[cfg(target_os = "linux")]
PadBackend::SteamDeck(m) => m.handle(ev),
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
PadBackend::DualSenseWindows(m) => m.handle(ev), PadBackend::DualSenseWindows(m) => m.handle(ev),
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -1454,6 +1481,8 @@ impl PadBackend {
PadBackend::DualSense(m) => m.apply_rich(_rich), PadBackend::DualSense(m) => m.apply_rich(_rich),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
PadBackend::DualShock4(m) => m.apply_rich(_rich), PadBackend::DualShock4(m) => m.apply_rich(_rich),
#[cfg(target_os = "linux")]
PadBackend::SteamDeck(m) => m.apply_rich(_rich),
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
PadBackend::DualSenseWindows(m) => m.apply_rich(_rich), PadBackend::DualSenseWindows(m) => m.apply_rich(_rich),
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -1479,6 +1508,8 @@ impl PadBackend {
PadBackend::DualSense(m) => m.pump(rumble, hidout), PadBackend::DualSense(m) => m.pump(rumble, hidout),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
PadBackend::DualShock4(m) => m.pump(rumble, hidout), PadBackend::DualShock4(m) => m.pump(rumble, hidout),
#[cfg(target_os = "linux")]
PadBackend::SteamDeck(m) => m.pump(rumble, hidout),
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
PadBackend::DualSenseWindows(m) => m.pump(rumble, hidout), PadBackend::DualSenseWindows(m) => m.pump(rumble, hidout),
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -1498,6 +1529,8 @@ impl PadBackend {
PadBackend::DualSense(m) => m.heartbeat(std::time::Duration::from_millis(8)), PadBackend::DualSense(m) => m.heartbeat(std::time::Duration::from_millis(8)),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
PadBackend::DualShock4(m) => m.heartbeat(std::time::Duration::from_millis(8)), PadBackend::DualShock4(m) => m.heartbeat(std::time::Duration::from_millis(8)),
#[cfg(target_os = "linux")]
PadBackend::SteamDeck(m) => m.heartbeat(std::time::Duration::from_millis(8)),
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
PadBackend::DualSenseWindows(m) => m.heartbeat(std::time::Duration::from_millis(8)), PadBackend::DualSenseWindows(m) => m.heartbeat(std::time::Duration::from_millis(8)),
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -1877,10 +1910,94 @@ 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,
// so it folds to Xbox360 for now (Windows Steam devices are M7).
GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck,
_ => GamepadPref::Xbox360, _ => GamepadPref::Xbox360,
} }
} }
/// Runtime degrade for the Linux UHID backends (DualSense / DualShock 4 / Steam Deck): if
/// `/dev/uhid` can't be opened for write *now*, fall back to the uinput X-Box 360 pad rather than a
/// dead controller (the UHID device-create would just fail). Cheap — opens + drops the char device,
/// no `UHID_CREATE2`, so no device is created. A no-op on non-Linux (those backends are UMDF/uinput).
#[cfg(target_os = "linux")]
fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
let needs_uhid = matches!(
chosen,
GamepadPref::DualSense | GamepadPref::DualShock4 | GamepadPref::SteamDeck
);
if needs_uhid
&& std::fs::OpenOptions::new()
.write(true)
.open("/dev/uhid")
.is_err()
{
tracing::warn!(
wanted = chosen.as_str(),
"/dev/uhid not writable — falling back to the X-Box 360 pad"
);
return GamepadPref::Xbox360;
}
chosen
}
#[cfg(not(target_os = "linux"))]
fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
chosen
}
/// True if a **physical** Valve Steam controller (`28DE`) is already attached. The host's own Steam
/// Input is then managing a `28DE` device, and presenting a second (virtual) one makes Steam juggle
/// two Decks — confirmed conflict-prone on a Deck-as-host (the physical `28DE:1205` + Steam's
/// `28DE:11FF` XInput output pad are both live). HID device dirs are named `BUS:VID:PID.INST`
/// (uppercase); a UHID virtual device resolves through `/devices/virtual/…`, a real one does not.
#[cfg(target_os = "linux")]
fn physical_steam_controller_present() -> bool {
let Ok(entries) = std::fs::read_dir("/sys/bus/hid/devices") else {
return false;
};
entries.flatten().any(|e| {
if !e.file_name().to_string_lossy().contains(":28DE:") {
return false;
}
match std::fs::read_link(e.path()) {
Ok(target) => !target.to_string_lossy().contains("/virtual/"),
Err(_) => true,
}
})
}
/// Gate a virtual Steam pad off when a physical Steam controller is attached (§ conflict). Degrade to
/// DualSense (then the uhid ladder), which Steam treats as an ordinary, distinct pad. Override with
/// `PUNKTFUNK_STEAM_FORCE=1` when the host has no competing Steam Input (e.g. a remote-only box).
#[cfg(target_os = "linux")]
fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref {
if !matches!(
chosen,
GamepadPref::SteamDeck | GamepadPref::SteamController
) {
return chosen;
}
let forced = std::env::var("PUNKTFUNK_STEAM_FORCE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
if !forced && physical_steam_controller_present() {
tracing::warn!(
wanted = chosen.as_str(),
"a physical Steam controller is attached — the host's Steam Input would manage two 28DE \
devices; falling back to DualSense (set PUNKTFUNK_STEAM_FORCE=1 to override)"
);
return degrade_if_no_uhid(GamepadPref::DualSense);
}
chosen
}
#[cfg(not(target_os = "linux"))]
fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref {
chosen
}
/// Resolve the client's gamepad-backend preference (the env/logging shell around /// Resolve the client's gamepad-backend preference (the env/logging shell around
/// [`pick_gamepad`]). Always concrete — the `Welcome` reports what the session will drive. /// [`pick_gamepad`]). Always concrete — the `Welcome` reports what the session will drive.
fn resolve_gamepad(pref: GamepadPref) -> GamepadPref { fn resolve_gamepad(pref: GamepadPref) -> GamepadPref {
@@ -1891,6 +2008,14 @@ fn resolve_gamepad(pref: GamepadPref) -> GamepadPref {
cfg!(target_os = "linux"), cfg!(target_os = "linux"),
cfg!(target_os = "windows"), cfg!(target_os = "windows"),
); );
// Runtime degrade (separate from the compile-time platform check above): the Linux UHID
// backends need `/dev/uhid` usable *now*, else creating the device just fails and the controller
// goes dead — fall back to the always-available uinput X-Box 360 pad instead.
let chosen = degrade_if_no_uhid(chosen);
// Conflict gate: don't present a virtual Steam (28DE) pad when the host already has a physical
// Steam controller — its own Steam Input would then manage two Decks (confirmed conflict-prone on
// a Deck-as-host). `PUNKTFUNK_STEAM_FORCE=1` overrides.
let chosen = degrade_steam_on_conflict(chosen);
match pref { match pref {
GamepadPref::Auto => { GamepadPref::Auto => {
// The operator's env knob deserves a diagnostic when it didn't drive the // The operator's env knob deserves a diagnostic when it didn't drive the
@@ -327,17 +327,17 @@ impl VirtualDisplayManager {
} }
}); });
// Windows defaults a new IddCx monitor into CLONE mode when a physical display is already // Resolve the capture target — wait for Windows to auto-activate the freshly-ADDed IDD into its
// active (a laptop panel, an attached monitor): the cloned IDD shares that display's source, so // OWN display path (it comes up EXTENDED alongside any existing/basic display; `set_active_mode`
// the OS never commits a distinct path for it and capture sees no frames. Force EXTEND first so // below then promotes it to primary and `isolate_displays_ccd` makes it the sole composited
// the IDD comes up as its OWN active path; the resolve loop below then finds it. Idempotent / // desktop — the proven flow). May be None on a GPU-less box (target added but not WDDM-activated);
// no-op on a sole-display box, so it's safe on the headless single-GPU path too.
// SAFETY: `force_extend_topology` only calls `SetDisplayConfig` (a CCD topology apply) with no
// borrowed caller memory; it runs under the manager `state` lock, the sole topology mutator.
unsafe { force_extend_topology() };
// Resolve the capture target. May be None on a GPU-less box (target added but not WDDM-activated);
// the capture backend re-resolves once a GPU is present. // the capture backend re-resolves once a GPU is present.
//
// We do NOT force a topology change FIRST: the bare `SDC_TOPOLOGY_EXTEND` preset is ACCESS_DENIED
// from our Session-0 service context on a headless box and BREAKS this auto-activate (it regressed
// the headless path — the IDD then never gets its own path → "not an active display path" → black).
// force-EXTEND is only the FALLBACK below, for an integrated-screen box where a fresh IDD is CLONED
// onto the panel (shares its source) instead of getting its own path.
let mut gdi_name = None; let mut gdi_name = None;
for _ in 0..15 { for _ in 0..15 {
thread::sleep(Duration::from_millis(200)); thread::sleep(Duration::from_millis(200));
@@ -349,6 +349,32 @@ impl VirtualDisplayManager {
break; break;
} }
} }
// Fallback for an integrated-screen box (e.g. a laptop panel): Windows CLONES a freshly-added
// IDD onto the existing display, sharing its source, so it never gets its own committed path. On
// the IddCx clone behaviour observed live (commit 8e87e61, an Intel-iGPU + NVIDIA-Optimus laptop)
// `resolve_gdi_name` then stays None — so this `is_none()` fallback fires, force-EXTENDs to
// de-clone, and the second resolve finds the now-committed path. Headless/extended boxes already
// resolved above (the IDD auto-activates with its OWN source) and skip this — which is the whole
// point, since force-EXTEND's bare preset is ACCESS_DENIED from our service context there.
//
// CAVEAT (unobserved for IddCx, untested across GPU/driver/OS): textbook CCD also lets a clone
// appear as a *shared-source ACTIVE* path (resolve → Some), which this `is_none()` gate would NOT
// catch. If that ever shows up, widen the gate to also fire when the IDD target's source is shared
// with another active path (a `target_is_cloned` helper) — needs on-laptop validation first.
if gdi_name.is_none() {
// SAFETY: as above — `force_extend_topology` only calls `SetDisplayConfig` (CCD) with no
// borrowed caller memory, under the `state` lock.
unsafe { force_extend_topology() };
for _ in 0..15 {
thread::sleep(Duration::from_millis(200));
// SAFETY: as the resolve loop above.
if let Some(n) = unsafe { resolve_gdi_name(added.target_id) } {
gdi_name = Some(n);
break;
}
}
}
let mut ccd_saved: Option<SavedConfig> = None; let mut ccd_saved: Option<SavedConfig> = None;
match &gdi_name { match &gdi_name {
Some(n) => { Some(n) => {
+33
View File
@@ -0,0 +1,33 @@
# Vendored + trimmed copy of the `usbip` crate (jiegec/usbip v0.8.0, MIT), reduced to the
# USB/IP *server simulation* path only: we present a virtual Steam Deck and let the local
# `vhci_hcd` attach it. The upstream crate hard-depends on `rusb`→`libusb1-sys` (for its USB
# *host* mode, which we do not use and which would add a libusb runtime dep + break `musl`),
# so the host modules (`host.rs`, the `rusb`/`nusb` device constructors) and the helper
# interface handlers (`cdc.rs`/`hid.rs`) are removed. What remains — the device model, the
# USB/IP protocol framing, and the `UsbInterfaceHandler` trait — is pure `std` + `tokio` and
# carries no libusb dependency. See `NOTICE` for upstream attribution.
[package]
name = "usbip-sim"
version = "0.8.0"
edition = "2021"
description = "Trimmed usbip server-simulation core (no libusb) — vendored for the virtual Steam Deck"
license = "MIT"
publish = false
[lib]
name = "usbip_sim"
path = "src/lib.rs"
[dependencies]
log = "0.4"
num-derive = "0.4"
num-traits = "0.2"
# `time` is for the interrupt-IN pacing added in device.rs (punktfunk modification — see NOTICE).
tokio = { version = "1", features = ["rt", "net", "io-util", "sync", "time"] }
# Upstream gated its struct derives behind a `serde` feature; kept (off by default) so the
# `#[cfg(feature = "serde")]` attributes stay valid and the vendored diff stays minimal.
serde = { version = "1", features = ["derive"], optional = true }
[features]
default = []
serde = ["dep:serde"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020-2025 Jiajie Chen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+23
View File
@@ -0,0 +1,23 @@
This crate (`usbip-sim`) is a vendored, trimmed copy of:
usbip v0.8.0
Copyright (c) Jiajie Chen <c@jia.je> and contributors
https://github.com/jiegec/usbip
Licensed under the MIT License.
Modifications by the punktfunk project:
- Removed the USB host modules (`src/host.rs`) and the `rusb`/`nusb` device
constructors in `src/lib.rs` (`with_rusb_*`, `with_nusb_*`, `new_from_host*`),
eliminating the libusb runtime dependency (which also broke `musl`).
- Removed the example helper interface handlers `src/cdc.rs` and `src/hid.rs`.
- Replaced the `rusb::Direction` re-export and `rusb::Version` conversions with
local definitions.
- Dropped the in-crate test modules (kept the library surface only).
- Paced interrupt/bulk IN endpoint transfers by bInterval in `device.rs`
`handle_urb` (so a simulated interrupt-IN mimics a real device's
NAK-until-bInterval behaviour rather than free-running over the loopback
link); added the tokio `time` feature for it.
Only the USB/IP server *simulation* path is retained: the device model, the
USB/IP wire protocol, and the `UsbInterfaceHandler` trait. The original MIT
license text is reproduced in LICENSE-MIT.
+122
View File
@@ -0,0 +1,122 @@
use super::*;
/// A list of known USB speeds
#[derive(Copy, Clone, Debug)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum UsbSpeed {
Unknown = 0x0,
Low,
Full,
High,
Wireless,
Super,
SuperPlus,
}
/// A list of defined USB class codes
// https://www.usb.org/defined-class-codes
#[derive(Copy, Clone, Debug)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum ClassCode {
SeeInterface = 0,
Audio,
CDC,
HID,
Physical = 0x05,
Image,
Printer,
MassStorage,
Hub,
CDCData,
SmartCard,
ContentSecurity = 0x0D,
Video,
PersonalHealthcare,
AudioVideo,
Billboard,
TypeCBridge,
Diagnostic = 0xDC,
WirelessController = 0xE0,
Misc = 0xEF,
ApplicationSpecific = 0xFE,
VendorSpecific = 0xFF,
}
/// A list of defined USB endpoint attributes
#[derive(Copy, Clone, Debug, FromPrimitive)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum EndpointAttributes {
Control = 0,
Isochronous,
Bulk,
Interrupt,
}
/// USB endpoint direction: IN or OUT.
///
/// Upstream re-exported `rusb::Direction`; vendored locally so this crate carries no libusb
/// dependency. `UsbEndpoint::direction()` returns this, and `device.rs` matches on the variants.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum Direction {
/// Host → device (`bEndpointAddress` bit 7 clear).
Out,
/// Device → host (`bEndpointAddress` bit 7 set).
In,
}
/// Emulated max packet size of EP0
pub const EP0_MAX_PACKET_SIZE: u16 = 64;
/// A list of defined USB standard requests
/// from USB 2.0 standard Table 9.4. Standard Request Codes
#[derive(Copy, Clone, Debug, FromPrimitive)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum StandardRequest {
GetStatus = 0,
ClearFeature = 1,
SetFeature = 3,
SetAddress = 5,
GetDescriptor = 6,
SetDescriptor = 7,
GetConfiguration = 8,
SetConfiguration = 9,
GetInterface = 10,
SetInterface = 11,
SynchFrame = 12,
}
/// A list of defined USB descriptor types
/// from USB 2.0 standard Table 9.5. Descriptor Types
#[derive(Copy, Clone, Debug, FromPrimitive)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum DescriptorType {
/// DEVICE
Device = 1,
/// CONFIGURATION
Configuration = 2,
/// STRING
String = 3,
/// INTERFACE
Interface = 4,
/// ENDPOINT
Endpoint = 5,
/// DEVICE_QUALIFIER
DeviceQualifier = 6,
/// OTHER_SPEED_CONFIGURATION
OtherSpeedConfiguration = 7,
/// INTERFACE_POINTER
InterfacePower = 8,
/// OTG
OTG = 9,
/// DEBUG
Debug = 0xA,
/// INTERFACE_ASSOCIATION
InterfaceAssociation = 0xB,
/// BOS
BOS = 0xF,
// DEVICE CAPABILITY
DeviceCapability = 0x10,
/// SUPERSPEED_USB_ENDPOINT_COMPANION
SuperspeedUsbEndpointCompanion = 0x30,
}
+555
View File
@@ -0,0 +1,555 @@
use super::*;
#[derive(Clone, Default, Debug)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Version {
pub major: u8,
pub minor: u8,
pub patch: u8,
}
// (Upstream's `From<rusb::Version>` conversions removed — this crate has no libusb dependency.)
/// bcdDevice
impl From<u16> for Version {
fn from(value: u16) -> Self {
Self {
major: (value >> 8) as u8,
minor: ((value >> 4) & 0xF) as u8,
patch: (value & 0xF) as u8,
}
}
}
/// Represent a USB device
#[derive(Clone, Default, Debug)]
#[cfg_attr(feature = "serde", derive(Serialize))]
pub struct UsbDevice {
pub path: String,
pub bus_id: String,
pub bus_num: u32,
pub dev_num: u32,
pub speed: u32,
pub vendor_id: u16,
pub product_id: u16,
pub device_bcd: Version,
pub device_class: u8,
pub device_subclass: u8,
pub device_protocol: u8,
pub configuration_value: u8,
pub num_configurations: u8,
pub interfaces: Vec<UsbInterface>,
#[cfg_attr(feature = "serde", serde(skip))]
pub device_handler: Option<Arc<Mutex<Box<dyn UsbDeviceHandler + Send>>>>,
pub usb_version: Version,
pub(crate) ep0_in: UsbEndpoint,
pub(crate) ep0_out: UsbEndpoint,
// strings
pub(crate) string_pool: HashMap<u8, String>,
pub(crate) string_configuration: u8,
pub(crate) string_manufacturer: u8,
pub(crate) string_product: u8,
pub(crate) string_serial: u8,
}
impl UsbDevice {
pub fn new(index: u32) -> Self {
let mut res = Self {
path: "/sys/bus/0/0/0".to_string(),
bus_id: "0-0-0".to_string(),
dev_num: index,
speed: UsbSpeed::High as u32,
ep0_in: UsbEndpoint {
address: 0x80,
attributes: EndpointAttributes::Control as u8,
max_packet_size: EP0_MAX_PACKET_SIZE,
interval: 0,
},
ep0_out: UsbEndpoint {
address: 0x00,
attributes: EndpointAttributes::Control as u8,
max_packet_size: EP0_MAX_PACKET_SIZE,
interval: 0,
},
// configured by default
configuration_value: 1,
num_configurations: 1,
..Self::default()
};
res.string_configuration = res.new_string("Default Configuration");
res.string_manufacturer = res.new_string("Manufacturer");
res.string_product = res.new_string("Product");
res.string_serial = res.new_string("Serial");
res
}
/// Returns the old value, if present.
pub fn set_configuration_name(&mut self, name: &str) -> Option<String> {
let old = (self.string_configuration != 0)
.then(|| self.string_pool.remove(&self.string_configuration))
.flatten();
self.string_configuration = self.new_string(name);
old
}
/// Unset configuration name and returns the old value, if present.
pub fn unset_configuration_name(&mut self) -> Option<String> {
let old = (self.string_configuration != 0)
.then(|| self.string_pool.remove(&self.string_configuration))
.flatten();
self.string_configuration = 0;
old
}
/// Returns the old value, if present.
pub fn set_serial_number(&mut self, name: &str) -> Option<String> {
let old = (self.string_serial != 0)
.then(|| self.string_pool.remove(&self.string_serial))
.flatten();
self.string_serial = self.new_string(name);
old
}
/// Unset serial number and returns the old value, if present.
pub fn unset_serial_number(&mut self) -> Option<String> {
let old = (self.string_serial != 0)
.then(|| self.string_pool.remove(&self.string_serial))
.flatten();
self.string_serial = 0;
old
}
/// Returns the old value, if present.
pub fn set_product_name(&mut self, name: &str) -> Option<String> {
let old = (self.string_product != 0)
.then(|| self.string_pool.remove(&self.string_product))
.flatten();
self.string_product = self.new_string(name);
old
}
/// Unset product name and returns the old value, if present.
pub fn unset_product_name(&mut self) -> Option<String> {
let old = (self.string_product != 0)
.then(|| self.string_pool.remove(&self.string_product))
.flatten();
self.string_product = 0;
old
}
/// Returns the old value, if present.
pub fn set_manufacturer_name(&mut self, name: &str) -> Option<String> {
let old = (self.string_manufacturer != 0)
.then(|| self.string_pool.remove(&self.string_manufacturer))
.flatten();
self.string_manufacturer = self.new_string(name);
old
}
/// Unset manufacturer name and returns the old value, if present.
pub fn unset_manufacturer_name(&mut self) -> Option<String> {
let old = (self.string_manufacturer != 0)
.then(|| self.string_pool.remove(&self.string_manufacturer))
.flatten();
self.string_manufacturer = 0;
old
}
pub fn with_interface(
mut self,
interface_class: u8,
interface_subclass: u8,
interface_protocol: u8,
name: Option<&str>,
endpoints: Vec<UsbEndpoint>,
handler: Arc<Mutex<Box<dyn UsbInterfaceHandler + Send>>>,
) -> Self {
let string_interface = name.map(|name| self.new_string(name)).unwrap_or(0);
let class_specific_descriptor = handler.lock().unwrap().get_class_specific_descriptor();
self.interfaces.push(UsbInterface {
interface_class,
interface_subclass,
interface_protocol,
endpoints,
string_interface,
class_specific_descriptor,
handler,
});
self
}
pub fn with_device_handler(
mut self,
handler: Arc<Mutex<Box<dyn UsbDeviceHandler + Send>>>,
) -> Self {
self.device_handler = Some(handler);
self
}
pub(crate) fn new_string(&mut self, s: &str) -> u8 {
for i in 1.. {
if let std::collections::hash_map::Entry::Vacant(e) = self.string_pool.entry(i) {
e.insert(s.to_string());
return i;
}
}
panic!("string poll exhausted")
}
pub(crate) fn find_ep(&self, ep: u8) -> Option<(UsbEndpoint, Option<&UsbInterface>)> {
if ep == self.ep0_in.address {
Some((self.ep0_in, None))
} else if ep == self.ep0_out.address {
Some((self.ep0_out, None))
} else {
for intf in &self.interfaces {
for endpoint in &intf.endpoints {
if endpoint.address == ep {
return Some((*endpoint, Some(intf)));
}
}
}
None
}
}
pub(crate) fn to_bytes(&self) -> Vec<u8> {
let mut result = Vec::with_capacity(312);
let mut path = self.path.as_bytes().to_vec();
debug_assert!(path.len() <= 256);
path.resize(256, 0);
result.extend_from_slice(path.as_slice());
let mut bus_id = self.bus_id.as_bytes().to_vec();
debug_assert!(bus_id.len() <= 32);
bus_id.resize(32, 0);
result.extend_from_slice(bus_id.as_slice());
result.extend_from_slice(&self.bus_num.to_be_bytes());
result.extend_from_slice(&self.dev_num.to_be_bytes());
result.extend_from_slice(&self.speed.to_be_bytes());
result.extend_from_slice(&self.vendor_id.to_be_bytes());
result.extend_from_slice(&self.product_id.to_be_bytes());
result.push(self.device_bcd.major);
result.push(self.device_bcd.minor);
result.push(self.device_class);
result.push(self.device_subclass);
result.push(self.device_protocol);
result.push(self.configuration_value);
result.push(self.num_configurations);
result.push(self.interfaces.len() as u8);
result
}
pub(crate) fn to_bytes_with_interfaces(&self) -> Vec<u8> {
let mut result = self.to_bytes();
result.reserve(4 * self.interfaces.len());
for intf in &self.interfaces {
result.push(intf.interface_class);
result.push(intf.interface_subclass);
result.push(intf.interface_protocol);
result.push(0); // padding
}
result
}
pub(crate) async fn handle_urb(
&self,
ep: UsbEndpoint,
intf: Option<&UsbInterface>,
transfer_buffer_length: u32,
setup_packet: SetupPacket,
out_data: &[u8],
) -> Result<Vec<u8>> {
use DescriptorType::*;
use Direction::*;
use EndpointAttributes::*;
use StandardRequest::*;
match (FromPrimitive::from_u8(ep.attributes), ep.direction()) {
(Some(Control), In) => {
// control in
debug!("Control IN setup={setup_packet:x?}");
match (
setup_packet.request_type,
FromPrimitive::from_u8(setup_packet.request),
) {
(0b10000000, Some(GetDescriptor)) => {
// high byte: type
match FromPrimitive::from_u16(setup_packet.value >> 8) {
Some(Device) => {
debug!("Get device descriptor");
// Standard Device Descriptor
let mut desc = vec![
0x12, // bLength
Device as u8, // bDescriptorType: Device
self.usb_version.minor,
self.usb_version.major, // bcdUSB: USB 2.0
self.device_class, // bDeviceClass
self.device_subclass, // bDeviceSubClass
self.device_protocol, // bDeviceProtocol
self.ep0_in.max_packet_size as u8, // bMaxPacketSize0
self.vendor_id as u8, // idVendor
(self.vendor_id >> 8) as u8,
self.product_id as u8, // idProduct
(self.product_id >> 8) as u8,
self.device_bcd.minor, // bcdDevice
self.device_bcd.major,
self.string_manufacturer, // iManufacturer
self.string_product, // iProduct
self.string_serial, // iSerial
self.num_configurations, // bNumConfigurations
];
// requested len too short: wLength < real length
if setup_packet.length < desc.len() as u16 {
desc.resize(setup_packet.length as usize, 0);
}
Ok(desc)
}
Some(BOS) => {
debug!("Get BOS descriptor");
let mut desc = vec![
0x05, // bLength
BOS as u8, // bDescriptorType: BOS
0x05, 0x00, // wTotalLength
0x00, // bNumCapabilities
];
// requested len too short: wLength < real length
if setup_packet.length < desc.len() as u16 {
desc.resize(setup_packet.length as usize, 0);
}
Ok(desc)
}
Some(Configuration) => {
debug!("Get configuration descriptor");
// Standard Configuration Descriptor
let mut desc = vec![
0x09, // bLength
Configuration as u8, // bDescriptorType: Configuration
0x00,
0x00, // wTotalLength: to be filled below
self.interfaces.len() as u8, // bNumInterfaces
self.configuration_value, // bConfigurationValue
self.string_configuration, // iConfiguration
0x80, // bmAttributes: Bus Powered
0x32, // bMaxPower: 100mA
];
for (i, intf) in self.interfaces.iter().enumerate() {
let mut intf_desc = vec![
0x09, // bLength
Interface as u8, // bDescriptorType: Interface
i as u8, // bInterfaceNum
0x00, // bAlternateSettings
intf.endpoints.len() as u8, // bNumEndpoints
intf.interface_class, // bInterfaceClass
intf.interface_subclass, // bInterfaceSubClass
intf.interface_protocol, // bInterfaceProtocol
intf.string_interface, //iInterface
];
// class specific endpoint
let mut specific = intf.class_specific_descriptor.clone();
intf_desc.append(&mut specific);
// endpoint descriptors
for endpoint in &intf.endpoints {
let mut ep_desc = vec![
0x07, // bLength
Endpoint as u8, // bDescriptorType: Endpoint
endpoint.address, // bEndpointAddress
endpoint.attributes, // bmAttributes
endpoint.max_packet_size as u8,
(endpoint.max_packet_size >> 8) as u8, // wMaxPacketSize
endpoint.interval, // bInterval
];
intf_desc.append(&mut ep_desc);
}
desc.append(&mut intf_desc);
}
// length
let len = desc.len() as u16;
desc[2] = len as u8;
desc[3] = (len >> 8) as u8;
// requested len too short: wLength < real length
if setup_packet.length < desc.len() as u16 {
desc.resize(setup_packet.length as usize, 0);
}
Ok(desc)
}
Some(String) => {
debug!("Get string descriptor");
let index = setup_packet.value as u8;
if index == 0 {
// String Descriptor Zero, Specifying Languages Supported by the Device
// language ids
let mut desc = vec![
4, // bLength
DescriptorType::String as u8, // bDescriptorType
0x09,
0x04, // wLANGID[0], en-US
];
// requested len too short: wLength < real length
if setup_packet.length < desc.len() as u16 {
desc.resize(setup_packet.length as usize, 0);
}
Ok(desc)
} else if let Some(s) = &self.string_pool.get(&index) {
// UNICODE String Descriptor
let bytes: Vec<u16> = s.encode_utf16().collect();
let mut desc = vec![
2 + bytes.len() as u8 * 2, // bLength
DescriptorType::String as u8, // bDescriptorType
];
for byte in bytes {
desc.push(byte as u8);
desc.push((byte >> 8) as u8);
}
// requested len too short: wLength < real length
if setup_packet.length < desc.len() as u16 {
desc.resize(setup_packet.length as usize, 0);
}
Ok(desc)
} else {
Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("Invalid string index: {index}"),
))
}
}
Some(DeviceQualifier) => {
debug!("Get device qualifier descriptor");
// Device_Qualifier Descriptor
let mut desc = vec![
0x0A, // bLength
DeviceQualifier as u8, // bDescriptorType: Device Qualifier
self.usb_version.minor,
self.usb_version.major, // bcdUSB
self.device_class, // bDeviceClass
self.device_subclass, // bDeviceSUbClass
self.device_protocol, // bDeviceProtocol
self.ep0_in.max_packet_size as u8, // bMaxPacketSize0
self.num_configurations, // bNumConfigurations
0x00, // bReserved
];
// requested len too short: wLength < real length
if setup_packet.length < desc.len() as u16 {
desc.resize(setup_packet.length as usize, 0);
}
Ok(desc)
}
_ => {
warn!("unknown desc type: {setup_packet:x?}");
Ok(vec![])
}
}
}
_ if setup_packet.request_type & 0xF == 1 => {
// to interface
// see https://www.beyondlogic.org/usbnutshell/usb6.shtml
// only low 8 bits are valid
let intf = &self.interfaces[setup_packet.index as usize & 0xFF];
let mut handler = intf.handler.lock().unwrap();
handler.handle_urb(intf, ep, transfer_buffer_length, setup_packet, out_data)
}
_ if setup_packet.request_type & 0xF == 0 && self.device_handler.is_some() => {
// to device
// see https://www.beyondlogic.org/usbnutshell/usb6.shtml
let lock = self.device_handler.as_ref().unwrap();
let mut handler = lock.lock().unwrap();
handler.handle_urb(transfer_buffer_length, setup_packet, out_data)
}
_ => unimplemented!("control in"),
}
}
(Some(Control), Out) => {
// control out
debug!("Control OUT setup={setup_packet:x?}");
match (
setup_packet.request_type,
FromPrimitive::from_u8(setup_packet.request),
) {
(0b00000000, Some(SetConfiguration)) => {
let mut desc = vec![
self.configuration_value, // bConfigurationValue
];
// requested len too short: wLength < real length
if setup_packet.length < desc.len() as u16 {
desc.resize(setup_packet.length as usize, 0);
}
Ok(desc)
}
_ if setup_packet.request_type & 0xF == 1 => {
// to interface
// see https://www.beyondlogic.org/usbnutshell/usb6.shtml
// only low 8 bits are valid
let intf = &self.interfaces[setup_packet.index as usize & 0xFF];
let mut handler = intf.handler.lock().unwrap();
handler.handle_urb(intf, ep, transfer_buffer_length, setup_packet, out_data)
}
_ if setup_packet.request_type & 0xF == 0 && self.device_handler.is_some() => {
// to device
// see https://www.beyondlogic.org/usbnutshell/usb6.shtml
let lock = self.device_handler.as_ref().unwrap();
let mut handler = lock.lock().unwrap();
handler.handle_urb(transfer_buffer_length, setup_packet, out_data)
}
_ => unimplemented!("control out"),
}
}
(Some(_), _) => {
// others (interrupt / bulk / iso transfers to an endpoint)
// punktfunk modification: pace IN transfers by bInterval so a virtual interrupt-IN
// endpoint mimics a real device's NAK-until-bInterval behaviour instead of
// free-running as fast as the transport allows (vhci_hcd does not throttle the
// server side, so an unpaced sim would spin the loopback link). HS bInterval N →
// 2^(N-1) microframes × 125µs.
if let In = ep.direction() {
let n = ep.interval.clamp(1, 16) as u32;
let period_us = (1u32 << (n - 1)) * 125;
tokio::time::sleep(std::time::Duration::from_micros(period_us as u64)).await;
}
let intf = intf.unwrap();
let mut handler = intf.handler.lock().unwrap();
handler.handle_urb(intf, ep, transfer_buffer_length, setup_packet, out_data)
}
_ => unimplemented!("transfer to {:?}", ep),
}
}
}
/// A handler for URB targeting the device
pub trait UsbDeviceHandler: std::fmt::Debug {
/// Handle a URB(USB Request Block) targeting at this device
///
/// When the lower 4 bits of `bmRequestType` is zero and the URB is not handled by the library, this function is called.
/// The resulting data should not exceed `transfer_buffer_length`
fn handle_urb(
&mut self,
transfer_buffer_length: u32,
setup: SetupPacket,
req: &[u8],
) -> Result<Vec<u8>>;
/// Helper to downcast to actual struct
///
/// Please implement it as:
/// ```ignore
/// fn as_any(&mut self) -> &mut dyn Any {
/// self
/// }
/// ```
fn as_any(&mut self) -> &mut dyn Any;
}
// (In-crate test module removed in the vendored copy — see NOTICE.)
+31
View File
@@ -0,0 +1,31 @@
use super::*;
/// Represent a USB endpoint
#[derive(Clone, Copy, Debug, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct UsbEndpoint {
/// bEndpointAddress
pub address: u8,
/// bmAttributes
pub attributes: u8,
/// wMaxPacketSize
pub max_packet_size: u16,
/// bInterval
pub interval: u8,
}
impl UsbEndpoint {
/// Get direction from MSB of address
pub fn direction(&self) -> Direction {
if self.address & 0x80 != 0 {
Direction::In
} else {
Direction::Out
}
}
/// Whether this is endpoint zero
pub fn is_ep0(&self) -> bool {
self.address & 0x7F == 0
}
}
+45
View File
@@ -0,0 +1,45 @@
use super::*;
/// Represent a USB interface
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(Serialize))]
pub struct UsbInterface {
pub interface_class: u8,
pub interface_subclass: u8,
pub interface_protocol: u8,
pub endpoints: Vec<UsbEndpoint>,
pub string_interface: u8,
pub class_specific_descriptor: Vec<u8>,
#[cfg_attr(feature = "serde", serde(skip))]
pub handler: Arc<Mutex<Box<dyn UsbInterfaceHandler + Send>>>,
}
/// A handler of a custom usb interface
pub trait UsbInterfaceHandler: std::fmt::Debug {
/// Return the class specific descriptor which is inserted between interface descriptor and endpoint descriptor
fn get_class_specific_descriptor(&self) -> Vec<u8>;
/// Handle a URB(USB Request Block) targeting at this interface
///
/// Can be one of: control transfer to ep0 or other types of transfer to its endpoint.
/// The resulting data should not exceed `transfer_buffer_length`.
fn handle_urb(
&mut self,
interface: &UsbInterface,
ep: UsbEndpoint,
transfer_buffer_length: u32,
setup: SetupPacket,
req: &[u8],
) -> Result<Vec<u8>>;
/// Helper to downcast to actual struct
///
/// Please implement it as:
/// ```ignore
/// fn as_any(&mut self) -> &mut dyn Any {
/// self
/// }
/// ```
fn as_any(&mut self) -> &mut dyn Any;
}
+250
View File
@@ -0,0 +1,250 @@
//! A USB/IP server (simulation path only).
//!
//! Vendored + trimmed from `usbip` v0.8.0 (jiegec/usbip, MIT); the USB *host* modules and the
//! `rusb`/`nusb` device constructors are removed so this carries no libusb dependency. See `NOTICE`.
use log::*;
use num_derive::FromPrimitive;
use num_traits::FromPrimitive;
use std::any::Any;
use std::collections::HashMap;
use std::io::{ErrorKind, Result};
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
use tokio::sync::RwLock;
use usbip_protocol::UsbIpCommand;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
mod consts;
mod device;
mod endpoint;
mod interface;
mod setup;
pub mod usbip_protocol;
mod util;
pub use consts::*;
pub use device::*;
pub use endpoint::*;
pub use interface::*;
pub use setup::*;
pub use util::*;
use crate::usbip_protocol::{UsbIpResponse, USBIP_RET_SUBMIT, USBIP_RET_UNLINK};
/// Main struct of a USB/IP server
#[derive(Default, Debug)]
pub struct UsbIpServer {
available_devices: RwLock<Vec<UsbDevice>>,
used_devices: RwLock<HashMap<String, UsbDevice>>,
}
impl UsbIpServer {
/// Create a [UsbIpServer] with simulated devices
pub fn new_simulated(devices: Vec<UsbDevice>) -> Self {
Self {
available_devices: RwLock::new(devices),
used_devices: RwLock::new(HashMap::new()),
}
}
pub async fn add_device(&self, device: UsbDevice) {
self.available_devices.write().await.push(device);
}
pub async fn remove_device(&self, bus_id: &str) -> Result<()> {
let mut available_devices = self.available_devices.write().await;
if let Some(device) = available_devices.iter().position(|d| d.bus_id == bus_id) {
available_devices.remove(device);
Ok(())
} else if let Some(device) = self
.used_devices
.read()
.await
.values()
.find(|d| d.bus_id == bus_id)
{
Err(std::io::Error::other(format!(
"Device {} is in use",
device.bus_id
)))
} else {
Err(std::io::Error::new(
ErrorKind::NotFound,
format!("Device {bus_id} not found"),
))
}
}
}
pub async fn handler<T: AsyncReadExt + AsyncWriteExt + Unpin>(
mut socket: &mut T,
server: Arc<UsbIpServer>,
) -> Result<()> {
let mut current_import_device_id: Option<String> = None;
loop {
let command = UsbIpCommand::read_from_socket(&mut socket).await;
if let Err(err) = command {
if let Some(dev_id) = current_import_device_id {
let mut used_devices = server.used_devices.write().await;
let mut available_devices = server.available_devices.write().await;
match used_devices.remove(&dev_id) {
Some(dev) => available_devices.push(dev),
None => unreachable!(),
}
}
if err.kind() == ErrorKind::UnexpectedEof {
info!("Remote closed the connection");
return Ok(());
} else {
return Err(err);
}
}
let used_devices = server.used_devices.read().await;
let mut current_import_device = current_import_device_id
.clone()
.and_then(|ref id| used_devices.get(id));
match command.unwrap() {
UsbIpCommand::OpReqDevlist { .. } => {
trace!("Got OP_REQ_DEVLIST");
let devices = server.available_devices.read().await;
// OP_REP_DEVLIST
UsbIpResponse::op_rep_devlist(&devices)
.write_to_socket(socket)
.await?;
trace!("Sent OP_REP_DEVLIST");
}
UsbIpCommand::OpReqImport { busid, .. } => {
trace!("Got OP_REQ_IMPORT");
current_import_device_id = None;
current_import_device = None;
std::mem::drop(used_devices);
let mut used_devices = server.used_devices.write().await;
let mut available_devices = server.available_devices.write().await;
let busid_compare =
&busid[..busid.iter().position(|&x| x == 0).unwrap_or(busid.len())];
for (i, dev) in available_devices.iter().enumerate() {
if busid_compare == dev.bus_id.as_bytes() {
let dev = available_devices.remove(i);
let dev_id = dev.bus_id.clone();
used_devices.insert(dev.bus_id.clone(), dev);
current_import_device_id = dev_id.clone().into();
current_import_device = Some(used_devices.get(&dev_id).unwrap());
break;
}
}
let res = if let Some(dev) = current_import_device {
UsbIpResponse::op_rep_import_success(dev)
} else {
UsbIpResponse::op_rep_import_fail()
};
res.write_to_socket(socket).await?;
trace!("Sent OP_REP_IMPORT");
}
UsbIpCommand::UsbIpCmdSubmit {
mut header,
transfer_buffer_length,
setup,
data,
..
} => {
trace!("Got USBIP_CMD_SUBMIT");
let device = current_import_device.unwrap();
let out = header.direction == 0;
let real_ep = if out { header.ep } else { header.ep | 0x80 };
header.command = USBIP_RET_SUBMIT.into();
let res = match device.find_ep(real_ep as u8) {
None => {
warn!("Endpoint {real_ep:02x?} not found");
UsbIpResponse::usbip_ret_submit_fail(&header)
}
Some((ep, intf)) => {
trace!("->Endpoint {ep:02x?}");
trace!("->Setup {setup:02x?}");
trace!("->Request {data:02x?}");
let resp = device
.handle_urb(
ep,
intf,
transfer_buffer_length,
SetupPacket::parse(&setup),
&data,
)
.await;
match resp {
Ok(resp) => {
if out {
trace!("<-Wrote {}", data.len());
} else {
trace!("<-Resp {resp:02x?}");
}
UsbIpResponse::usbip_ret_submit_success(&header, 0, 0, resp, vec![])
}
Err(err) => {
warn!("Error handling URB: {err}");
UsbIpResponse::usbip_ret_submit_fail(&header)
}
}
}
};
res.write_to_socket(socket).await?;
trace!("Sent USBIP_RET_SUBMIT");
}
UsbIpCommand::UsbIpCmdUnlink {
mut header,
unlink_seqnum,
} => {
trace!("Got USBIP_CMD_UNLINK for {unlink_seqnum:10x?}");
header.command = USBIP_RET_UNLINK.into();
let res = UsbIpResponse::usbip_ret_unlink_success(&header);
res.write_to_socket(socket).await?;
trace!("Sent USBIP_RET_UNLINK");
}
}
}
}
/// Spawn a USB/IP server at `addr` using [TcpListener]
pub async fn server(addr: SocketAddr, server: Arc<UsbIpServer>) {
let listener = TcpListener::bind(addr).await.expect("bind to addr");
let server = async move {
loop {
match listener.accept().await {
Ok((mut socket, _addr)) => {
info!("Got connection from {:?}", socket.peer_addr());
let new_server = server.clone();
tokio::spawn(async move {
let res = handler(&mut socket, new_server).await;
info!("Handler ended with {res:?}");
});
}
Err(err) => {
warn!("Got error {err:?}");
}
}
}
};
server.await
}
// (Host-mode constructors and in-crate tests removed in the vendored copy — see NOTICE.)
+31
View File
@@ -0,0 +1,31 @@
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
/// Parse the SETUP packet of control transfers
#[derive(Clone, Copy, Debug, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SetupPacket {
/// bmRequestType
pub request_type: u8,
/// bRequest
pub request: u8,
/// wValue
pub value: u16,
/// wIndex
pub index: u16,
/// wLength
pub length: u16,
}
impl SetupPacket {
/// Parse a [SetupPacket] from raw setup packet
pub fn parse(setup: &[u8; 8]) -> SetupPacket {
SetupPacket {
request_type: setup[0],
request: setup[1],
value: ((setup[3] as u16) << 8) | (setup[2] as u16),
index: ((setup[5] as u16) << 8) | (setup[4] as u16),
length: ((setup[7] as u16) << 8) | (setup[6] as u16),
}
}
}
@@ -0,0 +1,498 @@
//! USB/IP protocol structs
//!
//! This module contains declarations of all structs used in the USB/IP protocol,
//! as well as functions to serialize and deserialize them to/from byte arrays,
//! and functions to send and receive them over a socket.
//!
//! They are based on the [Linux kernel documentation](https://docs.kernel.org/usb/usbip_protocol.html).
use log::trace;
use std::io::Result;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::UsbDevice;
/// USB/IP protocol version
///
/// This is currently the only supported version of USB/IP
/// for this library.
pub const USBIP_VERSION: u16 = 0x0111;
/// Command code: Retrieve the list of exported USB devices
pub const OP_REQ_DEVLIST: u16 = 0x8005;
/// Command code: import a remote USB device
pub const OP_REQ_IMPORT: u16 = 0x8003;
/// Reply code: The list of exported USB devices
pub const OP_REP_DEVLIST: u16 = 0x0005;
/// Reply code: Reply to import
pub const OP_REP_IMPORT: u16 = 0x0003;
/// Command code: Submit an URB
pub const USBIP_CMD_SUBMIT: u16 = 0x0001;
/// Command code: Unlink an URB
pub const USBIP_CMD_UNLINK: u16 = 0x0002;
/// Reply code: Reply for submitting an URB
pub const USBIP_RET_SUBMIT: u16 = 0x0003;
/// Reply code: Reply for URB unlink
pub const USBIP_RET_UNLINK: u16 = 0x0004;
/// USB/IP direction
///
/// NOTE: Must not be confused with rusb::Direction,
/// which has the opposite enum values. This is only for
/// internal use in the USB/IP protocol.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Direction {
Out = 0,
In = 1,
}
/// Common header for all context sensitive packets
///
/// All commands/responses which rely on a device being attached
/// to a client use this header.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct UsbIpHeaderBasic {
pub command: u32,
pub seqnum: u32,
pub devid: u32,
pub direction: u32,
pub ep: u32,
}
impl UsbIpHeaderBasic {
/// Converts a byte array into a [UsbIpHeaderBasic].
pub fn from_bytes(bytes: &[u8; 20]) -> Self {
let result = UsbIpHeaderBasic {
command: u32::from_be_bytes(bytes[0..4].try_into().unwrap()),
seqnum: u32::from_be_bytes(bytes[4..8].try_into().unwrap()),
devid: u32::from_be_bytes(bytes[8..12].try_into().unwrap()),
direction: u32::from_be_bytes(bytes[12..16].try_into().unwrap()),
ep: u32::from_be_bytes(bytes[16..20].try_into().unwrap()),
};
// The direction should be 0 or 1
debug_assert!(result.direction & 1 == result.direction);
result
}
/// Converts the [UsbIpHeaderBasic] into a byte array.
pub fn to_bytes(&self) -> [u8; 20] {
let mut result = [0u8; 20];
result[0..4].copy_from_slice(&self.command.to_be_bytes());
result[4..8].copy_from_slice(&self.seqnum.to_be_bytes());
result[8..12].copy_from_slice(&self.devid.to_be_bytes());
result[12..16].copy_from_slice(&self.direction.to_be_bytes());
result[16..20].copy_from_slice(&self.ep.to_be_bytes());
result
}
pub(crate) async fn read_from_socket_with_command<T: AsyncReadExt + Unpin>(
socket: &mut T,
command: u16,
) -> Result<Self> {
let seqnum = socket.read_u32().await?;
let devid = socket.read_u32().await?;
let direction = socket.read_u32().await?;
// The direction should be 0 or 1
debug_assert!(direction & 1 == direction);
let ep = socket.read_u32().await?;
Ok(UsbIpHeaderBasic {
command: command.into(),
seqnum,
devid,
direction,
ep,
})
}
}
/// Client side commands from the Virtual Host Controller
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum UsbIpCommand {
OpReqDevlist {
status: u32,
},
OpReqImport {
status: u32,
busid: [u8; 32],
},
UsbIpCmdSubmit {
header: UsbIpHeaderBasic,
transfer_flags: u32,
transfer_buffer_length: u32,
start_frame: u32,
number_of_packets: u32,
interval: u32,
setup: [u8; 8],
data: Vec<u8>,
iso_packet_descriptor: Vec<u8>,
},
UsbIpCmdUnlink {
header: UsbIpHeaderBasic,
unlink_seqnum: u32,
},
}
impl UsbIpCommand {
/// Constructs a [UsbIpCommand] from a socket
///
/// This will consume a variable amount of bytes from the socket.
/// It might fail if the bytes does not follow the USB/IP protocol properly.
pub async fn read_from_socket<T: AsyncReadExt + Unpin>(socket: &mut T) -> Result<UsbIpCommand> {
let version: u16 = socket.read_u16().await?;
if version != 0 && version != USBIP_VERSION {
return Err(std::io::Error::other(format!(
"Unknown version: {version:#04X}"
)));
}
let command: u16 = socket.read_u16().await?;
trace!(
"Received command: {:#04X} ({}), parsing...",
command,
match command {
OP_REQ_DEVLIST => "OP_REQ_DEVLIST",
OP_REQ_IMPORT => "OP_REQ_IMPORT",
USBIP_CMD_SUBMIT => "USBIP_CMD_SUBMIT",
USBIP_CMD_UNLINK => "USBIP_CMD_UNLINK",
_ => "Unknown",
}
);
match command {
OP_REQ_DEVLIST => {
let status = socket.read_u32().await?;
debug_assert!(status == 0);
Ok(UsbIpCommand::OpReqDevlist { status })
}
OP_REQ_IMPORT => {
let status = socket.read_u32().await?;
debug_assert!(status == 0);
let mut busid = [0; 32];
socket.read_exact(&mut busid).await?;
Ok(UsbIpCommand::OpReqImport { status, busid })
}
USBIP_CMD_SUBMIT => {
let header =
UsbIpHeaderBasic::read_from_socket_with_command(socket, USBIP_CMD_SUBMIT)
.await?;
let transfer_flags = socket.read_u32().await?;
let transfer_buffer_length = socket.read_u32().await?;
let start_frame = socket.read_u32().await?;
let number_of_packets = socket.read_u32().await?;
let interval = socket.read_u32().await?;
let mut setup = [0; 8];
socket.read_exact(&mut setup).await?;
let data = if header.direction == Direction::In as u32 {
vec![]
} else {
let mut data = vec![0; transfer_buffer_length as usize];
socket.read_exact(&mut data).await?;
data
};
// The kernel docs specifies that this should be set to 0xFFFFFFFF for all
// non-ISO packets, however the actual implementation resorts to 0x00000000
// https://stackoverflow.com/questions/76899798/usb-ip-what-is-the-size-of-the-iso-packet-descriptor
let iso_packet_descriptor =
if number_of_packets != 0 && number_of_packets != 0xFFFFFFFF {
let mut result = vec![0; 16 * number_of_packets as usize];
socket.read_exact(&mut result).await?;
result
} else {
vec![]
};
Ok(UsbIpCommand::UsbIpCmdSubmit {
header,
transfer_flags,
transfer_buffer_length,
start_frame,
number_of_packets,
interval,
setup,
data,
iso_packet_descriptor,
})
}
USBIP_CMD_UNLINK => {
let header =
UsbIpHeaderBasic::read_from_socket_with_command(socket, USBIP_CMD_UNLINK)
.await?;
let unlink_seqnum = socket.read_u32().await?;
let mut _padding = [0; 24];
socket.read_exact(&mut _padding).await?;
Ok(UsbIpCommand::UsbIpCmdUnlink {
header,
unlink_seqnum,
})
}
_ => Err(std::io::Error::other(format!(
"Unknown command: {command:#04X}"
))),
}
}
/// Converts the [UsbIpCommand] into a byte vector
pub fn to_bytes(&self) -> Vec<u8> {
match *self {
UsbIpCommand::OpReqDevlist { status } => {
let mut result = Vec::with_capacity(8);
result.extend_from_slice(&USBIP_VERSION.to_be_bytes());
result.extend_from_slice(&OP_REQ_DEVLIST.to_be_bytes());
result.extend_from_slice(&status.to_be_bytes());
result
}
UsbIpCommand::OpReqImport { status, busid } => {
let mut result = Vec::with_capacity(40);
result.extend_from_slice(&USBIP_VERSION.to_be_bytes());
result.extend_from_slice(&OP_REQ_IMPORT.to_be_bytes());
result.extend_from_slice(&status.to_be_bytes());
result.extend_from_slice(&busid);
result
}
UsbIpCommand::UsbIpCmdSubmit {
ref header,
transfer_flags,
transfer_buffer_length,
start_frame,
number_of_packets,
interval,
setup,
ref data,
ref iso_packet_descriptor,
} => {
debug_assert!(
header.direction != Direction::Out as u32
|| transfer_buffer_length == data.len() as u32
);
let mut result = Vec::with_capacity(48 + data.len() + iso_packet_descriptor.len());
result.extend_from_slice(&header.to_bytes());
result.extend_from_slice(&transfer_flags.to_be_bytes());
result.extend_from_slice(&transfer_buffer_length.to_be_bytes());
result.extend_from_slice(&start_frame.to_be_bytes());
result.extend_from_slice(&number_of_packets.to_be_bytes());
result.extend_from_slice(&interval.to_be_bytes());
result.extend_from_slice(&setup);
result.extend_from_slice(data);
result.extend_from_slice(iso_packet_descriptor);
result
}
UsbIpCommand::UsbIpCmdUnlink {
ref header,
unlink_seqnum,
} => {
let mut result = Vec::with_capacity(48);
result.extend_from_slice(&header.to_bytes());
result.extend_from_slice(&unlink_seqnum.to_be_bytes());
result.extend_from_slice(&[0; 24]);
result
}
}
}
}
/// Server side responses from the USB Host
#[derive(Clone)]
#[cfg_attr(feature = "serde", derive(Serialize))]
pub enum UsbIpResponse {
OpRepDevlist {
status: u32,
device_count: u32,
devices: Vec<UsbDevice>,
},
OpRepImport {
status: u32,
device: Option<UsbDevice>,
},
UsbIpRetSubmit {
header: UsbIpHeaderBasic,
status: u32,
actual_length: u32,
start_frame: u32,
number_of_packets: u32,
error_count: u32,
transfer_buffer: Vec<u8>,
iso_packet_descriptor: Vec<u8>,
},
UsbIpRetUnlink {
header: UsbIpHeaderBasic,
status: u32,
},
}
impl UsbIpResponse {
/// Converts the [UsbIpResponse] into a byte vector
pub fn to_bytes(&self) -> Vec<u8> {
match *self {
Self::OpRepDevlist {
status,
device_count,
ref devices,
} => {
let mut result = Vec::with_capacity(
12 + devices.len() * 312
+ devices
.iter()
.map(|d| d.interfaces.len() * 4)
.sum::<usize>(),
);
result.extend_from_slice(&USBIP_VERSION.to_be_bytes());
result.extend_from_slice(&OP_REP_DEVLIST.to_be_bytes());
result.extend_from_slice(&status.to_be_bytes());
result.extend_from_slice(&device_count.to_be_bytes());
for dev in devices {
result.extend_from_slice(&dev.to_bytes_with_interfaces());
}
result
}
Self::OpRepImport { status, ref device } => {
let mut result = Vec::with_capacity(320);
result.extend_from_slice(&USBIP_VERSION.to_be_bytes());
result.extend_from_slice(&OP_REP_IMPORT.to_be_bytes());
result.extend_from_slice(&status.to_be_bytes());
if let Some(device) = device {
result.extend_from_slice(&device.to_bytes());
}
result
}
Self::UsbIpRetSubmit {
ref header,
status,
actual_length,
start_frame,
number_of_packets,
error_count,
ref transfer_buffer,
ref iso_packet_descriptor,
} => {
let mut result =
Vec::with_capacity(48 + transfer_buffer.len() + iso_packet_descriptor.len());
debug_assert!(header.command == USBIP_RET_SUBMIT.into());
debug_assert!(if header.direction == Direction::In as u32 {
actual_length == transfer_buffer.len() as u32
} else {
actual_length == 0
});
result.extend_from_slice(&header.to_bytes());
result.extend_from_slice(&status.to_be_bytes());
result.extend_from_slice(&actual_length.to_be_bytes());
result.extend_from_slice(&start_frame.to_be_bytes());
result.extend_from_slice(&number_of_packets.to_be_bytes());
result.extend_from_slice(&error_count.to_be_bytes());
result.extend_from_slice(&[0; 8]);
result.extend_from_slice(transfer_buffer);
result.extend_from_slice(iso_packet_descriptor);
result
}
Self::UsbIpRetUnlink { ref header, status } => {
let mut result = Vec::with_capacity(48);
debug_assert!(header.command == USBIP_RET_UNLINK.into());
result.extend_from_slice(&header.to_bytes());
result.extend_from_slice(&status.to_be_bytes());
result.extend_from_slice(&[0; 24]);
result
}
}
}
pub async fn write_to_socket<T: AsyncWriteExt + Unpin>(&self, socket: &mut T) -> Result<()> {
socket.write_all(&self.to_bytes()).await
}
/// Constructs a OP_REP_DEVLIST response
pub fn op_rep_devlist(devices: &[UsbDevice]) -> Self {
Self::OpRepDevlist {
status: 0,
device_count: devices.len() as u32,
devices: devices.to_vec(),
}
}
/// Constructs a successful OP_REP_IMPORT response
pub fn op_rep_import_success(device: &UsbDevice) -> Self {
Self::OpRepImport {
status: 0,
device: Some(device.clone()),
}
}
/// Constructs a failed OP_REP_IMPORT response
pub fn op_rep_import_fail() -> Self {
Self::OpRepImport {
status: 1,
device: None,
}
}
/// Constructs a successful OP_REP_IMPORT response
pub fn usbip_ret_submit_success(
header: &UsbIpHeaderBasic,
start_frame: u32,
number_of_packets: u32,
transfer_buffer: Vec<u8>,
iso_packet_descriptor: Vec<u8>,
) -> Self {
Self::UsbIpRetSubmit {
header: header.clone(),
status: 0,
actual_length: transfer_buffer.len() as u32,
start_frame,
number_of_packets,
error_count: 0,
transfer_buffer,
iso_packet_descriptor,
}
}
/// Constructs a failed OP_REP_IMPORT response
pub fn usbip_ret_submit_fail(header: &UsbIpHeaderBasic) -> Self {
Self::UsbIpRetSubmit {
header: header.clone(),
status: 1,
actual_length: 0,
start_frame: 0,
number_of_packets: 0,
error_count: 0,
transfer_buffer: vec![],
iso_packet_descriptor: vec![],
}
}
/// Constructs a successful OP_REP_IMPORT response
pub fn usbip_ret_unlink_success(header: &UsbIpHeaderBasic) -> Self {
Self::UsbIpRetUnlink {
header: header.clone(),
status: 0,
}
}
/// Constructs a failed OP_REP_IMPORT response.
pub fn usbip_ret_unlink_fail(header: &UsbIpHeaderBasic) -> Self {
Self::UsbIpRetUnlink {
header: header.clone(),
status: 1,
}
}
}
// (In-crate test module removed in the vendored copy — see NOTICE.)
+10
View File
@@ -0,0 +1,10 @@
/// Check validity of a USB descriptor
pub fn verify_descriptor(desc: &[u8]) {
let mut offset = 0;
while offset < desc.len() {
offset += desc[offset] as usize; // length
}
assert_eq!(offset, desc.len());
}
// (In-crate test module removed in the vendored copy — see NOTICE.)
+6
View File
@@ -34,6 +34,8 @@ holds the full originals.
| [`apple-stage2-presenter.md`](apple-stage2-presenter.md) | Apple stage-2 (VTDecompressionSession + CAMetalLayer) presenter | **Shipped (opt-in)** — make-default + iOS open | | [`apple-stage2-presenter.md`](apple-stage2-presenter.md) | Apple stage-2 (VTDecompressionSession + CAMetalLayer) presenter | **Shipped (opt-in)** — make-default + iOS open |
| [`game-library-stores.md`](game-library-stores.md) | Multi-store game library | **Phases 14 shipped** — 6 providers + 8 Qs open | | [`game-library-stores.md`](game-library-stores.md) | Multi-store game library | **Phases 14 shipped** — 6 providers + 8 Qs open |
| [`dualsense-haptics.md`](dualsense-haptics.md) | DualSense advanced-haptics feasibility | **HID shipped**; audio haptics deferred (3 walls) | | [`dualsense-haptics.md`](dualsense-haptics.md) | DualSense advanced-haptics feasibility | **HID shipped**; audio haptics deferred (3 walls) |
| [`steam-controller-deck-support.md`](steam-controller-deck-support.md) | Rich Steam Controller / Steam Deck **input fidelity** (paddles · trackpads · gyro → virtual `hid-steam`) | **Design + M0 GREEN** (Linux bind proven); M1+ open |
| [`controller-only-mode.md`](controller-only-mode.md) | Controller-only **session shape** — Deck/desktop as a remote gamepad, no video/audio (complements ↑) | **Design** — not yet implemented |
| [`archive/windows-secure-desktop.md`](archive/windows-secure-desktop.md) | Two-process WGC secure-desktop design | **Archived** — shipped but now a fallback (IDD-push primary) | | [`archive/windows-secure-desktop.md`](archive/windows-secure-desktop.md) | Two-process WGC secure-desktop design | **Archived** — shipped but now a fallback (IDD-push primary) |
Plus `research/gamestream-protocol-research.json` — raw Moonlight/GameStream wire reference (data, not prose). Plus `research/gamestream-protocol-research.json` — raw Moonlight/GameStream wire reference (data, not prose).
@@ -74,6 +76,10 @@ owning doc.)
**Game library** **Game library**
- 6 remaining providers (Desktop/Flatpak, itch.io, Ubisoft Connect, Amazon Games, Battle.net, EA app); the `/library/art/<entryId>/<slot>` mgmt endpoint; refactor `library.rs` into a `library/` dir; 8 open design questions; optional SteamGridDB v2 enrichment. → `game-library-stores` - 6 remaining providers (Desktop/Flatpak, itch.io, Ubisoft Connect, Amazon Games, Battle.net, EA app); the `/library/art/<entryId>/<slot>` mgmt endpoint; refactor `library.rs` into a `library/` dir; 8 open design questions; optional SteamGridDB v2 enrichment. → `game-library-stores`
**Controllers / input**
- Rich Steam Controller / Steam Deck capture + virtual `hid-steam` inject (M1+ — Linux UHID, then clients, then deferred Windows UMDF). → `steam-controller-deck-support`
- Controller-only session shape (Deck/desktop as a remote gamepad, no video/audio) — `session_flags`/`SESSION_INPUT_ONLY` protocol bit + host skip-data-plane branch + client controller-only path. → `controller-only-mode`
**Multi-user / sessions** **Multi-user / sessions**
- gamescope per-session input/audio isolation (independent desktops) — the 4 plumbing items, deferred. → `gamescope-multiuser`, `implementation-plan` - gamescope per-session input/audio isolation (independent desktops) — the 4 plumbing items, deferred. → `gamescope-multiuser`, `implementation-plan`
+299
View File
@@ -0,0 +1,299 @@
# Controller-only mode (Deck / desktop as a remote gamepad)
> **Status:** **DESIGN — not yet implemented.** Locked decisions (2026-06-29): build the
> **full-fidelity** path directly (no plain-Xbox interim), capture this as a doc before code.
> This is the **session-shape** complement to `design/steam-controller-deck-support.md` (which is
> the **input-fidelity** work: capturing the Deck's paddles/trackpads/gyro and injecting a virtual
> `hid-steam`/DualSense device). Controller-only mode reuses that capture + inject pipeline
> verbatim; it only adds a negotiated "no video / no audio" session shape so the Deck can be a
> wireless gamepad for a PC **without** the wasteful return video stream.
## 1. Goal + the use case
Let a punktfunk client (a Steam Deck, but also any desktop with a controller) connect to a
punktfunk host and forward **only controller input** — no video stream, no audio stream. The host
user watches their **own** monitor; the Deck is just a wireless, full-fidelity gamepad. Rumble /
lightbar / adaptive-trigger / HID feedback still flows back on the existing side planes.
Concretely this turns the Deck into:
- a **couch controller** for a PC wired to a TV — gyro aim, both trackpads, the 4 back grips, with
**lower** latency than streaming (no encode/decode round-trip at all), and no bandwidth wasted on
a video feed you aren't looking at;
- one of **several** Decks/pads driving one shared-screen PC for local co-op (rides the multi-pad
work already in flight);
- a way to use the Deck's superior input surface (trackpads + gyro) on a game running on a
beefier host.
**Non-goal (for v1):** forwarding a real keyboard/mouse. The Deck's trackpads/gyro are carried as
*gamepad* fidelity (DualSense/Steam touchpad + motion planes), which is display-independent (§5).
A genuine keyboard/mouse forward rides the libei/portal pointer path, which is *not*
display-independent — deferred to a follow-on (§9).
## 2. Does SteamOS already do this? — No, and that's the opening
Researched 2026-06-29 (web). Summary of why this is worth building:
- **Officially**, Valve's only endorsed "Deck as a controller for another PC" path is **Steam
Remote Play / Steam Link in reverse** — but it **always streams the game's video to the Deck**
even though you're looking at the PC's monitor. A dedicated controller-only mode has been
requested since **July 2022** (Steam community thread) and the matching `ValveSoftware/SteamOS`
issue **#1623 is "Closed as not planned."** SteamOS 3.8 (Jun 2026) and the new standalone Steam
Controller (2026 hardware) did **not** add it.
- **Community/DIY** splits three ways, all low-popularity (25129★), several stale or "currently
broken": USB-C HID gadget (**GadgetDeck** — wired-only, BIOS Dual-Role toggle, no gyro/trackpad,
unmaintained), Bluetooth HID (**steamdeck-bt-controller-emulator** — pairing/recognition pain + BT
latency), and network (**Deckpad** → commercial *paid* VirtualHere USB-over-IP, "currently
broken"; **swicd-remote-gamepad** → ViGEm, Windows-only, experimental).
- **The ceiling every existing tool hits:** none cleanly carries the Deck's **gyro + dual trackpads
+ the 4 back buttons (L4/L5/R4/R5)** to the remote host, because Steam's emulated Xbox pad
(`28DE:11FF`) hides them.
punktfunk is uniquely positioned: it already has a low-latency QUIC input back-channel, host-side
virtual Xbox-360 / DualSense / DS4 (and, in flight, `hid-steam`) pad injection with rumble/lightbar/
adaptive-trigger feedback, and SDL3 capture. Controller-only mode + the
`steam-controller-deck-support.md` capture work together deliver exactly the gap nobody else fills.
Sources (load-bearing): SteamOS issue #1623 (closed not-planned)
<https://github.com/ValveSoftware/SteamOS/issues/1623>; Steam community controller-only-mode request
<https://steamcommunity.com/app/1675200/discussions/2/3466100515592011642/>; Valve FAQ
<https://help.steampowered.com/en/faqs/view/0689-74B8-92AC-10F2>; GadgetDeck
<https://github.com/Frederic98/GadgetDeck>; Deckpad <https://github.com/HelloThisIsFlo/Deckpad>;
Steam Deck HID deep-dive <https://blogs.gnome.org/alicem/2024/10/24/steam-deck-hid-and-libmanette-adventures/>.
## 3. The key synergy: controller-only mode *dissolves* the Game-Mode capture wall
`steam-controller-deck-support.md` §6 / Wall A: on the Deck, SDL3's HIDAPI driver can open the raw
`28DE:1205` and expose paddles + both trackpads + gyro as a first-class SDL gamepad — **but in Deck
Game Mode, Steam Input grabs the device exclusively** and re-presents it as the gutted `28DE:11FF`
virtual XInput pad, so the rich controls silently vanish. The only escape there is the
disable-Steam-Input-per-title UX.
**Controller-only mode's natural launch context avoids that wall entirely.** The use case is "the
Deck is a controller, no game runs on the Deck" → it runs as a **desktop-mode / standalone app**,
where Steam Input is **not** managing the internal pad, so SDL3 binds `28DE:1205` and gets full
fidelity with no UX gymnastics. So the two features are mutually reinforcing: controller-only mode
is the very scenario in which full-fidelity Deck capture "just works."
The capture-side rule is therefore the same one §6 documents, and the client **must verify at
runtime it opened `28DE:1205` (HIDAPI GUID ending `6800`), not `28DE:11FF`** — if it only sees
`11FF`, Steam owns the pad and gyro/trackpad/grips are unavailable; surface that to the user.
## 4. Architecture — input plane is already decoupled from video
A punktfunk/1 native session already runs **two independent transports** (verified in-tree):
- a **QUIC** control connection that carries the `Hello`/`Welcome`/`Start` handshake **and every
side plane** as datagrams demuxed by first byte: input `0xC8`, rich input `0xCC` (DualSense/Deck
touchpad + motion), mic uplink `0xCB`, audio `0xC9`, rumble `0xCA`, HID-out `0xCD`;
- a **raw-UDP data plane** (`Session`, FEC + AES-GCM) that carries **only** the video AUs.
**Input never touches the UDP data plane** — it rides QUIC datagrams. So an input-only session is
"run the QUIC handshake + side planes, never bind/open the UDP data plane." The honest work is
making **both** ends *skip the data plane* and, on the host, *not spin up a virtual display +
encoder* (a desktop PC the operator is watching has no reason to allocate a headless virtual output
or burn an NVENC slot).
Critically: **gamepads are system-global kernel devices, not tied to any virtual output.** The
per-session `PadBackend` (`punktfunk1.rs:1396`) creates an Xbox-360 pad on `/dev/uinput`, or a
DualSense/DS4/Steam pad on `/dev/uhid` — all visible to Steam/Proton/every game on the host's real
seat with **zero** display involvement. Rumble/HID feedback (`0xCA`/`0xCD`) and DualSense/Deck
touchpad+motion (`0xCC` rich input, `apply_rich` at `:1467`/`:1606`) flow on the same per-session
input thread, also display-independent. So a controller-only session needs **no virtual display, no
compositor, no portal grant, no encoder** — just the input thread + the pad backend.
`clients/probe --input-test` already proves the shape: it connects, streams scripted gamepad
datagrams the host injects into a real pad, and never decodes video.
## 5. Protocol / ABI change — one `session_flags` byte (additive, fwd-compatible)
Reuse the **exact** trailing-byte back-compat discipline `Hello`/`Welcome` already apply to
`compositor`/`gamepad`/`video_caps`/`audio_channels` (`quic.rs:655-882`). Add a **session-flags**
byte as the new last trailing field on both.
```
quic.rs (core):
pub const SESSION_INPUT_ONLY: u8 = 0x01; // bit 0 of session_flags
Hello { …, audio_channels: u8, session_flags: u8 } // new trailing byte after audio_channels
Welcome { …, audio_channels: u8, session_flags: u8 } // echoes the resolved shape (offset 66)
```
### 5.1 Encode (placeholder discipline)
`Hello::encode` already emits `video_caps` / `audio_channels` only when non-default, emitting
upstream placeholders so each lands at a deterministic offset. Extend the same logic:
```
need_placeholders = video_caps != 0 || audio_channels != 2 || session_flags != 0;
// video_caps emitted when video_caps != 0 || audio_channels != 2 || session_flags != 0
// audio_channels emitted when audio_channels != 2 || session_flags != 0
// session_flags emitted when session_flags != 0 (one more trailing byte, after audio_channels)
```
`Hello::decode` reads it one past `audio_channels` (i.e. `video_caps_off + 2`), defaulting to `0`
(no flags) when absent → an older peer requests an ordinary video session, byte-identical wire.
`Welcome` is simpler (fixed-position trailing bytes): append `session_flags` at **offset 66**,
`b.get(66).copied().unwrap_or(0)`.
> **Why a flags byte, not an overloaded `Mode{0,0,0}` sentinel:** a flag is explicit and leaves
> room for future session-shape bits (e.g. audio-only, input+audio). `Mode` stays strictly a
> display mode. (Open question 7.1 — confirm with the user, but this is the recommendation.)
### 5.2 ABI
- New `connect_ex` rung in the existing ladder (the `connect_exN` precedent) that takes a
`session_flags` (or a bool `input_only`) and stores it on `NativeClient`; legacy `connect_ex*`
stay byte-for-byte. Regenerate `include/punktfunk_core.h` (CI fails on drift).
- New constant `PUNKTFUNK_SESSION_INPUT_ONLY = 0x01`.
- `next_au` / `next_frame` / `next_audio` simply return `NoFrame`/`Closed` in an input-only
connection; `send_input` / `send_rich_input` / `next_rumble` / `next_hidout` are unchanged — they
are already the full input-only surface.
## 6. Host changes — `serve_session` branch (`punktfunk1.rs:508`)
Branch on `hello.session_flags & SESSION_INPUT_ONLY`. When set:
| Step | Today (video session) | Input-only |
|---|---|---|
| `--max-concurrent` permit (`:640` `_permit`) | acquired (NVENC slot) | **skip** — input-only must not consume a GPU slot (§7.4) |
| `validate_dimensions` (`:654`) | required | **skip** |
| `resolve_compositor` (`:668`) | required (Virtual source) | **skip** — no virtual display |
| bit-depth / chroma / HDR / 444 probes, bitrate clamp | run | **skip** |
| `Welcome` (`:794`) | real mode + udp_port + caps | **sentinel** mode `{0,0,0}` + `udp_port=0` + `session_flags=INPUT_ONLY`; still carries the resolved `gamepad` backend |
| `Start::decode` (`:845`) | read client udp port | read + **ignore** (harmless no-op) |
| `input_thread` spawn (`:980`) | spawn | **spawn (unchanged)** — this is the whole point |
| client→host datagram demux (`:989`) | spawn | **spawn (unchanged)**`0xC8`/`0xCC`/`0xCB` in, `0xCA`/`0xCD` out |
| `audio_thread` (`:1032`, already gated on `source==Virtual`) | spawn | **skip** (add `&& !input_only`) |
| `virtual_stream(SessionContext{…})` (`:1155`) — the only place a display+encoder open and the UDP socket binds | run | **replace** with `await stop / conn.closed()` — no UDP bind happens (the bind lives inside that block), no display, no encoder |
| teardown (`:1190+`) | releases input thread + pads + held keys | **unchanged** — already correct |
Net host change is mostly **guards and deletions in one function**; no hot-path, FEC, crypto, or
injector changes. `cfg`-clean on Windows (no compositor/`virtual_stream` there either; the XUSB/UMDF
pads are likewise system-global, SendInput is irrelevant in a gamepad-only mode).
### 6.1 Pad backend / fidelity (reuses `steam-controller-deck-support.md` verbatim)
The full-fidelity path is **entirely** the in-flight Deck/DualSense inject work — controller-only
mode adds nothing here, it just runs it without video:
- Deck → host resolves the **Steam `hid-steam`** backend (Linux UHID) when available so Steam Input
re-emits `28DE:11FF` with the user's bindings + correct glyphs; trackpads → `RichInput::TouchpadEx`
(`0xCC 0x03`), gyro/accel → `RichInput::Motion` (`0xCC 0x02`), back grips → `BTN_PADDLE1..4`
(`0xC8` bits). See that doc §4–§7.
- Where a real Steam pad is unavailable, the **DualSense remap fallback** (`steam_remap.rs`) folds
Steam-only inputs into a virtual DualSense (gyro→motion, right pad→touchpad, grips→configured
fallback) so nothing is silently dropped.
- `GamepadPref` resolution policy is that doc's §7 — **unchanged**; the Welcome echoes the real
resolved backend (honest downgrade).
## 7. Client changes
### 7.1 Core connector (`client.rs::worker_main:714`)
Thread an `input_only` flag in. When set: do the full `Hello`/`Welcome` handshake and spawn the
input/mic/rich/ctrl/datagram-demux tasks (so `send_input`/`send_rich_input` + `next_rumble`/
`next_hidout` keep working), but **skip** the UDP port reservation + `Start`-derived
`UdpTransport::connect` + `Session` + the data-plane pump (`:810-849,1041`). `next_frame`/
`next_audio` return `Closed`/`NoFrame`.
### 7.2 Deck / Linux client (`clients/linux`) — a "Use as controller" path
Add a connect path that opens the connection **input-only** and runs **only** the app-lifetime
`GamepadService` (`clients/linux/src/gamepad.rs`) — it already `attach`es the connector, forwards
pads via `send_input` (`:161`), DualSense/Deck touchpad+motion via `send_rich_input`, and drains
`next_rumble` (`:566`)/`next_hidout` (`:583`). **Do not** run `session.rs`'s video/audio pump
(`:135-200`) — no decoder, no video window, no PipeWire player. UI is a minimal "Connected as
controller — <backend> · <host>" status surface (battery, latency, a Disconnect button), no video
widget.
On the Deck specifically: set `SDL_JOYSTICK_HIDAPI_STEAMDECK`/`HIDAPI_STEAM` before `sdl3::init()`,
resolve `GamepadPref::SteamDeck` from VID `0x28DE`, and **assert the opened device is `28DE:1205`
(HIDAPI GUID `…6800`), not `11FF`** — if `11FF`, show "Steam is managing this controller; full
gyro/trackpad/grip fidelity needs desktop mode / Steam Input off." (Capture mechanics =
`steam-controller-deck-support.md` §6.)
### 7.3 Other clients
`clients/windows` gets the same input-only connect + SDL `GamepadService`-only path (XUSB/UMDF pads
are system-global; no SwapChainPanel/decode). Apple/Android: parity is optional and lower-priority —
the connector ABI already exposes everything; a "controller-only" UI mode is a small add once the
core flag lands. Scope per the user's roadmap, not blocking.
### 7.4 Session lifetime / accounting
An input-only session is long-lived (until disconnect), like a video session, but must **not**
count against `--max-concurrent` (it holds no NVENC) and should be **exempt** from any `--seconds`
duration cap. The mgmt API / web console should list it distinctly (it is **not** a "stream" — no
fps/bitrate/mode), and `--max-sessions` accounting should likely treat it as its own class
(open question 8.x). mDNS advertisement is unchanged (the host already advertises native service;
input-only is a per-session negotiation, not a service variant).
## 8. Security / trust — unchanged
A controller-only session is a **full punktfunk/1 session**: same SPAKE2 PIN pairing / TOFU /
`--require-pairing` gate, same QUIC client-auth + pinned-fingerprint trust. The *only* difference is
the absent data plane. No new attack surface — if anything less (no UDP socket, no FEC reassembler,
no decoder fed attacker bytes). The host still requires `/dev/uinput` (+ `/dev/uhid` for DualSense/
Steam) writable — the documented `input` group + `60-punktfunk.rules` setup.
## 9. Milestones
- **M1 — protocol + ABI:** `session_flags` byte + `SESSION_INPUT_ONLY` on `Hello`/`Welcome` with
round-trip + old-peer-default unit tests; new `connect_ex` rung; regenerate the C header.
- **M2 — host branch:** `serve_session` input-only path (skip display/encoder/audio/permit, keep
input thread + pads), `cfg`-clean on Windows. Loopback test: handshake completes, **no UDP data
plane binds**, gamepad events still inject into a real uinput pad.
- **M3 — Deck/Linux client:** "Use as controller" connect + `GamepadService`-only run; `28DE:1205`
vs `11FF` runtime check + user messaging.
- **M4 — full fidelity on-glass:** with `steam-controller-deck-support.md`'s Deck capture + inject,
validate Deck (desktop mode) → host: paddles + both trackpads + gyro reach the host pad, Steam
Input re-emits with bindings/glyphs, rumble returns. Glass-to-glass input latency vs a wired pad.
- **M5 — Windows client parity + mgmt/web "controller session" surfacing.**
- **M6 (deferred) — keyboard/mouse forward** over the libei/portal path (needs the active-session
RemoteDesktop grant; not display-independent).
## 10. Risks / open questions
**Open questions (decide with the user):**
1. **`session_flags` byte vs `Mode{0,0,0}` sentinel** for "no video" — recommend the explicit flags
byte (room for future shapes). *(Recommended; confirm.)*
2. Should an input-only session be **exempt** from `--max-concurrent` and `--seconds`? (Recommend
yes — it holds no GPU.)
3. Should mgmt/web track controller-only sessions as a distinct class (no fps/bitrate/mode), and
should `--max-sessions` count them?
4. Deck default backend: **Steam `hid-steam`** (best — Steam Input bindings/glyphs) vs **DualSense**
(works off-Steam too). Tie to `steam-controller-deck-support.md`'s resolution policy.
**Risks:**
- **Capture wall (inherited):** full fidelity requires SDL to bind `28DE:1205`; if the Deck is in
Game Mode / Steam owns the pad, it degrades to `11FF` (sticks/buttons only). Mitigated by the
desktop-mode use case + runtime check + user messaging (§3, §7.2).
- **Host `/dev/uinput`(+`/dev/uhid`) perms** — a normal desktop PC operator must do the one-time
input-group/udev setup for the virtual pad to appear (already documented).
- **Handshake assumes a data plane:** `Start{client_udp_port}` + non-zero `udp_port` in `Welcome`
must become harmless no-ops (send 0 / ignore) without shifting the legacy wire — the trailing-byte
placeholder discipline is fragile; **add round-trip + old-peer tests** (M1).
- **Control-channel messages** (LossReport/Reconfigure/Probe/ClockProbe) assume a data plane; the
host (`:881`) + client (`:935`) control tasks must tolerate an input-only session with none —
mostly already fine since those are reactive.
- **Windows parity:** verify the input-only branch compiles `cfg`-clean where there is no
compositor/`virtual_stream`.
## 11. Validation plan
**Loopback (no hardware):**
- `quic.rs`: `session_flags` encode/decode round-trip; old-peer (no flags byte) → ordinary session;
flags coexist with non-default `video_caps`/`audio_channels` (placeholder offsets hold).
- Host: an input-only synthetic host+client asserting (a) handshake completes, (b) **no UDP socket
binds**, (c) no display/encoder opens, (d) scripted `0xC8`/`0xCC` events inject into a real pad,
(e) `0xCA`/`0xCD` feedback returns. Extend the `clients/probe` / `test-loopback.sh` harness.
**On-box / on-glass (with `steam-controller-deck-support.md` landed):**
- Deck (desktop mode) → this host, controller-only: confirm `28DE:1205` opens (not `11FF`); paddles
+ both trackpads + gyro reach the host pad; Steam Input on the host re-emits with bindings/glyphs;
rumble round-trips; measure input-only latency vs a wired pad and vs the streaming path.
- Confirm the host opens **no** virtual output / encoder (logs) and the session does **not** consume
a `--max-concurrent` slot (run alongside N video sessions).
+9 -6
View File
@@ -26,24 +26,27 @@ remedy, are deferred/accepted with a reason.
| #2 | High | **FIXED** (`6f903f7`, *Win CI/box pending*) — mgmt token written via `write_secret_file` (SYSTEM/Admins DACL) | | #2 | High | **FIXED** (`6f903f7`, *Win CI/box pending*) — mgmt token written via `write_secret_file` (SYSTEM/Admins DACL) |
| #3 | High | **FIXED** (`6f903f7`, *Win CI/box pending*) — config dir DACL-locked + re-owned; `host.env` locked. Residual: a host.env planted before the very first DACL apply is still loaded (an owner-check on load is a noted follow-up) | | #3 | High | **FIXED** (`6f903f7`, *Win CI/box pending*) — config dir DACL-locked + re-owned; `host.env` locked. Residual: a host.env planted before the very first DACL apply is still loaded (an owner-check on load is a noted follow-up) |
| #4 | High→Med | **FIXED** (`3532e35`) — RTSP/PLAY gated on a paired `/launch` + bound to the launching peer's IP | | #4 | High→Med | **FIXED** (`3532e35`) — RTSP/PLAY gated on a paired `/launch` + bound to the launching peer's IP |
| #5 | Med | **DEFERRED** — the shared-section SDDL is permissive for a restricted-token UMDF driver; scoping it needs on-box validation to avoid breaking the live-validated gamepad/IDD pipeline | | #5 | Med | **FIXED + on-box validated** (`e59fa60`, 2026-06-29) — section SDDL scoped to `D:(A;;GA;;;SY)(A;;GA;;;LS)`. The "restricted-token" premise was wrong: the WUDFHost token is LocalService, SYSTEM integrity, **zero** restricted SIDs. Validated live on the RTX box — a DualSense+IDD session works (6943 frames, HID round-trip; pf_dualsense + pf_vdisplay WUDFHosts both LocalService) while `OpenFileMapping` from a non-SYSTEM admin session now returns ACCESS_DENIED (was a granted handle under `WD`) |
| #6 | Med | **FIXED** (`3532e35`) — EIS relay moved to `$XDG_RUNTIME_DIR` (0700) + symlink reject | | #6 | Med | **FIXED** (`3532e35`) — EIS relay moved to `$XDG_RUNTIME_DIR` (0700) + symlink reject |
| #7 | Med→Low | **FIXED** (`3532e35`) — `vdisplay::ENV_LOCK` serializes setup-path env mutation (data-race UB closed); full per-session `SessionContext` threading for value-confusion is a follow-up | | #7 | Med→Low | **FIXED** (`3532e35`) — `vdisplay::ENV_LOCK` serializes setup-path env mutation (data-race UB closed); full per-session `SessionContext` threading for value-confusion is a follow-up |
| #8 | Low | **FIXED** (`6f903f7`, *Win CI/box pending*) — web-password file created empty → locked → written | | #8 | Low | **FIXED** (`6f903f7`, *Win CI/box pending*) — web-password file created empty → locked → written |
| #9 | Low | **ACCEPTED** — disarm-on-any-attempt IS the documented single-online-guess (prior-fix #2); the delegated-approval flow is structurally immune. Steer hostile LANs to it | | #9 | Low | **FIXED** (`f0574a5`) — a red-team showed the original "delegated approval is the immune fallback" premise was circular with #13. Added a **fingerprint-bound PIN window** (`arm_for`/`pin_for_attempt`): an attempt from any other fingerprint is rejected WITHOUT consuming the window, so an unpaired peer can't pair *or* burn a window armed for a specific device. (Disarm-on-attempt still gives the single-online-guess for the unbound flow; with #13 now flood-resistant, the knock fallback genuinely holds. Web "pair-this-pending-device-with-a-PIN" UX is a follow-up.) |
| #10 | Low | **FIXED** (`3532e35`) — ENet decrypt-failed warn throttled (exponential) | | #10 | Low | **FIXED** (`3532e35`) — ENet decrypt-failed warn throttled (exponential) |
| #11 | Low | **FIXED** (`6f903f7`, *Win CI/box pending*) — logs dir DACL-locked (subsumed by #3) | | #11 | Low | **FIXED** (`6f903f7`, *Win CI/box pending*) — logs dir DACL-locked (subsumed by #3) |
| #12 | Low/Info | **FIXED** (`3532e35`) — parked pairing-waiter cap (+regression test) | | #12 | Low/Info | **FIXED** (`3532e35`) — parked pairing-waiter cap (+regression test) |
| #13 | Info | **ACCEPTED** `PENDING_CAP` + LRU + `requested_at` refresh make an actively-retrying device non-evictable | | #13 | Info→Low | **FIXED** (`f0574a5`) — the "actively-retrying device is non-evictable" claim was really a timing race (and the designed flow parks, doesn't re-knock). Added a **per-source-IP cap** (`MAX_PENDING_PER_IP`, QUIC-validated source) so one host can't fill/evict the queue, and eviction now **never drops a live parked knock** — making the delegated-approval path genuinely flood-resistant |
| S2 | LowMed | **FIXED** (`3532e35`) — a malformed Opus frame drops the frame, keeps the shared mic open | | S2 | LowMed | **FIXED** (`3532e35`) — a malformed Opus frame drops the frame, keeps the shared mic open |
| S3 | Low | **FIXED** (`3532e35`) — held buttons/keys are capped `HashSet`s | | S3 | Low | **FIXED** (`3532e35`) — held buttons/keys are capped `HashSet`s |
| S4 | Low | **FIXED** (`3532e35`) — Epic launcher-cache reads size-capped | | S4 | Low | **FIXED** (`3532e35`) — Epic launcher-cache reads size-capped |
| S5 | Low→Info | **FIXED** (`3532e35`) — `fps==0`/absurd rejected at the `open_video` chokepoint | | S5 | Low→Info | **FIXED** (`3532e35`) — `fps==0`/absurd rejected at the `open_video` chokepoint |
| S6 | Low→Info | **FIXED** (`3532e35`) — shared mic mpsc bounded (drop-newest) | | S6 | Low→Info | **FIXED** (`3532e35`) — shared mic mpsc bounded (drop-newest) |
| S7 | Low→Info | **ACKNOWLEDGED**`rsa 0.9` Marvin has no fixed upstream release; GameStream is off by default and this is a signing (not decryption-oracle) path. Migrate the GameStream identity to Ed25519/ECDSA when feasible | | S7 | Low→Info | **ACCEPTED, rationale corrected + hardened** (`f6c9576`) — the prior "signing, not decryption, so the path isn't exercised" reason was *wrong* (signing runs the same secret-exponent modexp Marvin is about). Accept stands for the right reasons: no decryption/padding oracle; the signed `serversecret` is host-random (not attacker-chosen); signing is operator-PIN-gated; GameStream is off by default and the native plane uses rustls not `rsa`; Moonlight mandates RSA-2048 (no Ed25519 migration possible). Also closed the timing-sample amplifier: sign once per ceremony. No upstream `rsa` fix exists |
**Net:** 14 of 18 fixed (5 Linux-verified clusters + 4 Windows DACL paths awaiting CI/box); #5 **Net:** 17 of 18 fixed 5 Linux-verified clusters, 4 Windows DACL paths (#2/#3/#8/#11, awaiting CI
deferred pending on-box validation; #9/#13 accepted-with-rationale; S7 acknowledged (no upstream fix). compile-confirm), #5 (on-box validated on the RTX box, 2026-06-29), and #9/#13 (closed after a
red-team showed their accepts were circular). S7's accept stands but its rationale was corrected and
the timing amplifier hardened; only the transitive `rsa 0.9` advisory itself is un-fixable (no
upstream release). No finding remains open and actionable.
## Consolidated overview & top priorities ## Consolidated overview & top priorities
+836
View File
@@ -0,0 +1,836 @@
# Rich Steam Controller & Steam Deck support
> **Status:** **M0M6 GREEN — full pipeline + fallback + conflict gate built (2026-06-29).** Host:
> the virtual `hid-steam` Deck binds, is byte-exact, is a wired backend (`PUNKTFUNK_GAMEPAD=steamdeck`),
> the protocol carries the rich inputs, the **fallback remap** keeps them from silently dropping, and
> the **conflict gate** keeps a virtual Steam pad off a host that already has a physical one. Clients:
> the Linux + Windows SDL clients capture + send them; the Decky plugin has the Steam Deck mode +
> Disable-Steam-Input UX; the C-ABI has the `TouchpadEx` send path; Apple/Android round-trip the type.
>
> **⚠ Hardware finding that reframes the ceiling (2026-06-29, §11):** a UHID virtual Deck binds the
> kernel `hid-steam` (so the **kernel evdev + SDL-hidapi** consumers see the full surface — grips,
> trackpads, IMU) but **Steam Input will NOT manage it** — Steam filters the Deck's controller to USB
> **interface 2**, and a single UHID device reports interface `-1`. So the virtual Deck's value is for
> **non-Steam / SDL games on Linux**, not Steam Input; the **virtual DualSense** stays the right path
> for Steam-Input hosts (Steam recognizes a single-interface DualSense). **Recommendation: do NOT
> build M7** (a Windows virtual Deck would hit the same filter with no kernel-evdev fallback — nothing
> on Windows would consume it). Remaining is validation only (Moonlight paddle regression; a live
> SDL-game consume test).
>
> **M6 (conflict gate) result — validated on real hardware (a SteamOS Deck + a Bazzite host running
> Steam, 2026-06-29):** (a) **Empirical conflict confirmed.** A Deck-as-host already has its physical
> `28DE:1205` *and* Steam's `28DE:11FF` XInput output pad live — so a second virtual `28DE` makes Steam
> juggle two Decks. (b) **Bind robustness:** the virtual Deck binds `hid-steam` on a *second*
> independent kernel (Bazzite 6.17.7, vs the dev box 7.0) and the kernel accepted our serial (the M1
> report-id-0 fix). (c) **Criterion-4 (running-Steam recognition) — RESOLVED, negative for Steam
> Input (this is the third wall, §2).** Steam's `controller.txt` *enumerates* the virtual Deck
> (`Local Device Found, type: 28de 1205, Product "Punktfunk Steam Deck"`) but logs **`Interface:
> -1`** and never promotes it (no `28DE:11FF` pad, no "Controller connected"). On the same Steam logs
> the **physical** Deck is **`Interface: 2`** — a real Deck is a 3-interface USB device (keyboard 0,
> mouse 1, **controller 2**), and Steam binds the controller on interface 2. A single UHID device has
> no USB interface number → `-1` → Steam skips it. `hid-steam` binds by VID/PID regardless (so the
> kernel evdevs + SDL-hidapi path work), **but Steam Input itself will not manage a UHID virtual
> Deck.** (The feared `0x83`/`0xA1` attribute probes never fired — it's an interface filter, not a
> probe-reject.) See §11 for what this means + the M7 recommendation. **Policy (code):**
> `physical_steam_controller_present()` (scans `/sys/bus/hid/devices` for a non-virtual `28DE`) +
> `degrade_steam_on_conflict()` in `resolve_gamepad` — a resolved `SteamDeck`/`SteamController` on a
> host with a physical Steam controller degrades to DualSense (then the uhid ladder), overridable with
> `PUNKTFUNK_STEAM_FORCE=1`. Heuristic hardware-checked: **TRUE on the Deck, FALSE on Bazzite.**
> Workspace clippy/fmt/test green.
>
> **M5 (fallback remap + degrade ladder) result:** new pure, unit-tested `inject/proto/steam_remap.rs`:
> (1) **motion rescale** `motion_wire_to_deck` — the wire carries DualSense-convention units (what
> every client capture emits), the Deck's `hid-steam` report wants 16 LSB/°·s + 16384 LSB/g, so the
> Deck backend now rescales (gyro ×16/20, accel ×16384/10000) — a real **Deck↔Deck gyro/accel
> correctness fix**; (2) **`fold_paddles`** + `RemapConfig` (`PUNKTFUNK_STEAM_REMAP=paddles=drop|
> stickclicks|shoulders`, default drop) wired into the DualSense + DS4 managers so a client's back
> grips aren't silently lost on a PlayStation fallback (those pads have no back-button HID slot; the
> uinput Xbox pad already exposes them as `BTN_TRIGGER_HAPPY5-8`). Plus a **runtime degrade ladder**
> in `resolve_gamepad`: a UHID backend (DualSense/DS4/SteamDeck) on a host where `/dev/uhid` isn't
> writable now falls back to the uinput Xbox 360 pad instead of a dead controller. The throwaway M0/M1
> spike is deleted (M2's `#[ignore]`d backend test subsumes it). On-box backend test still green;
> workspace clippy/fmt/test green. *Deferred as optional `RemapConfig` growth: gyro→mouse / trackpad→
> stick/mouse synthesis on an Xbox target (no IMU/touchpad slot — currently a documented drop).*
>
> **M4 (complete) result:** *(desktop capture — see the prior entry.)* Plus: **C-ABI**
> `PunktfunkRichInputEx` (size-prefixed superset) + `punktfunk_connection_send_rich_input2` (the
> `struct_size` skew-guard precedent; the only way a C client emits `TouchpadEx`); legacy
> `PunktfunkRichInput`/`send_rich_input` byte-for-byte; `punktfunk_core.h` regenerated. **Decky**
> a "Steam Deck" gamepad option + an unmissable **Disable-Steam-Input** instruction (shown when
> selected) + a best-effort feature-detected programmatic flip in `launchStream` (never throws; the
> manual toggle is the source of truth). **Apple/Android parity** — `GamepadType.steamController/
> steamDeck` (Swift) + `PREF_STEAMCONTROLLER/STEAMDECK` + the `0x28DE` PIDs in `prefFor` (Kotlin), so
> the type round-trips; capture stays out of scope there (iOS GameController won't surface a `28DE`
> device; Android has no rich-input plane yet). Rust workspace clippy/fmt/test green; Decky `src/`
> typechecks clean; Swift/Kotlin compile on their CI.
>
> **Pending VALIDATION (construction is done; M7 is NOT recommended — §11):** (1) running-Steam
> recognition is **RESOLVED** — Steam won't promote a UHID virtual Deck (interface filter, §11); the
> virtual Deck serves non-Steam/SDL games, the virtual DualSense serves Steam Input. (2) A **live
> SDL/non-Steam game** consuming the virtual Deck's grips/trackpads (the path that works) — needs a
> real Deck/SC client + a Steam-Input-disabled consumer; note the Deck's `/dev/uhid` is root-only
> `0600`, so a Deck-as-host needs a udev rule for the input group. (3) The **Moonlight paddle
> regression** from the M3 xpad-map change.
>
> **M4 (desktop client capture) result:** `clients/{linux,windows}/src/gamepad.rs` (the SDL services)
> now: set the SDL HIDAPI Steam hints (`SDL_JOYSTICK_HIDAPI_STEAMDECK`/`_STEAM`) so SDL opens Valve
> devices directly; detect the Deck/SC by VID/PID (`0x28DE` + `0x1205`/`0x1102`/`0x1142`) →
> `GamepadPref::SteamDeck`; map the SDL paddle + Misc1 buttons → the `BTN_PADDLE1..4`/`BTN_MISC1`
> wire bits; and route a **second** touchpad → `RichInput::TouchpadEx` (SDL touchpad 0 = left →
> surface 1, 1 = right → surface 2, signed coords) while a single touchpad keeps the legacy
> `Touchpad`. Held touchpad contacts are now tracked per `(surface,finger)` and lifted on pad
> switch/detach. Sensor (gyro/accel) capture was already generic. Linux client builds + clippy clean;
> Windows is a near-verbatim mirror (windows CI compiles it). **Caveat:** on a Deck in Game Mode,
> Steam Input still holds the device — the user must disable Steam Input for the client (the Decky UX,
> next); on a desktop client (or a Deck with Steam Input off) the hints just work.
>
> **M3 result (protocol / ABI wire, on-box):** strictly additive + forward-compatible (§5).
> Core: back-button bits `BTN_PADDLE1..4` + `BTN_MISC1` (in Moonlight's `buttonFlags2<<16`
> namespace, so GameStream paddle + native grips share one map); `RichInput::TouchpadEx` (kind
> `0x03` — surface 0/1/2, click, signed coords, pressure); `HidOutput::TrackpadHaptic` (kind `0x04`).
> ABI: `PUNKTFUNK_GAMEPAD_STEAMDECK=6`/`_STEAMCONTROLLER=5` + the paddle/`RICH_TOUCHPAD_EX`/
> `HIDOUT_TRACKPAD_HAPTIC` constants, `from_hid` packs `TrackpadHaptic` into the existing
> `which`+`effect[0..6]` (the legacy structs do **not** grow — guarded by `size_of==20/19` asserts);
> regenerated `punktfunk_core.h`. Host: `steam_proto::from_gamepad` maps the paddles → the four Deck
> grips + QAM; `apply_rich` routes `TouchpadEx` left/right → the matching pad; every DS manager
> (DualSense/DS4, Linux + Windows) gained a `TouchpadEx` arm (surface 0/2 → its one touchpad); the
> xpad `BUTTON_MAP` finally consumes the GameStream paddle bits (`BTN_TRIGGER_HAPPY5-8`, previously
> dropped). Wire round-trips + mapping unit-tested; the on-box backend test now drives the full path
> (`from_gamepad` grip + `apply_rich` left-pad) → evdev `BTN_A` + `ABS_HAT0X`. Workspace
> clippy/fmt/test green. **Deferred to M4:** the C-ABI `PunktfunkRichInputEx` + `send_rich_input2`
> (only the Apple/C *send* path needs it; the host decodes `TouchpadEx` today).
>
> **M2 result (backend + wiring, on-box):** `inject/linux/steam_controller.rs`
> (`SteamControllerManager`/`SteamDeckPad`, mirroring `dualsense.rs`) is wired into `PadBackend`
> (new `SteamDeck` variant + `select`/`handle`/`apply_rich`/`pump`/`heartbeat` arms) and selectable
> via `GamepadPref::SteamDeck` (core enum byte 6 + `pick_gamepad` Linux arm; `SteamController` = byte
> 5 is reserved, folds to Xbox360 until its backend lands). Two Steam-specific quirks beyond the
> DualSense path: (1) **`gamepad_mode` entry** — best-effort `lizard_mode=0` via sysfs + a `b9.6`
> creation pulse (`MODE_ENTER` 650 ms) + an **anti-toggle guard** (`MENU_HOLD_CAP` 350 ms) so a long
> in-game Start-hold can't flip `gamepad_mode` off; (2) **`UHID_SET_REPORT`** answered `err=0`
> (DualSense omits it) + the `0xEB` rumble parsed onto the universal 0xCA plane. An `#[ignore]`d
> on-box test (`backend_binds_and_input_flows`) drives the real backend: it binds `hid-steam`
> (gamepad + IMU evdevs), enters gamepad mode, `BTN_A` reaches the evdev, and the device tears down
> on drop. Workspace clippy/fmt/test green; no generated-header drift (the C-ABI `GamepadPref`
> constants are M3).
>
> **M1 result (byte-exact serializer, on-box):** `inject/proto/steam_proto.rs` now carries the full
> Deck contract transcribed verbatim from the kernel `steam_do_deck_input_event` /
> `steam_do_deck_sensors_event`: the `u64` button map (bytes 8..16), sticks/triggers/trackpads/IMU
> at their exact offsets, `from_gamepad` + `apply_rich` mappers, the rumble-feedback parser
> (`0xEB`), and the serial reply (now with the leading report-id byte the kernel strips — fixes the
> M0 `XXXXXXXXXX` fallback). The validator pulses the `b9.6` mode-switch to enter `gamepad_mode`
> (the parser early-returns under default `lizard_mode` otherwise), holds a known test pattern, and
> reads both evdevs via `EVIOCGABS`/`EVIOCGKEY`: **every field matched**`ABS_X/Y/RX/RY` (incl. the
> kernel Y-negation), both triggers, the touched right-pad `HAT1X/Y`, the IMU accel/gyro (with the
> `ABS_Z/RZ` negations), and the 6 expected buttons incl. the L4/R5 grips. `byte 8 bit 7 = BTN_A` IS
> correct (the M0 "didn't hold" was a flaky single-bit read before `gamepad_mode` was entered). 5
> unit tests + workspace clippy/fmt/test green.
>
> **M0 result (box: headless Ubuntu 26.04, kernel 7.0, no Steam):** the spike
> (`crates/punktfunk-host/src/bin/steam_uhid_spike.rs` + the keeper `inject/proto/steam_proto.rs`)
> created a UHID `28DE:1205` device with a minimal vendor descriptor (one feature report — the sole
> thing `steam_is_valve_interface` checks) and serviced the handshake (the `UHID_SET_REPORT`
> answers the DualSense backend omits). `journalctl -k` showed **`hid-steam` binding it** (rebound
> off `hid-generic`), `"Steam Controller … connected"`, and the kernel creating **both** evdevs:
> `"Steam Deck"` (gamepad) **and** `"Steam Deck Motion Sensors"` (`INPUT_PROP_ACCELEROMETER`).
> Outstanding for later: recognition by a **running Steam** client (needs a box with Steam —
> untestable here); the `gamepad_mode` entry strategy on a real host (pulse `b9.6` at session start,
> or load `hid_steam lizard_mode=0`) is an M2 backend decision.
>
> **M0 result (box: headless Ubuntu 26.04, kernel 7.0, no Steam):** the spike
> (`crates/punktfunk-host/src/bin/steam_uhid_spike.rs` + the keeper `inject/proto/steam_proto.rs`)
> created a UHID `28DE:1205` device with a minimal vendor descriptor (one feature report — the sole
> thing `steam_is_valve_interface` checks) and serviced the handshake (the `UHID_SET_REPORT`
> answers the DualSense backend omits). `journalctl -k` showed **`hid-steam` binding it** (rebound
> off `hid-generic`), `"Steam Controller … connected"`, and the kernel creating **both** evdevs:
> `"Steam Deck"` (gamepad, `BTN_A` in key caps) **and** `"Steam Deck Motion Sensors"`
> (`INPUT_PROP_ACCELEROMETER`, 6 IMU axes). A layout-agnostic mash-probe confirmed the input path:
> **23 distinct `BTN_*` codes** (A/B/X/Y, TL/TR, SELECT/START/MODE, THUMBL, all 4 DPAD, grips, back
> codes) toggled through `hid-steam → evdev`. ✅ bind ✅ dual evdev incl. IMU ✅ report-parse path.
> Outstanding: (4) recognition by a **running Steam** client (needs a box with Steam — untestable
> here); and the exact per-bit button/stick/pad/IMU offsets (M1, line-checked vs the lab kernel —
> the v6.12-sourced `byte 8 bit 7 = BTN_A` did not hold on 7.0). The serial GET_REPORT reply also
> needs its report-number-prefix offset fixed (the kernel used the `XXXXXXXXXX` fallback; non-fatal).
## 1. Goal + scope
Carry the **full** Steam Controller / Steam Deck input surface end-to-end and let a remote
host present a **real virtual Steam device** that Steam Input and games bind as genuine:
- the 4 back grips (L4/L5/R4/R5),
- both capacitive **trackpads** (the Deck's L/R pads, the SC's dual pads) with touch + click +
pressure,
- the **IMU** (3-axis gyro + 3-axis accel),
- the Steam/quick-access (`…`/QAM) buttons,
- haptics/rumble back-channel (Deck rumble motors; SC trackpad voice-coils).
**Locked decisions (2026-06-29):**
1. **Full pipeline** — capture on every client + inject on the hosts, not a one-platform demo.
2. **Disable-Steam-Input UX** for the Deck-in-Game-Mode capture wall (§6) — we own the
instruction and a best-effort programmatic flip; the manual toggle is the source of truth.
3. **Max fidelity** — build the greenfield virtual `hid-steam` driver. **Linux UHID first**
(validates the contract against open-source `hid-steam.c` + SDL hidapi); **Windows UMDF
later**, gated on the Linux result (§8).
4. The virtual **DualSense remap is the proven fallback** wherever a virtual Steam device is
unavailable or undesired (§7), so Steam-only inputs are *never silently dropped*.
This is the same architectural bet as the virtual DualSense: the rich semantics
(adaptive triggers there; back grips + trackpads + gyro **bindings/glyphs** here) only
materialize end-to-end if the **game/Steam sees a real device** and therefore drives them. A
generic Xbox pad makes the game take its Xbox code path and the rich surface never exists.
The unique value of a virtual Steam device is realized **only when the host runs Steam Input**,
which re-grabs the `28DE` device and re-emits it as `28DE:11FF` with the user's per-game
bindings and correct glyphs. Off-Steam, we fall back to DualSense (§7).
## 2. The two walls + the honest fidelity ceiling
There are exactly two hard problems. Everything else is plumbing we have already shipped twice
(DualSense, DS4).
### Wall A — Steam Input capture ownership (client side, solvable via UX)
On the **client**, enabling SDL's `SDL_JOYSTICK_HIDAPI_STEAMDECK`/`HIDAPI_STEAM` drivers makes
SDL open the raw `28DE:1205` and expose paddles + trackpads + gyro as a first-class SDL
gamepad (the inputtino/Sunshine path). **But in Deck Gaming Mode, Steam Input grabs the device
exclusively** and re-presents it as a virtual XInput pad — so SDL's HIDAPI driver cannot open
the raw device and the rich controls silently vanish. **This is inherent**: Steam owns the
device. The only escape is the locked Disable-Steam-Input-per-title decision. Outside Game Mode
(desktop client, or a Deck used as a streaming *target*), the hints just work.
### Wall B — virtual-Steam-Controller recognition (host side, art-pushing)
On the **host**, no public project emulates a virtual `hid-steam` device (inputtino does
`hid-playstation`/Xbox/Switch, **not** Steam). Two unknowns stack:
- **Linux (lower risk):** the kernel bind path is well-mapped from `drivers/hid/hid-steam.c`
(open source) — match by VID/PID over `BUS_USB`, answer the probe feature handshake, stream
the 64-byte state report. This is provable against open source. *M0 proves it.*
- **Windows (higher risk):** Steam's **closed** userspace driver must accept the same contract
over a UMDF-presented HID interface, and SDL #12166 shows Steam/SDL **aborts** the controller
if its `0x83 GET_ATTRIBUTES_VALUES` / `0xA1 GET_DEVICE_INFO` feature probes fail. Those reply
blobs are **not derivable — they must be captured from real hardware**. Deferred to §8.
### The fidelity ceiling — what is inherent vs solvable
| Capability | Status |
|---|---|
| Buttons, dual sticks, analog triggers, dpad | Solvable — maps 1:1 from the existing Xbox-style frame |
| Back grips L4/L5/R4/R5 | Solvable on the virtual Steam pad + uinput Xbox (`BTN_TRIGGER_HAPPY*`); **lost on DualSense/DS4 fallback** (no back-button HID slot — remapped or dropped, documented) |
| Dual trackpads (touch/click/pressure) | Solvable on the virtual Steam pad; collapses to **one** surface on a DualSense fallback target |
| Gyro/accel (IMU) | Solvable — already carried by `RichInput::Motion`; **no IMU on an Xbox fallback** (xpad has none) |
| Rich semantics + glyphs in games | **Inherently requires Steam Input running on the host** to re-grab + rebind |
| Deck-in-Game-Mode capture | **Inherently requires disabling Steam Input** for the punktfunk title |
| Trackpad voice-coil haptics (SC) | Collapsed to the universal rumble plane unless a client renders localized haptics |
| Adaptive triggers / lightbar | **N/A** — Steam devices have none |
## 3. Architecture overview (capture → protocol → inject)
```
CLIENT (SDL3 / GameController / NDK) HOST (punktfunk1.rs PadBackend)
┌──────────────────────────────┐ ┌────────────────────────────────────────┐
│ buttons+sticks+triggers ────┼── 0xC8 ────────►│ SteamControllerManager (Linux UHID) │
│ back grips L4/L5/R4/R5 ────┼── 0xC8 bits ───►│ → serialize_deck_state → /dev/uhid │
│ trackpads (2 surfaces) ────┼── 0xCC 0x03 ───►│ → kernel hid-steam binds 28DE:1205 │
│ gyro+accel (IMU) ────┼── 0xCC 0x02 ───►│ → gamepad evdev + IMU evdev │
│ GamepadPref=SteamDeck ────┼── Hello byte ──►│ → Steam Input re-emits 28DE:11FF │
└──────────────────────────────┘ │ │
▲ rumble 0xCA / haptic 0xCD 0x04 │ parse_steam_output ◄── UHID_SET_REPORT │
└──────────────────────────────────────────┤ 0xEB rumble / 0x8F pad-haptic │
│ │
│ FALLBACK (target = DualSense/DS4/Xbox): │
│ inject/proto/steam_remap.rs │
│ gyro→Motion, pads→touchpad/stick/mouse │
│ grips→BTN_PADDLE→BTN_TRIGGER_HAPPY │
└────────────────────────────────────────┘
```
The **centerpiece** is the virtual `hid-steam` UHID device (§4). Gamepads are **not** on the
compositor injector path — they are owned by a per-session `PadBackend` created in
`PadBackend::select` and torn down (RAII) when the session input thread exits, identical to the
DualSense/DS4 lifecycle. So this drops in with **zero** changes to the injector, capture, or
audio planes; only `PadBackend` + `GamepadPref` + the protocol kinds grow.
The **proven fallback** is the virtual DualSense remap: whenever the resolved backend is not a
real Steam device, `steam_remap.rs` folds the Steam-only inputs into whatever pad we do present
so nothing is silently lost.
## 4. The virtual `hid-steam` driver (Linux UHID first)
The mechanism is the **exact analogue** of the shipped virtual DualSense
(`inject/linux/dualsense.rs` + `inject/proto/dualsense_proto.rs`), with three Steam-specific
deltas: the **bind identity**, the **feature SET_REPORT handshake** (the DualSense backend only
handles GET_REPORT — the Steam path MUST also service SET_REPORT or stall), and the
**unnumbered (report-id-0) raw 64-byte framing**.
### 4.1 Binding mechanism
`hid-steam` matches **purely by VID/PID over `BUS_USB`**:
`HID_USB_DEVICE(0x28DE, 0x1205, STEAM_QUIRK_DECK)`. A `UHID_CREATE2` device with `bus =
BUS_USB (0x03)`, `vendor = 0x28DE`, `product = 0x1205` is bound and `steam_probe` runs. `hid-steam`
is a **raw-event driver** (`steam_raw_event` returns "handled" and bypasses HID field parsing),
so the report descriptor is almost cosmetic — **except** `steam_probe` requires `hid_parse` to
succeed *and* a **non-empty FEATURE report list**, so the descriptor MUST declare ≥1 feature
report at report id 0.
**Recommend the Deck (`0x1205`), not the classic SC (`0x1102`), for M0.** The Deck has standard
dual sticks + dual analog triggers + 4 back grips + IMU, all of which map cleanly from the
existing Xbox-style client frame + `Motion`/`TouchpadEx` planes. The classic SC has **no right
stick** (dual trackpads) and trackpad-voice-coil-only haptics — awkward to synthesize. Deck is
also what the locked Game-Mode UX targets. SC (`0x1102`, report id 1) is a later identity behind
the same manager.
**Reports are UNNUMBERED (report id 0).** `steam_send_report`/`steam_recv_report` call
`hid_hw_raw_request(hdev, 0, …)`; interrupt-in payloads have **no report-id prefix**. So
`data[0]` is the protocol constant `0x01`, `data[1] = 0x00`, `data[2] = 0x09`. A *numbered*
descriptor would shift the whole frame one byte and `data[0] != 1` would drop every report.
### 4.2 The feature-report probe contract (the load-bearing delta from DualSense)
During probe, `steam_register → steam_get_serial()` sends command `0xAE`
(`ID_GET_STRING_ATTRIBUTE`) as a **feature SET_REPORT**, then **blocks on a GET_REPORT** for the
reply. On UHID these arrive as `UHID_SET_REPORT` (type 13) and `UHID_GET_REPORT` (type 9). The
service loop MUST handle **three** event types (vs the DualSense's two):
| Event | Reply | Notes |
|---|---|---|
| `UHID_GET_REPORT` (9) | `UHID_GET_REPORT_REPLY` (10) | serial blob `[0xAE, attrib=0x01, len, ascii…]`, or `err=EIO` |
| `UHID_SET_REPORT` (13) | `UHID_SET_REPORT_REPLY` (14), `err=0` **always** | **ignore → kernel stalls ~5 s/command**; parse `id u32@[4..8]`, `rnum@[8]`, `rsize u16@[9..11]`, `data@[11..]` |
| `UHID_OUTPUT` (6) | parse if present | feedback path |
Command IDs the device must **ack** (`err=0`) and may parse:
- `0xAE` `ID_GET_STRING_ATTRIBUTE` (serial) — **NON-FATAL**: the kernel falls back to a fake
serial `"XXXXXXXXXX"` and continues either way, so even an EIO reply yields a working device.
Answering keeps probe instant.
- `0x81` `ID_CLEAR_DIGITAL_MAPPINGS` / `0x8E` `ID_LOAD_DEFAULT_SETTINGS` / `0x87`
`ID_SET_SETTINGS_VALUES` (lizard-mode + settings) — **ack err=0, ignore content**. The Deck
path **skips** the auto lizard-mode disable at `input_open`, so these only arrive on an
options-hold or via Steam userspace — but must be ack'd to avoid the per-command stall.
- `0x83` `ID_GET_ATTRIBUTES_VALUES` / `0xA1` `ID_GET_DEVICE_INFO`**not** issued by the kernel
Deck probe, but Steam userspace queries them for full Steam Input fidelity (gyro/back
buttons). For M0 bind they are unnecessary; for full fidelity (M3+) answer with
real-hardware-captured blobs.
### 4.3 Input-report layout (Deck `ID_CONTROLLER_DECK_STATE`, msg `0x09`)
64-byte **unnumbered** report, little-endian. `steam_raw_event` drops anything where
`size != 64 || data[0] != 1 || data[1] != 0`, then `switch(data[2])`.
```
[0] 0x01 protocol constant (REQUIRED ==1)
[1] 0x00 protocol constant (REQUIRED ==0)
[2] 0x09 ID_CONTROLLER_DECK_STATE (0x01 = ID_CONTROLLER_STATE for the SC)
[3] len payload length, kernel ignores (set ~0x3C)
[4..8] u32 LE frame/sequence counter (monotonic)
[8] buttons b8 {A,X,B,Y, L1,R1, L2-full,R2-full}
[9] buttons b9 {DPAD_U,DPAD_R,DPAD_L,DPAD_D, view, steam, menu, GRIPL2(L5)}
[10] buttons b10 {GRIPR2(R5), lpad_touch, rpad_touch, L3, R3, …}
[11..13] b11/b12 reserved/touch + THUMBR alt
[13] buttons b13 GRIPL(L4)@bit1, GRIPR(R4)@bit2
[14] buttons b14 BTN_BASE (quick-access)@bit2
[16..24] s16 x4 LE LEFT pad X/Y, RIGHT pad X/Y → ABS_HAT0X/Y, ABS_HAT1X/Y (res ~1638)
[24..36] s16 x6 LE accel X, accel Z(neg), accel Y, gyro X, gyro Z(neg), gyro Y
→ IMU ABS_X/Y/Z + ABS_RX/RY/RZ
[36..44] s16 x4 LE orientation quaternion (optional)
[44..48] u16 x2 LE LEFT trigger, RIGHT trigger → ABS_HAT2Y / ABS_HAT2X
[48..56] s16 x4 LE LEFT stick X/Y(neg), RIGHT stick X/Y(neg) → ABS_X/Y, ABS_RX/RY
[56..60] u16 x2 LE LEFT/RIGHT pad pressure
[60..64] reserved
```
**Neutral state:** sticks/pads/triggers = `0x0000` (signed-centered at 0 — note this differs
from the DualSense's `0x80` stick centers); all button bytes 0. On bind the kernel exposes
**two** evdevs: a Deck gamepad (`BTN_A`..`BTN_GRIPL/R` + `GRIPL2/R2`, `BTN_BASE`, ABS
sticks/triggers/pads) **and** a separate IMU evdev (`INPUT_PROP_ACCELEROMETER`).
> **The exact per-byte button bit masks and the 0xEB rumble offsets in this table are
> summarized from secondary parsing and MUST be confirmed line-by-line against
> `steam_do_deck_input_event` / `steam_haptic_rumble` in the lab kernel before trusting input
> fidelity.** The backend logs a first-frame layout dump (the DS4 pattern) to catch slips.
### 4.4 Feedback surface
Simpler than the DualSense — no lightbar / player LEDs / adaptive triggers. Feedback arrives as
a feature **SET_REPORT** (type 13, ack `err=0`), not a `UHID_OUTPUT` interrupt:
- `0xEB` `ID_TRIGGER_RUMBLE_CMD` — Deck rumble motors; map left/right → `(low, high)` on the
existing universal **0xCA** rumble plane (exactly like the DualSense `fb.rumble`).
- `0x8F` `ID_TRIGGER_HAPTIC_PULSE` — the SC's two trackpad voice-coils (pad 0=left/1=right/2=both,
duration/interval/count). Niche on the Deck; for M0 ack-and-fold into 0xCA (left-pad→low,
right-pad→high). For clients with localized haptics, surface as the new `0xCD 0x04`
`TrackpadHaptic` (§5).
No `0xCD` HID-output plane is otherwise needed for the Deck.
### 4.5 New Linux modules (mirror the DualSense trio)
- `crates/punktfunk-host/src/inject/proto/steam_proto.rs` — transport-independent contract:
`STEAM_VENDOR=0x28DE`; `SteamModel{ Deck=0x1205 rid 9, Controller=0x1102 rid 1 }`; the verbatim
`STEAMDECK_RDESC` (≥1 feature report at id 0); `SteamState` superset model (sticks, analog
triggers, packed buttons, dpad, `gyro[3]`/`accel[3]`, `back:[bool;4]`, two `SteamPad{active,
click, x:i16, y:i16, pressure:u16}` surfaces, steam/quickaccess); `SteamState::from_gamepad`
(the XInput mapper + the new paddle/misc wire bits); `serialize_deck_state`/`serialize_sc_state`
(byte-exact); `feature_reply(rnum)`; `parse_steam_output(data, &mut SteamFeedback)`. Unit tests
for offsets + output parsing, mirroring `dualsense_proto`'s tests.
- `crates/punktfunk-host/src/inject/linux/steam_controller.rs``/dev/uhid` plumbing +
`SteamControllerManager`, byte-identical structure to `dualsense.rs`: `SteamPad::open(index,
model)` does `UHID_CREATE2`; `write_state → UHID_INPUT2`; `service()` answers GET_REPORT +
SET_REPORT + OUTPUT; `Drop → UHID_DESTROY`; `handle`/`apply_rich`/`pump`/`heartbeat(8 ms)`. The
8 ms heartbeat re-emits the last report — a real Deck streams continuously and multi-second
silence reads as a disconnect to SDL/Steam.
Needs `/dev/uhid` writable (the existing `60-punktfunk.rules` udev rule + `input` group, same as
DualSense) and `hid-steam` present/loaded (`modprobe hid-steam`; mainline module).
## 5. Protocol / ABI changes (exact wire/constants)
Strictly additive and forward-compatible — everything rides existing tags (`0xC8` buttons,
`0xCC` rich input, `0xCD` HID-out, the `GamepadPref` Hello/Welcome byte). Unknown kinds/bits drop
on old peers exactly as today.
### 5.1 Back-button bits (`input.rs::gamepad`, ride `0xC8`, no length change)
We align to Moonlight's `buttonFlags2 << 16` namespace so the GameStream paddle path and the
native path share one injector map. The classic-paddle slots are GameStream-aligned; the four
Steam grips sit just above the touchpad bit:
```
BTN_PADDLE1 = 0x0001_0000 (R4 / SDL RightPaddle1 / GameStream PADDLE1)
BTN_PADDLE2 = 0x0002_0000 (L4 / SDL LeftPaddle1 / GameStream PADDLE2)
BTN_PADDLE3 = 0x0004_0000 (R5 / SDL RightPaddle2 / GameStream PADDLE3)
BTN_PADDLE4 = 0x0008_0000 (L5 / SDL LeftPaddle2 / GameStream PADDLE4)
BTN_TOUCHPAD= 0x0010_0000 (already present, = TOUCHPAD_FLAG << 16)
BTN_MISC1 = 0x0020_0000 (Deck '…'/QAM, Share/Capture / GameStream MISC)
```
> **Decision (resolves the placement open-question):** native back buttons **reuse the
> GameStream paddle bits** (`0x0001_0000..0x0008_0000`) rather than a separate `0x40_0000+`
> range. This unifies the GameStream-paddle and native-grip injector maps onto one table, and
> Xbox Elite paddles map for free. Steam L4/L5/R4/R5 ↔ Xbox P1P4 is a semantic 1:1 for binding
> purposes; the device identity carries the glyph distinction.
### 5.2 `RichInput::TouchpadEx` (kind `0x03`, rides `0xCC`, client→host)
`0x01 Touchpad` (DualSense single contact) is kept forever. The new superset carries the second
pad + click + pressure with **signed** coords matching the real Steam report:
```
[0xCC][0x03][pad u8][surface u8][finger u8][state u8][x i16 LE][y i16 LE][pressure u16 LE] // 12 B
surface : 0 = single/DS touchpad, 1 = Steam left pad, 2 = Steam right pad
state : bit0 = touch (capacitive contact), bit1 = click (pad depressed)
pressure: 0 if the surface has no sensor
```
Decode gated `b.len() >= 12`; unknown kind → `None`. New clients emit `TouchpadEx` for all touch
surfaces; the host decodes both `0x01` and `0x03` indefinitely (no flag day). Every existing
manager (`dualsense.rs`, `dualshock4.rs`, `dualsense_windows.rs`) gains a `TouchpadEx` arm
(treat surface 0/2 → contact, ignore 1) so the new variant compiles everywhere.
### 5.3 `HidOutput::TrackpadHaptic` (kind `0x04`, rides `0xCD`, host→client)
```
[0xCD][0x04][pad u8][side u8][amplitude u16 LE][period u16 LE µs][count u16 LE] // 10 B
```
Decode gated `b.len() >= 10`. **The ABI `PunktfunkHidOutput` struct is NOT grown** (it has no
`struct_size` guard — growing it would overwrite old-built caller buffers): the new kind reuses
existing fields — `which = side`, `amplitude/period/count` packed LE into `effect[0..6]` with
`effect_len = 6`. Clients without coils drop it (or optionally map to rumble).
### 5.4 `GamepadPref` source hints
Trailing fwd-compat Hello/Welcome byte (unknown → `Auto`):
```
5 = SteamController (steam | steamcontroller | sc)
6 = SteamDeck (steamdeck | deck | sd)
```
These are **source hints** (what the physical client controller is) so the host prefers the
virtual `hid-steam` backend + the right glyph identity; honored only where the backend exists
(Linux UHID first), else degraded — the Welcome echoes the **real** resolved backend (honest
downgrade). Clients auto-resolve from VID/PID (§6), like DS5→DualSense.
### 5.5 ABI surface (`abi.rs` + regenerate `include/punktfunk_core.h`)
- New constants: `PUNKTFUNK_RICH_TOUCHPAD_EX=3`, `PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC=4`,
`PUNKTFUNK_GAMEPAD_STEAMCONTROLLER=5`, `PUNKTFUNK_GAMEPAD_STEAMDECK=6`, and
`PUNKTFUNK_GAMEPAD_BTN_PADDLE1..4` / `_BTN_MISC1` for embedders building raw `InputEvent`s.
- **Do not mutate `PunktfunkRichInput`** (no `struct_size` guard). Add a guarded superset
`PunktfunkRichInputEx{ u32 struct_size; u8 kind,pad,finger,active,surface,click; u8 _pad[2];
i16 x,y; u16 pressure; i16 gyro[3]; i16 accel[3]; }` + `punktfunk_connection_send_rich_input2`
that reads the size prefix first (the `connect_exN`/`config_from_ptr` precedent). Legacy
`PunktfunkRichInput` + `send_rich_input` stay byte-for-byte.
- `from_hid` gains the `TrackpadHaptic → effect[]`-packing arm; `to_rich` (Ex) gains the
`TouchpadEx` arm.
- Compile guards: extend the `GamepadPref` lockstep `assert!` block with the two new variants;
add `assert!(size_of::<PunktfunkRichInput>()==20)` + `assert!(size_of::<PunktfunkHidOutput>()
==19)` so the additive changes can never silently shift the legacy layouts. CI fails on header
drift.
### 5.6 GameStream host-map fix (no protocol change)
`gamestream/gamepad.rs:91` already computes `buttons = buttonFlags | (buttonFlags2 << 16)`, but
the xpad `BUTTON_MAP` drops every bit above `0x8000`, so Moonlight paddle/Share clients are
silently no-op'd today. Name the already-decoded bits (`PADDLE1=0x0001_0000 … PADDLE4=
0x0008_0000`, `TOUCHPAD=0x0010_0000`, `MISC=0x0020_0000`) and add them to the injector map so
the **native** and **GameStream** back-button paths drive one unified output. This is a behavior
change for existing Moonlight users — needs a live regression check (§10).
## 6. Client capture
### sdl3-rs 0.18.4 API-coverage gate — RESOLVED (was the top client risk)
Independently verified against docs.rs for `sdl3` 0.18.4 (2026-06-29), so **no raw `sdl3-sys`
fallback is needed**: `Button::{LeftPaddle1,RightPaddle1,LeftPaddle2,RightPaddle2,Misc1..6,
Touchpad}` exist (confirmed); `Gamepad::touchpads_count()` + `supported_touchpad_fingers()` exist
(confirmed); `vendor_id()`/`product_id()` exist (confirmed); `Event::ControllerTouchpad
{Down,Motion,Up}` carries the `touchpad` **surface** field the current code discards. **Sensor
capture (gyro/accel) is already proven in-tree** — the shipping client enables and reads SDL
sensors for the DualSense today (`set_sensors` in `clients/linux/src/gamepad.rs`), so
genericizing it past the DualSense gate is a one-line change, not a new dependency. The hint
strings `SDL_JOYSTICK_HIDAPI_STEAMDECK` / `SDL_JOYSTICK_HIDAPI_STEAM` live in `sdl3-sys`. There is
**no `SDL_GAMEPAD_TYPE_STEAM_DECK`** — detect by VID `0x28DE`.
### Linux + Windows SDL clients (near-verbatim ports — `clients/{linux,windows}/src/gamepad.rs`)
1. **Before `sdl3::init()`**, set `SDL_JOYSTICK_HIDAPI_STEAMDECK="1"` + `SDL_JOYSTICK_HIDAPI_STEAM
="1"` (the exact `sdl3::hint::set` mechanism already used for `SDL_NO_SIGNAL_HANDLERS`).
2. In `pad_info`, override to `GamepadPref::SteamDeck` when `vendor_id()==0x28DE` and
`product_id() ∈ {0x1205 Deck, 0x1102 SC wired, 0x1142 SC dongle}`; add a `"Steam Deck"` label.
3. Extend `button_bit`: `RightPaddle1→BTN_PADDLE1`, `LeftPaddle1→BTN_PADDLE2`,
`RightPaddle2→BTN_PADDLE3`, `LeftPaddle2→BTN_PADDLE4`, `Misc1→BTN_MISC1` (free win for Elite).
4. Bind the `touchpad` surface field in the three `ControllerTouchpad*` arms; **branch on
`touchpads_count()`** rather than hard-coding 2 — surface 0 → existing `RichInput::Touchpad`,
surface ≥1 → `RichInput::TouchpadEx{surface}`.
5. Track held contacts keyed by `(surface, finger)` and lift them (`active=false`) in
`flush_held` on pad switch/detach (today only the DualSense single surface is implicitly lifted).
6. Sensor capture is **already generic** — only the DualSense-only doc comments change.
### Disable-Steam-Input UX (Decky + docs)
- **Decky** (`clients/decky/`): a Settings toggle "Capture Steam Deck controls (paddles ·
trackpads · gyro)" that selects `gamepad pref=steamdeck`, adds `"steamdeck"` to the option set
(TS + `main.py` validation), and renders an **unmissable inline instruction**: gamescope game
page → gear → Controller → Steam Input → **Off** for the punktfunk shortcut.
- **Best-effort programmatic flip** (`clients/decky/src/steam.ts`):
`disableSteamInputForShortcut(appId)` — feature-detected at runtime (guarded exactly like the
existing optional `window.DeckyBackend`/`collectionStore` globals), called best-effort inside
`ensureShortcut()`, **never** blocking or throwing into `launchStream`. **The manual toggle is
the documented source of truth** — there is no confirmed stable SteamClient API and it may
regress across Steam updates.
### Apple / Android — honest no-code-now scope
- **Apple:** parity only — add a `.steamDeck` enum case (wire byte 6) so the type round-trips;
**no capture**. GameController never surfaces a `28DE` HID device as a `GCExtendedGamepad`
(Apple has no Steam Input; a raw path would need `IOHIDManager`). Document as blocked.
- **Android:** parity only — add `PREF_STEAMDECK` + the `28DE` PIDs to the mapping. Capture of
paddles/trackpads/gyro is **out of scope** here: `send_rich_input` is itself still a TODO
(`session.rs:13`), and a Deck dongle appears only as a generic gamepad via `InputDevice`.
Revisit after the rich-input port lands.
## 7. Host inject / mapping + host-integration semantics
### PadBackend wiring
`enum PadBackend` (`punktfunk1.rs`) gains `#[cfg(target_os="linux")] SteamController
(SteamControllerManager)` and `SteamDeck(SteamControllerManager)` — **one manager, a `SteamModel`
field** (sticks/descriptor/report differ, logic is shared; the DS4-reuses-DualSense pattern).
Wire all five arms (`select`/`handle`/`apply_rich`/`pump`/`heartbeat`). `pick_gamepad` /
`resolve_gamepad` gain `GamepadPref::SteamController|SteamDeck if linux` arms; **on Windows and
elsewhere they fold to Xbox360** until the UMDF driver lands (§8).
### Selection / resolution policy (the load-bearing part)
Unlike the DualSense, a virtual Steam pad only pays off under specific host conditions, so
resolution is gated. Highest priority first:
1. **Explicit client `SteamController`/`SteamDeck` pref** — honored if `/dev/uhid` is writable
AND `hid-steam` is loadable; else degrade.
2. **`PUNKTFUNK_GAMEPAD=steamdeck|steamcontroller`** host env.
3. **Auto** — resolve to a Steam pad **only when the host is running Steam Input** (so the rich
semantics are actually consumed). Otherwise Auto prefers **DualSense** (broader non-Steam SDL
surface: gyro + a real touchpad) over a Steam pad whose trackpads/grips a non-Steam game
won't understand.
4. **Degrade ladder** when a requested Steam pad is unavailable: `hid-steam` missing →
**DualSense****Xbox360**. The Welcome carries the real choice.
> **SteamOS/Deck-as-host conflict:** a host already running Steam eagerly grabs **any** `28DE`
> device, so our virtual pad could be double-handled alongside the operator's physical Deck
> controller. **Default policy: gate Steam pads OFF on a SteamOS/gamescope host** unless
> explicitly forced (M6 confirms).
### Fallback remap (`inject/proto/steam_remap.rs`, pure + unit-testable)
When the resolved backend is DualSense/DS4/Xbox, fold the Steam-only inputs in so nothing drops:
- **Gyro/accel**`RichInput::Motion` (native on DualSense/DS4; no-op on Xbox — xpad has no IMU).
- **Right trackpad** → DualSense/DS4 touchpad contact (1:1 absolute surface); on an Xbox target,
optionally synthesized to the right stick behind a config toggle.
- **Left trackpad** → left stick or relative mouse via the existing `InjectorService` pointer
plane (config; default mouse, matching SteamOS desktop feel).
- **Back buttons L4/L5/R4/R5**`BTN_PADDLE1..4` → on a uinput Xbox pad, `BTN_TRIGGER_HAPPY1..4`
(`0x2c0..0x2c3`, what Steam Input/SDL read as paddles); add the matching `UI_SET_KEYBIT`
registrations in `create()`. **DS4/DualSense have no back-button HID slot** — paddles fall back
to a configurable default (e.g. L4→L3, R4→R3) or are dropped, **documented, not silent**.
- **DS4 100 ms motion-timestamp keepalive** applies whenever motion is forwarded onto a DS4
target — keep `apply_rich`/`heartbeat` flowing so the sensor `ts += 188` advances, or games
reject motion as stale.
A `RemapConfig` (env/config driven, e.g. `PUNKTFUNK_STEAM_REMAP=…`) holds the trackpad/back-button
policy knobs.
## 8. Windows UMDF — a later, gated phase
**Do not start until the Linux UHID device binds.** Linux proves the report descriptor + feature
blobs + state layout against open-source `hid-steam.c` + SDL hidapi; Windows then only adds the
unknown of **Steam's closed userspace driver** accepting the same contract over UMDF.
Reuse the **entire** proven `pf-dualsense` UMDF path (the repo already proved a self-signed Rust
UMDF HID minidriver loads under Secure Boot ON and is recognized as a genuine controller):
- Fork `packaging/windows/drivers/pf-dualsense``pf-steamdeck`. Keep verbatim the
`vhidmini2`-derived WDF scaffolding, the **FORCE_INTEGRITY PE-bit clear** (PE+0x5e), the
timer-completes-pended-READ_REPORT pattern, the queue `NumberOfPresentedRequests=u32::MAX` and
timer `ExecutionLevel/SynchronizationScope=InheritFromParent + AutomaticSerialization=TRUE`
gotchas, the `Global\pfds-shm-<idx>` shared-memory channel, the multi-pad
`pszDeviceLocation`/`UmdfHostProcessSharing=ProcessSharingDisabled` plumbing, the
`Include=MsHidUmdf.inf`/`WUDFRD.inf` INF stanza, and `SwDeviceCreate` (enumerator `punktfunk`,
hardware id `pf_steamdeck`).
- Swap identity (VID `0x28DE` / PID `0x1205`), the hid-steam report descriptor, and — the
**riskiest, non-derivable** part — the `0x83 GET_ATTRIBUTES_VALUES` + `0xA1 GET_DEVICE_INFO`
feature blobs **captured from real hardware** (SDL #12166: Steam/SDL aborts the controller if
these probes fail). Add ACK-only SET_FEATURE handlers for `0x81`/`0x87`/`0x8E`.
- Host backend: a `SteamDeckWindows` reusing `inject/windows/dualsense_windows.rs` almost
wholesale (SwDeviceCreate, map the section, pack the 64-byte state, read the haptic output slot).
- Bundle `pf_steamdeck.{inf,cat,dll}` into the existing Inno installer + `install-gamepad-drivers
.ps1` pnputil flow, identical to pf-dualsense/DS4/XUSB.
**NEVER emulate `28DE:11FF`** — that is Steam's own emulated *output* pad, not an input device;
emulating it risks a feedback loop where Steam ingests its own output. Watch for: Steam requiring
a USB instance path a SwDevice lacks; Steam wanting the sibling emulated keyboard/mouse
collections present; VAC/device-trust rejection of a self-signed virtual Steam Controller; and
gating the Deck PID (`0x1205`) on Deck hardware (wired-SC `0x1102` may be the safer desktop
identity).
## 9. Milestone plan (M0 is the go/no-go)
See the structured `milestones`. The shape mirrors the DualSense effort: an **M0 feasibility
gate** answers the recognition question before any pipeline is built. M1M3 are Linux. M4M5 are
clients + protocol. M6 is the SteamOS-host conflict check. M7+ is the deferred Windows UMDF
phase, itself re-gated on its own recognition spike.
## 10. Risks, open questions, validation
### Validation / test plan
**Loopback (no hardware):**
- Core: `RichInput::TouchpadEx` + `HidOutput::TrackpadHaptic` + `GamepadPref` 5/6 encode/decode
round-trips + an old-peer-drops-unknown-kind assertion; the `from_gamepad` paddle/misc mapping;
`steam_proto` report-offset + `parse_steam_output` unit tests (mirror `dualsense_proto`); the
`steam_remap` fold policy.
- `pick_gamepad`/`resolve_gamepad`: client SteamDeck + hid-steam present + Steam → SteamDeck;
client SteamDeck + no module → DualSense in the Welcome; Auto + no Steam → DualSense;
`PUNKTFUNK_GAMEPAD=steamdeck` forces it; Windows folds to Xbox360. Assert the Welcome echoes
the real choice each time.
**On-box (the box has `hid-steam` mainline + the udev rule):**
- **BIND proof (Steam NOT running):** a tiny test main creates the device + heartbeats neutral.
Confirm `dmesg` shows `hid-steam … Valve Software Steam Deck Controller`; the sysfs node binds
`hid_steam`; a gamepad evdev AND a second IMU evdev appear (`udevadm info`
`ID_INPUT_JOYSTICK=1` + `INPUT_PROP_ACCELEROMETER`).
- **RECOGNITION proof:** `sdl2-jstest --list` / an SDL3 app reports GUID `28de:1205` "Steam
Deck"; `evtest` shows `BTN_SOUTH` etc.; toggle the A bit and watch the key event.
- **STEAM proof (Steam running on the host):** Settings → Controller shows a "Steam Deck
Controller"; the kernel evdev disappears (the `client_opened` standoff is expected); bind a
back grip to a key in Steam Input and confirm a non-Steam test game sees it.
- **RUMBLE proof:** `fftest` / a game triggers `FF_RUMBLE`; confirm a `0xEB` SET_REPORT arrives
and our parser emits `(low, high)` on `0xCA` back to the client.
- **Cross-machine:** the Linux client (paddles + both pads + gyro) over the LAN → the virtual
Deck on the host → Steam re-emits `28DE:11FF` with working bindings + glyphs.
- **GameStream regression:** confirm the new `buttonFlags2` consumption doesn't emit spurious
back-grip/record events for a stock Moonlight client with a normal pad.
(Full risks + open questions in the structured fields.)
## 11. The interface-2 ceiling — Steam Input won't manage a UHID virtual Deck (hardware-validated 2026-06-29)
Validated on a SteamOS Steam Deck (`192.168.1.253`) + a Bazzite host (`192.168.1.41`), both running
Steam, with a minimal C UHID probe (`28DE:1205` + the proven descriptor/handshake) run on Bazzite
(no physical Steam controller, so a clean test bed).
**What works.** The kernel `hid-steam` binds the virtual Deck by VID/PID on a second independent
kernel (Bazzite 6.17.7) exactly as on the dev box (7.0): it accepts our serial (the M1 report-id-0
fix), and creates both the `"Steam Deck"` gamepad evdev and the `"Steam Deck Motion Sensors"` IMU
evdev. So **any consumer that reads the kernel evdev or opens the hidraw via SDL's HIDAPI Steam Deck
driver sees the full surface** — the four grips (`BTN_GRIPL/R/L2/R2`), both trackpads (`ABS_HAT0/1`),
and the IMU.
**What does NOT work: Steam Input promotion.** Steam's own controller driver *enumerates* the device
`controller.txt` logs `Local Device Found, type: 28de 1205, Product "Punktfunk Steam Deck", path
/dev/hidraw1, Interface: -1` — but never promotes it: no `28DE:11FF` virtual XInput pad, no
"Controller N connected". On the same Steam logs the **physical** Deck appears as **`Interface: 2`**.
A real Steam Deck is a **3-interface USB device** (keyboard = interface 0, mouse = 1, **controller =
2**), and Steam binds the controller specifically on interface 2. A single `/dev/uhid` device is not
a USB device and has no `bInterfaceNumber`, so Steam reads **`-1`** and filters it out. (Notably the
`0x83 GET_ATTRIBUTES` / `0xA1 GET_DEVICE_INFO` probes the prior research feared — SDL #12166 — never
fired: this is an interface filter, not an attribute-probe rejection. That blocker, if it exists, is
Windows-driver-specific.)
**Why UHID can't fix it.** UHID creates one HID interface with no USB interface number; you cannot set
one, and creating three UHID devices wouldn't help (each is still interface-less / `-1`). Presenting a
real multi-interface USB Deck with the controller on interface 2 needs a **USB gadget** (`dummy_hcd` +
configfs) or a kernel USB bus driver — a much larger, less portable lift, and contrary to punktfunk's
"no kernel bus driver" stance (ViGEm was deliberately removed).
### Strategic consequences
- **The virtual Deck's real value is non-Steam / SDL games on Linux** (emulators, native SDL titles,
anything reading the kernel evdev or SDL's HIDAPI Steam driver) — there it delivers grips +
trackpads + gyro. It does **not** deliver Steam Input glyphs/bindings.
- **For Steam-Input hosts, the virtual DualSense is the right path — HARDWARE-VALIDATED (2026-06-29).**
A virtual DualSense (UHID, also `Interface: -1`) run on Bazzite while Steam ran was **fully
promoted**: `controller.txt` logged `Local Device Found type 054c 0ce6 "DualSense Wireless
Controller"`, then **`Controller using HIDAPI driver, vid=0x054c, pid=0x0ce6`** and **loaded
`configset_controller_ps5.vdf`** (it even read back our calibration/pairing/firmware feature blobs).
So the *same* interface `-1` that the Deck is rejected at is **accepted for the DualSense** — proof
the wall is specifically the Deck's multi-interface / interface-2 requirement, *not* a UHID
limitation. The DualSense path therefore delivers **real Steam Input** (gyro + touchpad + glyphs +
bindings) for a streamed Deck/SC client; the M5 paddle-fold carries the back grips onto standard
buttons. This is why the M6 conflict gate degrades to DualSense and `Auto` prefers it. **What the
DualSense identity loses vs a real Deck:** Deck glyphs, the *second* trackpad, and the 4 back grips
as distinct Steam-Input-bindable paddles (they fold to face/shoulder/stick buttons instead).
- **Full Deck-identity Steam Input would need interface 2 → a USB gadget (`dummy_hcd` + configfs HID
functions presenting kbd/mouse/controller, controller on interface 2).** Feasible in principle (it
gives real interface numbers), but heavy and less portable: `dummy_hcd` is not built on Bazzite, the
Deck, or the dev box, so it would have to be built/loaded per-kernel on every Steam host — and an
immutable SteamOS/Bazzite host makes that a package-layer + reboot. The marginal gain over the
validated DualSense path is Deck glyphs + the 2nd trackpad + native back-paddle bindings.
- **M7 (a Windows UMDF virtual Steam Deck) is NOT recommended.** Windows Steam applies the same
interface filter, and Windows has **no kernel-`hid-steam` evdev fallback** — Windows games consume
XInput / RawInput / Windows.Gaming.Input, none of which a non-promoted virtual Deck feeds. So a
Windows virtual Deck would be consumed by *nothing*. The existing Windows **virtual DualSense**
already covers the Steam-Input + gyro/touchpad case there.
### What the M0M6 work still delivers (not wasted)
- The **protocol/wire** (back buttons, second trackpad, gyro/accel, trackpad haptics) and the
**client capture** (paddles, both trackpads, gyro from a real Deck/SC) are general — they feed the
virtual DualSense path (Deck client → Steam-Input host) just as well, with the grips folded in (M5).
- The **virtual Deck backend** is the best option for non-Steam Linux games, and the **M5 motion
rescale + fallback remap** + the **M6 conflict gate** make the cross-backend behavior correct.
- The whole effort proved the greenfield `hid-steam` UHID device is real and kernel-validated on two
kernels — the open question was always Steam-userspace promotion, and now it's answered.
### Remaining validation (no further construction recommended)
1. A **live SDL/non-Steam game** on a Linux host actually consuming the virtual Deck's grips/trackpads
(the path that *does* work) — needs a real Deck/SC client + a Steam-Input-disabled consumer.
2. The **Moonlight paddle regression** from the M3 xpad-map change (stock paddle client → host).
### Gadget PoC — interface 2 is PROVEN on the Deck (2026-06-29)
SteamOS ships every primitive (`CONFIG_USB_DUMMY_HCD=m`, `CONFIG_USB_RAW_GADGET=m`,
`CONFIG_USB_CONFIGFS_F_HID=y`), so the gadget path is testable on the Deck itself with no
module-building. A pure-shell **configfs gadget** (`deck_gadget_up.sh`) stood up a real 3-interface
USB Deck on a `dummy_hcd` loopback UDC — keyboard = interface 0, mouse = 1, **controller = interface
2** (`STEAMDECK_RDESC`), `28DE:1205`. Result:
- It enumerates as a real USB device (`lsusb: 28de:1205 Valve Software Steam Deck Controller`) and
`hid-steam` binds **all three** interfaces — the controller on `bInterfaceNumber=02`.
- **Steam promoted it:** `Local Device Found … Interface: 2 … !! Steam controller device opened for
index 14 … Steam Controller reserving XInput slot 1`. *This is the proof: a device on interface 2
IS opened + XInput-reserved by Steam, where the interface-`-1` UHID device was filtered out.*
- It then failed at the next step — `f_hid` can't serve HID **feature reports** (`hid-steam:
steam_send_report: error -32 (ae 16 01)` → serial `XXXXXXXXXX`; Steam: `couldn't get controller
details … GetControllerInfo failed … Disconnecting zombie controller`). No gamepad evdev was
created either, for the same reason (hid-steam can't complete Deck init without the feature/output
channel).
**Conclusion: the wall is fully characterised and climbable.** Interface 2 is necessary *and*
sufficient for Steam to open + XInput-reserve the Deck; the only remaining piece is serving the
HID feature/output reports, which `f_hid` can't but **`raw_gadget` can** (userspace handles every
control transfer, exactly like the UHID path). Next: a `raw_gadget` userspace emulator of the
3-interface Deck (controller on interface 2) that answers the serial/attribute/settings feature
reports + streams the 64-byte state report — then re-test hid-steam gamepad evdev + Steam promotion.
### Gadget path SUCCESS — raw_gadget Deck gets full Steam Input recognition (2026-06-29)
The `f_hid` zombie was a feature-report problem, and `raw_gadget` (userspace handles every control
transfer) solves it. `packaging/linux/steam-deck-gadget/deck_raw_gadget.c` presents the real
3-interface Deck (descriptors captured verbatim from a physical Deck, controller on interface 2) and
answers the HID feature reports hid-steam/Steam need. Live on the Deck:
```
hid-steam ... Steam Controller 'PFDECK000' connected (serial READ — not XXXXXXXXXX)
input: Steam Deck / Steam Deck Motion Sensors (kernel gamepad + IMU evdevs)
controller.txt: Interface: 2 ... device opened for index 14 ... reserving XInput slot 1
input: Microsoft X-Box 360 pad 1 (Steam Input's XInput output — PROMOTED)
```
Stable (1 connect, 0 disconnects, no zombie). The kernel `"Steam Deck"` evdev is then grabbed by
Steam Input, which exposes its own X-Box 360 pad — a real Deck's exact behaviour. **This is the first
time a virtual Steam Deck has been fully promoted by Steam Input** (UHID can't; the interface-2 wall
is climbed). The hard part — recognition — is done.
Implementation gotchas (see the packaging README): `struct usb_endpoint_descriptor` is 9 bytes but
the wire descriptor needs 7; no-data OUT controls are acked with a zero-length `EP0_READ` not
`EP0_WRITE` (else `error -110`); the input streamer must not start until after SET_CONFIGURATION is
acked. Scope: SteamOS-host only (needs `dummy_hcd` + `raw_gadget`, which SteamOS ships; a generic
Linux host would have to build them).
**Remaining:** feed real client state through the interface-2 endpoint (the `steam_proto` serializer
already produces correct Deck reports — wire it to the gadget's stream), and wrap this as a host
gamepad backend (a `raw_gadget` alternative to the UHID `SteamDeckPad`). Then the streamed Deck/SC
client reaches the host's games as a true Deck through Steam Input.
### Input-flow status (2026-06-29) — delivered + format-validated; clean live-read is a backend task
With the A button held in the streamed report on a `pressa` build, on the Deck:
- **Reports are delivered to hid-steam** — the gadget logs `STREAM: first input report delivered
(host is polling int IN)`, i.e. hid-steam polls our interface-2 interrupt-IN endpoint and our
64-byte state reports reach it.
- **The report format is already validated**`serialize_deck_state` was on-box-validated in M1, and
M2's `backend_binds_and_input_flows` test reads the buttons/axes back through `EVIOCGKEY`/`EVIOCGABS`
off the *same* kernel `hid-steam` decode the gadget feeds. The gadget changes only the transport.
- **The gamepad evdev forms** (`input: Steam Deck` on `5-1:1.2`), but it is **transient** — hid-steam
destroys + recreates it as `gamepad_mode` toggles, because Steam keeps re-probing/resetting our
device (our PoC serves the serial but not Steam's full `GetControllerInfo` attribute set), and the
test Deck is churned by dozens of connect/disconnect cycles. So a *stable* live `EVIOCGKEY` catch of
the held A wasn't obtained.
Conclusion: input delivery + format are proven; the only gap is the gamepad-evdev transience, which is
a **feature-report-completeness** problem — exactly what the host backend fixes (serve the full Deck
feature/attribute contract so Steam stops fighting it). That's the next step, not more PoC patching.
### Feature contract hardened — the churn is fixed (2026-06-29)
The gamepad-evdev churn was Steam re-probing because the gadget served zeros for the HID feature
reports Steam's `GetControllerInfo` reads. The real contract was captured from a physical Deck
(`packaging/linux/steam-deck-gadget/get_deck_attrs.c`, hidraw `HIDIOCGFEATURE`) and implemented in
`steam_gadget.rs::feature_reply`: the **`0x83` GET_ATTRIBUTES_VALUES** blob (`[83,2d, 9×(id,u32-LE)]`
— product id `0x1205`, a per-instance unit serial, capability attrs) plus the **`0xAE`** string
attributes (serial / board serial) and a settings echo. Result on the Deck: **1 connect / 0
disconnect / 1 gamepad evdev** (was constant churn), Steam *activates* the gadget cleanly (no
`GetControllerInfo failed`, no zombie) and emits its **X-Box 360 pad 1**. usbmon on the gadget's bus
confirms our state reports (pressed button at byte 8) are delivered on the interrupt-IN and consumed
by hid-steam — so with M1/M2's byte-8→BTN_SOUTH decode, the input chain is proven end-to-end. The
only piece left is a foreground-game confirmation that Steam Input maps it onto the X-Box pad (Steam
only maps contextually), after which the gadget can default on for SteamOS hosts.
### Glass confirmed + default-on for SteamOS (2026-06-29)
Validated glass-to-glass on the Deck: the gadget shows up as a distinct second Steam controller, a
held A snaps the Steam overlay shut as "Resume Game" (so Steam Input receives + acts on the gadget's
input), and **a button press registers in a real game** — confirmed in-game. The two-Deck test
confound (the Deck has its physical Deck + the virtual one) is a test-rig artifact, not a feature
limit: a real non-Deck SteamOS host has only the virtual Deck, and a Deck-as-host degrades `SteamDeck`
→ DualSense via the M6 conflict gate before the gadget is ever built. So `gadget_preferred()` now
defaults **on for SteamOS** (`/etc/os-release` `ID=steamos`), off elsewhere (UHID stays default),
with `PUNKTFUNK_STEAM_GADGET=1`/`0` to force. The virtual Steam Deck — recognized + promoted by Steam
Input, churn-free, input flowing to games — is complete.
+205
View File
@@ -0,0 +1,205 @@
# Plan: production-ready Steam Deck pass-through (client) + shippable virtual Deck on any Linux host
> **Status (2026-06-29): BUILT — code-complete, all CI checks green on Linux (build · `clippy
> -D warnings` · `fmt` · ~270 tests), adversarially reviewed; NOT yet on-glass validated, NOT pushed.**
> Implemented in one pass:
> - **usbip/`vhci_hcd` transport** (`crates/punktfunk-host/src/inject/linux/steam_usbip.rs`) presenting
> a real interface-2 USB Deck, with **in-process vhci attach** (sysfs `OP_REQ_IMPORT` handshake) and a
> bounded `usbip`-CLI fallback. Backed by a **vendored, libusb-free trim** of the `usbip` crate
> (`crates/punktfunk-host/vendor/usbip-sim/`, MIT, see its NOTICE).
> - **Selection ladder** `raw_gadget` (SteamOS) → `usbip` (`vhci_hcd`, universal) → UHID
> (`steam_controller.rs::open_transport`); `PUNKTFUNK_STEAM_USBIP=0/1`, `PUNKTFUNK_USBIP_ATTACH=inproc|cli`.
> - **Shared Deck device contract** (captured descriptors + `0x83`/`0xAE` `feature_reply` + a
> Steam-accepted serial) consolidated into `steam_proto.rs`; the gadget now reuses it.
> - **Client leave-shortcuts**: keyboard **Ctrl+Alt+Shift+D** + controller **hold the escape chord
> (L1+R1+Start+Select) ≥1.5 s** → disconnect (short press still leaves fullscreen). Steam/QAM are NOT
> in the chord. Linux client only for now (windows/apple/android mirror is future work).
>
> **Decisions taken** (the plan's open questions): vendor-trim the crate (no libusb) ✓; in-process
> attach primary + CLI fallback ✓; escalate the existing escape chord (long-hold) ✓; keep BOTH
> `raw_gadget` (SteamOS fast-path) and usbip (universal) behind the transport ladder ✓.
>
> **Remaining = §6 on-glass validation** (Bazzite `192.168.1.41` + Deck `192.168.1.253`): confirm the
> in-process usbip attach promotes the Deck in game mode (Steam + QAM reach the game-mode UI), the
> raw_gadget path still works on SteamOS (regression), and the leave-shortcuts fire. The dev box has no
> Steam + no root, so this could not be run here.
>
> Companion doc: [`steam-controller-deck-support.md`](steam-controller-deck-support.md)
> (the virtual-Deck design + §11 the interface-2 / gadget story). The virtual Steam Deck that Steam
> Input promotes is **already built, hardware-validated, and default-on for SteamOS**
> (`raw_gadget`/`dummy_hcd`, glass-confirmed in-game on a real Deck).
## Goal
When a Steam Deck (or any Valve controller) is the **client**, streaming to a Linux **host** running
Steam (SteamOS *or* Bazzite/generic), every Deck control — including the **Steam** logo button and the
**QAM "…"** (quick-access) button — passes through and drives the host's game-mode UI, so it feels
native. Plus a leave-shortcut (controller + keyboard) since Steam/QAM now pass through.
## What's already true (do NOT rebuild — verified by investigation `wf_f5e3528b-3ef`)
The **client capture is already correct**, and the **wire + host mapping already carry Steam + QAM**:
- SDL3's HIDAPI Steam Deck driver exposes **Steam → `Button::Guide`** (joystick b5) and **QAM "…" →
`Button::Misc1`** (joystick b11→`misc1` in `SDL_gamepad_db.h:729`; confirmed in `SDL_gamepad.h`:
*"Steam Controller QAM"* = `MISC1`). Paddles → `RightPaddle1/2`,`LeftPaddle1/2`; trackpad clicks →
`Touchpad`/`Misc2`.
- `clients/linux/src/gamepad.rs:173-201` `button_bit()` already maps `Guide → wire::BTN_GUIDE`,
`Misc1 → wire::BTN_MISC1`, all four paddles, touchpad.
- Wire buttons are **`u32`** (`crates/punktfunk-core/src/input.rs:54-86`): `BTN_GUIDE=0x0400` (bit 10),
`BTN_MISC1=0x0020_0000` (bit 21); free bits = **11, 22-31**. Buttons ride as individual
`InputEvent` (0xC8) events (`code`=bit, `x`=1/0); rich input (touchpad/gyro) on `0xCC`.
- Host `steam_proto::from_gamepad` (`crates/punktfunk-host/src/inject/proto/steam_proto.rs:179-233`)
already maps **`BTN_GUIDE → btn::STEAM`** (line 214) and **`BTN_MISC1 → btn::QAM`** (line 230). The
`btn` module: `STEAM = 1<<13`, `QAM = 1<<50`.
- **Caveat that matters:** SDL only surfaces Steam/QAM when **Steam Input is NOT grabbing the
controller on the client** (else Steam consumes them globally and hands the app its virtual Xbox
pad, which lacks Steam/QAM). The fix is *disable Steam Input for the client* — already the Decky
plugin's "Disable Steam Input" UX. SDL's HIDAPI Deck driver is on by default on Linux
(`SDL_HIDAPI_DEFAULT`); `SDL_JOYSTICK_HIDAPI_STEAMDECK=1` is already set in `gamepad.rs:446-456`.
- **Only the Deck host backend carries QAM.** The Xbox/xpad map
(`inject/linux/gamepad.rs:80-97`) and DualSense (`dualsense_proto.rs:214`) map Guide→MODE/PS but
**drop `BTN_MISC1` (QAM)** — they have no slot for it. So QAM-to-game-mode *requires* the virtual
Deck backend (gadget or usbip). This is expected and correct.
**Net:** for SteamOS hosts the whole feature already works today (client capture → gadget Deck →
Steam Input). The remaining work is the *non-SteamOS host* (usbip) + the leave-shortcut + polish.
## Architecture decision: usbip/`vhci_hcd` is the shippable universal transport
Presenting a real interface-2 USB Deck on a generic Linux host is the only gap. Decision matrix:
| Mechanism | Ships? | Why |
|---|---|---|
| `dummy_hcd` + `raw_gadget` | SteamOS only | In-tree on SteamOS (used + validated). **Not** built on Bazzite/Fedora (`CONFIG_USB_DUMMY_HCD`/`RAW_GADGET` unset); building them needs `kernel-devel` **and** MOK-signing under Secure Boot → **not shippable**. |
| **`usbip` + `vhci_hcd`** | **everywhere** | **In-tree + signed** on SteamOS, Bazzite, and ~every distro (it's the standard usbip stack). Loads under Secure Boot, **no module build, no MOK**. A userspace usbip server emulates the Deck; `vhci_hcd` attaches it locally. |
**Both validated on hardware (2026-06-29):**
- `raw_gadget` Deck on a real Steam Deck → Steam promotes it, glass-confirmed in-game.
- `usbip` Deck on **Bazzite**`usbip attach -r 127.0.0.1 -b 0-0-0``vhci_hcd` enumerates the
3-interface Deck, `hid-steam` binds it, reads the serial, makes the `Steam Deck`/`Motion Sensors`
evdevs, **stable (1 connect / 0 disconnect)**, and Steam logs `Interface: 2 … opened for index …
reserving XInput slot 1` + emits an X-Box pad. **Identical recognition to the gadget.**
The working PoC is checked in at `packaging/linux/steam-deck-gadget/usbip-poc/` — the new session
should build on it. It uses the `usbip` crate (jiegec/usbip v0.8.0): a custom `UsbInterfaceHandler`
(`get_class_specific_descriptor` = the 9-byte HID descriptor; `handle_urb` = GET report-descriptor /
HID `GET_REPORT`=`feature_reply` / `SET_REPORT` / interrupt-IN = the 64-byte Deck state), reusing the
exact captured descriptors + feature contract from `steam_gadget.rs`.
## Build steps (ordered)
### 1. Refactor `steam_gadget.rs` into shared Deck-logic + a transport trait
The descriptor set (mouse/kbd/controller report descriptors, the device/config assembly), the
`feature_reply` (0x83 attributes + 0xAE serial), and `serialize_deck_state` are **transport-agnostic**
and already proven. Extract them into a shared module (e.g. `inject/proto/steam_proto.rs` already holds
`serialize_deck_state`/`feature_reply`-equivalents; consolidate the gadget's `feature_reply` +
descriptors there or a new `steam_device.rs`). Define:
```rust
/// A virtual Deck transport: feed it the current 64-byte state, drain feedback.
trait DeckTransport {
fn write_state(&mut self, st: &SteamState);
fn service(&mut self) -> Option<(u16, u16)>; // rumble
}
```
Make the existing `raw_gadget` `SteamDeckGadget` implement it (it already has `write_state`/`service`).
### 2. Add the usbip transport (`SteamDeckUsbip`)
- Reuse the PoC's device definition + handler. Drive the interrupt-IN report from the shared
`SteamState` (a `Arc<Mutex<[u8;64]>>` the handler reads), updated by `write_state`.
- **Dependency decision:** the `usbip` crate hard-depends on `rusb``libusb1-sys` (for its *host*
mode, which we don't use; it also breaks `musl`). For a clean shippable host, **vendor a trimmed
copy** of the crate (keep `lib.rs`, `device.rs`, `interface.rs`, `endpoint.rs`, `setup.rs`,
`usbip_protocol.rs`, `util.rs`, `consts.rs`; drop `host.rs`/`cdc.rs`/`hid.rs` + the `rusb`/`nusb`
deps) under e.g. `crates/punktfunk-host/vendor/usbip-sim/`, or accept the libusb dep if vendoring
is too much churn. Recommendation: vendor-trim (no libusb at runtime).
- **Runtime:** the usbip server is tokio-based. Run it on a dedicated runtime/thread (the host already
uses tokio behind the `quic` feature). Keep it off the per-frame video path (input only — fine).
- **Local attach without the `usbip` CLI (preferred):** don't shell out to `usbip attach`
(avoids an external `usbip-utils` runtime dep). Implement the client side in-process: connect to our
own server (or better, a `socketpair`/unix socket to avoid a TCP port), do the `OP_REQ_IMPORT`
handshake, then write `"<port> <sockfd> <devid> <speed>"` to
`/sys/devices/platform/vhci_hcd.0/attach`. (Acceptable fallback for v1: depend on the `usbip` CLI,
which is widely packaged, and `Command::new("usbip").args(["attach","-r","127.0.0.1","-b","0-0-0"])`.)
- **`ensure_modules`:** `modprobe vhci_hcd` (best-effort) the way the gadget does `dummy_hcd raw_gadget`.
### 3. Transport selection (in `inject/linux/steam_controller.rs` `ensure()` + `gadget_preferred`)
Extend the existing `DeckTransport` enum (currently `Uhid | Gadget`) to `Uhid | Gadget | Usbip` and the
selection ladder to: **`raw_gadget` if `/dev/raw-gadget` usable (SteamOS) → else `usbip` if `vhci_hcd`
loadable (Bazzite/generic) → else UHID/DualSense.** `gadget_preferred()` currently keys on
`ID=steamos`; generalize to "a recognized-by-Steam transport is available" (raw_gadget OR usbip).
Keep the M6 conflict gate (`degrade_steam_on_conflict` in `punktfunk1.rs`) ahead of all this — a host
with a *physical* Deck still degrades `SteamDeck`→DualSense, so two-Decks never happens in production.
### 4. Client leave-shortcut (`clients/linux/src/`)
Steam/QAM now pass through, so add an explicit disconnect:
- **Keyboard:** in `ui_stream.rs:300-310` (next to the `Ctrl+Alt+Shift+Q` capture toggle) add
`Ctrl+Alt+Shift+D``stop.store(true, …)` (the `stop_h` is already in scope), `Propagation::Stop`.
- **Controller:** in `gamepad.rs` (model on `maybe_fire_escape` at `:354-362`, `ESCAPE_CHORD` at `:36`)
add a disconnect chord. Recommended: **hold Start+Select+L1+R1 ≥ ~1.5 s** (escalate the existing
escape chord — short press leaves fullscreen, long-hold disconnects) OR a dedicated combo. Fire a
`disconnect_tx` consumed in `ui_stream.rs` (parallel to the escape future) → set the session `stop`
flag (`session.rs:73,212-214`). Do **not** use Steam/QAM in the chord (they're the marquee
pass-through buttons). Mirror the same to the other clients (windows/apple/android) later.
### 5. Polish
- **Serial format:** Steam flagged `PFDECK0000` as an "Invalid or missing unit serial number" and
substituted `28de-1205-<hash>` (benign, still promoted). Use a serial Steam accepts (a real Deck's
is alphanumeric like `FVZZ4200469B`); derive a per-instance valid-looking serial. The `0xAE`
attr-1 reply + the `0x83` unit-id attrs (`0x0a`/`0x04`) should be consistent.
- Verify the **Decky/client "Disable Steam Input"** path actually frees the Deck controller for SDL on
the client (so Steam/QAM reach SDL). This is the one runtime precondition for capture.
### 6. Validation (glass-to-glass)
- **Bazzite host** (`bazzite@192.168.1.41`): run the host with the usbip transport, connect the Linux
client (a Deck or a machine with a Valve controller, Steam Input disabled), and confirm in **game
mode** that the Steam button opens the Steam menu and the **QAM "…" button opens Quick Access**.
- **SteamOS host** (`deck@192.168.1.253`): confirm `raw_gadget` still selected + works (regression).
- Confirm the leave-shortcut works from both controller and keyboard while Steam/QAM pass through.
## Key findings / gotchas (so they aren't rediscovered)
- **usbip PoC portability:** the glibc build needs `GLIBC_2.34` (Bazzite has 2.42) + libusb (present
or vendored) → a dev-box glibc binary runs on Bazzite. `musl` fails (libusb1-sys). The server runs
as an unprivileged **user** (TCP 3240); only `modprobe vhci_hcd` + the attach need **root**. A
systemd *system* service can't exec from `/home` (perms) — run the server as the user.
- **raw_gadget gotchas** (already solved, see `steam-controller-deck-support.md` §11): 7-vs-9-byte
endpoint descriptor; no-data OUT controls acked via zero-length `EP0_READ`; no-arg ioctls must pass
an explicit `0` (musl); `libc::ioctl` request is `c_ulong`/`c_int` per libc → `as _`.
- **Feature contract** is what stops the gamepad-evdev churn (Steam re-probing): serve the captured
`0x83 GET_ATTRIBUTES` blob + `0xAE` serial (`packaging/linux/steam-deck-gadget/get_deck_attrs.c`
captures them from a physical Deck via hidraw `HIDIOCGFEATURE`; usbmon truncates to 32B). This is
already in `steam_gadget.rs::feature_reply` and the usbip PoC.
- **Captured descriptors** (verbatim from a physical Deck) live in `steam_gadget.rs` + the usbip PoC:
mouse (65B), keyboard (39B), controller (38B, Usage Page `0xFFFF`), endpoints `0x81/0x82/0x83`,
controller `bCountryCode 33`.
## Hardware + recipes
- **Deck (SteamOS)** `ssh deck@192.168.1.253` — has `dummy_hcd`+`raw_gadget`+`vhci_hcd`+`usbip`; a
*physical* Deck controller (so it degrades to DualSense by the M6 gate — for raw_gadget testing
there, de-authorize the physical Deck via `/sys/bus/usb/devices/3-3/authorized`). No `gcc`.
- **Bazzite** `ssh bazzite@192.168.1.41``vhci_hcd`+`usbip` (signed, in-tree), **no** dummy_hcd;
Secure Boot **on**; `gcc`+`kernel-devel` present; Steam runs. This is the usbip test bed.
- Both need passwordless sudo for driving (`/etc/sudoers.d/zz-punktfunk-poc` — remove when done). SSH
via `-o BatchMode=yes`. No `gcc` on the Deck → build static/glibc on the dev box + `scp`.
- usbip quick test (Bazzite): `sudo modprobe vhci_hcd; ./usbip-deck-poc pressa & ; sudo usbip attach
-r 127.0.0.1 -b 0-0-0` then watch `dmesg` + `~/.local/share/Steam/logs/controller.txt` for
`Interface: 2 … reserving XInput slot`.
## Open decisions for the new session
1. **Vendor-trim the `usbip` crate (no libusb) vs. accept the `rusb`/libusb dep.** Recommend trim.
2. **In-process vhci attach (write the sysfs) vs. shell out to the `usbip` CLI.** Recommend in-process
for v1-ship (no external CLI dep); CLI is the quick path to a working build first.
3. **Controller leave-chord**: escalate the escape chord (long-hold) vs. a dedicated combo.
4. Whether to **unify on usbip everywhere** (it works on SteamOS too) and retire `raw_gadget`, vs.
keep `raw_gadget` for SteamOS (already validated). Recommend keep both behind the trait — usbip is
the universal fallback, raw_gadget the validated SteamOS fast-path.
## Commit trail (this work, all on `main`, NOT pushed)
`faea4f1``a33c7d3` (M0M6) · `b6b6f27` (raw_gadget Deck) · `9e5112b` (feature contract) ·
`b3bc313` (host backend) · `8c3188d` (glass-confirmed + default-on SteamOS). The usbip PoC +
this plan are the next commits.
+98
View File
@@ -28,6 +28,11 @@
// `PunktfunkHidOutput::kind` — one adaptive-trigger effect (`which` + `effect`/`effect_len` valid). // `PunktfunkHidOutput::kind` — one adaptive-trigger effect (`which` + `effect`/`effect_len` valid).
#define PUNKTFUNK_HIDOUT_TRIGGER 3 #define PUNKTFUNK_HIDOUT_TRIGGER 3
// `PunktfunkHidOutput::kind` — a trackpad haptic pulse (Steam Controller voice-coils). `which` =
// side (0 = right pad, 1 = left pad); `effect[0..6]` packs `amplitude` / `period` / `count` as
// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
#define PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC 4
// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block). // Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
#define PUNKTFUNK_HID_EFFECT_MAX 11 #define PUNKTFUNK_HID_EFFECT_MAX 11
@@ -37,6 +42,12 @@
// `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid). // `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid).
#define PUNKTFUNK_RICH_MOTION 2 #define PUNKTFUNK_RICH_MOTION 2
// `RichInput::TouchpadEx` kind on the wire — an extended trackpad contact that identifies the
// surface (0 single / 1 Steam-left / 2 Steam-right) and carries click + pressure. The host decodes
// it today; *sending* it from a C client needs the size-prefixed `PunktfunkRichInputEx` +
// `punktfunk_connection_send_rich_input2` (added with client capture).
#define PUNKTFUNK_RICH_TOUCHPAD_EX 3
// Compositor preference for [`punktfunk_connect_ex`] (`compositor` arg). `AUTO` lets the host // Compositor preference for [`punktfunk_connect_ex`] (`compositor` arg). `AUTO` lets the host
// pick (auto-detect from its running desktop); a concrete value is honored only if that backend // pick (auto-detect from its running desktop); a concrete value is honored only if that backend
// is available on the host right now, else the host falls back to auto-detect. The resolved // is available on the host right now, else the host falls back to auto-detect. The resolved
@@ -82,6 +93,28 @@
// hosts); otherwise the host falls back to X-Box 360. // hosts); otherwise the host falls back to X-Box 360.
#define PUNKTFUNK_GAMEPAD_DUALSHOCK4 4 #define PUNKTFUNK_GAMEPAD_DUALSHOCK4 4
// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): dual trackpads, gyro,
// two grip paddles. Reserved — currently folds to `XBOX360` until its backend lands.
#define PUNKTFUNK_GAMEPAD_STEAMCONTROLLER 5
// 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
// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360.
#define PUNKTFUNK_GAMEPAD_STEAMDECK 6
// 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
// `buttonFlags2 << 16` namespace. Mirror `input::gamepad::BTN_PADDLE1..4` / `BTN_MISC1`.
#define PUNKTFUNK_GAMEPAD_BTN_PADDLE1 65536
#define PUNKTFUNK_GAMEPAD_BTN_PADDLE2 131072
#define PUNKTFUNK_GAMEPAD_BTN_PADDLE3 262144
#define PUNKTFUNK_GAMEPAD_BTN_PADDLE4 524288
#define PUNKTFUNK_GAMEPAD_BTN_MISC1 2097152
// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`. // Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`.
// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to // Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to
// [`punktfunk_connect_ex`] with `compositor = PUNKTFUNK_COMPOSITOR_AUTO`. // [`punktfunk_connect_ex`] with `compositor = PUNKTFUNK_COMPOSITOR_AUTO`.
@@ -139,11 +172,26 @@
#define PUNKTFUNK_BTN_Y 32768 #define PUNKTFUNK_BTN_Y 32768
// Back grip R4 — SDL `RightPaddle1` / GameStream `PADDLE1`.
#define BTN_PADDLE1 65536
// Back grip L4 — SDL `LeftPaddle1` / GameStream `PADDLE2`.
#define BTN_PADDLE2 131072
// Back grip R5 — SDL `RightPaddle2` / GameStream `PADDLE3`.
#define BTN_PADDLE3 262144
// Back grip L5 — SDL `LeftPaddle2` / GameStream `PADDLE4`.
#define BTN_PADDLE4 524288
// DualSense touchpad click. Moonlight's extended-button position (`buttonFlags2` // DualSense touchpad click. Moonlight's extended-button position (`buttonFlags2`
// merges in at `<< 16`, see `gamestream/gamepad.rs`), so GameStream clients land on // merges in at `<< 16`, see `gamestream/gamepad.rs`), so GameStream clients land on
// the same bit. Only the DualSense backend renders it; the xpad has no such button. // the same bit. Only the DualSense backend renders it; the xpad has no such button.
#define PUNKTFUNK_BTN_TOUCHPAD 1048576 #define PUNKTFUNK_BTN_TOUCHPAD 1048576
// Misc / capture button — the Deck `…`/quick-access, Share/Capture / GameStream `MISC`.
#define BTN_MISC1 2097152
// Axis ids for `InputKind::GamepadAxis`. // Axis ids for `InputKind::GamepadAxis`.
#define PUNKTFUNK_AXIS_LS_X 0 #define PUNKTFUNK_AXIS_LS_X 0
@@ -620,6 +668,44 @@ typedef struct {
} PunktfunkRichInput; } PunktfunkRichInput;
#endif #endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Forward-compatible superset of [`PunktfunkRichInput`] that can also express the rich Steam
// surfaces: a *second* trackpad (`surface`), a distinct `click` vs touch, signed coordinates, and
// pressure. Sent via [`punktfunk_connection_send_rich_input2`] — the only way a C client can emit a
// `TouchpadEx`. The caller MUST set `struct_size = sizeof(PunktfunkRichInputEx)` (the ABI-skew
// guard, like [`PunktfunkConfig`]); the legacy [`PunktfunkRichInput`] +
// [`punktfunk_connection_send_rich_input`] stay byte-for-byte for existing callers.
typedef struct {
// MUST equal `sizeof(PunktfunkRichInputEx)`.
uint32_t struct_size;
// One of `PUNKTFUNK_RICH_*` (`TOUCHPAD` / `MOTION` / `TOUCHPAD_EX`).
uint8_t kind;
// Gamepad index.
uint8_t pad;
// Touchpad/TouchpadEx: contact id.
uint8_t finger;
// Touchpad/TouchpadEx: 1 = finger down / touching, 0 = lifted.
uint8_t active;
// TouchpadEx: which surface — 0 = single/DualSense, 1 = Steam left pad, 2 = Steam right pad.
uint8_t surface;
// TouchpadEx: 1 = the pad is physically clicked (depressed), distinct from a touch contact.
uint8_t click;
// Reserved for alignment; set to 0.
uint8_t _reserved[2];
// TouchpadEx: x coordinate — **signed**, centred at 0 (the real Steam report convention). For a
// legacy `TOUCHPAD` kind sent through this struct, store the unsigned `0..=65535` value's bits.
int16_t x;
// TouchpadEx: y coordinate — signed, centred at 0.
int16_t y;
// TouchpadEx: contact pressure (`0` if the surface has no force sensor).
uint16_t pressure;
// Motion: gyro (pitch, yaw, roll), raw signed-16.
int16_t gyro[3];
// Motion: accelerometer (x, y, z), raw signed-16.
int16_t accel[3];
} PunktfunkRichInputEx;
#endif
// A speed-test measurement, filled by [`punktfunk_connection_probe_result`]. `done` is 0 until // A speed-test measurement, filled by [`punktfunk_connection_probe_result`]. `done` is 0 until
// the host's end-of-burst report lands, then 1 (the numbers are final). `throughput_kbps` is the // the host's end-of-burst report lands, then 1 (the numbers are final). `throughput_kbps` is the
// delivered wire throughput to drive a bitrate choice from; `loss_pct` is the link loss and // delivered wire throughput to drive a bitrate choice from; `loss_pct` is the link loss and
@@ -1111,6 +1197,18 @@ PunktfunkStatus punktfunk_connection_send_rich_input(PunktfunkConnection *c,
const PunktfunkRichInput *rich); const PunktfunkRichInput *rich);
#endif #endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Send a rich client→host input via the forward-compatible [`PunktfunkRichInputEx`] — the only way
// a C client can emit a `TouchpadEx` (a second trackpad / signed coords / pressure). Set
// `rich->struct_size = sizeof(PunktfunkRichInputEx)`; a smaller (older-layout) value is rejected.
//
// # Safety
// `c` is a valid connection handle; `rich` is null or points to at least its declared
// `struct_size` bytes.
PunktfunkStatus punktfunk_connection_send_rich_input2(PunktfunkConnection *c,
const PunktfunkRichInputEx *rich);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC) #if defined(PUNKTFUNK_FEATURE_QUIC)
// The currently active session mode — the Welcome's, until an accepted // The currently active session mode — the Welcome's, until an accepted
// [`punktfunk_connection_request_mode`] switches it. Safe any time after connect. // [`punktfunk_connection_request_mode`] switches it. Safe any time after connect.
+100
View File
@@ -0,0 +1,100 @@
# Virtual Steam Deck via USB gadget — true Steam Input recognition
**Proven on a real Steam Deck (SteamOS 3.8.11), 2026-06-29.** A `raw_gadget` userspace emulator of a
real 3-interface USB Steam Deck (`28DE:1205`) — mouse = interface 0, keyboard = 1, **controller =
interface 2** — bound to a `dummy_hcd` loopback UDC, so the host's own Steam sees a genuine
interface-2 Deck and **promotes it through Steam Input** (XInput pad emission, glyphs, bindings).
## Why this exists (the interface-2 wall)
A virtual Deck created via **UHID** (the `inject/proto/steam_proto.rs` / `steam_controller.rs` path)
binds the kernel `hid-steam` driver, but **Steam Input will not manage it**: Steam filters the Deck's
controller to USB **interface 2**, and a UHID device has no USB interface number (`Interface: -1` in
Steam's `controller.txt`), so Steam enumerates it but never promotes it. A single-interface DualSense
is accepted at `-1` (no ambiguity), but the multi-interface Deck specifically needs interface 2. See
`design/steam-controller-deck-support.md` §11.
A real multi-interface USB device with the controller on interface 2 requires a **USB gadget**.
SteamOS ships every piece (`CONFIG_USB_DUMMY_HCD=m`, `CONFIG_USB_RAW_GADGET=m`,
`CONFIG_USB_CONFIGFS_F_HID=y`), so this runs on a Deck with no module-building.
## What's here
- **`deck_raw_gadget.c`** — the working emulator. Presents the 3-interface Deck with descriptors
captured verbatim from a physical Deck (incl. the real 38-byte controller report descriptor), and
— crucially — answers **every** control transfer, including the HID feature reports (`f_hid` can't,
so it produced a "zombie controller" in Steam). Streams the 64-byte state report on the interface-2
interrupt-IN endpoint. Build static (the Deck has no compiler):
```sh
gcc -O2 -static -pthread -o deck_raw_gadget deck_raw_gadget.c
```
Run as root with `dummy_hcd` + `raw_gadget` loaded: `./deck_raw_gadget [seconds]`.
- **`configfs_gadget_up.sh` / `_down.sh`** — the simpler **configfs `f_hid`** variant. It proves the
structure (interface 2 → `hid-steam` binds → Steam *opens* it + *reserves an XInput slot*) but
`f_hid` cannot serve HID feature reports, so Steam can't read controller details and drops it as a
zombie. Kept as the minimal reproducer of the interface-2 result.
## Result (raw_gadget, live)
```
hid-steam ... Steam Controller 'PFDECK000' connected
input: Steam Deck / Steam Deck Motion Sensors (kernel gamepad + IMU evdevs)
controller.txt: Interface: 2 ... device opened for index 14 ... reserving XInput slot 1
input: Microsoft X-Box 360 pad 1 (Steam Input's XInput output — promoted)
```
Stable (1 connect, 0 disconnects), no zombie. The kernel `"Steam Deck"` evdev is then grabbed by
Steam Input, which exposes its own X-Box 360 pad — exactly a real Deck's behaviour.
## Key implementation gotchas (all real, all cost time)
- `struct usb_endpoint_descriptor` (ch9.h) is **9 bytes** (audio `bRefresh`/`bSynchAddress`); the wire
descriptor needs **7** — use a packed 7-byte struct in the config blob or the kernel mis-parses it.
- raw_gadget EP0: a **no-data OUT** control (`SET_CONFIGURATION`, `SET_INTERFACE`, `SET_IDLE`,
`SET_PROTOCOL`) is completed with a zero-length **`EP0_READ`**, not `EP0_WRITE` (using write →
`EBUSY`/`can't set config error -110`). IN controls (`GET_*`) use `EP0_WRITE`.
- Don't start the input streamer until after `SET_CONFIGURATION` is fully acked, or its blocking
`EP_WRITE` starves the control path.
- `dummy_hcd` + `raw_gadget` must both be loaded and `/dev/raw-gadget` present before launch.
## Host backend (shipped — default on for SteamOS)
The C PoC's transport is ported to a Rust host gamepad backend:
`crates/punktfunk-host/src/inject/linux/steam_gadget.rs` (`SteamDeckGadget`), driven by the same
`steam_proto` serializer as the UHID `SteamDeckPad`. The Steam-Deck manager
(`inject/linux/steam_controller.rs`) selects per-pad between **UHID** (universal) and the **USB
gadget**: the gadget is the **default on SteamOS hosts** (`gadget_preferred()``ID=steamos`;
best-effort `modprobe dummy_hcd raw_gadget`, graceful fallback to UHID if `/dev/raw-gadget` is
unusable), and off elsewhere where UHID stays the default. `PUNKTFUNK_STEAM_GADGET=1`/`0` forces it.
A Deck-as-host with a *physical* Deck never uses it — `resolve_gamepad`'s conflict gate degrades
`SteamDeck` → DualSense first.
The Rust transport is **validated on the Deck** (a static musl test binary that `#[path]`-includes the
real module): it enumerates the 3-interface Deck, hid-steam binds it + reads our serial + creates the
`Steam Deck` + `Motion Sensors` evdevs — identical to the C PoC. A real USB-stack bug it caught: on
musl, `ioctl(fd, RUN)` with no third arg passes a garbage `value`, and raw_gadget's `RUN`/`CONFIGURE`/
`EP0_STALL` reject a non-zero `value` with `EINVAL` — so the no-arg ioctls must pass an explicit `0`.
## Feature contract (hardened — churn fixed)
Steam's `GetControllerInfo` reads HID **feature reports**; serving the real ones is what stops Steam
re-probing (which was destroying + recreating the gamepad evdev — the "churn"). The contract was
captured from a physical Deck (`get_deck_attrs.c`, hidraw `HIDIOCGFEATURE`; usbmon truncates to 32B):
- **`0x83` GET_ATTRIBUTES_VALUES** — `[83, 2d, 9× (attr-id, u32-LE)]`: product id `0x1205`, a unit
serial (`0x0a`/`0x04` — we stamp a per-instance value so a gadget never collides with a real Deck),
and capability attrs (`0x09=0x2e`, `0x0b=0x0fa0`, `0x02/0x0c/0x0d/0x0e=0`). **This blob is the fix.**
- **`0xAE` GET_STRING_ATTRIBUTE** — `[ae, len, attr, ascii]`: serial (attr 1), board serial (attr 0).
- Other commands (e.g. `0x87` settings) read back the last write (echo).
Result on the Deck (`feature_reply` in `steam_gadget.rs`): **1 connect / 0 disconnect / 1 gamepad
evdev** (was constant churn), and Steam *activates* the controller cleanly (no `GetControllerInfo
failed`, no zombie) and emits its **X-Box 360 pad**. usbmon on the gadget's bus confirms our state
reports (with the pressed button at byte 8) are delivered on the interrupt-IN and consumed by
hid-steam — so the input transport is proven end-to-end.
## Remaining
- **Glass confirmation of the XInput mapping** — Steam Input only maps the gadget's raw input onto its
X-Box pad while a game using Steam Input is focused; confirm a button reaches a real game, then
default the gadget on for SteamOS hosts (it's strictly better than the non-promoted UHID path).
- A `punktfunk-host` build for SteamOS to exercise the integrated path end-to-end with a live client.
@@ -0,0 +1,12 @@
#!/bin/bash
# Tear down the PoC virtual Deck gadget.
G=/sys/kernel/config/usb_gadget/pfdeck
[ -d "$G" ] || { echo "no gadget"; exit 0; }
echo "" > "$G/UDC" 2>/dev/null || true
for l in "$G"/configs/c.1/hid.usb*; do [ -e "$l" ] && rm -f "$l"; done
rmdir "$G"/configs/c.1/strings/0x409 2>/dev/null || true
rmdir "$G"/configs/c.1 2>/dev/null || true
rmdir "$G"/functions/hid.usb* 2>/dev/null || true
rmdir "$G"/strings/0x409 2>/dev/null || true
rmdir "$G" 2>/dev/null || true
echo "gadget torn down ($(ls /sys/kernel/config/usb_gadget/ 2>/dev/null | wc -l) gadgets remain)"
@@ -0,0 +1,86 @@
#!/bin/bash
# PoC: stand up a REAL 3-interface USB Steam Deck (28DE:1205) on a dummy_hcd loopback UDC, with the
# controller on **interface 2** (kbd=0, mouse=1) — the structure Steam's controller driver filters
# for. Run as root on the Deck (which ships dummy_hcd + configfs f_hid). Then we check: does hid-steam
# bind interface 2, and does the Deck's own Steam promote it (controller.txt "Interface: 2")?
set -e
G=/sys/kernel/config/usb_gadget/pfdeck
echo "== modprobe dummy_hcd + libcomposite =="
modprobe dummy_hcd
modprobe libcomposite
UDC=$(ls /sys/class/udc | grep -i dummy | head -1)
echo "dummy UDC: ${UDC:-<none found!>}"
[ -n "$UDC" ] || { echo "no dummy UDC — abort"; exit 1; }
# Tear down a prior instance if present.
if [ -d "$G" ]; then
echo "" > "$G/UDC" 2>/dev/null || true
for l in "$G"/configs/c.1/hid.usb*; do [ -e "$l" ] && rm -f "$l"; done
rmdir "$G"/configs/c.1/strings/0x409 2>/dev/null || true
rmdir "$G"/configs/c.1 2>/dev/null || true
rmdir "$G"/functions/hid.usb* 2>/dev/null || true
rmdir "$G"/strings/0x409 2>/dev/null || true
rmdir "$G" 2>/dev/null || true
fi
echo "== build gadget $G =="
mkdir -p "$G"; cd "$G"
echo 0x28de > idVendor
echo 0x1205 > idProduct
echo 0x0110 > bcdDevice
echo 0x0200 > bcdUSB
mkdir -p strings/0x409
echo "Valve Software" > strings/0x409/manufacturer
echo "Steam Deck Controller" > strings/0x409/product
echo "PFDECK0001" > strings/0x409/serialnumber
# --- interface 0: boot keyboard ---
mkdir -p functions/hid.usb0
echo 1 > functions/hid.usb0/protocol
echo 1 > functions/hid.usb0/subclass
echo 8 > functions/hid.usb0/report_length
printf '\x05\x01\x09\x06\xa1\x01\x05\x07\x19\xe0\x29\xe7\x15\x00\x25\x01\x75\x01\x95\x08\x81\x02\x95\x01\x75\x08\x81\x03\x95\x05\x75\x01\x05\x08\x19\x01\x29\x05\x91\x02\x95\x01\x75\x03\x91\x03\x95\x06\x75\x08\x15\x00\x25\x65\x05\x07\x19\x00\x29\x65\x81\x00\xc0' > functions/hid.usb0/report_desc
# --- interface 1: boot mouse ---
mkdir -p functions/hid.usb1
echo 2 > functions/hid.usb1/protocol
echo 1 > functions/hid.usb1/subclass
echo 4 > functions/hid.usb1/report_length
printf '\x05\x01\x09\x02\xa1\x01\x09\x01\xa1\x00\x05\x09\x19\x01\x29\x03\x15\x00\x25\x01\x95\x03\x75\x01\x81\x02\x95\x01\x75\x05\x81\x03\x05\x01\x09\x30\x09\x31\x15\x81\x25\x7f\x75\x08\x95\x02\x81\x06\xc0\xc0' > functions/hid.usb1/report_desc
# --- interface 2: the Steam Deck controller (STEAMDECK_RDESC) ---
mkdir -p functions/hid.usb2
echo 0 > functions/hid.usb2/protocol
echo 0 > functions/hid.usb2/subclass
echo 64 > functions/hid.usb2/report_length
printf '\x06\x00\xff\x09\x01\xa1\x01\x15\x00\x26\xff\x00\x75\x08\x95\x40\x09\x01\x81\x02\x09\x01\x95\x40\xb1\x02\xc0' > functions/hid.usb2/report_desc
# --- config, link in order so interface numbers are 0,1,2 ---
mkdir -p configs/c.1/strings/0x409
echo "Punktfunk virtual Deck" > configs/c.1/strings/0x409/configuration
echo 250 > configs/c.1/MaxPower
ln -s functions/hid.usb0 configs/c.1/
ln -s functions/hid.usb1 configs/c.1/
ln -s functions/hid.usb2 configs/c.1/
echo "== bind to $UDC =="
echo "$UDC" > UDC
sleep 2
echo ""; echo "===== VERIFY ====="
echo "--- /sys hid devices for 28DE (which interface, which driver) ---"
for d in /sys/bus/hid/devices/*28DE*; do
[ -e "$d" ] || continue
rp=$(readlink -f "$d")
echo " $(basename "$d"): bInterfaceNumber=$(cat "$rp/../bInterfaceNumber" 2>/dev/null) driver=$(basename "$(readlink -f "$d/driver" 2>/dev/null)")"
done
echo "--- hidg char devices (controller = hidg for interface 2) ---"; ls -1 /dev/hidg* 2>/dev/null
echo "--- kernel log (hid-steam bind + Steam Deck evdev) ---"
journalctl -k --since "20 seconds ago" --no-pager 2>/dev/null | grep -iE "steam|28de|1205|hid-generic" | tail -10
echo "--- /proc input: Steam Deck evdevs created? ---"
grep -c '^N: Name="Steam Deck' /proc/bus/input/devices | sed 's/^/ Steam Deck input nodes: /'
echo "--- lsusb ---"; lsusb -d 28de:1205 2>/dev/null || true
echo ""
echo "Gadget is UP. Feed a neutral controller report with: printf '\\x01\\x00\\x09\\x3c' | dd of=/dev/hidg2 ..."
echo "Tear down with: deck_gadget_down.sh"
@@ -0,0 +1,260 @@
// raw_gadget emulator of a real 3-interface USB Steam Deck (28DE:1205): mouse=iface0, keyboard=iface1,
// controller=iface2 (the structure Steam filters for). Unlike f_hid, raw_gadget lets us answer EVERY
// control transfer — including the HID feature reports hid-steam/Steam need (the serial etc.) — so the
// Deck fully initialises (gamepad evdev) and Steam can read controller details (no "zombie").
//
// Descriptors captured verbatim from a physical Deck. Build (static, to run on SteamOS):
// gcc -O2 -static -o deck_raw_gadget deck_raw_gadget.c -lpthread
// Run as root on a host with dummy_hcd loaded: ./deck_raw_gadget [seconds]
#include <linux/usb/ch9.h>
#include <sys/ioctl.h>
#include <errno.h>
#include <fcntl.h>
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
/* ---- raw_gadget UAPI (inlined so we don't depend on the header) ---- */
#define UDC_NAME_LENGTH_MAX 128
struct usb_raw_init { __u8 driver_name[UDC_NAME_LENGTH_MAX]; __u8 device_name[UDC_NAME_LENGTH_MAX]; __u8 speed; };
enum usb_raw_event_type { USB_RAW_EVENT_INVALID, USB_RAW_EVENT_CONNECT, USB_RAW_EVENT_CONTROL };
struct usb_raw_event { __u32 type; __u32 length; __u8 data[0]; };
struct usb_raw_ep_io { __u16 ep; __u16 flags; __u32 length; __u8 data[0]; };
#define USB_RAW_EPS_NUM_MAX 30
#define USB_RAW_EP_NAME_MAX 16
struct usb_raw_ep_caps { __u32 type_control:1, type_iso:1, type_bulk:1, type_int:1, dir_in:1, dir_out:1; };
struct usb_raw_ep_limits { __u16 maxpacket_limit; __u16 max_streams; __u32 reserved; };
struct usb_raw_ep_info { __u8 name[USB_RAW_EP_NAME_MAX]; __u32 addr; struct usb_raw_ep_caps caps; struct usb_raw_ep_limits limits; };
struct usb_raw_eps_info { struct usb_raw_ep_info eps[USB_RAW_EPS_NUM_MAX]; };
#define USB_RAW_IOCTL_INIT _IOW('U', 0, struct usb_raw_init)
#define USB_RAW_IOCTL_RUN _IO('U', 1)
#define USB_RAW_IOCTL_EVENT_FETCH _IOR('U', 2, struct usb_raw_event)
#define USB_RAW_IOCTL_EP0_WRITE _IOW('U', 3, struct usb_raw_ep_io)
#define USB_RAW_IOCTL_EP0_READ _IOWR('U', 4, struct usb_raw_ep_io)
#define USB_RAW_IOCTL_EP_ENABLE _IOW('U', 5, struct usb_endpoint_descriptor)
#define USB_RAW_IOCTL_EP_WRITE _IOW('U', 7, struct usb_raw_ep_io)
#define USB_RAW_IOCTL_CONFIGURE _IO('U', 9)
#define USB_RAW_IOCTL_VBUS_DRAW _IOW('U', 10, __u32)
#define USB_RAW_IOCTL_EPS_INFO _IOR('U', 11, struct usb_raw_eps_info)
#define USB_RAW_IOCTL_EP0_STALL _IO('U', 12)
/* ---- captured-from-hardware report descriptors ---- */
static const __u8 RDESC_MOUSE[] = {
0x05,0x01,0x09,0x02,0xa1,0x01,0x09,0x01,0xa1,0x00,0x05,0x09,0x19,0x01,0x29,0x02,
0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x02,0x81,0x02,0x75,0x06,0x95,0x01,0x81,0x01,
0x05,0x01,0x09,0x30,0x09,0x31,0x15,0x81,0x25,0x7f,0x75,0x08,0x95,0x02,0x81,0x06,
0x95,0x01,0x09,0x38,0x81,0x06,0x05,0x0c,0x0a,0x38,0x02,0x95,0x01,0x81,0x06,0xc0,0xc0 };
static const __u8 RDESC_KBD[] = {
0x05,0x01,0x09,0x06,0xa1,0x01,0x05,0x07,0x19,0xe0,0x29,0xe7,0x15,0x00,0x25,0x01,
0x75,0x01,0x95,0x08,0x81,0x02,0x81,0x01,0x19,0x00,0x29,0x65,0x15,0x00,0x25,0x65,
0x75,0x08,0x95,0x06,0x81,0x00,0xc0 };
static const __u8 RDESC_CTRL[] = { // the real Deck controller, interface 2
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 (one per interface, points at the report descriptor length) ---- */
struct hid_desc { __u8 bLength,bDescriptorType; __u16 bcdHID; __u8 bCountryCode,bNumDescriptors,bReportType; __u16 wReportLength; } __attribute__((packed));
/* Exact 7-byte endpoint descriptor — `struct usb_endpoint_descriptor` is 9 bytes (audio bRefresh/
bSynchAddress), which would inject 2 garbage bytes per endpoint into the wire config + mis-parse. */
struct ep_desc7 { __u8 bLength,bDescriptorType,bEndpointAddress,bmAttributes; __u16 wMaxPacketSize; __u8 bInterval; } __attribute__((packed));
/* ---- full config descriptor, assembled to mirror the real Deck (3 HID interfaces) ---- */
struct config_blob {
struct usb_config_descriptor config;
struct usb_interface_descriptor i0; struct hid_desc h0; struct ep_desc7 e0;
struct usb_interface_descriptor i1; struct hid_desc h1; struct ep_desc7 e1;
struct usb_interface_descriptor i2; struct hid_desc h2; struct ep_desc7 e2;
} __attribute__((packed));
/* Full 9-byte endpoint descriptors, used only for the EP_ENABLE ioctl. */
static struct usb_endpoint_descriptor epfull0, epfull1, epfull2;
static struct usb_device_descriptor dev_desc = {
.bLength = USB_DT_DEVICE_SIZE, .bDescriptorType = USB_DT_DEVICE, .bcdUSB = 0x0200,
.bDeviceClass = 0, .bDeviceSubClass = 0, .bDeviceProtocol = 0, .bMaxPacketSize0 = 64,
.idVendor = 0x28de, .idProduct = 0x1205, .bcdDevice = 0x0300,
.iManufacturer = 1, .iProduct = 2, .iSerialNumber = 3, .bNumConfigurations = 1 };
#define HID_DT 0x21
#define HID_RPT_DT 0x22
static struct config_blob cfg;
static void build_config(void) {
memset(&cfg, 0, sizeof(cfg));
cfg.config = (struct usb_config_descriptor){ .bLength = USB_DT_CONFIG_SIZE, .bDescriptorType = USB_DT_CONFIG,
.wTotalLength = sizeof(cfg), .bNumInterfaces = 3, .bConfigurationValue = 1, .iConfiguration = 0,
.bmAttributes = 0x80, .bMaxPower = 250 };
// iface 0: mouse (subclass 0, protocol 2), EP 0x81 IN 8
cfg.i0 = (struct usb_interface_descriptor){ .bLength = USB_DT_INTERFACE_SIZE, .bDescriptorType = USB_DT_INTERFACE,
.bInterfaceNumber = 0, .bNumEndpoints = 1, .bInterfaceClass = 3, .bInterfaceSubClass = 0, .bInterfaceProtocol = 2 };
cfg.h0 = (struct hid_desc){ sizeof(struct hid_desc), HID_DT, 0x0110, 0, 1, HID_RPT_DT, sizeof(RDESC_MOUSE) };
cfg.e0 = (struct ep_desc7){ .bLength = USB_DT_ENDPOINT_SIZE, .bDescriptorType = USB_DT_ENDPOINT,
.bEndpointAddress = 0x81, .bmAttributes = USB_ENDPOINT_XFER_INT, .wMaxPacketSize = 8, .bInterval = 4 };
// iface 1: keyboard (subclass 1 boot, protocol 1), EP 0x82 IN 8
cfg.i1 = (struct usb_interface_descriptor){ .bLength = USB_DT_INTERFACE_SIZE, .bDescriptorType = USB_DT_INTERFACE,
.bInterfaceNumber = 1, .bNumEndpoints = 1, .bInterfaceClass = 3, .bInterfaceSubClass = 1, .bInterfaceProtocol = 1 };
cfg.h1 = (struct hid_desc){ sizeof(struct hid_desc), HID_DT, 0x0110, 0, 1, HID_RPT_DT, sizeof(RDESC_KBD) };
cfg.e1 = (struct ep_desc7){ .bLength = USB_DT_ENDPOINT_SIZE, .bDescriptorType = USB_DT_ENDPOINT,
.bEndpointAddress = 0x82, .bmAttributes = USB_ENDPOINT_XFER_INT, .wMaxPacketSize = 8, .bInterval = 4 };
// iface 2: the controller (subclass 0, protocol 0), EP 0x83 IN 64
cfg.i2 = (struct usb_interface_descriptor){ .bLength = USB_DT_INTERFACE_SIZE, .bDescriptorType = USB_DT_INTERFACE,
.bInterfaceNumber = 2, .bNumEndpoints = 1, .bInterfaceClass = 3, .bInterfaceSubClass = 0, .bInterfaceProtocol = 0 };
cfg.h2 = (struct hid_desc){ sizeof(struct hid_desc), HID_DT, 0x0110, 33, 1, HID_RPT_DT, sizeof(RDESC_CTRL) };
cfg.e2 = (struct ep_desc7){ .bLength = USB_DT_ENDPOINT_SIZE, .bDescriptorType = USB_DT_ENDPOINT,
.bEndpointAddress = 0x83, .bmAttributes = USB_ENDPOINT_XFER_INT, .wMaxPacketSize = 64, .bInterval = 4 };
// Full 9-byte endpoint descriptors for EP_ENABLE (the ioctl wants struct usb_endpoint_descriptor).
#define MKFULL(F,E) do{ memset(&F,0,sizeof F); F.bLength=USB_DT_ENDPOINT_SIZE; F.bDescriptorType=USB_DT_ENDPOINT; \
F.bEndpointAddress=E.bEndpointAddress; F.bmAttributes=E.bmAttributes; F.wMaxPacketSize=E.wMaxPacketSize; F.bInterval=E.bInterval; }while(0)
MKFULL(epfull0, cfg.e0); MKFULL(epfull1, cfg.e1); MKFULL(epfull2, cfg.e2);
}
static int fd = -1;
static int ctrl_ep = -1; // raw handle for the controller IN endpoint
static volatile int running = 1;
static volatile int configured = 0;
static int do_stream = 1; // argv: "nostream" disables the input streamer
static int dbg = 1;
static __u8 last_feature_cmd = 0; // last SET_REPORT command on iface 2
static void log_line(const char *s){ fprintf(stderr, "%s\n", s); }
static int ep0_write(const void *data, int len){
char buf[sizeof(struct usb_raw_ep_io)+4096]; struct usb_raw_ep_io *io=(void*)buf;
io->ep=0; io->flags=0; io->length=len; if(len) memcpy(io->data,data,len);
int r=ioctl(fd, USB_RAW_IOCTL_EP0_WRITE, io);
if(r<0){ char m[80]; snprintf(m,sizeof m," !! ep0_write(len=%d) errno=%d", len, errno); log_line(m); }
return r;
}
static int ep0_read(void *data, int len){
char buf[sizeof(struct usb_raw_ep_io)+4096]; struct usb_raw_ep_io *io=(void*)buf;
io->ep=0; io->flags=0; io->length=len;
int r=ioctl(fd, USB_RAW_IOCTL_EP0_READ, io); if(r>=0 && data) memcpy(data, io->data, r<len?r:len); return r;
}
static void ep0_stall(void){ ioctl(fd, USB_RAW_IOCTL_EP0_STALL); }
// Complete a no-data OUT control transfer: the status stage is an IN handled by a zero-length READ.
static void ep0_ack(void){ ep0_read(NULL,0); }
// String descriptors.
static int build_string(int idx, __u8 *out){
if(idx==0){ out[0]=4; out[1]=USB_DT_STRING; out[2]=0x09; out[3]=0x04; return 4; }
const char *s = idx==1?"Valve Software":idx==2?"Steam Deck Controller":idx==3?"PFDECK0001":"";
int n=strlen(s); out[0]=2+n*2; out[1]=USB_DT_STRING; for(int i=0;i<n;i++){ out[2+i*2]=s[i]; out[3+i*2]=0; } return 2+n*2;
}
static void enable_endpoints(void){
// Enable the 3 interrupt-IN endpoints; remember the controller's handle for streaming.
int e0=errno; int h0=ioctl(fd, USB_RAW_IOCTL_EP_ENABLE, &epfull0); e0=errno;
int h1=ioctl(fd, USB_RAW_IOCTL_EP_ENABLE, &epfull1); int e1=errno;
int h2=ioctl(fd, USB_RAW_IOCTL_EP_ENABLE, &epfull2); int e2=errno;
ctrl_ep = h2;
char m[128]; snprintf(m,sizeof m,"endpoints enabled: mouse=%d(e%d) kbd=%d(e%d) ctrl=%d(e%d)", h0,h0<0?e0:0,h1,h1<0?e1:0,h2,h2<0?e2:0); log_line(m);
}
static void handle_control(struct usb_ctrlrequest *ctrl){
int idx = ctrl->wIndex & 0xff;
if(dbg){ char m[128]; snprintf(m,sizeof m," CTRL bRT=0x%02x bR=0x%02x wV=0x%04x wI=0x%04x wL=%u",
ctrl->bRequestType, ctrl->bRequest, ctrl->wValue, ctrl->wIndex, ctrl->wLength); log_line(m); }
// Standard requests
if((ctrl->bRequestType & USB_TYPE_MASK) == USB_TYPE_STANDARD){
switch(ctrl->bRequest){
case USB_REQ_GET_DESCRIPTOR: {
int type = ctrl->wValue >> 8, di = ctrl->wValue & 0xff;
if(type==USB_DT_DEVICE){ ep0_write(&dev_desc, dev_desc.bLength); return; }
if(type==USB_DT_CONFIG){ int l=sizeof(cfg); if(l>ctrl->wLength) l=ctrl->wLength; ep0_write(&cfg, l); return; }
if(type==USB_DT_STRING){ __u8 s[260]; int l=build_string(di,s); if(l>ctrl->wLength) l=ctrl->wLength; ep0_write(s,l); return; }
if(type==HID_RPT_DT){ // HID report descriptor for the interface in wIndex
const __u8 *r; int l;
if(idx==0){ r=RDESC_MOUSE; l=sizeof(RDESC_MOUSE);} else if(idx==1){ r=RDESC_KBD; l=sizeof(RDESC_KBD);} else { r=RDESC_CTRL; l=sizeof(RDESC_CTRL);}
if(l>ctrl->wLength) l=ctrl->wLength; ep0_write(r,l); return;
}
if(type==HID_DT){ struct hid_desc *h = idx==0?&cfg.h0:idx==1?&cfg.h1:&cfg.h2; ep0_write(h,h->bLength); return; }
ep0_stall(); return;
}
case USB_REQ_SET_CONFIGURATION: {
__u32 power = 0x32; ioctl(fd, USB_RAW_IOCTL_VBUS_DRAW, power);
ioctl(fd, USB_RAW_IOCTL_CONFIGURE);
enable_endpoints();
ep0_ack(); // OUT/no-data: complete via a zero-length read
configured = 1; log_line(" SET_CONFIG: done");
return;
}
case USB_REQ_SET_INTERFACE: ep0_ack(); return;
case USB_REQ_GET_STATUS: { __u16 s=0; ep0_write(&s,2); return; }
default: ep0_stall(); return;
}
}
// HID class requests
if((ctrl->bRequestType & USB_TYPE_MASK) == USB_TYPE_CLASS){
switch(ctrl->bRequest){
case 0x01: { // GET_REPORT
// Reply the serial-style feature blob for the controller (iface 2); harmless for others.
__u8 rep[64]; memset(rep,0,sizeof rep);
// Reply [cmd, len, 0x01, serial...] echoing the last requested command (serial = 0xAE).
const char *serial = "PFDECK0001";
rep[0]=last_feature_cmd?last_feature_cmd:0xAE; rep[1]=strlen(serial); rep[2]=0x01;
memcpy(rep+3, serial, strlen(serial));
int l=ctrl->wLength>64?64:ctrl->wLength; ep0_write(rep,l); return;
}
case 0x09: { // SET_REPORT — read the host's data, remember the command byte
__u8 buf[64]; int r=ep0_read(buf,ctrl->wLength>64?64:ctrl->wLength);
if(r>0) last_feature_cmd = buf[0]; // unnumbered report: data[0] is the command
return; // ep0_read consumes the data stage + acks
}
case 0x0a: ep0_ack(); return; // SET_IDLE (OUT/no-data)
case 0x0b: ep0_ack(); return; // SET_PROTOCOL (OUT/no-data)
case 0x03: { __u8 z=0; ep0_write(&z,1); return; } // GET_PROTOCOL
default: ep0_stall(); return;
}
}
ep0_stall();
}
static void *stream_thread(void *arg){
(void)arg; __u8 rep[64]; __u32 seq=0;
while(running){
if(configured && ctrl_ep>=0){
memset(rep,0,sizeof rep);
rep[0]=0x01; rep[1]=0x00; rep[2]=0x09; rep[3]=0x3c; memcpy(rep+4,&seq,4); seq++;
char buf[sizeof(struct usb_raw_ep_io)+64]; struct usb_raw_ep_io *io=(void*)buf;
io->ep=ctrl_ep; io->flags=0; io->length=64; memcpy(io->data,rep,64);
ioctl(fd, USB_RAW_IOCTL_EP_WRITE, io); // blocks until the host polls the int IN ep
}
struct timespec ts={0, 8*1000*1000}; nanosleep(&ts,NULL);
}
return NULL;
}
int main(int argc, char **argv){
int seconds = argc>1?atoi(argv[1]):120;
for(int i=1;i<argc;i++){ if(!strcmp(argv[i],"nostream")) do_stream=0; }
build_config();
fd = open("/dev/raw-gadget", O_RDWR);
if(fd<0){ perror("open /dev/raw-gadget"); return 1; }
struct usb_raw_init init; memset(&init,0,sizeof init);
strcpy((char*)init.driver_name, "dummy_udc");
strcpy((char*)init.device_name, "dummy_udc.0");
init.speed = USB_SPEED_HIGH;
if(ioctl(fd, USB_RAW_IOCTL_INIT, &init)){ perror("INIT"); return 1; }
if(ioctl(fd, USB_RAW_IOCTL_RUN)){ perror("RUN"); return 1; }
log_line("raw_gadget Deck running (28DE:1205, controller on interface 2)");
pthread_t th; if(do_stream) pthread_create(&th,NULL,stream_thread,NULL);
struct timespec start; clock_gettime(CLOCK_MONOTONIC,&start);
char ebuf[sizeof(struct usb_raw_event)+256];
struct usb_raw_event *ev=(void*)ebuf;
while(running){
struct timespec n; clock_gettime(CLOCK_MONOTONIC,&n);
if(n.tv_sec-start.tv_sec>=seconds) break;
ev->type=0; ev->length=sizeof(struct usb_ctrlrequest);
if(ioctl(fd, USB_RAW_IOCTL_EVENT_FETCH, ev)<0){ if(running) perror("EVENT_FETCH"); break; }
if(ev->type==USB_RAW_EVENT_CONNECT){ log_line("CONNECT"); }
else if(ev->type==USB_RAW_EVENT_CONTROL){ handle_control((struct usb_ctrlrequest*)ev->data); }
}
running=0; if(do_stream) pthread_join(th,NULL);
log_line("exiting");
return 0;
}

Some files were not shown because too many files have changed in this diff Show More