17 KiB
Steam's touch controller in a DroidDeck session
Research notes, started 2026-10-07. The question: instead of DroidDeck drawing its own on-screen pad and keeping its own profiles, can the session use the touch controller Steam already has for the Steam Link app, so that touch layouts are per game, synced to the Steam account and shared through Steam's community configs?
This page records what the Steam client contains, the candidate approaches, and the experiments run on the Thor to decide between them. Findings are appended as they come in; see Experiment log.
How Steam Link's touch controller works
Sources: the arm64 client DroidDeck installs (steam_client_steamdeck_publicbeta_linuxarm64,
manifest version 1791249696), the protobuf definitions in
SteamDatabase/Protobufs
(steam/steammessages_remoteplay.proto, steam/steammessages_virtualcontroller.proto) and Valve's
touch controller guide.
The work is split between the streaming client (what runs on the phone) and the Steam client being streamed from (the host):
- Client: drawing and editing.
streaming_clientholdsCVirtualController, built fromstreaming/client/virtualcontroller.cpp: it draws the controls over the video, runs the layout editor (tray, colour picker, move/scale), and sends raw touches to the host asCInputTouchFingerDownMsg/Motion/Up(finger id, x and y normalised to the video). - Host: the controller.
steamui.soholds the stream server (CStreamServer,IStreamServerInputDelegate).steamclient.soholdsCMobileTouchControllerAbstraction, a Steam Input controller backend next toCSteamOSHandheldAbstraction(the Deck),CSteamControllerAbstractionandCGenericGamepadControllerAbstraction. Its display name is "Steam Link Touch Controller" and its config type iscontroller_mobile_touch. - Host: the config. The host owns the touch config and pushes it to the client:
k_EStreamControlTouchConfigActive(appid, revision, creator),GetTouchConfigData/SetTouchConfigData(config data and layout blobs),SaveTouchConfigLayout(the client sends back an edited layout),Get/SetTouchIconData,TouchActionSetActiveandTouchActionSetLayerAdded/Removed. Touch configs are Steam Input configs like any other: per game, saved to the account, publishable. SteamInputDB counted 10,540 community configs for Mobile Touch in March 2026.
Every piece of this is in the arm64 package DroidDeck already installs: the host side in
steamrtarm64/steamclient.so and steamui.so, and the full renderer and editor in
steamrtarm64/streaming_client. The package also carries an androidarm64/libsteamclient.so
with the same touch strings.
Where DroidDeck is today
The session's pad is presented to the Steam client as a Steam Deck controller (28de:1205 through a
served hidraw node and a written sysfs, see SteamDeckPad.kt and fakeinput_steam.cpp), so Deck
configs, per-game bindings and Deck community configs already apply. What Steam's touch controller
would add: control placement, icons, touch menus and on-device layout editing defined by Steam, and
the touch-specific community configs.
Candidate approaches
| How | For | Against | |
|---|---|---|---|
| A. Local Remote Play | DroidDeck streams from its own Steam client the way the Steam Link app does (pairing, start stream, touch input negotiated), using Valve's streaming_client or a minimal client of our own. |
Valve's controller, editor, sync and sharing unchanged. Leans on a protocol Valve keeps compatible with shipped Steam Link apps. | Host captures and encodes video nobody needs; the client may refuse to stream to itself. |
| B. In-process injection | Feed touches straight into the path between the stream server (steamui.so) and CMobileTouchControllerAbstraction (steamclient.so), with no stream. |
No video cost. | Reverse engineering stripped arm64 binaries; breaks when Steam updates; layout drawing is still ours. |
| C. Data only | Read Steam's touch configs and layouts, draw them ourselves, keep feeding the Deck pad. | Simple. | Touch configs bind straight to keys, mouse and actions; honouring them means reimplementing Steam Input. |
Experiments
Run in order; each one either rules an approach out or narrows the next.
- Host side alive? Does the arm64 client in a DroidDeck session run the stream server, answer discovery on the network and accept a Steam Link pairing?
- Touch controller instantiated? Stream to the official Steam Link app (same phone or another device) and check that the touch controller appears, takes a game config and drives a game.
- Loopback. Can a client on the same device (127.0.0.1 / the device's own address) stream from the session's Steam, and what does it cost (capture, encode)?
- Input without video. Does input keep flowing with the stream paused or captured tiny
(
k_EStreamControlPause,SetCaptureSize), or with a client that never decodes video? - Valve's renderer as an overlay. Can
streaming_clientitself run inside the session as an overlay over the game, rather than the phone app? - Injection points. How the stream server hands touches to
steamclient.so, to size approach B.
Experiment log
1. Host side alive: yes (2026-10-07, Thor, Steam 1791249696)
- With a Steam session READY, the client listens on TCP 27036 and UDP 27036 (
/proc/net, app uid). A discovery probe built fromsteammessages_remoteclient_discovery.protosent to127.0.0.1:27036from the device gets aCMsgRemoteClientBroadcastStatusback: hostnameDroidDeck,enabled_services98 (game streaming on), the signed-in user,steam_deckunset,gaming_device_type541. - The official Steam Link app (Play Store, on the Thor's bottom screen while the session runs on the top one) finds "DroidDeck" by itself, shows a PIN, and the session's Big Picture shows Authorize Device. After the PIN the app runs its network test and lists DroidDeck as paired. Pairing to the session's own client over loopback works with no changes.
- Launching Steam Link on display 0 instead puts DroidDeck's session activity into PiP; use
am start --display 4. On first start Steam Link sits on its splash until BLUETOOTH_CONNECT is answered, and the permission dialog does not render on the second screen (granted withpm grant).
2. Touch controller over the stream: works end to end
Starting a stream (streaming_log.txt, console_log.txt in the session's Steam logs/):
CLIENT: Sending HID device 2020/0112/-1 Xbox Wireless Controller at sdl://3
CLIENT: Sending HID device 0000/11fb/-1 Mobile Touch Control at touch://0
...
Remote Device Found type: 0000 11fb path: touch://0 Product: Mobile Touch Control
!! Steam controller device opened for index 1.
Controller device closed after hid_read failure
Controller 1 disconnected
- The touch controller reaches the host as a remote HID device, 0000:11fb, opened by the same controller code that opens the Deck pad, and turned into a virtual controller. The streaming client is what makes the reports; Steam on the host only sees a HID device.
- Video fails on the host:
PipeWire: Could not connect PipeWire context(the session runs no PipeWire), so the app gets audio but a black screen and keeps its loading spinner. - The app then re-sends only the Thor's built-in pad and drops the touch device; it seems to withdraw touch controls when a physical controller is present, or until video arrives.
With video fixed (below), Steam's touch controller runs against the session's own client:
- Steam Link withholds its touch controls while a physical pad is attached (the Thor's own pad
shows up as "Xbox Wireless Controller"). A four-finger tap opens its stream menu, which has
Enable Touch Controls (injected with
sendeventon/dev/input/event5, the bottom panel). - The client then re-sends
touch://0; the host opens it as controller type 43, product 0x11fb, serialMT-<Steam Link device id>, capabilities0000007f83045bff, creates a virtual controller for it, and sends the clientTouchConfigActive,TouchActionSetActiveandSetTouchConfigData. The app draws Big Picture's touch layout (d-pad, ABXY, Steam button, keyboard,...). - Touching the controls drives the session: the touch Steam button opens Big Picture's main
menu on the Thor's top screen, touch B closes it, and the session's glyphs switch to the touch
controller's. (D-pad taps from
input tapdid not register; not chased.) - Cost: the host encodes with libx264 in software (
/dev/video-enc0missing), 4 threads, 1240x698. CPU sat at 72-85 % in the overlay andk_EStreamControlVideoOverflowflooded the log; the app's picture froze within a minute while input kept working. Video loopback is not a shippable path; only the input half of the stream is useful.
Video fix used for this test only: PipeWire and WirePlumber are in the rootfs and gamescope is
built with PipeWire, so a gamescope wrapper on PATH (via Download/droiddeck-env) that starts
pipewire and wireplumber first gives gamescope a capture node (stream available on node ID)
and the client a picture. (Editing droiddeck-session on the device does not stick: the app
re-syncs the overlay each session.)
Where Steam keeps touch configs
In the session's Steam, steamapps/common/Steam Controller Configs/<account id>/config/:
<appid>/controller_mobile_touch.vdffor each game with a saved touch config (dozens already there for this account, synced down from Steam Cloud), plus named copies like<appid>/default touch_0.vdf;configset_controller_mobile_touch.vdf: which config each app uses (autosave= the per-app file above, otherwise a template or workshop id);configset_MT-<serial>.vdf,preferences_MT-<serial>.vdfandconfig/MT-<serial>_gyro.vdf: per touch device.
A touch config is an ordinary controller_mappings VDF (controller_type controller_mobile_touch,
bindings per action set and layer) with one extra key, touch_layout: the on-screen layout as
a hex-encoded CVirtualControllerLayouts protobuf: one layout per action set, each a list of
elements (EControllerElementType, visible, x/y position normalised to the screen, x/y scale)
and a colour, plus input mode, mouse mode and pinch-zoom settings. Bindings and layout travel
together in the one file that syncs and is shared as a community config.
The touch device's HID protocol
From Steam Link for Android (libmain.so, exported symbols; CVirtualController is both the
renderer and the HID device behind touch://0):
- Input report: 40 bytes (
CVirtualController::Read), kept atthis+200and marked dirty atthis+240by every setter. Bytes 0-7: button/touch bits (e.g. bit 27, 19, 20: trackpads 0, 1, 2 touched). Bytes 16, 20, 24: trackpads 0-2 as int16 x (centred,x*65535 ^ 0x8000) and y (inverted). Remaining fields (sticks, triggers, gyro) not yet mapped.CHIDDeviceReportGenerator::BInjectGamepadStateMobileTouchis a stub; the report is the controller's own format, not the generic SDL gamepad state. - Output reports from Steam (
Write→QueueFeatureReport→HandleFeatureReports), at least 17 bytes, first byte the type:- 1: rumble (u16 low, u16 high, u32 duration);
- 3: setting
0x30with a u16 value (a flag atthis+1488, likely gyro); - 4: action set change (u32 appid, u32 action set id): switches the layout shown;
- 5 / 6: action set layer added / removed (u32 appid, u32 layer id).
- Feature report 2: battery (level, from
SDL_GetPowerInfo), 17 bytes.
So Steam drives action sets, layers and rumble over HID. The only things that come solely over
the stream are the config and layout blobs (SetTouchConfigData), the active config's
appid/revision/creator (TouchConfigActive) and custom icons (SetTouchIconData); the first two
are on disk in the VDF above.
3. Without a stream: Steam takes a local 0000:11fb device (works)
Experimental build on this branch: FAKE_EVDEV_TOUCHCTL=1 (in Download/droiddeck-env) makes
libfakeinput serve /dev/hidraw17 as 0000:11fb "Mobile Touch Control" next to the Deck pad, with
its sysfs written by SteamDeckPad.prepare (a second USB device, usb2). The 40-byte input report
is read from /tmp/touchctl.report in the session; every output and feature report the client
sends is logged to pad.log and appended to /tmp/touchctl.out.
-
With a vendor-page report descriptor (
06 ff ff 09 01, as the Deck's) the client walks the whole sysfs tree, reads the descriptor and never opens the node. With Generic Desktop / Game Pad (05 01 09 05) it opens it: for a non-Valve vendor id the client wants a gamepad usage. -
Steam then builds the touch controller exactly as it does for Steam Link:
Local Device Found type: 0000 11fb path: /dev/hidraw17 serial_number: MT-DROIDDECK0001 !! Controller 0 attributes: Type: 43 ProductID: 4603 Serial: MT-MT-DROIDDECK0001 Capabilities: 0000007f83045bffand speaks the touch protocol to it:
get-feature 02(battery), output03 30 ..(the setting report), and output04 01 03 00 00 01 00 00 00: action set 1 of app 769 (Big Picture). -
Writing button bits into the report drives the client: Steam (
0x2000) opens Big Picture's main menu, B (0x20) closes it. Opening the menu made Steam send04with app 443510 (the Steam menu's own touch config; it is one of the app folders in the config directory) and closing it04with 769 / 1 again: the active app and action set arrive over HID, live.
Button bits (Steam Link's CVirtualController table at 0x463e70, indexed by
EControllerElementType - 1), the same layout as the Deck report's low word:
| Element | Bit | Element | Bit | |
|---|---|---|---|---|
| A | 7 | Select | 12 | |
| B | 5 | Steam | 13 | |
| X | 6 | Start | 14 | |
| Y | 4 | Left stick click | 22 | |
| Left bumper | 3 | Right stick click | 26 | |
| Right bumper | 2 | Macro 0-7 | 32-39 | |
| Left trigger | 1 | 1-finger / 2-finger macro | 48, 49 | |
| Right trigger | 0 | Trackpad 0 / 1 / 2 touched | 27 / 19 / 20 |
Report layout, all little-endian (Steam Link CVirtualController: TouchControl_JoyButton,
TouchControl_JoyStick, SetTrackpad, UpdateControllerState):
| Bytes | Field |
|---|---|
| 0-7 | u64 buttons and touch flags (table above; d-pad as the Deck's: up 8, right 9, left 10, down 11; left / right stick touched 46 / 47) |
| 8-15 | int16 left X, left Y, right X, right Y: x * 32767, Y stored inverted (~(y * 32767)) |
| 16-27 | three trackpads, int16 x (x * 65535 ^ 0x8000) and y (0x7fff - y * 65535) |
| 28-33 | accelerometer xyz, int16, ±2 g (19.61 m/s²) to ±32767 |
| 34-39 | gyro xyz, int16 |
Games, through Steam Input, with no stream. With the session started on Geometry Wars:
Retro Evolved (8400) and the game launched by pressing A twice on the local device, the client
loaded controller_base/templates/controller_mobile_touch_gamepad_joystick.vdf for app 8400 on the
touch controller (Valve's stock touch template), told the device 04 d0 20 00 00 ... (app 8400),
and a left-stick report flew the ship across the screen. Steam's own path end to end:
DroidDeck → /dev/hidraw17 → Mobile Touch controller → the game's touch config → Steam Input's
virtual Xbox pad → the game. D-pad bits navigated the Steam menu the same way.
What the stream gives that the local device does not:
- The active config and its layout. Steam Link asks for it (
GetTouchConfigData) and the host answers with the config and the layout. Locally the device is told only the app id and action set (report 4), so the overlay has to resolve the config itself:configset_controller_mobile_touch.vdfper app (autosave=<appid>/controller_mobile_touch.vdf, otherwise a template or workshop config), and the console log lineLoaded Config for ... App ID <n>, Controller <i>: <path>names the file Steam actually loaded. Stock templates carry notouch_layout; Steam Link draws its built-in default layout for them (CVirtualController::ResetLayoutToDefault), hiding elements the config leaves unbound (UpdateElementAvailability). - Saving an edited layout. Steam Link sends
SaveTouchConfigLayout; the host writes it into the app's autosave config, which is what syncs. The local device has no such message. Big Picture'sSteamClient.InputAPI (seen insteamui/sp.js) has the config editing calls (StartEditingControllerConfigurationForAppIDAndControllerIndex,SetEditingControllerConfigurationMiscSetting,SaveEditingControllerConfiguration,GetConfigForAppAndController,QueryControllerConfigsForApp,SetSelectedConfigForApp,GetTouchMenuIconsForApp,RegisterForTouchMenuMessages) but no layout call; whethertouch_layoutgoes through the misc-setting call is untested. The session's devtools port only admits guest processes (BL_CDP_GUARD), so this needs the agent bridge's guest commands rather than a probe from adb. - Custom icons (
SetTouchIconData).
Everything else (bindings, action sets, layers, touch menus, rumble, the configurator, community configs, cloud sync) is Steam's own and works with the local device.