Skip to content

Add Steam Controller (2026) support on macOS - #221

Open
thehobbit85 wants to merge 8 commits into
seedds:mainfrom
thehobbit85:codex/steam-controller
Open

thehobbit85 wants to merge 8 commits into
seedds:mainfrom
thehobbit85:codex/steam-controller

Conversation

@thehobbit85

@thehobbit85 thehobbit85 commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Adds Steam Controller (2026/Triton) input on macOS with USB-C, Bluetooth and Puck protocol handling, per-player motion and rumble, configurable trackpads/rear buttons, and a shared controls panel. Standard mappings apply to every controller; device-specific extras cannot override them. Generic HID and GameController input share player slots, focus handling and ownership, so connecting one controller preserves other players. Deferred generic devices recover when ownership becomes available.

Related to #388: the launcher and host share DM_KEYMAP / DM_PAD_MAP parsing and per-game saved choices. Launch overrides remain fixed; DM_SAVED_CHOICES=0 makes edits session-only. Main and related #382 do not provide the Triton adapter and panel. The existing malformed-HMMA signature regression coverage from #870 is retained.

Revision eb53ebb22ac84324a8e72e31546bd9a7d9f3e902 integrates main 99330eb81c873c3aafe9fc92194c9687d77fcdbb. Main already incorporates the exact #855 audio wakeup fix, so there is no separate audio prerequisite. Controller implementation, fixtures and build recipes are unchanged from the previous published head; upstream's corrected pipeline-archive reuse semantics remain intact.

Fresh validation:

  • Native t_controllers_mac, t_pad_map, t_pad_motion and t_haptics_apple pass; t_hid passes 251 checks, t_input_map 48, and t_audio_stats 26. Built with make -C dm/host/tests GAME=010015100B514000 and the named targets. Synthetic coverage includes ownership/open-failure recovery, mixed player slots, report decoding, mapping persistence, motion, touch and rumble; it does not establish device compatibility.
  • /opt/homebrew/bin/python3 -m unittest dm.tools.tests.test_docs dm.tools.tests.test_usage dm.tools.tests.test_nsp_mac_app dm.gfx.tests.test_dmsh_hmma: 69 pass. An initial sandboxed run stopped at unchanged iconutil synthetic-icon generation; the identical suite passes with normal macOS host-tool access.
  • dm/tools/lint_c.py compdb --game 010015100B514000 produces 219 sources. Mac frontend/controller syntax checks pass (one existing unused-activity warning). Symptom-index regeneration, links, main ancestry, range-diff and git diff --check pass. No chain tool or compiler lock changed.

Historical evidence remains separate: the previous published head 7938dee4eaeca3a9b07702ee53770137617d91ed passed all eight CI checks. Earlier native/sanitizer/GPU campaigns were not repeated; #855's 300-run/33-million-release audio stress is historical. Current local checks use macOS 26.6 and Xcode 27.0 / Metal .921; supported CI uses Xcode 27.2 / Metal .922. Local LLVM 23 clang-tidy and a fresh full graphics campaign are not claimed. CI for this revision is pending.

Game impact: regress.py impact 99330eb8..HEAD identifies Hades (vibration/player styles), Breath of the Wild (motion and controller styles), Pokémon Scarlet (player/style selection), and Tears of the Kingdom (gyro, vibration and player state); 123 profiles lack usage manifests and remain unknown. Super Mario Odyssey's shake-based actions also exercise the changed six-axis behavior. No games were launched or extracted caches rebuilt in this scoped update, and no new regress.py check result is claimed. Earlier unavailable-build results and Z-A's missing smoke route remain limitations, not passes. RePOP automated gameplay remains stopped.

The USB-C hardware report and gyro follow-up cover TotK 1.4.3 on an M4 Pro, macOS 27.0.1, Xcode 26.3: buttons, sticks and rumble worked, and the proposed 10% radial deadzone restored bow gyro aiming after walking and while moving. The six-axis handle mismatch was a separate finding. These are contributor reports on earlier revisions, not new tests. Gyro scale calibration, trackpads, rear buttons, mapping persistence, Bluetooth/Puck operation and physical hotplug still need testing on this revision; no new gameplay, display-sleep/fullscreen, performance or iOS-device result is claimed.

