diff --git a/build.gradle.kts b/build.gradle.kts index aa115d2..6cad540 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,7 +11,7 @@ plugins { } group = "dev.isxander" -version = "1.7.0-beta.2+1.20" +version = "1.7.0-beta.3+1.20.2" val isAlpha = "alpha" in version.toString() val isBeta = "beta" in version.toString() if (isAlpha) println("Controlify alpha version detected.") diff --git a/changelogs/1.7.0-beta.3+1.20.2.md b/changelogs/1.7.0-beta.3+1.20.2.md new file mode 100644 index 0000000..0da8fc0 --- /dev/null +++ b/changelogs/1.7.0-beta.3+1.20.2.md @@ -0,0 +1,29 @@ +# Controlify 1.7.0 (Beta 3) for 1.20.2 + +## New Features + +- Added D-Pad snapping in container screens +- Keyboard-like movement whitelist and warning toast when joining new servers +- Added bind to open F3 debug screen +- More snap points on recipe book +- Allow users to define a custom SDL natives path (so you can put them in a common dir if you want) +- Add a reset all binds button to controls tab + +## Changes + +- Internal changes to the way controllers are discovered, loaded and managed. (this could introduce new bugs) +- `delegate_setup` config option has been renamed to `quiet_mode`. +- Pause screen's disconnect shortcut now focuses the button instead of clicking it. +- Add a donate button to the controller carousel screen. +- Modify how analogue inputs are processed whilst ingame or using the virtual mouse to make it feel more "circular" +- Marginally improve performance of Controlify by using optimized hashmaps. + +## Bug Fixes + +- Fix hotplugging when using natives. +- Fix SDL download screen progress bar being a missing texture. +- Fix people being unable to write newlines and spaces in signs when using mixed input mode. +- Fix some modded GUIs crashing when attempting to open when Controlify is loaded. +- Fix tridents not causing a vibration. +- Fix rumble not working on joysticks. +- Fix fabric mod json requirement allowing any 1.20 version not 1.20.2 and above. diff --git a/src/main/java/dev/isxander/controlify/Controlify.java b/src/main/java/dev/isxander/controlify/Controlify.java index 4d7a860..705676d 100644 --- a/src/main/java/dev/isxander/controlify/Controlify.java +++ b/src/main/java/dev/isxander/controlify/Controlify.java @@ -3,6 +3,7 @@ package dev.isxander.controlify; import com.mojang.blaze3d.Blaze3D; import dev.isxander.controlify.api.ControlifyApi; import dev.isxander.controlify.api.entrypoint.ControlifyEntrypoint; +import dev.isxander.controlify.bindings.ControllerBindings; import dev.isxander.controlify.compatibility.ControlifyCompat; import dev.isxander.controlify.controller.joystick.JoystickController; import dev.isxander.controlify.controller.joystick.mapping.UnmappedJoystickMapping; @@ -63,10 +64,12 @@ public class Controlify implements ControlifyApi { private boolean probeMode = false; private Controller currentController = null; + private InputMode currentInputMode = InputMode.KEYBOARD_MOUSE; + private InGameInputHandler inGameInputHandler; public InGameButtonGuide inGameButtonGuide; private VirtualMouseHandler virtualMouseHandler; - private InputMode currentInputMode = InputMode.KEYBOARD_MOUSE; + private ControllerHIDService controllerHIDService; private CompletableFuture nativeOnboardingFuture = null; @@ -81,10 +84,6 @@ public class Controlify implements ControlifyApi { private int showMouseTicks = 0; - private @Nullable Controller switchableController = null; - private double askSwitchTime = 0; - private ToastUtils.ControlifyToast askSwitchToast = null; - /** * Called at usual fabric client entrypoint. * Always runs, even with no controllers detected. @@ -178,10 +177,6 @@ public class Controlify implements ControlifyApi { ClientTickEvents.END_CLIENT_TICK.register(client -> this.probeTick()); } - // initialise and compatability modules that controlify implements itself - // this does NOT invoke any entrypoints. this is done in the pre-initialisation phase - ControlifyCompat.init(); - // register events ClientLifecycleEvents.CLIENT_STOPPING.register(minecraft -> { controllerHIDService().stop(); @@ -211,19 +206,28 @@ public class Controlify implements ControlifyApi { Log.LOGGER.info("No controllers found."); } - // if no controller is currently selected, select the first one + // if no controller is currently selected, pick one if (getCurrentController().isEmpty()) { - Controller controller = controllerManager.getConnectedControllers().stream().findFirst().orElse(null); - if (controller != null && (controller.config().delayedCalibration || !controller.config().deadzonesCalibrated)) { - controller = null; - } + Optional> lastUsedController = controllerManager.getConnectedControllers() + .stream() + .filter(c -> c.uid().equals(config().currentControllerUid())) + .findAny(); - this.setCurrentController(controller, false); - } else { - // setCurrentController saves config so there is no need to set dirty to save - config().saveIfDirty(); + if (lastUsedController.isPresent()) { + this.setCurrentController(lastUsedController.get(), false); + } else { + Controller anyController = controllerManager.getConnectedControllers() + .stream() + .filter(c -> !c.config().delayedCalibration && c.config().deadzonesCalibrated) + .findFirst() + .orElse(null); + + this.setCurrentController(anyController, false); + } } + config().saveIfDirty(); + FabricLoader.getInstance().getEntrypoints("controlify", ControlifyEntrypoint.class).forEach(entrypoint -> { try { entrypoint.onControllersDiscovered(this); @@ -233,10 +237,16 @@ public class Controlify implements ControlifyApi { }); } + /** + * Completely finishes controlify initialization. + * This can be run at any point during the game's lifecycle. + * @return the future that completes when controlify has finished initializing + */ public CompletableFuture finishControlifyInit() { if (finishedInit) { return CompletableFuture.completedFuture(null); } + probeMode = false; finishedInit = true; askNatives().whenComplete((loaded, th) -> { @@ -248,12 +258,33 @@ public class Controlify implements ControlifyApi { ConnectServerEvent.EVENT.register((minecraft, address, data) -> { notifyNewServer(data); }); + + // initialise and compatability modules that controlify implements itself + // this does NOT invoke any entrypoints. this is done in the pre-initialisation phase + ControlifyCompat.init(); + + // make sure people don't someone add binds after controllers could have been created + ControllerBindings.lockRegistry(); + + if (config().globalSettings().quietMode) { + config().globalSettings().quietMode = false; + config().setDirty(); + } + discoverControllers(); }); return askNatives().thenApply(loaded -> null); } + /** + * Called when a controller is connected. Either from controller + * discovery or hotplugging. + * + * @param controller the new controller + * @param hotplugged if this was a result of hotplugging + * @param newController if this controller has never been seen before + */ private void onControllerAdded(Controller controller, boolean hotplugged, boolean newController) { if (SubmitUnknownControllerScreen.canSubmit(controller)) { minecraft.setScreen(new SubmitUnknownControllerScreen(controller, minecraft.screen)); @@ -264,12 +295,10 @@ public class Controlify implements ControlifyApi { config().setDirty(); } - if (hotplugged) { - if (controller.config().deadzonesCalibrated) { - setCurrentController(controller, hotplugged); - } else { - calibrationQueue.add(controller); - } + if (!controller.config().deadzonesCalibrated) { + calibrationQueue.add(controller); + } else if (hotplugged) { + setCurrentController(controller, true); } if (controller instanceof JoystickController joystick && joystick.mapping() instanceof UnmappedJoystickMapping) { @@ -278,7 +307,7 @@ public class Controlify implements ControlifyApi { Component.translatable("controlify.toast.unmapped_joystick.description", controller.name()), true ); - } else { + } else if (hotplugged) { ToastUtils.sendToast( Component.translatable("controlify.toast.controller_connected.title"), Component.translatable("controlify.toast.controller_connected.description", controller.name()), @@ -289,8 +318,17 @@ public class Controlify implements ControlifyApi { if (minecraft.screen instanceof ControllerCarouselScreen controllerListScreen) { controllerListScreen.refreshControllers(); } + + // saved after discovery + if (hotplugged) { + config().saveIfDirty(); + } } + /** + * Called when a controller is disconnected. + * @param controller controller that has been disconnected + */ private void onControllerRemoved(Controller controller) { this.setCurrentController( controllerManager.getConnectedControllers() @@ -325,6 +363,14 @@ public class Controlify implements ControlifyApi { if (nativeOnboardingFuture != null) return nativeOnboardingFuture; + // just say no if the platform doesn't support it + if (!SDL2NativesManager.isSupportedOnThisPlatform()) { + Log.LOGGER.warn("SDL is not supported on this platform. Platform: {}", SDL2NativesManager.Target.CURRENT); + nativeOnboardingFuture = new CompletableFuture<>(); + minecraft.setScreen(new NoSDLScreen(() -> nativeOnboardingFuture.complete(false), minecraft.screen)); + return nativeOnboardingFuture; + } + // the user has already been asked, initialise SDL if necessary // and return a completed future if (config().globalSettings().vibrationOnboarded) { @@ -373,19 +419,10 @@ public class Controlify implements ControlifyApi { boolean outOfFocus = !config().globalSettings().outOfFocusInput && !client.isWindowActive(); + // handles updating state of all controllers controllerManager.tick(outOfFocus); - if (switchableController != null && Blaze3D.getTime() - askSwitchTime <= 10000) { - if (switchableController.state().hasAnyInput()) { - switchableController.clearState(); - this.setCurrentController(switchableController, true); // setCurrentController sets switchableController to null - if (askSwitchToast != null) { - askSwitchToast.remove(); - askSwitchToast = null; - } - } - } - + // handle showing/hiding mouse whilst in mixed input mode if (minecraft.mouseHandler.isMouseGrabbed()) showMouseTicks = 0; if (currentInputMode() == InputMode.MIXED && showMouseTicks > 0) { @@ -400,6 +437,7 @@ public class Controlify implements ControlifyApi { LowBatteryNotifier.tick(); + // if splitscreen ever happens this can tick over every controller getCurrentController().ifPresent(currentController -> { wrapControllerError( () -> tickController(currentController, outOfFocus), @@ -409,6 +447,12 @@ public class Controlify implements ControlifyApi { }); } + /** + * Ticks a specific controller. + * + * @param controller controller to tick + * @param outOfFocus if the window is out of focus + */ private void tickController(Controller controller, boolean outOfFocus) { ControllerState state = controller.state(); @@ -479,10 +523,6 @@ public class Controlify implements ControlifyApi { this.currentController = controller; - if (switchableController == controller) { - switchableController = null; - } - if (controller == null) { this.setInputMode(InputMode.KEYBOARD_MOUSE); this.inGameInputHandler = null; @@ -495,7 +535,7 @@ public class Controlify implements ControlifyApi { DebugLog.log("Updated current controller to {}({})", controller.name(), controller.uid()); if (!controller.uid().equals(config().currentControllerUid())) { - config().save(); + config().setDirty(); } this.inGameInputHandler = new InGameInputHandler(controller); @@ -504,6 +544,8 @@ public class Controlify implements ControlifyApi { setInputMode(InputMode.MIXED); else if (changeInputMode) setInputMode(InputMode.CONTROLLER); + + config().saveIfDirty(); } public Optional getControllerManager() { diff --git a/src/main/java/dev/isxander/controlify/api/bind/ControllerBinding.java b/src/main/java/dev/isxander/controlify/api/bind/ControllerBinding.java index b195304..053d9d3 100644 --- a/src/main/java/dev/isxander/controlify/api/bind/ControllerBinding.java +++ b/src/main/java/dev/isxander/controlify/api/bind/ControllerBinding.java @@ -3,7 +3,6 @@ package dev.isxander.controlify.api.bind; import com.google.gson.JsonObject; import dev.isxander.controlify.bindings.BindContext; import dev.isxander.controlify.bindings.IBind; -import dev.isxander.controlify.bindings.RadialIcons; import dev.isxander.yacl3.api.Option; import net.minecraft.client.KeyMapping; import net.minecraft.network.chat.Component; diff --git a/src/main/java/dev/isxander/controlify/bindings/ControllerBindingImpl.java b/src/main/java/dev/isxander/controlify/bindings/ControllerBindingImpl.java index bb621b5..91832cf 100644 --- a/src/main/java/dev/isxander/controlify/bindings/ControllerBindingImpl.java +++ b/src/main/java/dev/isxander/controlify/bindings/ControllerBindingImpl.java @@ -17,6 +17,8 @@ import dev.isxander.controlify.controller.joystick.JoystickState; import dev.isxander.controlify.gui.DrawSize; import dev.isxander.yacl3.api.Option; import dev.isxander.yacl3.api.OptionDescription; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet; import net.minecraft.client.KeyMapping; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.locale.Language; @@ -39,9 +41,9 @@ public class ControllerBindingImpl implements Control private final ResourceLocation radialIcon; private final KeyMappingOverride override; - private static final Map, Set>> pressedBinds = new HashMap<>(); + private static final Map, Set>> pressedBinds = new Object2ObjectOpenHashMap<>(); - private int fakePressState = 0; + private byte fakePressState = 0; private ControllerBindingImpl(Controller controller, IBind defaultBind, ResourceLocation id, KeyMappingOverride vanillaOverride, Component name, Component description, Component category, Set contexts, ResourceLocation icon) { this.controller = controller; @@ -219,7 +221,7 @@ public class ControllerBindingImpl implements Control } private static void addPressedBind(ControllerBindingImpl binding) { - pressedBinds.computeIfAbsent(binding.controller, c -> new HashSet<>()).addAll(getBinds(binding.bind)); + pressedBinds.computeIfAbsent(binding.controller, c -> new ObjectOpenHashSet<>()).addAll(getBinds(binding.bind)); } private static Set> getBinds(IBind bind) { diff --git a/src/main/java/dev/isxander/controlify/bindings/ControllerBindings.java b/src/main/java/dev/isxander/controlify/bindings/ControllerBindings.java index 9d0cc71..6b2605b 100644 --- a/src/main/java/dev/isxander/controlify/bindings/ControllerBindings.java +++ b/src/main/java/dev/isxander/controlify/bindings/ControllerBindings.java @@ -14,6 +14,7 @@ import dev.isxander.controlify.mixins.compat.fapi.KeyBindingRegistryImplAccessor import dev.isxander.controlify.mixins.feature.bind.KeyMappingAccessor; import dev.isxander.controlify.mixins.feature.bind.ToggleKeyMappingAccessor; import dev.isxander.controlify.utils.Log; +import it.unimi.dsi.fastutil.objects.Object2ObjectLinkedOpenHashMap; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import net.minecraft.ChatFormatting; import net.minecraft.client.KeyMapping; @@ -23,6 +24,7 @@ import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; import net.minecraft.world.effect.MobEffects; import net.minecraft.world.item.Items; +import org.apache.commons.lang3.Validate; import java.util.*; import java.util.function.BooleanSupplier; @@ -32,6 +34,7 @@ import java.util.function.UnaryOperator; public class ControllerBindings { private static final Map, ControllerBinding>> CUSTOM_BINDS = new LinkedHashMap<>(); private static final Set EXCLUDED_VANILLA_BINDS = new HashSet<>(); + private static boolean lockRegistry = false; public static final Component MOVEMENT_CATEGORY = Component.translatable("key.categories.movement"); public static final Component GAMEPLAY_CATEGORY = Component.translatable("key.categories.gameplay"); @@ -75,7 +78,7 @@ public class ControllerBindings { GUI_NAVI_UP, GUI_NAVI_DOWN, GUI_NAVI_LEFT, GUI_NAVI_RIGHT, CYCLE_OPT_FORWARD, CYCLE_OPT_BACKWARD; - private final Map registry = new LinkedHashMap<>(); + private final Map registry = new Object2ObjectLinkedOpenHashMap<>(); private final Controller controller; @@ -643,11 +646,18 @@ public class ControllerBindings { } } + public static void lockRegistry() { + Api.INSTANCE.lockRegistry(); + } + public static final class Api implements ControlifyBindingsApi { public static final Api INSTANCE = new Api(); + private boolean lockedRegistry = false; + @Override public dev.isxander.controlify.api.bind.BindingSupplier registerBind(ResourceLocation id, UnaryOperator> builder) { + checkLocked(); CUSTOM_BINDS.put(id, bindings -> bindings.create(b -> builder.apply(b).identifier(id))); return controller -> controller.bindings().get(id); } @@ -655,6 +665,7 @@ public class ControllerBindings { @Deprecated @Override public dev.isxander.controlify.api.bind.BindingSupplier registerBind(GamepadBinds bind, ResourceLocation id) { + checkLocked(); CUSTOM_BINDS.put(id, bindings -> bindings.create(bind, id)); return controller -> controller.bindings().get(id); } @@ -662,18 +673,29 @@ public class ControllerBindings { @Deprecated @Override public dev.isxander.controlify.api.bind.BindingSupplier registerBind(GamepadBinds bind, ResourceLocation id, KeyMapping override, BooleanSupplier toggleOverride) { + checkLocked(); CUSTOM_BINDS.put(id, bindings -> bindings.create(bind, id, override, toggleOverride)); return controller -> controller.bindings().get(id); } @Override public void excludeVanillaBind(KeyMapping... keyMappings) { + checkLocked(); EXCLUDED_VANILLA_BINDS.addAll(Arrays.asList(keyMappings)); } @Override public void registerRadialIcon(ResourceLocation id, RadialIcon icon) { + checkLocked(); RadialIcons.registerIcon(id, icon); } + + public void lockRegistry() { + this.lockedRegistry = true; + } + + private void checkLocked() { + Validate.isTrue(!lockedRegistry, "Cannot register new binds after registry is locked! You most likely tried to register a binding too late in Controlify's lifecycle."); + } } } diff --git a/src/main/java/dev/isxander/controlify/bindings/RadialIcons.java b/src/main/java/dev/isxander/controlify/bindings/RadialIcons.java index 8e031d2..3922536 100644 --- a/src/main/java/dev/isxander/controlify/bindings/RadialIcons.java +++ b/src/main/java/dev/isxander/controlify/bindings/RadialIcons.java @@ -1,6 +1,7 @@ package dev.isxander.controlify.bindings; import dev.isxander.controlify.api.bind.RadialIcon; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; import net.minecraft.Util; import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.texture.TextureAtlasSprite; @@ -12,7 +13,6 @@ import net.minecraft.world.effect.MobEffect; import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; -import java.util.HashMap; import java.util.Map; public final class RadialIcons { @@ -22,7 +22,7 @@ public final class RadialIcons { public static final ResourceLocation FABRIC_ICON = new ResourceLocation("fabric-resource-loader-v0", "icon.png"); private static final Map icons = Util.make(() -> { - Map map = new HashMap<>(); + Map map = new Object2ObjectOpenHashMap<>(); map.put(EMPTY, (graphics, x, y, tickDelta) -> {}); map.put(FABRIC_ICON, (graphics, x, y, tickDelta) -> { diff --git a/src/main/java/dev/isxander/controlify/config/ControlifyConfig.java b/src/main/java/dev/isxander/controlify/config/ControlifyConfig.java index eea8c36..e8236a9 100644 --- a/src/main/java/dev/isxander/controlify/config/ControlifyConfig.java +++ b/src/main/java/dev/isxander/controlify/config/ControlifyConfig.java @@ -63,8 +63,8 @@ public class ControlifyConfig { Log.LOGGER.info("Loading Controlify config..."); if (!Files.exists(CONFIG_PATH)) { - firstLaunch = true; if (lastSeenVersion == null) { + firstLaunch = true; try { lastSeenVersion = Version.parse("0.0.0"); } catch (VersionParsingException e) { diff --git a/src/main/java/dev/isxander/controlify/controller/AbstractController.java b/src/main/java/dev/isxander/controlify/controller/AbstractController.java index 53abc61..409d38a 100644 --- a/src/main/java/dev/isxander/controlify/controller/AbstractController.java +++ b/src/main/java/dev/isxander/controlify/controller/AbstractController.java @@ -29,11 +29,6 @@ public abstract class AbstractController GLFW.GLFW_JOYSTICK_LAST || joystickId < 0) - throw new IllegalArgumentException("Joystick ID " + joystickId + " is out of range!"); - if (!GLFW.glfwJoystickPresent(joystickId)) - throw new IllegalArgumentException("Joystick " + joystickId + " is not present and cannot be initialised!"); - this.hidInfo = hidInfo; this.joystickId = joystickId; diff --git a/src/main/java/dev/isxander/controlify/controller/joystick/JoystickConfig.java b/src/main/java/dev/isxander/controlify/controller/joystick/JoystickConfig.java index 5225690..eb4169b 100644 --- a/src/main/java/dev/isxander/controlify/controller/joystick/JoystickConfig.java +++ b/src/main/java/dev/isxander/controlify/controller/joystick/JoystickConfig.java @@ -2,13 +2,15 @@ package dev.isxander.controlify.controller.joystick; import dev.isxander.controlify.controller.ControllerConfig; import dev.isxander.controlify.controller.joystick.mapping.JoystickMapping; +import it.unimi.dsi.fastutil.ints.IntOpenHashSet; +import it.unimi.dsi.fastutil.objects.Object2FloatOpenHashMap; import org.apache.commons.lang3.Validate; import java.util.*; public class JoystickConfig extends ControllerConfig { private Map deadzones; - private Set triggerAxes = new HashSet<>(); + private Set triggerAxes = new IntOpenHashSet(); private transient JoystickController controller; @@ -55,7 +57,7 @@ public class JoystickConfig extends ControllerConfig { this.controller = controller; if (this.deadzones == null) { - deadzones = new HashMap<>(); + deadzones = new Object2FloatOpenHashMap<>(); for (int i = 0; i < controller.mapping().axes().length; i++) { JoystickMapping.Axis axis = controller.mapping().axes()[i]; diff --git a/src/main/java/dev/isxander/controlify/controllermanager/AbstractControllerManager.java b/src/main/java/dev/isxander/controlify/controllermanager/AbstractControllerManager.java index e688ea7..55a0952 100644 --- a/src/main/java/dev/isxander/controlify/controllermanager/AbstractControllerManager.java +++ b/src/main/java/dev/isxander/controlify/controllermanager/AbstractControllerManager.java @@ -13,13 +13,13 @@ import dev.isxander.controlify.hid.HIDDevice; import dev.isxander.controlify.utils.ControllerUtils; import dev.isxander.controlify.utils.DebugLog; import dev.isxander.controlify.utils.Log; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; import net.minecraft.CrashReport; import net.minecraft.CrashReportCategory; import net.minecraft.ReportedException; import net.minecraft.client.Minecraft; import net.minecraft.server.packs.resources.Resource; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -29,7 +29,8 @@ import static dev.isxander.controlify.utils.ControllerUtils.wrapControllerError; public abstract class AbstractControllerManager implements ControllerManager { protected final Controlify controlify; protected final Minecraft minecraft; - private final Map> CONTROLLERS = new HashMap<>(); + + protected final Map> controllersByUid = new Object2ObjectOpenHashMap<>(); public AbstractControllerManager() { this.controlify = Controlify.instance(); @@ -40,37 +41,35 @@ public abstract class AbstractControllerManager implements ControllerManager { .ifPresent(this::loadGamepadMappings); } - public Optional> createOrGet(int joystickId, ControllerHIDService.ControllerHIDInfo hidInfo) { + public Optional> createOrGet(int joystickIndex, ControllerHIDService.ControllerHIDInfo hidInfo) { try { Optional uid = hidInfo.createControllerUID(); - if (uid.isPresent() && CONTROLLERS.containsKey(uid.get())) { - return Optional.of(CONTROLLERS.get(uid.get())); + if (uid.isPresent() && controllersByUid.containsKey(uid.get())) { + return Optional.of(controllersByUid.get(uid.get())); } if (hidInfo.type().dontLoad()) { - DebugLog.log("Preventing load of controller #" + joystickId + " because its type prevents loading."); + DebugLog.log("Preventing load of controller #" + joystickIndex + " because its type prevents loading."); return Optional.empty(); } - if (this.isControllerGamepad(joystickId) && !DebugProperties.FORCE_JOYSTICK && !hidInfo.type().forceJoystick()) { - GamepadController controller = new GamepadController(joystickId, hidInfo); - CONTROLLERS.put(controller.uid(), controller); - checkCompoundJoysticks(); + if (this.isControllerGamepad(joystickIndex) && !DebugProperties.FORCE_JOYSTICK && !hidInfo.type().forceJoystick()) { + GamepadController controller = new GamepadController(joystickIndex, hidInfo); + this.addController(joystickIndex, controller); return Optional.of(controller); } - SingleJoystickController controller = new SingleJoystickController(joystickId, hidInfo); - CONTROLLERS.put(controller.uid(), controller); - checkCompoundJoysticks(); + SingleJoystickController controller = new SingleJoystickController(joystickIndex, hidInfo); + this.addController(joystickIndex, controller); return Optional.of(controller); } catch (Throwable e) { - CrashReport crashReport = CrashReport.forThrowable(e, "Creating controller #" + joystickId); + CrashReport crashReport = CrashReport.forThrowable(e, "Creating controller #" + joystickIndex); CrashReportCategory category = crashReport.addCategory("Controller Info"); - category.setDetail("Joystick ID", joystickId); + category.setDetail("Joystick ID", joystickIndex); category.setDetail("Controller identification", hidInfo.type()); category.setDetail("HID path", hidInfo.hidDevice().map(HIDDevice::path).orElse("N/A")); category.setDetail("HID service status", Controlify.instance().controllerHIDService().isDisabled() ? "Disabled" : "Enabled"); - category.setDetail("GLFW name", Optional.ofNullable(getControllerSystemName(joystickId)).orElse("N/A")); + category.setDetail("GLFW name", Optional.ofNullable(getControllerSystemName(joystickIndex)).orElse("N/A")); throw new ReportedException(crashReport); } } @@ -86,11 +85,6 @@ public abstract class AbstractControllerManager implements ControllerManager { } } - @Override - public Optional> getController(int joystickId) { - return CONTROLLERS.values().stream().filter(controller -> controller.joystickId() == joystickId).findAny(); - } - protected void onControllerConnected(Controller controller, boolean hotplug) { Log.LOGGER.info("Controller connected: {}", ControllerUtils.createControllerString(controller)); @@ -103,20 +97,19 @@ public abstract class AbstractControllerManager implements ControllerManager { Log.LOGGER.info("Controller disconnected: {}", ControllerUtils.createControllerString(controller)); controller.hidInfo().ifPresent(controlify.controllerHIDService()::unconsumeController); - removeController(controller); + removeController(controller.uid()); + checkCompoundJoysticks(); ControlifyEvents.CONTROLLER_DISCONNECTED.invoker().onControllerDisconnected(controller); } - protected void removeController(Controller controller) { - controller.close(); - CONTROLLERS.remove(controller.uid(), controller); - + protected void addController(int id, Controller controller) { + controllersByUid.put(controller.uid(), controller); checkCompoundJoysticks(); } protected void removeController(String uid) { - Controller prev = CONTROLLERS.remove(uid); + Controller prev = controllersByUid.remove(uid); if (prev != null) { prev.close(); } @@ -126,17 +119,17 @@ public abstract class AbstractControllerManager implements ControllerManager { @Override public List> getConnectedControllers() { - return ImmutableList.copyOf(CONTROLLERS.values()); + return ImmutableList.copyOf(controllersByUid.values()); } @Override public boolean isControllerConnected(String uid) { - return CONTROLLERS.containsKey(uid); + return controllersByUid.containsKey(uid); } @Override public void close() { - CONTROLLERS.values().forEach(Controller::close); + controllersByUid.values().forEach(Controller::close); } protected abstract void loadGamepadMappings(Resource resource); @@ -154,7 +147,7 @@ public abstract class AbstractControllerManager implements ControllerManager { if (!info.isLoaded() && info.canBeUsed()) { Log.LOGGER.info("Loading compound joystick " + info.type().mappingId() + "."); CompoundJoystickController controller = info.attemptCreate().orElseThrow(); - CONTROLLERS.put(info.type().mappingId(), controller); + controllersByUid.put(info.type().mappingId(), controller); Controlify.instance().config().loadOrCreateControllerData(controller); } } catch (Exception e) { diff --git a/src/main/java/dev/isxander/controlify/controllermanager/ControllerManager.java b/src/main/java/dev/isxander/controlify/controllermanager/ControllerManager.java index 8700b08..04c3811 100644 --- a/src/main/java/dev/isxander/controlify/controllermanager/ControllerManager.java +++ b/src/main/java/dev/isxander/controlify/controllermanager/ControllerManager.java @@ -14,8 +14,6 @@ public interface ControllerManager { List> getConnectedControllers(); - Optional> getController(int jid); - boolean isControllerConnected(String uid); boolean isControllerGamepad(int jid); diff --git a/src/main/java/dev/isxander/controlify/controllermanager/GLFWControllerManager.java b/src/main/java/dev/isxander/controlify/controllermanager/GLFWControllerManager.java index bc897fd..75d929e 100644 --- a/src/main/java/dev/isxander/controlify/controllermanager/GLFWControllerManager.java +++ b/src/main/java/dev/isxander/controlify/controllermanager/GLFWControllerManager.java @@ -58,11 +58,6 @@ public class GLFWControllerManager extends AbstractControllerManager { return areControllersConnected(); } - @Override - public void tick(boolean outOfFocus) { - - } - @Override protected void loadGamepadMappings(Resource resource) { Log.LOGGER.debug("Loading gamepad mappings..."); @@ -79,6 +74,10 @@ public class GLFWControllerManager extends AbstractControllerManager { } } + private Optional> getController(int joystickId) { + return controllersByUid.values().stream().filter(controller -> controller.joystickId() == joystickId).findAny(); + } + @Override public boolean isControllerGamepad(int jid) { return GLFW.glfwJoystickIsGamepad(jid); diff --git a/src/main/java/dev/isxander/controlify/controllermanager/SDLControllerManager.java b/src/main/java/dev/isxander/controlify/controllermanager/SDLControllerManager.java index 9e0d355..94eca73 100644 --- a/src/main/java/dev/isxander/controlify/controllermanager/SDLControllerManager.java +++ b/src/main/java/dev/isxander/controlify/controllermanager/SDLControllerManager.java @@ -5,11 +5,15 @@ import com.sun.jna.Memory; import com.sun.jna.Pointer; import dev.isxander.controlify.Controlify; import dev.isxander.controlify.controller.Controller; +import dev.isxander.controlify.controller.ControllerType; import dev.isxander.controlify.driver.SDL2NativesManager; +import dev.isxander.controlify.hid.ControllerHIDService; import dev.isxander.controlify.utils.Log; import io.github.libsdl4j.api.event.SDL_Event; import io.github.libsdl4j.api.event.SDL_EventFilter; import io.github.libsdl4j.api.rwops.SDL_RWops; +import it.unimi.dsi.fastutil.ints.Int2ObjectArrayMap; +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; import net.minecraft.client.Minecraft; import net.minecraft.server.packs.resources.Resource; import org.apache.commons.lang3.Validate; @@ -17,17 +21,17 @@ import org.apache.commons.lang3.Validate; import java.io.InputStream; import java.util.Optional; -import static io.github.libsdl4j.api.error.SdlError.SDL_GetError; +import static io.github.libsdl4j.api.error.SdlError.*; import static io.github.libsdl4j.api.event.SDL_EventType.*; import static io.github.libsdl4j.api.event.SdlEvents.*; import static io.github.libsdl4j.api.gamecontroller.SdlGamecontroller.*; import static io.github.libsdl4j.api.joystick.SdlJoystick.*; -import static io.github.libsdl4j.api.rwops.SdlRWops.SDL_RWFromConstMem; +import static io.github.libsdl4j.api.rwops.SdlRWops.*; public class SDLControllerManager extends AbstractControllerManager { private final Controlify controlify; - private final Minecraft minecraft; + private final Int2ObjectMap> controllersByJid = new Int2ObjectArrayMap<>(); private final SDL_Event event = new SDL_Event(); // must keep a reference to prevent GC from collecting it and the callback failing @@ -38,34 +42,34 @@ public class SDLControllerManager extends AbstractControllerManager { Validate.isTrue(SDL2NativesManager.isLoaded(), "SDL2 natives must be loaded before creating SDLControllerManager"); this.controlify = Controlify.instance(); - this.minecraft = Minecraft.getInstance(); SDL_SetEventFilter(eventFilter = new EventFilter(), Pointer.NULL); } @Override public void tick(boolean outOfFocus) { + super.tick(outOfFocus); + + // SDL identifiers controllers in two different ways: + // device index, and device instance ID. while (SDL_PollEvent(event) == 1) { switch (event.type) { + // On added, `which` refers to the device index case SDL_JOYDEVICEADDED -> { - int jid = event.jdevice.which; - Optional> controllerOpt = createOrGet(jid, controlify.controllerHIDService().fetchType(jid)); - controllerOpt.ifPresent(controller -> onControllerConnected(controller, true)); - } - case SDL_CONTROLLERDEVICEADDED -> { - int jid = event.cdevice.which; - Optional> controllerOpt = createOrGet(jid, controlify.controllerHIDService().fetchType(jid)); + int deviceIndex = event.jdevice.which; + Optional> controllerOpt = createOrGet( + deviceIndex, + ControllerHIDService.fetchTypeFromSDL(deviceIndex) + .orElse(new ControllerHIDService.ControllerHIDInfo(ControllerType.UNKNOWN, Optional.empty())) + ); controllerOpt.ifPresent(controller -> onControllerConnected(controller, true)); } + // On removed, `which` refers to the device instance ID case SDL_JOYDEVICEREMOVED -> { int jid = event.jdevice.which; getController(jid).ifPresent(this::onControllerRemoved); } - case SDL_CONTROLLERDEVICEREMOVED -> { - int jid = event.cdevice.which; - getController(jid).ifPresent(this::onControllerRemoved); - } } } } @@ -93,6 +97,19 @@ public class SDLControllerManager extends AbstractControllerManager { return isControllerGamepad(joystickId) ? SDL_GameControllerNameForIndex(joystickId) : SDL_JoystickNameForIndex(joystickId); } + @Override + protected void addController(int index, Controller controller) { + super.addController(index, controller); + + // the instance id is technically a long, but it's usually only like 0, 1, 2, 3, etc. + int joystickId = SDL_JoystickGetDeviceInstanceID(index).intValue(); + controllersByJid.put(joystickId, controller); + } + + private Optional> getController(int joystickId) { + return Optional.ofNullable(controllersByJid.get(joystickId)); + } + @Override protected void loadGamepadMappings(Resource resource) { Log.LOGGER.debug("Loading gamepad mappings..."); @@ -119,8 +136,6 @@ public class SDLControllerManager extends AbstractControllerManager { switch (event.type) { case SDL_JOYDEVICEADDED: case SDL_JOYDEVICEREMOVED: - case SDL_CONTROLLERDEVICEADDED: - case SDL_CONTROLLERDEVICEREMOVED: return 1; default: return 0; diff --git a/src/main/java/dev/isxander/controlify/debug/DebugProperties.java b/src/main/java/dev/isxander/controlify/debug/DebugProperties.java index a78f5b0..15b8e3a 100644 --- a/src/main/java/dev/isxander/controlify/debug/DebugProperties.java +++ b/src/main/java/dev/isxander/controlify/debug/DebugProperties.java @@ -23,7 +23,7 @@ public class DebugProperties { /** Print what drivers are being used */ public static final boolean PRINT_DRIVER = boolProp("controlify.debug.print_driver", true, true); /** Print the state of the left and right triggers on gamepads */ - public static final boolean PRINT_GAMEPAD_STATE = boolProp("controlify.debug.print_gamepad_state", false, true); + public static final boolean PRINT_GAMEPAD_STATE = boolProp("controlify.debug.print_gamepad_state", false, false); /** Use experimental anti-snapback */ public static final boolean USE_SNAPBACK = boolProp("controlify.debug.use_snapback", false, false); diff --git a/src/main/java/dev/isxander/controlify/driver/SDL2NativesManager.java b/src/main/java/dev/isxander/controlify/driver/SDL2NativesManager.java index 1baa03b..7a5981e 100644 --- a/src/main/java/dev/isxander/controlify/driver/SDL2NativesManager.java +++ b/src/main/java/dev/isxander/controlify/driver/SDL2NativesManager.java @@ -54,6 +54,11 @@ public class SDL2NativesManager { attemptedLoad = true; + if (!isSupportedOnThisPlatform()) { + Log.LOGGER.warn("No native library for current platform, skipping SDL2 load"); + return initFuture = CompletableFuture.completedFuture(false); + } + Path localLibraryPath = getNativesFolderPath().resolve(Target.CURRENT.getArtifactName()); if (Files.exists(localLibraryPath)) { @@ -178,6 +183,10 @@ public class SDL2NativesManager { return attemptedLoad; } + public static boolean isSupportedOnThisPlatform() { + return Target.CURRENT.hasNativeLibrary(); + } + private static Path getNativesFolderPath() { Path nativesFolderPath = FabricLoader.getInstance().getGameDir(); ControlifyConfig config = Controlify.instance().config(); diff --git a/src/main/java/dev/isxander/controlify/driver/gamepad/SDL2GamepadDriver.java b/src/main/java/dev/isxander/controlify/driver/gamepad/SDL2GamepadDriver.java index 092563c..33550ff 100644 --- a/src/main/java/dev/isxander/controlify/driver/gamepad/SDL2GamepadDriver.java +++ b/src/main/java/dev/isxander/controlify/driver/gamepad/SDL2GamepadDriver.java @@ -63,12 +63,12 @@ public class SDL2GamepadDriver implements BasicGamepadInputDriver, GyroDriver, R // Triggers are in the range [0, 32767] (thanks SDL!) // https://wiki.libsdl.org/SDL2/SDL_GameControllerGetAxis GamepadState.AxesState axes = new GamepadState.AxesState( - Mth.inverseLerp(SDL_GameControllerGetAxis(ptrGamepad, SDL_CONTROLLER_AXIS_LEFTX), Short.MIN_VALUE, Short.MAX_VALUE) * 2f - 1f, - Mth.inverseLerp(SDL_GameControllerGetAxis(ptrGamepad, SDL_CONTROLLER_AXIS_LEFTY), Short.MIN_VALUE, Short.MAX_VALUE) * 2f - 1f, - Mth.inverseLerp(SDL_GameControllerGetAxis(ptrGamepad, SDL_CONTROLLER_AXIS_RIGHTX), Short.MIN_VALUE, Short.MAX_VALUE) * 2f - 1f, - Mth.inverseLerp(SDL_GameControllerGetAxis(ptrGamepad, SDL_CONTROLLER_AXIS_RIGHTY), Short.MIN_VALUE, Short.MAX_VALUE) * 2f - 1f, - Mth.inverseLerp(SDL_GameControllerGetAxis(ptrGamepad, SDL_CONTROLLER_AXIS_TRIGGERLEFT), 0, Short.MAX_VALUE), - Mth.inverseLerp(SDL_GameControllerGetAxis(ptrGamepad, SDL_CONTROLLER_AXIS_TRIGGERRIGHT), 0, Short.MAX_VALUE) + mapShortToFloat(SDL_GameControllerGetAxis(ptrGamepad, SDL_CONTROLLER_AXIS_LEFTX)), + mapShortToFloat(SDL_GameControllerGetAxis(ptrGamepad, SDL_CONTROLLER_AXIS_LEFTY)), + mapShortToFloat(SDL_GameControllerGetAxis(ptrGamepad, SDL_CONTROLLER_AXIS_RIGHTX)), + mapShortToFloat(SDL_GameControllerGetAxis(ptrGamepad, SDL_CONTROLLER_AXIS_RIGHTY)), + mapShortToFloat(SDL_GameControllerGetAxis(ptrGamepad, SDL_CONTROLLER_AXIS_TRIGGERLEFT)), + mapShortToFloat(SDL_GameControllerGetAxis(ptrGamepad, SDL_CONTROLLER_AXIS_TRIGGERRIGHT)) ); // Button values return 1 if pressed, 0 if not // https://wiki.libsdl.org/SDL2/SDL_GameControllerGetButton @@ -190,4 +190,9 @@ public class SDL2GamepadDriver implements BasicGamepadInputDriver, GyroDriver, R public String getBasicGamepadDetails() { return "SDL2gp"; } + + private static float mapShortToFloat(short value) { + return Mth.clampedMap(value, Short.MIN_VALUE, 0, -1f, 0f) + + Mth.clampedMap(value, 0, Short.MAX_VALUE, 0f, 1f); + } } diff --git a/src/main/java/dev/isxander/controlify/gui/guide/InGameButtonGuide.java b/src/main/java/dev/isxander/controlify/gui/guide/InGameButtonGuide.java index f0d50ad..25c513a 100644 --- a/src/main/java/dev/isxander/controlify/gui/guide/InGameButtonGuide.java +++ b/src/main/java/dev/isxander/controlify/gui/guide/InGameButtonGuide.java @@ -201,7 +201,10 @@ public class InGameButtonGuide implements IngameGuideRegistry { registerGuideAction(controller.bindings().ATTACK, ActionLocation.RIGHT, (ctx) -> { var hitResult = ctx.hitResult(); if (hitResult.getType() == HitResult.Type.ENTITY) - return Optional.of(Component.translatable("controlify.guide.ingame.attack")); + if (player.isSpectator()) + return Optional.of(Component.translatable("controlify.guide.ingame.spectate")); + else + return Optional.of(Component.translatable("controlify.guide.ingame.attack")); if (hitResult.getType() == HitResult.Type.BLOCK) return Optional.of(Component.translatable("controlify.guide.ingame.break")); return Optional.empty(); diff --git a/src/main/java/dev/isxander/controlify/gui/screen/NoSDLScreen.java b/src/main/java/dev/isxander/controlify/gui/screen/NoSDLScreen.java new file mode 100644 index 0000000..b6c8db7 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/gui/screen/NoSDLScreen.java @@ -0,0 +1,22 @@ +package dev.isxander.controlify.gui.screen; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.AlertScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.CommonComponents; +import net.minecraft.network.chat.Component; + +public class NoSDLScreen extends AlertScreen { + public NoSDLScreen(Runnable actionHandler, Screen parent) { + super( + () -> { + actionHandler.run(); + Minecraft.getInstance().setScreen(parent); + }, + Component.translatable("controlify.gui.no_sdl.title"), + Component.translatable("controlify.gui.no_sdl.message"), + CommonComponents.GUI_OK, + false + ); + } +} diff --git a/src/main/java/dev/isxander/controlify/hid/ControllerHIDService.java b/src/main/java/dev/isxander/controlify/hid/ControllerHIDService.java index 0f7066d..1ed59b7 100644 --- a/src/main/java/dev/isxander/controlify/hid/ControllerHIDService.java +++ b/src/main/java/dev/isxander/controlify/hid/ControllerHIDService.java @@ -7,9 +7,9 @@ import dev.isxander.controlify.driver.SDL2NativesManager; import dev.isxander.controlify.debug.DebugProperties; import dev.isxander.controlify.utils.Log; import dev.isxander.controlify.utils.ToastUtils; +import io.github.libsdl4j.api.joystick.SDL_JoystickGUID; import net.minecraft.network.chat.Component; import org.hid4java.*; -import org.lwjgl.glfw.GLFW; import java.util.*; import java.util.concurrent.ArrayBlockingQueue; @@ -154,17 +154,17 @@ public class ControllerHIDService { } } - private Optional fetchTypeFromSDL(int jid) { + public static Optional fetchTypeFromSDL(int jid) { if (SDL2NativesManager.isLoaded()) { int vid = SDL_JoystickGetDeviceVendor(jid); int pid = SDL_JoystickGetDeviceProduct(jid); - String path = GLFW.glfwGetJoystickGUID(jid); + SDL_JoystickGUID guid = SDL_JoystickGetDeviceGUID(jid); if (vid != 0 && pid != 0) { Log.LOGGER.info("Using SDL to identify controller type."); return Optional.of(new ControllerHIDInfo( ControllerType.getTypeForHID(new HIDIdentifier(vid, pid)), - Optional.of(new HIDDevice.SDLHidApi(vid, pid, path)) + Optional.of(new HIDDevice.SDLHidApi(vid, pid, guid.toString())) )); } } diff --git a/src/main/java/dev/isxander/controlify/rumble/RumbleSource.java b/src/main/java/dev/isxander/controlify/rumble/RumbleSource.java index f6ff8df..dee4c77 100644 --- a/src/main/java/dev/isxander/controlify/rumble/RumbleSource.java +++ b/src/main/java/dev/isxander/controlify/rumble/RumbleSource.java @@ -2,12 +2,13 @@ package dev.isxander.controlify.rumble; import com.google.gson.JsonObject; import dev.isxander.controlify.utils.Log; +import it.unimi.dsi.fastutil.objects.Object2ObjectLinkedOpenHashMap; import net.minecraft.resources.ResourceLocation; import java.util.*; public record RumbleSource(ResourceLocation id) { - private static final Map SOURCES = new LinkedHashMap<>(); + private static final Map SOURCES = new Object2ObjectLinkedOpenHashMap<>(); public static final RumbleSource MASTER = register("master"), diff --git a/src/main/java/dev/isxander/controlify/screenop/Registry.java b/src/main/java/dev/isxander/controlify/screenop/Registry.java index 0734eb2..4e0260e 100644 --- a/src/main/java/dev/isxander/controlify/screenop/Registry.java +++ b/src/main/java/dev/isxander/controlify/screenop/Registry.java @@ -1,8 +1,8 @@ package dev.isxander.controlify.screenop; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; import org.jetbrains.annotations.ApiStatus; -import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.function.Function; @@ -13,8 +13,8 @@ public class Registry { private final Map cache; public Registry() { - this.registry = new HashMap<>(); - this.cache = new HashMap<>(); + this.registry = new Object2ObjectOpenHashMap<>(); + this.cache = new Object2ObjectOpenHashMap<>(); } /** diff --git a/src/main/java/dev/isxander/controlify/screenop/ScreenProcessorFactory.java b/src/main/java/dev/isxander/controlify/screenop/ScreenProcessorFactory.java index f128113..fde2f68 100644 --- a/src/main/java/dev/isxander/controlify/screenop/ScreenProcessorFactory.java +++ b/src/main/java/dev/isxander/controlify/screenop/ScreenProcessorFactory.java @@ -1,5 +1,6 @@ package dev.isxander.controlify.screenop; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; import net.minecraft.client.gui.screens.Screen; import java.util.HashMap; @@ -8,7 +9,7 @@ import java.util.function.Function; @SuppressWarnings("unchecked") public final class ScreenProcessorFactory { - private static final Map, Factory> factories = new HashMap<>(); + private static final Map, Factory> factories = new Object2ObjectOpenHashMap<>(); private ScreenProcessorFactory() { } diff --git a/src/main/resources/assets/controlify/lang/en_us.json b/src/main/resources/assets/controlify/lang/en_us.json index 5a9fe72..2e00e0c 100644 --- a/src/main/resources/assets/controlify/lang/en_us.json +++ b/src/main/resources/assets/controlify/lang/en_us.json @@ -147,6 +147,9 @@ "controlify.gui.controller_unavailable": "Controller unavailable and cannot be edited.", + "controlify.gui.no_sdl.title": "Controlify Native Library Incompatible", + "controlify.gui.no_sdl.message": "Controlify uses an extra native library to add more features for controller support. Your system does not support this library. You will be missing out on features such as:\n - controller vibration\n - enhanced controller identification\n - gyroscope controls", + "controlify.new_features.title": "Controlify updated to %s!", "controlify.new_features.1.5.0": "Added a radial menu that can be configured to any action you want. You can find it in your controller settings.", @@ -175,10 +178,8 @@ "controlify.toast.vmouse_disabled.description": "Controlify virtual mouse is now disabled for this screen.", "controlify.toast.vmouse_unavailable.title": "Virtual Mouse Unavailable", "controlify.toast.vmouse_unavailable.description": "This screen is forcing a specific virtual mouse mode and you cannot change it.", - "controlify.toast.ask_to_switch.title": "Switch Controller?", - "controlify.toast.ask_to_switch.description": "A new controller named '%s' has just been connected. Press any button to switch to it.", - "controlify.toast.default_controller_connected.title": "Controller Connected", - "controlify.toast.default_controller_connected.description": "Your primary controller has been connected and automatically switched to.", + "controlify.toast.controller_connected.title": "Controller Connected", + "controlify.toast.controller_connected.description": "A controller named `%s` has been connected. It has automatically been switched to.", "controlify.toast.controller_disconnected.title": "Controller Disconnected", "controlify.toast.controller_disconnected.description": "'%s' was disconnected.", "controlify.toast.faulty_input.title": "Controller disabled",