diff --git a/src/main/java/dev/isxander/controlify/Controlify.java b/src/main/java/dev/isxander/controlify/Controlify.java index 9a87ca18e..bede2a635 100644 --- a/src/main/java/dev/isxander/controlify/Controlify.java +++ b/src/main/java/dev/isxander/controlify/Controlify.java @@ -22,6 +22,8 @@ import dev.isxander.controlify.config.settings.device.DeviceSettings; import dev.isxander.controlify.config.settings.profile.ProfileSettings; import dev.isxander.controlify.controller.*; +import dev.isxander.controlify.controller.dualsense.BuiltinTriggerEffects; +import dev.isxander.controlify.controller.dualsense.TriggerEffectManager; import dev.isxander.controlify.controller.id.ControllerTypeManager; import dev.isxander.controlify.controller.input.ControllerState; import dev.isxander.controlify.controller.input.ControllerStateView; @@ -29,6 +31,7 @@ import dev.isxander.controlify.controller.rumble.RumbleComponent; import dev.isxander.controlify.controllermanager.ControllerManager; import dev.isxander.controlify.controllermanager.SDLControllerManager; +import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; import dev.isxander.controlify.driver.sdl.SDLNativesLoader; import dev.isxander.controlify.driver.steamdeck.SteamDeckMode; import dev.isxander.controlify.driver.steamdeck.SteamDeckUtil; @@ -62,6 +65,7 @@ import net.minecraft.client.multiplayer.ServerData; import net.minecraft.network.chat.Component; import net.minecraft.resources.Identifier; +import net.minecraft.world.item.Items; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.lwjgl.glfw.GLFW; @@ -94,6 +98,8 @@ public class Controlify implements ControlifyApi { private ControllerTypeManager controllerTypeManager; private KeyboardLayoutManager keyboardLayoutManager; + private TriggerEffectManager triggerEffectManager; + private Set thisTickContexts; private ConfigManager config; @@ -242,10 +248,12 @@ public void initializeControlify() { CUtil.LOGGER.log("Initializing Controlify..."); this.minecraft = Minecraft.getInstance(); + config().loadOrDefault(); + this.inGameInputHandler = null; // set when the current controller changes this.virtualMouseHandler = new VirtualMouseHandler(); - - config().loadOrDefault(); + this.triggerEffectManager = new TriggerEffectManager(this.minecraft); + BuiltinTriggerEffects.register(); ControlifyEvents.CONTROLLER_CONNECTED.register(event -> this.onControllerAdded( event.controller(), event.hotplugged())); @@ -520,6 +528,9 @@ private void tickActiveController(ControllerEntity controller, boolean outOfFocu rumbleManager.ifPresent(RumbleManager::tick); } + boolean controllerInputSuppressed = outOfFocus || currentInputMode() == InputMode.KEYBOARD_MOUSE; + triggerEffectManager.applyTriggerEffects(controller, controllerInputSuppressed); + if (state.isGivingInput()) { minecraft.getFramerateLimitTracker().onInputReceived(); @@ -754,6 +765,10 @@ public KeyboardLayoutManager keyboardLayoutManager() { return keyboardLayoutManager; } + public TriggerEffectManager triggerEffectManager() { + return triggerEffectManager; + } + public Set thisTickBindContexts() { return this.thisTickContexts; } diff --git a/src/main/java/dev/isxander/controlify/api/buttonguide/ButtonGuideApi.java b/src/main/java/dev/isxander/controlify/api/buttonguide/ButtonGuideApi.java index 11cf378a8..ab2bca95c 100644 --- a/src/main/java/dev/isxander/controlify/api/buttonguide/ButtonGuideApi.java +++ b/src/main/java/dev/isxander/controlify/api/buttonguide/ButtonGuideApi.java @@ -18,6 +18,10 @@ * This should be called every time a button is initialised, like in {@link Screen#init()} */ public final class ButtonGuideApi { + + private ButtonGuideApi() { + } + /** * Makes the button render the image of the binding specified. * This does not invoke the button press on binding trigger, only renders the guide. diff --git a/src/main/java/dev/isxander/controlify/api/triggereffect/TriggerEffectApi.java b/src/main/java/dev/isxander/controlify/api/triggereffect/TriggerEffectApi.java new file mode 100644 index 000000000..8ef2cdb5e --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/triggereffect/TriggerEffectApi.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.triggereffect; + +import dev.isxander.controlify.Controlify; +import dev.isxander.controlify.controller.dualsense.TriggerEffectHolder; +import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; +import net.minecraft.core.component.DataComponentType; +import net.minecraft.world.item.Item; +import org.jetbrains.annotations.NotNull; + +import java.util.function.Function; + +public final class TriggerEffectApi { + + private TriggerEffectApi() { + } + + public static void registerUseItemEffect(DataComponentType componentType, Function effectFunction) { + Controlify.instance().triggerEffectManager().registerUseItemComponentEffect(componentType, effectFunction); + } + + public static void registerUseItemEffect(DataComponentType componentType, @NotNull DualsenseTriggerEffect effect) { + registerUseItemEffect(componentType, _ -> effect); + } + + public static void registerSwingItemEffect(DataComponentType componentType, Function effectFunction) { + Controlify.instance().triggerEffectManager().registerSwingItemComponentEffect(componentType, effectFunction); + } + + public static void registerSwingItemEffect(DataComponentType componentType, @NotNull DualsenseTriggerEffect effect) { + registerSwingItemEffect(componentType, _ -> effect); + } + + public static void registerUseItemEffect(Item item, @NotNull DualsenseTriggerEffect effect) { + ((TriggerEffectHolder) item).controlify$assignUseTriggerEffect(effect); + } + + public static void registerSwingItemEffect(Item item, @NotNull DualsenseTriggerEffect effect) { + ((TriggerEffectHolder) item).controlify$assignSwingTriggerEffect(effect); + } +} diff --git a/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifyDataFixer.java b/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifyDataFixer.java index b0117a38e..350bfedc6 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifyDataFixer.java +++ b/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifyDataFixer.java @@ -13,7 +13,7 @@ import dev.isxander.controlify.config.settings.profile.ProfileSettings; public final class ControlifyDataFixer { - public static final int CURRENT_VERSION = 5; + public static final int CURRENT_VERSION = 6; private static final DataFixer FIXER = createFixer(); @@ -28,14 +28,15 @@ private static DataFixer createFixer() { var v1 = builder.addSchema(1, ControlifySchemas.V1::new); var v2 = builder.addSchema(2, ControlifySchemas.V2::new); var v3 = builder.addSchema(3, ControlifySchemas.V3::new); + var v6 = builder.addSchema(6, ControlifySchemas.V6::new); - builder.addFixer(new TheHolyMigrationFix( - v1, - GlobalSettings.defaults(), - ProfileSettings.createDefault() - )); + var globalDefaults = GlobalSettings.defaults(); + var profileDefaults = ProfileSettings.createDefault(); + + builder.addFixer(new TheHolyMigrationFix(v1, globalDefaults, profileDefaults)); builder.addFixer(new AnalogueMovementWhitelistFix(v2)); builder.addFixer(new HorizontalLookInvertFix(v2)); + builder.addFixer(new DualsenseConfigFix(v6, profileDefaults)); return builder.build().fixer(); } diff --git a/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifySchemas.java b/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifySchemas.java index 791cdc160..adb3f1834 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifySchemas.java +++ b/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifySchemas.java @@ -74,4 +74,10 @@ public void registerTypes(Schema schema, Map> ent ); } } + + public static class V6 extends Schema { + public V6(int versionKey, Schema parent) { + super(versionKey, parent); + } + } } diff --git a/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/DualsenseConfigFix.java b/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/DualsenseConfigFix.java new file mode 100644 index 000000000..3f11e7d53 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/DualsenseConfigFix.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.config.dto.dfu.fixes; + +import com.mojang.datafixers.DSL; +import com.mojang.datafixers.DataFix; +import com.mojang.datafixers.TypeRewriteRule; +import com.mojang.datafixers.schemas.Schema; +import com.mojang.serialization.Dynamic; +import dev.isxander.controlify.config.dto.dfu.ControlifyTypeReferences; +import dev.isxander.controlify.config.settings.profile.ProfileSettings; + +public final class DualsenseConfigFix extends DataFix { + private final ProfileSettings profileDefaults; + + public DualsenseConfigFix(Schema outputSchema, ProfileSettings profileDefaults) { + super(outputSchema, true); + this.profileDefaults = profileDefaults; + } + + @Override + protected TypeRewriteRule makeRule() { + var profileType = getInputSchema().getType(ControlifyTypeReferences.PROFILE_CONFIG); + + return fixTypeEverywhereTyped( + "Controlify: add DualSense profile config", + profileType, + typed -> typed.update(DSL.remainderFinder(), this::rewriteProfile) + ); + } + + private Dynamic rewriteProfile(Dynamic root) { + Dynamic dualsense = root.get("dualsense").orElseEmptyMap(); + if (dualsense.get("trigger_effects").result().isEmpty()) { + dualsense = dualsense.set("trigger_effects", root.createBoolean(profileDefaults.dualsense.triggerEffects)); + } + return root.set("dualsense", dualsense); + } +} diff --git a/src/main/java/dev/isxander/controlify/config/dto/profile/DualsenseConfig.java b/src/main/java/dev/isxander/controlify/config/dto/profile/DualsenseConfig.java new file mode 100644 index 000000000..064842fd0 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/config/dto/profile/DualsenseConfig.java @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.config.dto.profile; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +public record DualsenseConfig( + boolean triggerEffects +) { + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.BOOL.fieldOf("trigger_effects").forGetter(DualsenseConfig::triggerEffects) + ).apply(instance, DualsenseConfig::new)); +} diff --git a/src/main/java/dev/isxander/controlify/config/dto/profile/ProfileConfig.java b/src/main/java/dev/isxander/controlify/config/dto/profile/ProfileConfig.java index e4d76c18e..1daf893f6 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/profile/ProfileConfig.java +++ b/src/main/java/dev/isxander/controlify/config/dto/profile/ProfileConfig.java @@ -19,7 +19,8 @@ public record ProfileConfig( RumbleConfig rumble, HDHapticConfig hdHaptic, GyroConfig gyro, - BluetoothDeviceConfig bluetoothDevice + BluetoothDeviceConfig bluetoothDevice, + DualsenseConfig dualsense ) { public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( Codec.STRING.optionalFieldOf("name").forGetter(ProfileConfig::name), @@ -29,6 +30,7 @@ public record ProfileConfig( RumbleConfig.CODEC.fieldOf("rumble").forGetter(ProfileConfig::rumble), HDHapticConfig.CODEC.fieldOf("hd_haptic").forGetter(ProfileConfig::hdHaptic), GyroConfig.CODEC.fieldOf("gyro").forGetter(ProfileConfig::gyro), - BluetoothDeviceConfig.CODEC.fieldOf("bluetooth_device").forGetter(ProfileConfig::bluetoothDevice) + BluetoothDeviceConfig.CODEC.fieldOf("bluetooth_device").forGetter(ProfileConfig::bluetoothDevice), + DualsenseConfig.CODEC.fieldOf("dualsense").forGetter(ProfileConfig::dualsense) ).apply(instance, ProfileConfig::new)); } diff --git a/src/main/java/dev/isxander/controlify/config/settings/profile/DualsenseSettings.java b/src/main/java/dev/isxander/controlify/config/settings/profile/DualsenseSettings.java new file mode 100644 index 000000000..a69a951ac --- /dev/null +++ b/src/main/java/dev/isxander/controlify/config/settings/profile/DualsenseSettings.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.config.settings.profile; + +import dev.isxander.controlify.config.dto.profile.DualsenseConfig; + +public class DualsenseSettings { + public boolean triggerEffects; + + public DualsenseSettings(boolean triggerEffects) { + this.triggerEffects = triggerEffects; + } + + public static DualsenseSettings fromDTO(DualsenseConfig dto) { + return new DualsenseSettings(dto.triggerEffects()); + } + + public DualsenseConfig toDTO() { + return new DualsenseConfig(triggerEffects); + } +} diff --git a/src/main/java/dev/isxander/controlify/config/settings/profile/ProfileSettings.java b/src/main/java/dev/isxander/controlify/config/settings/profile/ProfileSettings.java index 4d5966008..f4b624f37 100644 --- a/src/main/java/dev/isxander/controlify/config/settings/profile/ProfileSettings.java +++ b/src/main/java/dev/isxander/controlify/config/settings/profile/ProfileSettings.java @@ -21,6 +21,7 @@ public class ProfileSettings { public final HDHapticSettings hdHaptic; public final GyroSettings gyro; public final BluetoothDeviceSettings bluetoothDevice; + public final DualsenseSettings dualsense; public ProfileSettings( @Nullable String name, @@ -30,7 +31,8 @@ public ProfileSettings( RumbleSettings rumble, HDHapticSettings hdHaptic, GyroSettings gyro, - BluetoothDeviceSettings bluetoothDevice + BluetoothDeviceSettings bluetoothDevice, + DualsenseSettings dualsense ) { this.name = name; this.controllerUid = controllerUid; @@ -40,6 +42,7 @@ public ProfileSettings( this.hdHaptic = hdHaptic; this.gyro = gyro; this.bluetoothDevice = bluetoothDevice; + this.dualsense = dualsense; } public static ProfileSettings fromDTO(ProfileConfig dto) { @@ -51,7 +54,8 @@ public static ProfileSettings fromDTO(ProfileConfig dto) { RumbleSettings.fromDTO(dto.rumble()), HDHapticSettings.fromDTO(dto.hdHaptic()), GyroSettings.fromDTO(dto.gyro()), - BluetoothDeviceSettings.fromDTO(dto.bluetoothDevice()) + BluetoothDeviceSettings.fromDTO(dto.bluetoothDevice()), + DualsenseSettings.fromDTO(dto.dualsense()) ); } @@ -64,7 +68,8 @@ public ProfileConfig toDTO() { rumble.toDTO(), hdHaptic.toDTO(), gyro.toDTO(), - bluetoothDevice.toDTO() + bluetoothDevice.toDTO(), + dualsense.toDTO() ); } diff --git a/src/main/java/dev/isxander/controlify/controller/ControllerEntity.java b/src/main/java/dev/isxander/controlify/controller/ControllerEntity.java index 5bd323d61..412aabcbe 100644 --- a/src/main/java/dev/isxander/controlify/controller/ControllerEntity.java +++ b/src/main/java/dev/isxander/controlify/controller/ControllerEntity.java @@ -8,7 +8,7 @@ import dev.isxander.controlify.config.settings.profile.ProfileSettings; import dev.isxander.controlify.controller.battery.BatteryLevelComponent; -import dev.isxander.controlify.controller.dualsense.DualSenseComponent; +import dev.isxander.controlify.controller.dualsense.DualsenseComponent; import dev.isxander.controlify.controller.haptic.HDHapticComponent; import dev.isxander.controlify.controller.info.ControllerInfo; import dev.isxander.controlify.controller.info.DriverNameComponent; @@ -133,8 +133,8 @@ public Optional hdHaptics() { } @Contract(pure = true) - public Optional dualSense() { - return this.getComponent(DualSenseComponent.ID); + public Optional dualSense() { + return this.getComponent(DualsenseComponent.ID); } @Contract(pure = true) diff --git a/src/main/java/dev/isxander/controlify/controller/dualsense/BuiltinTriggerEffects.java b/src/main/java/dev/isxander/controlify/controller/dualsense/BuiltinTriggerEffects.java new file mode 100644 index 000000000..c6f53079d --- /dev/null +++ b/src/main/java/dev/isxander/controlify/controller/dualsense/BuiltinTriggerEffects.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.controller.dualsense; + +import dev.isxander.controlify.api.triggereffect.TriggerEffectApi; +import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; +import net.minecraft.core.component.DataComponents; +import net.minecraft.world.item.Items; + +public final class BuiltinTriggerEffects { + private BuiltinTriggerEffects() { + } + + public static void register() { + var quickClick = new DualsenseTriggerEffect.Weapon((byte) 3, (byte) 5, (byte) 1); + + TriggerEffectApi.registerUseItemEffect( + Items.BOW, + new DualsenseTriggerEffect.FeedbackSlope((byte) 3, (byte) 9, (byte) 2, (byte) 8) + ); + + TriggerEffectApi.registerSwingItemEffect( + DataComponents.WEAPON, + quickClick + ); + + TriggerEffectApi.registerUseItemEffect( + DataComponents.CHARGED_PROJECTILES, + chargedProjectiles -> chargedProjectiles.isEmpty() + ? new DualsenseTriggerEffect.FeedbackSlope((byte) 2, (byte) 9, (byte) 5, (byte) 8) + : quickClick + ); + + TriggerEffectApi.registerUseItemEffect( + DataComponents.CONSUMABLE, + new DualsenseTriggerEffect.Feedback((byte) 3, (byte) 1) + ); + + TriggerEffectApi.registerUseItemEffect( + DataComponents.BLOCKS_ATTACKS, + new DualsenseTriggerEffect.Feedback((byte) 3, (byte) 3) + ); + + TriggerEffectApi.registerUseItemEffect( + DataComponents.EQUIPPABLE, + quickClick + ); + + TriggerEffectApi.registerUseItemEffect( + DataComponents.KINETIC_WEAPON, + new DualsenseTriggerEffect.Feedback((byte) 3, (byte) 3) + ); + + TriggerEffectApi.registerUseItemEffect( + DataComponents.INSTRUMENT, + new DualsenseTriggerEffect.Feedback((byte) 3, (byte) 3) + ); + } +} diff --git a/src/main/java/dev/isxander/controlify/controller/dualsense/DualSenseComponent.java b/src/main/java/dev/isxander/controlify/controller/dualsense/DualsenseComponent.java similarity index 69% rename from src/main/java/dev/isxander/controlify/controller/dualsense/DualSenseComponent.java rename to src/main/java/dev/isxander/controlify/controller/dualsense/DualsenseComponent.java index c33f6f14c..7f8741f2f 100644 --- a/src/main/java/dev/isxander/controlify/controller/dualsense/DualSenseComponent.java +++ b/src/main/java/dev/isxander/controlify/controller/dualsense/DualsenseComponent.java @@ -6,12 +6,15 @@ */ package dev.isxander.controlify.controller.dualsense; +import dev.isxander.controlify.config.settings.profile.DualsenseSettings; import dev.isxander.controlify.controller.impl.ECSComponentImpl; -import dev.isxander.controlify.driver.sdl.dualsense.DualsenseTriggerEffect; +import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; import dev.isxander.controlify.utils.CUtil; import net.minecraft.resources.Identifier; -public class DualSenseComponent extends ECSComponentImpl { +import java.util.Objects; + +public class DualsenseComponent extends ECSComponentImpl { public static final Identifier ID = CUtil.rl("dualsense"); private boolean muteLight; @@ -22,8 +25,10 @@ public class DualSenseComponent extends ECSComponentImpl { private boolean dirty; public void setLeftTriggerEffect(DualsenseTriggerEffect effect) { + if (!Objects.equals(effect, this.leftTriggerEffect)) { + this.setDirty(); + } this.leftTriggerEffect = effect; - this.setDirty(); } public DualsenseTriggerEffect getLeftTriggerEffect() { @@ -31,8 +36,10 @@ public DualsenseTriggerEffect getLeftTriggerEffect() { } public void setRightTriggerEffect(DualsenseTriggerEffect effect) { + if (!Objects.equals(effect, this.rightTriggerEffect)) { + this.setDirty(); + } this.rightTriggerEffect = effect; - this.setDirty(); } public DualsenseTriggerEffect getRightTriggerEffect() { @@ -60,6 +67,14 @@ public boolean consumeDirty() { return old; } + public DualsenseSettings settings() { + return this.controller().settings().dualsense; + } + + public DualsenseSettings defaultSettings() { + return this.controller().defaultSettings().dualsense; + } + @Override public Identifier id() { return ID; diff --git a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectCodecs.java b/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectCodecs.java new file mode 100644 index 000000000..8f1822d89 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectCodecs.java @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.controller.dualsense; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; +import dev.isxander.controlify.utils.codec.CExtraCodecs; +import net.minecraft.util.ExtraCodecs; + +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public final class TriggerEffectCodecs { + + private TriggerEffectCodecs() { + } + + public static final MapCodec MAP_CODEC_OFF = + MapCodec.unit(DualsenseTriggerEffect.Off.INSTANCE); + + public static final MapCodec MAP_CODEC_FEEDBACK = + RecordCodecBuilder.mapCodec(instance -> instance.group( + CExtraCodecs.byteRange(0, 9).fieldOf("position") + .forGetter(DualsenseTriggerEffect.Feedback::position), + CExtraCodecs.byteRange(0, 8).fieldOf("strength") + .forGetter(DualsenseTriggerEffect.Feedback::strength) + ).apply(instance, DualsenseTriggerEffect.Feedback::new)); + + public static final MapCodec MAP_CODEC_WEAPON = + RecordCodecBuilder.mapCodec(instance -> instance.group( + CExtraCodecs.byteRange(2, 7).fieldOf("start_position") + .forGetter(DualsenseTriggerEffect.Weapon::startPosition), + CExtraCodecs.byteRange(3, 8).fieldOf("end_position") + .forGetter(DualsenseTriggerEffect.Weapon::endPosition), + CExtraCodecs.byteRange(0, 8).fieldOf("strength") + .forGetter(DualsenseTriggerEffect.Weapon::strength) + ).apply(instance, DualsenseTriggerEffect.Weapon::new)); + + public static final MapCodec MAP_CODEC_VIBRATION = + RecordCodecBuilder.mapCodec(instance -> instance.group( + CExtraCodecs.byteRange(0, 9).fieldOf("position") + .forGetter(DualsenseTriggerEffect.Vibration::position), + CExtraCodecs.byteRange(0, 8).fieldOf("amplitude") + .forGetter(DualsenseTriggerEffect.Vibration::amplitude), + Codec.BYTE.fieldOf("frequency") + .forGetter(DualsenseTriggerEffect.Vibration::frequency) + ).apply(instance, DualsenseTriggerEffect.Vibration::new)); + + public static final MapCodec MAP_CODEC_FEEDBACK_MULTIPLE_POSITION = + RecordCodecBuilder.mapCodec(instance -> instance.group( + CExtraCodecs.byteArray(CExtraCodecs.byteRange(0, 8).listOf(10, 10)).fieldOf("strength") + .forGetter(DualsenseTriggerEffect.FeedbackMultiplePosition::strength) + ).apply(instance, DualsenseTriggerEffect.FeedbackMultiplePosition::new)); + + public static final MapCodec MAP_CODEC_FEEDBACK_SLOPE = + RecordCodecBuilder.mapCodec(instance -> instance.group( + CExtraCodecs.byteRange(0, 8).fieldOf("start_position") + .forGetter(DualsenseTriggerEffect.FeedbackSlope::startPosition), + CExtraCodecs.byteRange(1, 9).fieldOf("end_position") + .forGetter(DualsenseTriggerEffect.FeedbackSlope::endPosition), + CExtraCodecs.byteRange(1, 8).fieldOf("start_strength") + .forGetter(DualsenseTriggerEffect.FeedbackSlope::startStrength), + CExtraCodecs.byteRange(1, 8).fieldOf("end_strength") + .forGetter(DualsenseTriggerEffect.FeedbackSlope::endStrength) + ).apply(instance, DualsenseTriggerEffect.FeedbackSlope::new)); + + public static final MapCodec MAP_CODEC_VIBRATION_MULTIPLE_POSITION = + RecordCodecBuilder.mapCodec(instance -> instance.group( + Codec.BYTE.fieldOf("frequency") + .forGetter(DualsenseTriggerEffect.VibrationMultiplePosition::frequency), + CExtraCodecs.byteArray(CExtraCodecs.byteRange(0, 8).listOf(10, 10)).fieldOf("amplitude") + .forGetter(DualsenseTriggerEffect.VibrationMultiplePosition::amplitude) + ).apply(instance, DualsenseTriggerEffect.VibrationMultiplePosition::new)); + + private record TriggerEffectType(String id, MapCodec mapCodec) { + } + + private static final TriggerEffectType TYPE_OFF = + new TriggerEffectType<>("off", MAP_CODEC_OFF); + + private static final TriggerEffectType TYPE_FEEDBACK = + new TriggerEffectType<>("feedback", MAP_CODEC_FEEDBACK); + + private static final TriggerEffectType TYPE_WEAPON = + new TriggerEffectType<>("weapon", MAP_CODEC_WEAPON); + + private static final TriggerEffectType TYPE_VIBRATION = + new TriggerEffectType<>("vibration", MAP_CODEC_VIBRATION); + + private static final TriggerEffectType TYPE_FEEDBACK_MULTIPLE_POSITION = + new TriggerEffectType<>("feedback_multiple_position", MAP_CODEC_FEEDBACK_MULTIPLE_POSITION); + + private static final TriggerEffectType TYPE_FEEDBACK_SLOPE = + new TriggerEffectType<>("feedback_slope", MAP_CODEC_FEEDBACK_SLOPE); + + private static final TriggerEffectType TYPE_VIBRATION_MULTIPLE_POSITION = + new TriggerEffectType<>("vibration_multiple_position", MAP_CODEC_VIBRATION_MULTIPLE_POSITION); + + private static final Map> TYPES_BY_ID = Stream.of( + TYPE_OFF, + TYPE_FEEDBACK, + TYPE_WEAPON, + TYPE_VIBRATION, + TYPE_FEEDBACK_MULTIPLE_POSITION, + TYPE_FEEDBACK_SLOPE, + TYPE_VIBRATION_MULTIPLE_POSITION + ).collect(Collectors.toUnmodifiableMap( + TriggerEffectType::id, + Function.identity() + )); + + private static final Codec> TYPE_CODEC = + ExtraCodecs.idResolverCodec(Codec.STRING, TYPES_BY_ID::get, TriggerEffectType::id); + + public static final Codec CODEC = TYPE_CODEC.dispatch( + "type", + effect -> switch (effect) { + case DualsenseTriggerEffect.Off _ -> TYPE_OFF; + case DualsenseTriggerEffect.Feedback _ -> TYPE_FEEDBACK; + case DualsenseTriggerEffect.Weapon _ -> TYPE_WEAPON; + case DualsenseTriggerEffect.Vibration _ -> TYPE_VIBRATION; + case DualsenseTriggerEffect.FeedbackMultiplePosition _ -> TYPE_FEEDBACK_MULTIPLE_POSITION; + case DualsenseTriggerEffect.FeedbackSlope _ -> TYPE_FEEDBACK_SLOPE; + case DualsenseTriggerEffect.VibrationMultiplePosition _ -> TYPE_VIBRATION_MULTIPLE_POSITION; + }, + TriggerEffectType::mapCodec + ); +} diff --git a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectHolder.java b/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectHolder.java new file mode 100644 index 000000000..cd56f3705 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectHolder.java @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.controller.dualsense; + +import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; +import org.jetbrains.annotations.NotNull; + +import java.util.Optional; + +public interface TriggerEffectHolder { + Optional controlify$getUseTriggerEffect(); + + Optional controlify$getSwingTriggerEffect(); + + void controlify$assignUseTriggerEffect(@NotNull DualsenseTriggerEffect effect); + + void controlify$assignSwingTriggerEffect(@NotNull DualsenseTriggerEffect effect); +} diff --git a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectManager.java b/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectManager.java new file mode 100644 index 000000000..138f3cf65 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectManager.java @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.controller.dualsense; + +import dev.isxander.controlify.bindings.ControlifyBindings; +import dev.isxander.controlify.controller.ControllerEntity; +import dev.isxander.controlify.controller.input.GamepadInputs; +import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; +import dev.isxander.controlify.utils.MinecraftUtil; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.core.component.DataComponentType; +import net.minecraft.resources.Identifier; +import net.minecraft.world.item.ItemStack; + +import java.util.*; +import java.util.function.Function; + +public class TriggerEffectManager { + private final Minecraft minecraft; + + private final Map, Function> useItemComponentEffects; + private final Map, Function> swingItemComponentEffects; + + public TriggerEffectManager(Minecraft minecraft) { + this.minecraft = minecraft; + this.useItemComponentEffects = new LinkedHashMap<>(); + this.swingItemComponentEffects = new LinkedHashMap<>(); + } + + public void applyTriggerEffects(ControllerEntity controller, boolean inputSuppressed) { + controller.dualSense().ifPresent(ds -> { + ds.setLeftTriggerEffect(this.getCurrentLeftTriggerEffect(controller, inputSuppressed)); + ds.setRightTriggerEffect(this.getCurrentRightTriggerEffect(controller, inputSuppressed)); + }); + } + + public void registerUseItemComponentEffect(DataComponentType componentType, Function effectFunction) { + this.useItemComponentEffects.put(componentType, (Function) effectFunction); + } + + public void registerSwingItemComponentEffect(DataComponentType componentType, Function effectFunction) { + this.swingItemComponentEffects.put(componentType, (Function) effectFunction); + } + + public DualsenseTriggerEffect getCurrentLeftTriggerEffect(ControllerEntity controller, boolean inputSuppressed) { + if (!this.shouldUseTriggerEffects(controller, inputSuppressed)) { + return DualsenseTriggerEffect.Off.INSTANCE; + } + + LocalPlayer player = Objects.requireNonNull(this.minecraft.player); + + if (this.getUseItemTrigger(controller) == Trigger.LEFT) { + return this.getUseItemTriggerEffect(player) + .orElse(DualsenseTriggerEffect.Off.INSTANCE); + } + if (this.getSwingItemTrigger(controller) == Trigger.LEFT) { + return this.getSwingItemTriggerEffect(player) + .orElse(DualsenseTriggerEffect.Off.INSTANCE); + } + + return DualsenseTriggerEffect.Off.INSTANCE; + } + + public DualsenseTriggerEffect getCurrentRightTriggerEffect(ControllerEntity controller, boolean inputSuppressed) { + if (!this.shouldUseTriggerEffects(controller, inputSuppressed)) { + return DualsenseTriggerEffect.Off.INSTANCE; + } + + LocalPlayer player = Objects.requireNonNull(this.minecraft.player); + + if (this.getUseItemTrigger(controller) == Trigger.RIGHT) { + return this.getUseItemTriggerEffect(player) + .orElse(DualsenseTriggerEffect.Off.INSTANCE); + } + if (this.getSwingItemTrigger(controller) == Trigger.RIGHT) { + return this.getSwingItemTriggerEffect(player) + .orElse(DualsenseTriggerEffect.Off.INSTANCE); + } + + return DualsenseTriggerEffect.Off.INSTANCE; + } + + public boolean shouldUseTriggerEffects(ControllerEntity controller, boolean inputSuppressed) { + return !inputSuppressed + && MinecraftUtil.getScreen() == null + && this.minecraft.player != null + && controller.dualSense().map(ds -> ds.settings().triggerEffects).orElse(false); + } + + private Trigger getUseItemTrigger(ControllerEntity controller) { + List relevantInputs = ControlifyBindings.USE.on(controller).boundInput().getRelevantInputs(); + + if (relevantInputs.contains(GamepadInputs.LEFT_TRIGGER_AXIS)) { + return Trigger.LEFT; + } else if (relevantInputs.contains(GamepadInputs.RIGHT_TRIGGER_AXIS)) { + return Trigger.RIGHT; + } else { + return Trigger.NEITHER; + } + } + + private Trigger getSwingItemTrigger(ControllerEntity controller) { + List relevantInputs = ControlifyBindings.ATTACK.on(controller).boundInput().getRelevantInputs(); + + if (relevantInputs.contains(GamepadInputs.LEFT_TRIGGER_AXIS)) { + return Trigger.LEFT; + } else if (relevantInputs.contains(GamepadInputs.RIGHT_TRIGGER_AXIS)) { + return Trigger.RIGHT; + } else { + return Trigger.NEITHER; + } + } + + public Optional getUseItemTriggerEffect(LocalPlayer player) { + return this.getUseItemTriggerEffect(player.getActiveItem()) + .or(() -> this.getUseItemTriggerEffect(player.getOffhandItem())); + } + + public Optional getUseItemTriggerEffect(ItemStack stack) { + for (var entry : this.useItemComponentEffects.entrySet()) { + if (stack.has(entry.getKey())) { + Function effectFunction = entry.getValue(); + DualsenseTriggerEffect effect = effectFunction.apply(stack.get(entry.getKey())); + if (effect != null) { + return Optional.of(effect); + } + } + } + + var effectHolder = (TriggerEffectHolder) stack.getItem(); + return effectHolder.controlify$getUseTriggerEffect(); + } + + public Optional getSwingItemTriggerEffect(LocalPlayer player) { + return this.getSwingItemTriggerEffect(player.getMainHandItem()); + } + + public Optional getSwingItemTriggerEffect(ItemStack stack) { + for (var entry : this.swingItemComponentEffects.entrySet()) { + if (stack.has(entry.getKey())) { + Function effectFunction = entry.getValue(); + DualsenseTriggerEffect effect = effectFunction.apply(stack.get(entry.getKey())); + if (effect != null) { + return Optional.of(effect); + } + } + } + + var effectHolder = (TriggerEffectHolder) stack.getItem(); + return effectHolder.controlify$getSwingTriggerEffect(); + } + + + private enum Trigger { + LEFT, RIGHT, NEITHER + } +} diff --git a/src/main/java/dev/isxander/controlify/driver/sdl/dualsense/DS5EffectsState.java b/src/main/java/dev/isxander/controlify/driver/dualsense/DualsenseEffectsState.java similarity index 99% rename from src/main/java/dev/isxander/controlify/driver/sdl/dualsense/DS5EffectsState.java rename to src/main/java/dev/isxander/controlify/driver/dualsense/DualsenseEffectsState.java index 359b02e3c..b494bd087 100644 --- a/src/main/java/dev/isxander/controlify/driver/sdl/dualsense/DS5EffectsState.java +++ b/src/main/java/dev/isxander/controlify/driver/dualsense/DualsenseEffectsState.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.driver.sdl.dualsense; +package dev.isxander.controlify.driver.dualsense; import org.intellij.lang.annotations.MagicConstant; @@ -15,7 +15,7 @@ import static java.lang.foreign.MemoryLayout.PathElement.groupElement; -public final class DS5EffectsState { +public final class DualsenseEffectsState { public static final MemoryLayout LAYOUT = MemoryLayout.structLayout( ValueLayout.JAVA_BYTE.withName("ucEnableBits1"), ValueLayout.JAVA_BYTE.withName("ucEnableBits2"), diff --git a/src/main/java/dev/isxander/controlify/driver/sdl/dualsense/DualsenseTriggerEffect.java b/src/main/java/dev/isxander/controlify/driver/dualsense/DualsenseTriggerEffect.java similarity index 87% rename from src/main/java/dev/isxander/controlify/driver/sdl/dualsense/DualsenseTriggerEffect.java rename to src/main/java/dev/isxander/controlify/driver/dualsense/DualsenseTriggerEffect.java index 364d91cbf..33edb3e73 100644 --- a/src/main/java/dev/isxander/controlify/driver/sdl/dualsense/DualsenseTriggerEffect.java +++ b/src/main/java/dev/isxander/controlify/driver/dualsense/DualsenseTriggerEffect.java @@ -4,23 +4,25 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.driver.sdl.dualsense; +package dev.isxander.controlify.driver.dualsense; import org.apache.commons.lang3.Validate; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Range; -public interface DualsenseTriggerEffect { - DS5EffectsState.TriggerEffect createState(); +public sealed interface DualsenseTriggerEffect { + DualsenseEffectsState.TriggerEffect createState(); /** * Turn the trigger effect off and return the trigger stop to the neutral position. * This is an official effect and is expected to be present in future DualSense firmware versions. */ record Off() implements DualsenseTriggerEffect { + public static final Off INSTANCE = new Off(); + @Override - public DS5EffectsState.TriggerEffect createState() { - return DS5EffectsState.TriggerEffect.OFF; + public DualsenseEffectsState.TriggerEffect createState() { + return DualsenseEffectsState.TriggerEffect.OFF; } } @@ -42,7 +44,7 @@ record Feedback( } @Override - public DS5EffectsState.TriggerEffect createState() { + public DualsenseEffectsState.TriggerEffect createState() { if (strength > 0) { byte forceValue = (byte) ((strength - 1) & 0x07); int forceZones = 0; @@ -52,7 +54,7 @@ public DS5EffectsState.TriggerEffect createState() { activeZones |= (char) (1 << i); } - return new DS5EffectsState.TriggerEffect(DualsenseTriggerEffectTypes.FEEDBACK, new byte[]{ + return new DualsenseEffectsState.TriggerEffect(DualsenseTriggerEffectTypes.FEEDBACK, new byte[]{ (byte) (activeZones & 0xff), (byte) ((activeZones >> 8) & 0xff), (byte) (forceZones & 0xff), @@ -61,7 +63,7 @@ public DS5EffectsState.TriggerEffect createState() { (byte) ((forceZones >> 24) & 0xff), }); } else { - return DS5EffectsState.TriggerEffect.OFF; + return DualsenseEffectsState.TriggerEffect.OFF; } } } @@ -89,17 +91,17 @@ record Weapon( } @Override - public DS5EffectsState.TriggerEffect createState() { + public DualsenseEffectsState.TriggerEffect createState() { if (strength > 0) { char startAndStopZones = (char) ((1 << startPosition) | (1 << endPosition)); - return new DS5EffectsState.TriggerEffect(DualsenseTriggerEffectTypes.WEAPON, new byte[]{ + return new DualsenseEffectsState.TriggerEffect(DualsenseTriggerEffectTypes.WEAPON, new byte[]{ (byte) (startAndStopZones & 0xff), (byte) ((startAndStopZones >> 8) & 0xff), (byte) (strength - 1), // this is actually packed into 3 bits, but since it's only one why bother with the fancy code? }); } else { - return DS5EffectsState.TriggerEffect.OFF; + return DualsenseEffectsState.TriggerEffect.OFF; } } } @@ -126,7 +128,7 @@ record Vibration( } @Override - public DS5EffectsState.TriggerEffect createState() { + public DualsenseEffectsState.TriggerEffect createState() { if (amplitude > 0 && frequency > 0) { byte strengthValue = (byte) ((amplitude - 1) & 0x07); int amplitudeZones = 0; @@ -137,7 +139,7 @@ public DS5EffectsState.TriggerEffect createState() { activeZones |= (char) (1 << i); } - return new DS5EffectsState.TriggerEffect(DualsenseTriggerEffectTypes.VIBRATION, new byte[]{ + return new DualsenseEffectsState.TriggerEffect(DualsenseTriggerEffectTypes.VIBRATION, new byte[]{ (byte) (activeZones & 0xff), (byte) ((activeZones >> 8) & 0xff), (byte) (amplitudeZones & 0xff), @@ -148,7 +150,7 @@ public DS5EffectsState.TriggerEffect createState() { frequency, }); } else { - return DS5EffectsState.TriggerEffect.OFF; + return DualsenseEffectsState.TriggerEffect.OFF; } } } @@ -171,7 +173,7 @@ record FeedbackMultiplePosition( } @Override - public DS5EffectsState.TriggerEffect createState() { + public DualsenseEffectsState.TriggerEffect createState() { boolean allZero = true; for (int i = 0; i < 10; i++) { allZero &= strength[i] == 0; @@ -191,7 +193,7 @@ public DS5EffectsState.TriggerEffect createState() { } } - return new DS5EffectsState.TriggerEffect(DualsenseTriggerEffectTypes.FEEDBACK, new byte[]{ + return new DualsenseEffectsState.TriggerEffect(DualsenseTriggerEffectTypes.FEEDBACK, new byte[]{ (byte) (activeZones & 0xff), (byte) ((activeZones >> 8) & 0xff), (byte) (forceZones & 0xff), @@ -200,7 +202,7 @@ public DS5EffectsState.TriggerEffect createState() { (byte) ((forceZones >> 24) & 0xff), }); } else { - return DS5EffectsState.TriggerEffect.OFF; + return DualsenseEffectsState.TriggerEffect.OFF; } } } @@ -232,7 +234,7 @@ record FeedbackSlope( } @Override - public DS5EffectsState.TriggerEffect createState() { + public DualsenseEffectsState.TriggerEffect createState() { byte[] strength = new byte[10]; float gradient = (endStrength - startStrength) / (float) (endPosition - startPosition); for (int i = startPosition; i < 10; i++) { @@ -264,7 +266,7 @@ record VibrationMultiplePosition( } @Override - public DS5EffectsState.TriggerEffect createState() { + public DualsenseEffectsState.TriggerEffect createState() { if (frequency > 0) { boolean allZero = true; for (int i = 0; i < 10; i++) { @@ -286,7 +288,7 @@ public DS5EffectsState.TriggerEffect createState() { } } - return new DS5EffectsState.TriggerEffect(DualsenseTriggerEffectTypes.VIBRATION, new byte[]{ + return new DualsenseEffectsState.TriggerEffect(DualsenseTriggerEffectTypes.VIBRATION, new byte[]{ (byte) (activeZones & 0xff), (byte) ((activeZones >> 8) & 0xff), (byte) (strengthZones & 0xff), @@ -299,7 +301,7 @@ public DS5EffectsState.TriggerEffect createState() { } } - return DS5EffectsState.TriggerEffect.OFF; + return DualsenseEffectsState.TriggerEffect.OFF; } } } diff --git a/src/main/java/dev/isxander/controlify/driver/sdl/dualsense/DualsenseTriggerEffectTypes.java b/src/main/java/dev/isxander/controlify/driver/dualsense/DualsenseTriggerEffectTypes.java similarity index 96% rename from src/main/java/dev/isxander/controlify/driver/sdl/dualsense/DualsenseTriggerEffectTypes.java rename to src/main/java/dev/isxander/controlify/driver/dualsense/DualsenseTriggerEffectTypes.java index 3e4144e60..9fc0680a7 100644 --- a/src/main/java/dev/isxander/controlify/driver/sdl/dualsense/DualsenseTriggerEffectTypes.java +++ b/src/main/java/dev/isxander/controlify/driver/dualsense/DualsenseTriggerEffectTypes.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.driver.sdl.dualsense; +package dev.isxander.controlify.driver.dualsense; public final class DualsenseTriggerEffectTypes { diff --git a/src/main/java/dev/isxander/controlify/driver/sdl/SDLCommonDriver.java b/src/main/java/dev/isxander/controlify/driver/sdl/SDLCommonDriver.java index 6d4980d16..d974cbc86 100644 --- a/src/main/java/dev/isxander/controlify/driver/sdl/SDLCommonDriver.java +++ b/src/main/java/dev/isxander/controlify/driver/sdl/SDLCommonDriver.java @@ -10,7 +10,7 @@ import dev.isxander.controlify.controller.ControllerEntity; import dev.isxander.controlify.controller.battery.BatteryLevelComponent; import dev.isxander.controlify.controller.battery.PowerState; -import dev.isxander.controlify.controller.dualsense.DualSenseComponent; +import dev.isxander.controlify.controller.dualsense.DualsenseComponent; import dev.isxander.controlify.controller.haptic.CompleteSoundData; import dev.isxander.controlify.controller.haptic.HDHapticComponent; import dev.isxander.controlify.controller.id.ControllerType; @@ -22,7 +22,8 @@ import dev.isxander.controlify.controller.rumble.RumbleComponent; import dev.isxander.controlify.controller.rumble.TriggerRumbleComponent; import dev.isxander.controlify.driver.Driver; -import dev.isxander.controlify.driver.sdl.dualsense.DS5EffectsState; +import dev.isxander.controlify.driver.dualsense.DualsenseEffectsState; +import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; import dev.isxander.controlify.rumble.RumbleState; import dev.isxander.controlify.rumble.TriggerRumbleState; import dev.isxander.controlify.utils.CUtil; @@ -60,7 +61,7 @@ public abstract class SDLCommonDriver implements Driver { protected TriggerRumbleComponent triggerRumbleComponent; protected HDHapticComponent hdHapticComponent; protected LEDComponent ledComponent; - protected DualSenseComponent dualSenseComponent; + protected DualsenseComponent dualSenseComponent; protected final boolean isRumbleSupported, isTriggerRumbleSupported; protected final boolean isDualsense; @@ -113,8 +114,8 @@ public SDLCommonDriver(Sdl sdl, SdlController ptrController, SdlJoystickId jid, // open audio device for dualsense hd haptics this.dualsenseAudioHandles = new ArrayList<>(); - if (CUtil.rl("dualsense").equals(type.namespace())) { - this.isDualsense = true; + this.isDualsense = CUtil.rl("dualsense").equals(type.namespace()); + if (this.isDualsense) { logger.debugLog("DualSense controller detected."); // macOS HD haptics are broken @@ -141,8 +142,6 @@ public SDLCommonDriver(Sdl sdl, SdlController ptrController, SdlJoystickId jid, logger.debugLog("DualSense HD Haptics audio device not found."); } } - } else { - this.isDualsense = false; } } @@ -163,7 +162,7 @@ public void addComponents(ControllerEntity controller) { controller.setComponent(this.ledComponent = new LEDComponent(1)); } if (this.isDualsense) { - controller.setComponent(this.dualSenseComponent = new DualSenseComponent()); + controller.setComponent(this.dualSenseComponent = new DualsenseComponent()); } if (this.dualsenseAudioDev != null) { controller.setComponent(this.hdHapticComponent = new HDHapticComponent()); @@ -194,6 +193,12 @@ public void close() { throw new IllegalStateException("Tried to close controller when it's already closed."); } + if (dualSenseComponent != null) { + dualSenseComponent.setLeftTriggerEffect(DualsenseTriggerEffect.Off.INSTANCE); + dualSenseComponent.setRightTriggerEffect(DualsenseTriggerEffect.Off.INSTANCE); + updateDualSense(); + } + SDL_CloseController(ptrController); ptrController = null; @@ -268,26 +273,26 @@ private void updateDualSense() { if (dualSenseComponent == null) return; if (this.dualSenseComponent.consumeDirty()) { - DS5EffectsState effectsState = new DS5EffectsState(); + DualsenseEffectsState effectsState = new DualsenseEffectsState(); // Left Trigger Effect Optional.ofNullable(this.dualSenseComponent.getLeftTriggerEffect()).ifPresent(effect -> { - effectsState.ucEnableBits1 |= DS5EffectsState.EnableBitFlags1.ALLOW_LEFT_TRIGGER_FFB; + effectsState.ucEnableBits1 |= DualsenseEffectsState.EnableBitFlags1.ALLOW_LEFT_TRIGGER_FFB; effectsState.rgucLeftTriggerEffect = effect.createState(); }); // Right Trigger Effect Optional.ofNullable(this.dualSenseComponent.getRightTriggerEffect()).ifPresent(effect -> { - effectsState.ucEnableBits1 |= DS5EffectsState.EnableBitFlags1.ALLOW_RIGHT_TRIGGER_FFB; + effectsState.ucEnableBits1 |= DualsenseEffectsState.EnableBitFlags1.ALLOW_RIGHT_TRIGGER_FFB; effectsState.rgucRightTriggerEffect = effect.createState(); }); // Mute Light - effectsState.ucEnableBits2 |= DS5EffectsState.EnableBitFlags2.ALLOW_MUTE_LIGHT; - effectsState.ucMicLightMode = DS5EffectsState.MuteLightState.fromBoolean(this.dualSenseComponent.getMuteLight()); + effectsState.ucEnableBits2 |= DualsenseEffectsState.EnableBitFlags2.ALLOW_MUTE_LIGHT; + effectsState.ucMicLightMode = DualsenseEffectsState.MuteLightState.fromBoolean(this.dualSenseComponent.getMuteLight()); try (Arena arena = Arena.ofConfined()) { - MemorySegment memory = arena.allocate(DS5EffectsState.LAYOUT); + MemorySegment memory = arena.allocate(DualsenseEffectsState.LAYOUT); effectsState.writeTo(memory); SDL_SendControllerEffect(ptrController, memory.asByteBuffer()); } diff --git a/src/main/java/dev/isxander/controlify/gui/screen/ControllerConfigScreenFactory.java b/src/main/java/dev/isxander/controlify/gui/screen/ControllerConfigScreenFactory.java index 57bdf5e6a..b79b00f63 100644 --- a/src/main/java/dev/isxander/controlify/gui/screen/ControllerConfigScreenFactory.java +++ b/src/main/java/dev/isxander/controlify/gui/screen/ControllerConfigScreenFactory.java @@ -451,6 +451,7 @@ private ConfigCategory createAdvancedCategory( makeVibrationGroup(settings, defaults, controller).ifPresent(builder::group); makeGyroGroup(settings, defaults, controller).ifPresent(builder::group); + makeTriggerEffectsGroup(settings, defaults, controller).ifPresent(builder::group); makeControllerMappingGroup(settings, defaults, controller).ifPresent(builder::group); return builder.build(); @@ -682,6 +683,32 @@ private Optional makeGyroGroup( return Optional.of(gyroGroup.build()); } + private Optional makeTriggerEffectsGroup( + ProfileSettings settings, + ProfileSettings defaults, + Optional controller + ) { + var group = OptionGroup.createBuilder() + .name(Component.translatable("controlify.gui.group.trigger_effects")) + .description(OptionDescription.createBuilder() + .text(Component.translatable("controlify.gui.trigger_effects.tooltip")) + .build()); + + if (controller.isPresent() && controller.get().dualSense().isEmpty()) { + group.collapsed(true); + group.option(LabelOption.create(Component.translatable("controlify.gui.group.trigger_effects.no_dualsense.tooltip").withStyle(ChatFormatting.RED))); + } + + group.option(Option.createBuilder() + .name(Component.translatable("controlify.gui.trigger_effects")) + .description(OptionDescription.of(Component.translatable("controlify.gui.trigger_effects.tooltip"))) + .binding(defaults.dualsense.triggerEffects, () -> settings.dualsense.triggerEffects, v -> settings.dualsense.triggerEffects = v) + .controller(TickBoxControllerBuilder::create) + .build()); + + return Optional.of(group.build()); + } + private Optional makeBindsCategory( ProfileSettings settings, ProfileSettings defaults, diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/triggereffect/ItemMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/triggereffect/ItemMixin.java new file mode 100644 index 000000000..616e27245 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/mixins/feature/triggereffect/ItemMixin.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.mixins.feature.triggereffect; + +import dev.isxander.controlify.controller.dualsense.TriggerEffectHolder; +import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; +import net.minecraft.world.item.Item; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +import java.util.Optional; + +@Mixin(Item.class) +public class ItemMixin implements TriggerEffectHolder { + @Unique private @Nullable DualsenseTriggerEffect controlify$useEffect = null; + + @Unique private @Nullable DualsenseTriggerEffect controlify$swingEffect = null; + + @Override + public Optional controlify$getUseTriggerEffect() { + return Optional.ofNullable(controlify$useEffect); + } + + @Override + public Optional controlify$getSwingTriggerEffect() { + return Optional.ofNullable(controlify$swingEffect); + } + + @Override + public void controlify$assignUseTriggerEffect(@NotNull DualsenseTriggerEffect effect) { + this.controlify$useEffect = effect; + } + + @Override + public void controlify$assignSwingTriggerEffect(@NotNull DualsenseTriggerEffect effect) { + this.controlify$swingEffect = effect; + } +} diff --git a/src/main/java/dev/isxander/controlify/utils/codec/CExtraCodecs.java b/src/main/java/dev/isxander/controlify/utils/codec/CExtraCodecs.java index dd3a9a8f7..dcd225c36 100644 --- a/src/main/java/dev/isxander/controlify/utils/codec/CExtraCodecs.java +++ b/src/main/java/dev/isxander/controlify/utils/codec/CExtraCodecs.java @@ -8,6 +8,7 @@ import com.mojang.datafixers.util.Pair; import com.mojang.serialization.*; +import it.unimi.dsi.fastutil.bytes.ByteArrayList; import org.jetbrains.annotations.Nullable; import java.util.List; @@ -90,5 +91,26 @@ public static Codec stringResolver(final Function toString, fi ); } + static > Function> checkRange(final N minInclusive, final N maxInclusive) { + return value -> { + if (value.compareTo(minInclusive) >= 0 && value.compareTo(maxInclusive) <= 0) { + return DataResult.success(value); + } + return DataResult.error(() -> "Value " + value + " outside of range [" + minInclusive + ":" + maxInclusive + "]"); + }; + } + + public static Codec byteRange(int minInclusive, int maxInclusive) { + final Function> checker = checkRange((byte) minInclusive, (byte) maxInclusive); + return Codec.BYTE.flatXmap(checker, checker); + } + + public static Codec byteArray(Codec> byteListCodec) { + return byteListCodec.xmap( + byteList -> new ByteArrayList(byteList).toByteArray(), + ByteArrayList::wrap + ); + } + private CExtraCodecs() {} } diff --git a/src/main/java/dev/isxander/controlify/utils/codec/FuzzyMapCodec.java b/src/main/java/dev/isxander/controlify/utils/codec/FuzzyMapCodec.java index 75200f235..1c49aceda 100644 --- a/src/main/java/dev/isxander/controlify/utils/codec/FuzzyMapCodec.java +++ b/src/main/java/dev/isxander/controlify/utils/codec/FuzzyMapCodec.java @@ -31,7 +31,7 @@ public DataResult decode(DynamicOps ops, MapLike input) { } } - return DataResult.error(() -> "No matching codec found."); + return DataResult.error(() -> "No matching mapCodec found."); } @Override diff --git a/src/main/resources/assets/controlify/controllers/default_config.json b/src/main/resources/assets/controlify/controllers/default_config.json index edb9f9e0f..033431232 100644 --- a/src/main/resources/assets/controlify/controllers/default_config.json +++ b/src/main/resources/assets/controlify/controllers/default_config.json @@ -63,5 +63,8 @@ }, "bluetooth_device": { "dont_show_warning": false + }, + "dualsense": { + "trigger_effects": true } } diff --git a/src/main/resources/assets/controlify/lang/en_us.json b/src/main/resources/assets/controlify/lang/en_us.json index 615c20d30..4c4d30763 100644 --- a/src/main/resources/assets/controlify/lang/en_us.json +++ b/src/main/resources/assets/controlify/lang/en_us.json @@ -189,7 +189,10 @@ "controlify.gui.screen_repeat_navi_delay.tooltip": "How fast a screen navigation action repeats.", "controlify.gui.test_vibration": "Test Vibration", "controlify.gui.test_vibration.tooltip": "Test the vibration of your controller.", - + "controlify.gui.group.trigger_effects": "Trigger Effects", + "controlify.gui.group.trigger_effects.no_dualsense.tooltip": "This controller does not support adaptive triggers. You must have a DualSenseā„¢ controller or other compatible controller to use this feature.", + "controlify.gui.trigger_effects": "Trigger Effects", + "controlify.gui.trigger_effects.tooltip": "Enable DualSense Adaptive Trigger effects for items in game.", "controlify.gui.show_keyboard.off": "Off", "controlify.gui.show_keyboard.controlify": "Controlify", "controlify.gui.show_keyboard.system": "System", diff --git a/src/main/resources/controlify.mixins.json b/src/main/resources/controlify.mixins.json index f924794ca..a7f9418c2 100644 --- a/src/main/resources/controlify.mixins.json +++ b/src/main/resources/controlify.mixins.json @@ -122,5 +122,8 @@ "feature.virtualmouse.snapping.RecipeBookPageAccessor", "feature.virtualmouse.snapping.ScreenMixin", "feature.virtualmouse.snapping.StonecutterScreenMixin" + ], + "mixins": [ + "feature.triggereffect.ItemMixin" ] }