The player guide, shared frontend contract and developer documentation describe current behavior. TotK's note retains durable findings and limitations, with subsystem references under “Found on this game”; detailed validation is recorded here. No agent planning/execution artifacts or game data are included.

@hrdkbhatnagar

hrdkbhatnagar commented Oct 26, 2026 •

Copy link
Copy Markdown

I tested this PR with a real 2026 Steam Controller. Rumble works. Gyro data reaches the game, but TotK's gyro aiming does not respond to it. Details below.

Setup

  • MacBook Pro M4 Pro, macOS 27.0.1 (26A434), Xcode 26.3
  • Steam Controller (2026) over USB-C: 28de:1302, report 0x42 (54 bytes, about 250 per second). Steam was closed.
  • PR head f7c2f2f, merged locally into ff44c59 (the build my TotK Mac app came from) and into c484b13 (current main). Each merge has 3 conflicts, all additive (dm/host/Makefile, dm/host/tests/Makefile, dm/host/tests/t_hid.c). In tests/Makefile, the run: … t_controllers_mac line ends up in the middle of the main run recipe, so $(B)/t_symbols has to move above it.
  • t_hid (129 checks), t_pad_map, t_pad_motion, t_controllers_mac and t_haptics_apple pass on both merges. dmhost builds for TotK.

Results

Feature Result
Detection controller 'Steam Controller (2026)': player 1, no [RED] lines
Buttons / sticks worked in play
Rumble works
Gyro: report decoding (steam_input.h) matches the hardware (see below)
Gyro: delivery to the game works: TotK reads live values
Gyro: TotK bow aiming (motion controls on) no response
Trackpads, rear buttons, Bluetooth, Puck not tested

Axes and signs

A read-only IOKit probe (sends nothing to the controller) guided four moves and integrated the raw gyro at offset 40, using the PR's scale. The accelerometer is at offset 34.

Move Raw gyro (x, y, z), degrees Raw accel (x, y, z), G Expected with Switch axes (x right, y forward, z up)
Flat on desk (rest) (+0.01, +0.00, +0.99) +1 G on z (specific force); the PR negates it to (0, 0, −1) ✓
Turn left (CCW from above) (+4.5, −3.8, +27.7) positive about z ✓
Tilt far edge up (+16.9, −1.9, −2.4) y +0.11 positive about x ✓; accel +y ✓
Roll right (right grip down) (−4.5, +25.2, +2.4) x −0.35 positive about y ✓; accel −x ✓

So the controller's axes are the Switch's, and the PR's mapping (acc = -raw, gyro = +raw) has the right signs. The angles were a third to a half of what I asked for. That may be my movement, but I have not confirmed the ±2000 dps scale against gravity.

What TotK receives

I added a temporary trace, DM_GYRO_TRACE=1, to controller_publish_motion and six_states:

gyro trace: controller motion #1 focus 1 (app 1 window 1 modal 0) slot 0 merge 0 gyro_enabled 1 -> gyro (0.000, 0.000, -0.000) rev/s acc (-0.00, 0.15, -1.00)
gyro trace: GetSixAxisSensorHandles npad 0 style 0x1 -> 1 handle(s), first 0
gyro trace: six_states #2760 handle 0 n 16 player 0 physical 1 (history 7054) -> gyro (-0.090, 0.005, 0.096) rev/s acc (-0.48, -0.43, -0.63) dir row0 (0.83, -0.37, 0.42)
gyro trace: six_states #3480 handle 0 n 16 player 0 physical 1 (history 8341) -> gyro (0.017, -0.044, 0.269) rev/s acc (0.03, -0.05, -1.00) dir row0 (0.95, -0.31, -0.07)

TotK 1.4.3 imports only GetSixAxisSensorHandles, StartSixAxisSensor and GetSixAxisSensorStates. Its poller at main+0x47aec0 reads 16 states per npad into a 0xe98-byte per-npad record (states at +0x290). main+0x47a140 onwards then:

  • counts new samples from the sampling number (newest − last seen, at most 16);
  • converts acceleration and gyro to (−x, z, y);
  • remaps the angle and direction the same way;
  • flags an "untouched sensor" pattern (zero x/z acceleration with an identity direction).

A DM_PROBE at main+0x47a1d4 (the store of the new-sample count) gave x15 = 2 to 4 every frame with the sampling number advancing, so the game's input layer takes the samples. The bow aim still did not move. Whatever drops the motion is further up, in TotK's aiming or camera code. I didn't get further than that.

