Project plan for a Steam Input-like controller mapping tool.
Name. deckhand - the product and Cargo-workspace name (the working directory
can be renamed to match later). Internal crates keep descriptive names (steam-hid,
virt-out, vocab-hid, vocab-out, config, mapper, engine, ipc, ...). "Steam"
is deliberately avoided in the product name (Valve trademark).
What it is. An implementation of something akin to Steam Input: it reads input from Steam controllers and maps that input to mouse, keyboard, and gamepad output.
Supported input devices.
- Steam Controller (original)
- Steam Controller (new)
- Steam Deck (built-in controls)
Output targets.
- Mouse
- Keyboard
- Gamepad (virtual controller)
Language. Rust.
Toolchain. Rust edition 2024; track latest stable, no MSRV policy (fits the break-freely stance - no stale-compiler baggage).
License. Dual MIT OR Apache-2.0 (the Rust-ecosystem default). We take only
protocol facts from the GPL hid-steam kernel driver and the C# reference - our code
and API are our own - so we're free to choose.
Multiplatform. Targets multiple operating systems (Linux, Windows, macOS). The working folder is synced by the user across several machines during development.
Dependencies. Only well-maintained, widely-used crates. No small, untested, or
unproven dependencies. A crate is acceptable when it is popular and actively
maintained (e.g. the HID access crate hidapi, which is the de-facto cross-platform
choice) and rejected otherwise, even if convenient.
API design. Always prioritize the cleanest, best API design for what we need now.
The public API is not stable and does not need to be (probably ever) - refactor or
break it freely whenever that yields a better design. Never carry technical debt to
preserve a stable API. Forward-extensibility tools (#[non_exhaustive], etc.) are
fine when they genuinely help, but never as a crutch to avoid improving the design.
(One future exception, deferred: the on-disk config format becomes a versioned,
non-breaking user contract at stable release - see 3.)
Cross-cutting conventions. Logging/diagnostics via the log facade (with env_logger
in the daemon). Errors via thiserror in the HAL/engine library crates; binaries surface
those. Pin shared crate versions in [workspace.dependencies].
Structure. A Rust workspace of twelve crates in a ports-and-adapters shape, layered so the pure mapping core links no hardware. Bottom to top (the numbered ones keep their own section below):
vocab-hid/vocab-out- the leaf vocabularies (deps: serde, plusbitflagsforvocab-hid).vocab-hid= controller input vocabulary;vocab-out= output vocabulary. Each is splitcore(the namesconfigneeds) +state/event(the snapshot / emit types the mapper adds):vocab-hid=Button|Buttons/Axis/ControllerState;vocab-out=Key/MouseButton/GamepadButton/GamepadAxis|OutputEvent.steam-hid(1) - input HAL: decode Steam-controller HID into avocab-hid::ControllerState. The only crate that speakshidapi.virt-out(2) - output HAL: realize avocab-out::OutputEventas a virtual mouse/ keyboard/gamepad per OS.config(3) - the authoring model: on-disk RON, validation, the names of inputs/ outputs. Depends on both vocab crates, neither HAL.mapper- the pure mapping core: evaluates a compiledProgram(its own IR) over avocab-hidframe intovocab-outevents. Depends only on the vocab crates + config; no HAL, no OS - golden-testable with an injected clock. (ConfigDoc -> Programcompilation lives inengine, the runtime's contract.)engine(4) - the headless runtime / manager: owns and drives the real devices (steam-hid) and the output sink (virt-out), compilesconfiginto amapper::Program, and runs the mapper on threads (a reader per device + a central mapping loop). Exposes an in-process API; also hosts the 6 network seam. Not a pure core - that is themappercrate.ipc- the daemon control protocol (Request/Response/Eventover a local socket).deckhandd/deckhandctl- the daemon (embedsengine, serves its API overipc) and the thin CLI client.deckhand(5, iced) - the GUI: a tray configurator + profile editor, and anipcclient.forwarder-ui- a minimal touch-firstipcclient for the Steam Deck forwarder case.
Build order: vocab-out/vocab-hid -> steam-hid/virt-out/config -> mapper -> engine
-> ipc -> deckhandd/deckhandctl/deckhand/forwarder-ui.
The networked / remote-controller capability (6 - e.g. a Steam Deck driving a gaming PC)
reuses these same crates: the network is another adapter around the engine (raw ControllerState
on the wire, the mapper on the sink), an extension, not a rewrite.
One organizing idea: the mapper is pure. It reads one vocabulary and writes another, and depends on nothing else - not a HAL, not the OS, not the engine. Everything hardware- or process-shaped is layered around it.
The core - read input vocab, write output vocab:
vocab-hid --> mapper (+ Program) --> vocab-out
(input vocab) the pure core: (output vocab) no HAL, no OS, injected clock
The mapping path - real hardware to virtual hardware:
hardware --> steam-hid --> vocab-hid --> mapper --> vocab-out --> virt-out --> virtual hardware
(Steam input HAL input pure output output (Xbox pad,
controller) (hidapi) vocab core vocab HAL kbd, mouse)
^
|
Program (config compiled to the mapper's IR)
The runtime path - engine composes the above into a running process:
config --engine::compile()--> Program ----.
v
steam-hid <--reader threads-- [ engine ] --mapping loop, drives mapper--> virt-out
engine owns the threads (one reader per device feeding a central loop over channels), compiles the
ConfigDoc it receives through its API into a mapper::Program, and feeds the mapper. It is headless
- no UI, no notion of a desktop or running apps.
The process path - daemon + clients over ipc:
deckhandctl deckhand (UI) forwarder-ui <- three ipc clients
\ | /
`-----------> ipc <-------------' <- control protocol (its own crate)
|
deckhandd <- the daemon: embeds `engine`, exposes its API over `ipc`
|
engine
The UI also edits configs directly (not through the daemon): it uses the config crate to
build/validate a ConfigDoc, and vocab-hid::core + vocab-out::core to present the input/output
names a binding can choose. The compiled config is then shipped to the daemon over ipc.
Note on the rest of this PLAN. Sections 1-6 were written as each piece was designed, and several
crates above (vocab-hid/vocab-out, mapper, ipc, the split of the UI into deckhand /
forwarder-ui) did not exist when their surrounding sections were first drafted - the mapper and its
IR, for example, are described under section 4 (ENGINE) because they lived there originally. So the
per-section detail is not a complete per-crate reference; this 0.1 is the current map.
A standalone library crate that (a) discovers and opens Steam input devices, (b)
reads their full input state (digital, analog, gyro/IMU), and (c) sends commands to
them (lizard mode on/off, gyro on/off, haptics/rumble, LED, idle timeout, power off,
plus a raw escape hatch). It is the input device layer only - no mapping (that is
engine, 4), no virtual output (that is virt-out, 2). Built to be embedded cleanly
by the rest of the workspace (consumed by engine; its types feed the mapping core).
Initial work is based on the C# Steam HID API implementation in the sibling folder
../steam-hidapi.net. We take from it only the device-level HID protocol (report
byte layouts, feature-report command bytes, init sequences, VID/PIDs) - documented in
1.4. We do not mirror the C# library's public API surface; the Rust API (1.5) is
our own idiomatic design.
Additional protocol references (all siblings of the working dir): the Linux hid-steam
kernel driver (authoritative command/setting IDs - we adopt its naming), SDL
(../SDL, the Triton driver + the shared steam/controller_{constants,structs}.h - e.g.
MsgSimpleRumbleCmd/MsgHapticRumble/NCHapticPacket2), sc-controller (Triton BLE +
v2 framing), and InputPlumber (../InputPlumber, a Rust device-translation daemon -
useful cross-check for the Deck's HID report + haptic structs, though it mislabels a few
fields; see the 0xea/0xeb notes in 1.4 and the emulated-PID caveat in 1.3).
Valve VID = 0x28DE. Valve codenames: Gordon = original Steam Controller,
Neptune = Steam Deck, Triton = new Steam Controller (2026).
| Device | Codename | PID | Input event type |
|---|---|---|---|
| Steam Controller, wired | Gordon | 0x1102 |
INPUT_DATA (0x01) |
| Steam Controller, wireless dongle | Gordon | 0x1142 |
INPUT_DATA (0x01) |
| Steam Controller, Bluetooth | Gordon | 0x1106 |
compact BLE stream (1.4 BLE) |
| Steam Deck | Neptune | 0x1205 |
DECK_INPUT_DATA (0x09) |
| New Steam Controller (2026), wired | Triton | 0x1302 |
Triton state, id in byte 0 (1.4 Triton) |
| New Steam Controller (2026), Bluetooth | Triton | 0x1303 |
Triton state (1.4 Triton) |
| New Steam Controller (2026), "puck" dongle | Triton | 0x1304 |
Triton state (1.4 Triton) |
Bluetooth (BLE) is HW-verified (1.9): same Gordon commands, different transport + input format - see "BLE transport" in 1.4.
New Steam Controller (Triton) - HW-verified on real hardware across all three transports
(puck/wired/BT), reverse-engineered from SDL + sc-controller - see 1.4 Triton. SDL's 2nd
puck variant "Nereid" 0x1305 is not added until one is seen in the wild.
0x28de:0x12f0-0x12ff are NOT real Valve hardware - do not enumerate them. This range
is InputPlumber's emulated Steam Deck target: it reads a Linux handheld's native
controller and re-emits a virtual Steam-Deck-protocol device under Valve's VID, one PID per
brand so SDL/Steam can still tell them apart - 0x12f0 Generic, 0x12fa MSI Claw, 0x12fb
Lenovo Legion Go 2, 0x12fc Zotac Zone, 0x12fd ASUS ROG Ally, 0x12fe Lenovo Legion Go,
0x12ff Lenovo Legion Go S (from ../InputPlumber src/drivers/steam_deck/mod.rs). They
speak the Neptune protocol by construction, so matching them would "work" but bind the wrong
physical device - keep enumeration pinned to the real PIDs above (this also resolves the old
mystery of the C#/reference 0x12f0). The 0x12f0 value is also what InputPlumber's uhid
path emits for an unbranded emulated Deck.
Framing. 64-byte HID reports. Input reports begin 01 00 <event> where event is
INPUT_DATA=0x01, CONNECT=0x03, BATTERY=0x04, or DECK_INPUT_DATA=0x09. Gordon
delivers state as INPUT_DATA; Neptune as DECK_INPUT_DATA.
Commands are HID feature reports shaped [cmd_id, payload_len, ...payload].
Full command-ID set from the Linux hid-steam driver (v6.18.30) - kernel naming,
the authoritative superset (C# used a small subset). Names credited to Valve/SDL.
0x80 ID_SET_DIGITAL_MAPPINGS 0xAF ID_RADIO_ERASE_RECORDS
0x81 ID_CLEAR_DIGITAL_MAPPINGS 0xB0 ID_RADIO_WRITE_RECORD
0x82 ID_GET_DIGITAL_MAPPINGS 0xB1 ID_SET_DONGLE_SETTING
0x83 ID_GET_ATTRIBUTES_VALUES 0xB2 ID_DONGLE_DISCONNECT_DEVICE
0x84 ID_GET_ATTRIBUTE_LABEL 0xB3 ID_DONGLE_COMMIT_DEVICE
0x85 ID_SET_DEFAULT_DIGITAL_MAPPINGS 0xB4 ID_DONGLE_GET_WIRELESS_STATE
0x86 ID_FACTORY_RESET 0xB5 ID_CALIBRATE_GYRO
0x87 ID_SET_SETTINGS_VALUES 0xB6 ID_PLAY_AUDIO
0x88 ID_CLEAR_SETTINGS_VALUES 0xB7 ID_AUDIO_UPDATE_START
0x89 ID_GET_SETTINGS_VALUES 0xB8 ID_AUDIO_UPDATE_DATA
0x8A ID_GET_SETTING_LABEL 0xB9 ID_AUDIO_UPDATE_COMPLETE
0x8B ID_GET_SETTINGS_MAXS 0xBA ID_GET_CHIPID
0x8C ID_GET_SETTINGS_DEFAULTS 0xBF ID_CALIBRATE_JOYSTICK
0x8D ID_SET_CONTROLLER_MODE 0xC0 ID_CALIBRATE_ANALOG_TRIGGERS
0x8E ID_LOAD_DEFAULT_SETTINGS 0xC1 ID_SET_AUDIO_MAPPING
0x8F ID_TRIGGER_HAPTIC_PULSE 0xC2 ID_CHECK_GYRO_FW_LOAD
0x9F ID_TURN_OFF_CONTROLLER 0xC3 ID_CALIBRATE_ANALOG
0xA1 ID_GET_DEVICE_INFO 0xC4 ID_DONGLE_GET_CONNECTED_SLOTS
0xA7 ID_CALIBRATE_TRACKPADS 0xCE ID_RESET_IMU
0xA8 ID_RESERVED_0 0xEA ID_TRIGGER_HAPTIC_CMD
0xA9 ID_SET_SERIAL_NUMBER 0xEB ID_TRIGGER_RUMBLE_CMD
0xAA ID_GET_TRACKPAD_CALIBRATION
0xAB ID_GET_TRACKPAD_FACTORY_CALIBRATION
0xAC ID_GET_TRACKPAD_RAW_DATA
0xAD ID_ENABLE_PAIRING
0xAE ID_GET_STRING_ATTRIBUTE
Two further command IDs are not in the kernel/C# refs but appear in InputPlumber's
Steam Deck ReportType enum (as UnknownDc/UnknownE2): 0xDC and 0xE2 - undecoded
(no known payload). Their presence in the Deck's enum means they're part of the shared
Valve command space, not Triton-only. sc-controller's Triton v2 pairing capture groups
them with 0xED (a third undecoded, v2-only ID not in SDL/InputPlumber) and 0xAD -
but 0xAD is the already-known ID_ENABLE_PAIRING (in SDL's controller_constants.h and
the kernel, listed above), merely reused in that flow, not a mystery. SDL's Triton driver
references none of these (it does input+haptics only; puck pairing is out of its scope).
Left as reverse-engineering leads; needs a USB packet dump to decode ((!) pairing/radio-
adjacent - probe with care).
C#->kernel notes: C#'s DEFAULT_MOUSE (0x8e) is really ID_LOAD_DEFAULT_SETTINGS;
C#'s "register" ops = kernel "settings" ops (0x87-0x8c); C#'s GET_SERIAL (0xae)
is the generic ID_GET_STRING_ATTRIBUTE (serial = string-attribute id 1).
Settings (C# "registers") are written via ID_SET_SETTINGS_VALUES
(87 <len> (<id> <lo> <hi>)..., little-endian). The kernel SETTING_* enum is a flat
list where the index is the setting id. Full list recorded below (kernel naming):
0 MOUSE_SENSITIVITY 41 HAPTIC_INTENSITY_MOUSE_MODE
1 MOUSE_ACCELERATION 42 LEFT_DPAD_REQUIRES_CLICK
2 TRACKBALL_ROTATION_ANGLE 43 RIGHT_DPAD_REQUIRES_CLICK
3 HAPTIC_INTENSITY_UNUSED 44 LED_BASELINE_BRIGHTNESS
4 LEFT_GAMEPAD_STICK_ENABLED 45 LED_USER_BRIGHTNESS
5 RIGHT_GAMEPAD_STICK_ENABLED 46 ENABLE_RAW_JOYSTICK
6 USB_DEBUG_MODE 47 ENABLE_FAST_SCAN
7 LEFT_TRACKPAD_MODE 48 IMU_MODE
8 RIGHT_TRACKPAD_MODE 49 WIRELESS_PACKET_VERSION
9 MOUSE_POINTER_ENABLED 50 SLEEP_INACTIVITY_TIMEOUT
10 DPAD_DEADZONE 51 TRACKPAD_NOISE_THRESHOLD
11 MINIMUM_MOMENTUM_VEL 52 LEFT_TRACKPAD_CLICK_PRESSURE
12 MOMENTUM_DECAY_AMMOUNT 53 RIGHT_TRACKPAD_CLICK_PRESSURE
13 TRACKPAD_RELATIVE_MODE_TICKS_PER_PIXEL 54 LEFT_BUMPER_CLICK_PRESSURE
14 HAPTIC_INCREMENT 55 RIGHT_BUMPER_CLICK_PRESSURE
15 DPAD_ANGLE_SIN 56 LEFT_GRIP_CLICK_PRESSURE
16 DPAD_ANGLE_COS 57 RIGHT_GRIP_CLICK_PRESSURE
17 MOMENTUM_VERTICAL_DIVISOR 58 LEFT_GRIP2_CLICK_PRESSURE
18 MOMENTUM_MAXIMUM_VELOCITY 59 RIGHT_GRIP2_CLICK_PRESSURE
19 TRACKPAD_Z_ON 60 PRESSURE_MODE
20 TRACKPAD_Z_OFF 61 CONTROLLER_TEST_MODE
21 SENSITIVY_SCALE_AMMOUNT 62 TRIGGER_MODE
22 LEFT_TRACKPAD_SECONDARY_MODE 63 TRACKPAD_Z_THRESHOLD
23 RIGHT_TRACKPAD_SECONDARY_MODE 64 FRAME_RATE
24 SMOOTH_ABSOLUTE_MOUSE 65 TRACKPAD_FILT_CTRL
25 STEAMBUTTON_POWEROFF_TIME 66 TRACKPAD_CLIP
26 UNUSED_1 67 DEBUG_OUTPUT_SELECT
27 TRACKPAD_OUTER_RADIUS 68 TRIGGER_THRESHOLD_PERCENT
28 TRACKPAD_Z_ON_LEFT 69 TRACKPAD_FREQUENCY_HOPPING
29 TRACKPAD_Z_OFF_LEFT 70 HAPTICS_ENABLED
30 TRACKPAD_OUTER_SPIN_VEL 71 STEAM_WATCHDOG_ENABLE
31 TRACKPAD_OUTER_SPIN_RADIUS 72 TIMP_TOUCH_THRESHOLD_ON
32 TRACKPAD_OUTER_SPIN_HORIZONTAL_ONLY 73 TIMP_TOUCH_THRESHOLD_OFF
33 TRACKPAD_RELATIVE_MODE_DEADZONE 74 FREQ_HOPPING
34 TRACKPAD_RELATIVE_MODE_MAX_VEL 75 TEST_CONTROL
35 TRACKPAD_RELATIVE_MODE_INVERT_Y 76 HAPTIC_MASTER_GAIN_DB
36 TRACKPAD_DOUBLE_TAP_BEEP_ENABLED 77 THUMB_TOUCH_THRESH
37 TRACKPAD_DOUBLE_TAP_BEEP_PERIOD 78 DEVICE_POWER_STATUS
38 TRACKPAD_DOUBLE_TAP_BEEP_COUNT 79 HAPTIC_INTENSITY
39 TRACKPAD_OUTER_RADIUS_RELEASE_ON_TRANSITION 80 STABILIZER_ENABLED
40 RADIAL_MODE_ANGLE 81 TIMP_MODE_MTE
Used initially (only these low, known settings; the rest are recorded for later
testing, not for the first implementation): 7 LEFT_TRACKPAD_MODE (C# LPAD_MODE),
8 RIGHT_TRACKPAD_MODE (C# RPAD_MODE), 45 LED_USER_BRIGHTNESS (C# LED_INTENSITY),
48 IMU_MODE (C# GYRO_MODE), 50 SLEEP_INACTIVITY_TIMEOUT (C# IDLE_TIMEOUT),
52/53 LEFT/RIGHT_TRACKPAD_CLICK_PRESSURE.
IMU_MODE (48) bitmask - matches C# GCGyroMode exactly: OFF=0, STEERING=1,
TILT=2, SEND_ORIENTATION=4, SEND_RAW_ACCEL=8, SEND_RAW_GYRO=0x10. Trackpad modes
(for LEFT/RIGHT_TRACKPAD_MODE): ABSOLUTE_MOUSE=0, RELATIVE_MOUSE=1, dpad 2-4,
RADIAL=5, ABSOLUTE_DPAD=6, NONE=7 (disables the pad for raw input),
GESTURE_KEYBOARD=8. Pad ids: LEFT=0, RIGHT=1, BOTH=2.
Key command sequences (kernel naming):
| Purpose | Sequence |
|---|---|
| Write settings | 87 <len> (<setting_id> <lo> <hi>)... |
| Lizard OFF (raw) | 81 (CLEAR_DIGITAL_MAPPINGS), then write LEFT/RIGHT_TRACKPAD_MODE=NONE(7); Deck also click-pressures 0xFFFF + STEAM_WATCHDOG_ENABLE=0 |
| Lizard ON | 85 (SET_DEFAULT_DIGITAL_MAPPINGS), then 8e (LOAD_DEFAULT_SETTINGS) |
| Gyro/IMU enable | write IMU_MODE(48) = SEND_RAW_ACCEL|SEND_RAW_GYRO (0x18); 0 to disable |
| Power off | 9f 04 6f 66 66 21 ("off!") |
| Get serial | ae 15 01 (ATTRIB_STR_UNIT_SERIAL); reply serial from offset 4 |
Haptics - HW-verified (full exploration in 1.9; the byte layouts below are confirmed).
Each reference decodes a different subset: the kernel hid-steam and SDL for 0x8f/0xeb
(and Triton's output reports), C# and InputPlumber for 0xea (kernel/SDL only define the
id and never send it), sc-controller for Triton framing.
-
0x8fID_TRIGGER_HAPTIC_PULSE- trackpad-actuator pulse (Gordon's only haptic; works on the Deck too).[8f 08 <pad> <duration:u16> <interval:u16> <count:u16> <gain:i8>]. Pad map HW-verified: wire 0 = RIGHT, 1 = LEFT (kernel legacy swap);pad=2/BOTH no-ops on Gordon (fire both pads separately).gain(dB -24..+6) is honored on the Deck, ignored on Gordon (Gordon's amplitude lever is the duty cycle). Subsumes C#'s len-7 no-gain variant. -
0xebID_TRIGGER_RUMBLE_CMD- the Deck's dual-motor rumble (kernelFF_RUMBLE).[eb 09 <unRumbleType> <intensity:u16> <left_speed:u16> <right_speed:u16> <left_gain:i8> <right_gain:i8>](kernelsteam_haptic_rumblewritesreport[3]=intensity LSB /report[4]=MSB; SDLMsgSimpleRumbleCmd).intensityis a u16 LE, an inverted fine amplitude lever (0= strongest) - a low-byte-only HW sweep looks inert but that's just the LSB (same as the low bytes ofleft/right); InputPlumber's "event_type:u8 + intensity:u8" split is wrong.unRumbleType(report[2]) is HW-confirmed inert (swept 0..255, no effect) - every reference sends 0.left/right= pulse rate;*_gaindB = coarse amplitude. Fixed ~0.5 s burst, re-issue to sustain,(0,0)stops. Deck-only (no motors on Gordon). -
0xeaID_TRIGGER_HAPTIC_CMD- the Deck's finely-tuned trackpad click (now our command-click; strongest beats a full0x8f). SDL/kernel only define the id and never send it - C#NCHapticPacket2and InputPlumberPackedHapticReportare the only payload refs. Layout (C# framing, kept):[ea 0d <side> <style> <intensity> <gain:i8> 04 <tsA:i32> <tsB:i32>].side: 0=L, 1=R, 2=Both - all HW-verified (Motor::Both).style=HapticStyleoff/weak/strong (0/1/2) - == InputPlumber'scmd_typeoff/tick/click.intensity=HapticIntensity0..4 (InputPlumber names: Default/Short/Medium/Long/Insane) - the byte C# hard-codes to 0. HW: 0..2 identical, 3 stronger, 4 stronger/different character; values outside 0..4 do nothing.gaini8 dB (C#-7..5=> ~-2..+10).- Trailing
04+ twoTickCountwords are unexposed; InputPlumber's alternate fixed tail (5F CC 03 ... 10) made no HW difference, so we keep the C# tail.
-
Triton output-report haptics ride the interrupt-OUT endpoint (not feature reports):
0x80rumble = SDLMsgHapticRumble[80 <unRumbleType> <intensity:u16> <left{speed:u16,gain:i8}> <right{speed:u16,gain:i8}>]- sameunRumbleType-inert /intensity-u16 facts as0xeb;0x82click =[82 <side> <style> <amplitude:u8>](side honors Both). Full Triton detail in 1.9. -
Neptune keep-alive - DONE (in the engine reader, not a steam-hid thread; HW-verified). The Deck reverts to lizard mode unless lizard-off is re-asserted periodically - measured on HW: revert ~10 s after lizard-off, so any heartbeat well under 10 s suffices. The engine reader thread re-asserts lizard-off every ~2 s, Neptune-gated (
ReaderCfg::keepalive), interleaved with its poll/rumble/click writes - not a background thread inside steam-hid (Deviceis the single writer: Send, not Sync, so the cadence belongs in the owner's read loop). HW-verified holding a real Deck alive across a multi-minute network-mode session (never dropped to lizard). OnCONNECT(Gordon), previously-set config (lizard/gyro/idle) is restored.
Input report layouts (both 64 B, packed). Offsets are byte positions in the report.
INPUT_DATA - Gordon:
| Off | Field | Type | Off | Field | Type |
|---|---|---|---|---|---|
| 0x04 | seq | u32 | 0x1C | accel x/y/z | 3xi16 |
| 0x08 | buttons0/1/2 | 3xu8 | 0x22 | gyro x/y/z (pitch/roll/yaw) | 3xi16 |
| 0x0B | left/right trigger | 2xu8 | 0x28 | quaternion q1..q4 | 4xi16 |
| 0x10 | lpad x/y | 2xi16 | 0x32 | wireless "unfiltered" trig/joy/pad | i16... |
| 0x14 | rpad x/y | 2xi16 | 0x3E | battery | u16 |
| 0x18 | wireless trig l/r | 2xi16 |
DECK_INPUT_DATA - Neptune:
| Off | Field | Type | Off | Field | Type |
|---|---|---|---|---|---|
| 0x04 | seq | u32 | 0x24 | quaternion q1..q4 | 4xi16 |
| 0x08 | buttons0/1/2/3 | 4xu8 | 0x2C | left/right trigger | 2xi16 |
| 0x0D | buttons5/6 | 2xu8 | 0x30 | left stick x/y | 2xi16 |
| 0x10 | lpad x/y | 2xi16 | 0x34 | right stick x/y | 2xi16 |
| 0x14 | rpad x/y | 2xi16 | 0x38 | lpad pressure | i16 |
| 0x18 | accel x/y/z | 3xi16 | 0x3A | rpad pressure | i16 |
| 0x1E | gyro pitch/yaw/roll | 3xi16 | 0x3C | l/r stick force | 2xi16 |
Stick capacitive force (0x3C/0x3E, Neptune-only, InputPlumber-sourced). SDL's
SteamDeckStatePacket_t and the kernel stop at pad pressure (0x3A); only InputPlumber reads
0x3C/0x3E (it names them thumbstick "force"). HW-verified they carry a raw capacitive
magnitude - idles ~-5..0, ~380..450 pressed, per-stick (InputPlumber's STICK_FORCE_MAX = 112
is wrong), most likely the analog the firmware thresholds into the binary stick-touch bit.
Parsed into NeptuneReport.{left,right}_stick_force for reference only - deliberately NOT exposed
as a bindable input (a stick-pressure axis is meaningless; even pad pressure isn't surfaced).
Triton has no equivalent - SDL's TritonMTUNoQuat_t/Full/Ibex bodies carry pad pressure but
no stick-force field. Probe: cargo run -p steam-hid --example stickforce.
Button bitfields (per-device wire bits) map to a unified button set in the parser. Superset of inputs across both devices: A/B/X/Y, Dpad, LB/RB, LT/RT, LGRIP/RGRIP, LGRIP2/RGRIP2, View/Menu/Steam/QuickAccess, left/right pad press+touch, left/right stick press+touch.
Layouts kept as-is; button naming unified. The kernel confirmed the Deck (0x09)
layout byte-for-byte and added nothing new; for Gordon it parses less (no IMU), whereas
the C# Gordon IMU offsets (accel/gyro/quaternion) are in daily use and known-good - so we
keep the current field layouts. Button names are unified across all layers onto one
scheme (was C#'s L1/L2/L4/L5): LB/RB bumpers, LT/RT trigger full-pulls, LGRIP/RGRIP
(+ LGRIP2/RGRIP2 on the Deck) back buttons - deliberate abbreviations of the descriptive
config::InputSource names (LeftBumper/LeftTriggerFull/LeftGrip/LeftGrip2), and
matching the kernel's GRIPL/GRIPL2 for the grips and sc-controller's LB/RB, LT/RT
for shoulders/triggers (bumpers/triggers pick sc-controller/Xbox over the kernel's cryptic
TL/TR, TL2/TR2 - UX over provenance, deliberately). The two small top buttons are Valve's
View ([copy], left/select, kernel BTN_SELECT) and Menu ([menu], right/start, BTN_START)
- NOT C#'s misleading
Menu/Options- unified acrosssteam-hid/config/engine; the output layer (vocab, an Xbox pad) correctly usesBack/Startthere instead. Gyro naming: HW-verified (1.9) the three Gordon gyroi16s at0x22/0x24/0x26are angular velocity aboutX/Y/Z= pitch/roll/yaw, not the C#gpitch/gyaw/groll(yaw/roll transposed); offsets correct, names dropped (see the IMU-frame note underControllerState).
Scale constants (from the kernel; useful for normalization / IMU -> physical units).
The C# app likely has equivalents outside the HID lib we read; treat these as a
starting point and verify empirically:
STEAM_DECK_ACCEL_RES_PER_G = 16384 (raw/g), STEAM_DECK_GYRO_RES_PER_DPS = 16
(raw/deg*s^-1), STEAM_DECK_TRIGGER_RESOLUTION = 5461, STEAM_DECK_JOYSTICK_RESOLUTION = 6553, STEAM_PAD_RESOLUTION = 1638, STEAM_TRIGGER_RESOLUTION = STEAM_JOYSTICK_RESOLUTION = 51.
Protocol caveats to carry into implementation:
- Offset comments in the C#
GCInputstruct contain typos (e.g.rpad_xlabelled0x13, actually0x14); the sequential packed layout is authoritative. - Gordon (USB) multiplexes left-pad vs left-stick - both the
lpad_x/ycoordinates and the shared left-click bit - keyed onLPAD_TOUCH(+LPAD_AND_JOY). Resolved entirely inparse_gordon; full behaviour incl. simultaneous pad+stick use in 1.9. (Not a wired-vs-wireless divide - both USB transports do it; BLE has no multiplex at all.) - Haptics packets (
0x8f/0xea/0xeb) are all under test - see the Haptics block above; none is trusted yet. - Feature reports - confirmed against the Linux
hid-steamdriver (v6.18.30): all commands and reads use HID feature reports with report ID 0.- Setters (lizard, gyro, rumble, power off, register writes) need only
SET_REPORT(send_feature_report) to take effect - no read-back required. (The C#RequestFeatureReportalways did a GET too, which merely hid this.) - Queries (serial, read register, attribs) are
SET_REPORTthenGET_REPORT(get_feature_report); the GET fetches the reply. - Register writes (
0x87) may be followed by a GET purely to drain a lingering reply the device queues, so it can't surface later and corrupt the next query's read - this is hygiene, not needed for the write itself. Add the drain only if we observe stale reads. - Report-ID-0 framing: prepend a
0x00report-ID byte and pad theSET_REPORTbuffer to >=64 bytes (the kernel sendsmax(size, 64) + 1). - Wireless
EPIPE: the dongle intermittently failsSET_REPORTwithEPIPE; the kernel retries up to 50x with 20 ms sleeps. Our dongle path (primary dev target) should replicate retry-on-EPIPE.
- Setters (lizard, gyro, rumble, power off, register writes) need only
Bluetooth (BLE) transport - Gordon 0x1106 (HW-verified, 1.9). The kernel
hid-steam driver has no Bluetooth entry (its device table is USB-only), so BLE is
reverse-engineered from SDL (../SDL/src/joystick/hidapi/SDL_hidapi_steam.c +
steam/controller_{constants,structs}.h) and sc-controller
(../sc-controller/scc/drivers/sc_by_bt.{py,c}). sc-controller's input parser is
approximate; SDL's UpdateBLESteamControllerState is authoritative. The command
bytes are identical to USB - only the transport framing and the input format differ.
-
Everything rides Report ID 3 on 20-byte HID reports (1 report id + 1 header + 18 payload - the BLE ATT_MTU cap). Both feature writes and input longer than 18 bytes are segmented. Segment header byte =
0x80 (DATA) | segnum(0..7) | 0x40 (LAST); a single-segment packet's header is0xC0. (SDL:MAX_REPORT_SEGMENT_PAYLOAD_SIZE=18,REPORT_SEGMENT_DATA_FLAG=0x80,_LAST_FLAG=0x40,BLE_REPORT_NUMBER=0x03.) -
Feature writes: split the logical
[cmd_id, len, payload]into <=18-byte segments, each sent as[0x03][header][chunk, zero-padded to 20]. Same command IDs as USB, so lizard-off / settings / gyro /0x8fhaptics / LED / idle reuse the existing helpers. -
Input is a compact DELTA stream (not the fixed 64-byte USB report). Reassembled payload:
byte0low nibble = report type (4=State,5=Status),(byte0 & 0xF0) | (byte1 << 8)= chunk mask; only present chunks follow, in ascending bit order:Bit Chunk Bytes Bit Chunk Bytes 0x0010 Button1 (low 3 button bytes) 3 0x0200 RightTrackpad x/y 4 0x0020 Triggers L/R 2 0x0400 Accel x/y/z 6 0x0040 Button3 (high bytes; unused on SC) 3 0x0800 Gyro x/y/z 6 0x0080 LeftStick x/y 4 0x1000 Quat w/x/y/z 8 0x0100 LeftTrackpad x/y 4 Devicereassembles segments and accumulates chunk updates into aGordonReport(the same type USB produces - see convergence below), emitting a full snapshot per input packet (seqsynthesized - the wire carries none). -
Button bit layout is identical to USB
GordonButtons- same positions and names, so USB and BLE share oneGordonButtons/GordonReport/ fold (the separateGordonBle*types were removed at convergence); only the two parsers differ (parse_gordonfor the 64-byte USB frame,apply_gordon_blefor the reassembled BLE delta). Dpad works over BT (bits 8..11, synthesized from left-pad directional clicks - HW-verified). Crucially BLE has no left multiplex - stick and pad are separate chunks and the pad-click / stick-press / touch bits are independent - soapply_gordon_bleproduces already-clean data and never runsparse_gordon's de-multiplex (hardware-accurate: BT is not forced through it). -
IMU raw == USB Gordon raw (all axes/signs HW-verified equal), so BLE flows through the shared
from_gordonand gets the samegordon_gyroy-negation. The orientation quaternion (0x1000) is only sent ifSEND_ORIENTATION(0x04) is set; we enable only0x18(raw accel+gyro), soorientationstays default (unused downstream- USB sends it every frame regardless; enabling it on BT costs an extra 20-byte segment).
-
udev: BLE is created via
/dev/uhid(modern BlueZ), landing under/devices/virtual/misc/uhid/...with noid/vendorattribute in the sysfs chain - soATTRS{idVendor}/ATTRS{id/vendor}can't match. The rule matches the parent HID kernel name instead:KERNEL=="hidraw*", KERNELS=="0005:28DE:*"(bus0005=BT, VID/PID uppercase). Shipped incrates/steam-hid/udev/69-steam-hid.rules. -
enumerate: hidapi lists the single BLE hidraw node once per top-level collection (mouse
0x01, keyboard0x01, vendor0xFF00- all same path). The standardusage_page >= 0xFF00gamepad-interface filter keeps exactly the vendor entry - no BLE-special-casing needed.interface_numberis-1(BT has none) and the serial is the MAC, soDeviceId = gordon:bt:-1:<mac>;-i btis the transport selector token. -
Keep-alive: not needed. Unlike Neptune (reverts to lizard ~10 s after lizard-off), BLE Gordon stayed alive well past that window during gameplay, so it's excluded from keepalive (which remains Neptune-gated - no code change needed).
Triton (new Steam Controller, 2026) - HW-verified (1.9). The 1.3 "new Steam Controller",
not in the C#/kernel refs. Reverse-engineered from SDL (SDL_hidapi_steam_triton.c +
steam/controller_structs.h - Valve's own struct names; the primary source, and the one that
handles newer report ids) and sc-controller (scc/drivers/sc2.py +
docs/steam-controller-v2-protocol.md - the better RE narrative + usbmon command captures).
-
Topology / PIDs (all
0x28DE):0x1302wired (single HID iface 0),0x1303Bluetooth LE,0x1304"puck" wireless dongle (SDL "Proteus"). The puck enumerates per-slot HID interfaces 2..=6 on the observed unit (SDL documents 2..5 as the 4 controller slots - "4 devices per puck"; real HW exposes one more, likely a dongle-management iface) and reports a real serial (unlike the Gordon dongle). Auto-open treats it as a dongle (poll-probe each slot for a streaming frame - it streams by default). SDL's 2nd variant "Nereid"0x1305is not added until seen. -
Framing is NOT the
0x01-eventValveInReport_t. Triton input/haptic reports carry the report id in byte 0 - dispatched inreport::parse_triton(kind-aware, routed fromDevice::next_frame_triton), never theparse/buf[2]event path. Report ids:0x42state (with on-controller quaternion),0x45state "NoQuat",0x47state "Ibex" (16-bit timestamps),0x43battery,0x46/0x79wireless status (TritonWirelessStatus{state}: 2=connect, 1=disconnect). HW: puck + wired stream0x42, Bluetooth streams0x45- the same NoQuat body (the0x42quaternion is just trailing bytes we skip; orientation is unused downstream). BT dropping the quaternion mirrors Gordon BLE limiting orientation over the air.0x47deferred. SDL parses it (TritonMTUNoQuat32TS_t, "Ibex" - a trackpad timestamp + a 16-bit IMU timestamp); sc-controller does not. We skip it (parse_triton->None-> keep reading). It is NOT simply a newer-firmware default: on 2026-08-26 Steam force-updated this unit's firmware on connect (so, latest) and it still streams0x42- so whatever selects0x47is something else (a different SKU/variant or mode), currently unknown. If a unit ever streams it, add a parser: its extrau16trackpad-timestamp shifts the pad/pressure fields +2, but the IMU timestamp shrinksu32->u16(-2), so accel/gyro stay at the same offsets.
-
Report
0x42/0x45NoQuat body (offsets into the raw read, byte 0 = report id; little-endian; matches SDLTritonMTUNoQuat_tand sc-controller's doc):Offset Type Field Offset Type Field 0 u8 report id 22-23 u16 left pad pressure 1 u8 seq_num 24-27 i16x2 right pad x,y 2-5 u32 buttons 28-29 u16 right pad pressure 6-7 i16 left trigger (0..~32767) 30-33 u32 IMU timestamp 8-9 i16 right trigger 34-39 i16x3 accel x,y,z 10-13 i16x2 left stick x,y 40-45 i16x3 gyro x,y,z 14-17 i16x2 right stick x,y (46-53) i16x4 quaternion w,x,y,z ( 0x42only; skipped)18-21 i16x2 left pad x,y -
Button bits -
u32=byte2 | byte3<<8 | byte4<<16 | byte5<<24(SDLTritonButtons== sc-controllerSC2Button;steam-hid::TritonButtonsuses the unified names):byte 0x010x020x040x080x100x200x400x802 A B X Y QuickAccess R3 Menu R4->RGrip 3 R5->RGrip2 RB Dpad-Down Dpad-Right Dpad-Left Dpad-Up View L3 4 Steam L4->LGrip L5->LGrip2 LB RStickTouch RPadTouch RPadClick RT full-pull 5 LStickTouch LPadTouch LPadClick LT full-pull RGripTouch LGripTouch ? ? Back paddles follow the Deck convention (upper R4/L4 -> grip, lower R5/L5 -> grip2). New over the Deck: capacitive grip-touch (byte5
0x10/0x20) - reads on whenever the handles are held, including resting on a table (byte5 idles0x30; hands fully off ->0x00). Added asL/RGripTouchinvocab-hid(bindable + usable as gaters). Two byte5 bits (0x40/0x80) are unidentified on the test units. -
Commands: feature report
0x01, the whole HID report EXACTLY 64 bytes (report id + 63 payload). Gordon/Neptune use report0x00with 64 data bytes = a 65-byte buffer (SDL's documented "firmware quirk: Set/Get Feature always require a 65-byte buffer"; report 0 is unnumbered so nothing extra rides the wire). A 65-byte Triton write stalls (ioctl SFEATURE: Broken pipe) and lizard-off / gyro / LED / idle silently fail - but input still streams (0x42/0x45flow even in lizard mode), so the symptom is "everything works but the config writes fail."Device::frame(cmd, report_id, buf_len)sizes it: Triton(0x01, 64), Gordon/Neptune(0x00, 65). The command body ([cmd_id, len, payload]) and setting indices are otherwise identical to Gordon/Neptune, so lizard-off (CLEAR_DIGITAL_MAPPINGS+ trackpadNONE), gyro-enable, LED, idle reuse the existing helpers. The puck streams by default, so noDONGLE_GET_WIRELESS_STATE (0xB4)prompt is sent (Gordon-dongle-only). -
IMU passes through RAW - like Neptune, no correction (HW-verified 1.9): accel flat->
+Z, right-side->+X, nose-up->+Y; gyro pitch-up->+X, roll-right->+Y, yaw-left->+Z(sc-controller's-ryis its own DS4 convention, not ours). Gyro full-scale is 2000 dps (res ~16.384LSB/dps) vs the canonicalGYRO_RES_PER_DPS = 16(2048 dps) - the ~2.3 % delta is accepted un-rescaled (below sensitivity granularity). IMU is default-on: removing theset_gyro(true)call and full-power-cycling the controller (no persisted state) still streams accel/gyro - so no explicit enable is needed (verified on dongle/wired). We keep the reader'sset_gyro(true)anyway (harmless re-assert, and load-bearing for Gordon/Neptune). Over BT the body is0x45(NoQuat) so the quaternion is absent there; unused downstream regardless. -
Haptics ride OUTPUT reports
0x80-0x85(via hidapiwrite-> interrupt-OUT endpoint, exactly as SDL'sSDL_hid_write; sc-controller's "stall" caveat is only about the feature/control path, which we don't use for haptics). Wired up:0x80rumble ({type, intensity, left{speed,gain}, right{speed,gain}}, 10 B) and0x82click ({side, style, amplitude}, 4 B). Also wired + HW-explored (the beep/haptic session):0x81pulse (pulse_triton- rumble train + single-pulse click),0x83LFO tone +0x84log sweep (lfo_tone_triton/logsweep_triton- the audio paths). Recorded but unused:0x85script. Full findings in 1.10.- Rumble levers == the Deck's
0xeb:left/right= per-motor rate (coarse strength),*_gain(dB) the strength trim,intensity= a finer inverted lever (0= no change). side0=left/1=right (verified).Device::rumble_tritonparam order mirrorsrumble_cmd. Re-fire 400 ms - the firmware sustains each command well past that (no gap until >500 ms), far laxer than the Deck's 500 ms burst; readerTRITON_REFIRE_MS. - Click
styleis aHapticStyle(1Weak /2Strong) and the only working lever; the amplitude byte (sc-controller0=medium...255=strong; SDL's struct mis-types it as signedgain_db, but SDL never sends0x82) is HW-confirmed inert - and Steam's own 0-12 dB haptic setting changes nothing either, so it's a firmware/hardware limit, not our packet. EvenWeakis a fairly firm click => only two distinct strengths. Readertriton_click: Low=(Weak,0)Med=(Weak,255)High=(Strong,0)(Med's 255 is only a gradient for a possible future firmware). Explore withhaptic --triton.
- Rumble levers == the Deck's
-
Transports need no per-transport input code - everything keys on
DeviceKind::Triton, not the transport (this is why wired and BT each cost zero core changes).- Wired (
0x1302): identical to the puck; battery pinned 100 % placeholder (like Gordon wired - a USB device that reports no real charge). - Bluetooth (
0x1303): not segmented like Gordon BLE. The OS HID-over-GATT stack reassembles GATT notifications into plain numbered reports and prepends the report id, so BT reports arrive exactly like USB (state id0x45) and route throughnext_frame_triton- the GordonBleStatesegmenter is!is_triton()-gated, so Triton never touches it. (SDL's manual report-id reconstruction is Android/iOS-only, where the app does raw GATT; Linux HOG - and Windowshidclass- do it in the OS.)interface_number = -1, serial = MAC (triton:bt:-1:<mac>); the udev ruleKERNELS=="0005:28DE:*"already covers it. - Keep-alive: Triton reverts to lizard ~3 s after lizard-off on all transports (a
firmware watchdog - unlike BLE Gordon, which needs none), so
DeviceKind::needs_keepaliveis kind-based (Neptune + Triton) and the reader re-asserts on a single ~3 s cadence.
- Wired (
Idiomatic, embeddable, blocking + sync by default, no forced async runtime.
Frame model - the device speaks full snapshots, and the read stream is heterogeneous.
Every input frame is the complete current state (buttons as a current-held bitfield,
sticks/pads/triggers as absolute values, IMU) pushed ~250 Hz-1 kHz; the controller never
sends deltas. But the same read stream also carries non-input frames - connect/disconnect
(0x03) and battery (0x04, wireless only). So one physical read yields exactly one
frame of some type, and that is modelled as an enum, never a bare ControllerState.
(A bare-state return can't represent a disconnect frame and would then block forever once
the controller powers off but the dongle stays connected - see disconnect semantics below.)
Two layers. Layer 1 = RawReport, the decoded wire frame (per-device input fields
in wire types, plus the lifecycle frames). Layer 2 = Report, wrapping a unified,
normalized ControllerState (produced by a pure From conversion), or the same
lifecycle signals. The mapper consumes Report::State(ControllerState); the dump example
can read raw. Illustrative sketch (subject to refinement during implementation):
// ---- discovery / lifecycle ----
pub struct Manager { /* owns hid backend context */ }
impl Manager {
pub fn new() -> Result<Self>;
pub fn enumerate(&self) -> Result<Vec<DeviceInfo>>; // gamepad interfaces only, see 1.6
pub fn open(&self, info: &DeviceInfo) -> Result<Device>;
pub fn open_first(&self) -> Result<Device>; // convenience
}
pub struct DeviceInfo {
pub kind: DeviceKind, // Gordon | Neptune | (future) ... (codenames, unified w/ RawReport)
pub transport: Transport, // UsbWired | UsbDongle | Bluetooth (Gordon BLE, 1.4)
pub serial: Option<String>,
pub vid: u16, pub pid: u16,
pub interface: i32, // HID interface No. - identifies the dongle slot / wired gamepad iface
// + opaque OS path used to open
}
// ---- a Device is a transport ENDPOINT (wired gamepad iface / dongle slot), NOT a live
// controller: it outlives connect/disconnect and stays usable across reconnects ----
pub struct Device { /* owns its hid handle; is Send; no keep-alive thread - the owning consumer drives it (see 1.6) */ }
impl Device {
pub fn info(&self) -> &DeviceInfo;
pub fn is_connected(&self) -> bool; // cached from 0x03 frames; convenience, not load-bearing
pub fn battery(&self) -> Option<Battery>; // cached last-known (wireless only); see below
// ---- input: one physical read == one frame of some type ----
// low-level: the decoded wire frame (per-device input fields, or a lifecycle frame)
pub fn read_raw(&mut self) -> Result<RawReport>; // blocks until next frame
pub fn poll_raw(&mut self, timeout: Duration) -> Result<Option<RawReport>>; // None = timed out
// high-level: unified normalized snapshot, or the same lifecycle signal
pub fn read(&mut self) -> Result<Report>; // blocks until next frame
pub fn poll(&mut self, timeout: Duration) -> Result<Option<Report>>; // None = timed out
// event view: the SAME stream expressed as deltas (+ lifecycle passthrough).
// Holds an internal previous-state; derived from read() + ControllerState::diff() (see below).
pub fn events(&mut self) -> Events<'_>;
// ---- commands ----
pub fn set_lizard_mode(&mut self, on: bool) -> Result<()>;
pub fn set_imu_mode(&mut self, mode: ImuMode) -> Result<()>; // bitflags (1.4 IMU_MODE)
pub fn set_gyro(&mut self, on: bool) -> Result<()>; // convenience = raw accel|gyro
pub fn set_led_intensity(&mut self, percent: u8) -> Result<()>; // 0..=100
pub fn set_idle_timeout(&mut self, secs: u16) -> Result<()>;
pub fn rumble(&mut self, motor: Motor, params: Rumble) -> Result<()>; // wraps the verified packet
pub fn power_off(&mut self) -> Result<()>;
// escape hatch: takes the LOGICAL command `[cmd_id, len, payload...]`; the report-ID-0
// prepend + pad-to->=64 framing (1.4) is applied internally, not by the caller.
pub fn send_feature_report(&mut self, cmd: &[u8]) -> Result<()>;
pub fn get_feature_report(&mut self, buf: &mut [u8]) -> Result<usize>;
}
// ---- LAYER 1: raw decoded wire frame (input variants + lifecycle variants) ----
pub enum RawReport {
Gordon(GordonReport), // 0x01 INPUT_DATA
Neptune(NeptuneReport), // 0x09 DECK_INPUT_DATA
Connected, // 0x03, payload byte 0x02
Disconnected, // 0x03, payload byte 0x01
Battery(BatteryRaw), // 0x04 CONTROLLER_STATUS (wireless only)
}
pub struct GordonReport { // original Steam Controller - input fields only
pub seq: u32,
pub buttons: GordonButtons, // bitflags of Gordon's buttons only
pub left_trigger: u8, pub right_trigger: u8, // wire: u8 0..=255
pub left_stick: Vec2i, pub left_pad: Vec2i, // multiplex resolved via touch flags
pub right_pad: Vec2i, // (no right stick on Gordon)
pub accel: Vec3i, pub gyro: Vec3i, pub orientation: Quati, // raw i16
// NOTE: battery is NOT here - it arrives out-of-band as the 0x04 frame (RawReport::Battery),
// wireless only. The inline GCInput field @0x3E is an unverified alternative source (1.9).
}
pub struct NeptuneReport { // Steam Deck - input fields only
pub seq: u32,
pub buttons: NeptuneButtons, // Deck buttons only (+L5/R5, QuickAccess, stick touch...)
pub left_trigger: i16, pub right_trigger: i16, // wire: i16
pub left_stick: Vec2i, pub right_stick: Vec2i,
pub left_pad: Vec2i, pub right_pad: Vec2i,
pub left_pad_pressure: i16, pub right_pad_pressure: i16,
pub accel: Vec3i, pub gyro: Vec3i, pub orientation: Quati, // raw i16
// (Neptune-as-controller has no battery; the Deck's device battery is a separate concern)
}
// ---- LAYER 2: unified snapshot, or the same lifecycle signal ----
pub enum Report {
State(ControllerState), // an input frame, converted
Connected,
Disconnected,
Battery(Battery),
}
impl From<&RawReport> for Report { /* input variants -> State(convert); lifecycle -> passthrough */ }
pub struct ControllerState {
pub seq: u32,
pub timestamp: Timestamp, // monotonic, captured at read; recorded in traces &
// drives the engine's injected clock on replay (1.8, 4)
pub buttons: Buttons, // unified superset bitflags; absent buttons never set
pub left_trigger: f32, pub right_trigger: f32, // normalized 0.0..=1.0
pub left_stick: Vec2, pub right_stick: Vec2, // normalized -1.0..=1.0 (zeroed if absent)
pub left_pad: TrackPad, pub right_pad: TrackPad, // pos -1..1, pressure 0..1, touched
pub accel: Vec3i, pub gyro: Vec3i, pub orientation: Quati, // RAW i16 + documented scale
// NOTE: battery is NOT a field here - it is device-level state (is_connected()/battery()),
// surfaced as Report::Battery / Event::Battery, not carried in every input snapshot.
}
impl ControllerState {
// STATELESS diff primitive: caller holds both snapshots (e.g. the engine keeps prev).
pub fn diff<'a>(&'a self, prev: &'a Self) -> impl Iterator<Item = Event> + 'a;
}
pub enum Event {
ButtonPressed(Button), ButtonReleased(Button),
AxisChanged(Axis, f32), // normalized axes
Connected, Disconnected, Battery(Battery),
}
// small value types
pub struct Vec2 { pub x: f32, pub y: f32 } // normalized
pub struct Vec2i { pub x: i16, pub y: i16 } // raw wire
pub struct Vec3i { pub x: i16, pub y: i16, pub z: i16 }
pub struct Quati { pub x: i16, pub y: i16, pub z: i16, pub w: i16 }
pub struct TrackPad { pub pos: Vec2, pub pressure: f32, pub touched: bool }Snapshots vs. events - how the four input methods relate. They are not four parallel things; they are two layers x two views over the one frame stream:
read_raw/read/poll_*= snapshots. The native truth: each read returns one full frame.read_rawgives wire fields;readgives the normalizedControllerState(or a lifecycleReport). The engine uses these - mapping needs the whole current state each tick (a held button is a state, not a one-shot event), not deltas.ControllerState::diff(prev, cur)= stateless diff primitive. Give it two snapshots, get the changes. No hidden state; the caller ownsprev.events()= the stateful streaming view, built from the two above: it keeps an internalprev, and per frame yields changes for input frames + passthrough for lifecycle frames. Roughly:Use it for change-driven consumers (theloop { match read()? { Report::State(cur) => { yield cur.diff(&prev); prev = cur; } // diffed Report::Disconnected => yield Event::Disconnected, // passthrough Report::Connected => yield Event::Connected, Report::Battery(b) => yield Event::Battery(b), } }dumpexample; a UI showing "you pressed A").- One mode per Device.
read/pollandevents()both consume the single frame stream, so a givenDeviceis driven in snapshot mode or event mode, not both at once. - Event semantics (threshold + scope). The device streams full snapshots
continuously at a fixed cadence (free-running per-frame
seq; it does not send on-change), so consecutive frames usually differ only by analog/IMU noise. Sodiff()applies a small per-axis deadband before emittingAxisChanged, andevents()covers buttons + normalized axes + lifecycle only - IMU is snapshot-only (raw, always varying; meaningless as discrete "changed" events). Without the deadband a change-log would spew at the full stream rate on jitter alone.- The deadband is analog-only, cosmetic, and off the mapping path. Digital buttons
are exact bit flips (no threshold). And the engine does not use
events()/diff()at all - it consumes raw snapshots and applies its own configurable deadzones/curves (3/4). Where the engine needs input edges (activators: long-press, double-tap, on-release, turbo) it computes exact button transitions from its own retainedprev(cur.buttons & !prev.buttons), not from this path. So this deadband can never reach mapping.diff()/events()exist for change-log consumers (dump; future UI binding-capture);tuijust renders the current snapshot and uses neither.
- The deadband is analog-only, cosmetic, and off the mapping path. Digital buttons
are exact bit flips (no threshold). And the engine does not use
Disconnect semantics - two distinct flavors, neither of which hangs read.
- Transport gone (wired Gordon unplugged, or the dongle removed): the USB device
disappears, the HID handle goes invalid, the underlying read fails ->
Err(Error::...). TheDevice(interface) is dead. This is an error. - Controller gone, transport alive (dongle stays; pad powers off / out of range): a
0x03frame arrives, then the dongle goes quiet. This is not an error - it is returned asOk(RawReport::Disconnected)/Ok(Report::Disconnected)and surfaced asEvent::Disconnected. Because disconnect is a returned value,readnever blocks on a vanished controller: a frame woke it. A subsequent blockingreadthen legitimately waits for the next frame (a reconnect, or a transport error).is_connected()/battery()are just cached conveniences updated from these frames - not required for correctness.
Conversion rules (RawReport -> ControllerState), hybrid normalization:
- Normalized to
f32: triggers ->0.0..=1.0(Gordonu8/255, Neptunei16/32767); sticks & pads ->-1.0..=1.0(i16/32768). The u8-vs-i16 trigger divergence disappears here - the unified struct is device-agnostic. Divisors are provisional - the 1.4 resolution constants hint some axes may not span full i16 range; verify actual full-scale per axis/device on hardware (1.9). - Kept raw
i16: accel, gyro, quaternion - normalizing to-1..1would destroy their physical scale, so they pass through raw with documented scale factors. One sign fix: Gordon's raw gyroy(roll) channel is mounted inverted, so the convert negates it to land in the unified right-handed IMU frame (X=right, Y=forward, Z=up;x=pitch,y=roll,z=yaw rate) - HW-verified, 1.9. Accel passes through unchanged (already right-handed). Neptune's IMU sign/axis map is a separate, still-unverified question (different sensor layout). - Buttons: per-device
GordonButtons/NeptuneButtonsfold into the unifiedButtonssuperset; buttons a device lacks are simply never set. - Absent inputs zeroed: e.g. Gordon has no right stick ->
right_stick = (0, 0).
Error type. A steam_hid::Error (thiserror) is part of the public surface: at least
HID/IO failure, transport-gone disconnect, unsupported device, and a framing/short-read
variant. Timeout is not an error - it is Ok(None) from poll_*. Controller disconnect
is not an error - it is a Report::Disconnected value.
Traits, derives & serde. Public value / state / event types derive
Debug + Clone + PartialEq - Clone (never Copy; clone explicitly where a copy is
wanted) and PartialEq/Debug for golden-test assertions and for holding a prev. An
optional serde feature derives Serialize/Deserialize on ControllerState,
Report, Buttons, and the value types - needed by the dump trace format (1.8) and the
6 network path; off by default so non-serializing consumers don't pay for it.
Button / axis taxonomy. Button (enum, one variant per bit) and Buttons (bitflags)
are two views of the same unified superset (listed in 1.4); Axis enumerates the
normalized analog channels - both triggers, both sticks x/y, both pads x/y + pressure. Both
are fully enumerable now (no hardware needed).
-
HID backend:
hidapi(input viaread_timeout, commands viasend_feature_report/get_feature_report). We target USB only for now (wired + wireless dongle); Bluetooth is deferred (see 1.9), buthidapikeeps the door open with no API change. The backend is a concrete internal wrapper (HidapiDevice), not exposed in the public API; a rawhidraw/nusbLinux backend, if ever needed, would be a compile-time swap - no runtime abstraction (trait/dyn) is carried speculatively for it. -
Interface topology &
enumeratefiltering (important). Each Steam device exposes several HID interfaces, only one (or a few) of which is the real gamepad; the others are the lizard-mode emulated mouse/keyboard. Fromhid-steam.c:- Wired Gordon (
0x1102): 3 interfaces -0emulated mouse,1emulated keyboard,2the real gamepad. - Wireless dongle (
0x1142): 5 interfaces -0emulated keyboard,1-4the up-to-4 gamepad slots.
So
enumerate()must filter to the gamepad interface(s) and drop the emulated ones, otherwiseopen_first()grabs the mouse interface and reads nothing useful. The kernel's discriminator is "the interface that has a feature report"; viahidapithe practical filter is the Valve vendor usage page (0xFF00-range) / interface number, both exposed onhidapi's device info.DeviceInfo.interfacecarries the number. - Wired Gordon (
-
Dongle slots are separate interfaces, not a multiplexed stream. Each wireless slot (
1-4) is its own HID interface -> its ownhidapipath -> its ownDevice, with its own connect/disconnect frames and input stream. "Which slot" == "which interface you opened"; there is no phantom-slot bookkeeping - an idle slot simply never emits a connect. (Multi-slot use is still deferred per 1.9; this just records how it actually works.) -
Linux
hid-steam: the in-kernel driver claims the device. Getting raw access may require a udev rule and/or unbinding the kernel driver. Spike this first. Ship an exampleudev/69-steam-hid.rulesgrantinguaccessfor the VID/PIDs above. -
Deviceownership &Send. ADeviceis self-contained - it owns (anArcto) the HID context rather than borrowingManager, and isSendso the 4 manager can move one onto its own worker thread (thread-per-device). It is notSync(single-owner per thread), which is all the thread-per-device model needs. -
Device lifecycle -
openandDrop.- On
open()of a wireless Device, request connection status (ID_DONGLE_GET_WIRELESS_STATE0xB4) - the kernel does exactly this ("useful if this driver is loaded when the controller is already connected"). Without it, a controller that was already connected before we opened sends no fresh0x03connect frame, sois_connected()would sit false and we'd wait forever for a connect that already happened. Opening prompts the current state. - On
Drop, restore lizard mode (best-effort). Otherwise, when the mapper exits, the controller is left with mappings cleared -> dead (does nothing) until replugged. Default is restore-on-exit, not leave-raw. (No keep-alive thread to join - see 1.6.)
- On
-
Reads are internally timeout-based (cooperative shutdown). Even the "blocking"
read()loops over short-timeout reads checking a shutdown flag - a thread parked in a truly infinitehidapiread can't be cancelled, soDrop/ the 4 manager tearing down a worker would hang. This holds regardless of which keep-alive sync option (below) wins. -
Concurrency - the keep-alive vs. reader question (RESOLVED: option C, at the engine layer). Blocking reads run on the owning thread. The Neptune lizard-off keep-alive (1.4) must send a feature report ~1 Hz while a read is in flight, and both touch the same handle - this was the one real synchronization question, and it is Deck-only: Gordon has no keep-alive, so it is lock-free and thread-free (direct blocking/timeout reads + direct writes). The three options weighed were: A. single handle +
Mutex+ timeout reads; B. a dedicated write fd (needs a Windows-exclusive-open spike); C. a single IO thread owning the handle, interleaving keep-alive with reads/writes. Outcome: C won, but realized one layer up - steam-hid stays thread-free and stays the single-writerDevice; the engine reader thread (4) is the IO thread that owns the handle and interleaves the ~2 s keep-alive with its poll/rumble/click writes. So steam-hid needs no lock, no second fd, and no internal thread; the keep-alive cadence and its Neptune gate live in the engine (ReaderCfg::keepalive), documented onset_lizard_mode.
hidapi (HID access), bitflags (button set), thiserror (error types). Optional:
serde behind an off-by-default serde feature (for trace + network serialization,
1.5). Examples only ([dev-dependencies]): ratatui + crossterm for the TUI.
Two, both living inside the steam-hid crate initially as examples/ (their deps
come from [dev-dependencies] and do not leak into the library's public dependency
graph - unlike src/bin/). Run via cargo run --example dump / --example tui. Split
the TUI into its own workspace crate later only if its dependency footprint grows.
dump- prints controller state changes to the terminal as they happen; can also send a few commands (toggle lizard/gyro, rumble, power off) for manual testing, including firing each candidate haptic packet (0x8f/0xea/0xeb) so we can compare them during verification (1.9). Optionally records input traces to a file, reused as golden fixtures forenginetests (4). Traces must record each frame's arrival timestamp (theControllerState.timestamp, stored as elapsed-from-start so it's serializable) -seqalone gives ordering, not the wall-clock spacing that timing-based activators (long-press, double-tap) need to replay faithfully.tui- a liveratatuidashboard of the full controller state (buttons, pads, sticks, triggers, gyro), same command controls.
Device scope & extensibility.
- New Steam Controller (post-2023): out of scope - no hardware, no reference, will
not implement. But keep the design ready for it:
DeviceKind,RawReport, and the button/axis mapping must be extensible (add a variant + a reader) without reworking the public API. Mark the extensible public enums#[non_exhaustive]-DeviceKind,RawReport,Report,Event,Button,Axis, andError. 0x12f0("SteamOS handheld"): identity unconfirmed - ignore for now.
Transport scope & development order.
- Implementation is written for all supported devices in parallel, but development &
verification proceed in order: primary dev target is Gordon + wireless dongle
(
0x1142). Once that works, connect and test wired Gordon (0x1102), then Steam Deck (0x1205). - Bluetooth: deferred. The dev device has no BT firmware and it isn't needed now (maybe later). USB only for the foreseeable work.
steam-hid API scope (decided).
- Multiple devices: the
ManagerAPI is designed for multiple controllers from the start (enumerate -> list); we only test single-device for now. Multiple wireless slots per dongle (up to 4) are physically separate HID interfaces (1.6), each its ownDevice- so multi-controller support is nearly free; only its testing/UX is deferred.ID_DONGLE_GET_CONNECTED_SLOTS(querying slot occupancy) is deferred. - Hotplug: handle the controller's own
CONNECT/DISCONNECTframes (the dongle sends them; 1.5 disconnect semantics) + reconnect on the sameDevice/interface. OS-level USB hotplug monitoring (a whole interface appearing/vanishing, e.g. dongle plugged in) is deferred - decide when doing the wired setup (fiddly cross-platform).
Haptics - Gordon RESOLVED; Deck HW-explored (this session).
- Gordon (
0x8fTRIGGER_HAPTIC_PULSE): VERIFIED via thehapticexample (escape hatch -> raw packets). The original SC has only this one haptic path - it drives the two trackpad actuators (no rumble motors). Findings: wire pad 0 = RIGHT, 1 = LEFT (the kernel's "legacy swap"pad ^= 1; ourMotor::Right->0/Left->1matches). Params areduration/interval(us on/off -> tone) andcount(pulses -> length). The kernel's 8-byte variant adds a trailinggainbyte (dB, -24..+6) - ignored on Gordon (no audible difference across the range), so the C# 7-byte and kernel 8-byte forms are functionally identical here.Device::haptic_pulse()(renamed fromrumble()this session) sends the kernel 8-byte form (gain honored on Deck, inert on Gordon).pad=2(BOTH) is NOT a valid wire value on Gordon - it no-ops; "both pads" must be two pulses (wire 0 + wire 1). - Gordon haptic parameter map (verified via
hapticsweep). The pulse is a square wave: freq ~1e6/(duration+interval)Hz, on/off ratio = duty cycle,countxperiod = length.- Frequency = rumble<->tone: <= ~130 Hz feels like rumble; ~350 Hz is transitional (more sound than rumble); 600-1000 Hz is an audible tone (still faintly felt). So game rumble -> Gordon is a low-frequency drive (~30-130 Hz).
- Duty cycle = crude amplitude (since
gainis inert): more on-time = stronger, but non-linear and saturating - a clear jump 10%->50%, much less 50%->90%. - Effective strength - why per-profile
strengthcan exceed 100 % (engine mapping, HW-found). Two compounding attenuations make raw game rumble feel weak on Gordon: (1) the pad saturates at ~25 % duty, so the engine maps full drive onto onlyRUMBLE_MAX_DUTY = 0.25of the period (the honest ceiling - above it feels identical); and (2) games under-drive their FF - HW logging caught the test game peaking at only ~25 % of theu16FF range (raw ~16381/65535), so its "full" rumble reached ~ 0.25 x 0.25 ~ 6 % duty. So per-profilestrengthis au8gain that may exceed 100 % (up to 255):200 % multiplies such a game's drive back up so it can saturate up to ~0.50 of the range (12 % duty - bumping its weak low forces), while the drive still clamps atu16::MAXso it can only reach, never overshoot, the 0.25-duty ceiling. Attenuation is now per-device (Gordon'sdutylever, replacing the dropped globalmaster_rumble). This lives in the engine mapping (runtime/reader.rsRUMBLE_MAX_DUTY+mapping.rsscaling), not steam-hid - the why, since it's easy to misread as a bug. - Future tuning (deferred): the Xbox360-strength -> Gordon-drive mapping should be non-linear, and left/right actuators differ in hardware characteristics (one may be more linear), so calibrate the two pads separately. Fine-tuning for the engine/mapping stage, not steam-hid.
0xebTRIGGER_RUMBLE_CMD: does nothing on Gordon (confirmed) - the kernel gatesFF_RUMBLEonSTEAM_QUIRK_DECK, and the original SC has no rumble motors. Deck-only.- Deck haptics - HW-EXPLORED (this session;
0xebwired into the engine, tuning ongoing). Three packets, characterised on a real Deck via thehapticexample:0x8f(trackpad pulse) works well on BOTH controllers. On the Deck itsgainbyte IS honored (ignored on Gordon), so there amplitude = gain (Gordon amplitude = duty). Feel: 150 Hz is nice across most gains (right pad weaker at low gains, comparable higher); 60-90 Hz feel weaker at low gains (need more gain to register). Useful gain ~ -16...+6 dB (~0-2 dB already strong, +6 shakes the whole unit).- The re-fired short train feels slightly jerky on BOTH controllers - each re-fire
(
RUMBLE_REFIRE_MS= 220 ms) restarts the actuator -> a ~4.5 Hz amplitude beat (previously mistaken for Gordon's character; it's the re-fire). - "Longrumble" = ONE very long train (huge
count, no re-fire) is smooth on both (rings up once, then holds). Stopped withcount = 0(HW-confirmed on Gordon AND Deck;count = 1also works - a single imperceptible tick that ends). A viable future alternative to the re-fire model; if adopted,RUMBLE_MAX_DUTYneeds re-tuning (a full-ring-up train is much stronger, so the 0.25 / 200 % values - which partly compensated for the under-ringing short train - would drop), possibly per-controller/Shape. A first engine cut was reverted (multi-rumble stop/interleave bugs) and parked on a branch. 0xebTRIGGER_RUMBLE_CMD= the Deck's real dual motors (Device::rumble_cmd; the kernelFF_RUMBLEpath; Deck-only, no-ops on Gordon). Character: inherently pulsating (matches Steam's own in-game feel). Field semantics (HW-found):left/right"speed" = pulse RATE, not amplitude - higher value = faster pulsing (a pulse frequency, NOT a rumble frequency);gain= amplitude/strength (clean, monotonic, felt symmetrically on both motors -> we drive both at +2 dB, dropping the kernel's asymmetric +2/0). Each command is a fixed ~0.5 s burst with no length field (measured consistent from ~10 % to ~100 % strength over several seconds), so a sustained rumble must be re-issued - the engine re-fires everyNEPTUNE_REFIRE_MS(500 ms, HW-tunable) while non-zero + on change;(0,0)stops it. The leadingintensityu16 is a SECOND amplitude lever (HW-found) - a finer control than the coarse dBgain(gain's -16...+6 dB ~ ~20 steps;intensityis 16-bit), changing only amplitude (not pulse rate / length / character). It is inverted:0= strongest, larger = weaker (gets weaker ~256, usable to ~16k, ~unfelt nearu16::MAX- a faint tingle). Now a param onDevice::rumble_cmdbut passed 0 (strongest) everywhere - plumbed, not yet a mapping lever (a future controller-strength -> rumble map could use it for fine resolution).haptic --eintsweeps it.0xeaSET_HAPTIC2= a short, finely-tuned trackpad "click" (Device::haptic_cmd,HapticStyleDisabled/Weak/Strong +gaini8; Deck-only, HW-tested viahaptic --ea). Earlier called "known wrong", but the C# app uses it exclusively on the Deck and it works great:stylexgain(dB, C#'s -7..5 => -2..+10 dB) give a wide range of click strengths (Weak < Strong at the same gain;Disabled= off), and the strongest beats a full0x8fcommand click - so on the Deck this IS the command-haptic click now (see below). HW-found: its two motors are the REVERSE of the0x8fwire pads, sohaptic_cmdmapsMotor::Left -> 0,Motor::Right -> 1. Thegainparam is C#'sNCHapticPacket2.intensityfield, renamed for consistency with the other haptic gains; the packet's other bytes (C#'sunsure2/unsure3, two timestamp words) stay unverified/unused - filled with C#'s fixed values + a current-ms tick, not exposed.- Command-haptic clicks map per-device from a strength level. The mapping loop sends the
Low/Med/Highlevel to the reader (not a resolved duration - dropped the master-rumble scaling of clicks; a discrete tick, and game rumble still scales), and the reader resolves it: Gordon =0x8fpulse whose duration encodes strength (500/1000/2000 us; gain inert -> 0); Deck = the0xeahaptic_cmd(Strongstyle) whose gain encodes strength, per side (the two motors differ - left -4/0/4, right -2/2/6 dB). Replaces the old flat per-controller gain (0/+6).
Verification status (Gordon). The full Gordon input parse is hardware-verified
on both the wireless dongle (0x1142) and wired (0x1102): buttons (all, correct bit
map), triggers (u8/255 reaches 0..1), pads & left stick (i16/32768 reaches +/-1,
correct directions), the left multiplex (the pad and analog stick share lpad_x/y
0x10, disambiguated by LPAD_TOUCH - pad when touched, stick when not - the same on wired
and wireless; 0x36 is not the stick (0 on wireless, ~500 noise on wired); the left
click and touch buttons are also multiplexed and are de-muxed against engaged =
LPAD_TOUCH || LPAD_AND_JOY - see the dedicated multiplex bullet below),
and IMU (accel reads 1g gravity at rest -> ACCEL_RES_PER_G=16384 confirmed; gyro rests
~0 and scales sensibly -> GYRO_RES_PER_DPS=16 confirmed). Enumerate + interface filtering
(wired iface 2; dongle slots 1-4, both drop the emulated ifaces) and the connect/disconnect/
battery lifecycle frames are confirmed. Wired sends no battery (0x04) frame - expected
(USB-powered; the kernel registers battery only for wireless).
- IMU axis/sign conventions: RESOLVED (Gordon). Verified by the 6-pose gravity method
(accel) + single-axis rotations (gyro). The device frame is right-handed
X=right, Y=forward (toward the nose), Z=up (out of the face). Accel reads+1gon the up-axis and passes through raw (already right-handed: face-up+Z, nose-up+Y, right-side-up+X). Gyro channels are axis-aligned with accel - rawx=pitch (about X),y=roll (about Y),z=yaw (about Z) - not the C#pitch/yaw/rollfield order (C# transposed yaw/roll; offsets are right, names lie). Right-hand-rule signs: pitch-up+x, yaw-left+zare already correct, but roll-right reads-y(the raw gyroyis inverted), so the convert negatesyto make(wx, wy, wz)a proper right-handed vector. Neptune IMU axis/sign: RESOLVED (2026-08-06). Compared theimuexample's converted accel + gyro on a real Deck against Gordon - all 3 accel axes/signs and all 3 gyro axes/signs match. So Neptune's raw IMU already sits in the unified right-handed frame Gordon reaches after itsy-negation:from_neptunepasses IMU through raw with nogordon_gyro-style correction, confirmed correct (Neptune's gyroyis mounted the proper way round, unlike Gordon's).
Other open questions (need hardware verification).
- Battery source: RESOLVED. The out-of-band
0x04frame is authoritative on the wireless dongle - voltage at offset0x0C, charge % at0x0E(kernel offsets; verified:2743 mV, 92%). The inlineGCInput @0x3Efield the C# reads is vestigial (always0on the dongle), so it is not decoded. (Note: offset0x04of the0x04frame is the seq/timestamp, not voltage - an early mis-decode read it and saw a climbing counter.) - Normalization divisors:
i16/32768(sticks/pads) andu8/255(Gordon triggers) confirmed on Gordon (full +/-1 / 0..1 reached). Neptune triggersi16/32767and pad pressurei16/32767also confirmed on a real Deck (norm_i16, full 0..1 reached). - Gordon wired vs wireless: RESOLVED. They share the same input layout, including
the left pad/stick multiplex on
0x10(the earlier guess that wired had a separate0x36stick was wrong -0x36is noise on wired).parseis not transport-aware. Only difference confirmed: wired sends no0x04battery frame. (The C# reference carries a// TODO: this logic is for wireless, wired should report those directlynext to the multiplex - an unverified guess that our hardware test disproved; the multiplex is not a transport quirk.) - Multiplex is Gordon-specific - Neptune does NOT multiplex (from C# ref). Neptune
(
NeptuneControllerInputState) has dedicated separate axis fields -lthumb_x/y(left stick) andlpad_x/y(left pad), plusrthumb_x/y/rpad_x/yand pad pressure - all populated unconditionally, no touch-gating. The touch-gated left pad/stick share on0x10is unique to Gordon (which has only a left stick + two pads; no right stick). The Neptune parse (parse_neptune/from_neptune) has since landed and is HW-verified on a real Deck (wired0x1205): it reads the separate stick/pad fields directly (noLPAD_TOUCHmultiplex), plus pad pressure and the firmware-synthesized L2/R2 full-pull bits. - Dpad note (not a bug): the left pad reports dpad-quadrant bits in every raw report (firmware-derived from click position), regardless of lizard mode, alongside the raw pad x/y. The parser exposes both; choosing dpad vs. analog is the mapper's job (4). Confirmed identical over Bluetooth (dpad bits fire from left-pad directional clicks alongside the raw pad coords).
- Left click/touch multiplex: RESOLVED (HW-verified, dongle - via a raw-bit probe). The left
pad and stick share not only
lpad_x/ybut a click bit. Probed raw: a stick click raisesLPAD_PRESS(bit17)+LSTICK_PRESS(bit22) with no touch; a pad click raisesLPAD_PRESS+LPAD_TOUCH(bit19); both together holdLPAD_AND_JOY(bit23) set and flickerLPAD_TOUCHframe-to-frame (the per-frame axis tag - sc-controller'sSTICKTILT). SoLPAD_AND_JOYis NOT unused (earlier assumption corrected) - it's the both-engaged flag.parse_gordonde-muxes the buttons against engaged =LPAD_TOUCH || LPAD_AND_JOY:left_pad_touch = engaged,left_pad_click = LPAD_PRESS && engaged,left_stick_click = LSTICK_PRESS(ungated) - matching the kernel'sBTN_THUMB = lpad_touched || lpad_and_joy. Kernel and C# disagreed (kernel reads only bit22 as the left click and ignores bit17; C# gates bit17 on touch) - the probe settled it in C#'s favour, now HW-verified, not inherited. Buttons are fully clean on both transports; only X/Y stays time-multiplexed (single coord field - not separable per-frame without retention; accepted, and BLE has no multiplex at all). - Bluetooth (BLE) Gordon
0x1106: HW-verified (RESOLVED). Same commands as USB, segmented Report-ID-3 transport + compact delta-input format (see "BLE transport", 1.4). Verified: input, buttons, dpad (left-pad clicks), IMU axis/sign,0x8fhaptics, and the full daemon path (-i bt) including a quick in-game test. IMU raw == USB Gordon raw (samegordon_gyroy-negation via the Gordon path). No left multiplex (separate stick/pad chunks). Button bit layout identical to USBGordonButtons. Orientation quaternion is available only withSEND_ORIENTATION; we run0x18(accel+gyro), leaving it off/unused. Keep-alive not needed (stayed alive well past Neptune's ~10 s revert during play; BLE is excluded from keepalive, which stays Neptune-gated). - Gordon USB/BLE path convergence - DONE (landed with the button naming unification). USB and
BLE Gordon now share one
GordonButtons/GordonReport/from_gordon/map_gordon_buttonsand emitRawReport::Gordon; the separateGordonBle*types were deleted. Parsers stay separate (the wire genuinely differs: 64-byte USB frame vs segmented BLE delta) but the conversion converged - the USB left multiplex (coords and click and touch) is absorbed entirely inparse_gordon, so the shared fold is a plain 1:1 map and BLE (no multiplex) never runs it. - Auxiliary output commands (Gordon): mostly RESOLVED (via the
auxexample).set_led_intensityverified (Steam-button LED: 0=dark, 50=dim, 100=default full; regLED_USER_BRIGHTNESS=45).power_offverified - the controller powers off and the dongle delivers aDisconnectedvalue (not a readErr, since the dongle transport stays alive; the two-flavor disconnect model, 1.5, confirmed on hardware).set_idle_timeout(regSLEEP_INACTIVITY_TIMEOUT=50, seconds) writes byte-identically to the verified C#WriteRegisterpath (0x87, 0x03, 0x32, lo, hi) - but physical auto-sleep was not reproducible at 60/120/300 s under a continuously-reading host (the controller streamed a0x04battery frame every ~1 s and never slept; no reconnect occurred). Command bytes are correct; sleep appears gated by host activity/firmware conditions. Note the C# reference re-assertsSetIdleTimeout(+ lizard + gyro) inside its CONNECT handler - the controller likely resets these on each wireless (re)connect, so the engine's connect-handling should re-apply config onConnected; revisit idle behavior then.set_lizard_mode/set_gyroare exercised and known-good (raw pads + IMU data work).
Deferred implementation decision (see 1.6).
- Keep-alive vs. reader handle synchronization (Deck-only): choose A (mutex + timeout), B (dedicated write fd), or C (single IO thread) once the Deck is in the loop. Gordon ships lock-free/thread-free until then; the choice stays contained in the internal backend/device layer.
(Resolved: feature-report set/get behavior, report-ID-0 framing, and wireless EPIPE
retry are confirmed against the hid-steam kernel driver - see 1.4.)
(Recorded here as a future-work reference. Steam exposes far more than we do;
captured for later. All 3 controllers unless noted. We currently only write feature reports -
every "get/check" below needs a get_feature_report round-trip helper. Command/setting IDs live in SDL
controller_constants.h + kernel hid-steam.c enums; a subset is mirrored in protocol.rs. NOTE:
GET round-trips do NOT work on Triton - its feature channel is effectively write-only, replies come
over the interrupt-IN stream with un-RE'd framing; see Device::get_feature_report.)
- Audio feedback - RESOLVED 2026-08-30 (
PLAY_AUDIO 0xB6DROPPED); the audible-tone HAPTIC paths were then HW-characterized in depth 2026-09-01/02 (see the beep block below). Two separate things:- Rich audio (
PLAY_AUDIO 0xB6) = ABANDONED, no reference.0xB6is defined in every reference (SDL/kernel/C#/sc-controller) but SENT by none and has no payload struct at all. Its slots (0 startup..4 identify..6 normal, max 15) stay empty until Steam uploads a blob overAUDIO_UPDATE_START/DATA/COMPLETE 0xB7-0xB9+SET_AUDIO_MAPPING 0xC1- HW-confirmed:0xB6is silent until Steam pings the pad, then slots 4/5/6 play rich sounds (uploaded, RAM-resident, gone on restart). Blob format undocumented -> would need a USB capture. Not worth it; dropped. - Simple audible feedback (beeps/tones on layer/mode change) = the HAPTIC actuator, not audio -
HW-EXPLORED IN DEPTH (this session). Three per-device command paths, one parallel
examples/beep*suite each; all three back ontoDevicemethods. (Gordon has no speaker; the trackpad voice-coils/LRAs are the "speaker". Kernelsteam_do_deck_inputplays the mode-switch notes on0x8f- a right-pad ack tick then a left-pad note ~1502 Hz on / ~1000 Hz off.)- Gordon -
0x8fTRIGGER_HAPTIC_PULSE(haptic_pulse), examplebeep. Broadband voice coils reproduce arbitrary tones faithfully. A tone isduration == interval(50%-duty square wave,f = 1e6/(dur+interval),countcycles). Pitch is a clean continuous lever up to a high ceiling (~4-5 kHz); amplitude = DUTY CYCLE (gaininert on Gordon). Same primitive as Gordon's (low-freq) rumble. Gordon has no other haptic path. - Deck -
0xEASET_HAPTIC2(MsgTriggerHaptic), examplebeep-neptune. The Deck's LRAs are resonant, so the0x8froute collapses off-resonance (only ~5-6 usable pitches, coarse plateaus).0xEAis a firmware-SYNTHESIZED engine (SDLhaptic_type_t) and IS the real Deck beep path:cmd=Tone(haptic_tone(side,freq,dur_ms,gain,lfo_freq,lfo_depth)) - WORKS. Tracks pitch cleanly from ~200 Hz to a ~2 kHz ceiling (higher = silent); no resonance collapse.cmd=LogSweep(haptic_logsweep(side,start,end,dur_ms,gain)) - WORKS, glides start->end; the nicest-feeling cue.- Levers:
dbgain(i8) is the amplitude lever (~8 dB good,0too quiet);ui_intensityis INERT for a Tone (a click-only lever, HW-confirmed). cmd=Noise- tested, DROPPED. Fires but is rumble-only (~Click/Insane), moved ONLY bydbgain;noise_intensity/rand_tone_gaininert. Not a sound.lfo_freq/lfo_depth(LFO = low-freq oscillator, tremolo/texture) - WORKS but marginal. Audibly makes a difference on a Tone, subtly; NOT yet a predictable/consistent lever.lfo_freqis au16whose character keeps shifting well above ~64.cmd=Rumble/Script- UNTESTED (0xEBcovers rumble; script needs a firmware preset id).- Clicks (Tick/Click x
ui_intensity,haptic_cmd) remain the reference-proven click path.
- Triton - output reports
0x80-0x85(interrupt-OUT, not feature reports), examplebeep-triton. Near-1:1 with the Deck's0xEA, but each primitive is its own report id:0x83 LfoTone(lfo_tone_triton(side,freq,dur_ms,gain,lfo_freq,lfo_depth)) - WORKS, the Triton tone path. Pitch tracks to a ~1.9 kHz ceiling; 2 kHz+ is silent or repeats lower pitches.dbgainamplitude (~8 default, a guess); LFO audible but subtle/not a consistent lever (as Deck). The one primitive that carries the LFO as a first-class report.0x84 LogSweep(logsweep_triton(side,start,end,dur_ms,gain)) - WORKS, glides like the Deck's.0x81 Pulse(pulse_triton,MsgHapticPulse, structural Gordon-0x8fanalog) - USABLE both ways (first read as "erratic" while chasing beeps, but that was the wrong lens - it's a rumble, not a tone). As a train (repeat_count>1,beep-style freq sweep) it's a pulse/rumble usable across the range, with a narrow ~600-700 Hz band that oscillates oddly (the one sign that spooked the first look) - the rest is clean; NOT a clean tone path, but good as a rumble. As a single pulse (repeat_count=1) it's a discrete click, width (on_us) = strength, finer than0x82. LEFT/RIGHT are physically SWAPPED (like Gordon's0x8f; the motor/0x80and0x82sides are not); BOTH works. Probed inhaptic-triton(pulsetrain /clicks-pulsesingle).0x82 Command(click,haptic_command_triton,HapticStyleoff/weak/strong) - proven (the reader's shipped click; only 2 distinct strengths,amplitudeinert - the0x81single-pulse click above is the finer-grained alternative).0x85 Script- untested/ignored.
- Tone pitch ceilings (HW): Gordon ~4-5 kHz * Neptune ~2 kHz * Triton ~1.9 kHz.
- Shared example command set (beep-style positional modes): common -
kernel/pattern/feedback/note <hz> [ms]/sweep/fine(500..3000, 100 Hz steps) /gain/melody/vader; device tails -beep:dutyringdown;beep-neptune:chirplfodurclicks;beep-triton:chirplfoclicks.melody/vadernote tables are shared inexamples/common.
- Gordon -
- Design direction (NOT built - deferred): a device-independent "feedback sound" = a small
pattern of
(pitch, duration, gap)steps rendered per device (Gordon rich/continuous; Deck & Triton on the firmware Tone up to their ceilings; portable lever = rhythm, pitch a bonus). LogSweep is a good enter/exit-cue primitive on Deck+Triton. Wiring intoconfig/enginedeferred.
- Rich audio (
- Reference-backed (implementable directly):
- Gordon-dongle pairing: SDL
SDL_hidapi_steam.c-ENABLE_PAIRING(0xAD)=[0xAD, 2, enable(0/1), duration_s], thenDONGLE_COMMIT_DEVICE(0xB3)=[0xB3, 0]. Flow: enable -> controller announces (wireless status) -> commit to accept. - Any device setting (set+get):
SET_SETTINGS_VALUES(0x87)(used) +GET_SETTINGS_VALUES(0x89), shape(id:u8, val:u16). ~80 settings (trackpad sens/noise, touch thresholds, click pressures, stabilizer, LED, idle, momentum...).GET_SETTINGS_MAXS/DEFAULTS(0x8B/0x8C)= valid ranges (basis for a Steam-like settings UI). - Read-only queries:
GET_DEVICE_INFO(0xA1),GET_ATTRIBUTES_VALUES(0x83),GET_CHIPID(0xBA),GET_STRING_ATTRIBUTE(0xAE)= the real serial getter (Gordon dongle reports empty ->None; this could fill that gap). All implemented for Gordon/Neptune (examples/getters.rs); Triton stalls (write-only feature channel).
- Gordon-dongle pairing: SDL
- Probeable without a dump (command known, payload trivial/none):
- Trackpad calibrate:
CALIBRATE_TRACKPADS(0xA7)- likely a no-payload trigger. - Gyro calibrate:
CALIBRATE_GYRO(0xB5)/RESET_IMU(0xCE)- likely no-payload "calibrate at rest" triggers. - Gyro drift CHECK/CALIBRATE (host-side): read gyro at rest, average -> bias; subtract in the mapper (we already have a small drift deadzone; this would make it a proper offset).
- Trackpad calibrate:
- Needs a USB packet dump (unknown multi-byte payload / risky):
- Custom audio/music upload:
AUDIO_UPDATE_START/DATA/COMPLETE(0xB7-0xB9)+SET_AUDIO_MAPPING (0xC1)- blob-upload protocol, unknown framing. - Remove/list pairings:
DONGLE_DISCONNECT_DEVICE(0xB2),DONGLE_GET_CONNECTED_SLOTS(0xC4)(the read may be probeable = slot bitmask). - Trackpad calibration tables:
GET_TRACKPAD_CALIBRATION/_FACTORY/_RAW(0xAA/0xAB/0xAC). - (!)
RADIO_ERASE/WRITE_RECORD(0xAF/0xB0)~ FIRMWARE UPDATE - DO NOT TOUCH. Also avoidFACTORY_RESET(0x86),SET_SERIAL_NUMBER(0xA9).
- Custom audio/music upload:
- Triton caveat (the weak spot): Triton uses a different settings/config framing (
sc2.py:0x87+configTypebytes, not the flatSETTING_*indices) -> settings surface known-good for Gordon/Neptune, probe to confirm on Triton. Triton puck pairing = a different v2 protocol (sc-controller captured but did NOT decode:user/wireless_transport,esb/bond, opcodesED/AD/DC/E2) -> dump. Grip-touch sensitivity/flicker is Triton-new HW (noSETTING_*in the v1 enum) -> probe or dump.
Purpose. The output HAL - the symmetric peer to steam-hid. Emits virtual input to
the OS: mouse (move/buttons/scroll), keyboard, and gamepad. engine produces abstract
output events; virt-out implements the sink that realizes them per platform.
Platform code is isolated behind a compile-time-selected Sink (a per-OS concrete type,
cfg-picked - no trait), exactly like steam-hid's concrete HID backend - engine,
config, etc. stay platform-agnostic.
Discussion (why a separate crate). The mapper has two device-facing layers, not one.
Output emission (uinput/ViGEm/CoreGraphics) is as large and as platform-specific as HID
input, and must not live inside engine - otherwise the mapping core becomes
platform-locked and hard to test. Extracting it keeps engine a pure function of
(input + config) -> output events, testable without an OS. Decided: add virt-out.
Backends.
- Linux (primary):
evdev/ uinput. Crate:evdev(hasuinputvirtual-device support; most widely-used/maintained Rust evdev binding; user has used it). Create virtual keyboard/mouse/gamepad via/dev/uinput- needs a udev rule / group, the same permissions story assteam-hid(track together). - Windows - DONE & in-game validated.
SendInputfor mouse/keyboard; the virtual gamepad via a compile-time-selected controller backend (see 2.1): ViGEm (vigem-client+ ViGEmBus driver, proven) or VIIPER (Virtual Input over IP Emulator - USB/IP, experimental), or neither (kb/mouse-only build). Both driver stacks are the user's. - macOS: plan only - keep the path open (CoreGraphics/IOKit), no implementation yet.
Crate shape. virt-out exposes a platform-agnostic sink the engine drives, plus an
owned output vocabulary (Key / MouseButton / GamepadButton / GamepadAxis enums -
the set of things that can be emitted). Platform code sits behind a per-OS Sink selected
at compile time by cfg(target_os) (a concrete type, not a trait; Linux uinput; Windows
SendInput + a controller backend), mirroring steam-hid's concrete HID backend. On Windows the
controller (virtual-gamepad) backend is
a further compile-time, mutually-exclusive choice (ViGEm / VIIPER / none - see the Windows
block below); kb/mouse is backend-independent. Sync, no async (emit is a syscall on the engine's
mapping-loop thread). Update (3, DONE): the output vocabulary was extracted into a new
bottom vocab crate (now built) shared by virt-out + config (so config need not
depend on virt-out); the input vocabulary is config's own, hardware-independent.
Output model - imperative event stream, not desired-state. The engine is level-driven and
already reconciles desired-vs-applied output (4), so virt-out stays a thin realizer: it
takes a batch of OutputEvents and flushes (SYN). Levels: Key(k, down), MouseButton,
GamepadButton, GamepadAxis(axis, value). Deltas: MouseMove{dx,dy}, Scroll{dx,dy}.
The engine holds "applied" state and sends only changes -> virt-out is dumb, the engine stays
golden-testable on the emitted stream.
Linux backend - 3 separate uinput devices (decided): keyboard (EV_KEY), mouse (EV_KEY buttons + EV_REL X/Y/WHEEL/HWHEEL), gamepad (EV_KEY + EV_ABS). Cleaner identity than one combined device.
Gamepad = Xbox 360 (decided). A uinput device is an X360 pad via (1) input_id:
bus USB, vendor 0x045e, product 0x028e; and (2) the xpad code set: buttons
BTN_A/B/X/Y, BTN_TL/TR, BTN_SELECT/START/MODE, BTN_THUMBL/THUMBR; axes ABS_X/Y +
ABS_RX/RY (sticks, i16), ABS_Z/RZ (triggers 0..255), ABS_HAT0X/Y (dpad -1..1), each with
an AbsInfo. SDL2 derives a GUID from bus+vendor+product and applies its built-in X360
mapping -> games see a standard pad; uinput injects at the same layer xpad would (no driver).
Force feedback - advertised now (decided). The gamepad advertises FF_RUMBLE; we read
UI_FF_UPLOAD/UI_FF_ERASE off the uinput fd and translate effect magnitude -> Gordon
trackpad haptics (the verified low-freq 0x8f drive, 1.9). Risk: uinput FF is a
request/response ioctl protocol, not plain event emission - verify evdev 0.13 exposes it
before committing; else a small libc ioctl shim. Spike this early in Phase A.
Vertical slice (both phases):
- Phase A - DONE, hardware-verified. The crate + Linux backend + 3 devices + FF, with a
selftestexample. Confirmed:/proc/bus/input/devicesshows the pad as045e:028e "Microsoft X-Box 360 pad"withjs/event nodes, 8 axes (ABS=3003f) andFF_RUMBLE(EVhas EV_FF,FF=10000); kb/mouse events seen vialibinput(it ignores gamepads - use/procorjstest/evtestfor the pad);jstestshows buttons/axes;fftestuploads only rumble effects (periodic/constant/spring/damper correctly rejected by the kernel) andpoll_rumblereads magnitudes with the correct mapping (Strong/heavy->strong, Weak/light->weak- verified by effect-id tracing). Gotchas fixed: evdev's syncVirtualDevicefd is blocking, sofetch_eventshangs on no FF - set the gamepad fdO_NONBLOCKand treatWouldBlockas "no rumble". FF play tracking is single-slot (last effect wins) - combine simultaneous effects later. (evdev0.13.2 handles the whole uinput FF ioctl protocol; only extra dep islibcfor the onefcntl.) - Phase B - a
bridgeexample (dev-dep onsteam-hid) with a hardcoded mapping: real Gordon -> virtual mouse/keyboard/Xbox pad, and the FF loop back:fftest/a game rumbles the virtual pad -> we drive the real Gordon's trackpad haptics. Proves the whole ports-and-adapters path (incl. the 6-style haptic back-channel) before config/engine/ Windows/Neptune.
Permissions: /dev/uinput needs a udev rule / group - same story as steam-hid's hidraw;
enumerate the exact requirement during Phase A (user handles the Gentoo side).
Windows backend - DONE & in-game validated. Kb/mouse via SendInput with scancode
injection (games read scancodes); the media/volume/browser keys fall back to virtual-key
injection (they have only E0-extended scancodes, which would otherwise alias to letters);
discrete and hi-res smooth scroll both work. The virtual gamepad is a compile-time,
mutually-exclusive controller backend, opt-in - no default, so a plain build is kb/mouse-only
and drops controller output with a warning:
vigem- virtual Xbox 360 pad via the ViGEmBus driver (vigem-client), with the FF (rumble) back-channel. The proven backend; validated in a real game.viiper- experimental, runtime-verified (2026-07-31): pad + rumble in-game, clean Ctrl-C shutdown, server survives + reconnects. VIIPER is not a local driver like ViGEm; it is a USB/IP system with three parts: (1)usbip-win2, a separately-installed USB/IP vhci kernel driver; (2)viiper.exe, a server process hosting a USB/IP endpoint + a TCP management/streaming API that auto-attaches created devices to the local driver; (3)viiper-client, a pure-Rust TCP client (build=false, no linking/DLL/FFI). We create an Xbox 360 device over the API and stream full input snapshots; rumble returns over the same stream. Heavier to deploy than ViGEm (usbip-win2 driver + a runningviiper.exe server), though no password is needed on localhost (we connect plain). Two gotchas found in bring-up: the server's native-IOCTL auto-attach fails on the current driver build and falls back tousbip.exe, which must be on PATH; and the crate's encrypted path deadlocks on shutdown, so we default to a plain (unauthenticated) connection (both reported upstream). Its USB/IP transport is an internal implementation detail of this one backend, NOT deckhand's 6 networking - we run the VIIPER server on localhost and treatviiperas a purely local virtual pad, exactly likevigem. deckhand's own 6 network seam is separate, works for every backend (including ViGEm), and carries rawControllerStatewith the Mapper on the sink; VIIPER's wire protocol is never used for it.
A compile_error! guards against enabling both backends. virt-out keeps no default feature; the
engine (as the app) defaults to vigem and forwards the choice: plain cargo run -p engine =
ViGEm pad, --no-default-features = kb/mouse-only, --no-default-features --features viiper = VIIPER.
Still open / deferred: macOS (CoreGraphics/IOKit, plan only); VIIPER runtime verification
(needs the usbip-win2 driver + a running server). Dependency policy bites hardest here (niche
virtual-pad crates, driver installs) - Windows bring-up confirmed this (ViGEmBus / usbip-win2).
Purpose. The mapping configuration as a first-class, feature-rich concern: a declarative data model of all bindings, its (de)serialization, validation, and compilation into a fast runtime form. Feature target: not full Steam Input, but not far off - action sets, layers (which subsume mode-shift), activators, gyro, trackpad modes, macros.
Status - BUILT. The vocab crate (output leaf enums, optional serde) and the
config crate (data model + serde/RON + validate() collecting all diagnostics, model
rounds A-E) are implemented and in use. compile() -> Program moved to engine (4.1
decision A: the IR is the runtime's contract, so it lives with its consumer - config stays pure
authoring data). Everything below is the design record; it is implemented unless noted otherwise.
Discussion / decision (own crate from the start). The UI needs the config types
(to render/edit bindings) but must NOT need the engine runtime. A separate config
crate means ui depends on config only - not on timers/output-sinks/steam-hid.
Decided: build config as its own crate from the start (rather than extracting it
later); we'd reach that point anyway, so keep it clean from day one.
Crate graph & vocabulary ownership (decided). config is pure data - it must not
drag platform backends (hidapi, evdev, the windows crate) into everything that touches
it, and it must not speak any controller's wire layout. Resolution (the "tiny types crate"
2.1 anticipated):
- A new bottom crate
vocab(no deps) holds the hardware-independent output target enums (Key/MouseButton/GamepadButton/GamepadAxis), extracted fromvirt-out. Shared byvirt-out(realizes them) andconfig(names them in actions). configowns its own hardware-independent input vocabulary (logical controller inputs - pads, sticks, grips, gyro...), NOTsteam-hid's wire-levelButtons. TheControllerState -> logical-inputresolution is the engine's boundary job (fixed per device for now, not user-configurable).- So
configdeps =serde+ron+thiserror+vocab; it links no hidapi/evdev/ windows.ui -> config(+vocab) stays free of those too (in the embedded build the UI still links them viaengine; the win is for config-only consumers, the daemon-mode UI, and clean layering - plus config genuinely must not speak Gordon's vocabulary).
Layering (bottom-up): vocab -> { steam-hid(hidapi), virt-out(evdev/windows, vocab) }
-> config(serde/ron/vocab) -> engine(steam-hid/virt-out/config) -> ui(config [+engine
when embedded]).
Data-vs-behavior + compile step (design I proposed, agreed). Config is data; the engine is behavior. Three layers, like source -> bytecode -> VM:
ConfigDoc- on-disk, user-facing, forgiving format (serde); what the UI edits.- compile / lower - validate references, resolve names->indices, expand sugar, precompute tables.
Program- flattened, index-based, cheap to execute; lives inconfig(it is the output of config's own compile step;enginedepends onconfigto consume it). Carries a small metadata header (name/id) so the engine's status API can report what is loaded - but no file/profile linkage (profiles are purely UI/filesystem).
This keeps a rich, permissive, editable format out of the ~4 ms hot loop and localizes all
validity checks in one testable place, so the runtime can assume a well-formed Program.
compile() is per-profile (one ConfigDoc -> one Program) and collects all
diagnostics (every dangling reference / unknown action) rather than failing on the first,
so the UI can surface every problem at once.
Model sketch (feature-rich target).
ConfigDoc { version, name, action_sets: Vec<ActionSet>, rumble, ... } // ONE profile (device toggles -> DeviceConfig)
ActionSet // full-controller mode; exactly ONE active at a time; `ChangeActionSet` swaps it
Layer // partial overlay; STACKABLE; applied Hold / Toggle / Add / Remove
// one uniform node per top-level control (type name TBD: `InputSource` | `SourceBinding`):
InputSource ::= Button // standalone -> DEGENERATE: no mode, just bindings
| ButtonGroup(mode) // A/B/X/Y & D-Pad clusters; mode: ButtonPad(v1) | DirectionalPad/Joystick(defer)
| Pad(mode) | Stick(mode) | Trigger | Gyro(activation)
// a rich source = mode + Settings + directional bindings + click/touch sub-bindings
Commands // every button-like node -> Vec<Command> (>=1; several activators/commands per node)
Command // { activator, actions: Vec<Action>, settings }
// actions = a combo (1st = command, rest = subcommands); all fire together while the
// command is active - emitted as one level set per tick, NOT sequenced (see Round C)
Activator ::= Regular{interruptible} | Long{ms} | Double{ms} | Start | Release (Chord deferred -> entry gater)
// settings: Toggle, Turbo{rate}, Haptics{activation,strength} (interruptible is on the Regular variant)
Action ::= Key | MouseButton (incl. Scroll Up/Down/Left/Right pseudo-buttons) | GamepadButton
| ChangeActionSet(id) | HoldLayer(id) | Add/RemoveLayer(id) | None
// Macro deferred; MouseMove / continuous-scroll / stick+trigger axes = behavior outputs
Settings // InputProcess: deadzones, curves, sensitivity, invert, gyro activation, per-mode params
Model - Steam-aligned skeleton (part 2, decided; contents pending rounds A-E). Mirror Steam
Input's paradigm and vocabulary faithfully; implement a subset initially; keep every enum
#[non_exhaustive] so deferred modes/activators/actions are additive, not structural. Locked
structure:
- Two-level modes. Action Sets (full-controller swap, exactly one active,
ChangeActionSet) and Action Set Layers (partial overlays, stackable, Hold/Toggle/Add/Remove) - both from the start. The runtime resolution ("active set as base, apply active layer stack") is one path regardless, so Action Sets add little over Layers and retrofitting later would be worse. - Mode Shift - DROPPED (layers cover it). Steam's per-input "mode shift" (an alternate config of
one source, active while a button is held) is a single-input hold-layer - anything it expresses,
a
Layerdoes, and more (stackable, multi-input, toggle/add/remove). Adding it would mean a secondSourceBindingper input + a trigger field - real format complexity for no new capability. So it is not in the model: author the effect as an explicitLayer+HoldLayer(theconfigbridge_profileexample does exactly this). Revisit only if the authoring convenience is ever wanted - it would be a compile-time expansion into a 1-input hold-layer (the "expand sugar" step), changing no runtime. - Uniform node; buttons degenerate. One node type per top-level control; a standalone Button
has no mode (just Activator->Action), while rich sources carry mode + Settings + directional
bindings (clicks/touches are separate
Buttoninputs - see Round A). The UI surface stays degenerate for plain buttons (no mode picker), exactly like Steam.ButtonGroup(the A/B/X/Y and D-Pad clusters) is a group whose mode isButtonPad(v1 - Steam's term for the cluster-of-buttons behavior) orDirectionalPad/Joystick(deferred). ("Button Group" is the logical InputSource;ButtonPadis Steam's mode name - a pad/group behaving as a set of buttons.) - Multi-activator everywhere. Every button-like node - a standalone
Button(incl. clicks/touches/ trigger full-pulls), aButtonPadmember, or a mode-synthesized virtual button (a Trigger soft-pull, aDirectionalPaddirection) - can hold >=1Activator -> [Action]entries: separate activators (regular/long/double...) and/orChord-gated modifier entries. - Clicks/touches/full-pulls - resolved (Round A): every real hardware bit is a standalone
Button(uniform digital model), not a sub-binding - clicks, touches, and trigger full-pulls included. What a source's mode synthesizes (a Trigger soft-pull,DirectionalPaddirections, ...) is a virtual button/axis under the source - bindable, but not a hardware bit; no exceptions. - Gordon:
DPadandLeftTrackpadare SEPARATE InputSources. Hardware reports the dpad quadrant classifiers and raw pad X/Y independently every frame, so we expose both:DPad(aButtonGroup, fed by classifiers on Gordon / the physical dpad on Neptune) andLeftTrackpad(aPad). Keeps profiles portable Gordon<->Neptune (dpad-out->dpad-in uses hardware classifiers on both); binding both = double output, the user's choice - "go with what hardware reports." - Shapes + device-independent profiles (Round A). A
Shape= a device's input-layout/ capability descriptor; shapes live inconfigso the daemon-mode UI authors offline, while profile data stays device-independent. The engine ignores bindings for absent inputs. Detail in Round A below.
Drill-down rounds: A (done) input vocabulary + shapes; B (done) behavior InputSources + settings;
C (done) activators (commands) + settings; D (done) actions; E (done) Tier-B above-profile config
(DeviceConfig + Chords) + rumble (all below). Config model design COMPLETE (A-E).
Round A - logical input vocabulary + shapes (decided). Device-agnostic superset of InputSources - defined in full now (cheap enum variants); only Gordon is wired in V1, Neptune entries are defined-but-stubbed until hardware:
| InputSource | Type | Gordon | Neptune |
|---|---|---|---|
| Face Buttons (A B X Y) | ButtonGroup | y | y |
| D-Pad | ButtonGroup | y (left-pad quadrant classifiers) | y (physical) |
| Left / Right Trackpad | Pad | y | y (+pressure) |
| Left Stick | Stick | y | y |
| Right Stick | Stick | - | y |
| Left / Right Trigger | Trigger | y | y |
| Left / Right Bumper (L1/R1) | Button | y | y |
| Left / Right Full Pull (L2/R2 click) | Button | y | y |
| Left / Right Grip (L4/R4) | Button | y | y |
| Left / Right Grip 2 (L5/R5) | Button | - | y |
View (Back) - Gordon < / Deck [copy] |
Button | y | y |
Menu (Start) - Gordon > / Deck [menu] |
Button | y | y |
| Steam (Guide) | Button | y | y |
Quick Access - Deck ... |
Button | - | y |
| Left / Right Stick Click | Button | y (left only) | y |
| Left / Right Pad Click | Button | y | y |
| Left / Right Pad Touch | Button | y | y |
| Gyro | Gyro | y | y |
- Uniform digital model (resolves the click/touch TBD). Every hardware digital bit is a plain
Button- face buttons,ButtonPadmembers, bumpers, grips, system buttons, and stick/pad clicks, pad touches, and trigger full-pulls. EveryButtonboth carriesActivator -> Actionbindings and/or is referenceable as a gate/activation (a behavior mode, or gyro) - one mechanism, no special-cased "touch is only a gate" (touch is usually unmapped, as in Steam, but not special). A rich source's raw clicks/touches/full-pulls are thus separateButtons; the source itself contributes its mode + settings + any virtual buttons/axes the mode synthesizes (next bullet). Principle: no special cases when we don't have to. - Mode-synthesized virtual buttons/axes (no exceptions). What a source's mode produces from its
analog/positional/bit input is a virtual output living under the source, shaped by its settings:
a
Trigger's soft-pull (a configurable threshold on the analog value), aPad/Stick/ButtonGroupinDirectionalPad/Joystickmode (its directions/axis), aPadinMousemode (motion). The soft-pull is not special - it's simply the Trigger's virtual button, the same category as aDirectionalPaddirection. (Real hardware bits - incl. trigger full-pulls - are standaloneButtons instead; full synthesis detail in Round B.) - Shapes + device-independent profiles. A
Shape= a device's input-layout/capability descriptor (present InputSources + flags:pad_pressure,dpad: physical|pad-derived,has_right_stick, ...). Shapes live inconfig(aShapeenum + capability tables) so the daemon-mode UI authors offline - making the crate shape-aware while profile data stays device-independent (aConfigDocreferences logical inputs only). The engine mapssteam_hid::DeviceKind -> Shapeand ignores bindings for inputs absent on the live device; a profile authored for one shape silently drops what the device lacks - the UI may warn, but config/engine just drop. Keeps profiles portable Gordon<->Neptune.
Round B - behavior InputSources + settings (decided). Each Round A rich source takes a
source-specific behavior (Steam's names) that produces virtual buttons (bound via
activators->actions like real Buttons, Round C) and/or drives an output target chosen in settings,
plus a settings palette. A behavior is picked from the set valid for its physical source:
| Behavior | on | virtual buttons (-> activators->actions) | output target (a setting) | v1 settings |
|---|---|---|---|---|
| Joystick | Pad, Stick | OuterRing |
gamepad stick (L/R) | axis, inner deadzone, anti-deadzone, outer-ring radius, curve, invert X/Y, rotation, activation |
| DirectionalPad | Pad, Stick | U/D/L/R + OuterRing (5) |
- | register deadzone, 4-way/8-way, outer-ring radius, rotation, activation |
| AsMouse | Pad | - | cursor / scroll | axis, sensitivity, acceleration, invert X/Y, rotation, 1-Euro filter, activation (no deadzone; velocity behavior -> accel not curve) |
| JoystickMouse | Stick | - | cursor / scroll | axis, sensitivity, inner deadzone, invert X/Y, curve, rotation, activation (deflection behavior -> curve not accel; no 1-Euro - a stick is already smooth) |
| GyroToMouse | Gyro | - | cursor / scroll | axis, per-axis sensitivity, invert X/Y, radial deadzone, acceleration, rotation, 1-Euro filter, space, activation |
| Trigger | Trigger | soft-pull | gamepad trigger (L/R) | soft-pull threshold, output curve, input deadzone |
| ButtonPad | ButtonGroup | 4 buttons | - | - |
- Virtual buttons vs settings (the split). A behavior's virtual buttons (
Joystick/DpadOuterRing,Dpaddirections,Triggersoft-pull,ButtonPad's 4) are bound on the main page via activators->actions, exactly like hardwareButtons. Everything else - output target, deadzones, curves, sensitivities - is chosen in the source's settings, not bound. - Nullifying (two kinds, added post-Round-E). (1)
SourceBinding::None- an explicit unbind, valid on ANY source. In a layer it overrides a base binding ("this input does nothing here" - e.g. suppress a pad's mouse while a mode-shift layer is held); in a base set it equals omission. It is the general nullifier for non-button sources (which have no top-level activator to leave empty). (2) Output targetNoneonTriggerOutput/StickOutput- drop the primary gamepad axis but keep the behavior's virtual buttons (e.g. a trigger bound only to a mouse click, no phantom LT/RT axis). Added only where a behavior has both an axis and virtual buttons;MouseOutputdeliberately has noNone- mouse behaviors have no virtual buttons, so "no output" already isSourceBinding::None(one clear way, no redundant second path). (Rust variant nameNone=> RON serializesr#None; plainNonealso parses.) - Shared vs source-specific.
JoystickandDirectionalPadare one shared behavior each, valid on Pad and Stick with the same settings - parametrized, not split (the only pad-vs-stick difference, "requires click," is a gater - see activation). The three mouse behaviors are distinct (AsMouse= pad delta,JoystickMouse= stick deflection->rate,GyroToMouse= angular velocity): different signals, different math + settings. Their output target = cursor or scroll (scroll = our extension over Steam; onGyroToMousescroll may be nonsensical but kept for consistency). - Activation - general (provisional). Promoted from GyroToMouse-only to an optional per-behavior
setting, default always:
mode: HoldToEnable | HoldToDisable+gaters: [Button](plain hardware-bit buttons incl. trigger full-pull; OR-combined; no thresholds - deferred). HoldToEnable + no gater = never; HoldToDisable + no gater = always. One mechanism expresses both gyro gating (gaters=[FullPull]) and pad-DirectionalPad"requires click" (HoldToEnable, gaters=[LeftPadClick]), soJoystick/DirectionalPadstay single shared types. (User not fully convinced - provisional; revisit if it complicates.) - Gyro
space= an extensible enum:Yaw/Roll/YawRoll(local presets - horizontal from yaw / roll / yaw+roll;YawRolldefault) andPlayerSpace(yaw+roll projected onto the gravity axis, normalized; vertical stays local pitch). IMPLEMENTED + HW-verified. World-space / laser-pointer, and fusion-based gravity (player space uses a crude accel low-pass), deferred. Rotation, the 1-Euro (One-Euro) filter, and player-space are V1 - not deferred polish (the earlier "engine polish" note meant "don't polish the bridge now," not "exclude these from v1"). - Axis limit (
Axis: Both | Horizontal | Vertical; added 2026-08-21). On the four 2D output behaviors (Joystick,AsMouse,JoystickMouse,GyroToMouse; placed just belowoutput), constrains the behavior to a single screen axis -Horizontalkeeps x and forces y=0,Verticalkeeps y and forces x=0,Both(default) is no limit. Semantics = make it a true 1-D control, so the mapper applies the mask early (see 4.2), not as a naive output null: on the deflection behaviors before the radial deadzone/curve (so the kept axis gates + rescales on its own magnitude), on the velocity behaviors before the 1-Euro filter + acceleration (so accel reads the kept axis' speed only). Config carries it as plain data; all zeroing is mapper-side. *(Not onDirectionalPad/Trigger- a dpad is already directional and a trigger is 1-D.)*
- Shared setting types (defined once, reused):
Deadzone(inner, radial),AntiDeadzone,Curve(Linear | Power),Sensitivity,Acceleration(simple),Rotation,OneEuroFilter,OuterRingRadius. - Dropped / deferred. Pad
ButtonPaddropped (DirectionalPadsupersedes it). Deferred behaviors: padScrollWheel(circular-edge),MouseRegion,MouseJoystick, Radial/Touch menus; stickJoystickCamera; gyroGyroToJoystick; ButtonGroupDirectional/Joystick. Deferred settings: outer deadzone/saturation, named/custom curves, dpad overlap/cross-gate.
Round C - activators (commands) + settings (decided). Every button-like node (hardware Buttons
and the virtual buttons from Round B - OuterRing, dpad directions, soft-pull, ButtonPad's four) holds
commands: Vec<Command> - several activators per node.
Command { activator, actions: Vec<Action>, settings }
- Command structure. A command is an activator: an activator type + its settings +
an action list. The 1st action is the "command," the rest are subcommands (action-only - no
own type/settings) for key combos (e.g.
Ctrl+C). All actions of a firing command are held together while it fires - they are not sequenced: every action becomes a desired output level and the level reconciler emits them as one set per tick (4.2). Declaration order is NOT honored - see the ordering note below. Settings apply to the whole combo. (Replaces the sketch'sentries: Vec<(Activator, Vec<Action>)>.)- Combo ordering (the truth, corrected). The intent was "modifiers press before the key, release
after" (
Ctrl-down C-down / C-up Ctrl-up). The level reconciler doesn't sequence within a tick (it's aBTreeSet<Key>diffed and emitted sorted by theKeyenum's ordinal, releases before presses- deliberately, so no key can strand across a layer swap). So the declared order of subcommands
(or of separate commands on the node) never affects output. Modifier combos still come out
correct because the
Keyenum lists all modifiers first (lowest ordinals), so a modifier's key-down is always emitted before a letter's within the same tick's event batch, and all land in one frame. This means[Ctrl, C]and[C, Ctrl]produce identical output; a combo whose intended sequence contradicts enum-ordinal order (two letters, or a key-before-modifier) would not be honored. Making emission order-preserving is a scoped reconciler change, deliberately not done - the only observable effect today is terminal autorepeat cosmetics, and the level model is load-bearing for stuck-key safety.
- deliberately, so no key can strand across a layer swap). So the declared order of subcommands
(or of separate commands on the node) never affects output. Modifier combos still come out
correct because the
- Combo ordering (the truth, corrected). The intent was "modifiers press before the key, release
after" (
- Activator types (v1):
Regular{interruptible} | Long{hold_time} | Double{window} | Start | Release. (Cycle, Double-and-hold deferred.) - Per-command settings (v1):
- Interruptible - must-have. Lives on the activator, not in
CommandSettings:Activator::Regular { interruptible: bool }(it'sRegular-only, so a field of the variant makes the invalid combos unrepresentable and drops the engine's runtimematches!(Regular)guard). Its behaviour is the full activator model below, not a simple suppress. - Haptics -
activation: Off | OnPress | OnRelease | Both,strength: Low | Medium | High. Fires on the action's edges, not the raw button (soLongpulses after its timeout;Turborepeats it). A singular click at one of 3 strengths - the reader maps the level per device (Gordon0x8fpulse duration / Deck0xeagain, 1.9). Actuator side = the triggering input's side (below). The click is paired to the effect that LANDS - a level command clicks on its output edge, but an OpSet's click is deferred and gated on the op actually landing (dedup, no-op, engage/disengage); see the 4 "Command-haptic pairing" addendum (2026-08-30). - Toggle (latch on/off per activation), Turbo (
{rate}, repeat while held), timing (hold_time,window,turbo_rate). Deferred: fire-delay.
- Interruptible - must-have. Lives on the activator, not in
- Haptic actuator side. Inputs on the left buzz the left actuator, right->right (as in
the user's prior software).
Side(InputSource) -> Left | Rightis a device-independent match inconfig- the logical vocab already encodes left/right and the cluster/center assignments are consistent across Gordon+Neptune (ABXY=right,DPad=left), so noShapetable is needed. Central buttons: View->Left, Steam->Left, Menu->Right, QuickAccess->Right; gyro is moot (no haptic-firing virtual buttons). (Movable intoShapelater if a mirrored device ever appears.) - Chord - deferred (hold-layers cover it). When added it's an optional
gaters: [Button]on aCommand(reusing Round B's gater), not a new activator type. - Engine resolution - the activator model (not config; config just declares, engine resolves).
A node's commands are evaluated together each tick (
engine/src/mapper/command.rs, two passes: classify hold-takers -> resolve every command's output). Five activators, two roles:-
Start/Release- independent one-shot taps (TAP_MS~40 ms) on the press / release edge. Never interrupt, never interrupted, don't participate in anything below. -
Long/Double- the interrupters and hold-takers. Fire when their condition holds (Long: held pasthold_ms;Double: a second press withinwindow_msof the first press- press-to-press, so holding the first press past the window forecloses a double) and then hold
from that point until release. They never contest each other - several can be active at once
and they all stay held (
double(200),long(300),long(500)held long -> all three down until release; verified against Steam withwev, the terminal's autorepeat had earlier made it look like only the last held). While any is active, an interruptibleRegularis killed.
- press-to-press, so holding the first press past the window forecloses a double) and then hold
from that point until release. They never contest each other - several can be active at once
and they all stay held (
-
Regular- the only interruptible command; the whole behaviour is one rule:A
Regularpresses-and-holds the moment it becomes safe from interruption - "safe" = noLong/Doubleon the node can still fire from this interaction. Safe at press (no interrupters present) -> holds from press. Becomes safe later while still held -> holds from that moment. Safe only after release (or button already up) -> aTAP_MStap. ALong/Doublefires before safe -> interrupted, no output.Consequences (all fall out of the one rule):
- No interrupters on the node -> holds from press (
interruptibleis a no-op - the neutral default). Longpresent: short press (released before it fires) -> tap on release; hold past threshold -> interrupted. ALongnever permits "safe while held" (it either fires or keeps threatening), so a committed real hold only happens withDouble(s) and noLong.Doublepresent: safe only when the window closes (from first press). Quick press -> tap after the window; press-and-hold past the window -> a real, delayed press-and-hold; a genuine second press ->Doublefires,Regularinterrupted. (The window latency is unavoidable - you can't know it's not a double until the window elapses.)
- No interrupters on the node -> holds from press (
-
toggle/turbo/haptics are post-processing on the resolved level; meaningful on holds, inert on the one-shot taps (the editor hides the ones that don't apply). Full worked examples + golden cases live incommand.rs's module docs and tests.
-
Round D - actions (decided). An Action is what a command/subcommand fires. Two families:
- Output (from
vocab, realized byvirt-out):Key(Key),MouseButton(MouseButton),GamepadButton(GamepadButton). Discrete scroll is folded intoMouseButtonas pseudo-buttons (ScrollUp/Down/Left/Right) - a scroll tick is a button-like impulse (fires per activation,Turborepeats), a la X11 buttons 4-7 / the Windows wheel; the backend realizes them as wheel ticks, notBTN_*. (MouseButtonset: Left/Right/Middle/Side/Extra + the four scroll pseudo-buttons.) - Config/engine (no OS output):
ChangeActionSet(id),HoldLayer(id)/AddLayer(id)/RemoveLayer(id), andNone(explicit no-op / block). Key= adopt the siblinguinput-simulation's groupedKeyenum (throughKbdIllumUp; the obscure codes below are additive later) intovocabas bare names - no embedded evdev value; the backends map (virt-outLinux ->evdev::KeyCode, Windows -> scancode). Adjustments applied on import: media pairs backward-first (PreviousSongbeforeNextSong,RewindbeforeFastForward) to match the Down/decrement-first convention of Volume/Brightness/KbdIllum;KpDotmoved besideKp0;Menu+Composesplit into their own small "Special" group.- Not actions - continuous outputs (
MouseMove, axis/continuous scroll, gamepad stick / trigger axes) come from a behavior's output-target/settings (Round B), never a button-fired action. Macrodeferred - a timed sequence (press A, wait, release A, press B...), distinct from the Round C simultaneous combo (all actions held together, e.g.Ctrl+C- no sequencing). Slots in later asAction::Macro(Vec<MacroStep>)({ action, down/up, delay }). Steam/system actions also deferred.
Round E - above-profile config (Tier B) + rumble (decided). Completes the model.
SPLIT (2026-08-19): the original single
GlobalConfigblob was split into two independent, separately-stored, uncompiled config "beings" -config::DeviceConfig(device-local reader-side settings) andconfig::Chords(the top-level switch/command chords, a third engine slot). The text below is the post-split shape;GlobalConfig/GlobalChord/GlobalActionno longer exist.
DeviceConfig (config/src/device.rs; profile-independent, device-local; uncompiled;
handed to the engine and applied reader-side on connect, ReaderCfg::for_device; never crosses
the 6 wire - each machine keeps its own; on disk devcfg.ron):
DeviceConfig { // container #[serde(default)] -> old files migrate (unknown fields ignored)
led_brightness: Option<u8>, // 0..=100 % (None = leave default)
idle_timeout: Option<u16>, // seconds (None = leave default; caveat below)
gordon: GordonTuning, // Gordon pulse-train shaping (duty lever + freq)
neptune: RumbleTuning, // Deck motor shaping (speed + gain levers)
triton: RumbleTuning, // Triton motor shaping (separate - different motors)
} // NO master_rumble (dropped 2026-08-27); NO top-level rumble_hz (-> gordon.hz)
GordonTuning { duty: Lever<u8> /*% of drive*/, hz: u16 /*pulse rate, default 60*/ }
RumbleTuning { speed: Lever<u8> /*% of drive*/, gain: Lever<i8> /*dB, -8..16*/ }
Lever<T> ::= Fixed(T) // constant, strength-independent
| Scaled { min: T, max: T } // per-motor strength lerped into [min,max]
// drive(strength)->u16 (renamed from speed()): strength 0 -> 0 (a zero motor is ALWAYS silent); min is
// a floor for NONZERO strength only. Scaled{0,100} = raw identity. Defaults: duty/speed Scaled{0,100},
// gain Fixed(2) (Neptune) / Fixed(0) (Triton) - reproduce the removed NEPTUNE/TRITON_L/R_GAIN consts.
Chords (config/src/chords.rs; the third engine slot, an Option<Chords> beside the two program
roles; uncompiled; live hot-swappable; on disk chords.ron):
Chords { chords: Vec<Chord> } // named struct (not a bare Vec) so it can grow chord-level settings
Chord { buttons: Vec<Button> /*physical bits, AND*/, action: ChordAction }
ChordAction ::= SwitchProfile { mode: SwitchMode } // main<->fallback
| CommandExecute { command, args } // run a headless tool
SwitchMode ::= HoldFallback // force Fallback while held; base on release
| Toggle // flip base (Main<->Fallback) on each engage
| SetMain // latch base to Main on engage (idempotent)
| SetFallback // latch base to Fallback on engage (idempotent)
- No global rumble knob -
master_rumblewas dropped (2026-08-27) as redundant with the per-device levers (each device'sduty/speed/gainalready scale its own strength), andreader::scale_masterwent with it. Rumble shaping is applied reader-side, not in the mapper: the mapper'sRumbleCmdis profile-scaled only; the reader applies the bound device's tuning at emit - Gordon'sGordonTuning(duty lever + pulsehz) viaapply_gordon, or Neptune/Triton'sRumbleTuning(speed + gain) viaapply_rumble{,_triton}. The level is kept raw and the levers read fromReaderCfg.rumble(aGordon|Neptune|Tritonenum holding only the bound kind's tuning), so a live tuning change lands on the next re-fire. - Device toggles are device-local -
led_brightness(verified working) andidle_timeout(writes correctly, but sleep is gated by host read-activity -> mostly moot on a running engine, kept for completeness). Gyro/IMU enable is auto-derived (on iff the program uses aGyroToMouse); lizard is always off while running (raw input) - neither is a setting. chords= physical hardware-bit buttons AND-combined; the engine evaluates them first and consumes their buttons (4), so they work UI-down/daemon-only. EachChordActioncarries its own params:SwitchProfileholds aSwitchMode(HoldFallback/Toggleare relative to the current role;SetMain/SetFallbacklatch a specific role on engage - same persistent outcome asTogglebut unconditional);CommandExecutespawns a subprocess - the escape hatch for system actions (on-screen keyboard, switch audio device, brightness...) that links no X/Wayland/DE/audio deps, keeping the engine headless/system-dep-free on Linux (4). Noreloadaction (the engine has no on-disk config - it's pushed from the UI).
rumble feel (per-profile, in ConfigDoc): rumble { strength /*default 100 %*/, curve /*default Linear*/ } - game rumble (strong/weak) -> Gordon trackpad haptics; the profile tunes
texture, the per-device rumble levers (DeviceConfig) shape it. hz is NO LONGER per-profile -
it now lives in DeviceConfig::gordon.hz (device-level, applied reader-side; was the top-level
rumble_hz between the 2026-08-19 split and the 2026-08-27 per-device rework).
curve = strength->drive response (the non-linear Xbox-strength map is the future tweak, 1.9).
Per-profile strength may exceed 100 % to boost games that under-drive their FF (1.9).
Complete ConfigDoc: { version, name, action_sets: Vec<ActionSet> (+default/active), rumble }
- no
device_settings(LED/idle live inDeviceConfig). Config model design COMPLETE (rounds A-E) and BUILT - thevocab+configcrates are implemented (see the 3 status note above).
Two tiers (decided). Configuration splits into two profile-independent levels:
- Tier A - Profile:
ConfigDoc -> Program, above. One per file, hot-swappable; holds everything inside a mapping (action-sets, layers, bindings, per-profile gyro/rumble texture/deadzones). - Tier B - above-profile (engine-level): since the 2026-08-19 split, two independent uncompiled
beings, each authored by the UI, handed to the engine directly (not per profile-swap):
DeviceConfig- device-local settings applied reader-side: per-device rumble shaping (gordon/neptune/triton), LED, idle. Staged (applies at nextstart()); never crosses the 6 wire.Chords- the top-level chords (eachChordfires aChordActioncarrying its own paramsSwitchProfile{HoldFallback|Toggle|SetMain|SetFallback}for main<->fallback, orCommandExecute{command,args}for system actions via headless tools, keeping the engine system-dep-free). A third live engine slot; hot-swappable. Full detail in Round E above.
The engine therefore holds { device_config, chords: Option<Chords>, program[main], program[fallback] } - two live profile Programs, the device settings, and the chords slot. config
compiles one profile at a time and never itself knows about "two"; the UI orchestrates which Programs
are loaded and in which role. (Engine mechanics - the chord switch, apply-API role tagging -
are the engine talk, 4.)
Versioning (decision - deferred). During initial development the on-disk format WILL break as it evolves - that's fine, test setups getting broken is acceptable. Config versioning + migration is added only at the stable release, after which the format is a non-breaking user contract. This is the explicit exception to the "break the API freely" rule (0), and it applies only post-stable.
Profiles & switching (decision). One ConfigDoc = one profile (its own action-
sets/layers); the always-reachable desktop / fallback profile is just another
ConfigDoc. Switching between profiles is manual for now. Automatic per-application
switching (detect the foreground game -> activate its profile) is explicitly out of
scope initially - it needs per-OS foreground detection that is painful on Wayland and
ugly on Windows. Kept as a possible future capability, not planned.
Where switching lives (boundary). Manual switching, and automatic switching if it is
ever built, belong in the ui/tray app (5) - not in the engine. The
engine/daemon is headless and has no concept of what applications are running:
it holds the compiled program(s) (main + fallback) + device-config + chords and maps whatever is
active, but never inspects the desktop. Consequently the engine must not link any
X/Wayland/desktop-environment code on Linux; only the ui may. (On Windows the engine
may still need some winapi linkage for its own output/device work, but not for
app/foreground awareness.) The UI decides "which profile is active" and tells the engine.
Config ownership (decision). The UI owns the config lifecycle - authoring, persistence to disk, managing the set of profiles, and selecting the active one. It hands a (compiled) config to the engine via the narrow control API (4); the engine keeps no notion of profiles and no ownership of the on-disk store - it just runs what it's given. A daemon variant, if ever built, is fed the same way (UI or a small CLI config-sender).
Serialization format (decided: RON). The model is nested and enum-heavy (input sources, activators, actions are all sum types); RON represents Rust enums/nesting cleanly, supports comments, and is more editable than JSON - the right fit for a UI-authored, occasionally-hand-inspected format. (TOML fights deep nesting/enums; JSON has no comments.)
Skeleton (decided). Edition 2024, dual MIT/Apache, publish = false, no Copy
(project convention), serde a hard dep (the crate's purpose, not an optional feature).
Deps: serde, ron, thiserror, vocab. Compile errors: collect-all diagnostics.
Purpose. The headless runtime / manager - the central place the whole mapper
runs, with no UI. It owns and drives everything below it: opens and manages devices via
steam-hid (lifecycle, the Neptune keep-alive, hotplug/reconnect), routes each device to
its mapping, runs a compiled config Program over the input, and emits the resulting
output through virt-out. It holds the live runtime state - active action set/layer,
activator timers, turbo counters, gyro integration, trackpad momentum. engine depends
on both steam-hid and virt-out directly and drives them; it is the thing a daemon
or CLI binary is a thin wrapper around.
Status - CORE COMPLETE & HW-validated. The engine crate is built: Program IR +
compile(), the pure golden-tested Mapper, and the threaded runtime/manager (reader-thread-
per-device -> central mapping loop -> virt-out), driven by the deckhand-run example. See 4.2
for the step-by-step record. Remaining work is the deferred list at the end of 4.2 (daemon/
socket + 6 networking, multi-device->multi-Mapper, USB hotplug, and a few polish items).
This crate is deliberately not a side-effect-free "pure core." It manages real devices and real OS output. Purity is kept inside, at the mapping step (below), where it actually pays off for testing - not by pushing I/O out of the crate.
But it is strictly headless: it has no concept of running applications or the desktop and must not link any X/Wayland/DE code on Linux. It holds the config and maps the active profile; it never decides which profile to activate from desktop state. Profile switching (manual now, automatic if ever) lives in
ui/tray (3, 5), which tells the engine. (Windows may need somewinapilinkage for output/device work, never for app/foreground awareness.)
Design notes (mine, agreed).
- Manager shell + pure inner step. Factor a pure mapping step -
step(state, config, clock) -> output_events- that the manager shell drives each tick. The shell does the I/O (readsteam-hid, writevirt-out, timers, threads); the step is a pure function of (input + config + injected clock). Inject the clock - activators are timing-based; never read "now" deep in the step. This keeps the mapping logic golden-testable without hardware: feed recorded input traces (from thesteam-hiddump, 1.8) + a config intostep-> assert output events. "Pure & testable" applies to the step, not the whole crate. - Consumes
steam-hid'sControllerStatedirectly (don't over-abstract the input side); emits abstract output events intovirt-out. - Level-driven mapping - the diff is on the output, not the input. Each tick the step
(a) takes the current input snapshot (level, exact - never
steam-hid'sdiff()), (b) computes the desired output state as a function of current input + config + runtime (active layer, timers), then (c) reconciles desired-output vs. applied-output and emits only the difference tovirt-out. So "hold the virtual button while the physical is held, release it when the physical releases" falls out of output reconciliation - no input-edge diffing - and it composes cleanly with layers (a held button is recomputed fresh each tick, so a layer change can't strand a stuck output). Where activators genuinely need input edges (long-press, double-tap, on-release, turbo), the step derives exact digital transitions from its own retained previous snapshot (cur.buttons & !prev.buttons) - digital, so no deadband is ever involved, and independent of thesteam-hidevents()/diff()convenience (which the engine does not use; 1.5). Analog deadzones/curves/gyro sensitivity are the configurableInputProcess(3), applied to raw snapshot values - never a fixed library filter.- Why output-side reconciliation especially pays off with filters. When a smoothing filter sits in the path - e.g. the 1-Euro (One Euro) low-pass filter on gyro or a pad, or a response curve - it removes jitter, so a steady hold yields a steady (often zero-delta) filtered output. Reconciling on the output then emits a virtual event only when the filtered result actually changes, silently absorbing the raw input jitter the filter smoothed away. Input-edge diffing can't do this - it never sees that the filter flattened those frames.
- Layer holds latch to the triggering node's held-state (avoids self-shadowing oscillation). A
HoldLayerhold is registered when the resolved binding fires it and keyed to the held-state of the node that fired it: it persists while that node is held - even if the layer it activates then overlays that same node so its new binding no longer namesHoldLayer. Without this latch - i.e. re-deriving the active-layer set from the currently-commandedHoldLayers each tick - a self-shadowing button oscillates (layer on -> node rebound -> the command keeping it alive vanishes -> layer off -> node reverts -> ... strobing every tick). Output reconciliation (above) still prevents stuck outputs either way; the latch is what makes it stable.- The latch is robust only for frame-local triggers. "Physical" isn't the real criterion -
"always derivable from the raw frame, so it can't vanish when re-bound" is. Physical hardware
bits qualify (always present), and so do the trigger soft-pull / full-pull (a frame-local
threshold on the always-present analog). A fully pipeline-derived virtual (a
DirectionalPaddirection from a pad's position+mode, an outer-ring) - or even a soft-pull whose layer overlay uses a different threshold - can effectively vanish under the active layer (e.g. base fires at threshold 0.5, the layer's overlay needs 0.9, the analog sits at 0.6), so its latch can't hold -> flicker. This is never a stuck key (reconciliation), only strobing. - Decision:
HoldLayer/ mode-switch actions are permitted on any button-like node, virtual included - unlike activation gaters (gyro/behavior), which are physical-only. The reason gaters are physical is evaluation order, not theInputSourcetyping (the typing merely encodes the rule): a gate answers "does this behavior run this tick?", so it must be resolvable before the behavior runs - i.e. from the raw frame, up front. A virtual button is a behavior output, so gating on it inverts the dependency (behavior A's gate needs a virtual from behavior B, which may be gated by a virtual from A -> a cyclic behavior-dependency graph needing per-tick topological sort + cycle checks). Physical/raw-frame gates keep the tick a single fixed acyclic pass (read frame -> resolve all gates -> run behaviors in any order -> reconcile).HoldLayerdoesn't hit this: it's a runtime-state hold evaluated as the tick evolves - if its trigger vanishes the tick still completes (worst case flicker), no ordering problem. So gating enforces frame-local;HoldLayertolerates non-frame-local. The doubly-pathological self-shadowing of a virtual/ threshold-fuzzy trigger is user error - only flickers, never sticks. (A "freeze the fire-time condition" latch could make even that stable, but it's extra state + odd semantics; skipped.) - Corollary (ordinary overlay semantics, no special rule): a layer overrides only the inputs it names, so an input the active layer is silent about keeps resolving to the base binding for the whole hold.
- Layer/mode changes take effect next tick. Layer state is fixed at the start of a tick;
all bindings resolve against it, and
HoldLayer/Add/RemoveLayer/ChangeActionSetcollected this tick update the state for the next. This is what keeps the pass acyclic - a binding this tick depends only on last tick's layer state, never on this tick's behavior outputs. It also means a layer can emulate "gate on a virtual" (which a gater can't): e.g. base gyro always-disabled, a layer gyro always-enabled,HoldLayer(that layer)on a soft-pull => "gyro on while soft-pull held", stable, at a one-tick on/off latency (~4 ms @ 250 Hz - imperceptible). So gater vs layer+HoldLayerisn't a capability gap, just a trade: gater = same-tick but frame-local only; layer = any trigger, one tick deferred (the tick is the price of breaking the cycle). - Golden tests (pure
step()): (i) base binding = out-key +HoldLayer(L)whereLre-binds that same button => stable, no stuck output (a brief base-key blip, then the layer's key held); (ii) same butLdoes not name the button => the base out-key persists for the whole hold.
- The latch is robust only for frame-local triggers. "Physical" isn't the real criterion -
"always derivable from the raw frame, so it can't vanish when re-bound" is. Physical hardware
bits qualify (always present), and so do the trigger soft-pull / full-pull (a frame-local
threshold on the always-present analog). A fully pipeline-derived virtual (a
- Persistent stack/set ops dedup per node (fix self-toggle strobing).
AddLayer/RemoveLayer/ChangeActionSetare persistent state mutations, and a self-referential toggle re-creates the oscillation theHoldLayerlatch defeats - one tick worse: baseAddLayer(L)+ the layer's own same-buttonRemoveLayer(L)(or a one-button action-set cycle set0->set1->set2->...). While the button is held, aRegularre-applies its op every tick, the op flips the node's own winning binding (base<->layer / set<->set), and the binding change resets the activator (the no-long-press-bleed rule) into a phantom press - so the two opposing ops alternate every tick and the landing state is the parity of held-ticks ("works intermittently / needs 2-5 presses"). This bites every activator, not justRegular: evenStartre-detects a press edge after the reset. OnlyReleaseescapes, because its edge is button-up, when no phantom press can form (so aRelease-driven toggle already worked pre-fix).- Fix - fire once per node engagement. A persistent op applies only if its trigger node isn't
already armed this press; then the node arms until it releases. This is the same node-latch as
HoldLayer-armed_nodes, aMap<NodeKey, NodeHeld>rebuilt each tick by "keep whilenode.held(frame)" - so it survives the activator reset (it lives on the mapper, keyed by the physical node, not in the per-source activator slots) and disarms exactly when the node releases. Node-keyed, so it survives aChangeActionSet(a set swap doesn't change the node), which is what makes the set-cycle advance once per press rather than free-run. - Same machinery, opposite lifetime, as
HoldLayer- one place, no fire-time special case. Both are node-latches anchored to the raw frame, not to whether a command re-fired (the property that kills the oscillation). They differ only in what a live entry means: aHoldLayerentry = "this layer is held" (sustains, drops on release); anarmed_nodesentry = "this node already made its one persistent change this press" (suppresses, also drops on release). SoLayerOpscarries the trigger node on every op (holds/adds/removes/set_change),eval_commands/apply_actionstay pure "what fired" reporters that just queue the raw ops (the persistent ones only on the command's rising edge - see the edge-fire bullet below), and all node-latch logic (both latches + the dedup + the set swap) lives together inreconcile_layer_ops(renamed fromreconcile_layers, since it now folds layers, the set change, and the dedup). (Rejected: doing the dedup at fire-time insideeval_commands, which needed a fourthSinksmember - asymmetric withHoldLayerand put state logic on the hot per-command path.) - Rule: one persistent layer/set change per button press, whatever the activator. Ops fired
together in one tick - one command's
AddLayer(X)+RemoveLayer(Y), or two commands firing the same tick - all apply, because the armed check reads the pre-tick snapshot (a single fire lands atomically); but a second persistent op later in the same press (e.g.long(300)->AddLayerthenlong(500)->ChangeActionSeton one button) is suppressed.Regular{interruptible}+Longis unaffected - tap vs hold are mutually exclusive, so only one ever fires per press. This one-per-press ceiling is the deliberate cost of per-node keying; finer keying would reintroduce the strobe. (set_changeadditionally wins over a co-firedAdd/Removein the same command - the full swap clears the per-set stacks, so the layer add is dropped.) - Persistent ops are edge-fired, not level-fired (2026-08-24 - the fix that makes
armed_nodesactually correct).armed_nodesdisarms the tick a node releases, but an interruptible-Regulartap holds its output forTAP_MS(~40 ms) past release. So queuing the op every tick a command is high (level) re-applied it during that post-release tail - after the node had disarmed - re-adding a just-removed layer: exactly the "needs 2-5 presses" feel, now via the tap rather than the strobe (the self-removing-layer / cp2077 shape: baseRegular->AddLayer+Long->HoldLayer, layerRegular->RemoveLayer). Fix:apply_actionfires the three persistent ops only on the command's rising edge (CmdState::prev_out, unified with the command-haptic edge), so each lands once, at the commit tick, where the node is still armed -> suppressed -> no re-apply.HoldLayerstays level-fired (node-latched viaheld_layers, notarmed_nodes, so re-queuing is idempotent). (The originalarmed_nodescommit's message said "edge events" but the code level-fired + deduped; this is the fix that makes the description true. Verified against the self-removing-layer regression test, which fails under level-firing.) - Same frame-local robustness caveat as the latch (above). The dedup's
held-re-derivation is robust for frame-local trigger nodes - the criterion is "re-derivable from the raw frame," not "physical vs virtual" - so physical buttons, group members, and trigger soft-pull / full-pull dedup exactly, while a fully pipeline-derived virtual (outer-ring,DirectionalPaddirection) hasNodeHeld::held()==falseand so can't stay armed -> a self-toggle off such a node can oscillate (never sticks - reconciliation), the identical user-error corner as a virtual-triggeredHoldLayer. A soft-pull re-bound at a different threshold across layers has the same fuzziness (base 0.5 / layer 0.9 / analog 0.6 -> the captured threshold disagrees with the active condition). - Golden tests (pure
step()/tick()): same-buttonAddLayer/RemoveLayertoggle stable at hold-lengths 1/50/37 ticks (not parity-dependent); one-button 3-set cycle advances once per press (0->1->2->0);Regular{interruptible}+Longtaps one op / holds the other, each once; a single command'sAdd(X)+Remove(Y)lands atomically; the dedup is per-node (two buttons toggle independently, held together); two hold-takers on one node = one-per-press; and the layer<->set mix (tap adds a layer / hold changes set;set_changeoverrides a co-firedAdd; a set-changing node doesn't also fire the new set's op until released). - Command-haptic pairing +
HoldLayerfolded into the uniform dedup (2026-08-30). Two changes, both moving OpSet effects to be decided inreconcile_layer_ops, where the real layer state lives:- The command click is now paired to the effect that LANDS, not the raw output level.
command_effect(cmd)(mapper/command.rs) classifies a command's effect into three mutually- exclusive routes, and the click follows the effect's own landing edge: (a) Level - any output leaf / bareNone/ a mix -> clicks inline on the command's output edges (unchanged). (b) PersistentOpSet - onlyAdd/Remove/ChangeActionSet-> click deferred intoLayerOps::pending_haptics, fired iff the op changed state (its node lands in anappliedset: past dedup,set_change-wins and a realinsert/removereturningtrue), so a deduped-away, superseded, or no-op op is silent; a persistent op has no release edge (fallingforced off, soOnPress/Bothclick once on landing,OnReleasenever). (c) Hold- only
HoldLayer-> click rides the layer'sheld_layerslifecycle: engage when the layer entersheld_layers(OnPress/Both), disengage when it leaves (OnRelease/Both), the haptic stored on theHeldLayerentry so the disengage click survives the self-shadow that hides the command on the release tick. Fixes a no-opRemoveLayerbuzzing on a held layer, and aBothhold never clicking on release.
- only
HoldLayeris now a uniform OpSet for dedup - this SUPERSEDES the "HoldLayerstays level-fired, notarmed_nodes" / "same machinery, opposite lifetime" framing in the bullets above. Engaging a hold arms the node (it joins the requesting arm set besideAdd/Remove/set_change), and a new engagement is armed-gated (NodeHeld::may_fire) exactly like the other OpSets - a hold can't engage on a node that already fired an OpSet this press. What is not a dedup decision is an already-engaged hold continuing to hold: that stays theheld_layersnode-latch (keep-while-node.held), the definition of a hold, and it's how an engaged hold rides out its own self-shadowed no-opRemoveLayerand stays immune to another button's ops. So the "one persistent change per press" rule now coversHoldLayertoo. Fixes a hold-to-remove re-adding the layer via a re-firing baseLong- a real re-hold, not just a stray click (the cp2077 RG2 double-click). The held-layers rebuild + its lifecycle clicks now live in their ownreconcile_held_layers.- Golden tests added: persistent-op click fires once on landing / ignores release; self-toggle
clicks once per real change; a no-op removal / a deduped op / a
set_change-superseded add stays silent; aBothhold clicks on engage and release through the self-shadow; a hold-after-remove doesn't re-engage; engaging a hold arms the node (a shadowed op is deduped); a held layer isn't torn down by another button's op; the hold lifecycle click follows its edge config; aChangeActionSetclicks once on the swap.
- The command click is now paired to the effect that LANDS, not the raw output level.
- Fix - fire once per node engagement. A persistent op applies only if its trigger node isn't
already armed this press; then the node arms until it releases. This is the same node-latch as
- Layers stack; precedence is declared order, not activation order. More than one layer can be
active at once -
AddLayer/RemoveLayermanage persistent stack entries, and multiple heldHoldLayers add held entries; both feed one active-layer stack over the base action set. Overlap resolution: for each input, the highest-precedence active layer that names it wins; a layer that doesn't name the input falls through to the next active layer down, then to the base set- so non-overlapping layers compose freely (layer A rebinds the stick, layer B the gyro, both live).
Precedence = each layer's position in
ActionSet.layers(fixed declared order), not the order it was activated: declared order is deterministic (the same active set always resolves the same way) and checkable/configurable, whereas activation order is path-dependent ("hold A then B" != "B then A" when they overlap). The config'sVec<Layer>already carries this order; the UI may offer re-ordering.
- so non-overlapping layers compose freely (layer A rebinds the stick, layer B the gyro, both live).
Precedence = each layer's position in
- Relative outputs accumulate a sub-pixel remainder (don't reconcile). Mouse-move and
scroll are deltas/rates, not levels - the output-reconciliation above is for levels
(keys/buttons/abs axes); relative motion is accumulated instead. Converting fractional
per-tick motion (pad/gyro -> pixels/ticks, after sensitivity/accel/curve) to integer device
units must carry the fraction forward in a float accumulator: emit
trunc(), keep the remainder for next tick. Truncating each tick silently drops sub-pixel motion and makes slow/fine movement feel dead - the accumulator restores it (validated in the Phase B bridge; a big feel improvement). Applies to every relative output (mouse move, scroll). Note (the accumulator is not anti-drift): it faithfully integrates; it does not filter. Gordon's gyro has a small DC bias (stationary raw isn't zero-mean, 1.9) that integrates into a slow but real cursor drift (confirmed by holding the controller still with gyro active for an extended time - trigger-gating hides this in normal short-burst play but is not the fix). The fix is a small radial deadzone on the per-frame gyro delta: ~0.1 px kills the drift completely (the bias is ~0.003 px/frame, far below it) while sitting well under real aiming motion, so fine/slow movement is untouched. So gyro wants a small, configurable deadzone (radial, on the delta magnitude - cleaner than per-axis) and/or bias calibration (measure the resting offset, subtract it): a needed input-process knob (3), just a very small one. - Gyro processing - validated wants for the engine (from real play). The Phase B bridge's
gyro is deliberately crude (local space: raw yaw->horizontal, pitch->vertical, linear
sens, trigger-gated) and already "100% playable" in Cyberpunk. To reach parity with a good
daily config, the engine's gyro/
InputProcess(3) should add: (a) player-space gyro - use the accel gravity vector to combine yaw+roll into a stable "turn = horizontal" mapping regardless of how the controller is tilted/held (vs. the bridge's raw local space), the biggest feel gap; (b) a light 1-euro (One Euro) smoothing filter on gyro/pad (the 4 filter note - confirmed subtle-but-desirable in practice); (c) configurable response curves on gyro & pad. None are blockers (crude version is playable) - they're the top polish items. - Haptic / rumble back-channel (engine-owned, must be configurable). Rumble flows the
other way: a game rumbles the virtual gamepad ->
virt-outsurfaces it (twou16magnitudes, heavy/low-freq + light/high-freq - the only thing evdev FF / XInput give; no frequency or waveform) -> the engine routes it to the real device's haptics viasteam-hid. On Gordon the "motors" are the two trackpad actuators (1.9), so the engine synthesizes the buzz from the bare intensity: it chooses the frequency, maps intensity -> duty-cycle amplitude (non-linear/saturating - wants a response curve), and applies an overall strength. These must be user-configurable (validated in the Phase B bridge as fixed consts - ~60 Hz, 50% strength, linear): expose Hz, response curve, and strength in the config model (3). Scope (leaning, undecided): per-profile haptic settings plus a single global master strength % - so profiles tune texture while one knob scales everything. (Device-dependent: the Deck has real rumble motors + trackpad haptics, so its mapping differs; keep the config expressive enough for both.)
Input/output seams = the network role-swap points. The step's input and output are
seams, not hard-wired to the local adapters. The manager feeds the step from steam-hid
or a network receiver, and routes the step's output to virt-out or a network
sender. That is exactly what lets a single engine implementation run either 6 role -
source (local device -> net) or sink (net -> local output) - with the role chosen at
runtime, not by a separate build. (This is also why even the sink side links steam-hid:
same artifact, it may drive a local device a moment later - see 6.)
Control API (transport-agnostic, deliberately narrow). The engine exposes one
in-process Rust control API. (Concrete method surface + lifecycle are refined and made
authoritative in 4.1 below - set_input/set_output, apply/set_chords/set_device_config,
start/stop/shutdown; open()/stop(self) here are the earlier sketch.) That same API is what a daemon would wrap behind a socket
(with an optional small CLI config-sender speaking that socket), and what the UI calls
directly when embedded. Embedded vs daemon = the same command set, different transport
- this is precisely what keeps that deployment choice deferrable without a rewrite. The API has two families plus lifecycle, and no concept of profiles:
- Config:
apply/swap config- hand the engine a (compiled) config to run. Config authoring, persistence, profiles, and which config is active all live in the UI (3); "switch profile" is just the UI callingapply configwith a different one, so hot-reload rides the same path (an atomic swap on the running engine - define what resets vs. persists across it). Refinement (3 part 1, mechanics pending this section): the engine can hold two live profile Programs (active + fallback) plus the two above-profile beings - aDeviceConfig(per-device rumble shaping, LED/idle; reader-side) and anOption<Chords>slot (top-level chords) - and switch main<->fallback itself via a profile-independent top-level chord - so not every switch is a UI re-apply. Apply-API therefore tags each Program with a role (main/fallback) and takes device-config + chords separately; it may expose loaded-program name/metadata back (for UI restart/status). Still no profile/file concept in the engine - just program(s) + device-config + chords. (Post-2026-08-19-split naming: the earlier singleGlobalConfigblob is gone.) Chord evaluation (decided): global chords are evaluated first, before any profile binding, and an activated chord consumes its trigger buttons (they are not forwarded to the active Program). So chords may shadow profile bindings, never the reverse - fine in practice, since chords sit on keys profiles don't use (Steam button, grips). Switch semantics are hold-vs-toggle, configurable per chord (3 Tier B; toggle the safe default). The whole point of holding this in the engine: the escape hatch works UI-down / daemon-only, instantly, with no IPC. - Devices: enumerate/report available devices, initialize/open, manage lifecycle, and
inform the UI what is available (so the UI can bind configs to real devices and show
status). This is the engine surfacing
steam-hid's device world upward. - Lifecycle/observability:
start/stop,status, and a small live monitor channel (low-rate, for the UI to show current state) - distinct from the 6 high-rate state stream.
Concurrency model (decided): sync, no async. Well-defined thread workers where
needed - the natural shape is thread-per-device (blocking steam-hid reads) feeding a
central mapping loop over channels. The Neptune keep-alive rides inside each reader thread
(steam-hid owns no threads of its own; 1.6). No async runtime is imposed; this is not a distributed system, so an executor
would be overhead. (If 6 networking ever makes multiplexing painful, revisit then - the
local engine does not need it.)
Engine is a library (decided). engine is always a library crate. What's
undecided is where that library runs - the process/deployment model (decide after
steam-hid, virt-out, and initial config are done):
- Embedded: the engine library is linked into the UI process (UI = config + engine), optionally with a thin CLI runner.
- Daemon: the engine library is hosted in a separate daemon process; the UI and/or a
CLI drive it over a control-plane socket (UI = config only). Note this daemon
control seam (UI -> daemon: push config, read state, a small live monitor - low-rate,
reliable, request/response) is a different channel from the 6 network seam (a
high-rate, unreliable, latest-wins
ControllerStatestream). Do not conflate them.
Both put the same library to work - only the process boundary differs. The crate
name (engine, core, mapper, ...) and the final dependency graph are deferred
to that decision.
Locked in a pre-implementation design pass, and since implemented (4.2 checklist S0-S10, HW-validated). This subsection is authoritative for the concrete surface; the notes in 4 above are the reasoning behind it. Where the built code refined a detail, the change is noted inline.
Crate & IR placement (decision A). crates/engine (edition 2024, dual-license,
publish=false). Deps: steam-hid, virt-out, config, vocab, crossbeam-channel,
log; the test binary adds ctrlc + env_logger. Program (the runtime IR) and
compile(&ConfigDoc) -> Result<Program, Vec<Diagnostic>> live in engine, not config
- the IR is the engine's runtime contract (resolved indices, runtime-ready settings),
co-designed with the Mapper;
configstays pure authoring data. (Supersedes the earlier 3 "Program lives in config": it read as config's third pipeline layer when it was still "flattened config"; once it became the runtime's contract it moved to its consumer.)
Control API - one owned Engine handle (sync; owns the worker threads). This is the
exact API a daemon later wraps behind a socket, and the UI calls directly when embedded.
Engine::new(opts) -> Engine // IDLE: control channel + in-memory config only; acquires NO hardware
// staging (idle) - set_input/set_output are staged-only; apply/set_chords also work live (running):
.set_input(Input) // Input = Local(DeviceSelect) | Network(bind_addr); applied at next start()
.set_output(Output) // Output = Local | Network(connect_addr); default Local; applied at next start()
.apply(program, role) // Role = Main | Fallback; callable idle (staged) OR running (live hot-swap)
.set_chords(Option<Chords>) // top-level chords; callable idle (staged) OR running (live hot-swap)
.set_device_config(DeviceConfig) // reader-side device settings; live hot-swap when running (dedicated device_config_tx, machine-local - NOT control_tx), else staged
.start() -> Result<()> // acquire HW: open device (+reader thread), create Sink, run the mapping loop
.stop() -> Result<()> // halt loop, release HW (device->lizard restored, virtual pad unplugged); config RETAINED; start() resumes
.devices() -> Vec<DeviceStatus> // enumerate; works ANY time (idle or running) - just steam-hid::Manager
.status() -> StatusInfo // ONE atomic snapshot { state, input, output, bound: Option<DeviceId>, controller: Option<bool>, main/fallback: Option<String>, active: Option<Role>, device_config: DeviceConfig, chords: Option<Chords> }
.monitor() -> Receiver<MonitorEvent> // low-rate live state for a UI (distinct from the 6 high-rate stream)
.shutdown(self) // ensure stopped, full teardown, consume the handle
- Lifecycle rationale (daemon-friendly):
new()never touches hardware -> construction always succeeds and HW errors surface atstart().stop()releases HW but keeps config in memory -> a daemon pauses/resumes without losing configuration and the process outlivesstop().shutdown()is the explicit full teardown. - Mutability rules:
apply/set_chords/set_device_config= live hot-swap while running (also stageable while idle) -set_device_configpushes on a dedicated reader channel (Runtime::device_config_tx), machine-local, nevercontrol_tx/the 6 wire; a no-op on the server role (no local reader).set_input/set_output= HW bindings, staged only, take effect at the nextstart(); between astart/stoppair the I/O set is fixed (no mid-run device/transport swap).devices()always available. - No profile/file concept in the engine - just program(s) + device-config + chords, each program tagged Main/Fallback. Chords evaluated first, consume their trigger buttons (masked out of the frame before the Mapper), switch Main<->Fallback per-chord hold|toggle. First cut ships SwitchFallback; CommandExecute chords now IMPLEMENTED (was decision B) - run a headless external program on the engage edge, spawned on a detached thread, output logged.
Internal architecture (sync, no async - the 4 model made concrete).
- Reader thread, one per open device. Owns the
steam-hid::Device(Send, not Sync) -> all device writes happen here: the blockingread()loop, the Neptune keep-alive, re-applying device config onConnected, and haptics (both game-rumble and command-activation). Pushes eachReportframe onto a channel to the central loop; receivesHapticReqs back over a per-device channel. - Central mapping loop. Owns the
virt-out::Sinkand the runtime (aMapperper device, the Main/FallbackPrograms, chords).crossbeam-channel::select!over {frame channel, control channel, fallback tick}. Gordon/Neptune stream ~250 Hz continuously while connected, so it's frame-driven; the tick is insurance for rumble/turbo if a stream stalls. Each iteration alsoSink::poll_rumble()-> routes game rumble to the reader thread. - Clock injection. The loop stamps each frame with a monotonic
Tick(Duration since start) at dequeue and passes it into the Mapper. Golden tests feed synthetic(input, Tick)pairs -> no threads, no hardware.
The Mapper (pure, golden-testable core) - retained state: active_set; the layer stack
(persistent Add/Remove entries + held HoldLayer entries); the prev logical frame (raw + virtual
levels -> digital edges); the activator table (per resolved-binding timing/toggle/turbo/double
state); applied levels (last-emitted keys/buttons/abs-axes, for reconciliation); relative
accumulators (mouse + scroll sub-pixel remainders); gyro state (bias/deadzone/integration).
Per-tick algorithm - one fixed ACYCLIC pass (this is how the 4 rules hold):
- Layer state is frozen from last tick (
active_set, stack) - every binding this tick resolves against it. (This is what makes the pass acyclic; layer/set changes apply NEXT tick.) - Resolve activation gaters from the raw frame, up front (physical-only -> all readable before any behavior runs).
- Resolve each input's winning binding: walk active layers highest-declared-order first, first layer that names the input wins, else fall through to base.
- Run behaviors (pad/stick/trigger/gyro) in any order -> abs axes, relative nudges, and virtual button levels (soft-pull, dpad dirs, outer-ring).
- Run all commands on button-like nodes - physical and the virtual buttons from step 4 -
evaluating activators against digital edges (
cur & !prev) +now+ retained timers -> desired key/button levels, toggle latches, turbo fires, queued layer/set actions, haptic requests. - Collect layer/set changes fired this tick -> update stack/active_set for the next tick; a HoldLayer entry is latched to its firing node's held-state (survives that layer re-binding the node).
- Reconcile desired levels vs
applied-> emit only diffs (no stuck outputs across layer changes). Accumulate relative outputs with remainder -> emittrunc(), keep the fraction. prev = input.
Activator state keying: by the resolved binding's stable id (SetId, source, LayerId-or-base, command-idx); when an input's winning binding changes between ticks (a layer overlays it), its
activator/toggle state resets - a half-completed long-press can't bleed into a different binding.
ControllerState -> logical input is the engine's job: a fixed table per DeviceKind
(the Gordon multiplex etc. is already resolved in steam-hid; here it's just which bit/axis is which
InputSource). LogicalFrame = per-InputSource resolved values; behaviors read raw values,
commands read digital levels/edges. Compile stays device-independent (config is); the device table
is applied at runtime and absent inputs are ignored.
Program IR + compile().
Program { meta, sets: Vec<CompiledSet>, default_set: SetId }
CompiledSet { name, base: SourceMap<CompiledBinding>, layers: Vec<CompiledLayer> }
CompiledLayer { name, bindings: SourceMap<CompiledBinding> } // Vec index == declared-order precedence
compile(): run validate() (bail on any Error) -> resolve ActionSetRef/LayerRef names to
SetId/LayerId (the Vec<Layer> index is the precedence, for free) -> flatten base + layer
bindings into per-source lookups -> precompute settings into runtime-ready floats. The
InputSource -> ControllerState resolution is not in compile (it's the device-specific runtime
table).
Networking seam (documented; implemented later - all agreed).
- Seam locked now, only
Localbuilt.set_input(Local|Network),set_output(Local|Network); theNetworkvariants return "unimplemented" for now. Locking the method shape costs nothing and matches 6's "seams = role-swap points." - Transmission = REPORT-side (raw
ControllerStateon the wire); the Mapper runs on the SINK. Rationale: aControllerStateis an idempotent snapshot (drop-tolerant, latest-wins,seqdrops stale) -> safe over unreliable UDP and self-heals on the next frame;OutputEvents are stateful diffs that a dropped packet turns into a stuck output, forcing a reliable channel whose retransmit/head-of-line latency hurts twitch input. (This settles the earlier 6 "emit vs report" openness -> report.) - Server/client split (agreed): the output/game side (PC) is the SERVER - started once with a bind address, headless, survives client disconnect/reconnect, never needs reconfiguring; the controller side (Deck) is the CLIENT - user-facing, owns/authors config + profiles, initiates the connection out to the server. Who-binds-vs-who-connects is independent of who-maps - which is what lets the Mapper sit on the server while all configuration stays on the client you hold.
- Config stays owned on the client, shipped to the server over the reliable control channel at
connect; frames stream over the unreliable UDP data-plane; rumble flows back server->client
(light reliability). So the Deck's networked role is a thin forwarder (device -> net, no local
Mapper); the PC runs a device-less engine = net-receiver -> Mapper -> virt-out (still links
steam-hid, 6: one artifact, role chosen at runtime).
Haptics - v1 model (agreed; Gordon path, provisional).
- Two sources, both -> the reader thread as
HapticReq: (1) game rumble (Sink::poll_rumble-> strong/weak magnitudes), (2) command-activationHaptics(config Round C, on the action's press/ release edges). - From the HID POV (Gordon, 1.9): each haptic is an independent fire-and-forget
0x8fpulse to one physical actuator (left pad / right pad). Different actuators are independent (both can buzz at once). Same actuator can't play two waveforms -> latest-wins (a new pulse preempts). No firmware-side mixing or priority - any arbitration is ours. - Sustained rumble = a pulse train the reader thread re-issues at the configured Hz (per 1.9); the reader thread runs this small synthesizer, holding the current level per pad.
- v1 arbitration = NONE. Command pulses just fire; on a shared actuator a one-shot pulse
briefly interrupts the rumble train (resumes next period), on the opposite actuator fully
independent. Command haptics land on the input's own side, rumble is usually both -> you feel a short
"tick" over the rumble. Acceptable; tune later. Deck (real motors
0xea+ pad haptics) = a different, unverified path -> deferred. - First cut wires only the game->device rumble path (proven in the Phase B bridge);
command-activation haptics deferred (decision C) but the
HapticReqseam is in from day one (Mapper::tickemitshapticsalongsideout).
Gyro/pad polish ordering (decision D). First pass = crude local-space linear gyro + the small radial gyro deadzone (~0.1 px - kills the DC-bias drift, 4/1.9). Next (soon) = 1-Euro (One Euro) filter across gyro/pad/stick relative motion (deferring it defers it everywhere, incl. all three mouse behaviors). Later = player-space gyro, then response curves.
First-cut scope (test binary, deckhand-run or similar). Parse --wired/--dongle, a profile
.ron (+ optional --fallback, --chords, --devcfg), env_logger. Engine::new -> compile() the RON(s) ->
apply() -> set_input(Local(device)) -> start() -> run until Ctrl-C (clean Drop restores lizard,
unplugs the pad - the bridge pattern), then shutdown(). Essentially the Phase B bridge loop, but
driven by a real compiled Program. Build order: Program + compile() (reusing validate())
-> Mapper + golden tests -> manager shell (threads/channels) + test binary. Manager includes
Main/Fallback + the SwitchFallback chord from the start. CommandExecute chords, command-activation
haptics, and gyro spaces are now all IMPLEMENTED (were deferred here).
Ordered so each box is one reviewable, committable, green step; later boxes depend on
earlier ones. Progress marker for future sessions: check the box when its commit lands.
Progress: S0-S10 DONE - the 4.2 checklist is complete (commits c701f5b scaffold ->
f6d3842 IR -> 58273cb compile -> 1775d90 logical -> 0f48ec6 reconcile -> a4af743 Mapper
skeleton -> e35d143 behaviors S6a -> 5d9bd3c behaviors S6b -> b816b8e activators S7a ->
c9d0569 activators S7b -> caf7b45 layer/set actions S8 -> 75a5258 chords/switch S9a ->
3fca718 runtime shell S9b -> 0b6f680 Engine handle + deckhand-run S10). HW-VALIDATED on
the Gordon dongle (mapping/behaviors/activators, HoldLayer mode-shift, main<->fallback chord,
SourceBinding::None, game->pad rumble - confirmed in a real game + jstest); a few sign fixes
landed during validation (cursor/stick Y +up->+down, gyro yaw->-X, scroll coarser than cursor).
Remaining: extended sensitivity tuning, then the deferred list below.
Suggested engine module layout: program.rs (IR), compile.rs, logical.rs
(ControllerState->LogicalFrame), mapper/ (mod.rs state+tick, resolve.rs,
behavior.rs, command.rs, reconcile.rs), runtime.rs (threads/channels), handle.rs
(Engine), error.rs.
- S0 - Scaffold
crates/engine. Cargo.toml (edition 2024, dual-license,publish=false; depsconfig/vocab/steam-hid/virt-out/crossbeam-channel/log/thiserror), add to workspace members,lib.rswith module stubs +Error/Result. Builds empty-green. - S1 -
ProgramIR types.SetId/LayerIdnewtypes,Program/CompiledSet/CompiledLayer/CompiledBinding/CompiledCommand/CompiledAction(refs resolved to ids),SourceMap(per-InputSourcelookup),ProgramMeta{name, role}. Derives Clone/Debug/PartialEq; no Copy. Types only, no logic. - S2 -
compile(&ConfigDoc) -> Result<Program, Vec<Diagnostic>>. ReuseConfigDoc::validate()(bail on anyError); resolveActionSetRef/LayerRef->SetId/LayerId(Vec index = declared-order precedence); flatten base + layer bindings; precompute settings to runtime floats. Unit tests over a bridge-likeConfigDoc(round-trips ids; rejects dangling refs). - S3 -
LogicalFrame+ControllerState->logical (perDeviceKind). Fixed table mapping eachInputSourceto itsControllerStatebit/axis (Gordon multiplex already resolved upstream); absent inputs ignored. Unit test Gordon + Neptune samples. - S4 - Reconciliation + relative accumulators.
AppliedLevels(keys/buttons/abs axes) -> emit only diffs asOutputEvent;RelAccum(mouse+scroll) carrying sub-pixel remainder -> emittrunc(). Pure, unit-tested in isolation (no Mapper yet). - S5 -
Mapperskeleton + binding resolution. Retained state struct;tick(&mut self, &LogicalFrame, Tick, &Program, &mut Vec<OutputEvent>, &mut Vec<HapticReq>); the frozen-layer-state read + winning-binding resolution (declared-order stack walk, else base). Wire buttons->level outputs through S4. Golden test: simple button map. Done:Tick/HapticReqseam types added; onlyButton+Regularwired (held-while-held); layer-precedence resolution in place (poked in a test) but the stack stays empty until S8. - S6 - Behaviors (crude first).
Trigger(soft-pull virtual + analog axis),Joystick/DirectionalPad(shared),AsMouse/JoystickMouse(relative via S4),GyroToMouse(local-space linear + radial deadzone),ButtonPad. Emit abs axes/relative nudges/virtual-button levels; gaters resolved from the raw frame up front. Golden tests per behavior. Done in two commits: S6a (mapper/behavior.rs+ sharedmapper/command.rs) - the non-relative producers (ButtonPad/Joystick/DirectionalPad/Trigger), pure functions of the current frame; pad-as-joystick/dpad gated on touch. S6b - the relative mouse behaviors via a per-tickbehavior::Ctx {cur, prev, dt}feedingRelAccum(Mappergainedlast_tickfordt, and now readsprev). Virtual buttons run througheval_commands(stillRegular-only until S7). Applied: rotation/sensitivity/invert + gyro radial deadzone. Acceleration later implemented (proper, not the bridge stopgap):out *= 1 + speed*factorkeyed off a proper instantaneous speed - AsMouse velocity (|delta|/dt), JoystickMouse deflection, GyroToMouse angular-velocity - so it's poll-rate-independent (the bridge's per-frame-delta version is rate-tied). 1-Euro smoothing later implemented (mapper/smooth.rs) for AsMouse (pad velocity) + GyroToMouse (angular velocity), on the rate signal (frame-rate-independent);JoystickMousesmoothing dropped (a stick is already smooth). Player-space gyro since implemented (gyrospace= Yaw/Roll/YawRoll/PlayerSpace, HW-verified, with a gravity estimate). Still deferred (decision D): response curve on the mouse behaviors; fusion-based gravity + World/Laser gyro spaces. Output gains are starting points, HW-tuned. Axis limit added (2026-08-21): theAxissetting (3) is masked in the output frame, early, so a limited behavior is a genuine 1-D control rather than a post-hoc output null. Ordering (learned the hard way): the mask must precede any coupling stage - the radial deadzone/curve onJoystick/JoystickMouse(else a large discarded-axis deflection carries a sub-deadzone kept-axis value through the 2D magnitude gate, under-scaled), and the 1-Euro filter + acceleration onAsMouse/GyroToMouse(else the discarded axis' speed inflates the kept axis' accel; the 1-Euro filter is per-axis so the mask's position relative to it is neutral, but it's placed before for a uniform 1-D chain). While here,eval_as_mouseandeval_gyro_to_mousewere unified to one op order - rotate -> mask -> 1-Euro smooth -> accelerate -> scale/invert -> (gyro) anti-drift gate -> emit - and the single-useprocess_relativehelper was inlined and dropped (its bundled rotate-at-the-end was what had forced AsMouse to filter before rotating, out of step with gyro). Kept + commented as intentional: AsMouse's base motion stays positional (dt-independent - a same-millisecond tick still applies the finger displacement), gyro's is rate*dt; hence AsMouse's/dt ... xdtwrap around the filter vs gyro filtering the rate directly. - S7 - Activators/commands.
Regularfirst, thenStart/Long/Double/Release, plusToggle/Turbo/Interruptible; activator table keyed by resolved-binding id, reset on winning-binding change. Golden tests incl. interruptible short-vs-long. Done in two commits: S7a -mapper/activator.rs(theActivatorstable keyed by source->BindingKey{Base|Layer}, reset on change; oneSlotStateper button-like node, lazily grown) + the five activator types timed off the injectedTick(Start/Release= taps ofTAP_MS;Longarms afterhold_ms;Double= second press withinwindow_ms);eval_commandsis now stateful and every slot advances even when a behavior is gated off (edges stay correct; reconcile releases outputs). S7b -toggle(latch on the raw rising edge),turbo(50%-duty square wave, periodinterval_ms),interruptible(Regular deferred -> tap on release iff no sibling fired the press). Mapper-level reset-on-rebind golden test. Deferred: scroll-impulse handling (turbo+scroll), command haptics (decision C). - S8 - Layer/set actions + next-tick semantics.
HoldLayer(latched to firing node's held-state),AddLayer/RemoveLayer(persistent stack),ChangeActionSet; changes apply next tick. Golden tests: the 4 pathological cases (self-shadowing HoldLayer stable; non-naming layer keeps base output; two Hold layers stack). Done:mapper/layers.rs(LayerOpscollected per tick;NodeHeldre-derives a hold's trigger held-state - frame-local buttons/group/soft-pull exactly, layer-dependent virtuals read released -> flicker-not-stick);command.rsapply_actionroutes output vs layer/set actions;Mapper::reconcile_layersfolds ops into persistent+held layers and recomputesactive_layers(declared-order) for the next tick; set change is a full swap clearing the stack. All six 4 cases golden-tested (+persistent Add/Remove, +ChangeActionSet-clears-layers). Deferred: the "flicker on a fully virtual/differently-thresholded trigger" is accepted, never sticks. - S9 - Manager shell (threads/channels). Reader-thread-per-device (read loop +
keep-alive + re-apply-on-Connected + haptics writer), central
crossbeam select!loop (owns Sink + Mapper(s), stamps Tick,poll_rumble->reader),HapticReqplumbing (game rumble only in first cut). Global chords evaluated first + button masking; Main/Fallback- SwitchFallback switch. Done in two commits: S9a (
chords.rspure global-chord eval LogicalFrame::masked+Mapper::switch_program- hot main<->fallback swap keeping applied levels; unit-tested) and S9b (runtime.rs: reader thread owns Device; central mapping loop owns Sink+Mapper,select!over {frame,control,8ms tick}, chords->role+mask->tick->emit, rumble poll->reader scaled by master;Runtime::start/stop/control+Controlmessages). Rumble = latest-level pulse train, sent on change. Deferred: command haptics (decision C), status/Battery surfacing (S10).
- SwitchFallback switch. Done in two commits: S9a (
- S10 -
Enginehandle +deckhand-runbinary.new/set_input/set_output/apply/set_chords/set_device_config/start/stop/devices/status/monitor/shutdownover the control channel; the CLI (--wired/--dongle, profile/fallback/chords/devcfg.ron,env_logger, ctrlc -> clean shutdown). HW-validate against the bridge behavior. Done:handle.rs(Engine- staged input/output Local;apply/set_chordsretained + live hot-swap;startopens device [examples' wired-direct/dongle-poll selection] +Sink+ spawnsRuntime;stopjoins + retains config;Error::NotReadyfor misuse);monitorfolded intostatus+devices(Battery/lifecycle surfacing deferred).examples/deckhand-run.rsloads RON ->compile-> drives the stack, ctrlc/SIGTERM clean shutdown; shipsexamples/test_profile.ron(bridge-parity) + a test that compiles it. HW-VALIDATED on the Gordon dongle - full stack (input->Mapper->virtual pad+kbd/mouse, HoldLayer mode-shift, fallback chord, rumble) confirmed working in a real game + jstest; a few output sign conventions were fixed during validation (cursor/stick Y +up->+down, gyro yaw->-X, scroll coarser + own polarity - all inmapper/behavior.rs).
Since done (were deferred past the first cut): CommandExecute chords; command-activation haptics (decision C); 1-Euro filter; player-space gyro; gyro spaces (Yaw/Roll/YawRoll/PlayerSpace). Still deferred: response curve on the mouse behaviors; fusion-based gravity + World/Laser gyro spaces; Deck haptics; Battery/status surfacing and true USB hotplug enumeration (design in 4.3); multi-device -> multi-Mapper; the daemon/socket transport and 6 networking.
Structural refactors. Q2 DONE (2026-08-01): runtime.rs (~815 lines) split into a runtime/
module - mod.rs (Runtime lifecycle + inter-thread types), reader.rs (reader thread + device
writes + reacquire), mapping.rs (the central mapping loop) - pure move, tests unchanged.
Q1 DEFERRED (to-decide): split the pure mapper/ core (+ program/compile/logical/
reconcile/gyro/smooth) into its own crate, leaving engine = handle/runtime/chords/
event. The mapper module is already pure/golden-tested, so the crate mainly buys a hard wall
enforcing that purity (the mapper crate physically can't depend on the runtime/threading code) - a
lasting but mostly-preventative gain. Moderate work (decide which crate owns the shared IR types).
Do it at a natural pause if the enforcement appeals; the Q2 reader/loop split already clarified where
the boundary would fall.
Covers the deferred USB hotplug, Battery/status surfacing, and (surface-sharing) daemon items together. Single active device throughout - several controllers may be connected, but the engine runs/maps exactly one (multi-device -> multi-Mapper stays deferred). Decisions below are agreed but not yet implemented.
-
Enumeration is presence-based (the topology model).
enumerate()returns oneDeviceInfoper Steam gamepad interface present, independent of whether a controller is actually connected to it. The dongle is a composite device exposing 4 slot interfaces (distinguished byDeviceInfo.interface), all present whenever the dongle is plugged; the wired controller is 1. So a dongle + a wired controller = 5DeviceInfos from 2 USB ports, all selectable.Explicit/Transportcan open an idle slot (the interface exists -> opens fine, streams nothing until a controller pairs -> thenReport::Connected);Autoskips idle slots (Auto = "first that streams"). (HW-confirmed 2026-07-31:--donglewith the controller off enumerates all 4 slots.) -
DeviceId- a stable identity decoupled from the path. Reconnect needs an identity that survives replug. hidapi'spathis the/dev/hidrawNnode, reassigned on replug (ephemeral); the real USB topology path is not exposed by hidapi (would need Linuxlibudev/sysfs, per-OS- explicitly out of scope, no udev). So
DeviceIdis derived from the stable public fields:transport+serial+interface(slot) (+pid). Reconnect = matchDeviceIdin a freshenumerate(), then open by the current path -> survives the Linux path changing. Not pinned: the physical USB port (no topology path) - moving the dongle to another port still reconnects; the only loss is telling two identical dongles apart, and same-model-same-serial is pathological, ignored for V1. (Quicklistcheck that the dongle populatesserial; ifNone, a single dongle is still unique viatransport+interface.)
- explicitly out of scope, no udev). So
-
Selection resolves once, at
start(); V1 reconnects only to that exact device. The policy (Auto/Transport/Explicit) resolves once atstart()to one concrete device; the engine pins itsDeviceId. This keeps "device fixed within a start/stop pair" - the policy is irrelevant until the nextstart(). A physical drop ->WaitingForDevice, which waits for that same pinnedDeviceIdand reacquires only it; a different device that also matches the policy is ignored (true failover to a different device is surprising -> deferred, opt-in later, never default). Reconnect is therefore "the same physical device came back," never a mid-run switch. -
Transport-gone (USB loss) ->
WaitingForDevice, notstop. Two disconnect flavors stay distinct. Controller-off-dongle-alive (Report::Disconnectedvalue) is already handled - the reader keeps its interface and waits forConnected. Transport-gone (the interface vanishes; reader read ->Err) currently just kills the reader thread silently whilestatus()still saysRunning- the resilience hole. Plan: on transport-gone, tear down that reader thread, reconcile all outputs to neutral (reconcile already prevents stuck keys), keep the virtual pad plugged (the game shouldn't lose its controller mid-session), and move to a newStatus::WaitingForDevice(Idle | Running | WaitingForDevice) - notstop/shutdown, config retained - then auto-reacquire the pinnedDeviceIdwhen it reappears. -
Topology awareness = on-demand, no monitor thread (DECIDED 2026-08-01 - was a poll-monitor). An always-polling topology-monitor thread (poll
enumerate()~1 s, diff, debounce, pushDeviceAdded/Removed) was planned but dropped as YAGNI.enumerate()re-scans the bus on every call (the D6refresh_devices()fix), so a device plugged in after the daemon started is already discoverable + usable on demand -list-devices->set-input <id>->start, no restart (HW-confirmed). Device plug/unplug is a rare, user-initiated event, so a manual UI refresh (or the UI pollinglist-devicesclient-side only while the picker is open) covers it - a background thread spinning 24/7 to save one click isn't worth it. The never-emittedDeviceAdded/Removedevents were removed (ipc::Eventstays#[non_exhaustive]so they can come back). Far-future option (only if ever genuinely needed): a native OS device-change push - Linux udev / WindowsWM_DEVICECHANGE/ macOS IOKit - which would re-add those events; polling is explicitly not the plan. (Unrelated: the reader's own reacquire poll runs only whileWaitingForDevice, not continuously.) -
Event surface = a logging stub first. The natural API for all this (hotplug add/remove, connect/disconnect, battery, binding lost/acquired) is one
subscribe() -> Receiver<EngineEvent>stream - which is also what Battery/status surfacing needs and what the daemon must serialize onto its socket. Rather than commit that API early, stub it now: wherever anEngineEventwould be emitted, log a shapedlog::info!("event(stub): ...")line - no channel, no API commitment, but the stream is visible in the log and doubles as the spec. Three kinds are detectable in today's code: controller connect/disconnect (reader already sees them), battery (reader already gets0x04), topology add/remove (once the monitor lands). Then: real event channel -> more event kinds -> daemon serialization - all later. -
Sequencing & open points. Build this after / alongside the daemon, since the event+status surface is shared (fold in Battery and the transport-gone resilience fix while doing the daemon). Concrete first symptom to validate against:
--donglewith the controller off ->NotReady("no matching controller streamed")(fromopen_device'smanybranch, which needs a stream to disambiguate the 4 slots) - the poster-child for "start()intoWaitingForDevice". Open: cold-start "dongle present but no controller" (a live idle interface) vs "USB device gone entirely" (nothing) are different states and may deserve distinct handling - do not flatten them into oneWaitingForDeviceprematurely. Assumption:hid-steamis blacklisted on the dev box (raw hidraw open unobstructed); deferred check of hotplug/open behavior with the kernel module loaded (it would claim a freshly-appeared device -> reacquire needs the detach dance). Two disconnect flavors, two behaviors (both DONE): transport-gone (unplug) -> D5 waiting phase (reader dies,release_all, keep pad,WaitingForDevice); controller-off-dongle-alive (battery/ range) -> mapperrelease_alls onReport::Disconnectedbut stays connected (reader alive, resumes onConnected) - fixes a real stuck-output bug (held stick kept a game character running). DEFERRED (to-decide):release_alldrops outputs but keeps mapper latches (toggles, active layers), so a toggle re-asserts on reconnect - revisit whether a disconnect should reset transient mapper state too (raised 2026-07-31; don't lose it).
The daemon (deckhandd) and a thin CLI client (deckhandctl) that drives it over a local
socket. Both are new binary crates wrapping the engine library (engine stays library-only).
Sequenced with 4.3 (shared event/status surface). (Self-daemonization / --background was
considered and dropped: under systemd or a Windows service you run foreground and the
supervisor manages you; manual runs use Ctrl-C. No daemonization code at all.)
deckhandd - one binary, two uses. It is both a standalone runner (pass everything on the
CLI -> a working mapper, Ctrl-C to quit) and a controllable daemon (a client connects and drives
it further). It always opens the control socket; the two uses intermingle freely - the CLI seeds
initial state, the client mutates it live (apply/set_chords hot-swap, set_input/set_output
stage). Promote the current deckhand-run example into this binary crate; engine stays a
pure library.
CLI = a thin, explicit seed for the engine. Options:
-m/--main <profile.ron>->apply(_, Main)-f/--fallback <profile.ron>->apply(_, Fallback)-c/--chords <chords.ron>->set_chords;-d/--devcfg <devcfg.ron>->set_device_config-i/--input <dongle|wired|STRING|host:port>->set_input.dongle/wired=Transport;STRING=ExplicitbyDeviceId(the 4.3 stable-id string - parse it or error; this is the "sanitization");host:port= theNetworkinput (6 sink role - engine variant currently stubbed).-o/--output <local|host:port>->set_output.local=Local;host:port= theNetworkoutput (6 source-role sender - engine variant currently stubbed). Symmetric with--input.-s/--start-> callstart()after seeding.-l/--list-devices-> the one non-persistent option: enumerate the attached devices, print theirDeviceIdstrings to stdout (same format asdeckhandctl list-devices), and quit before any seeding or the socket is served. Handled first, ahead of-m/apply().-v/-vv/-vvv-> verbosity (unchanged).
The seeding rule - pass-through, with exactly one convenience. The daemon issues to the engine
only the calls whose options were actually given - no automagic. Sole exception: when
--start is passed, a missing --input/--output is defaulted (--input->Local(Auto),
--output->Local) so start() has a source/sink - but only because --start was passed.
Without --start, set_input/set_output fire only for explicitly-given options. Profiles are
never defaulted (there is no default program): --start with no --main -> start() returns
Error::NotReady, which is logged, and the daemon keeps serving the socket (identical to a client
asking to start an under-configured engine). --start itself is a real command (it calls start());
only its defaulting of a missing -i/-o is the convenience.
deckhandctl - thin one-shot client + a follow monitor. One-shot commands mirror the Engine API
1:1: main/fallback (ship a profile), chords/devcfg (ship above-profile config),
start/stop/shutdown, list-devices,
select-device, status. Plus a persistent monitor mode (-f/follow, like journalctl -f)
that streams events and prints them (lands with the real event channel, 4.3 - a stub until then).
Connect -> send one request -> print reply -> exit; monitor stays connected until Ctrl-C.
Who compiles: the client ships ConfigDoc, the daemon compiles. compile() lives in engine;
a thin client must not link it. So main/fallback ship the ConfigDoc (RON) and the daemon
compiles + applies, returning diagnostics over the socket. This keeps the client genuinely thin and
is the same split as 6 networking (config over the wire, compile + Mapper on the sink) -> free
groundwork for the remote-controller role. The client may depend on config for optional local
pre-validation, but the daemon is authoritative.
Crate structure (decided). The wire protocol (request/response/event types, framing, codec) is
shared by all sides, so it lives in one dedicated IPC library crate (ipc) holding
the message enums + a Client helper + a server-listener helper. Deps: serde + postcard +
interprocess + config (messages carry ConfigDoc) - not engine. Then deckhandd =
ipc + engine; deckhandctl = ipc (thin); the UI in daemon mode also
depends on ipc and drives the daemon over the same socket - so the crate is the single
client-side entry point for both deckhandctl and the UI. (Preferred over putting the client lib
inside deckhandd: a crate has one dependency set, so depending on the deckhandd crate would
transitively drag engine/steam-hid/virt-out into a thin client. The shared IPC crate keeps the
client clean while still keeping both sides' socket code in one crate.)
The control socket.
- Location (Linux):
$XDG_RUNTIME_DIR/deckhand.sock(under/run/user/<uid>/). Per-user, cleaned on logout. - Cross-platform, minimal code: the
interprocesscrate - a unified sync local-socket API over Unix domain sockets (Unix) and named pipes (Windows), light and well-maintained (fits the dependency policy, no async/tokio). The only platform-specific code is the name:$XDG_RUNTIME_DIR/deckhand.sockvs\\.\pipe\deckhand(onecfg). - Wire format: length-prefixed
postcardmessages (compact, reuses serde; RON was considered fornc-debuggability but rejected as a wire format). OneRequest, oneResponse, oneEventenum. Low-rate control plane. - Single-instance: binding the socket/pipe is the lock - a failed bind = "already running", no pidfile needed (clean up a stale Unix socket file on start).
- systemd socket activation (BUILT - Linux user session):
deckhandd --systemdadopts the listening socket systemd binds and passes viaLISTEN_FDS(fd 3) - it never binds or removes one itself (systemd owns the socket's lifecycle) - and reports readiness withsd-notify(READY=1once accepting,STOPPING=1on teardown; unit isType=notify). Thesd-notifycrate (listen_fds()validatesLISTEN_PID, sets the fd close-on-exec;notify()sends the state) is the one dependency. Adoption is clean because the socket-setup was already isolated inipc: the daemon does the onlyunsafestep (fd -> std::os::unix::net::UnixListener::from_raw_fd), reads the socket's real bound path off it viagetsockname(local_addr()) - used for the log line and the Ctrl-C/SIGTERM self-connect wake, so shutdown works regardless of the unit'sListenStreamname - then hands the listener to a safeipc::Server::from_unix_listener(-> interprocessuds_local_socket::Listener, which carries no reclaim name soDropwon't unlink the systemd socket -> genericlocal_socket::Listener).incoming()/Connand the whole serve loop are untouched.--systemdwith noLISTEN_FDSis a hard error (never a self-bind fallback), and it drops env_logger's own timestamp (the journal already stamps each line). Non---systemdruns bind their own socket as before (single-instance = the bind, stale-socket dance, remove-on-exit). See 4.5 for the two units + enablement model. (Windows service still deferred.) - Serving = thread-per-connection (DECIDED 2026-08-08 - was serial). The original accept loop
served one connection to completion, blocking on
recv()for that client's next request - so a client holding its connection open and idle (the daemon-mode UI's persistent command connection) wedged the accept loop, and any second client (deckhandctl) hung indefinitely. OnlySubscribeescaped it (already handed to its own thread). Now each accepted connection is served on its own thread over a sharedArc<Mutex<Daemon>>: a request briefly locks the engine, so a persistent-connection UI, an event-stream subscriber, and an occasionaldeckhandctlare served concurrently. Engine access stays serialized by the mutex (handling is fast; the mapping loop runs on its own threads regardless -> networking/engine internals untouched).EngineisSend(all fieldsSend;hidapi::HidApiis just aVecdevice-list cache) so there's nounsafe.Daemon::shutdownnow takes&mut self(releases HW in place after the accept loop, rather than consumingself), and the event monitor runs on the connection's own serve thread (no separate spawn). Regression test: a client holding its connection open must not block a second one.
Builds 4.3 + 4.4 across several sessions. Order is dependency-driven - a usable controllable
daemon exists after D3, then resilience/hotplug/events layer on. Each item is its own reviewable
commit(s), HW-validated on the Gordon dongle where it touches device lifecycle. engine stays a
library; deckhandd/deckhandctl are new binary crates. Prefix D (daemon phase).
- D0 -
DeviceId(stable device identity).steam_hid::DeviceId(kind+transport+interface(slot) +serial) withDisplay/FromStr(grammarkind:transport:interface:serial, e.g.gordon:dongle:1:ABC; serial = verbatim remainder);DeviceInfo::id();as_str/from_tokenonDeviceKind/Transport;Error::ParseDeviceId.DeviceSelect::Explicitnow carries aDeviceIdandopen_deviceresolves it against a freshenumerate()(opens by the current path -> survives replug, 4.3).listexample prints the id. Still to verify on HW: that the dongle populatesserial(single-dongle stays unique via transport+slot regardless). - D1 -
ipccrate (wire protocol). New lib crate:Request/Response/Eventenums (+ProfileRole/StatusSnapshot/RunState; messages carryconfig::ConfigDoc, selection specs- device ids travel as strings the daemon parses), length-prefixed
postcardframing (clean-EOF ->Ok(None)), andinterprocesslocal-socketClient/Server/Conn(Unix domain socket / Windows named pipe; only the socket name is platform-specific). Deps: serde/postcard/interprocess/config - notengine. Codec unit-tested + a real client<->server round-trip integration test. clippy clean.
- device ids travel as strings the daemon parses), length-prefixed
- D2 -
deckhanddbinary (daemon). New binary crate (engine+ipc+config). CLI-m/-f/-g/-i/-o/-s/-v; the pass-through seeding rule (only given options applied;--startthe sole convenience, defaulting a missing-i/-otoauto/local; profiles never defaulted ->--startwith no-mlogsNotReadyand keeps serving). Socket at$XDG_RUNTIME_DIR/deckhand.sock(override$DECKHAND_SOCKET, or-k/--socket <path>per-invocation - see the post-pass note), bind = single-instance lock + stale-socket dance; request/reply -> theEnginehandle (serving was serial; now thread-per-connection, 4.4); Ctrl-C/SIGTERM (ctrlctermination) wakes accept via self-connect -> clean shutdown + socket removed. Client shipsConfigDoc-> daemon compiles ->Ok/Diagnostics. Input/output spec parsing shared by CLI +SetInput/SetOutput. Also derivedDebug/Cloneon engineInput/Output/DeviceSelect. End-to-end subprocess test drives the real binary over a temp socket (no HW). HW-validated on the Gordon dongle (deckhandd -m ... -f ... -g ... -s: mapping + Main/Fallback chord switching confirmed, socket at/run/user/1000/deckhand.sock); the olddeckhand-runexample is retired (superseded by the daemon). - D3 -
deckhandctlbinary (client). Thin one-shot:status,list-devices,select-device <id>,input/output <spec>,main/fallback(shipConfigDoc) +chords/devcfg(shipChords/DeviceConfig),start/stop/shutdown. Depends onipc+config, notengine. Connect -> one request -> render reply -> exit (errors/diagnostics -> stderr + non-zero).$DECKHAND_SOCKETfolded intoipc::default_socket_pathso daemon+clients agree. End-to-end test (realdeckhandctlvs an in-process fake daemon) + live-checked against a realdeckhandd(status / select-device / list-devices showing the 4 real dongle slots / shutdown). - D4 - Status + event stub + battery surfacing.
Status->Idle | Running | WaitingForDevice(the variant; its transition is D5) + mapped in the daemon.engine::EngineEventdefined (ControllerConnected/Disconnected,BatteryChanged,BindingLost/Acquired,State; theDeviceAdded/Removedvariants were later dropped with the poll-monitor - 4.3), the mirror ofipc::Event. No channel yet (D7):EngineEvent::emit()logs anevent(stub): ...line. Wired now: the reader emits connect/disconnect (replacing the ad-hoc logs) +BatteryChanged(edge-triggered off the ~1 Hz0x04report -> surfaces the deferred Battery item at stub level); the handle emitsState(Running)/State(Idle)on start/stop. (4.3 event surface.) - D5 - Transport-gone resilience ->
WaitingForDevice. Reader on transport-goneErrsets a sharedwaitingflag + emitsBindingLost+ exits (device drops -> lizard). The mapper splits into a connected phase and a waiting phase (run_waiting): on frame-recv error withwaitingset itrelease_all()s every applied output (drops all held keys/buttons/axes regardless of toggles/latches- a golden test covers it), keeps the pad plugged, emits
State(WaitingForDevice), and idles on{control, tick}accepting config hot-swaps + draining the pad's FF uploads (so a game's rumble thread can't block).Runtimecarries the flag;Engine::status()->WaitingForDevice. Control handling factored intoapply_control(shared by both phases). Fixes the silent-dead-reader hole. HW-validated: dongle unplugged mid-session -> readerErr->BindingLost+State(WaitingForDevice)events,status()->WaitingForDevice, daemon survives with config retained (main/fallbackstill loaded). (Auto-reacquire is D6.)
- a golden test covers it), keeps the pad plugged, emits
- D6 - Seamless reattach (auto-reacquire, pad never leaves). Chose Option B (keep the
Sink/mapper alive across the outage) over A (restart - blips the pad, risks Proton not re-enumerating). The reader is now a persistent device-session loop:read_sessionreads until stop/transport-gone; on transport-gone it flagswaiting+BindingLost, thenreacquire()polls its own lazily-createdManager(~1 Hz) for the pinnedDeviceIdto reappear, reopens it, mints fresh channels, emitsBindingAcquired+State(Running), and sendsControl::Reattachto the mapper. The mapper alternates connected/waiting phases in an outer loop with swappable frame/rumble/ click channels, so the sameSinkserves throughout - the game never sees a disconnect.start()pinsdevice.info().id()(reacquire targets the exact resolved device, 4.3). Prerequisite bug fixed:Manager::enumerate()nowrefresh_devices()first (hidapi caches at context creation -> a long-livedManagernever saw hotplug; brokedevices()freshness and reacquire);enumerate/open_firstare&mut self. Key correctness detail (HW-found): on transport-gone the reader must drop its old frame/rumble/click channels so the mapper'sframe_rxdisconnects and it enters the waiting phase - else the mapper stays connected (never releases outputs, never emitsWaitingForDevice) and the laterReattachis ignored there -> channels never swap -> no mapping + lizard stuck on.apply_device_cfgis non-fatal (a transient feature-write NAK must not tear down the reader) and reader-thread errors are logged (a silent death cost two debug round-trips). HW- VALIDATED: unplug+replug the dongle mid-game ->WaitingForDevice-> reattaches to the same device, controller never drops in-game, mapping + chord-switch resume, lizard off. (General topologyDeviceAdded/Removedevents + the poll-monitor were later dropped - see 4.3 "Topology awareness = on-demand". The cold-start "dongle present, no controller" state Just Works via Explicit selection opening an idle slot - HW-confirmed.) - D7 - Real event channel + client monitor.
engine::EventSinkbroadcastsEngineEventto subscribers (Arc<Mutex<Vec<Sender>>>; still logs each at info);Engine::subscribe() -> EventStream(thin wrapper hiding the channel). The sink is cloned into the reader+mapping threads and held by the handle, so every emission point feeds one stream;EngineEventis no longer#[non_exhaustive](a new variant should break the daemon's wire map).ipc:Client::subscribe(). Daemon: the serve loop interceptsSubscribeand turns that connection's own serve thread into the client's event pump (monitor), mappingEngineEvent->ipc::Eventand streaming it - so a long-lived monitor no longer blocks request/reply (now moot - every connection is per-thread, 4.4).deckhandctl monitorsubscribes + prints events until close/Ctrl-C. Verified: fake-server monitor test + a live real-daemon check (subscribe logged, concurrent shutdown still served, stream closes cleanly) + HW-validated (live device events over the socket during real use).
This implementation pass is DONE (D0-D7, all HW-validated). The daemon + client are complete and
usable. Windows: HW-tested by the user - deckhandd/deckhandctl over the named-pipe socket work.
Post-pass refinements (redundancy + status snapshot). (1) Daemon holds no shadow state: the
Daemon struct is just the Engine - status is read back from the engine, not kept in parallel copies;
Input/Output/DeviceSelect carry Display+FromStr so a reported spec round-trips straight back to
set_input/set_output. (2) Status unified into one snapshot: an intermediate step exposed five
engine getters that the daemon reassembled field-by-field, re-diverging the engine and wire shapes; that
merged back into one Engine::status() -> engine::StatusInfo, mirroring the single wire reply. The
parallel types follow the project's naming convention (Status/RunState, Role/ProfileRole):
engine StatusInfo is typed (input: Input, output: Output - the engine is the richer layer)
while the wire mirror ipc::StatusSnapshot carries the stringified round-trip specs, the daemon
stringifying in one status_info() map. (3) Bound device surfaced in status + on every bind
(2026-08-02, for the UI): status reported the staged input (which may be a policy like
auto/dongle), never the concrete device actually in use, and BindingAcquired(DeviceId) fired only
on reacquire (D6), never the initial bind - so a UI attaching to a live daemon could not learn what is
bound. Two complementary fixes. (a) status field: Engine gained bound: Option<DeviceId>, captured
at start() from the reader's pinned id (a cheap clone of a value the handle already holds before it
moves into the Runtime - not shadow state: the id lives across the reader thread boundary, so the
handle keeping a copy is its natural home, same category as staged input/output), cleared at stop(),
and left untouched through the waiting phase so it keeps reporting the id being reacquired while
WaitingForDevice. Threaded StatusInfo::bound: Option<DeviceId> (typed) ->
ipc::StatusSnapshot::bound: Option<String> (stringified) -> deckhandctl prints a bound: line. For
Auto/Transport selections this adds information the snapshot lacked (staged input=auto vs
bound=the resolved concrete id). (b) event symmetry: start() now emits BindingAcquired(pinned_id)
before State(Running) (mirroring the reader's reacquire order), so every bind - initial and reacquire
- emits
BindingAcquired, symmetric withBindingLost; a live subscriber no longer special-cases the first. The two halves are the standard subscribe-then-snapshot-to-seed pair: the event serves a client already subscribed at bind time, the status field seeds current truth for one that connects afterward. No IPC wire-map change for the event (ipc::Event::BindingAcquiredalready existed). (4) Control-plane sync - staged/profile/above-profile events + full above-profile config in status: so a UI attached to a running daemon stays current when another client mutates it on the side. New absolute-valued events fire from the handle setters -InputStaged/OutputStaged(the staged selection changed),ProfileSet{role,name}(a program was applied to a role), and (post-2026-08-19-split) the two above-profile setters emitDeviceConfigSet(DeviceConfig)+ChordsSet(Option<Chords>)(the old singleGlobalConfigSet(GlobalConfig)is gone) - mirrored on the wire (ipc::Event; the daemon stringifies specs / mapsRole->ProfileRole).StatusInfo/StatusSnapshotalso gained a fulldevice_config: DeviceConfigfield plus achords: Option<Chords>field (kept last), so onestatus()seeds a client's complete view and later changes stream as events. Above-profile config travels whole, profiles by name - the daemon holds only a compiledProgram(no serde back toConfigDoc) whereasDeviceConfig/Chordsare uncompiled, andipcalready depends onconfig; the crate boundaries make this the only shape that fits. Events carry absolute state (not deltas) -> the subscribe-then-status-to-seed race stays safe (a duplicate is fine, a gap impossible). Live active-role ("switch profile") event - BUILT 2026-08-11, see the "Live thread-state readbacks" entry below (ActiveRole(Role)+status().active; the follow-up sketched here - a role edge from the mapping thread plus the live role fed tostatus()via a shared atomic likewaiting- is exactly what landed). (5)--socket/-kon both binaries: override$DECKHAND_SOCKETand the default with an explicit control-socket path (Unix) / pipe name (Windows) - needed to run a second daemon on its own socket for 6 localhost network testing, and the only socket override that works on Windows (the env var is Unix-only). Both binaries resolve the default identically (ipc::default_socket_path/DEFAULT_PIPE_NAME);ipc::Client::connect_default/Server::bind_defaultwere dropped in favor of the explicitconnect_path/bind_path(Unix) /connect_name/bind_name(Windows), removing the client/daemon asymmetry. A bind failure stays fatal.
systemd integration (BUILT - Linux user session). Two user units ship in systemd/, installed
(not enabled) by install.sh into ${XDG_CONFIG_HOME:-~/.config}/systemd/user with the service's
ExecStart rewritten to the resolved $CARGO_INSTALL_ROOT/bin/deckhandd:
deckhandd.socket-ListenStream=%t/deckhand.sock(%t=$XDG_RUNTIME_DIR, matchingipc::default_socket_path(), so an un-configureddeckhandctlconnects there and triggers activation),Accept=no(one long-lived daemon serves all connections),SocketMode=0600,RemoveOnStop=yes;WantedBy=sockets.target.deckhandd.service-Type=notify,ExecStart=... deckhandd --systemd --verbose(soinfo!reaches the journal),Requires=deckhandd.socket+After=deckhandd.socket,Restart=on-failure;WantedBy=default.target.- Enablement model (enable one, not both): enabling only the socket -> lazy activation (first
deckhandctlconnection starts the daemon on demand); enabling only the service -> always-on from login -Requires=pulls the socket up first so the fd is always socket-activated (the daemon never self-binds under--systemd). A.socketpasses its fd to the same-named.serviceby default, so no explicitService=is needed. Enabling both is redundant (harmless - the service never binds), so we don't (no[Install] Also=). See 4.4 for the fd-adoption mechanics.
Still deferred (not now): a proper Windows service (the named-pipe socket compiles and the
daemon is user-tested on Windows, but no service wrapper is built). The --background/daemonize path
stays dropped (4.4) - real supervisors run the daemon foreground.
Binding-event/status consistency + profile clear (2026-08-09). Two related follow-ups the UI bake-off surfaced.
(1) Binding events now track the bound-device lifetime. The earlier "(3)(b) event symmetry" model
emitted BindingLost on transport-gone and BindingAcquired on both initial bind and reacquire -
but status().bound (the pinned id) stays set across an outage and is only cleared at stop(), which
emitted no binding event. So a UI mirroring bound off the event stream never cleared its
bound-device display on stop, while status() reported nothing bound - a visible disparity. Fixed by
making the two binding events bracket exactly the self.bound lifetime: renamed BindingLost -> BindingRemoved, emitted at stop() alongside State(Idle); BindingAcquired stays start-only.
The reader's transport-gone/reacquire path no longer emits binding events - it emits
State(WaitingForDevice)/State(Running). The mapper already emits those same edges, so locally
they're duplicated (idempotent - the events are absolute-valued), and on a 6 network split (reader on
the controller machine, mapper on the PC) each side's copy is the only run-state signal its own
subscribers see. Net invariant: BindingAcquired <=> bound set (start), BindingRemoved <=> bound cleared (stop), so the event stream and status().bound never diverge. All four ui-test consumers
clear their bound field on BindingRemoved. (Supersedes the reacquire-emits-BindingAcquired behavior
noted in the (3)(b) narrative above.)
(2) Clear a profile role (Some -> None). Apply could only set a role's program; there was no
way to unset it, so a game-launch wrapper couldn't hand control back to the fallback when the game
exits. Threaded Option through the whole apply path - ipc::Request::Apply{config: Option<Box<ConfigDoc>>}, Engine::apply(Option<Program>, role), Control::Apply{program: Option<Box<Program>>}, Uplink::Apply{program: Option<Program>} - where None clears the slot.
apply_control re-seeds the live mapper via program_for, so clearing main resolves straight to
fallback (both-empty still falls through to the shared empty placeholder). deckhandctl main "" (an
empty string) clears the role; the daemon skips compilation on a clear and emits ProfileSet{name: None}. Enables semi-automatic per-game switching with no chords/UI: deckhandctl main game.ron ->
launch the game -> deckhandctl main "" on exit reverts to the desktop/fallback profile. The clear
round-trips over the wire (clear_role_survives_the_tcp_frame_codec), so it works networked too.
Live thread-state readbacks - controller-connected + active-role (2026-08-11). Two pieces of
live state that lived inside the runtime threads and so were invisible to status() (a pull built
only from handle-local fields): the reader's controller-presence and the mapper's live role (which
flips on SwitchProfile chords - the deferred item above). Both surfaced the same way, mirroring the
existing detached/is_waiting precedent: a Runtime-owned Option<Arc<AtomicBool>> the owning
thread writes and the handle reads in status(), paired with a value-carrying event. The
Option is Some only in the roles whose thread runs - controller-connected in local + client
(a reader exists), fallback_active->active-role in local + server (a mapper exists) - and None
otherwise, which is the honest answer: a forwarder's live role is on the remote server, a server has
no local controller, an idle engine has neither. StatusInfo gained controller: Option<bool> +
active: Option<Role>; the wire mirror ipc::StatusSnapshot gained controller: Option<bool> +
active: Option<ProfileRole>.
- Events carry their value uniformly. The old split
ControllerConnected/ControllerDisconnectedpair was merged intoControllerConnected(bool), and a newActiveRole(Role)parallelsState(Status)(the profile-mode edge; distinct fromProfileSet, which reports a program loaded into a slot, not which slot is live). Wire:ipc::Event::ControllerConnected(bool)/ActiveRole(ProfileRole). Both are store-before-emit (the atomic is never staler than the last event), so seed-then-subscribe stays race-free. - Controller presence is presumed present at every
read_sessionstart (all transports) and cleared on transport-gone. On wired/BT the controller is the transport, so there's no connect/disconnect report - this presume is the only "connected" signal. It's needed on the dongle too: the receiver announcesConnectedonly on the first open, not on a re-open of an already-on pad, so without the presume a secondstart()would sit atfalseforever. The cost is a brieftrue->falsewhen a dongle slot is opened with the pad off (the receiver then reportsDisconnected) - accepted, since a correct settled state beats no state (an earlier attempt to guard the presume out on the dongle broke exactly the re-open case).set_connectedalways emits (no change-detection): every call site is a genuine transition, a rare duplicate is a harmless absolute-valued repeat, and always-emitting is what lets a first reportedDisconnectedthrough past the flag's defaultfalse. - Active-role publishes at mapper loop start (the initial role - always Main since the
2026-08-19 split dropped
start_profile/start_role; the chord evaluator'sfallback_baseseeds from Main - always emitted so a UI seeds from the event alone) and on each chord switch (already guarded on a real change). - Housekeeping:
EngineEvent::emitlogs at debug, not info (events are per-transition and got noisy;status/monitorare the normal-usage surface). Thread-fn arg order settled to a convention: the twoArc<AtomicBool>s adjacent (running+ the readback flag),eventslast.deckhandctl statusprintscontroller: Connected|Disconnected|(none)andactive: Main|Fallback|(none);monitorrenders both events.
Purpose. The user-facing configurator: create/edit mappings, manage profiles / action sets, and drive the running mapper.
GUI (decided). The ui app is a GUI. (The only TUI in the project is the
steam-hid test harness, 1.8 - kept deliberately for ANSI fun.)
Toolkit (to test). libadwaita is the preferred look, but its Windows portability
is doubtful - GTK4 itself runs on Windows (MSYS2 / gtk-rs), but libadwaita is
GNOME-oriented, non-native and heavy to package there. Action: test whether
libadwaita is viable on Windows at all; if not (likely), fall back to iced or
egui (portable, pure-Rust), which is the default expectation.
Tray (decided). The app lives in the system tray regardless of topology -
whether it ends up as just the configurator or configurator + embedded engine. The tray
is also the natural place for manual profile switching (3) - pick the active profile,
drop to the desktop/fallback profile - since automatic game-detection is out of scope
initially. No toolkit provides a tray natively (verified in the 5.1 bake-off - not iced,
egui, cxx-qt, nor gtk4); the plan is the tray-icon companion crate (from Tauri;
cross-platform Linux/Windows/macOS), run alongside whichever toolkit - so tray support does
not bias the toolkit choice.
Dependencies (contingent on the 4 deployment model).
- Embedded (engine library linked into UI):
uidepends onconfig+engine- it embeds the runtime. - Daemon (engine library hosted in its own process):
uidepends onconfig+ipc(4.4) and drives the daemon over the socket - notengine.
So under the daemon deployment ui links config + ipc (the shared IPC crate,
never engine); the embedded one links config + engine instead. Resolved together with 4.
Per-behavior setting scales (design note, from acceleration tuning). A setting shared across
behaviors can have a different sensible range per behavior - the clearest case is acceleration
factor (pad velocity / gyro deg-s want ~0.02-0.05, stick->mouse deflection 0..1 wants ~1-4; see
config::Acceleration), but sensitivity/deadzone may differ too. Since the three mouse behaviors are
distinct SourceBinding variants with distinct settings structs, their settings panels are already
separate widgets, so each can carry its own slider range - this is a non-issue as long as no shared
setting widget hardcodes a range. Rule: any reusable setting widget takes its range/scale as a
parameter from the parent behavior widget. Escape hatch if the raw per-behavior scales are annoying:
expose a normalized 0-100 % "amount" in config/UI and let the engine map it to the real
per-behavior factor (the engine already owns the speed-scale + gain knowledge) - a uniform slider
everywhere; a config-model decision to make when building the UI.
Before committing to a toolkit, build the same representative UI slice in each candidate
(qt/gtk/iced/egui) and compare look, feel, and how each handles the daemon wiring
(blocking-call-off-the-render-thread + live events). Structure: five opt-in crates -
ui-test-common (lib) + ui-test-{qt,gtk,iced,egui} (bins) - added as workspace members but
excluded from default-members so a plain cargo build never pulls a GUI stack. Build one to a
polished baseline, refine, then copy the design to the rest. ui-test-iced is the reference
implementation (retained-mode Elm architecture - ports cleanly to gtk4/Qt, both retained-mode). The
design below is the baseline for the tests and for the future real UI (5).
Navigation model (adopted from the Steam Deck configurator - NOT the old visual-controller one). Four window-width regions in the widget hierarchy: top daemon bar / left sidebar / scrollable content / bottom status bar. The sidebar is a category list that swaps the content pane entirely; the content scrolls within the window. Sidebar layout: per-input-type categories up top (Buttons/Triggers/Joysticks/Trackpads/Gyro - the profile-edit screens), application-level entries pinned at the bottom (Settings, Globals; later profile load/select, maybe daemon mgmt). This same list-swaps-content model serves both the application shell and, within it, profile editing.
The four regions.
- Top bar (daemon management): Start / Stop / Connect (enable driven by run-state), a manual
Refresh (no USB-hotplug push - this re-enumerates), and input/output pickers on the right
(input = static presets
auto|dongle|wired|bt+ the daemon's livelist-devices; output = presets). Buttons left, pickers right. - Left sidebar: the category nav (above).
- Content: the selected screen, scrollable. Per-input-type screens follow the Steam-Deck list model - grouped rows (Face Buttons / Bumpers / D-Pad / ...), a group "Behavior" picker, and a per-input gear that drills into that input's detail. (In the test, Buttons is a mockup of this; the rest are stubs.)
- Bottom status bar: live engine status - connection, run-state, bound device, Main/Fallback
program names, chord count (everything from
StatusInfoexcept input/output, which live up top).
Application Settings (distinct from the daemon's config). RON-persisted, in the UI's own file
($XDG_CONFIG_HOME/deckhand/ui-test.ron), NOT the daemon: start-the-daemon-on-launch, load Main/
Fallback on start + their profile paths (each path greyed when its toggle is off; native file picker
via rfd), and the theme stored by name (toolkit-independent - each toolkit maps the name to one
of its own built-in themes, falling back to its default). Globals is a read-only widget preview for
now (item deferred).
Daemon wiring (the architecture that matters - daemon-mode UI, 4.4). The UI links config +
ipc, never engine; it ships ConfigDoc and the daemon compiles. Connection model:
- One persistent event-stream (monitor) connection + short-lived per-command connections. This works cleanly because the daemon is thread-per-connection (4.4) - the persistent monitor and the occasional command connection are served concurrently.
- Commands run off the render thread (each toolkit in its own idiom - iced
Task::perform, gtk glib async, Qt signals/QtConcurrent, egui worker-thread + repaint), so a slowstart()(e.g. dongle present, controller off) never freezes the UI. - Seed once, then deltas. On (re)connect: fetch one full
Statussnapshot +list-devicesto seed; thereafter the absolute-valued event stream carries every change, applied as in-place deltas to the cached status - no per-event refetch. Devices have no hotplug event (by design, 4.3), so the manual Refresh + the on-connect seed cover topology.
Shared vs per-toolkit (what ui-test-common owns). Everything toolkit-independent, so each view
layer stays thin: AppSettings (+ RON load/save), a blocking Client over ipc (reconnects
on a dropped socket), run_event_loop (a resilient blocking subscribe loop each toolkit bridges
into its own event pump), the Category nav enum, and the input/output presets. Each ui-test-*
crate owns only its widget layer + the dispatch glue (running blocking calls off its render thread
and draining events) - and that glue is part of what the bake-off compares.
Running notes as each ui-test-* is built - impressions that matter for a long-lived, config-heavy
app, not just the toy slice. Updated as toolkits are added; the choice is made once all four exist.
iced 0.14 (BUILT - reference).
- Shape: retained-mode, Elm architecture - explicit
State/Message/update/view, split cleanly across files. More boilerplate up front; structure scales well and forces a tidy state model. This is why it's the reference: it ports almost mechanically to the other retained-mode toolkits (gtk4/Qt). - Async is first-class:
Task::performruns blocking work off the render thread and feeds the result back as aMessage; subscriptions bridge the event stream. The threading story is hidden and clean - no manual repaint/wake. - Theming: ~22 built-in themes (
Theme::ALL), eachDisplay-named - trivially wired. - Widgets:
pick_list/checkbox/text_input/scrollable/containerall present and composable; helper-fn API (row!/column!macros) reads well. Styling via theme presets + per-widget closures. - Friction: a few type-inference/HRTB papercuts (closure annotations for
theme/subscription builders; boxing the subscription stream). The functional builder API means deep widget trees get verbose. Font fallback rendered all glyphs (dot / warning / refresh / gear) fine. - Verdict so far: the safe, structured choice; best fit for a large configurator that will grow.
egui 0.36 / eframe (BUILT).
- Shape: immediate-mode - one file, state + drawing interleaved, redraw every frame, no message/task loop. Fastest to stand up a screen; less structure as it grows (mutation happens mid-layout). Being immediate-mode it's the odd one out - a refined egui design does not port directly to the retained toolkits.
- Async is fully manual: no
Task- spawn a thread per command + the event monitor, push results over anmpscchannel, and callctx.request_repaint()to wake the UI, which drains the channel each frame. Works well andui-test-commoncarried over unchanged, but the plumbing iced hides is yours to write here. - Theming: only built-in Dark/Light - that picker is two entries (vs iced's list).
- Widgets: everything needed is there, but the API churned noticeably vs iced - panels unified
into one
Paneltype,SelectableLabelremoved (->Button::selectable),show_inside->show. More breaking renames to absorb across versions matters for a long-lived app. - Friction:
&mut selfinside immediate-mode closures fights the borrow checker (collect a choice into a local, act after) - a recurring papercut iced's message model sidesteps. Right-alignment is manual (Layout::right_to_left). Reactive repaint (eframe only redraws on interaction/request_ repaint), so no idle CPU spin. - Verdict so far: great for quick/lightweight or highly-dynamic UIs; the immediate-mode model and API churn are the concerns for a big, long-lived configurator.
cxx-qt 0.9 / Qt 6 (BUILT).
- Shape: not a Rust-native toolkit - the UI is QML (
qml/main.qml) backed by a RustBridgeQObject (#[cxx_qt::bridge]). Abuild.rsrunscxx-qt-build(moc + a C++ compile, ~45-60 s rebuilds). Two languages: declarative QML for layout + Rust for logic. Powerful and very mature widget set (Qt Quick Controls, Material/Universal styles, native dialogs), but the most moving parts by far. - Async / threading: blocking work runs on std threads; results are marshaled back onto the Qt
thread with
CxxQtThread::queue(the only safe way to touch a QObject off-thread). Clean once understood;ui-test-commoncarried over unchanged. - Bridge boilerplate: every value QML reads is a
#[qproperty]; every action an#[qinvokable]. Qt types (QString,QStringList, ...) must be declared inside the bridge (extern "C++"+cxx-qt-lib/....h). By default Rustsnake_casestays snake_case in QML - add#[auto_cxx_name]for idiomatic camelCase. Property/invokable count balloons for a config-heavy UI. - Theming: the richest - all Qt Quick Controls styles; Material
themeDark/Light switched live from QML. - Friction: the QML/Rust split means errors surface at runtime in QML (undefined property / "not a function") rather than at compile time - the weakest safety of the four. Heavy build (C++/ moc). Needs a C++ Qt install (see below), which dominates the Windows/packaging story.
- Verdict so far: the most capable and polished UI, but the heaviest to build/ship and the least Rust-safe (runtime QML errors, two languages, native Qt dependency). Its strength (QML + Controls) is also its cost.
gtk4 0.11 / GTK 4 (BUILT).
- Shape: retained-mode, GObject/C-backed (auto-generated
gtk-rsbindings). Build widgets once, hold handles, mutate on events - imperative, noviewfn. Mature, complete widget set with real accessibility and native desktop integration; everything is a refcounted GObject (clone handles freely). - Async / threading: the glib main loop. Off-thread work posts over an
async-channeldrained byglib::spawn_future_local; widgets are mutated on the main thread. Clean;ui-test-commoncarried over unchanged. - Boilerplate: the heaviest closure/clone dance of the four - an
Rc<Ui>cloned into every signal handler,RefCellfor shared state, and a suppress-guard so programmatic dropdown updates aren't taken as user picks.glib::clone!helps but it's verbose. - Theming: real GTK themes, but runtime app control is limited to the light/dark preference (no per-app palette like iced/Qt) - it follows the desktop theme. libadwaita would add the adaptive/GNOME look but is impractical on Windows, so this build is pure gtk4.
- Friction: a native GTK4 dependency (pkg-config) that dominates the Windows story (gvsbuild/
MSYS2 + bundled DLLs). Widgets are runtime-typed (
upcast/downcast), so some mistakes surface at runtime. The Rc/RefCell + GObject-signal discipline is a constant low-level tax. - Verdict so far: the most "native desktop" feel + best accessibility, but the heaviest Windows deploy of the Rust-driven options and the most closure/clone ceremony. A Linux-first choice.
Native libraries & Windows packaging (investigated for all four). Decisive for a cross-platform (Linux and Windows) app. No toolkit here auto-downloads its native deps in a way we can rely on.
- iced / egui (Rust-native): nothing to install on Windows. They bundle their renderer
(wgpu/glow) + windowing (winit); only GPU drivers needed.
rfdfile dialogs are native. Defaultx86_64-pc-windows-msvctoolchain, done. This is a major point in their favor. - cxx-qt / Qt: needs a real Qt 6 install at build time -
qt-build-utilsfinds it via theQMAKEenv var -> elseqmakein PATH; no auto-download (an experimentalqt_minimaldownloader exists inqt-build-utilsbut cxx-qt-build 0.9 doesn't expose it, and "minimal" lacks Qt Quick - unusable here). Windows: install Qt 6 (Qt online installer /aqtinstall/ vcpkg), use the MSVC build to match Rust's MSVC target (cxx compiles C++ - don't mix with MinGW Qt unless switching to the GNU Rust target); pointQMAKEatqmake.exeor put Qtbinon PATH. Runtime: bundle Qt DLLs + the QtQuick/Controls/Material QML plugins withwindeployqt. The app code is platform-agnostic; only the toolchain/deploy differs. (On the Gentoo dev box Qt 6.11 is installed; build withQMAKE=qmake6.) - gtk4 (gtk4-rs): links native GTK4 via pkg-config /
system-deps; no auto-download. Windows: either gvsbuild (builds GTK4 for MSVC -> MSVC Rust target, setPKG_CONFIG_PATH+ PATH) or MSYS2 (pacman -S mingw-w64-x86_64-gtk4-> GNU Rust target); gvsbuild is the more shippable. libadwaita on Windows is GNOME-oriented and painful to package -> expect pure gtk4 there. Runtime: bundle GTK DLLs + loaders/schemas - the heaviest deploy of the four. - Bottom line: to ship on Windows - iced/egui = zero native setup; Qt = install Qt (MSVC) +
windeployqt; gtk4 = gvsbuild + bundle. The Rust-native pair is dramatically simpler to cross-target, which weighs heavily for a tool meant to run on both Linux and Windows.
Summary / leaning (all four BUILT - input for the decision, which is the user's). Every toolkit
rendered the same slice fine and reused ui-test-common unchanged; the split is architecture,
Rust-safety, and shipping - not looks.
- iced - front-runner for a large, long-lived, cross-platform configurator: structured retained (Elm) model that scales, compile-time-safe, first-class async, ~22 themes, and zero native deps on Windows. Cost: verbose builder trees, some HRTB papercuts.
- egui - strong for quick/lightweight or highly-dynamic UIs; also zero Windows native deps. The immediate-mode model (state mutated mid-layout, more borrow-checker friction) and the heavier API churn are the reservations for a big app.
- cxx-qt / Qt - the most capable + polished (QML + Controls), but the heaviest and least Rust-safe:
two languages, runtime QML errors, a C++/moc build, and a native Qt install +
windeployqtto ship on Windows. - gtk4 - the most native-desktop feel + accessibility, but Linux-first: most closure/clone ceremony, and the worst Windows deploy (gvsbuild + bundled DLLs, no libadwaita).
- Where it points: the two Rust-native toolkits dominate on cross-platform simplicity and compile-time safety; between them, iced for a UI meant to grow, egui if speed-to-build or a highly-dynamic UI matters more. Qt/gtk win on widget maturity but pay for it in native dependencies + Windows packaging.
- DECISION (2026-08-09): iced, pending a confirming Windows build. Chosen for zero-native-dep
cross-platform shipping, compile-time safety, the structured retained model that scales for a
config-heavy configurator, and being the most polished of the four prototypes (
ui-test-icedis the reference to promote into the realuicrate). egui is the fallback if the app stays small/ dynamic. Tray is atray-iconcompanion crate regardless (5).
The in-app editor for a ConfigDoc profile: action sets, layers, per-input bindings, behaviours,
commands, settings, gaters, and global chords. Built over many sessions (2026-08-13 -> 2026-08-15).
Per the "UI changes are NOT documented" convention this was the one documented UI area. This
section is the durable capture of that design + the knowledge gathered building it so nothing is
lost - decisions and
rationale, NOT code shape (the code is read directly). Where a decision was labelled A-F in the
working notes, that letter is kept here as the stable reference.
Status. Feature-complete for the owner's needs on Linux: Profile page (sets/layers), the five
input pages (commands/activators/subcommands), command settings, the per-behaviour settings
framework, rumble, gaters + global chords over all hardware buttons. Remaining work is
optional/deferred, not blocking: device greying (decision D) and Windows compile-verification of
the win-only paths (virt-out axis buttons, ico/png build step). Future sessions are expected to
be styling/finetuning (undocumented) plus those deferrals if ever wanted.
Module layout (where the code lives). The editor is split into a mutation side and a view side:
editor/mod.rs (the doc-mutation seam + semantic types), editor/authoring.rs (authoring
defaults), editor/settings.rs (the settings edit path - apply_setting + &mut accessors);
view/editor/mod.rs (editor chrome + per-screen views), view/editor/settings.rs (settings
forms/blocks), view/modal/{mod,action,buttons}.rs (the modal shell + the two pickers),
view/{device,chords,profiles,settings}.rs (the old view/globals.rs became the device + chords
pages in the 2026-08-19 split), and nav.rs (category->input-group mapping). The
settings subsystem is deliberately split into its own file on each side, next to (not inside)
the respective mod.rs, mirroring editor/authoring.rs.
Mutation discipline (the load-bearing invariant). editor::update is the single
doc-mutation site - every change to the ConfigDoc funnels through it. This is what makes undo
(deferred) cheap to add later (snapshot a Vec<ConfigDoc> there + a Ctrl-Z message) and keeps the
Elm model coherent. Keep that discipline. Settings edits reach it through editor::settings::apply_setting.
Navigation & category layout (decision C - Steam-style, static). Sub-buttons (clicks/touches,
trigger full-pulls) nest under their parent input's page (pad click/touch on the Trackpads
page, etc.), never a generic Buttons page. A rich-source page = header (source name) + behaviour
selector + its sub-buttons. Mapping is encoded statically in nav (nav::Category->input-group).
Addressing sets/layers - EditTarget is name-based, not index-based. EditTarget { set: String, layer: Option<String> }. Set names are unique among sets; layer names unique among a set's own
siblings (both enforced by the editor's uniqueness checks), so the pair is a stable key that
survives reorder/removal - an index would silently shift. Used two ways with independent values:
the sidebar's selected target (Editing::target) and the identity each Profile-page bar's gear acts
on (built from that bar's own name). Renaming a set/layer also repoints action refs
(ChangeActionSet globally; Hold/Add/RemoveLayer per-set).
Sidebar selector scopes the input pages. The sidebar action-set/layer carousel selector
sets EditTarget; the five input editor pages edit that set/layer's bindings. The Profile page
lists action sets with their layers nested/indented beneath (Steam-style); set/layer
create/rename/delete via per-bar gear context menus + unique-name dialogs; new profiles = one
Default set, no layers; Duplicate/Create-new open for editing via load_for_editing. The Profile
page management and the selector address the same set/layer via EditTarget. A set's gear hosts
"Add layer".
Modal system - one shared Popup enum. The old network Popup struct became a single enum (the
one modal shown at a time; backdrop click-away = generic PopupCancel): Network | Menu | NameEntry | ActionPicker | CommandMenu | SlotMenu | ButtonPicker. All modals live in view::modal (the
shell, the Popup enum, every card); the semantic types (EditTarget, NameEntryKind,
CommandRef, ActionTarget, ...) stay in editor.
The two pickers.
- Action picker (
view::modal::action, decision F): a fixed-size (840x520) tabbed modal (Gamepad / Mouse / Keyboard / Numpad / Action Sets) returning oneconfig::Action, immediate-confirm on click (no OK). Gamepad's LT/RT + the eight stick directions are enabled (they emit the axis pseudo-buttons - vocab-out). Keyboard/Numpad cover all 127Keys via an auto-complementing "Other keys" grid. Action Sets usespick_listscoped to the edited set's layers. It writes to anActionTargetstored in the popup. Resolved as tabbed-by-category, not a search field. - Button picker (
view::modal::buttons): single-select chooser of all 28 rawvocab_hid::Buttonhardware bits in grouped rows (face A/B/X/Y, dpad, bumpers, full-triggers, grips, system, stick/pad press and touch).Popup::ButtonPickercarries aButtonTarget(Gater(input)/Chord(i));ButtonPicked(Button)routes the pick to the gater edit (SetSetting -> AddGater) or the chord (app.chords.chords[i].buttons). - Vocab labels (
action_label/key_label/button_label/...) are UI-owned (both vocab crates stay presentation-free); gamepad labels are fully descriptive (noLB/L3).
Input-page wiring (the five per-input pages). Each renders the selected set/layer's
BTreeMap<InputSource, SourceBinding> (editor::current_bindings). Rich sources + button groups get
a behaviour combobox (Behavior::valid_for -> default_binding; None/Unbound = remove the
map entry). Switching behaviour rebuilds from authoring defaults and discards the old binding's
commands (accepted - no merge). Plain buttons + sub-buttons have NO combobox - the command bar
is the SourceBinding::Button (created on first action). Which virtual buttons a behaviour exposes
= virtual_slots; group members = group_member_slots.
Command model (multi-command, subcommands, gear menus). A slot is a Vec<Command>; a Command
= activator + actions (main = actions[0], subcommands = actions[1..], an ordered
press-in-order combo). Layout: 0 = <unbound> (inert gear); 1 = one command bar (main action
button + gear) with its subcommands double-indented; >=2 = a top slot bar (label + gear, no
action button) over one indented "Command (activator)" bar each - every command bar keeps its own
action button, only the top slot bar drops it. Non-Regular activators show a blue "(Long press: 450
ms)" suffix; every action (main or sub) is click-to-repick; subcommand bars have a x remove, no menu.
Addressing = CommandRef { input, slot, index } + an ActionTarget enum (Replace { cmd, action }
/ AddCommand { input, slot } / AddSubCommand { cmd }) the picker writes on confirm.
ActivatorKind (UI enum) drives the activator combobox (Long = 450 ms, Double = 200 ms defaults;
menu stays open on change). Gear menus: command menu (Remove / activator / Settings -> the command
settings sub-page / Add sub command / + Add extra command only when sole); slot menu (Remove
all / Add extra). Remove-all drops a plain-Button entry from the map but only empties a rich
binding's slot vector (the behaviour persists). Duplicate activators are allowed (two Regulars =
a key + a gamepad button on one press - deliberate; no "offer only unused activators" guard).
One button-label source (view::slot_display). slot_display(input, slot) -> (&str, Dot) is the
single place a button's name + Xbox glyph-colour dot is decided (Y=yellow / A=green / X=blue /
B=red; dpad/virtual/plain names), rendered by the shared view::label_row. Used by both the command
bars and the gear-menu titles (so a menu shows the same "[dot] Y Button" as its bar). A future
glyph/colour/name change is one edit here.
Behaviour three-state per layer: something / Inherited / Disabled (BUILT). Per-InputSource a
layer has three states - something / no entry (Inherited, falls through) / explicit
SourceBinding::None (Disabled, nullifies the base). Behavior (UI enum, drift-guarded by of())
has a Disabled variant: of(SourceBinding::None) = Disabled; a missing entry is the caller's
Unbound fallback. valid_for(kind, on_layer) - a layer adds [..., Disabled, Inherited] (Inherited
= the relabeled Unbound, always last). SetBehavior: Unbound->remove entry, Disabled->insert
None, real->default_binding.
- Rich sources + button groups carry all three via the combobox (Inherited dims the "Behavior" label = passthrough; Disabled reads normal; gear inert for both).
- Standalone
SourceKind::Buttoninputs carry it via the bar:<inherited>(dim label) /<disabled>(normal), both clickable to add a command (soapply_action::AddCommandreplaces aNone); the gear opens a smallLayerButtonMenu-> Disable (inherited) or Remove (disabled -> drops theNone). NoInherit- Remove goes to no-entry.on_layerthreads through the input-page view fns;editor::on_layer(app)feeds the menus. - Menu order (all contexts): command menu leads with Remove (above the activator); slot menu is Remove all; Disable sits at the very top for a top-level Button on a layer. The Profile-page set/layer context menu likewise leads with Remove (above Rename).
Settings pages are a full-page sub-route, NOT a modal (paradigm, decision 2026-08-14). Every
settings page (command settings + per-behaviour settings, same shape) renders instead of the
current input page via content() (view/editor::settings_screen short-circuits the category
dispatch), reached from a gear menu, left via a < Back button. Chosen over a modal so the pattern
scales to the larger behaviour-settings forms, reuses the (scroll-fixed) content pane full-width, and
keeps "which command/behaviour" as navigation state. State = Editing::settings: Option<SettingsView>
(enum: Command(CommandRef) | Behavior(InputSource)); cleared by ANY navigation
(Message::Navigate and the target selector's step_target) so a CommandRef/behaviour ref never
outlives its set/layer - the page doesn't change app.category, so Back falls back to the input page
the sidebar still highlights. Form style = plain label/control/readout rows (the Globals/Rumble
aesthetic, SET_LABEL=160), NOT card() bars. Sliders: .step()-snapped + a right-hand value
readout (iced has NO tick marks; snapping is free, notches would need a custom widget - declined).
Command settings contents (all autosave through editor::update, applicability-gated per
decision B): the activator kind combobox (duplicated from the gear menu - deliberate, so the page
is the command's complete home) + its additional-settings row (activator_additional_settings:
Long hold_ms 100-2000 step 50 / Double window_ms 100-1000 step 25 / Regular Interruptible
checkbox - the activator's own parameter, one per kind); Toggle; Turbo (checkbox gates a
20-500 ms step-10 rate slider, seeded DEFAULT_TURBO_INTERVAL_MS=100; hidden on Release); Haptics
edge (Off/OnPress/OnRelease/Both) + Strength (Low/Med/High, shown only when the edge is on).
Haptic edge/strength labels are UI-owned (config stays presentation-free).
Per-behaviour settings = a building-block framework (BUILT 2026-08-14). Not one bespoke page per
behaviour - a reusable block per setting element (deadzone, sensitivity, curve, smoothing,
acceleration, invert, rotation, outer_ring, soft_pull, output, layout, space, activation) composed by
a small per-behaviour fn in the canonical field order. Shared view AND logic: (1) blocks emit
one generic EditorMessage::SetSetting(InputSource, SettingEdit) where SettingEdit is a sum
type, one variant per field (NOT a god-struct - the typed config settings structs stay the source
of truth); (2) editor::settings::apply_setting writes that one edit via small &mut accessors over
SourceBinding (deadzone_mut/sensitivity_mut/... -> Option<&mut T>), so mutation isn't duplicated
across the six behaviours. Adding a behaviour = one compose fn from existing blocks (+ any new
block). The behaviour-row gear opens the page iff Behavior::has_settings() (true for the six
analog behaviours; false for Button/ButtonPad and the pseudo-behaviours - governs ONLY the
behaviour-row gear; a plain Button's gear is a command menu, untouched). Curve = a Linear/Power kind
picker + an exponent slider when Power. Smoothing = an enable checkbox gating min_cutoff/beta.
Activation = the mode picker + the gater set editor.
Button-set editor = chips + the single-select Button picker (gaters & chords share it). Both
Activation.gaters and Chord.buttons are Vec<vocab_hid::Button> (raw controller bits - any
button incl. face buttons and dpad directions, so Steam + A / face+dpad gaters work), so one shared
widget edits both: view::button_chips(buttons, on_remove, add) renders each button as a removable
chip (view::chip, style::chip) with a trailing + that opens the Button picker
(OpenButtonPicker(ButtonTarget)); the pick appends (deduped for gaters). Chosen over a
bar-per-button (too heavy) and over a new multiselect modal (reuses the existing picker; upgradeable
to multiselect later without touching the chips). Gaters live on the behaviour Activation block
(add via SetSetting::AddGater, remove RemoveGater). Chords live on the Chords page (split
out from the old Globals page in the 2026-08-19 split, now separate from the Device page): one
card bar per chord - the trigger chips, an action-kind combobox (ChordActionKind Switch-profile
/ Run-command), its detail (SwitchMode picker, or a single command-line text field), and a x to
delete; an "Add chord" button below. Chord edits mutate app.chords and go through apply_chords
(persist + push; chords::to_push collapses an empty set to None). Command-line round-trip: the one field is split on the literal ' ' (keeping
empties) into command + args and rejoined with ' ', so the field's exact text (incl. trailing
spaces) survives per-keystroke parsing without cursor mangling - cost is transient empty args while a
trailing space exists.
Canonical behaviour-settings field order (config structs + settings pages MUST match). output ->
behaviour-specific (layout/space/soft_pull/outer_ring, at position 2 right after output) ->
sensitivity -> acceleration/curve -> smoothing -> deadzones (deadzone, anti_deadzone) ->
invert -> rotation -> activation. Applied to the config::settings struct definitions (which
drive RON field order), the example_profiles/engine-test literals, and the compose fns. When
adding/reordering a settings field, keep struct def + compose fn + any literal in this order.
interruptible lives on the activator, defaults on. Moved off CommandSettings onto
Activator::Regular { interruptible: bool } (it was only ever meaningful on Regular -> invalid combos
now unrepresentable, and the engine's runtime matches!(Regular) guard is gone). RON:
Regular(interruptible: false). New Regulars are born interruptible (the authoring default),
single-sourced in ActivatorKind::to_activator (editor::apply_action routes through it): a no-op
when the Regular is the node's only command (no Long/Double -> the engine's has_interrupter gate
makes it a plain press/release hold) and the intended short-vs-long behaviour the moment a
Long/Double is added - safe under the reworked engine activator model (3/4). Editor:
SetInterruptible rewrites the whole activator like SetHoldMs/SetWindowMs; the checkbox lives in
activator_additional_settings.
Ownership line (firm). config = data model + serde + validation. The UI owns ALL
authoring/construction of config values (that's what an editor is). So empty-profile creation,
authoring defaults, and the Behavior tag all live in the UI - config gained nothing for the
editor and shouldn't. (A ConfigDoc::new_empty() was reverted for exactly this reason.)
Two "default" layers - keep distinct. serde Default (in config) is the neutral/minimal
on-disk fallback (0/None/whatever's safe) - untouched. Authoring defaults
(Behavior::default_binding, in the UI) are the context-tuned starting values a freshly-picked
behaviour gets. Authoring defaults are UI-only because the only moment a tuned default is needed is
when the user picks a behaviour in the editor - no ctl/test/engine path needs them.
UI slider ranges: per-behaviour, NOT per-input, and NO table. They live inline in each behaviour's
settings-view function. Rationale: the only knobs that differ are velocity-signal ones (accel
factor, 1-Euro min_cutoff/beta) and the difference is pad-vs-gyro = AsMouse-vs-GyroToMouse =
per-behaviour (distinct settings types), not per-input. It's small in practice (gyro accel 0.02
vs pad ~0.05, min_cutoff 1 vs 3, beta identical 0.5) because the engine's per-behaviour
gain constants already absorb the deg/s-vs-pad-units/s scale gap. A single shared range per field is
fine; per-behaviour narrowing is a free later block param.
Save model + undo (decision A). Autosave on edit (no dirty flag - matches Steam, small files,
closed option set). No undo for now, deferred; safe to add later because every doc mutation
funnels through editor::update (keep that discipline) + the Elm model -> later a Vec<ConfigDoc>
history snapshotted there + a Ctrl-Z message.
Validation UX (decision B). The editor makes invalid states unrepresentable (offer only
behaviours valid for the source kind; gaters/chords are vocab_hid::Buttons so "not a physical
button" is type-enforced, not validated). Valid-but-no-effect is allowed (e.g. HoldToEnable
with an empty gater - legal to the mapper). config::validate() stays the apply-time backstop.
No live-diagnostics panel.
Two "Set as Main/Fallback" paths are intentional (decision E) - do NOT merge them. The top-bar buttons are editor-scoped: they send the in-memory edited profile, are shown on every editor page, and are labelled by the profile name - for edit-and-test-immediately. The Profiles-page grid sends the selected on-disk file - daemon management. Different workflows; merging harms the test-while-editing flow.
Device greying (decision D) - NOT built. Grey inputs the connected device lacks
(config::Shape::has), behind a toggleable UI setting. When building it: cleanly knowing the
connected device's Shape means either parsing the bound id prefix (gordon:/neptune: -
interim/stringly) or adding kind/Shape to StatusSnapshot (a protocol/engine change =
functional, deliberately out of scope for the editor build). Decide then.
A second, deliberately minimal UI crate (iced; bin deckhand-forwarder), built as a
prototype - the throwaway question below is unresolved.
What it is. A single-screen, touch-first "quick connect" tool: pick a local controller input,
type an ip:port output on an on-screen numeric keypad (3x4 phone layout 1-9/. 0 : + a
backspace; every key edits the field regardless of focus), set a master-rumble slider, press Start.
No tray, no sidebar, no profiles/chords, no editor. Fixed daemon policy (no settings screen):
launch-the-daemon-if-absent (spawned with --prevent-sleep so the Deck doesn't idle-sleep while
forwarding), restore the last input only, push the device config, never auto-start (Start is
always manual; the output is set from the text field verbatim at Start, not on edit). Whole UI
drawn at a global 1.75x scale (the Deck's touch display makes default widgets too small); the
window opens near Deck-native (1280x800) and the content pane scrolls if the scaled layout overflows.
What it's for. The Steam Deck - its touch screen, forwarding controller input to a PC over
the LAN (the 6 client/forwarder role). On a normal PC the full deckhand UI already does the
client job (it can set_output(host:port)), so the forwarder exists only for the Deck's
touch-driven quick-connect case, where the full UI is awkward.
Construction. A deliberate duplicate of the parts of deckhand it reuses - the daemon
client + resilient connect loop (trimmed to the fixed policy), RON persistence, a few styles -
copied, not shared (no common crate). Own settings file forwarder.ron
(theme/window_{width,height}/last_input/last_output_network); shares devcfg.ron with
the main UI (loads the full struct, mutates only the bound device's rumble, preserves the rest).
Reacts to the
daemon event stream for what it displays (state / controller / bound device / input); the output
field is user-owned and never overwritten by an event. Workspace member but NOT a
default-member (heavy iced tree - build with -p forwarder-ui). Linux install is opt-in:
install.sh -f/--forwarder adds the binary + icon + .desktop + a ~/Desktop launcher symlink.
Open question (unresolved). It's unclear the forwarder is worth keeping. Options when revisited:
(a) drop it if the main UI grows a good enough touch/quick-connect mode; (b) keep it standalone;
(c) factor the duplicated daemon/persistence/style code into a shared crate the two UIs import.
Deferred deliberately - treat the crate as throwaway until this is decided; per the "UI changes
NOT documented" rule its internals live in the code (crates/forwarder-ui/src/main.rs), not here.
Goal. Use a Steam Deck (or any machine with a Steam controller) as a controller for a different computer over the network - Deck in hand, mapping + virtual output on the gaming PC. Low-latency, LAN-first.
Feasibility. Very doable - the Steam Remote Play / Steam Link / Moonlight input pattern. On a LAN, UDP input latency is sub-ms to a few ms, well within what games tolerate. It also validates the ports-and-adapters split: the network is just another adapter around the pure engine.
Architecture - one engine, network as an adapter (refinement of the original "two
engines communicating" idea). You want a single mapping engine, not two. The pipeline
steam-hid -> engine -> virt-out is simply cut at one seam and stretched over the wire.
Two app roles:
- Source role (on the Deck):
steam-hid+ a network sender. ReadsControllerState, ships it over UDP. Minimal, "dumb" end. - Sink role (on the gaming PC): network receiver ->
engine->virt-out. The mapping engine and theconfiglive here (natural - games are configured on the PC).
The net sender/receiver are peers of the local adapters: a network input source (peer
of steam-hid) feeds the engine exactly like a local device would. (So "two engines" is
really two app roles; the engine logic runs only on the sink side.)
Recommended seam: send raw ControllerState (cut at input->engine). State is small and
fixed-size, config/engine stay centralized on the PC, and the Deck end stays trivial. The
seam is flexible - you could run the engine on the Deck and send output events instead -
but state-forwarding is the cleaner default.
Bidirectional. State flows Deck->PC; commands flow back PC->Deck - rumble/haptics (the 2 back-channel becomes a network round-trip) and any config the source needs.
Transport: UDP.
- Forward (state): unreliable UDP, latest-wins. A dropped
ControllerStateis harmless - the next packet supersedes it a few ms later. Use the reportseq(u32) to drop stale/out-of-order packets. No TCP (retransmit stalls add latency / head-of-line blocking to a continuous stream). - Backward (commands): light reliability. Rumble/config are events, not idempotent state -> sequence numbers + optional resend, or accept occasional rumble loss.
- Send rate matches the device (~250 Hz-1 kHz); tiny packets -> negligible bandwidth.
Where the code lives. Likely a small shared crate (link / net) holding the wire
format + UDP transport, used by both roles - or adapters split across steam-hid /
virt-out. Decided later. Future capability, tackled after the local pipeline works;
the crate split already makes it a natural extension, not a rewrite.
Later concerns. Pairing/auth (don't accept input from arbitrary hosts - LAN, so minimal at first); no time-sync/prediction needed for direct input passthrough (unlike video streaming); WiFi adds a few ms jitter vs wired.
Goal of this refinement. Contain all the network complexity in one place so it does
not smear across reader / mapping / runtime. The fear is legitimate - a naive network
implementation would leak sockets, seq handling, reconnection, and serialization into the two
device-driving loops. The fix is to abstract the inter-thread communication seam itself,
not the transport mechanism via a heavy trait hierarchy. This is the same ports-and-adapters
move used everywhere else in the project, applied to the reader<->mapping seam.
The abstraction. Today the two threads talk over crossbeam channels (frame, rumble,
click, plus the control channel from the Engine handle / daemon). Wrap those channels in a
small type - CommChannel - with two adapters chosen at Runtime::start() (from the
staged Input::Local vs Input::Network): a local adapter (the channels are direct
crossbeam pairs - nearly free) and a network adapter (sockets + hidden bridge threads).
reader and mapping are rewritten to talk only to CommChannel and become identical
across local and network - the prize. No network concept leaks into their code; the only
network-specific surface is the constructor argument (IP:PORT).
Two endpoint types, not one symmetric class. The directions and method sets differ, and they map cleanly onto the already-locked client/server roles (client = controller/Deck side; server = output/PC side) - so name them accordingly:
LinkClient- the device side (reader), always hosted where the controller is (the client):frame_tx(),rumble_rx(),click_rx(),control_tx(), plusdetach()/reattach()lifecycle methods.LinkServer- the mapper side (mapping loop), always hosted where the output is (the server):frame_rx(),rumble_tx(),click_tx(),control_rx().
This is not a new vocabulary - it's the same client/server split the docs already use, now
naming the types, and it checks out method-by-method: config/control flows client->server, so
control_tx sits on LinkClient (the reader's reattach path and the Deck's config uplink)
and control_rx on LinkServer; frames flow client->server, rumble/clicks server->client.
Each type is an enum internally (Local | Network) - runtime dispatch, matching "one class,
two implementations underneath, chosen at construction." No dyn, no trait zoo. Locally there
is no network: both LinkClient and LinkServer are co-located in one process, wired by
crossbeams - a loopback link. That's a feature, not a hack: it reinforces "one artifact,
role chosen at runtime" - the reader always talks to a LinkClient, the mapper always to a
LinkServer; whether the link is a crossbeam loopback or real sockets is the adapter's
business.
Where it lives - in the engine, NOT a separate crate (decision 2026-08-02, supersedes the
"small link/net crate" note above). The Link{Client,Server} types must live at the
engine seam because they carry engine types (Program, Control/RumbleCmd/Click, Report)
- so put the whole thing in
engine/src/runtime/link.rs(besidemod.rs/reader.rs/mapping.rs). A separatelinkcrate is actually awkward, not just unnecessary: an engine- type-aware crate would depend onenginewhile the engine's Network adapter depends on it -> dependency cycle. The only cycle-free split is a payload-generic transport (T: Serializeover UDP+seq+TCP+reconnect, knowing nothing aboutProgram) - but that is small and, crucially, has no second consumer (the network client is a full engine deployment; deckhandctl doesn't network), so the old shared-crate rationale (from the pre-"one engine" framing) no longer applies. Iflink.rsgrows: first aruntime/link/submodule (mod/client/server/wire), and only if the generic-transport part gets genuinely heavy, extract a payload-generic crate later - never an engine-type-aware one. Matches the "refactor freely, no premature abstraction" policy.
The reader's control_tx() is the crux of what must be abstracted: today (reader.rs:85) the
reader, on reacquiring a device, mints fresh frame/rumble/click channels and hands the
mapper's ends over via Control::Reattach so the pad never leaves. That channel-swap dance
becomes a CommChannel internal - the reader just calls detach() when the device is lost and
reattach() when it returns, and the type does whatever its adapter requires underneath.
Key mechanism - expose crossbeam Receivers, do NOT invent a poll(). CommChannel
returns the actual crossbeam endpoints (&Receiver<Report>, &Receiver<Control>, ...). Both
adapters produce the same crossbeam endpoints:
- Local:
frame_rx()is a plain crossbeamReceiverpaired with the reader'sframe_tx(). - Network:
frame_rx()is the receiving end of an internal, in-process crossbeam channel fed by the adapter's hidden socket threads - frames arrive over both UDP and TCP (the idempotency split below);control_rx()is fed by a hidden TCP thread. The crossbeams are always local (thread<->thread); there is no such thing as a "network crossbeam". The bridge threads are created and owned by the network adapter and are completely invisible toreader/mapping/runtime- those three never know the extra threads exist.
Because crossbeam select! operates on &Receiver, the mapping loop's existing
select! { frame, control, tick } composes with CommChannel verbatim - the mapper is
unchanged whether input is local or networked. The tick insurance arm stays a local timer
outside CommChannel (it provides two of the three arms; the timer is the third). This is
why no custom multiplexing poll() is needed: crossbeam already gives blocking-with-timeout.
Wire contract = Program (decision B, 2026-08-02). The engine's internal control channel
already carries Program (Control::Apply { program: Box<Program> }), and the Runtime never
calls compile() - compile(&ConfigDoc) -> Program is an external convenience the daemon
uses before apply(). So the network path ships Program, keeping the compiler external
(the client/Deck compiles ConfigDoc -> Program before the wire, symmetric with the local
daemon compiling before apply()) and keeping the engine core compile-free. control_rx()
then yields Control::Apply{Program} from both the local Engine handle and the remote TCP
feed with no compile step in the adapter - local and remote config apply transparently
through the same receiver. This extends the 4.4 "client ships ConfigDoc, daemon compiles"
decision: that still holds for the local ipc client (deckhandctl -> daemon, thin, no engine);
the network client (Deck forwarder) is a full engine deployment that compiles locally, so
compile diagnostics surface on the client, where the user and the config live - a bonus that
falls out for free.
- Cost:
Programand its whole IR (CompiledSet/Layer/Binding/Command/Action,SetId/LayerId, the opaqueSourceMap) must gain serde derives. Note theBoxinControl::Applydoes not imply serialization today - the local control channel is an in-process ownership move (a pointer handed between threads, zero bytes encoded); theBoxonly keeps the enum small / moves the big IR cheaply. The network wire needs real serde.program.rs's "runtime state, never persisted" note becomes "transmitted over the 6 wire, still never written to disk" (ConfigDocremains the sole on-disk form).
Frames split by idempotency; both merge into one frame_rx(). Report is
State | Connected | Disconnected | Battery:
Statesnapshots -> UDP (idempotent, latest-wins,seq-drop, loss-tolerant).Connected/Disconnected/Battery-> the reliable TCP channel (events, not snapshots - must not be lost).
The network adapter merges both back into the single frame_rx(), so the mapper sees an
ordinary Report stream. Adapter-internal detail: a late UDP State can arrive after a
TCP Disconnected (separate channels, no cross-ordering) - the adapter gates UDP behind a
connected/epoch flag so a stray snapshot can't re-apply outputs after a release. Invisible to
the mapper.
Lifecycle unification - "transport lost" is transport-lost. The mapper's existing D5/D6
connected/waiting phase machine is reused verbatim for the network case. All of {local device
Err, local Disconnected value, network transport loss} drive the same transition into
the waiting phase -> WaitingForDevice; recovery (device reacquired or peer reconnected)
drives the reattach -> Running. detach() / reattach() express this per adapter, and how
each adapter implements them is fully internal: the local adapter drops the frame/rumble/click
channels and re-mints them (control survives in-process - an implementation detail, not an API
promise); the network adapter tears down the connection entirely and re-establishes it via TCP
reconnection + renegotiation. Same two methods, adapter-specific mechanics, identical
downstream state (the mapper's phase machine).
Well-behaved server for free. The mapper side's control_rx() is fed by two sources:
the local Engine handle (in-process, always alive) and, in the network adapter, the TCP
thread. On a full network partition the network feed dies but the local-handle feed survives ->
the server stays controllable (stop/shutdown work) while it sits in WaitingForDevice. The
client side is trivial on drop: retry the TCP reconnect, nothing to send or receive meanwhile.
Shared runtime state beyond the channels. reader/mapping today also share two
Arc<AtomicBool> (running, waiting) and an EventSink (runtime/mod.rs). Under the
two-runtimes-over-the-wire model these mostly dissolve rather than needing a new wire:
running- stays local, not a Link concern. It is a handle->own-threads stop signal, shared today only because oneRuntimeowns both threads. Network mode has two independent runtimes, each with its ownrunning(client stops its reader; server stops its mapper; a peer shutdown just looks like transport loss to the other side). Off the list.waiting- already covered bydetach()/reattach(); just expose it through the Link instead of a raw shared bool. The mapper picks its phase andstatus()reportsWaitingForDeviceby asking theLinkServer"are we detached?"; the far side flips it (the reader locally, or over the network theLinkServerderiving it from link-down or a client "device gone" message). Not new scope - the visible face of the lifecycle we already defined.EventSink- each side keeps its own local sink (its own daemon'ssubscribe()); passed into the Link at construction like the channels, not shared across it. No dedicated event wire: the server synthesizes every controller event from data the Link already carries -ControllerConnected/Disconnected/BatteryChangedfrom the frame stream (they ride TCP in the idempotency split),BindingLost/BindingAcquiredfrom link down/up, andStateis each runtime's own. The one newLinkServerjob: re-emit those events into the local sink as it decodes frames / detects link transitions.
Discipline (why it stays clean). The CommChannel API is deliberately common to both
use cases even where that is not optimal for a single case - reader/mapping must not get
more complex than today (and may get simpler). No network concept (peer address, seq,
retransmit) may appear on the device-side local type; if that pressure arises, it belongs
network-internal. The shared surface is exactly {frame, rumble, click, control endpoints +
the lifecycle methods}.
Status: design agreed; the local loopback adapter is BUILT + HW-validated (2026-08-05,
engine/src/runtime/link.rs - LinkClient/LinkServer, reader/mapper rewired through it; see
6.2). The network adapter is the next piece (6.2 slices below).
The role is chosen by the handle's input/output staging (no new entrypoint - "one engine, role
at runtime"): set_output(Network(connect)) -> client/forwarder role (reader + client link,
no mapper/Sink); set_input(Network(bind)) -> server role (mapper + server link + Sink, no
device); Local/Local stays the loopback (both threads). The 6.1 Link* method surface is
unchanged - the network variants slot in behind it, so reader/mapping stay untouched.
Sliced to front-load the zero-design-risk prerequisites:
- Serde prerequisites - derive
Serialize/DeserializeonProgram+IR (engine:CompiledSet/Layer/Binding/Command/Action,SetId/LayerId,SourceMap) and onControllerState/Report(+ their sub-types) in steam-hid. Mechanical, no design risk, needed by everything. (Programstays disk-unpersisted -ConfigDocremains the on-disk form; this only adds a wire path.) - Wire protocol - message enums + framing (reuse
ipc's length-prefixed postcard pattern) and the hidden bridge-thread machinery, all insideruntime/link.rs:State->UDP (seq, latest-wins);Connected/Disconnected/Battery+ control (Program/chords)->TCP; rumble/click back server->client. Decision to settle here: back-channel reliability (lean: rumble over UDP latest-wins like frames - it's a level; clicks TBD, possibly TCP). LinkClient/LinkServernetwork variants - turn the internal repr into aLocal | Networkenum behind the same methods; the network variant spawns the UDP/TCP bridge threads that feed local crossbeams (so the mapper'sselect!composes verbatim) and merges the two frame feeds into oneframe_rx(epoch-gate stale UDP after a TCPDisconnected). The client'scontrol_tx(config uplink) reappears here (net-only; the local client has none).- Role branching in
handle::start+Runtime-output=Networkbuilds reader-only + client link (no Sink);input=Networkbuilds mapper-only + server link (no device); the client routesapply/set_chordsto the control uplink instead of a local mapper (DeviceConfigstays local - never on the wire).status/ events per side (client = device state; server = mapping state), each synthesizing the far side's events from the wire per 6.1. - Reconnect / lifecycle / epoch-gating. 5a BUILT + HW-validated (server reconnect):
NetServerloop-accepts (survives a client close/restart), a shareddetachedflag, epoch-reset of the UDP seq gate between connections. 5b BUILT (server surfaces it; local D6 re-check pending HW): the mapper pollsis_detached()->WaitingForDevice; reattach unified behindLinkServer::poll_reattach()(local session-swap / network resume-on-same-channel); the mapper owns theState(Running)reattach edge; net server logs connect/disconnect. 5b changed the local D6 reattach fromselect!to a 50 ms poll. 5a+5b HW-VALIDATED CROSS-MACHINE (2026-08-06 - the 6 goal): a Steam Deck (Neptune) forwarding to a PC server over a LAN, with mapping, Main/Fallback chords over the wire, and client disconnect->WaitingForDevice->reconnect->Running; plus a local Gordon dongle unplug/replug D6 regression, all confirmed. 5c BUILT (pending HW): the client uplink threadpumps + re-dials on any drop, gated ondevice_presentso a device outage drops the link (not reconnect-to-nothing);LinkClient::Networkdetach()shuts the TCP (-> serverWaitingForDevice) +reattach()re-dials (reader-driven); anUplink::Pingkeep-alive (~1 s) surfaces a dead server over the otherwise-idle TCP. NETWORK ADAPTER (slices 1-5) COMPLETE; 5c wants HW (mid-session drop / server restart -> re-dial; client controller unplug -> server waits -> replug -> resume). Superseded note: client-sideLinkClient::Networkdetach()=drop the link (device-loss -> serverWaitingForDevice, Q1),reattach()=re-dial, + internal re-dial on a network blip while the device is fine (today the client uplink thread just exits on a write error). Tested with two daemons on localhost (--socket/-k, one daemon per socket).
BUILT (2026-08-06, slices 1-4; HW/two-daemon validation of the end-to-end path still pending):
- S1 serde on the
ProgramIR (steam-hidControllerState/Reportwere already serde-ready behind their feature - just enabled it).Programstays disk-unpersisted. - S2
runtime/link/wire.rs:FramePacket(=ControllerState, UDP),Uplink{Hello,Apply, SetChords,Event}(TCP; only chords cross the wire, neverDeviceConfig),Downlink{Rumble, Click}(UDP back-channel), + datagram/frame codecs. Back-channel decision settled: rumble AND click both over UDP (latest-wins rumble; a dropped click is an imperceptible missed tick - revisit if it matters). - S3
runtime/link/net.rs:NetClient/NetServer- the hidden UDP/TCP bridge threads feeding local crossbeams; server binds TCP+UDP on one port and learns the client's UDP return address from the first datagram;Hello{PROTOCOL_VERSION}handshake; clean shutdown. Single connection. - S4
LinkClient/LinkServerare nowLocal | Networkenums (4a);Runtime::start_local/ start_client/start_server+handle::startbranch on the staged input/output (4b), withInput::Network/Output::Network(host:port) parsing.
Design refinements settled during the build (supersede earlier 6/6.1 notes):
- Config is ordinary commands, NOT a handshake step (Q3). The client does not auto-ship
config at connect;
--output host:portalone just forwards frames and the server maps with its own config.-m/-f/-c -son the client issuesApply/SetChordsafter connecting (the forwarder'spush_staged_config, which pushes chords only whenSomeso a thin client retains the server's), exactly like local seeding;DeviceConfig(-d) stays machine-local and is never shipped. The server retains its config across reconnects (revises "config shipped to server at connect"). Handshake = TCP connect +Helloonly. - Lifecycle: the client drops the link on device-loss -> the server has ONE outage path (Q1).
When the client loses its device (transport-gone / reader
detach) it drops the network link, so a client-device-loss and a real network drop both manifest as link-down -> serverWaitingForDevice-> (re)connect ->reattach. This yields faithful local semantics for free: transport-loss (link drop) ->WaitingForDevice; controller-off (Report::Disconnectedvalue, forwarded over the still-alive link) -> mapperrelease_allbut Running. Detection + re-dial is slice 5 - the S4 network arms are single-connection stubs (detach/reattachno-op,is_detached=false, a never-readyreattach_rx).
Per-command tactile/audible feedback: extends today's single-click command haptic into a small,
bounded set of presets - single/double/triple haptic clicks, short/long audio tones (single/
double/triple, morse-like), and trackpad-actuator sweeps ("chirps"). Design agreed 2026-09-03; the
hardware facts this builds on are in 1.9 (the beep* exploration, lines ~1184-1238) and are NOT
restated here. This section is the durable model + build-order record (a multi-session feature
like 5.3); once the reader/HW step lands, its knowledge folds into CLAUDE.md haptics.
Audio on these controllers is the haptic actuator driven as a voice-coil/LRA - there is no
separate speaker (1.9). So per side, a haptic click and an audio tone share one physical
actuator and cannot coexist; the later command just preempts the earlier. This is a hardware fact,
not a policy: hence one Effect per edge is a single medium (click OR tone OR chirp), and "both
at once on one side" is simply inexpressible. Two independent axes remain:
- Rumble (per-profile, continuous) is a different channel on Deck/Triton (motors,
0xeb/0x80) -> it coexists with audio; only on Gordon does everything share0x8f(a feedback sequence briefly steps on rumble, resumed by the re-fire). Rumble ducking is a future reader policy, not modelled. - Left/Right are independent actuators. Haptic uses the triggering input's side; audio plays
both sides (heard, not felt) - the reader ignores
sidefor audio.sidestill crosses the wire so an audio-per-side policy stays possible later without a wire change.
Replaces the old CommandSettings.haptics: Haptics wholesale (no back-compat - API-not-stable
policy). Deliberately preset, not free-form (no user-set freq/interval); the model mirrors the
UI 1:1 (one combobox = one enum; arity encoded as distinct variants so each note gets its own
configurator; two independent per-edge checkboxes).
enum Click { Low, Medium, High } // was HapticStrength - renamed
enum Tone { ShortLow, ShortMedium, ShortHigh, LongLow, LongMedium, LongHigh } // pitch x length
enum Sweep { Up1, Down1, Up2, Down2, Up3, Down3 } // noop on Gordon (no sweep path)
enum Effect { // one medium per edge (enforces the 7.1 either/or)
Haptic(Click),
HapticDouble(Click, Click),
HapticTriple(Click, Click, Click),
Audio(Tone),
AudioDouble(Tone, Tone),
AudioTriple(Tone, Tone, Tone),
Chirp(Sweep),
}
struct Feedback { // replaces `Haptics`; two independent edges
on_press: Option<Effect>, // UI: a checkbox each; its Effect configurator appears below
on_release: Option<Effect>,
}
// CommandSettings { toggle, turbo, feedback: Feedback }
Decisions settled: (a) arity-as-variant (not Vec) - keeps UI 1:1 and makes the per-arity
interval natural; cap = triple. (b) length folded into Tone (6 variants) not a separate
AudioLen - so every note in a double/triple is independently short/long (morse patterns) with
fewer Effect variants; a Click has no length (impulse), so the audio/haptic asymmetry is
honest. (c) per-edge Option + two checkboxes (not a 4-way Off/Press/Release/Both combobox) -
toggling one edge never disturbs the other, and each edge's settings render under its checkbox. (d)
Melody dropped (was a gimmick; no enum slot - re-addable later, nothing depends on it). (e)
Sweep flat Up/Down, direction not split out (would need 2 comboboxes).
The mapper resolves the edge (press->on_press / release->on_release, exactly as haptic_fires
does today) and emits one request per edge carrying the chosen Effect; the reader owns when/
how (golden-test purity holds - the mapper says what, deterministically per tick):
struct FeedbackReq { side: Side, effect: Effect } // was HapticReq; only Effect+side cross the wire
The three-way effect routing is reused verbatim - only the ferried payload changes
HapticReq->FeedbackReq: Level (inline on output edges), PersistentOpSet (deferred into
pending_feedback, fired iff the op mutated state), Hold (rides the held-layer lifecycle via
HoldFeedback). Naming: the mapper's private routing enum (currently Effect { Level, PersistentOpSet, Hold }) is renamed Route to free Effect for the config type above (and
Route is the better name for it anyway). Rename cluster: HapticReq->FeedbackReq,
haptic_req()->feedback_req(), haptic_fires->feedback_fires, pending_haptics->
pending_feedback, HoldHaptic->HoldFeedback, HapticStrength->Click, the Click downlink type
click_tx/rx->feedback_tx/rx.
The reader-local vocab is built from Effect and never crosses the wire (so each machine maps a
preset to freqs audible on its own hardware - the per-device tone ceilings differ, 1.9). The key
layering the design turns on: from(FeedbackReq) needs no DeviceKind (pure arity -> notes +
intervals over presets); DeviceKind enters only at fire_note (pitch -> per-device freq,
length -> shared const ms; audio -> both actuators; chirp -> noop on Gordon). Lives in a new
feedback.rs module, NOT reader.rs (the split is clean; rumble stays in reader.rs).
const SHORT_MS: u16 = 30; const LONG_MS: u16 = 100; // tone durations; used by from() AND fire_note
// gaps (fire_end -> next onset), placeholders to tune on HW:
// AUDIO_DOUBLE=40 AUDIO_TRIPLE=30 HAPTIC_DOUBLE=50 HAPTIC_TRIPLE=40 (clicks ~0 duration)
enum Note { Haptic(Click), Audio(Tone), Chirp(Sweep) } // Note::Audio is a single Tone, no length arg
struct SeqNote { interval_ms: u16, note: Note } // interval = wait BEFORE this note fires
struct Sequencer { side: Side, notes: VecDeque<SeqNote> } // + next_due
Interval semantics (all in from()): a note's interval is "wait after the previous note's
onset until I fire" = duration(prev) + gap (audio; haptics = gap only, clicks have ~0 duration).
So AudioDouble(ShortHigh, LongLow) -> note1.interval = SHORT_MS(30)+AUDIO_DOUBLE(40) = 70; Long
first -> 140. Triple audio: 60/130. Haptic double/triple: 50/40. The predecessor-duration coupling
lives only in from(); the Sequencer consumes intervals blindly (it never reasons about played
notes - that's what makes the pre-note/pop-front shape clean).
Play loop (pre-note, pop-front, latest-wins):
- On receive
FeedbackReq: build theVecDeque,pop_front+ fire it immediately (its interval ignored), setnext_duefrom the new front, install as the active sequencer replacing any prior tail. This fire-on-arrival is an explicit action at receive time, NOT anote0.interval=0run through the lap loop - otherwise twoFeedbackReqs arriving in the same drain lap would let the 2nd replace the queue before the 1st's note 0 fired, losing today's "two simultaneous single clicks both fire". (So single-note effects always fire; only a multi-note tail yields to a newer effect.) - Each reader lap (the loop already laps at the ~4 ms
polltimeout - so timing is free, NO deadline-capped wait needed): whilenow >= next_due,pop_front+fire_note, then advancenext_due += front().interval(schedule-relative, drift-free); empty queue -> clear the sequencer.
One sequencer total (not per-side): each note decides actuator count (audio=both, haptic=side), so a left-click + right-tone don't need two timelines - latest-wins is enough (feedback edges are sparse).
BUILT (2026-09-04), + volume knobs. feedback.rs is as above, with one refinement: fire_note
takes the reader's DeviceTuning (the bound device's rumble+audio tuning, ReaderRumble
renamed) instead of DeviceKind - the variant is both the device discriminant and its audio
volume, so DeviceKind is gone from feedback entirely. Per-device volume is a fixed knob
(not strength-scaled) in config: GordonTuning.audio_duty (0..=100 %) and MotorTuning.audio_gain
(dB, -16..8; RumbleTuning renamed MotorTuning, rumble fields renamed rumble_*). Gordon maps
audio_duty linearly across a new AUDIO_MAX_DUTY = 0.5 (the beep saturates ~50 % duty, HW),
mirroring RUMBLE_MAX_DUTY; Deck/Triton pass audio_gain straight to the firmware tone/sweep. The
knob rides cfg.tuning, so a live devcfg change lands on the next note. UI: the device page's three
groups (headers no longer say "rumble"; sliders relabelled Rumble *) each end with an Audio duty
/ Audio gain slider (deckhand + forwarder-ui).
Starting-point values (HW-tuned a little; more finetuning later): tone pitch Low 800 / Med 1200 /
High 1600 Hz (all under every ceiling); sweeps (Up = lo->hi, Down = hi->lo) 1: 800<->2000, 2:
800<->1400, 3: 1400<->2000, SWEEP_MS=200; SHORT_MS=30/LONG_MS=100; gaps audio 40/30, haptic
50/40; default volume Gordon 50 % duty, Neptune 2 dB, Triton 0 dB.
- Config types + validation + mapper routing. Add the 7.2 model, do the 7.3 rename cluster, route
FeedbackReqthrough the existingRoutemachinery. Reader is a stub: it receives aFeedbackReqand demotes it to today's single click -Haptic*-> fire the firstClickas the current click;Audio*/Chirp-> ignored (HW not built; doesn't matter for UI work). No sequencer, nofeedback.rsyet. - UI - the editor command-settings feedback block (two checkboxes + per-edge
Effectcombobox + per-note configurators), 1:1 with 7.2. - Tune the model shape by using the UI; adjust variants/naming if the UI reveals a better shape.
- Reader sequencer + per-device
fire_note(7.4) + finetune the consts/freqs on real hardware.
Steps 1-4 all BUILT (2026-09-03/04); feature is functional + HW-validated on all three devices.
Remaining is finetuning only (not blocking): dial the freqs/sweeps/gaps/gains + per-device
volumes; consider driving Triton clicks off 0x81 pulse instead of the 2-strength 0x82 for
finer granularity (PLAN 1.9 notes 0x81 single-pulse width = strength). Open/deferred as before:
rumble-ducking on Gordon (accepted collision); a UI warning for moot combos (turbo re-firing a long
sequence only replays note 0; OpSet-only fires OnPress) - validation-warning territory, later.