diff --git a/README.md b/README.md
index 2ffd99b7aa3..1c3a3e3258e 100644
--- a/README.md
+++ b/README.md
@@ -80,6 +80,13 @@ LizardByte has the full documentation hosted on [Read the Docs](https://docs.liz
✅2
✅3
+
+
Steam Controller (2nd generation)
+
🟡1
+
✅
+
❌
+
✅
+
Xbox 360
🟡1
diff --git a/docs/configuration.md b/docs/configuration.md
index 6d51304d62b..8ae6d1f14a8 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -383,7 +383,7 @@ supported on the current platform.
@endcode
-
Choices
+
Choices
generic
Generic HID gamepad
@@ -399,6 +399,10 @@ supported on the current platform.
switch
Switch Pro controller
+
+
steam_triton
+
Steam Controller (2nd generation)
+
x360
Xbox 360 controller
diff --git a/docs/getting_started.md b/docs/getting_started.md
index 4f583676956..e8ce2860c3c 100644
--- a/docs/getting_started.md
+++ b/docs/getting_started.md
@@ -414,6 +414,12 @@ keyboard or mouse input. Sunshine’s menu bar **Virtual HID Broker** submenu sh
for license management and downloads. Sunshine’s DMG does not contain the broker. Keyboard and mouse input use the
standard macOS synthetic-input permission path.
+The `steam_triton` option requires a Virtual HID Broker build with support for the Steam Controller
+(2nd generation). It creates a native Steam Controller profile on macOS;
+Sunshine forwards its pads, rear buttons, motion, battery state, and feedback
+through the same client protocol used on Windows and Linux. The macOS path
+still needs a signed-broker, physical-controller test.
+
#### DMG
##### Install
@@ -587,10 +593,15 @@ gamepad support. ViGEmBus remains available as a limited alternative for Xbox 36
When Virtual HID Broker is used, Sunshine requires libvirtualhid version `2026.914.1218.10` or newer.
-Compared with the ViGEmBus fallback, Virtual HID Broker can create Xbox One, Xbox Series, DualSense, Nintendo Switch
-Pro, and Generic gamepads in addition to Xbox 360 and DualShock 4. It can also expose controller-specific features such
-as motion, touchpads, LEDs, and adaptive triggers when supported. Virtual HID Broker is actively developed and
-supported by the LizardByte team.
+Compared with the ViGEmBus fallback, Virtual HID Broker can create the Steam Controller (2nd generation), Xbox One,
+Xbox Series, DualSense, Nintendo Switch Pro, and Generic gamepads in addition to Xbox 360 and DualShock 4. The
+`steam_triton` selection specifically emulates the Steam Controller (2nd generation), not the original Steam Controller. It exposes the standard controls,
+Home and miscellaneous buttons, four rear buttons, motion, battery state, both trackpads, and separate clicks for both
+trackpads when the Moonlight client reports those capabilities. Standard rumble and the controller's independently
+addressable trackpad haptics are returned to capable Moonlight clients. Older clients retain pressure- and
+trigger-position-based click inference and do not receive addressable haptic effects. Virtual HID Broker
+also exposes controller-specific features such as LEDs and adaptive triggers for other profiles when supported, and is
+actively developed and supported by the LizardByte team.
With a compatible driver and active license, normal key transitions are exposed through a real HID keyboard so
applications using Raw Input can receive them. Unicode text input and keys outside the supported HID keyboard page
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 889bea61639..81e537d161f 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -318,6 +318,12 @@ and the broker or license is unavailable. Choose **None** under **Configuration
virtual gamepads and that notice without affecting keyboard or mouse input. Reconnect the Moonlight session after choosing
a different emulated gamepad profile.
+For `steam_triton`, use a broker build that includes the Steam Controller (2nd generation) profile.
+Older macOS broker builds reject that profile during creation. The
+new macOS path still needs validation with a signed broker and physical
+controller; if it fails, check the broker version and permission before
+changing Moonlight's gamepad mapping.
+
### Dynamic session lookup failed
If you get this error:
@@ -340,9 +346,13 @@ gamepad support. ViGEmBus is a limited alternative for Xbox 360 and DualShock 4
When Virtual HID Broker is used, Sunshine requires libvirtualhid version `2026.914.1218.10` or newer.
-Virtual HID Broker adds Xbox One, Xbox Series, DualSense, Nintendo Switch Pro, and Generic gamepads, plus advanced
-controller features such as motion, touchpads, LEDs, and adaptive triggers when supported. Unlike the discontinued
-ViGEmBus project, Virtual HID Broker is actively developed and supported by the LizardByte team.
+Virtual HID Broker adds the Steam Controller (2nd generation), Xbox One, Xbox Series, DualSense, Nintendo Switch Pro, and Generic
+gamepads, plus advanced controller features such as motion, touchpads, LEDs, and adaptive triggers when supported. The
+`steam_triton` option is exclusively for the Steam Controller (2nd generation), not the original Steam Controller. It requires a
+compatible Virtual HID Broker and cannot fall back to ViGEmBus. Native trackpad clicks, trigger clicks, stick and grip
+touch sensors, exact battery reporting, and addressable trackpad haptics also require a capable Moonlight client; older
+clients use Sunshine's pressure and trigger-position click fallback. Unlike the discontinued ViGEmBus project, Virtual
+HID Broker is actively developed and supported by the LizardByte team.
An active paid Virtual HID Broker machine license is required before Sunshine can create driver-backed libvirtualhid
devices, including gamepads and the Raw Input keyboard and mouse. Use the message on the Web UI home page, the startup
diff --git a/gh-pages-template/_data/features.yml b/gh-pages-template/_data/features.yml
index 067bdcd7a2f..1012c8274b5 100644
--- a/gh-pages-template/_data/features.yml
+++ b/gh-pages-template/_data/features.yml
@@ -27,8 +27,8 @@
- title: "Control"
icon_fa: "fas fa-gamepad"
description: >
- Sunshine emulates Xbox 360|One|Series, PlayStation DualShock 4|DualSense, or Nintendo Switch Pro controllers.
- Use nearly any controller on your Moonlight client!
+ Sunshine emulates Steam Controller (2nd Generation), Xbox 360|One|Series, PlayStation DualShock 4|DualSense,
+ or Nintendo Switch Pro controllers. Use nearly any controller on your Moonlight client!
- title: "Configurable"
icon_fa: "fas fa-gear"
diff --git a/src/input.cpp b/src/input.cpp
index b30352205b8..6c3ad23ca5c 100644
--- a/src/input.cpp
+++ b/src/input.cpp
@@ -1505,6 +1505,7 @@ namespace input {
from_clamped_netfloat(packet->x, 0.0f, 1.0f),
from_clamped_netfloat(packet->y, 0.0f, 1.0f),
from_clamped_netfloat(packet->pressure, 0.0f, 1.0f),
+ packet->touchpadIndex,
};
platf::gamepad_touch(platf_input, touch);
@@ -2474,6 +2475,28 @@ namespace input {
::input::passthrough(input, &packet);
}
+ void send_controller_touch_packet(
+ std::shared_ptr &input,
+ std::uint8_t controller_number,
+ std::uint8_t event_type,
+ std::uint8_t touchpad_index,
+ std::uint32_t pointer_id,
+ float x,
+ float y,
+ float pressure
+ ) {
+ SS_CONTROLLER_TOUCH_PACKET packet {};
+ packet.controllerNumber = controller_number;
+ packet.eventType = event_type;
+ packet.touchpadIndex = touchpad_index;
+ packet.pointerId = util::endian::little(pointer_id);
+ boost::endian::endian_store(packet.x, x);
+ boost::endian::endian_store(packet.y, y);
+ boost::endian::endian_store(packet.pressure, pressure);
+
+ ::input::passthrough(input, &packet);
+ }
+
void reset_keyboard_state() {
task_pool.cancel(key_press_repeat_id);
key_press_repeat_id = nullptr;
diff --git a/src/input.h b/src/input.h
index 6d78447e53b..7948c26a16d 100644
--- a/src/input.h
+++ b/src/input.h
@@ -163,6 +163,29 @@ namespace input {
*/
void send_keyboard_packet(std::shared_ptr &input, std::uint16_t key_code, std::uint8_t modifiers, std::uint8_t flags, bool release);
+ /**
+ * @brief Process one client controller-touch packet on the calling thread.
+ *
+ * @param input Retained input state.
+ * @param controller_number Client-relative controller index.
+ * @param event_type Moonlight touch event type.
+ * @param touchpad_index Zero-based touchpad index carried by the packet.
+ * @param pointer_id Client-provided contact identifier.
+ * @param x Normalized horizontal coordinate.
+ * @param y Normalized vertical coordinate.
+ * @param pressure Normalized contact pressure.
+ */
+ void send_controller_touch_packet(
+ std::shared_ptr &input,
+ std::uint8_t controller_number,
+ std::uint8_t event_type,
+ std::uint8_t touchpad_index,
+ std::uint32_t pointer_id,
+ float x,
+ float y,
+ float pressure
+ );
+
/**
* @brief Forget every key Sunshine tracks as pressed and cancel any pending key repeat.
*/
diff --git a/src/platform/common.h b/src/platform/common.h
index e8aff70ec2c..9419a797ad6 100644
--- a/src/platform/common.h
+++ b/src/platform/common.h
@@ -100,6 +100,14 @@ namespace platf {
constexpr std::uint32_t PADDLE4 = 0x080000; ///< Moonlight gamepad button mask bit for paddle 4.
constexpr std::uint32_t TOUCHPAD_BUTTON = 0x100000; ///< Moonlight gamepad button mask bit for touchpad click.
constexpr std::uint32_t MISC_BUTTON = 0x200000; ///< Moonlight gamepad button mask bit for the miscellaneous button.
+ constexpr std::uint32_t STEAM_LEFT_TOUCHPAD_BUTTON = STEAM_LEFT_TOUCHPAD_FLAG; ///< Moonlight bit for the Steam Controller (2nd generation) left trackpad click.
+ constexpr std::uint32_t STEAM_RIGHT_TOUCHPAD_BUTTON = STEAM_RIGHT_TOUCHPAD_FLAG; ///< Moonlight bit for the Steam Controller (2nd generation) right trackpad click.
+ constexpr std::uint32_t STEAM_LEFT_TRIGGER_CLICK = STEAM_LEFT_TRIGGER_CLICK_FLAG; ///< Moonlight bit for the Steam Controller (2nd generation) left trigger click.
+ constexpr std::uint32_t STEAM_RIGHT_TRIGGER_CLICK = STEAM_RIGHT_TRIGGER_CLICK_FLAG; ///< Moonlight bit for the Steam Controller (2nd generation) right trigger click.
+ constexpr std::uint32_t STEAM_LEFT_STICK_TOUCH = STEAM_LEFT_STICK_TOUCH_FLAG; ///< Moonlight bit for the Steam Controller (2nd generation) left stick touch sensor.
+ constexpr std::uint32_t STEAM_RIGHT_STICK_TOUCH = STEAM_RIGHT_STICK_TOUCH_FLAG; ///< Moonlight bit for the Steam Controller (2nd generation) right stick touch sensor.
+ constexpr std::uint32_t STEAM_LEFT_GRIP_TOUCH = STEAM_LEFT_GRIP_TOUCH_FLAG; ///< Moonlight bit for the Steam Controller (2nd generation) left grip touch sensor.
+ constexpr std::uint32_t STEAM_RIGHT_GRIP_TOUCH = STEAM_RIGHT_GRIP_TOUCH_FLAG; ///< Moonlight bit for the Steam Controller (2nd generation) right grip touch sensor.
/**
* @brief Gamepad type exposed to clients and why it may be disabled.
@@ -120,6 +128,26 @@ namespace platf {
set_rgb_led, ///< Set RGB LED
set_player_leds, ///< Set player indicator LEDs
set_adaptive_triggers, ///< Set adaptive triggers
+ set_haptics, ///< Play an addressable haptic effect
+ };
+
+ /**
+ * @brief Profile-neutral addressable gamepad haptic effect.
+ */
+ struct gamepad_haptic_effect_t {
+ std::uint8_t target; ///< Target actuator selection.
+ std::uint8_t kind; ///< Haptic effect category.
+ std::int8_t gain_db; ///< Signed gain in decibels.
+ std::uint16_t intensity; ///< Profile-defined intensity value.
+ std::uint16_t frequency_hz; ///< Primary tone frequency in hertz.
+ std::int32_t duration_us; ///< Effect duration in microseconds; negative means indefinite.
+ std::uint32_t interval_us; ///< Off interval between pulses in microseconds.
+ std::uint16_t repeat_count; ///< Pulse repeat count.
+ std::uint16_t lfo_frequency_hz; ///< Low-frequency oscillator frequency in hertz.
+ std::uint8_t lfo_depth_percent; ///< Low-frequency oscillator depth in percent.
+ std::uint16_t start_frequency_hz; ///< Sweep start frequency in hertz.
+ std::uint16_t end_frequency_hz; ///< Sweep end frequency in hertz.
+ std::uint8_t script_id; ///< Controller-defined scripted effect identifier.
};
/**
@@ -227,6 +255,21 @@ namespace platf {
return msg;
}
+ /**
+ * @brief Create an addressable haptic effect command.
+ *
+ * @param id Identifier for the controller.
+ * @param effect Profile-neutral haptic effect parameters.
+ * @return Constructed haptic effect command.
+ */
+ static gamepad_feedback_msg_t make_haptics(std::uint16_t id, const gamepad_haptic_effect_t &effect) {
+ gamepad_feedback_msg_t msg;
+ msg.type = gamepad_feedback_e::set_haptics;
+ msg.id = id;
+ msg.data.haptics = effect;
+ return msg;
+ }
+
gamepad_feedback_e type; ///< Feedback command type stored in the union payload.
std::uint16_t id; ///< Controller identifier associated with this message.
@@ -264,6 +307,8 @@ namespace platf {
std::array left; ///< Left adaptive-trigger effect parameters.
std::array right; ///< Right adaptive-trigger effect parameters.
} adaptive_triggers; ///< Adaptive-trigger effect payload.
+
+ gamepad_haptic_effect_t haptics; ///< Addressable haptic effect payload.
} data; ///< Controller feedback payload for the selected feedback type.
};
@@ -454,6 +499,7 @@ namespace platf {
float x; ///< Horizontal coordinate or vector component.
float y; ///< Vertical coordinate or vector component.
float pressure; ///< Contact pressure reported by the client.
+ std::uint8_t touchpadIndex = 0; ///< Zero-based Moonlight touchpad index.
};
/**
diff --git a/src/platform/virtualhid_input.cpp b/src/platform/virtualhid_input.cpp
index db710dae6d4..ae4eb371685 100644
--- a/src/platform/virtualhid_input.cpp
+++ b/src/platform/virtualhid_input.cpp
@@ -27,14 +27,9 @@ using namespace std::literals;
namespace platf::virtualhid {
/**
- * @brief Runtime state for one virtual gamepad.
+ * @brief Last feedback values forwarded to the current client session.
*/
- struct gamepad_context_t {
- std::unique_ptr adapter; ///< State adapter for the virtual gamepad.
- std::mutex feedback_mutex; ///< Synchronizes feedback routing with session rebinding.
- feedback_queue_t feedback_queue; ///< Feedback queue for client output events.
- std::array, 2> touch_ids; ///< Client touch IDs assigned to libvirtualhid slots.
- std::uint8_t client_relative_index = 0; ///< Client-relative controller index.
+ struct feedback_cache_t {
bool has_last_rumble = false; ///< Whether last rumble values are valid.
std::uint16_t last_low_frequency_rumble = 0; ///< Last low-frequency rumble value.
std::uint16_t last_high_frequency_rumble = 0; ///< Last high-frequency rumble value.
@@ -50,8 +45,33 @@ namespace platf::virtualhid {
std::uint8_t last_flashing_player_leds = 0; ///< Last flashing player indicator mask.
};
+ /**
+ * @brief Runtime state for one virtual gamepad.
+ */
+ struct gamepad_context_t {
+ std::unique_ptr adapter; ///< State adapter for the virtual gamepad.
+ std::mutex feedback_mutex; ///< Synchronizes feedback routing with session rebinding.
+ feedback_queue_t feedback_queue; ///< Feedback queue for client output events.
+ std::array, 2> touch_ids; ///< Client touch IDs assigned to libvirtualhid slots.
+ std::array inferred_touchpad_clicks {}; ///< Steam touchpad clicks inferred from client pressure.
+ std::array inferred_trigger_clicks {}; ///< Steam trigger clicks inferred from client axes.
+ std::uint32_t client_button_flags = 0; ///< Latest client button state, before Steam click inference.
+ std::uint32_t supported_button_flags = 0; ///< Button flags the client can report natively.
+ std::uint8_t client_relative_index = 0; ///< Client-relative controller index.
+ bool supports_haptics = false; ///< Whether the client can play addressable haptic effects.
+ feedback_cache_t feedback_cache; ///< Feedback values already sent to this client session.
+ };
+
namespace {
+ // Moonlight currently carries Steam trackpad pressure and analog trigger
+ // travel but not all native click bits. Keep this compatibility fallback
+ // specific to the Steam Controller (2nd generation) profile.
+ constexpr float steam_touchpad_click_press_threshold = 0.35F;
+ constexpr float steam_touchpad_click_release_threshold = 0.25F;
+ constexpr float steam_trigger_click_press_threshold = 0.95F;
+ constexpr float steam_trigger_click_release_threshold = 0.90F;
+
/**
* @brief Gamepad profile exposed through Sunshine config.
*/
@@ -72,6 +92,7 @@ namespace platf::virtualhid {
gamepad_profile_t {"ds4", lvh::GamepadProfileKind::dualshock4, lvh::profiles::dualshock4},
gamepad_profile_t {"ds5", lvh::GamepadProfileKind::dualsense, lvh::profiles::dualsense},
gamepad_profile_t {"switch", lvh::GamepadProfileKind::switch_pro, lvh::profiles::switch_pro},
+ gamepad_profile_t {"steam_triton", lvh::GamepadProfileKind::steam_triton, lvh::profiles::steam_triton},
};
void log_failure(std::string_view operation, const lvh::OperationStatus &status) {
@@ -121,6 +142,10 @@ namespace platf::virtualhid {
return profile_for_name(config::input.gamepad);
}
+ if (metadata.type == LI_CTYPE_STEAM) {
+ BOOST_LOG(info) << "Gamepad will be Steam Controller (2nd generation), auto-selected by client-reported type"sv;
+ return profile_for_name("steam_triton"sv);
+ }
if (metadata.type == LI_CTYPE_PS) {
BOOST_LOG(info) << "Gamepad will be DualSense controller (auto-selected by client-reported type)"sv;
return profile_for_name("ds5"sv);
@@ -133,6 +158,10 @@ namespace platf::virtualhid {
BOOST_LOG(info) << "Gamepad will be Xbox Series controller (auto-selected by client-reported type)"sv;
return profile_for_name("xseries"sv);
}
+ if (metadata.capabilities & LI_CCAP_DUAL_TOUCHPAD) {
+ BOOST_LOG(info) << "Gamepad will be Steam Controller (2nd generation), auto-selected by dual-touchpad capability"sv;
+ return profile_for_name("steam_triton"sv);
+ }
if (config::input.motion_as_ds4 && (metadata.capabilities & (LI_CCAP_ACCEL | LI_CCAP_GYRO))) {
BOOST_LOG(info) << "Gamepad will be DualSense controller (auto-selected by motion sensor presence)"sv;
return profile_for_name("ds5"sv);
@@ -175,7 +204,7 @@ namespace platf::virtualhid {
result.client_relative_index = id.clientRelativeIndex;
result.client_type = client_controller_type(metadata.type);
result.has_motion_sensors = metadata.capabilities & (LI_CCAP_ACCEL | LI_CCAP_GYRO);
- result.has_touchpad = metadata.capabilities & LI_CCAP_TOUCHPAD;
+ result.has_touchpad = metadata.capabilities & (LI_CCAP_TOUCHPAD | LI_CCAP_DUAL_TOUCHPAD);
result.has_rgb_led = metadata.capabilities & LI_CCAP_RGB_LED;
result.has_battery = metadata.capabilities & LI_CCAP_BATTERY_STATE;
result.stable_id = gamepad_stable_id(id, profile);
@@ -186,26 +215,36 @@ namespace platf::virtualhid {
if ((metadata.capabilities & (LI_CCAP_ACCEL | LI_CCAP_GYRO)) && !support.supports_motion) {
BOOST_LOG(warning) << "Gamepad "sv << global_index << " has motion sensors, but the selected virtual profile cannot expose them"sv;
}
- if ((metadata.capabilities & LI_CCAP_TOUCHPAD) && !support.supports_touchpad) {
+ if ((metadata.capabilities & (LI_CCAP_TOUCHPAD | LI_CCAP_DUAL_TOUCHPAD)) && !support.supports_touchpad) {
BOOST_LOG(warning) << "Gamepad "sv << global_index << " has a touchpad, but the selected virtual profile cannot expose it"sv;
}
if ((metadata.capabilities & LI_CCAP_RGB_LED) && !support.supports_rgb_led) {
BOOST_LOG(warning) << "Gamepad "sv << global_index << " has an RGB LED, but the selected virtual profile cannot expose it"sv;
}
+ if ((metadata.capabilities & LI_CCAP_HAPTICS) && !support.supports_haptics) {
+ BOOST_LOG(warning) << "Gamepad "sv << global_index << " has addressable haptics, but the selected virtual profile cannot expose them"sv;
+ }
}
void warn_missing_client_features(int global_index, const gamepad_arrival_t &metadata, const lvh::GamepadProfileSupport &support) {
if (support.supports_motion && !(metadata.capabilities & (LI_CCAP_ACCEL | LI_CCAP_GYRO))) {
BOOST_LOG(warning) << "Gamepad "sv << global_index << " is emulating a motion-capable controller, but the client gamepad does not have motion sensors active"sv;
}
- if (support.supports_touchpad && !(metadata.capabilities & LI_CCAP_TOUCHPAD)) {
+ if (support.supports_touchpad && !(metadata.capabilities & (LI_CCAP_TOUCHPAD | LI_CCAP_DUAL_TOUCHPAD))) {
BOOST_LOG(warning) << "Gamepad "sv << global_index << " is emulating a touchpad-capable controller, but the client gamepad does not have a touchpad"sv;
}
+ if (support.supported_touchpad_count > 1U && !(metadata.capabilities & LI_CCAP_DUAL_TOUCHPAD)) {
+ BOOST_LOG(warning) << "Gamepad "sv << global_index << " is emulating the Steam Controller (2nd generation), but the client cannot report its second touchpad"sv;
+ }
+ if (support.supports_haptics && !(metadata.capabilities & LI_CCAP_HAPTICS)) {
+ BOOST_LOG(warning) << "Gamepad "sv << global_index << " is emulating addressable haptics, but the client cannot play them"sv;
+ }
}
- lvh::GamepadState make_gamepad_state(const gamepad_state_t &state, const lvh::GamepadProfileSupport &support) {
+ lvh::GamepadState make_gamepad_state(const gamepad_state_t &state, const lvh::DeviceProfile &profile) {
using enum lvh::GamepadButton;
+ const auto support = lvh::gamepad_profile_support(profile);
lvh::GamepadState result;
const auto flags = state.buttonFlags;
@@ -226,6 +265,21 @@ namespace platf::virtualhid {
result.buttons.set(y, flags & Y);
result.buttons.set(misc1, support.supports_misc1_button && (flags & MISC_BUTTON));
result.buttons.set(touchpad, support.supports_touchpad_button && (flags & TOUCHPAD_BUTTON));
+ result.buttons.set(paddle1, support.supported_rear_paddle_count >= 1U && (flags & PADDLE1));
+ result.buttons.set(paddle2, support.supported_rear_paddle_count >= 2U && (flags & PADDLE2));
+ result.buttons.set(paddle3, support.supported_rear_paddle_count >= 3U && (flags & PADDLE3));
+ result.buttons.set(paddle4, support.supported_rear_paddle_count >= 4U && (flags & PADDLE4));
+
+ if (profile.gamepad_kind == lvh::GamepadProfileKind::steam_triton) {
+ result.buttons.set(left_touchpad, flags & STEAM_LEFT_TOUCHPAD_BUTTON);
+ result.buttons.set(right_touchpad, flags & STEAM_RIGHT_TOUCHPAD_BUTTON);
+ result.buttons.set(left_trigger_click, flags & STEAM_LEFT_TRIGGER_CLICK);
+ result.buttons.set(right_trigger_click, flags & STEAM_RIGHT_TRIGGER_CLICK);
+ result.buttons.set(left_stick_touch, flags & STEAM_LEFT_STICK_TOUCH);
+ result.buttons.set(right_stick_touch, flags & STEAM_RIGHT_STICK_TOUCH);
+ result.buttons.set(left_grip_touch, flags & STEAM_LEFT_GRIP_TOUCH);
+ result.buttons.set(right_grip_touch, flags & STEAM_RIGHT_GRIP_TOUCH);
+ }
if (support.supports_touchpad_button && config::input.ds4_back_as_touchpad_click && configured_gamepad_supports_touchpad() && (flags & BACK)) {
result.buttons.set(touchpad);
@@ -238,6 +292,84 @@ namespace platf::virtualhid {
return result;
}
+ bool threshold_button_state(bool pressed, float value, float press_threshold, float release_threshold) {
+ return pressed ? value > release_threshold : value >= press_threshold;
+ }
+
+ bool is_steam_triton(const std::shared_ptr &gamepad) {
+ return gamepad->adapter->gamepad()->profile().gamepad_kind == lvh::GamepadProfileKind::steam_triton;
+ }
+
+ void apply_inferred_steam_clicks(const std::shared_ptr &gamepad, lvh::GamepadState &state) {
+ if (!is_steam_triton(gamepad)) {
+ return;
+ }
+
+ using enum lvh::GamepadButton;
+ state.buttons.set(left_touchpad, state.buttons.test(left_touchpad) || gamepad->inferred_touchpad_clicks[0]);
+ state.buttons.set(right_touchpad, state.buttons.test(right_touchpad) || gamepad->inferred_touchpad_clicks[1]);
+ state.buttons.set(left_trigger_click, state.buttons.test(left_trigger_click) || gamepad->inferred_trigger_clicks[0]);
+ state.buttons.set(right_trigger_click, state.buttons.test(right_trigger_click) || gamepad->inferred_trigger_clicks[1]);
+ }
+
+ void update_inferred_steam_trigger_clicks(
+ const std::shared_ptr &gamepad,
+ float left_trigger,
+ float right_trigger
+ ) {
+ if (!is_steam_triton(gamepad)) {
+ return;
+ }
+
+ gamepad->inferred_trigger_clicks[0] = threshold_button_state(
+ gamepad->inferred_trigger_clicks[0],
+ left_trigger,
+ steam_trigger_click_press_threshold,
+ steam_trigger_click_release_threshold
+ );
+ gamepad->inferred_trigger_clicks[1] = threshold_button_state(
+ gamepad->inferred_trigger_clicks[1],
+ right_trigger,
+ steam_trigger_click_press_threshold,
+ steam_trigger_click_release_threshold
+ );
+ if (gamepad->supported_button_flags & STEAM_LEFT_TRIGGER_CLICK) {
+ gamepad->inferred_trigger_clicks[0] = false;
+ }
+ if (gamepad->supported_button_flags & STEAM_RIGHT_TRIGGER_CLICK) {
+ gamepad->inferred_trigger_clicks[1] = false;
+ }
+ }
+
+ void update_inferred_steam_touchpad_click(
+ const std::shared_ptr &gamepad,
+ std::size_t index,
+ bool active,
+ float pressure
+ ) {
+ if (!is_steam_triton(gamepad) || index >= gamepad->inferred_touchpad_clicks.size()) {
+ return;
+ }
+
+ auto &inferred = gamepad->inferred_touchpad_clicks[index];
+ inferred = active && threshold_button_state(
+ inferred,
+ pressure,
+ steam_touchpad_click_press_threshold,
+ steam_touchpad_click_release_threshold
+ );
+
+ const auto explicit_flag = index == 0U ? STEAM_LEFT_TOUCHPAD_BUTTON : STEAM_RIGHT_TOUCHPAD_BUTTON;
+ if (gamepad->supported_button_flags & explicit_flag) {
+ inferred = false;
+ }
+ const auto button = index == 0U ? lvh::GamepadButton::left_touchpad : lvh::GamepadButton::right_touchpad;
+ log_failure(
+ "submit inferred Steam touchpad click"sv,
+ gamepad->adapter->set_button(button, inferred || (gamepad->client_button_flags & explicit_flag))
+ );
+ }
+
std::optional mouse_button(int button) {
using enum lvh::MouseButton;
@@ -382,54 +514,147 @@ namespace platf::virtualhid {
std::lock_guard lock {gamepad->feedback_mutex};
switch (output.kind) {
case lvh::GamepadOutputKind::rumble:
- if (gamepad->has_last_rumble && gamepad->last_low_frequency_rumble == output.low_frequency_rumble && gamepad->last_high_frequency_rumble == output.high_frequency_rumble) {
+ if (gamepad->feedback_cache.has_last_rumble && gamepad->feedback_cache.last_low_frequency_rumble == output.low_frequency_rumble && gamepad->feedback_cache.last_high_frequency_rumble == output.high_frequency_rumble) {
return;
}
- gamepad->has_last_rumble = true;
- gamepad->last_low_frequency_rumble = output.low_frequency_rumble;
- gamepad->last_high_frequency_rumble = output.high_frequency_rumble;
+ gamepad->feedback_cache.has_last_rumble = true;
+ gamepad->feedback_cache.last_low_frequency_rumble = output.low_frequency_rumble;
+ gamepad->feedback_cache.last_high_frequency_rumble = output.high_frequency_rumble;
raise_feedback_unlocked(gamepad, gamepad_feedback_msg_t::make_rumble(gamepad->client_relative_index, output.low_frequency_rumble, output.high_frequency_rumble));
break;
case lvh::GamepadOutputKind::trigger_rumble:
- if (gamepad->has_last_trigger_rumble && gamepad->last_left_trigger_rumble == output.left_trigger_rumble && gamepad->last_right_trigger_rumble == output.right_trigger_rumble) {
+ if (gamepad->feedback_cache.has_last_trigger_rumble && gamepad->feedback_cache.last_left_trigger_rumble == output.left_trigger_rumble && gamepad->feedback_cache.last_right_trigger_rumble == output.right_trigger_rumble) {
return;
}
- gamepad->has_last_trigger_rumble = true;
- gamepad->last_left_trigger_rumble = output.left_trigger_rumble;
- gamepad->last_right_trigger_rumble = output.right_trigger_rumble;
+ gamepad->feedback_cache.has_last_trigger_rumble = true;
+ gamepad->feedback_cache.last_left_trigger_rumble = output.left_trigger_rumble;
+ gamepad->feedback_cache.last_right_trigger_rumble = output.right_trigger_rumble;
raise_feedback_unlocked(gamepad, gamepad_feedback_msg_t::make_rumble_triggers(gamepad->client_relative_index, output.left_trigger_rumble, output.right_trigger_rumble));
break;
case lvh::GamepadOutputKind::rgb_led:
- if (gamepad->has_last_rgb && gamepad->last_red == output.red && gamepad->last_green == output.green && gamepad->last_blue == output.blue) {
+ if (gamepad->feedback_cache.has_last_rgb && gamepad->feedback_cache.last_red == output.red && gamepad->feedback_cache.last_green == output.green && gamepad->feedback_cache.last_blue == output.blue) {
return;
}
- gamepad->has_last_rgb = true;
- gamepad->last_red = output.red;
- gamepad->last_green = output.green;
- gamepad->last_blue = output.blue;
+ gamepad->feedback_cache.has_last_rgb = true;
+ gamepad->feedback_cache.last_red = output.red;
+ gamepad->feedback_cache.last_green = output.green;
+ gamepad->feedback_cache.last_blue = output.blue;
raise_feedback_unlocked(gamepad, gamepad_feedback_msg_t::make_rgb_led(gamepad->client_relative_index, output.red, output.green, output.blue));
break;
case lvh::GamepadOutputKind::player_leds:
{
const auto solid = player_led_mask(output.player_leds);
const auto flashing = player_led_mask(output.flashing_player_leds);
- if (gamepad->has_last_player_leds && gamepad->last_solid_player_leds == solid && gamepad->last_flashing_player_leds == flashing) {
+ if (gamepad->feedback_cache.has_last_player_leds && gamepad->feedback_cache.last_solid_player_leds == solid && gamepad->feedback_cache.last_flashing_player_leds == flashing) {
return;
}
- gamepad->has_last_player_leds = true;
- gamepad->last_solid_player_leds = solid;
- gamepad->last_flashing_player_leds = flashing;
+ gamepad->feedback_cache.has_last_player_leds = true;
+ gamepad->feedback_cache.last_solid_player_leds = solid;
+ gamepad->feedback_cache.last_flashing_player_leds = flashing;
raise_feedback_unlocked(gamepad, gamepad_feedback_msg_t::make_player_leds(gamepad->client_relative_index, solid, flashing));
break;
}
case lvh::GamepadOutputKind::adaptive_triggers:
raise_feedback_unlocked(gamepad, gamepad_feedback_msg_t::make_adaptive_triggers(gamepad->client_relative_index, output.adaptive_trigger_flags, output.left_trigger_effect_type, output.right_trigger_effect_type, output.left_trigger_effect, output.right_trigger_effect));
break;
+ case lvh::GamepadOutputKind::haptics:
+ if (gamepad->supports_haptics && output.haptic_effect) {
+ const auto &effect = *output.haptic_effect;
+ raise_feedback_unlocked(
+ gamepad,
+ gamepad_feedback_msg_t::make_haptics(
+ gamepad->client_relative_index,
+ {
+ .target = std::to_underlying(effect.target),
+ .kind = std::to_underlying(effect.kind),
+ .gain_db = effect.gain_db,
+ .intensity = effect.intensity,
+ .frequency_hz = effect.frequency_hz,
+ .duration_us = effect.duration_us,
+ .interval_us = effect.interval_us,
+ .repeat_count = effect.repeat_count,
+ .lfo_frequency_hz = effect.lfo_frequency_hz,
+ .lfo_depth_percent = effect.lfo_depth_percent,
+ .start_frequency_hz = effect.start_frequency_hz,
+ .end_frequency_hz = effect.end_frequency_hz,
+ .script_id = effect.script_id,
+ }
+ )
+ );
+ }
+ break;
case lvh::GamepadOutputKind::raw_report:
break;
}
}
+ void cancel_all_gamepad_touches(const std::shared_ptr &gamepad) {
+ for (std::size_t index = 0; index < gamepad->touch_ids.size(); ++index) {
+ update_inferred_steam_touchpad_click(gamepad, index, false, 0.0F);
+ if (gamepad->touch_ids[index].has_value()) {
+ log_failure("release libvirtualhid gamepad touch"sv, gamepad->adapter->clear_touchpad_contact(index));
+ gamepad->touch_ids[index].reset();
+ }
+ }
+ }
+
+ std::optional indexed_touch_slot(
+ const std::shared_ptr &gamepad,
+ const gamepad_touch_t &touch,
+ const lvh::GamepadProfileSupport &support
+ ) {
+ if (touch.touchpadIndex >= support.supported_touchpad_count || touch.touchpadIndex >= gamepad->touch_ids.size()) {
+ BOOST_LOG(warning) << "Invalid libvirtualhid gamepad touchpad index: "sv << static_cast(touch.touchpadIndex);
+ return std::nullopt;
+ }
+
+ auto &slot = gamepad->touch_ids[touch.touchpadIndex];
+ if (touch.eventType == LI_TOUCH_EVENT_DOWN) {
+ if (slot.has_value() && slot.value() != touch.pointerId) {
+ BOOST_LOG(warning) << "No free libvirtualhid gamepad touch slot for touchpad "sv << static_cast(touch.touchpadIndex);
+ return std::nullopt;
+ }
+ slot = touch.pointerId;
+ } else if (!slot.has_value() || slot.value() != touch.pointerId) {
+ return std::nullopt;
+ }
+
+ return touch.touchpadIndex;
+ }
+
+ std::optional shared_touch_slot(
+ const std::shared_ptr &gamepad,
+ const gamepad_touch_t &touch
+ ) {
+ auto slot = std::ranges::find(gamepad->touch_ids, touch.pointerId);
+ if (touch.eventType == LI_TOUCH_EVENT_DOWN && slot == gamepad->touch_ids.end()) {
+ slot = std::ranges::find_if(gamepad->touch_ids, [](const auto &id) {
+ return !id.has_value();
+ });
+ if (slot == gamepad->touch_ids.end()) {
+ BOOST_LOG(warning) << "No free libvirtualhid gamepad touch slots"sv;
+ return std::nullopt;
+ }
+ *slot = touch.pointerId;
+ }
+
+ if (slot == gamepad->touch_ids.end()) {
+ return std::nullopt;
+ }
+ return static_cast(std::distance(gamepad->touch_ids.begin(), slot));
+ }
+
+ std::optional gamepad_touch_slot(
+ const std::shared_ptr &gamepad,
+ const gamepad_touch_t &touch,
+ const lvh::GamepadProfileSupport &support
+ ) {
+ if (support.supported_touchpad_count > 1U) {
+ return indexed_touch_slot(gamepad, touch, support);
+ }
+ return shared_touch_slot(gamepad, touch);
+ }
+
void cancel_all_touches(client_context_t &context) {
if (!context.touch) {
return;
@@ -614,6 +839,9 @@ namespace platf::virtualhid {
const auto &selection = profile_for_metadata(metadata);
auto profile = selection.profile();
+ if (selection.kind == lvh::GamepadProfileKind::steam_triton) {
+ profile.name = "Steam Controller (2nd generation)";
+ }
profile.name = std::format("Sunshine {}", profile.name);
if (config::input.gamepad != "auto"sv) {
BOOST_LOG(info) << "Gamepad "sv << id.globalIndex << " will be "sv << profile.name << " (manual selection)"sv;
@@ -633,7 +861,9 @@ namespace platf::virtualhid {
auto gamepad = std::make_shared();
gamepad->adapter = std::move(created.adapter);
gamepad->feedback_queue = std::move(feedback_queue);
+ gamepad->supported_button_flags = metadata.supportedButtons;
gamepad->client_relative_index = id.clientRelativeIndex;
+ gamepad->supports_haptics = metadata.capabilities & LI_CCAP_HAPTICS;
gamepad->adapter->set_output_callback([weak_gamepad = std::weak_ptr {gamepad}](const lvh::GamepadOutput &output) {
if (const auto gamepad = weak_gamepad.lock()) {
handle_output(gamepad, output);
@@ -661,10 +891,7 @@ namespace platf::virtualhid {
std::lock_guard lock {gamepad->feedback_mutex};
gamepad->feedback_queue = std::move(feedback_queue);
gamepad->client_relative_index = id.clientRelativeIndex;
- gamepad->has_last_rumble = false;
- gamepad->has_last_trigger_rumble = false;
- gamepad->has_last_rgb = false;
- gamepad->has_last_player_leds = false;
+ gamepad->feedback_cache = {};
if (gamepad->adapter->support().supports_motion) {
raise_feedback_unlocked(gamepad, gamepad_feedback_msg_t::make_motion_event_state(id.clientRelativeIndex, LI_MOTION_TYPE_ACCEL, 100));
@@ -696,7 +923,10 @@ namespace platf::virtualhid {
}
auto &gamepad = context.gamepads[nr];
- auto updated_state = make_gamepad_state(state, gamepad->adapter->support());
+ gamepad->client_button_flags = state.buttonFlags;
+ auto updated_state = make_gamepad_state(state, gamepad->adapter->gamepad()->profile());
+ update_inferred_steam_trigger_clicks(gamepad, updated_state.left_trigger, updated_state.right_trigger);
+ apply_inferred_steam_clicks(gamepad, updated_state);
const auto &cached_state = gamepad->adapter->state();
updated_state.acceleration = cached_state.acceleration;
updated_state.gyroscope = cached_state.gyroscope;
@@ -711,40 +941,25 @@ namespace platf::virtualhid {
}
auto &gamepad = context.gamepads[touch.id.globalIndex];
- if (!gamepad->adapter->support().supports_touchpad) {
+ const auto &support = gamepad->adapter->support();
+ if (!support.supports_touchpad) {
return;
}
if (touch.eventType == LI_TOUCH_EVENT_CANCEL_ALL) {
- for (std::size_t index = 0; index < gamepad->touch_ids.size(); ++index) {
- if (gamepad->touch_ids[index].has_value()) {
- log_failure("release libvirtualhid gamepad touch"sv, gamepad->adapter->clear_touchpad_contact(index));
- gamepad->touch_ids[index].reset();
- }
- }
+ cancel_all_gamepad_touches(gamepad);
return;
}
- auto slot = std::ranges::find(gamepad->touch_ids, touch.pointerId);
- if (touch.eventType == LI_TOUCH_EVENT_DOWN && slot == gamepad->touch_ids.end()) {
- slot = std::ranges::find_if(gamepad->touch_ids, [](const auto &id) {
- return !id.has_value();
- });
- if (slot == gamepad->touch_ids.end()) {
- BOOST_LOG(warning) << "No free libvirtualhid gamepad touch slots"sv;
- return;
- }
- *slot = touch.pointerId;
- }
-
- if (slot == gamepad->touch_ids.end()) {
+ const auto slot = gamepad_touch_slot(gamepad, touch, support);
+ if (!slot.has_value()) {
return;
}
- const auto index = static_cast(std::distance(gamepad->touch_ids.begin(), slot));
if (touch.eventType == LI_TOUCH_EVENT_UP || touch.eventType == LI_TOUCH_EVENT_CANCEL) {
- log_failure("release libvirtualhid gamepad touch"sv, gamepad->adapter->clear_touchpad_contact(index));
- slot->reset();
+ update_inferred_steam_touchpad_click(gamepad, *slot, false, 0.0F);
+ log_failure("release libvirtualhid gamepad touch"sv, gamepad->adapter->clear_touchpad_contact(*slot));
+ gamepad->touch_ids[*slot].reset();
return;
}
if (touch.eventType != LI_TOUCH_EVENT_DOWN && touch.eventType != LI_TOUCH_EVENT_MOVE) {
@@ -752,11 +967,13 @@ namespace platf::virtualhid {
}
lvh::GamepadTouchContact contact;
- contact.id = static_cast(index);
- contact.active = touch.pressure > 0.5F;
+ contact.id = static_cast(*slot);
+ contact.active = true;
contact.x = std::clamp(touch.x, 0.0F, 1.0F);
contact.y = std::clamp(touch.y, 0.0F, 1.0F);
- log_failure("submit libvirtualhid gamepad touch"sv, gamepad->adapter->set_touchpad_contact(index, contact));
+ contact.pressure = std::clamp(touch.pressure, 0.0F, 1.0F);
+ log_failure("submit libvirtualhid gamepad touch"sv, gamepad->adapter->set_touchpad_contact(*slot, contact));
+ update_inferred_steam_touchpad_click(gamepad, *slot, true, contact.pressure);
}
void gamepad_motion(input_context_t &context, const gamepad_motion_t &motion) {
diff --git a/src/stream.cpp b/src/stream.cpp
index 1291c9c625e..99a1416234e 100644
--- a/src/stream.cpp
+++ b/src/stream.cpp
@@ -53,6 +53,7 @@ constexpr int IDX_SET_MOTION_EVENT = 13; ///< Control-stream message index for
constexpr int IDX_SET_RGB_LED = 14; ///< Control-stream message index for set rgb led.
constexpr int IDX_SET_ADAPTIVE_TRIGGERS = 15; ///< Control-stream message index for set adaptive triggers.
constexpr int IDX_SET_PLAYER_LEDS = 16; ///< Control-stream message index for set player indicator LEDs.
+constexpr int IDX_SET_CONTROLLER_HAPTICS = 17; ///< Control-stream message index for addressable controller haptics.
static const short packetTypes[] = {
0x0305, // Start A
@@ -72,6 +73,7 @@ static const short packetTypes[] = {
0x5502, // Set RGB LED (Sunshine protocol extension)
0x5503, // Set Adaptive triggers (Sunshine protocol extension)
0x5504, // Set player indicator LEDs (Sunshine protocol extension)
+ 0x5505, // Set controller haptics (Sunshine protocol extension)
};
namespace asio = boost::asio;
@@ -273,6 +275,28 @@ namespace stream {
std::uint8_t right[DS_EFFECT_PAYLOAD_SIZE]; ///< Right adaptive-trigger effect payload.
};
+ /**
+ * @brief Control payload that plays an addressable controller haptic effect.
+ */
+ struct control_set_controller_haptics_t {
+ control_header_v2 header; ///< Control message header preceding this payload.
+
+ std::uint16_t id; ///< Controller identifier associated with this message.
+ std::uint8_t target; ///< Target actuator selection.
+ std::uint8_t kind; ///< Haptic effect category.
+ std::int8_t gain_db; ///< Signed gain in decibels.
+ std::uint16_t intensity; ///< Profile-defined intensity value.
+ std::uint16_t frequency_hz; ///< Primary tone frequency in hertz.
+ std::uint32_t duration_us; ///< Signed effect duration encoded as a little-endian two's-complement value.
+ std::uint32_t interval_us; ///< Off interval between pulses in microseconds.
+ std::uint16_t repeat_count; ///< Pulse repeat count.
+ std::uint16_t lfo_frequency_hz; ///< Low-frequency oscillator frequency in hertz.
+ std::uint8_t lfo_depth_percent; ///< Low-frequency oscillator depth in percent.
+ std::uint16_t start_frequency_hz; ///< Sweep start frequency in hertz.
+ std::uint16_t end_frequency_hz; ///< Sweep end frequency in hertz.
+ std::uint8_t script_id; ///< Controller-defined scripted effect identifier.
+ };
+
/**
* @brief Control payload that toggles HDR mode and carries metadata.
*/
@@ -1101,6 +1125,31 @@ namespace stream {
plaintext.type_right = msg.data.adaptive_triggers.type_right;
std::ranges::copy(msg.data.adaptive_triggers.right, plaintext.right);
+ std::array
+ encrypted_payload;
+
+ payload = encode_control(session, util::view(plaintext), encrypted_payload);
+ } else if (msg.type == platf::gamepad_feedback_e::set_haptics) {
+ control_set_controller_haptics_t plaintext;
+ plaintext.header.type = packetTypes[IDX_SET_CONTROLLER_HAPTICS];
+ plaintext.header.payloadLength = sizeof(plaintext) - sizeof(control_header_v2);
+
+ const auto &data = msg.data.haptics;
+ plaintext.id = util::endian::little(msg.id);
+ plaintext.target = data.target;
+ plaintext.kind = data.kind;
+ plaintext.gain_db = data.gain_db;
+ plaintext.intensity = util::endian::little(data.intensity);
+ plaintext.frequency_hz = util::endian::little(data.frequency_hz);
+ plaintext.duration_us = util::endian::little(static_cast(data.duration_us));
+ plaintext.interval_us = util::endian::little(data.interval_us);
+ plaintext.repeat_count = util::endian::little(data.repeat_count);
+ plaintext.lfo_frequency_hz = util::endian::little(data.lfo_frequency_hz);
+ plaintext.lfo_depth_percent = data.lfo_depth_percent;
+ plaintext.start_frequency_hz = util::endian::little(data.start_frequency_hz);
+ plaintext.end_frequency_hz = util::endian::little(data.end_frequency_hz);
+ plaintext.script_id = data.script_id;
+
std::array
encrypted_payload;
diff --git a/src/system_tray.cpp b/src/system_tray.cpp
index 53fc797d391..9734328a064 100644
--- a/src/system_tray.cpp
+++ b/src/system_tray.cpp
@@ -166,9 +166,9 @@ namespace system_tray {
*/
std::array &virtualhid_benefits_menu_storage() {
static std::array benefits_menu {{
- {.text = "Xbox One, Xbox Series, DualSense (DS5), Switch Pro, and Generic", .disabled = 1},
+ {.text = "Steam Controller (2nd generation), Xbox One, Xbox Series, DualSense (DS5), Switch Pro, and Generic", .disabled = 1},
{.text = "Raw Input keyboard and mouse for physical-style input", .disabled = 1},
- {.text = "Motion, touchpads, LEDs, and adaptive triggers where supported", .disabled = 1},
+ {.text = "Motion, dual touchpads, rear buttons, LEDs, and adaptive triggers where supported", .disabled = 1},
{.text = "Actively developed and supported by LizardByte", .disabled = 1},
{},
}};
diff --git a/src_assets/common/assets/web/configs/tabs/Inputs.vue b/src_assets/common/assets/web/configs/tabs/Inputs.vue
index 0bf664555ff..87f2b809d8c 100644
--- a/src_assets/common/assets/web/configs/tabs/Inputs.vue
+++ b/src_assets/common/assets/web/configs/tabs/Inputs.vue
@@ -149,6 +149,7 @@ watch(
+