Side finding: six-axis handle encoding

GetSixAxisSensorHandles puts the style bit number in byte 0 (FullKey → 0). On the console, byte 0 is the SDK style index (FullKey 3, Handheld 4, JoyDual 5, JoyLeft 6, JoyRight 7, with 0 = None), and the host's vibration handles already use that. I switched both the handles and six_connected to the index (ctz(style) + 3) and TotK then got handle 0x3. It made no difference to TotK's aiming, but it is a divergence from hardware that another game could trip on. It predates this PR (main has the same encoding), and t_hid's h = 0x100 would need updating if it changes.

Not done

  • Trackpads, rear buttons and the mapping window were not exercised.
  • Bluetooth and Puck were not tested; this controller was on USB-C only.
  • The gyro scale against gravity is unconfirmed.

@hrdkbhatnagar

Copy link
Copy Markdown

Follow-up: gyro aiming works with a stick deadzone

I found why TotK's bow aiming ignored the gyro. The motion data was correct throughout. The Steam Controller's sticks have no deadzone in this PR.

steam_decode passes the raw stick axes through. At rest they sit a few counts off zero, while the console reports a centred stick as exactly 0. TotK treats any non-zero stick as stick input and ignores motion while aiming. In play, gyro aiming started working briefly after I used an ability (which reset TotK's input state) and stopped for good once I touched the left stick.

With a radial deadzone in steam_decode, gyro aiming works: right away, after walking with the left stick, and while moving and aiming at once. The camera stick feels normal.

--- a/dm/host/steam_input.h
+++ b/dm/host/steam_input.h
@@ #pragma once
+#include <math.h>
 #include <stddef.h>
+#include <stdlib.h>
 #include <string.h>
@@
+// The raw HID sticks rest a few counts off centre; the console's input service reports a centred stick as exactly 0 (a radial deadzone), and games rely on it:
+// Tears of the Kingdom treats any non-zero stick as stick input and stops gyro aiming. Radial deadzone of DM_STEAM_DEADZONE (fraction of full scale, default 0.10),
+// rescaled so the edge still reaches full scale
+static inline void steam_deadzone(int32_t *x, int32_t *y) {
+    static float dz = -1; if (dz < 0) { const char *e = getenv("DM_STEAM_DEADZONE"); dz = e ? (float)atof(e) : 0.10f; if (!(dz >= 0 && dz < 0.9f)) dz = 0.10f; }
+    float fx = *x / 32767.0f, fy = *y / 32767.0f, m = sqrtf(fx * fx + fy * fy);
+    if (m <= dz) { *x = *y = 0; return; }
+    float k = (m - dz) / (1 - dz) / m;   // each axis clamped on its own, so a full diagonal stays at full scale on both axes
+    *x = (int32_t)lrintf(fmaxf(-1, fminf(1, fx * k)) * 32767); *y = (int32_t)lrintf(fmaxf(-1, fminf(1, fy * k)) * 32767);
+}
@@ steam_decode
     s->rx = steam_axis(p + 14); s->ry = steam_axis(p + 16);
+    steam_deadzone(&s->lx, &s->ly); steam_deadzone(&s->rx, &s->ry);

t_pad_map (including its full-diagonal case), t_controllers_mac, t_hid (129 checks) and t_haptics_apple pass. The 10% default worked well; I didn't try other values.

How it was traced (TotK 1.4.3, main offsets):

  • main+0x100d030 copies the newest sample (count, gyro, direction rows) into a per-frame snapshot every frame.
  • main+0x874c1c updates the player's input object. Its motion object is at +0x430, and main+0x874e6c updates it from that snapshot.
  • The same function sets bit 0x10 of [input+0x418] when a button in mask 0xf60ff is down, or when the stick at [snapshot+8] is not exactly 0.0. I didn't trace this further than that, but it is probably what the aiming code checks.
  • Hardware read watchpoints showed nothing reading the motion object while aiming before the fix. Gyro worked as soon as the sticks rested at exactly 0.

Corrections to my comment above:

  • The validity check in the input layer flags a sample when its acceleration is all zero or its direction is exactly the identity. It isn't one combined pattern.
  • main+0x874e6c treats an exactly-identity direction as "no sensor" and resets its aim reference. The PR's orientation starts at identity, as it does after Recenter gyro. That may be worth knowing, though once the controller is moving it didn't cause problems here.
  • The six-axis handle encoding note stands, but it isn't related to this problem.

Still not tested: trackpads, rear buttons, the mapping window, Bluetooth and the Puck.

@v0lumehi

Copy link
Copy Markdown
Contributor

Heads-up: #388 proposes treating controls as data that every UI writes (#388 (comment)): the key and button maps (#386's DM_KEYMAP / DM_PAD_MAP) in one per-game store, so a controls page in any launcher and an in-app panel never disagree. Support for a new controller sits underneath that and is independent of it. Mentioning it so the thread has the full picture.

@thehobbit85
thehobbit85 force-pushed the codex/steam-controller branch 2 times, most recently from e2bdbaf to f115432 Compare October 29, 2026 20:18
@thehobbit85

Copy link
Copy Markdown
Contributor Author

The fixes for the USB-C findings and gyro-aiming follow-up are included in the cleaned three-commit series:

  • Both raw sticks have a 10% radial deadzone, configurable with DM_STEAM_DEADZONE, with rescaled travel and preserved outer diagonals. Trackpad coordinates are unchanged.
  • Six-axis handles use SDK style indices and device bytes, with matching validation on reads.
  • The complete test recipe, including t_symbols, is retained. Added tests receive the sanitizer flags.
  • Mapped Shake follows the controller's player without discarding native/Pro/phone orientation; direct Steam histories remain independent.

Implementation and regressions: motion/rumble foundations and controller input. The full host suite, ASan/UBSan and thread checks passed locally, as did the Mac host build. Both code commits also build and pass the host suite independently.

The remaining hardware limits are documented: gyro scale calibration is still unconfirmed, and this final revision needs physical trackpad/rear-button/transport retesting. Recenter keeps a truthful identity pose; we have not added a fabricated rotation to avoid TotK's identity check. No game was launched during this update.

@thehobbit85

Copy link
Copy Markdown
Contributor Author

The shared-controls coordination comment is now implemented in 075ce81.

The launcher and host use #386's input_map.h parser and launcher.DM_KEYMAP / launcher.DM_PAD_MAP keys in the same per-game app preference domain. The panel writes the shared standard button map; extra physical inputs, pad modes and gyro settings use model-specific dictionaries in that same domain and cannot override standard mappings. Shake is an additive target to preserve this PR's per-player bindings. The HID transport remains independent of the mapping UI.

Launch environment and env.txt overrides remain temporary and fixed in the panel. DM_SAVED_CHOICES=0 ignores saved choices and prevents writes; restoring defaults removes the relevant keys. Legacy controllers.plist mappings are imported explicitly, keeping the old file intact. Standalone hosts use the same lowercase default domain as packaged apps, and custom bundle IDs are preserved.

Synthetic integration tests cover launcher/host domain agreement, precedence, malformed choices, session-only edits, model isolation and panel actions. They passed, along with the packaging/docs tests and lint ratchet. The store contract and player guide describe the behavior.

This incorporates #386 at 97eaa989. The later proposed type: syntax remains subject to maintainer review and is not claimed as implemented here. The PR description also records a separate failure in unchanged graphics code: t_pso_bundle builds an archive for the installed macOS 27 SDK, which this macOS 26.6 runtime rejects.

Keep independent motion histories and complete mapped Shake gestures per player, while retaining live native orientation and upstream source selection. Encode SDK six-axis handles consistently. Add lifecycle-safe direct rumble sinks using the shared intensity cutoff and deterministic refresh tests.
Add physical-device-scoped Triton USB-C, Bluetooth and Puck input without disturbing other controllers. Support rumble, gyro, trackpads, extra buttons and the resting-stick deadzone through the existing player, motion and haptics policies.

Use the shared DM_KEYMAP and DM_PAD_MAP parser and saved-choice contract from PR seedds#386 at 97eaa98, with an additive Shake target. Keep launch overrides temporary, support session-only edits and explicit legacy import, and test both adapters, the mapping panel and the launcher/host preference domain.
Describe Steam Controller capabilities, per-player Shake, shared frontend preferences, explicit legacy import and Input Monitoring recovery after a rebuild. Record the TotK hardware findings and remaining calibration limits.
@thehobbit85
thehobbit85 force-pushed the codex/steam-controller branch from 7938dee to eb53ebb Compare November 2, 2026 05:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants