From 71c2d47fedace5a0674597a36be2222642a38599 Mon Sep 17 00:00:00 2001 From: varunaditya-plus Date: Sat, 4 Jul 2026 17:06:44 +0200 Subject: [PATCH 1/2] feat: added steam controller support --- .../src/main/groovy/multiloader-common.gradle | 1 + common/build.gradle | 1 + .../com/mrcrayfish/controllable/Config.java | 2 +- .../mrcrayfish/controllable/Controllable.java | 2 + .../controllable/client/CameraHandler.java | 15 + .../controllable/client/VirtualCursor.java | 32 ++ .../client/input/InputLibrary.java | 3 +- .../input/RelativePointerController.java | 8 + .../client/input/steam/SteamController.java | 490 ++++++++++++++++++ .../input/steam/SteamControllerDevice.java | 18 + .../input/steam/SteamControllerManager.java | 263 ++++++++++ fabric/build.gradle | 3 + gradle.properties | 3 +- neoforge/build.gradle | 4 + 14 files changed, 842 insertions(+), 3 deletions(-) create mode 100644 common/src/main/java/com/mrcrayfish/controllable/client/input/RelativePointerController.java create mode 100644 common/src/main/java/com/mrcrayfish/controllable/client/input/steam/SteamController.java create mode 100644 common/src/main/java/com/mrcrayfish/controllable/client/input/steam/SteamControllerDevice.java create mode 100644 common/src/main/java/com/mrcrayfish/controllable/client/input/steam/SteamControllerManager.java diff --git a/buildSrc/src/main/groovy/multiloader-common.gradle b/buildSrc/src/main/groovy/multiloader-common.gradle index bbb715d5..b857019e 100644 --- a/buildSrc/src/main/groovy/multiloader-common.gradle +++ b/buildSrc/src/main/groovy/multiloader-common.gradle @@ -29,6 +29,7 @@ def getPackagesRepo() { } repositories { + mavenLocal() mavenCentral() exclusiveContent { forRepository { diff --git a/common/build.gradle b/common/build.gradle index d15be4cb..a844b2be 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -21,6 +21,7 @@ dependencies { compileOnly "mezz.jei:jei-${jei_minecraft_version}-gui:${jei_version}" compileOnly "mezz.jei:jei-${jei_minecraft_version}-lib:${jei_version}" compileOnly "com.mrcrayfish:controllable-sdl:${controllable_sdl_version}" + compileOnly "org.hid4java:hid4java:${hid4java_version}" // gives access to hid4java to detect steam controller buttons //compileOnly "dev.emi:emi-xplat-mojmap:${emi_version}" compileOnly "me.shedaniel:RoughlyEnoughItems-neoforge:${rei_version}" compileOnly "org.spongepowered:mixin:0.8.7" diff --git a/common/src/main/java/com/mrcrayfish/controllable/Config.java b/common/src/main/java/com/mrcrayfish/controllable/Config.java index 5945e9e2..286c6a3d 100644 --- a/common/src/main/java/com/mrcrayfish/controllable/Config.java +++ b/common/src/main/java/com/mrcrayfish/controllable/Config.java @@ -28,7 +28,7 @@ public static class Client @ConfigProperty(name = "inputLibrary", gameRestart = true, comment = """ The library to use for game controller input. You can change this if you are having problems detecting game controllers. Please note that changing from the default library may disable some feature in Controllable, only use this as a last resort""") - public final EnumProperty inputLibrary = EnumProperty.create(InputLibrary.SDL2); + public final EnumProperty inputLibrary = EnumProperty.create(InputLibrary.STEAM_HID); public static class Options { diff --git a/common/src/main/java/com/mrcrayfish/controllable/Controllable.java b/common/src/main/java/com/mrcrayfish/controllable/Controllable.java index fd2b1cf7..4902b055 100644 --- a/common/src/main/java/com/mrcrayfish/controllable/Controllable.java +++ b/common/src/main/java/com/mrcrayfish/controllable/Controllable.java @@ -13,6 +13,7 @@ import com.mrcrayfish.controllable.client.input.Controller; import com.mrcrayfish.controllable.client.input.glfw.GLFWControllerManager; import com.mrcrayfish.controllable.client.input.sdl2.SDL2ControllerManager; +import com.mrcrayfish.controllable.client.input.steam.SteamControllerManager; import com.mrcrayfish.controllable.util.Utils; import org.jetbrains.annotations.Nullable; @@ -113,6 +114,7 @@ private static AdaptiveControllerManager createManager() return switch (Config.CLIENT.inputLibrary.get()) { case GLFW -> new GLFWControllerManager(); case SDL2 -> new SDL2ControllerManager(); + case STEAM_HID -> new SteamControllerManager(); }; } } diff --git a/common/src/main/java/com/mrcrayfish/controllable/client/CameraHandler.java b/common/src/main/java/com/mrcrayfish/controllable/client/CameraHandler.java index 7df70796..a979b8ee 100644 --- a/common/src/main/java/com/mrcrayfish/controllable/client/CameraHandler.java +++ b/common/src/main/java/com/mrcrayfish/controllable/client/CameraHandler.java @@ -5,12 +5,14 @@ import com.mrcrayfish.controllable.Controllable; import com.mrcrayfish.controllable.client.binding.ButtonBindings; import com.mrcrayfish.controllable.client.input.Controller; +import com.mrcrayfish.controllable.client.input.RelativePointerController; import com.mrcrayfish.controllable.client.util.EventHelper; import com.mrcrayfish.controllable.client.util.InputHelper; import com.mrcrayfish.controllable.event.Value; import com.mrcrayfish.framework.api.event.client.FrameworkClientTickEvents; import net.minecraft.client.DeltaTracker; import net.minecraft.client.Minecraft; +import org.joml.Vector2f; import org.jetbrains.annotations.ApiStatus; /** @@ -55,6 +57,19 @@ private void updateRotationDelta() if(controller == null) return; + // ingame trackpad motion turns the camera; menus use the same deltas in VirtualCursor + if(controller instanceof RelativePointerController relative) + { + Vector2f delta = relative.consumeRelativePointerDelta(); + if(delta.lengthSquared() > 0) + { + this.yawDelta = delta.x * 0.35F; + this.pitchDelta = delta.y * 0.35F; + controller.updateInputTime(); + return; + } + } + float thumbstickX = InputHelper.getCombinedPressedValue(controller, ButtonBindings.LOOK_LEFT, ButtonBindings.LOOK_RIGHT); float thumbstickY = InputHelper.getCombinedPressedValue(controller, ButtonBindings.LOOK_UP, ButtonBindings.LOOK_DOWN); if(thumbstickX * thumbstickX > 0 || thumbstickY * thumbstickY > 0) diff --git a/common/src/main/java/com/mrcrayfish/controllable/client/VirtualCursor.java b/common/src/main/java/com/mrcrayfish/controllable/client/VirtualCursor.java index 3c5d9a06..76cf4222 100644 --- a/common/src/main/java/com/mrcrayfish/controllable/client/VirtualCursor.java +++ b/common/src/main/java/com/mrcrayfish/controllable/client/VirtualCursor.java @@ -6,6 +6,7 @@ import com.mrcrayfish.controllable.client.binding.ButtonBindings; import com.mrcrayfish.controllable.client.gui.screens.ControllerLayoutScreen; import com.mrcrayfish.controllable.client.input.Controller; +import com.mrcrayfish.controllable.client.input.RelativePointerController; import com.mrcrayfish.controllable.client.util.InputHelper; import com.mrcrayfish.controllable.client.util.MouseHooks; import com.mrcrayfish.controllable.client.util.ScreenHelper; @@ -204,6 +205,9 @@ private void updateMovement() if(mc.screen == null || mc.screen instanceof ControllerLayoutScreen) return; + if(this.applyRelativePointerInput(controller, mc)) + return; + this.updateInputVector(controller); // If the magnitude is greater than zero, input is being given @@ -322,6 +326,34 @@ private void updateInputVector(Controller controller) this.inputVector.y = InputHelper.applyDeadzone(cursorVectorY, moveThreshold); } + // steam controller trackpad deltas should move the cursor before thumbstick cursor input is used + private boolean applyRelativePointerInput(Controller controller, Minecraft mc) + { + if(!(controller instanceof RelativePointerController relative)) + return false; + + Vector2f delta = relative.consumeRelativePointerDelta(); + if(delta.lengthSquared() <= 0) + return false; + + double cursorSpeed = Math.max(mc.getWindow().getGuiScale(), 1); + this.x += delta.x * cursorSpeed; + this.y += delta.y * cursorSpeed; + this.clampCursorToWindowBounds(); + this.setVisible(true); + this.snapIfNoMove = true; + this.mode = CursorMode.CONTROLLER; + controller.updateInputTime(); + + if(this.x != this.lastMoveX || this.y != this.lastMoveY) + { + MouseHooks.invokeMouseMoved(mc.screen, this.x, this.y, this.x - this.lastMoveX, this.y - this.lastMoveY); + this.lastMoveX = this.x; + this.lastMoveY = this.y; + } + return true; + } + /** * Clamps the position of the cursor to the bounds of the window */ diff --git a/common/src/main/java/com/mrcrayfish/controllable/client/input/InputLibrary.java b/common/src/main/java/com/mrcrayfish/controllable/client/input/InputLibrary.java index 4c6046de..0e7ae281 100644 --- a/common/src/main/java/com/mrcrayfish/controllable/client/input/InputLibrary.java +++ b/common/src/main/java/com/mrcrayfish/controllable/client/input/InputLibrary.java @@ -6,5 +6,6 @@ public enum InputLibrary { GLFW, - SDL2 // Default + SDL2, + STEAM_HID } diff --git a/common/src/main/java/com/mrcrayfish/controllable/client/input/RelativePointerController.java b/common/src/main/java/com/mrcrayfish/controllable/client/input/RelativePointerController.java new file mode 100644 index 00000000..9cd42000 --- /dev/null +++ b/common/src/main/java/com/mrcrayfish/controllable/client/input/RelativePointerController.java @@ -0,0 +1,8 @@ +package com.mrcrayfish.controllable.client.input; + +import org.joml.Vector2f; + +public interface RelativePointerController +{ + Vector2f consumeRelativePointerDelta(); +} diff --git a/common/src/main/java/com/mrcrayfish/controllable/client/input/steam/SteamController.java b/common/src/main/java/com/mrcrayfish/controllable/client/input/steam/SteamController.java new file mode 100644 index 00000000..ebb4eb3f --- /dev/null +++ b/common/src/main/java/com/mrcrayfish/controllable/client/input/steam/SteamController.java @@ -0,0 +1,490 @@ +package com.mrcrayfish.controllable.client.input.steam; + +import com.mrcrayfish.controllable.Constants; +import com.mrcrayfish.controllable.client.input.ButtonStates; +import com.mrcrayfish.controllable.client.input.Buttons; +import com.mrcrayfish.controllable.client.input.Controller; +import com.mrcrayfish.controllable.client.input.DeviceInfo; +import com.mrcrayfish.controllable.client.input.RelativePointerController; +import net.minecraft.client.Minecraft; +import net.minecraft.util.Mth; +import org.hid4java.HidDevice; +import org.joml.Vector2f; +import org.joml.Vector3f; + +import java.util.Arrays; + +public class SteamController extends Controller implements RelativePointerController +{ + private static final int REPORT_SIZE = 64; + private static final int FEATURE_REPORT_SIZE = 64; + private static final int MAX_REPORTS_PER_POLL = 64; + private static final long LIZARD_HEARTBEAT_MS = 800; + + private static final int ID_CLEAR_DIGITAL_MAPPINGS = 0x81; + private static final int ID_LOAD_DEFAULT_SETTINGS = 0x82; + private static final int ID_SET_DEFAULT_DIGITAL_MAPPINGS = 0x80; + private static final int ID_SET_SETTINGS_VALUES = 0x87; + private static final int SETTING_LEFT_TRACKPAD_MODE = 7; + private static final int SETTING_RIGHT_TRACKPAD_MODE = 8; + private static final int SETTING_LIZARD_MODE = 9; + private static final int SETTING_SMOOTH_ABSOLUTE_MOUSE = 27; + private static final int SETTING_WIRELESS_PACKET_VERSION = 49; + private static final int TRACKPAD_NONE = 7; + + private static final int ID_TRITON_CONTROLLER_STATE = 0x42; + private static final int ID_TRITON_CONTROLLER_STATE_BLE = 0x45; + private static final int ID_TRITON_CONTROLLER_STATE_TIMESTAMP = 0x47; + + private static final int TRITON_A = 0x00000001; + private static final int TRITON_B = 0x00000002; + private static final int TRITON_X = 0x00000004; + private static final int TRITON_Y = 0x00000008; + private static final int TRITON_QAM = 0x00000010; + private static final int TRITON_R3 = 0x00000020; + private static final int TRITON_VIEW = 0x00000040; + private static final int TRITON_R4 = 0x00000080; + private static final int TRITON_R5 = 0x00000100; + private static final int TRITON_R = 0x00000200; + private static final int TRITON_DPAD_DOWN = 0x00000400; + private static final int TRITON_DPAD_RIGHT = 0x00000800; + private static final int TRITON_DPAD_LEFT = 0x00001000; + private static final int TRITON_DPAD_UP = 0x00002000; + private static final int TRITON_MENU = 0x00004000; + private static final int TRITON_L3 = 0x00008000; + private static final int TRITON_STEAM = 0x00010000; + private static final int TRITON_L4 = 0x00020000; + private static final int TRITON_L5 = 0x00040000; + private static final int TRITON_L = 0x00080000; + private static final int TRITON_RIGHT_PAD_TOUCH = 0x00200000; + private static final int TRITON_RIGHT_PAD_CLICK = 0x00400000; + private static final int TRITON_RIGHT_TRIGGER_CLICK = 0x00800000; + private static final int TRITON_LEFT_PAD_CLICK = 0x04000000; + private static final int TRITON_LEFT_TRIGGER_CLICK = 0x08000000; + + private static final float INT16_MAX = 32767.0F; + private static final float GYRO_SCALE = (float) (2000.0 * Math.PI / 180.0 / 32768.0); + private static final float TRACKPAD_MOUSE_SCALE = 1.0F / 24.0F; + private static final float TRACKPAD_MOUSE_ACCELERATION = 0.25F; + + private final SteamControllerDevice deviceInfo; + private final DeviceInfo info; + private HidDevice device; + private Snapshot snapshot = Snapshot.EMPTY; + private long lastLizardHeartbeat; + private boolean open; + private boolean lastRightPadActive; + private int lastRightPadX; + private int lastRightPadY; + private float pointerDeltaX; + private float pointerDeltaY; + + SteamController(SteamControllerDevice deviceInfo) + { + this.deviceInfo = deviceInfo; + this.info = deviceInfo.toDeviceInfo(); + } + + @Override + public boolean open() + { + if(this.open) + return true; + + this.device = this.deviceInfo.hidDevice(); + if(!this.device.open()) + { + Constants.LOG.warn("Unable to open Steam Controller HID path {}", this.deviceInfo.path()); + this.device = null; + return false; + } + + this.device.setNonBlocking(true); + this.open = true; + this.disableLizardMode(); + this.lastLizardHeartbeat = System.currentTimeMillis(); + return true; + } + + @Override + public void close() + { + if(this.device == null) + return; + + try + { + this.restoreLizardMode(); + } + catch(RuntimeException e) + { + Constants.LOG.debug("Unable to restore Steam Controller lizard mode", e); + } + + this.device.close(); + this.device = null; + this.open = false; + this.snapshot = Snapshot.EMPTY; + this.lastRightPadActive = false; + this.pointerDeltaX = 0; + this.pointerDeltaY = 0; + } + + @Override + public Number getJid() + { + return this.deviceInfo.jid(); + } + + @Override + public boolean isOpen() + { + return this.open && this.device != null; + } + + @Override + public ButtonStates captureButtonStates() + { + this.pollReports(); + this.maintainLizardMode(); + + Snapshot snapshot = this.snapshot; + int raw = snapshot.buttons(); + ButtonStates states = new ButtonStates(); + states.setState(Buttons.A, has(raw, TRITON_A)); + states.setState(Buttons.B, has(raw, TRITON_B)); + states.setState(Buttons.X, has(raw, TRITON_X)); + states.setState(Buttons.Y, has(raw, TRITON_Y)); + states.setState(Buttons.SELECT, has(raw, TRITON_MENU)); + states.setState(Buttons.HOME, has(raw, TRITON_STEAM)); + states.setState(Buttons.START, has(raw, TRITON_VIEW)); + states.setState(Buttons.MISC, has(raw, TRITON_QAM)); + states.setState(Buttons.LEFT_THUMB_STICK, has(raw, TRITON_L3)); + states.setState(Buttons.RIGHT_THUMB_STICK, has(raw, TRITON_R3)); + states.setState(Buttons.LEFT_BUMPER, has(raw, TRITON_L)); + states.setState(Buttons.RIGHT_BUMPER, has(raw, TRITON_R)); + states.setState(Buttons.LEFT_TRIGGER, has(raw, TRITON_LEFT_TRIGGER_CLICK) || snapshot.leftTrigger() >= 0.5F); + states.setState(Buttons.RIGHT_TRIGGER, has(raw, TRITON_RIGHT_TRIGGER_CLICK) || snapshot.rightTrigger() >= 0.5F); + states.setState(Buttons.DPAD_UP, has(raw, TRITON_DPAD_UP)); + states.setState(Buttons.DPAD_DOWN, has(raw, TRITON_DPAD_DOWN)); + states.setState(Buttons.DPAD_LEFT, has(raw, TRITON_DPAD_LEFT)); + states.setState(Buttons.DPAD_RIGHT, has(raw, TRITON_DPAD_RIGHT)); + states.setState(Buttons.PADDLE_ONE, has(raw, TRITON_R4)); + states.setState(Buttons.PADDLE_TWO, has(raw, TRITON_L4)); + states.setState(Buttons.PADDLE_THREE, has(raw, TRITON_R5)); + states.setState(Buttons.PADDLE_FOUR, has(raw, TRITON_L5)); + states.setState(Buttons.TOUCHPAD, has(raw, TRITON_RIGHT_PAD_CLICK) || has(raw, TRITON_LEFT_PAD_CLICK)); + states.setState(Buttons.LEFT_THUMB_STICK_UP, snapshot.leftY() <= -0.5F); + states.setState(Buttons.LEFT_THUMB_STICK_DOWN, snapshot.leftY() >= 0.5F); + states.setState(Buttons.LEFT_THUMB_STICK_LEFT, snapshot.leftX() <= -0.5F); + states.setState(Buttons.LEFT_THUMB_STICK_RIGHT, snapshot.leftX() >= 0.5F); + states.setState(Buttons.RIGHT_THUMB_STICK_UP, snapshot.rightY() <= -0.5F); + states.setState(Buttons.RIGHT_THUMB_STICK_DOWN, snapshot.rightY() >= 0.5F); + states.setState(Buttons.RIGHT_THUMB_STICK_LEFT, snapshot.rightX() <= -0.5F); + states.setState(Buttons.RIGHT_THUMB_STICK_RIGHT, snapshot.rightX() >= 0.5F); + + // treat right pad click as primary select while a menu is open + if(Minecraft.getInstance().screen != null && has(raw, TRITON_RIGHT_PAD_CLICK)) + { + states.setState(Buttons.A, true); + } + return states; + } + + @Override + public String getName() + { + return this.deviceInfo.name(); + } + + @Override + public boolean supportsRumble() + { + return false; + } + + @Override + protected boolean internalRumble(float lowFrequency, float highFrequency, int timeInMs) + { + return false; + } + + @Override + protected float internalGetLTriggerValue() + { + this.pollReports(); + return this.snapshot.leftTrigger(); + } + + @Override + protected float internalGetRTriggerValue() + { + this.pollReports(); + return this.snapshot.rightTrigger(); + } + + @Override + protected float internalGetLThumbStickXValue() + { + this.pollReports(); + return this.snapshot.leftX(); + } + + @Override + protected float internalGetLThumbStickYValue() + { + this.pollReports(); + return this.snapshot.leftY(); + } + + @Override + protected float internalGetRThumbStickXValue() + { + this.pollReports(); + return this.snapshot.rightX(); + } + + @Override + protected float internalGetRThumbStickYValue() + { + this.pollReports(); + return this.snapshot.rightY(); + } + + @Override + public DeviceInfo getInfo() + { + return this.info; + } + + @Override + public boolean supportsGyroscope() + { + return true; + } + + @Override + public Vector3f getGyroscope() + { + this.pollReports(); + Snapshot snapshot = this.snapshot; + return new Vector3f(snapshot.gyroX(), snapshot.gyroY(), snapshot.gyroZ()); + } + + @Override + public Vector2f consumeRelativePointerDelta() + { + this.pollReports(); + Vector2f delta = new Vector2f(this.pointerDeltaX, this.pointerDeltaY); + this.pointerDeltaX = 0; + this.pointerDeltaY = 0; + return delta; + } + + void tick() + { + this.pollReports(); + this.maintainLizardMode(); + } + + private void pollReports() + { + if(!this.isOpen()) + return; + + byte[] report = new byte[REPORT_SIZE]; + for(int i = 0; i < MAX_REPORTS_PER_POLL; i++) + { + Arrays.fill(report, (byte) 0); + int read = this.device.read(report, 0); + if(read == 0) + return; + + if(read < 0) + { + this.open = false; + return; + } + + Snapshot next = this.parseReport(report, read); + if(next != null) + { + this.applyRightPadPointer(next); + this.snapshot = next; + } + } + } + + private Snapshot parseReport(byte[] report, int length) + { + if(length <= 0) + return null; + + int reportId = report[0] & 0xFF; + if(reportId != ID_TRITON_CONTROLLER_STATE && reportId != ID_TRITON_CONTROLLER_STATE_BLE && reportId != ID_TRITON_CONTROLLER_STATE_TIMESTAMP) + return null; + + if(length < 46) + return null; + + int buttons = u32(report, 2); + float leftTrigger = trigger(s16(report, 6)); + float rightTrigger = trigger(s16(report, 8)); + float leftX = axis(s16(report, 10)); + float leftY = axis(-s16(report, 12)); + float rightX = axis(s16(report, 14)); + float rightY = axis(-s16(report, 16)); + + // report 0x47 shifts the pad fields by two bytes, but its shorter timestamp keeps gyro bytes aligned + int rightPadOffset = reportId == ID_TRITON_CONTROLLER_STATE_TIMESTAMP ? 26 : 24; + int imuOffset = reportId == ID_TRITON_CONTROLLER_STATE_TIMESTAMP ? 32 : 30; + int gyroOffset = imuOffset + (reportId == ID_TRITON_CONTROLLER_STATE_TIMESTAMP ? 8 : 10); + + int rightPadX = s16(report, rightPadOffset); + int rightPadY = s16(report, rightPadOffset + 2); + int rightPadPressure = u16(report, rightPadOffset + 4); + + float gyroX = s16(report, gyroOffset) * GYRO_SCALE; + float gyroY = s16(report, gyroOffset + 4) * GYRO_SCALE; + float gyroZ = -s16(report, gyroOffset + 2) * GYRO_SCALE; + + return new Snapshot(buttons, leftTrigger, rightTrigger, leftX, leftY, rightX, rightY, + rightPadX, rightPadY, rightPadPressure, gyroX, gyroY, gyroZ); + } + + private void applyRightPadPointer(Snapshot next) + { + boolean active = has(next.buttons(), TRITON_RIGHT_PAD_TOUCH) || next.rightPadPressure() > 0; + if(!active) + { + this.lastRightPadActive = false; + return; + } + + if(this.lastRightPadActive) + { + // pad reports absolute positions while mc needs motion deltas for cursor and camera control + int dx = next.rightPadX() - this.lastRightPadX; + int dy = next.rightPadY() - this.lastRightPadY; + double magnitude = Math.sqrt(dx * (double) dx + dy * (double) dy); + float acceleration = 1.0F + Math.min(2.0F, (float) magnitude / 5000.0F) * TRACKPAD_MOUSE_ACCELERATION; + float scale = TRACKPAD_MOUSE_SCALE * acceleration; + this.pointerDeltaX += dx * scale; + this.pointerDeltaY += -dy * scale; + } + + this.lastRightPadActive = true; + this.lastRightPadX = next.rightPadX(); + this.lastRightPadY = next.rightPadY(); + } + + private void maintainLizardMode() + { + if(!this.isOpen()) + return; + + long now = System.currentTimeMillis(); + if(now - this.lastLizardHeartbeat >= LIZARD_HEARTBEAT_MS) + { + // keep lizard mode off while Controllable owns the reports + this.sendFeatureCommand(ID_CLEAR_DIGITAL_MAPPINGS); + this.sendLizardSetting(false); + this.lastLizardHeartbeat = now; + } + } + + private void disableLizardMode() + { + this.sendFeatureCommand(ID_CLEAR_DIGITAL_MAPPINGS); + this.sendFeatureCommand(ID_SET_SETTINGS_VALUES, + 15, + SETTING_WIRELESS_PACKET_VERSION, 2, 0, + SETTING_LEFT_TRACKPAD_MODE, TRACKPAD_NONE, 0, + SETTING_RIGHT_TRACKPAD_MODE, TRACKPAD_NONE, 0, + SETTING_SMOOTH_ABSOLUTE_MOUSE, 0, 0, + SETTING_LIZARD_MODE, 0, 0); + this.sendLizardSetting(false); + } + + private void restoreLizardMode() + { + this.sendFeatureCommand(ID_SET_DEFAULT_DIGITAL_MAPPINGS); + this.sendFeatureCommand(ID_LOAD_DEFAULT_SETTINGS, 0); + this.sendLizardSetting(true); + } + + private boolean sendLizardSetting(boolean enabled) + { + byte[] report = new byte[FEATURE_REPORT_SIZE]; + report[0] = (byte) ID_SET_SETTINGS_VALUES; + report[1] = 3; + report[2] = SETTING_LIZARD_MODE; + report[3] = (byte) (enabled ? 1 : 0); + return this.sendFeatureReport((byte) 1, report); + } + + private boolean sendFeatureCommand(int command, int... payload) + { + byte[] report = new byte[FEATURE_REPORT_SIZE]; + report[0] = (byte) command; + for(int i = 0; i < payload.length && i + 1 < report.length; i++) + { + report[i + 1] = (byte) payload[i]; + } + return this.sendFeatureReport((byte) 0, report); + } + + private boolean sendFeatureReport(byte reportId, byte[] report) + { + if(this.device == null) + return false; + + // hid4java attaches HID report ID so report contains only the 64-byte Steam command payload + int sent = this.device.sendFeatureReport(report, reportId); + if(sent <= 0) + { + Constants.LOG.debug("Steam Controller feature report failed: command=0x{}", Integer.toHexString(report.length > 0 ? report[0] & 0xFF : 0)); + return false; + } + return true; + } + + private static boolean has(int buttons, int mask) + { + return (buttons & mask) != 0; + } + + private static float axis(int value) + { + return Mth.clamp(value / INT16_MAX, -1.0F, 1.0F); + } + + private static float trigger(int value) + { + return Mth.clamp(value / INT16_MAX, 0.0F, 1.0F); + } + + private static int u16(byte[] data, int offset) + { + return (data[offset] & 0xFF) | ((data[offset + 1] & 0xFF) << 8); + } + + private static int s16(byte[] data, int offset) + { + int value = u16(data, offset); + return value > 0x7FFF ? value - 0x10000 : value; + } + + private static int u32(byte[] data, int offset) + { + return (data[offset] & 0xFF) | + ((data[offset + 1] & 0xFF) << 8) | + ((data[offset + 2] & 0xFF) << 16) | + ((data[offset + 3] & 0xFF) << 24); + } + + private record Snapshot(int buttons, float leftTrigger, float rightTrigger, float leftX, float leftY, float rightX, float rightY, int rightPadX, int rightPadY, int rightPadPressure, float gyroX, float gyroY, float gyroZ) + { + private static final Snapshot EMPTY = new Snapshot(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + } +} diff --git a/common/src/main/java/com/mrcrayfish/controllable/client/input/steam/SteamControllerDevice.java b/common/src/main/java/com/mrcrayfish/controllable/client/input/steam/SteamControllerDevice.java new file mode 100644 index 00000000..d9a8f989 --- /dev/null +++ b/common/src/main/java/com/mrcrayfish/controllable/client/input/steam/SteamControllerDevice.java @@ -0,0 +1,18 @@ +package com.mrcrayfish.controllable.client.input.steam; + +import com.mrcrayfish.controllable.client.input.Buttons; +import com.mrcrayfish.controllable.client.input.DeviceInfo; +import org.hid4java.HidDevice; + +record SteamControllerDevice(int index, int jid, HidDevice hidDevice, String name, String serial, int vendor, int product, int productVersion) +{ + String path() + { + return this.hidDevice.getPath(); + } + + DeviceInfo toDeviceInfo() + { + return new DeviceInfo(this.name, "steam-controller-hid", this.serial, -1, this.vendor, this.product, this.productVersion, -1, Buttons.LENGTH, 6); + } +} diff --git a/common/src/main/java/com/mrcrayfish/controllable/client/input/steam/SteamControllerManager.java b/common/src/main/java/com/mrcrayfish/controllable/client/input/steam/SteamControllerManager.java new file mode 100644 index 00000000..1ac6a84f --- /dev/null +++ b/common/src/main/java/com/mrcrayfish/controllable/client/input/steam/SteamControllerManager.java @@ -0,0 +1,263 @@ +package com.mrcrayfish.controllable.client.input.steam; + +import com.google.common.io.ByteStreams; +import com.mrcrayfish.controllable.Config; +import com.mrcrayfish.controllable.Constants; +import com.mrcrayfish.controllable.client.input.AdaptiveControllerManager; +import com.mrcrayfish.controllable.client.input.Controller; +import com.mrcrayfish.controllable.client.input.DeviceInfo; +import org.apache.commons.lang3.tuple.Pair; +import org.hid4java.HidDevice; +import org.hid4java.HidManager; +import org.hid4java.HidServices; +import org.hid4java.HidServicesSpecification; +import org.hid4java.ScanMode; +import org.hid4java.jna.HidApi; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class SteamControllerManager extends AdaptiveControllerManager +{ + private static final int VALVE_VENDOR_ID = 0x28DE; + private static final int VENDOR_USAGE_PAGE = 0xFF00; + private static final Set STEAM_CONTROLLER_PRODUCTS = Set.of(0x1302, 0x1303, 0x1304, 0x1142); + private static final int JID_BASE = 0x5C000000; + + private HidServices hidServices; + private List devices = List.of(); + private long lastRefreshTime; + + @Override + public void init() + { + try + { + HidServicesSpecification specification = new HidServicesSpecification(); + specification.setAutoStart(false); + specification.setAutoDataRead(false); + specification.setScanMode(ScanMode.NO_SCAN); + // macos opens HID devices exclusively by default. + // non-exclusive opens coexist with Steam, Lunar, and macos + HidApi.darwinOpenDevicesNonExclusive = true; + this.hidServices = HidManager.getHidServices(specification); + this.hidServices.start(); + this.refreshDevices(true); + this.controllers = this.createRawControllerMap(); + } + catch(RuntimeException e) + { + Constants.LOG.error("Unable to initialize Steam Controller HID backend", e); + this.devices = List.of(); + this.controllers = Map.of(); + } + } + + @Override + public void dispose() + { + this.setActiveController(null); + if(this.hidServices != null) + { + try + { + this.hidServices.stop(); + } + catch(RuntimeException e) + { + Constants.LOG.debug("Unable to stop Steam Controller HID backend cleanly", e); + } + this.hidServices = null; + } + } + + @Override + public Controller createController(int deviceIndex, Number jid) + { + SteamControllerDevice device = this.findDevice(deviceIndex, jid); + if(device == null) + throw new IllegalArgumentException("Unknown Steam Controller device: index=" + deviceIndex + " jid=" + jid); + return new SteamController(device); + } + + @Override + @Nullable + public Controller connectToBestGameController() + { + this.refreshDevices(false); + List lastDevices = this.getLastDevices(); + if(!lastDevices.isEmpty()) + { + for(DeviceInfo info : lastDevices) + { + for(SteamControllerDevice device : this.devices) + { + if(device.toDeviceInfo().equals(info)) + { + SteamController controller = new SteamController(device); + if(this.setActiveController(controller)) + return controller; + } + } + } + } + + if(!this.devices.isEmpty()) + { + SteamController controller = new SteamController(this.devices.getFirst()); + if(this.setActiveController(controller)) + return controller; + } + return null; + } + + @Override + public void updateMappings(InputStream is) throws IOException + { + ByteStreams.exhaust(is); + Constants.LOG.info("Steam Controller HID backend uses built-in Triton mappings"); + } + + @Override + protected int getRawControllerCount() + { + this.refreshDevices(false); + return this.devices.size(); + } + + @Override + protected Map> createRawControllerMap() + { + Map> controllers = new HashMap<>(); + for(SteamControllerDevice device : this.devices) + { + controllers.put(device.jid(), Pair.of(device.index(), device.name())); + } + return controllers; + } + + @Override + public void tick() + { + this.refreshDevices(false); + Map> nextControllers = this.createRawControllerMap(); + if(!nextControllers.keySet().equals(this.controllers.keySet())) + { + Controller activeController = this.getActiveController(); + if(activeController != null && !nextControllers.containsKey(activeController.getJid())) + { + this.sendControllerToast(false, activeController); + this.setActiveController(null); + activeController = null; + } + this.controllers = nextControllers; + if(this.ready && activeController == null && Config.CLIENT.options.autoSelect.get()) + { + Controller connected = this.connectToBestGameController(); + this.sendControllerToast(true, connected); + } + } + + Controller activeController = this.getActiveController(); + if(activeController instanceof SteamController steamController) + { + steamController.tick(); + if(!steamController.isOpen()) + { + this.sendControllerToast(false, steamController); + this.setActiveController(null); + } + } + } + + private SteamControllerDevice findDevice(int deviceIndex, Number jid) + { + this.refreshDevices(false); + for(SteamControllerDevice device : this.devices) + { + if(device.index() == deviceIndex || device.jid() == jid.intValue()) + return device; + } + return null; + } + + private void refreshDevices(boolean force) + { + if(this.hidServices == null) + return; + + long now = System.currentTimeMillis(); + if(!force && now - this.lastRefreshTime < 1000) + return; + + this.lastRefreshTime = now; + List nextDevices = new ArrayList<>(); + List allDevices = this.enumerateValveDevices().stream() + .sorted(Comparator.comparing(device -> device.getPath() == null ? "" : device.getPath())) + .toList(); + + List preferred = allDevices.stream() + .filter(SteamControllerManager::isSteamController) + .filter(device -> usagePage(device) == VENDOR_USAGE_PAGE) + .toList(); + // steam controller exposes multiple HID interfaces, so the vendor page is the one that carries Valve reports + List candidates = preferred.isEmpty() ? allDevices.stream().filter(SteamControllerManager::isSteamController).toList() : preferred; + + Set seenPaths = new HashSet<>(); + for(HidDevice device : candidates) + { + if(device.getPath() == null || !seenPaths.add(device.getPath())) + continue; + + int index = nextDevices.size(); + int product = device.getProductId(); + int jid = JID_BASE | ((product & 0xFFFF) << 8) | (index & 0xFF); + String name = device.getProduct() != null && !device.getProduct().isBlank() ? device.getProduct() : "Steam Controller"; + if(!name.toLowerCase().contains("steam")) + { + name = "Steam Controller"; + } + nextDevices.add(new SteamControllerDevice(index, jid, device, name + " (Steam HID)", + device.getSerialNumber(), device.getVendorId(), product, releaseNumber(device))); + } + this.devices = List.copyOf(nextDevices); + } + + private List enumerateValveDevices() + { + try + { + return this.hidServices.getAttachedHidDevices().stream() + .filter(device -> device.getVendorId() == VALVE_VENDOR_ID) + .toList(); + } + catch(RuntimeException e) + { + Constants.LOG.debug("Unable to enumerate Steam Controller HID devices", e); + return List.of(); + } + } + + private static boolean isSteamController(HidDevice device) + { + return device.getVendorId() == VALVE_VENDOR_ID && STEAM_CONTROLLER_PRODUCTS.contains(device.getProductId()); + } + + private static int releaseNumber(HidDevice device) + { + return device.getReleaseNumber() & 0xFFFF; + } + + private static int usagePage(HidDevice device) + { + return device.getUsagePage() & 0xFFFF; + } +} diff --git a/fabric/build.gradle b/fabric/build.gradle index 2b9884a5..162be71c 100644 --- a/fabric/build.gradle +++ b/fabric/build.gradle @@ -20,6 +20,9 @@ dependencies { library include("com.mrcrayfish:controllable-sdl:${controllable_sdl_version}") { transitive false } + library include("org.hid4java:hid4java:${hid4java_version}") { + transitive false + } } sourceSets.main.resources.srcDir 'src/generated/resources' diff --git a/gradle.properties b/gradle.properties index a6181383..c5e4da4a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -31,7 +31,8 @@ mod_license=MIT # Dependency options controllable_sdl_version=2.32.10-1.1.0 -framework_version=0.13.22 +hid4java_version=0.8.0 +framework_version=0.13.23 architectury_api_version=18.0.3 jei_version=29.5.0.24 jei_minecraft_version=26.1.2 diff --git a/neoforge/build.gradle b/neoforge/build.gradle index f59a25b6..f8417e42 100644 --- a/neoforge/build.gradle +++ b/neoforge/build.gradle @@ -68,6 +68,10 @@ dependencies { transitive(false) } + library jarJar("org.hid4java:hid4java:${hid4java_version}") { + transitive(false) + } + testImplementation "org.junit.jupiter:junit-jupiter:5.7.2" testRuntimeOnly "org.junit.platform:junit-platform-launcher" From fc594f3f311366d4c2e1899d4401233fe48fc71e Mon Sep 17 00:00:00 2001 From: varunaditya-plus Date: Sat, 4 Jul 2026 21:35:52 +0200 Subject: [PATCH 2/2] feat: added steam controller trackpad sensitivity settings --- .../java/com/mrcrayfish/controllable/Config.java | 12 ++++++++++++ .../controllable/client/CameraHandler.java | 5 +++-- .../controllable/client/VirtualCursor.java | 2 +- .../client/gui/screens/SettingsScreen.java | 4 ++++ .../resources/assets/controllable/lang/en_us.json | 7 ++++++- 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/common/src/main/java/com/mrcrayfish/controllable/Config.java b/common/src/main/java/com/mrcrayfish/controllable/Config.java index 286c6a3d..7af07f0c 100644 --- a/common/src/main/java/com/mrcrayfish/controllable/Config.java +++ b/common/src/main/java/com/mrcrayfish/controllable/Config.java @@ -142,6 +142,9 @@ remote servers (read message below). Gyro related options. (Experimental)""") public final Gyro gyro = new Gyro(); + @ConfigProperty(name = "steamController", comment = "Steam Controller related options.") + public final SteamController steamController = new SteamController(); + @ConfigProperty(name = "advanced", comment = """ Advanced related options. "advancedMode" option must be enabled""") public final Advanced advanced = new Advanced(); @@ -169,6 +172,15 @@ public static class Gyro public final BoolProperty invertY = BoolProperty.create(false); } + public static class SteamController + { + @ConfigProperty(name = "trackpadMenuSensitivity", comment = "The Steam Controller's trackpad sensitivity when moving the cursor in menus") + public final DoubleProperty trackpadMenuSensitivity = DoubleProperty.create(1.0, 0.0, 5.0); + + @ConfigProperty(name = "trackpadGameSensitivity", comment = "The Steam Controller's trackpad sensitivity when turning the camera in game") + public final DoubleProperty trackpadGameSensitivity = DoubleProperty.create(0.35, 0.0, 2.0); + } + public static class Advanced { @ConfigProperty(name = "advancedMode", comment = """ diff --git a/common/src/main/java/com/mrcrayfish/controllable/client/CameraHandler.java b/common/src/main/java/com/mrcrayfish/controllable/client/CameraHandler.java index a979b8ee..0c06ba49 100644 --- a/common/src/main/java/com/mrcrayfish/controllable/client/CameraHandler.java +++ b/common/src/main/java/com/mrcrayfish/controllable/client/CameraHandler.java @@ -63,8 +63,9 @@ private void updateRotationDelta() Vector2f delta = relative.consumeRelativePointerDelta(); if(delta.lengthSquared() > 0) { - this.yawDelta = delta.x * 0.35F; - this.pitchDelta = delta.y * 0.35F; + float sensitivity = Config.CLIENT.options.steamController.trackpadGameSensitivity.get().floatValue(); + this.yawDelta = delta.x * sensitivity; + this.pitchDelta = delta.y * sensitivity; controller.updateInputTime(); return; } diff --git a/common/src/main/java/com/mrcrayfish/controllable/client/VirtualCursor.java b/common/src/main/java/com/mrcrayfish/controllable/client/VirtualCursor.java index 76cf4222..8feecfc7 100644 --- a/common/src/main/java/com/mrcrayfish/controllable/client/VirtualCursor.java +++ b/common/src/main/java/com/mrcrayfish/controllable/client/VirtualCursor.java @@ -336,7 +336,7 @@ private boolean applyRelativePointerInput(Controller controller, Minecraft mc) if(delta.lengthSquared() <= 0) return false; - double cursorSpeed = Math.max(mc.getWindow().getGuiScale(), 1); + double cursorSpeed = Math.max(mc.getWindow().getGuiScale(), 1) * Config.CLIENT.options.steamController.trackpadMenuSensitivity.get(); this.x += delta.x * cursorSpeed; this.y += delta.y * cursorSpeed; this.clampCursorToWindowBounds(); diff --git a/common/src/main/java/com/mrcrayfish/controllable/client/gui/screens/SettingsScreen.java b/common/src/main/java/com/mrcrayfish/controllable/client/gui/screens/SettingsScreen.java index 5e84fdc1..ee65eb98 100644 --- a/common/src/main/java/com/mrcrayfish/controllable/client/gui/screens/SettingsScreen.java +++ b/common/src/main/java/com/mrcrayfish/controllable/client/gui/screens/SettingsScreen.java @@ -382,6 +382,10 @@ public SettingsTab() advancedModeOption.setChangeCallback(aBoolean -> optionsList.rebuildList(true)); optionsList.addEntry(advancedModeOption); + optionsList.addEntry(new TabOptionTitleItem(Component.translatable("controllable.gui.title.steam_controller").withStyle(ChatFormatting.BOLD, ChatFormatting.YELLOW))); + optionsList.addEntry(new TabOptionSliderItem(Config.CLIENT.options.steamController.trackpadMenuSensitivity, 0.05)); + optionsList.addEntry(new TabOptionSliderItem(Config.CLIENT.options.steamController.trackpadGameSensitivity, 0.05)); + this.listWidget = rootHelper.addChild(new TabListWidget(optionsList)); optionsList.finalise(); optionsList.rebuildList(false); diff --git a/common/src/main/resources/assets/controllable/lang/en_us.json b/common/src/main/resources/assets/controllable/lang/en_us.json index 68a5bd59..cebd6708 100644 --- a/common/src/main/resources/assets/controllable/lang/en_us.json +++ b/common/src/main/resources/assets/controllable/lang/en_us.json @@ -130,6 +130,7 @@ "controllable.gui.title.select_button_bindings": "Add/Remove Bindings", "controllable.gui.title.select_controller": "Select a Controller", "controllable.gui.title.select_key_bindings": "Add Custom Keybinds", + "controllable.gui.title.steam_controller": "Steam Controller", "controllable.gui.title.thumbstick_settings": "Thumbstick Settings", "controllable.gui.unregister": "Unregister", "controllable.gui.update_mapping_message": "Controllable will download the latest gamepad mappings from GitHub with the following url: %s", @@ -262,6 +263,10 @@ "framework_config.controllable.client.options.showActions.tooltip": "If enabled, shows common actions when displaying available on the screen", "framework_config.controllable.client.options.spyglassSensitivity": "Spyglass Sensitivity", "framework_config.controllable.client.options.spyglassSensitivity.tooltip": "Adjusts the sensitivity of the camera when looking through a spyglass", + "framework_config.controllable.client.options.steamController.trackpadGameSensitivity": "Trackpad In-Game Sensitivity", + "framework_config.controllable.client.options.steamController.trackpadGameSensitivity.tooltip": "The Steam Controller's trackpad sensitivity when turning the camera in game.", + "framework_config.controllable.client.options.steamController.trackpadMenuSensitivity": "Trackpad Menu Sensitivity", + "framework_config.controllable.client.options.steamController.trackpadMenuSensitivity.tooltip": "The Steam Controller's trackpad sensitivity when moving the cursor in menus.", "framework_config.controllable.client.options.switchThumbsticks": "Switch Thumbsticks", "framework_config.controllable.client.options.triggerDeadZone": "Trigger Dead Zone", "framework_config.controllable.client.options.triggerDeadZone.tooltip": "How much the trigger has to be pressed before it's input is registered. This fixes issues with triggers not being completely released to zero.", @@ -269,4 +274,4 @@ "framework_config.controllable.client.options.yawSensitivity.tooltip": "The sensitivity of the camera's yaw rotation when applying the rotation speed. Setting to 1.0 would mean applying 100% of the rotation speed to the yaw.", "key.category.controllable.custom": "Custom Keybinds", "key.category.controllable.ui": "Game Interface" -} \ No newline at end of file +}