1
0
forked from Clones/Controlify

Migrate to libsdl4j, SDL download screen, use gamecontrollerdb.txt, calibration now detects joystick triggers

This commit is contained in:
isXander
2023-09-03 23:41:18 +01:00
parent 7dfd178444
commit 1cb4a40bac
39 changed files with 1222 additions and 543 deletions

View File

@ -1,18 +1,19 @@
package dev.isxander.controlify;
import com.google.common.io.ByteStreams;
import com.mojang.blaze3d.Blaze3D;
import com.sun.jna.Memory;
import dev.isxander.controlify.api.ControlifyApi;
import dev.isxander.controlify.api.entrypoint.ControlifyEntrypoint;
import dev.isxander.controlify.compatibility.ControlifyCompat;
import dev.isxander.controlify.controller.joystick.JoystickController;
import dev.isxander.controlify.controller.joystick.mapping.UnmappedJoystickMapping;
import dev.isxander.controlify.gui.controllers.ControllerBindHandler;
import dev.isxander.controlify.gui.screen.ControllerCarouselScreen;
import dev.isxander.controlify.gui.screen.*;
import dev.isxander.controlify.controller.Controller;
import dev.isxander.controlify.controller.ControllerState;
import dev.isxander.controlify.controller.sdl2.SDL2NativesManager;
import dev.isxander.controlify.driver.SDL2NativesManager;
import dev.isxander.controlify.debug.DebugProperties;
import dev.isxander.controlify.gui.screen.ControllerCalibrationScreen;
import dev.isxander.controlify.gui.screen.SDLOnboardingScreen;
import dev.isxander.controlify.gui.screen.SubmitUnknownControllerScreen;
import dev.isxander.controlify.ingame.ControllerPlayerMovement;
import dev.isxander.controlify.server.*;
import dev.isxander.controlify.screenop.ScreenProcessorProvider;
@ -22,11 +23,13 @@ import dev.isxander.controlify.api.event.ControlifyEvents;
import dev.isxander.controlify.gui.guide.InGameButtonGuide;
import dev.isxander.controlify.ingame.InGameInputHandler;
import dev.isxander.controlify.mixins.feature.virtualmouse.MouseHandlerAccessor;
import dev.isxander.controlify.utils.ControllerUtils;
import dev.isxander.controlify.utils.DebugLog;
import dev.isxander.controlify.utils.Log;
import dev.isxander.controlify.utils.ToastUtils;
import dev.isxander.controlify.virtualmouse.VirtualMouseHandler;
import dev.isxander.controlify.wireless.LowBatteryNotifier;
import io.github.libsdl4j.api.rwops.SDL_RWops;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
@ -39,10 +42,15 @@ import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.Resource;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.lwjgl.glfw.GLFW;
import org.lwjgl.system.MemoryUtil;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.util.ArrayDeque;
import java.util.List;
import java.util.Optional;
@ -50,6 +58,9 @@ import java.util.Queue;
import java.util.concurrent.CompletableFuture;
import java.util.stream.IntStream;
import static io.github.libsdl4j.api.gamecontroller.SdlGamecontroller.SDL_GameControllerAddMappingsFromRW;
import static io.github.libsdl4j.api.rwops.SdlRWops.SDL_RWFromConstMem;
public class Controlify implements ControlifyApi {
private static Controlify instance = null;
@ -67,7 +78,7 @@ public class Controlify implements ControlifyApi {
private final ControlifyConfig config = new ControlifyConfig(this);
private final Queue<Controller<?, ?>> calibrationQueue = new ArrayDeque<>();
private boolean canDiscoverControllers = true;
private boolean hasDiscoveredControllers = false;
private int consecutiveInputSwitches = 0;
private double lastInputSwitchTime = 0;
@ -78,147 +89,19 @@ public class Controlify implements ControlifyApi {
private double askSwitchTime = 0;
private ToastUtils.ControlifyToast askSwitchToast = null;
public void initializeControlify() {
Log.LOGGER.info("Initializing Controlify...");
config().load();
ControlifyCompat.init();
var controllersConnected = IntStream.range(0, GLFW.GLFW_JOYSTICK_LAST + 1).anyMatch(GLFW::glfwJoystickPresent);
if (controllersConnected) {
if (!config().globalSettings().delegateSetup) {
askNatives().whenComplete((loaded, th) -> discoverControllers());
} else {
ToastUtils.sendToast(
Component.translatable("controlify.toast.setup_in_config.title"),
Component.translatable(
"controlify.toast.setup_in_config.description",
Component.translatable("options.title"),
Component.translatable("controls.keybinds.title"),
Component.literal("Controlify")
),
false
);
}
}
ClientTickEvents.START_CLIENT_TICK.register(this::tick);
ClientLifecycleEvents.CLIENT_STOPPING.register(minecraft -> {
controllerHIDService().stop();
});
// listen for new controllers
GLFW.glfwSetJoystickCallback((jid, event) -> {
try {
this.askNatives().whenComplete((loaded, th) -> {
if (event == GLFW.GLFW_CONNECTED) {
this.onControllerHotplugged(jid);
} else if (event == GLFW.GLFW_DISCONNECTED) {
this.onControllerDisconnect(jid);
}
});
} catch (Throwable e) {
e.printStackTrace();
}
});
notifyOfNewFeatures();
}
private CompletableFuture<Boolean> askNatives() {
if (nativeOnboardingFuture != null) return nativeOnboardingFuture;
if (config().globalSettings().vibrationOnboarded) {
boolean loadNatives = config().globalSettings().loadVibrationNatives;
if (loadNatives && !SDL2NativesManager.isInitialised()) {
SDL2NativesManager.initialise();
}
return CompletableFuture.completedFuture(loadNatives);
}
nativeOnboardingFuture = new CompletableFuture<>();
Screen parent = minecraft.screen;
minecraft.setScreen(new SDLOnboardingScreen(
() -> parent,
answer -> {
if (answer)
SDL2NativesManager.initialise();
nativeOnboardingFuture.complete(answer);
}
));
return nativeOnboardingFuture;
}
public void discoverControllers() {
if (!canDiscoverControllers) {
throw new IllegalStateException("Already discovered/cannot discover controllers");
}
canDiscoverControllers = false;
DebugLog.log("Discovering and initializing controllers...");
if (config().globalSettings().loadVibrationNatives)
SDL2NativesManager.initialise();
// find already connected controllers
for (int jid = 0; jid <= GLFW.GLFW_JOYSTICK_LAST; jid++) {
if (GLFW.glfwJoystickPresent(jid)) {
var controllerOpt = ControllerManager.createOrGet(jid, controllerHIDService.fetchType(jid));
if (controllerOpt.isEmpty()) continue;
var controller = controllerOpt.get();
Log.LOGGER.info("Controller found: " + controller.name());
config().loadOrCreateControllerData(controller);
if (SubmitUnknownControllerScreen.canSubmit(controller)) {
minecraft.setScreen(new SubmitUnknownControllerScreen(controller, minecraft.screen));
}
if (controller.uid().equals(config().currentControllerUid()))
setCurrentController(controller);
if (controller.config().allowVibrations && !config().globalSettings().loadVibrationNatives) {
controller.config().allowVibrations = false;
config().setDirty();
}
}
}
if (ControllerManager.getConnectedControllers().isEmpty()) {
Log.LOGGER.info("No controllers found.");
}
if (getCurrentController().isEmpty()) {
var controller = ControllerManager.getConnectedControllers().stream().findFirst().orElse(null);
if (controller != null && controller.config().delayedCalibration) {
controller = null;
}
this.setCurrentController(controller);
} else {
// setCurrentController saves config
config().saveIfDirty();
}
FabricLoader.getInstance().getEntrypoints("controlify", ControlifyEntrypoint.class).forEach(entrypoint -> {
try {
entrypoint.onControllersDiscovered(this);
} catch (Throwable e) {
Log.LOGGER.error("Failed to run `onControllersDiscovered` on Controlify entrypoint: " + entrypoint.getClass().getName(), e);
}
});
}
/**
* Called at usual fabric client entrypoint.
* Always runs, even with no controllers detected.
* In this state, Controlify is only partially loaded, no controllers
* have been initialised, nor has the config. This is done at {@link Controlify#initializeControlify()}.
* This is where regular fabric callbacks are registered.
*/
public void preInitialiseControlify() {
DebugProperties.printProperties();
Log.LOGGER.info("Pre-initializing Controlify...");
this.inGameInputHandler = null;
this.inGameInputHandler = null; // set when the current controller changes
this.virtualMouseHandler = new VirtualMouseHandler();
controllerHIDService = new ControllerHIDService();
@ -262,9 +145,315 @@ public class Controlify implements ControlifyApi {
});
}
/**
* Called once Minecraft has completely loaded.
* (When the loading overlay starts to fade).
*
* This is where controllers are usually initialised, as long
* as one or more controllers are connected.
*/
public void initializeControlify() {
Log.LOGGER.info("Initializing Controlify...");
config().load();
// initialise and compatability modules that controlify implements itself
// this does NOT invoke any entrypoints. this is done in the pre-initialisation phase
ControlifyCompat.init();
var controllersConnected = IntStream.range(0, GLFW.GLFW_JOYSTICK_LAST + 1)
.anyMatch(GLFW::glfwJoystickPresent);
if (controllersConnected) { // only initialise Controlify if controllers are detected
if (!config().globalSettings().delegateSetup) {
// check native onboarding then discover controllers
askNatives().whenComplete((loaded, th) -> discoverControllers());
} else {
// delegate setup: don't auto set up controllers, require the user to open config screen
ToastUtils.sendToast(
Component.translatable("controlify.toast.setup_in_config.title"),
Component.translatable(
"controlify.toast.setup_in_config.description",
Component.translatable("options.title"),
Component.translatable("controls.keybinds.title"),
Component.literal("Controlify")
),
false
);
}
}
// register events
ClientTickEvents.START_CLIENT_TICK.register(this::tick);
ClientLifecycleEvents.CLIENT_STOPPING.register(minecraft -> {
controllerHIDService().stop();
});
// set up the hotplugging callback with GLFW
// TODO: investigate if there is any benefit to implementing this with SDL
GLFW.glfwSetJoystickCallback((jid, event) -> {
try {
this.askNatives().whenComplete((loaded, th) -> {
if (event == GLFW.GLFW_CONNECTED) {
this.onControllerHotplugged(jid);
} else if (event == GLFW.GLFW_DISCONNECTED) {
this.onControllerDisconnect(jid);
}
});
} catch (Throwable e) {
Log.LOGGER.error("Failed to handle controller connect/disconnect event", e);
}
});
// sends toasts of new features
notifyOfNewFeatures();
}
/**
* Loops through every controller slot and initialises it if it is connected.
* This is guaranteed to be called at most once. If no controllers are connected
* in the whole game lifecycle, this is never ran.
*/
public void discoverControllers() {
if (hasDiscoveredControllers) {
Log.LOGGER.warn("Attempted to discover controllers twice!");
}
hasDiscoveredControllers = true;
DebugLog.log("Discovering and initializing controllers...");
// load gamepad mappings before every
minecraft.getResourceManager()
.getResource(Controlify.id("controllers/gamecontrollerdb.txt"))
.ifPresent(this::loadGamepadMappings);
// find already connected controllers
// TODO: investigate if there is any benefit to implementing this with SDL
for (int jid = 0; jid <= GLFW.GLFW_JOYSTICK_LAST; jid++) {
if (GLFW.glfwJoystickPresent(jid)) {
Optional<Controller<?, ?>> controllerOpt = ControllerManager.createOrGet(
jid,
controllerHIDService.fetchType(jid)
);
if (controllerOpt.isEmpty())
continue;
Controller<?, ?> controller = controllerOpt.get();
Log.LOGGER.info("Controller found: " + ControllerUtils.createControllerString(controller));
boolean newController = !config().loadOrCreateControllerData(controller);
if (SubmitUnknownControllerScreen.canSubmit(controller)) {
minecraft.setScreen(new SubmitUnknownControllerScreen(controller, minecraft.screen));
}
// only "equip" the controller if it has already been calibrated
if (!controller.config().deadzonesCalibrated) {
calibrationQueue.add(controller);
} else if (controller.uid().equals(config().currentControllerUid())) {
setCurrentController(controller);
}
// make sure that allow vibrations is not mismatched with the native library setting
if (controller.config().allowVibrations && !config().globalSettings().loadVibrationNatives) {
controller.config().allowVibrations = false;
config().setDirty();
}
// if a joystick and unmapped, tell the user that they need to configure the controls
// joysticks have an abstract number of inputs, so applying a default control scheme is impossible
if (newController && controller instanceof JoystickController<?> joystick && joystick.mapping() instanceof UnmappedJoystickMapping) {
ToastUtils.sendToast(
Component.translatable("controlify.toast.unmapped_joystick.title"),
Component.translatable("controlify.toast.unmapped_joystick.description", controller.name()),
true
);
}
}
}
if (ControllerManager.getConnectedControllers().isEmpty()) {
Log.LOGGER.info("No controllers found.");
}
// if no controller is currently selected, select the first one
if (getCurrentController().isEmpty()) {
var controller = ControllerManager.getConnectedControllers().stream().findFirst().orElse(null);
if (controller != null && (controller.config().delayedCalibration || !controller.config().deadzonesCalibrated)) {
controller = null;
}
this.setCurrentController(controller);
} else {
// setCurrentController saves config so there is no need to set dirty to save
config().saveIfDirty();
}
FabricLoader.getInstance().getEntrypoints("controlify", ControlifyEntrypoint.class).forEach(entrypoint -> {
try {
entrypoint.onControllersDiscovered(this);
} catch (Throwable e) {
Log.LOGGER.error("Failed to run `onControllersDiscovered` on Controlify entrypoint: " + entrypoint.getClass().getName(), e);
}
});
}
/**
* Called when a controller has been connected after mod initialisation.
* If this is the first controller to be connected in the game's lifecycle,
* this is delegated to {@link Controlify#discoverControllers()} for it to be "discovered",
* otherwise the controller is initialised and added to the list of connected controllers.
*/
private void onControllerHotplugged(int jid) {
if (!hasDiscoveredControllers) {
discoverControllers();
return;
}
var controllerOpt = ControllerManager.createOrGet(jid, controllerHIDService.fetchType(jid));
if (controllerOpt.isEmpty()) return;
var controller = controllerOpt.get();
Log.LOGGER.info("Controller connected: " + ControllerUtils.createControllerString(controller));
config().loadOrCreateControllerData(controller);
if (SubmitUnknownControllerScreen.canSubmit(controller)) {
minecraft.setScreen(new SubmitUnknownControllerScreen(controller, minecraft.screen));
}
if (config().globalSettings().delegateSetup) {
config().globalSettings().delegateSetup = false;
config().setDirty();
}
if (controller.config().allowVibrations && !config().globalSettings().loadVibrationNatives) {
controller.config().allowVibrations = false;
config().setDirty();
}
if (ControllerManager.getConnectedControllers().size() == 1 && (controller.config().deadzonesCalibrated || controller.config().delayedCalibration)) {
this.setCurrentController(controller);
ToastUtils.sendToast(
Component.translatable("controlify.toast.default_controller_connected.title"),
Component.translatable("controlify.toast.default_controller_connected.description"),
false
);
} else {
this.askToSwitchController(controller);
config().saveIfDirty();
}
if (minecraft.screen instanceof ControllerCarouselScreen controllerListScreen) {
controllerListScreen.refreshControllers();
}
}
/**
* Called when a controller is disconnected.
* Equips another controller if available.
*
* @param jid the joystick id of the disconnected controller
*/
private void onControllerDisconnect(int jid) {
ControllerManager.getConnectedControllers().stream().filter(controller -> controller.joystickId() == jid).findAny().ifPresent(controller -> {
ControllerManager.disconnect(controller);
controller.hidInfo().ifPresent(controllerHIDService::unconsumeController);
setCurrentController(ControllerManager.getConnectedControllers().stream().findFirst().orElse(null));
Log.LOGGER.info("Controller disconnected: " + controller.name());
this.setInputMode(currentController == null ? InputMode.KEYBOARD_MOUSE : InputMode.CONTROLLER);
ToastUtils.sendToast(
Component.translatable("controlify.toast.controller_disconnected.title"),
Component.translatable("controlify.toast.controller_disconnected.description", controller.name()),
false
);
if (minecraft.screen instanceof ControllerCarouselScreen controllerListScreen) {
controllerListScreen.refreshControllers();
}
});
}
/**
* Asks the user if they want to download the SDL2 library,
* or initialises it if it hasn't been already.
* If the user has already been asked and SDL is already initialised,
* a completed future is returned.
* The future is completed once the user has made their choice and SDL
* has been downloaded and initialised (or not).
*/
public CompletableFuture<Boolean> askNatives() {
// if the future already exists, just return it
if (nativeOnboardingFuture != null)
return nativeOnboardingFuture;
// the user has already been asked, initialise SDL if necessary
// and return a completed future
if (config().globalSettings().vibrationOnboarded) {
if (config().globalSettings().loadVibrationNatives) {
return nativeOnboardingFuture = SDL2NativesManager.maybeLoad();
}
// micro-optimization. no need to create a new future every time. use the first not null check
return nativeOnboardingFuture = CompletableFuture.completedFuture(false);
}
nativeOnboardingFuture = new CompletableFuture<>();
// open the SDL onboarding screen. complete the future when the user has made their choice
Screen parent = minecraft.screen;
minecraft.setScreen(new SDLOnboardingScreen(
() -> parent,
answer -> {
if (answer) {
SDL2NativesManager.maybeLoad().whenComplete((loaded, th) -> {
if (th != null) nativeOnboardingFuture.completeExceptionally(th);
else nativeOnboardingFuture.complete(loaded);
});
} else {
nativeOnboardingFuture.complete(false);
}
}
));
return nativeOnboardingFuture;
}
/**
* Loads the gamepad mappings for both GLFW and SDL2.
* @param resource the already located `gamecontrollerdb.txt` resource
*/
private void loadGamepadMappings(Resource resource) {
try (InputStream is = resource.open()) {
byte[] bytes = ByteStreams.toByteArray(is);
ByteBuffer buffer = MemoryUtil.memASCIISafe(new String(bytes));
if (!GLFW.glfwUpdateGamepadMappings(buffer)) {
Log.LOGGER.error("GLFW failed to load gamepad mappings!");
}
try (Memory memory = new Memory(bytes.length)) {
memory.write(0, bytes, 0, bytes.length);
SDL_RWops rw = SDL_RWFromConstMem(memory, (int) memory.size());
int count = SDL_GameControllerAddMappingsFromRW(rw, 1);
if (count < 1) {
Log.LOGGER.error("SDL2 failed to load gamepad mappings!");
}
}
} catch (Exception e) {
Log.LOGGER.error("Failed to load gamecontrollerdb.txt", e);
}
}
/**
* The main loop of Controlify.
* In Controlify's current state, only the current controller is ticked.
*/
public void tick(Minecraft client) {
if (minecraft.getOverlay() == null) {
if (!calibrationQueue.isEmpty() && !(minecraft.screen instanceof SDLOnboardingScreen)) {
if (!calibrationQueue.isEmpty() && !(minecraft.screen instanceof DontInteruptScreen)) {
Screen screen = minecraft.screen;
while (!calibrationQueue.isEmpty()) {
screen = new ControllerCalibrationScreen(calibrationQueue.poll(), screen);
@ -372,70 +561,6 @@ public class Controlify implements ControlifyApi {
return config;
}
private void onControllerHotplugged(int jid) {
var controllerOpt = ControllerManager.createOrGet(jid, controllerHIDService.fetchType(jid));
if (controllerOpt.isEmpty()) return;
var controller = controllerOpt.get();
Log.LOGGER.info("Controller connected: " + controller.name());
config().loadOrCreateControllerData(controller);
if (SubmitUnknownControllerScreen.canSubmit(controller)) {
minecraft.setScreen(new SubmitUnknownControllerScreen(controller, minecraft.screen));
}
canDiscoverControllers = false;
if (config().globalSettings().delegateSetup) {
config().globalSettings().delegateSetup = false;
config().setDirty();
}
if (controller.config().allowVibrations && !config().globalSettings().loadVibrationNatives) {
controller.config().allowVibrations = false;
config().setDirty();
}
if (ControllerManager.getConnectedControllers().size() == 1) {
this.setCurrentController(controller);
ToastUtils.sendToast(
Component.translatable("controlify.toast.default_controller_connected.title"),
Component.translatable("controlify.toast.default_controller_connected.description"),
false
);
} else {
this.askToSwitchController(controller);
config().saveIfDirty();
}
if (minecraft.screen instanceof ControllerCarouselScreen controllerListScreen) {
controllerListScreen.refreshControllers();
}
}
private void onControllerDisconnect(int jid) {
ControllerManager.getConnectedControllers().stream().filter(controller -> controller.joystickId() == jid).findAny().ifPresent(controller -> {
ControllerManager.disconnect(controller);
controller.hidInfo().ifPresent(controllerHIDService::unconsumeController);
setCurrentController(ControllerManager.getConnectedControllers().stream().findFirst().orElse(null));
Log.LOGGER.info("Controller disconnected: " + controller.name());
this.setInputMode(currentController == null ? InputMode.KEYBOARD_MOUSE : InputMode.CONTROLLER);
ToastUtils.sendToast(
Component.translatable("controlify.toast.controller_disconnected.title"),
Component.translatable("controlify.toast.controller_disconnected.description", controller.name()),
false
);
if (minecraft.screen instanceof ControllerCarouselScreen controllerListScreen) {
controllerListScreen.refreshControllers();
}
});
}
private void askToSwitchController(Controller<?, ?> controller) {
this.switchableController = controller;
this.askSwitchTime = Blaze3D.getTime();

View File

@ -164,14 +164,16 @@ public class ControlifyConfig {
}
}
public void loadOrCreateControllerData(Controller<?, ?> controller) {
public boolean loadOrCreateControllerData(Controller<?, ?> controller) {
var uid = controller.uid();
if (controllerData.has(uid)) {
DebugLog.log("Loading controller data for " + uid);
applyControllerConfig(controller, controllerData.getAsJsonObject(uid));
return true;
} else {
DebugLog.log("New controller found, setting config dirty ({})", uid);
setDirty();
return false;
}
}

View File

@ -2,11 +2,11 @@ package dev.isxander.controlify.config;
import com.terraformersmc.modmenu.api.ConfigScreenFactory;
import com.terraformersmc.modmenu.api.ModMenuApi;
import dev.isxander.controlify.gui.screen.ControllerCarouselScreen;
import dev.isxander.controlify.gui.screen.ModConfigOpenerScreen;
public class ModMenuIntegration implements ModMenuApi {
@Override
public ConfigScreenFactory<?> getModConfigScreenFactory() {
return ControllerCarouselScreen::createConfigScreen;
return ModConfigOpenerScreen::new;
}
}

View File

@ -50,6 +50,12 @@ public interface Controller<S extends ControllerState, C extends ControllerConfi
return true;
}
int axisCount();
int buttonCount();
int hatCount();
@Deprecated
Controller<?, ?> DUMMY = new Controller<>() {
private final ControllerBindings<ControllerState> bindings = new ControllerBindings<>(this);
@ -165,5 +171,20 @@ public interface Controller<S extends ControllerState, C extends ControllerConfi
public boolean supportsRumble() {
return false;
}
@Override
public int axisCount() {
return 0;
}
@Override
public int buttonCount() {
return 0;
}
@Override
public int hatCount() {
return 0;
}
};
}

View File

@ -57,7 +57,7 @@ public record ControllerType(String friendlyName, String mappingId, String theme
String friendlyName = null;
String legacyIdentifier = null;
String themeId = null;
String mappingId = null;
String mappingId = "unmapped";
boolean forceJoystick = false;
boolean dontLoad = false;
Set<HIDIdentifier> hids = new HashSet<>();

View File

@ -149,4 +149,19 @@ public class GamepadController extends AbstractController<GamepadState, GamepadC
String theme = config().theme == BuiltinGamepadTheme.DEFAULT ? type().themeId() : config().theme.id();
return Controlify.id("textures/gui/gamepad/" + theme + "/icon.png");
}
@Override
public int axisCount() {
return 6;
}
@Override
public int buttonCount() {
return 15;
}
@Override
public int hatCount() {
return 0;
}
}

View File

@ -4,11 +4,11 @@ import dev.isxander.controlify.controller.ControllerConfig;
import dev.isxander.controlify.controller.joystick.mapping.JoystickMapping;
import org.apache.commons.lang3.Validate;
import java.util.HashMap;
import java.util.Map;
import java.util.*;
public class JoystickConfig extends ControllerConfig {
private Map<String, Float> deadzones;
private Set<Integer> triggerAxes = new HashSet<>();
private transient JoystickController<?> controller;
@ -33,8 +33,27 @@ public class JoystickConfig extends ControllerConfig {
return deadzones.getOrDefault(controller.mapping().axes()[axis].identifier(), 0.2f);
}
public boolean isTriggerAxis(int axis) {
if (axis < 0)
throw new IllegalArgumentException("Axis cannot be negative!");
return triggerAxes.contains(axis);
}
public void setTriggerAxis(int axis, boolean isTrigger) {
if (axis < 0)
throw new IllegalArgumentException("Axis cannot be negative!");
if (isTrigger) {
triggerAxes.add(axis);
} else {
triggerAxes.remove(axis);
}
}
void setup(JoystickController<?> controller) {
this.controller = controller;
if (this.deadzones == null) {
deadzones = new HashMap<>();
for (int i = 0; i < controller.mapping().axes().length; i++) {

View File

@ -16,13 +16,6 @@ public interface JoystickController<T extends JoystickConfig> extends Controller
return Controlify.id("textures/gui/joystick/icon.png");
}
@Deprecated
int axisCount();
@Deprecated
int buttonCount();
@Deprecated
int hatCount();
@Override
default boolean canBeUsed() {
return !(mapping() instanceof UnmappedJoystickMapping);

View File

@ -4,22 +4,25 @@ import com.google.gson.Gson;
import com.google.gson.JsonElement;
import dev.isxander.controlify.bindings.ControllerBindings;
import dev.isxander.controlify.controller.AbstractController;
import dev.isxander.controlify.controller.joystick.mapping.UnmappedJoystickMapping;
import dev.isxander.controlify.hid.ControllerHIDService;
import dev.isxander.controlify.controller.joystick.mapping.RPJoystickMapping;
import dev.isxander.controlify.controller.joystick.mapping.JoystickMapping;
import dev.isxander.controlify.controller.sdl2.SDL2NativesManager;
import dev.isxander.controlify.driver.SDL2NativesManager;
import dev.isxander.controlify.rumble.RumbleManager;
import dev.isxander.controlify.rumble.RumbleSource;
import dev.isxander.controlify.utils.Log;
import org.libsdl.SDL;
import io.github.libsdl4j.api.joystick.SDL_Joystick;
import java.util.Objects;
import static io.github.libsdl4j.api.error.SdlError.*;
import static io.github.libsdl4j.api.joystick.SdlJoystick.*;
public class SingleJoystickController extends AbstractController<JoystickState, JoystickConfig> implements JoystickController<JoystickConfig> {
private JoystickState state = JoystickState.EMPTY, prevState = JoystickState.EMPTY;
private final JoystickMapping mapping;
private final long ptrJoystick;
private final SDL_Joystick ptrJoystick;
private RumbleManager rumbleManager;
private boolean rumbleSupported;
@ -31,8 +34,8 @@ public class SingleJoystickController extends AbstractController<JoystickState,
this.config = new JoystickConfig(this);
this.defaultConfig = new JoystickConfig(this);
this.ptrJoystick = SDL2NativesManager.isLoaded() ? SDL.SDL_JoystickOpen(joystickId) : 0;
this.rumbleSupported = SDL2NativesManager.isLoaded() && SDL.SDL_JoystickHasRumble(this.ptrJoystick);
this.ptrJoystick = SDL2NativesManager.isLoaded() ? SDL_JoystickOpen(joystickId) : new SDL_Joystick();
this.rumbleSupported = SDL2NativesManager.isLoaded() && SDL_JoystickHasRumble(this.ptrJoystick);
this.rumbleManager = new RumbleManager(this);
this.bindings = new ControllerBindings<>(this);
@ -91,6 +94,16 @@ public class SingleJoystickController extends AbstractController<JoystickState,
public void setConfig(Gson gson, JsonElement json) {
super.setConfig(gson, json);
this.config.setup(this);
if (mapping() instanceof UnmappedJoystickMapping unmapped) {
for (int i = 0; i < unmapped.axes().length; i++) {
unmapped.axes()[i].setTriggerAxis(this.config.isTriggerAxis(i));
}
} else {
for (int i = 0; i < mapping().axes().length; i++) {
this.config.setTriggerAxis(i, false);
}
}
}
@Override
@ -99,8 +112,8 @@ public class SingleJoystickController extends AbstractController<JoystickState,
// the duration doesn't matter because we are not updating the joystick state,
// so there is never any SDL check to stop the rumble after the desired time.
if (!SDL.SDL_JoystickRumbleTriggers(ptrJoystick, (int)(strongMagnitude * 65535.0F), (int)(weakMagnitude * 65535.0F), 1)) {
Log.LOGGER.error("Could not rumble controller " + name() + ": " + SDL.SDL_GetError());
if (SDL_JoystickRumbleTriggers(ptrJoystick, (short)(strongMagnitude * 65535.0F), (short)(weakMagnitude * 65535.0F), 1) != 0) {
Log.LOGGER.error("Could not rumble controller " + name() + ": " + SDL_GetError());
return false;
}
return true;
@ -118,9 +131,11 @@ public class SingleJoystickController extends AbstractController<JoystickState,
@Override
public void close() {
if (ptrJoystick != 0)
SDL.SDL_JoystickClose(ptrJoystick);
if (!ptrJoystick.equals(new SDL_Joystick()))
SDL_JoystickClose(ptrJoystick);
this.rumbleSupported = false;
this.rumbleManager = null;
}
}

View File

@ -257,7 +257,7 @@ public class RPJoystickMapping implements JoystickMapping {
public static JoystickMapping fromType(JoystickController<?> joystick) {
var resource = Minecraft.getInstance().getResourceManager().getResource(new ResourceLocation("controlify", "mappings/" + joystick.type().mappingId() + ".json"));
if (resource.isEmpty()) {
Log.LOGGER.warn("No joystick mapping found for controller: '" + joystick.type().mappingId() + "'");
Log.LOGGER.warn("No joystick mapping found for controller type: '{}' - using unmapped", joystick.type().mappingId());
return new UnmappedJoystickMapping(joystick.joystickId());
}

View File

@ -9,6 +9,8 @@ import org.lwjgl.glfw.GLFW;
import java.nio.ByteBuffer;
import java.nio.FloatBuffer;
import java.util.Arrays;
import java.util.Objects;
import java.util.Optional;
public class UnmappedJoystickMapping implements JoystickMapping {
@ -21,7 +23,7 @@ public class UnmappedJoystickMapping implements JoystickMapping {
private UnmappedJoystickMapping(int axisCount, int buttonCount, int hatCount) {
this.axes = new UnmappedAxis[axisCount];
for (int i = 0; i < axisCount; i++) {
this.axes[i] = new UnmappedAxis(i, new GenericRenderer.Axis(Integer.toString(i + 1)));
this.axes[i] = new UnmappedAxis(i, new GenericRenderer.Axis(Integer.toString(i + 1)), false);
}
this.buttons = new UnmappedButton[buttonCount];
@ -44,7 +46,7 @@ public class UnmappedJoystickMapping implements JoystickMapping {
}
@Override
public Axis[] axes() {
public UnmappedAxis[] axes() {
return axes;
}
@ -58,15 +60,32 @@ public class UnmappedJoystickMapping implements JoystickMapping {
return hats;
}
private record UnmappedAxis(int axis, GenericRenderer.Axis renderer) implements Axis {
public void setTriggerAxes(int axis, boolean triggerAxis) {
axes[axis].setTriggerAxis(triggerAxis);
}
public static final class UnmappedAxis implements Axis {
private final int axis;
private final GenericRenderer.Axis renderer;
private boolean triggerAxis;
private UnmappedAxis(int axis, GenericRenderer.Axis renderer, boolean triggerAxis) {
this.axis = axis;
this.renderer = renderer;
this.triggerAxis = triggerAxis;
}
@Override
public float getAxis(JoystickData data) {
return data.axes()[axis];
float value = data.axes()[axis];
if (triggerAxis)
value = (value + 1) / 2;
return value;
}
@Override
public String identifier() {
return "axis-" + axis;
return "axis-" + axis;
}
@Override
@ -76,7 +95,7 @@ public class UnmappedJoystickMapping implements JoystickMapping {
@Override
public boolean requiresDeadzone() {
return true;
return !triggerAxis;
}
@Override
@ -93,6 +112,45 @@ public class UnmappedJoystickMapping implements JoystickMapping {
public String getDirectionIdentifier(int axis, JoystickAxisBind.AxisDirection direction) {
return direction.name().toLowerCase();
}
public int index() {
return axis;
}
@Override
public GenericRenderer.Axis renderer() {
return renderer;
}
public boolean isTriggerAxis() {
return triggerAxis;
}
public void setTriggerAxis(boolean triggerAxis) {
this.triggerAxis = triggerAxis;
}
@Override
public boolean equals(Object obj) {
if (obj == this) return true;
if (obj == null || obj.getClass() != this.getClass()) return false;
var that = (UnmappedAxis) obj;
return this.axis == that.axis &&
Objects.equals(this.renderer, that.renderer);
}
@Override
public int hashCode() {
return Objects.hash(axis, renderer);
}
@Override
public String toString() {
return "UnmappedAxis[" +
"axis=" + axis + ", " +
"renderer=" + renderer + ']';
}
}
private record UnmappedButton(int button, GenericRenderer.Button renderer) implements Button {

View File

@ -1,163 +0,0 @@
package dev.isxander.controlify.controller.sdl2;
import dev.isxander.controlify.utils.DebugLog;
import dev.isxander.controlify.utils.Log;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.Util;
import org.libsdl.SDL;
import java.io.File;
import java.io.FileOutputStream;
import java.net.URL;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.channels.ReadableByteChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.Map;
import static org.libsdl.SDL_Hints.*;
public class SDL2NativesManager {
private static final String SDL2_VERSION = "<SDL2_VERSION>";
private static final Map<Target, String> NATIVE_LIBRARIES = Map.of(
new Target(Util.OS.WINDOWS, true, false), "windows64.dll",
new Target(Util.OS.WINDOWS, false, false), "window32.dll",
new Target(Util.OS.LINUX, true, false), "linux64.so",
new Target(Util.OS.OSX, true, false), "macosx64.dylib",
new Target(Util.OS.OSX, true, true), "macosxarm64.dylib"
);
private static final String NATIVE_LIBRARY_URL = "https://maven.isxander.dev/releases/dev/isxander/sdl2-jni-natives/%s/".formatted(SDL2_VERSION);
private static boolean loaded = false;
private static boolean initialised = false;
public static void initialise() {
if (initialised) return;
initialised = true;
DebugLog.log("Initialising SDL2 native library");
if (!Target.CURRENT.hasNativeLibrary()) {
Log.LOGGER.warn("SDL2 native library not available for OS: " + Target.CURRENT);
return;
}
Path localLibraryPath = Target.CURRENT.getLocalNativePath();
if (Files.notExists(localLibraryPath)) {
if (Files.exists(localLibraryPath.getParent())) {
try(var walk = Files.walk(localLibraryPath.getParent())) {
walk.sorted(Comparator.reverseOrder())
.map(Path::toFile)
.forEachOrdered(File::delete);
} catch (Exception e) {
Log.LOGGER.error("Failed to delete old SDL2 native library", e);
}
}
Log.LOGGER.info("Downloading SDL2 native library: " + Target.CURRENT.getArtifactName());
downloadLibrary(localLibraryPath);
}
try {
SDL.load(localLibraryPath);
startSDL2();
loaded = true;
} catch (Exception e) {
Log.LOGGER.error("Failed to load SDL2 native library", e);
}
}
private static void startSDL2() {
// we have no windows, so all events are background events
SDL.SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1");
// accelerometer as joystick is not good UX. unexpected
SDL.SDL_SetHint(SDL_HINT_ACCELEROMETER_AS_JOYSTICK, "0");
// see first hint
SDL.SDL_SetHint(SDL_HINT_MAC_BACKGROUND_APP, "1");
// raw input requires controller correlation, which is impossible
// without calling JoystickUpdate, which we don't do.
SDL.SDL_SetHint(SDL_HINT_JOYSTICK_RAWINPUT, "0");
// better rumble
SDL.SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI, "1");
SDL.SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS4_RUMBLE, "1");
SDL.SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS5_RUMBLE, "1");
SDL.SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_STEAM, "1");
int joystickSubsystem = 0x00000200; // implies event subsystem
int gameControllerSubsystem = 0x00002000; // implies event subsystem
if (SDL.SDL_Init(joystickSubsystem | gameControllerSubsystem) != 0) {
Log.LOGGER.error("Failed to initialise SDL2: " + SDL.SDL_GetError());
throw new RuntimeException("Failed to initialise SDL2: " + SDL.SDL_GetError());
}
DebugLog.log("Initialised SDL2");
}
private static boolean downloadLibrary(Path path) {
try {
Files.deleteIfExists(path);
Files.createDirectories(path.getParent());
Files.createFile(path);
} catch (Exception e) {
e.printStackTrace();
return false;
}
try(FileOutputStream fileOutputStream = new FileOutputStream(path.toFile())) {
String url = NATIVE_LIBRARY_URL + Target.CURRENT.getArtifactName();
URL downloadUrl = new URL(url);
ReadableByteChannel readableByteChannel = Channels.newChannel(downloadUrl.openStream());
FileChannel fileChannel = fileOutputStream.getChannel();
fileChannel.transferFrom(readableByteChannel, 0, Long.MAX_VALUE);
Log.LOGGER.info("Downloaded SDL2 native library from " + downloadUrl);
} catch (Exception e) {
e.printStackTrace();
return false;
}
return true;
}
public static boolean isLoaded() {
return loaded;
}
public static boolean isInitialised() {
return initialised;
}
public record Target(Util.OS os, boolean is64Bit, boolean isARM) {
public static final Target CURRENT = Util.make(() -> {
Util.OS os = Util.getPlatform();
String arch = System.getProperty("os.arch");
boolean is64bit = arch.contains("64");
boolean isARM = arch.contains("arm") || arch.contains("aarch");
return new Target(os, is64bit, isARM);
});
public boolean hasNativeLibrary() {
return NATIVE_LIBRARIES.containsKey(this);
}
public String getArtifactName() {
String suffix = NATIVE_LIBRARIES.get(Target.CURRENT);
return "sdl2-jni-natives-" + SDL2_VERSION + "-" + suffix;
}
public Path getLocalNativePath() {
return FabricLoader.getInstance().getGameDir()
.resolve("controlify-natives")
.resolve(getArtifactName());
}
public boolean isMacArm() {
return os == Util.OS.OSX && isARM;
}
}
}

View File

@ -10,21 +10,21 @@ public class DebugProperties {
private static final List<DebugProperty> properties = new ArrayList<>();
public static final boolean DEBUG_LOGGING = boolProp("controlify.debug.logging", false, true);
/* Print the VID and PID of every controller connected. */
/** Print the VID and PID of every controller connected. */
public static final boolean PRINT_VID_PID = boolProp("controlify.debug.print_vid_pid", false, true);
/* Renders debug overlay for vmouse snapping */
/** Renders debug overlay for vmouse snapping */
public static final boolean DEBUG_SNAPPING = boolProp("controlify.debug.snapping", false, false);
/* Forces all gamepads to be treated as a regular joystick */
/** Forces all gamepads to be treated as a regular joystick */
public static final boolean FORCE_JOYSTICK = boolProp("controlify.debug.force_joystick", false, false);
/* Prints joystick input counts for making joystick mappings */
/** Prints joystick input counts for making joystick mappings */
public static final boolean PRINT_JOY_STATE = boolProp("controlify.debug.print_joy_state", false, false);
/* Print gyro data if supported */
/** Print gyro data if supported */
public static final boolean PRINT_GYRO = boolProp("controlify.debug.print_gyro", false, false);
/* Print what drivers are being used */
/** 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 */
/** Print the state of the left and right triggers on gamepads */
public static final boolean PRINT_TRIGGER_STATE = boolProp("controlify.debug.print_trigger_state", false, false);
/* Use experimental anti-snapback */
/** Use experimental anti-snapback */
public static final boolean USE_SNAPBACK = boolProp("controlify.debug.use_snapback", false, false);
public static void printProperties() {

View File

@ -1,7 +1,6 @@
package dev.isxander.controlify.driver;
import com.google.common.collect.Sets;
import dev.isxander.controlify.controller.sdl2.SDL2NativesManager;
import dev.isxander.controlify.debug.DebugProperties;
import dev.isxander.controlify.hid.HIDDevice;
import dev.isxander.controlify.utils.Log;
@ -45,7 +44,6 @@ public record GamepadDrivers(BasicGamepadInputDriver basicGamepadInputDriver, Gy
gyroDriver = sdl2Driver;
rumbleDriver = sdl2Driver;
batteryDriver = sdl2Driver;
// SDL2 bypasses XInput abstraction
guidProviderDriver = sdl2Driver;
}

View File

@ -1,27 +1,37 @@
package dev.isxander.controlify.driver;
import com.sun.jna.Memory;
import com.sun.jna.Pointer;
import dev.isxander.controlify.controller.BatteryLevel;
import dev.isxander.controlify.controller.gamepad.GamepadState;
import dev.isxander.controlify.debug.DebugProperties;
import dev.isxander.controlify.utils.Log;
import io.github.libsdl4j.api.gamecontroller.SDL_GameController;
import net.minecraft.util.Mth;
import org.libsdl.SDL;
import static io.github.libsdl4j.api.error.SdlError.*;
import static io.github.libsdl4j.api.gamecontroller.SDL_GameControllerAxis.*;
import static io.github.libsdl4j.api.gamecontroller.SDL_GameControllerButton.*;
import static io.github.libsdl4j.api.gamecontroller.SdlGamecontroller.*;
import static io.github.libsdl4j.api.joystick.SDL_JoystickPowerLevel.*;
import static io.github.libsdl4j.api.joystick.SdlJoystick.*;
import static io.github.libsdl4j.api.sensor.SDL_SensorType.*;
public class SDL2GamepadDriver implements BasicGamepadInputDriver, GyroDriver, RumbleDriver, BatteryDriver, GUIDProvider {
private final long ptrGamepad;
private final SDL_GameController ptrGamepad;
private BasicGamepadState state = BasicGamepadState.EMPTY;
private GamepadState.GyroState gyroDelta = new GamepadState.GyroState(0, 0, 0);
private final boolean isGyroSupported, isRumbleSupported;
private final String guid;
public SDL2GamepadDriver(int jid) {
this.ptrGamepad = SDL.SDL_GameControllerOpen(jid);
this.guid = SDL.SDL_JoystickGUIDString(SDL.SDL_GameControllerGetJoystick(ptrGamepad));
this.isGyroSupported = SDL.SDL_GameControllerHasSensor(ptrGamepad, SDL.SDL_SENSOR_GYRO);
this.isRumbleSupported = SDL.SDL_GameControllerHasRumble(ptrGamepad);
this.ptrGamepad = SDL_GameControllerOpen(jid);
this.guid = SDL_JoystickGetGUID(SDL_GameControllerGetJoystick(ptrGamepad)).toString();
this.isGyroSupported = SDL_GameControllerHasSensor(ptrGamepad, SDL_SENSOR_GYRO);
this.isRumbleSupported = SDL_GameControllerHasRumble(ptrGamepad);
if (this.isGyroSupported()) {
SDL.SDL_GameControllerSetSensorEnabled(ptrGamepad, SDL.SDL_SENSOR_GYRO, true);
SDL_GameControllerSetSensorEnabled(ptrGamepad, SDL_SENSOR_GYRO, true);
}
}
@ -29,45 +39,50 @@ public class SDL2GamepadDriver implements BasicGamepadInputDriver, GyroDriver, R
public void update() {
if (isGyroSupported()) {
float[] gyro = new float[3];
if (SDL.SDL_GameControllerGetSensorData(ptrGamepad, SDL.SDL_SENSOR_GYRO, gyro, 3) == 0) {
gyroDelta = new GamepadState.GyroState(gyro[0], gyro[1], gyro[2]);
if (DebugProperties.PRINT_GYRO) Log.LOGGER.info("Gyro delta: " + gyroDelta);
} else {
Log.LOGGER.error("Could not get gyro data: " + SDL.SDL_GetError());
try (Memory memory = new Memory(gyro.length * Float.BYTES)) {
if (SDL_GameControllerGetSensorData(ptrGamepad, SDL_SENSOR_GYRO, memory, 3) == 0) {
memory.read(0, gyro, 0, gyro.length);
gyroDelta = new GamepadState.GyroState(gyro[0], gyro[1], gyro[2]);
if (DebugProperties.PRINT_GYRO) Log.LOGGER.info("Gyro delta: " + gyroDelta);
} else {
Log.LOGGER.error("Could not get gyro data: " + SDL_GetError());
}
}
}
SDL.SDL_GameControllerUpdate();
SDL_GameControllerUpdate();
// Axis values are in the range [-32768, 32767] (short)
// 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.SDL_GameControllerGetAxis(ptrGamepad, SDL.SDL_CONTROLLER_AXIS_LEFTX), Short.MIN_VALUE, Short.MAX_VALUE) * 2f - 1f,
Mth.inverseLerp(SDL.SDL_GameControllerGetAxis(ptrGamepad, SDL.SDL_CONTROLLER_AXIS_LEFTY), Short.MIN_VALUE, Short.MAX_VALUE) * 2f - 1f,
Mth.inverseLerp(SDL.SDL_GameControllerGetAxis(ptrGamepad, SDL.SDL_CONTROLLER_AXIS_RIGHTX), Short.MIN_VALUE, Short.MAX_VALUE) * 2f - 1f,
Mth.inverseLerp(SDL.SDL_GameControllerGetAxis(ptrGamepad, SDL.SDL_CONTROLLER_AXIS_RIGHTY), Short.MIN_VALUE, Short.MAX_VALUE) * 2f - 1f,
Mth.inverseLerp(SDL.SDL_GameControllerGetAxis(ptrGamepad, SDL.SDL_CONTROLLER_AXIS_TRIGGERLEFT), 0, Short.MAX_VALUE),
Mth.inverseLerp(SDL.SDL_GameControllerGetAxis(ptrGamepad, SDL.SDL_CONTROLLER_AXIS_TRIGGERRIGHT), 0, Short.MAX_VALUE)
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)
);
// Button values return 1 if pressed, 0 if not
// https://wiki.libsdl.org/SDL2/SDL_GameControllerGetButton
GamepadState.ButtonState buttons = new GamepadState.ButtonState(
SDL.SDL_GameControllerGetButton(ptrGamepad, SDL.SDL_CONTROLLER_BUTTON_A) == 1,
SDL.SDL_GameControllerGetButton(ptrGamepad, SDL.SDL_CONTROLLER_BUTTON_B) == 1,
SDL.SDL_GameControllerGetButton(ptrGamepad, SDL.SDL_CONTROLLER_BUTTON_X) == 1,
SDL.SDL_GameControllerGetButton(ptrGamepad, SDL.SDL_CONTROLLER_BUTTON_Y) == 1,
SDL.SDL_GameControllerGetButton(ptrGamepad, SDL.SDL_CONTROLLER_BUTTON_LEFTSHOULDER) == 1,
SDL.SDL_GameControllerGetButton(ptrGamepad, SDL.SDL_CONTROLLER_BUTTON_RIGHTSHOULDER) == 1,
SDL.SDL_GameControllerGetButton(ptrGamepad, SDL.SDL_CONTROLLER_BUTTON_BACK) == 1,
SDL.SDL_GameControllerGetButton(ptrGamepad, SDL.SDL_CONTROLLER_BUTTON_START) == 1,
SDL.SDL_GameControllerGetButton(ptrGamepad, SDL.SDL_CONTROLLER_BUTTON_GUIDE) == 1,
SDL.SDL_GameControllerGetButton(ptrGamepad, SDL.SDL_CONTROLLER_BUTTON_DPAD_UP) == 1,
SDL.SDL_GameControllerGetButton(ptrGamepad, SDL.SDL_CONTROLLER_BUTTON_DPAD_DOWN) == 1,
SDL.SDL_GameControllerGetButton(ptrGamepad, SDL.SDL_CONTROLLER_BUTTON_DPAD_LEFT) == 1,
SDL.SDL_GameControllerGetButton(ptrGamepad, SDL.SDL_CONTROLLER_BUTTON_DPAD_RIGHT) == 1,
SDL.SDL_GameControllerGetButton(ptrGamepad, SDL.SDL_CONTROLLER_BUTTON_LEFTSTICK) == 1,
SDL.SDL_GameControllerGetButton(ptrGamepad, SDL.SDL_CONTROLLER_BUTTON_RIGHTSTICK) == 1
SDL_GameControllerGetButton(ptrGamepad, SDL_CONTROLLER_BUTTON_A) == 1,
SDL_GameControllerGetButton(ptrGamepad, SDL_CONTROLLER_BUTTON_B) == 1,
SDL_GameControllerGetButton(ptrGamepad, SDL_CONTROLLER_BUTTON_X) == 1,
SDL_GameControllerGetButton(ptrGamepad, SDL_CONTROLLER_BUTTON_Y) == 1,
SDL_GameControllerGetButton(ptrGamepad, SDL_CONTROLLER_BUTTON_LEFTSHOULDER) == 1,
SDL_GameControllerGetButton(ptrGamepad, SDL_CONTROLLER_BUTTON_RIGHTSHOULDER) == 1,
SDL_GameControllerGetButton(ptrGamepad, SDL_CONTROLLER_BUTTON_BACK) == 1,
SDL_GameControllerGetButton(ptrGamepad, SDL_CONTROLLER_BUTTON_START) == 1,
SDL_GameControllerGetButton(ptrGamepad, SDL_CONTROLLER_BUTTON_GUIDE) == 1,
SDL_GameControllerGetButton(ptrGamepad, SDL_CONTROLLER_BUTTON_DPAD_UP) == 1,
SDL_GameControllerGetButton(ptrGamepad, SDL_CONTROLLER_BUTTON_DPAD_DOWN) == 1,
SDL_GameControllerGetButton(ptrGamepad, SDL_CONTROLLER_BUTTON_DPAD_LEFT) == 1,
SDL_GameControllerGetButton(ptrGamepad, SDL_CONTROLLER_BUTTON_DPAD_RIGHT) == 1,
SDL_GameControllerGetButton(ptrGamepad, SDL_CONTROLLER_BUTTON_LEFTSTICK) == 1,
SDL_GameControllerGetButton(ptrGamepad, SDL_CONTROLLER_BUTTON_RIGHTSTICK) == 1
);
this.state = new BasicGamepadState(axes, buttons);
}
@ -80,8 +95,8 @@ public class SDL2GamepadDriver implements BasicGamepadInputDriver, GyroDriver, R
@Override
public boolean rumble(float strongMagnitude, float weakMagnitude) {
// duration of 0 is infinite
if (!SDL.SDL_GameControllerRumble(ptrGamepad, (int)(strongMagnitude * 65535.0F), (int)(weakMagnitude * 65535.0F), 0)) {
Log.LOGGER.error("Could not rumble controller: " + SDL.SDL_GetError());
if (SDL_GameControllerRumble(ptrGamepad, (short)(strongMagnitude * 65535.0F), (short)(weakMagnitude * 65535.0F), 0) != 0) {
Log.LOGGER.error("Could not rumble controller: " + SDL_GetError());
return false;
}
return true;
@ -94,15 +109,16 @@ public class SDL2GamepadDriver implements BasicGamepadInputDriver, GyroDriver, R
@Override
public BatteryLevel getBatteryLevel() {
return switch (SDL.SDL_JoystickCurrentPowerLevel(ptrGamepad)) {
case SDL.SDL_JOYSTICK_POWER_UNKNOWN -> BatteryLevel.UNKNOWN;
case SDL.SDL_JOYSTICK_POWER_EMPTY -> BatteryLevel.EMPTY;
case SDL.SDL_JOYSTICK_POWER_LOW -> BatteryLevel.LOW;
case SDL.SDL_JOYSTICK_POWER_MEDIUM -> BatteryLevel.MEDIUM;
case SDL.SDL_JOYSTICK_POWER_FULL -> BatteryLevel.FULL;
case SDL.SDL_JOYSTICK_POWER_WIRED -> BatteryLevel.WIRED;
case SDL.SDL_JOYSTICK_POWER_MAX -> BatteryLevel.MAX;
default -> throw new IllegalStateException("Unexpected value: " + SDL.SDL_JoystickCurrentPowerLevel(ptrGamepad));
int powerLevel = SDL_JoystickCurrentPowerLevel(SDL_GameControllerGetJoystick(ptrGamepad));
return switch (powerLevel) {
case SDL_JOYSTICK_POWER_UNKNOWN -> BatteryLevel.UNKNOWN;
case SDL_JOYSTICK_POWER_EMPTY -> BatteryLevel.EMPTY;
case SDL_JOYSTICK_POWER_LOW -> BatteryLevel.LOW;
case SDL_JOYSTICK_POWER_MEDIUM -> BatteryLevel.MEDIUM;
case SDL_JOYSTICK_POWER_FULL -> BatteryLevel.FULL;
case SDL_JOYSTICK_POWER_WIRED -> BatteryLevel.WIRED;
case SDL_JOYSTICK_POWER_MAX -> BatteryLevel.MAX;
default -> throw new IllegalStateException("Unexpected value: " + powerLevel);
};
}
@ -123,7 +139,7 @@ public class SDL2GamepadDriver implements BasicGamepadInputDriver, GyroDriver, R
@Override
public void close() {
SDL.SDL_GameControllerClose(ptrGamepad);
SDL_GameControllerClose(ptrGamepad);
}
@Override

View File

@ -1,15 +1,18 @@
package dev.isxander.controlify.driver;
import dev.isxander.controlify.utils.Log;
import org.libsdl.SDL;
import io.github.libsdl4j.api.joystick.SDL_Joystick;
import static io.github.libsdl4j.api.error.SdlError.*;
import static io.github.libsdl4j.api.joystick.SdlJoystick.*;
public class SDL2JoystickDriver implements RumbleDriver {
private final long ptrJoystick;
private final SDL_Joystick ptrJoystick;
private final boolean isRumbleSupported;
public SDL2JoystickDriver(int jid) {
this.ptrJoystick = SDL.SDL_JoystickOpen(jid);
this.isRumbleSupported = SDL.SDL_JoystickHasRumble(ptrJoystick);
this.ptrJoystick = SDL_JoystickOpen(jid);
this.isRumbleSupported = SDL_JoystickHasRumble(ptrJoystick);
}
@Override
@ -20,8 +23,8 @@ public class SDL2JoystickDriver implements RumbleDriver {
@Override
public boolean rumble(float strongMagnitude, float weakMagnitude) {
// duration of 0 is infinite
if (!SDL.SDL_JoystickRumble(ptrJoystick, (int)(strongMagnitude * 65535.0F), (int)(weakMagnitude * 65535.0F), 0)) {
Log.LOGGER.error("Could not rumble controller: " + SDL.SDL_GetError());
if (SDL_JoystickRumble(ptrJoystick, (short)(strongMagnitude * 65535.0F), (short)(weakMagnitude * 65535.0F), 0) != 0) {
Log.LOGGER.error("Could not rumble controller: " + SDL_GetError());
return false;
}
return true;
@ -39,6 +42,6 @@ public class SDL2JoystickDriver implements RumbleDriver {
@Override
public void close() {
SDL.SDL_JoystickClose(ptrJoystick);
SDL_JoystickClose(ptrJoystick);
}
}

View File

@ -0,0 +1,232 @@
package dev.isxander.controlify.driver;
import dev.isxander.controlify.Controlify;
import dev.isxander.controlify.gui.screen.DownloadingSDLScreen;
import dev.isxander.controlify.utils.DebugLog;
import dev.isxander.controlify.utils.Log;
import dev.isxander.controlify.utils.TrackingBodySubscriber;
import dev.isxander.controlify.utils.TrackingConsumer;
import io.github.libsdl4j.jna.SdlNativeLibraryLoader;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import org.apache.commons.lang3.Validate;
import java.io.File;
import java.io.FileOutputStream;
import java.net.URI;
import java.net.URL;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.channels.ReadableByteChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Comparator;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
import static io.github.libsdl4j.api.Sdl.*;
import static io.github.libsdl4j.api.SdlSubSystemConst.*;
import static io.github.libsdl4j.api.error.SdlError.*;
import static io.github.libsdl4j.api.hints.SdlHints.*;
import static io.github.libsdl4j.api.hints.SdlHintsConst.*;
public class SDL2NativesManager {
private static final String SDL2_VERSION = "<SDL2_VERSION>";
private static final Map<Target, NativeFileInfo> NATIVE_LIBRARIES = Map.of(
new Target(Util.OS.WINDOWS, true, false), new NativeFileInfo("win32-x86-64", "windows64", "dll"),
new Target(Util.OS.WINDOWS, false, false), new NativeFileInfo("win32-x86", "window32", "dll"),
new Target(Util.OS.LINUX, true, false), new NativeFileInfo("linux-x86-64", "linux64", "so"),
new Target(Util.OS.OSX, true, false), new NativeFileInfo("darwin-x86-64", "macos-x86_64", "dylib"),
new Target(Util.OS.OSX, true, true), new NativeFileInfo("darwin-aarch64", "macos-aarch64", "dylib")
);
private static final String NATIVE_LIBRARY_URL = "https://maven.isxander.dev/releases/dev/isxander/libsdl4j-natives/%s/".formatted(SDL2_VERSION);
private static final Path NATIVES_PATH = FabricLoader.getInstance().getGameDir().resolve("controlify-natives");
private static boolean loaded = false;
private static boolean attemptedLoad = false;
private static CompletableFuture<Boolean> initFuture;
public static CompletableFuture<Boolean> maybeLoad() {
if (initFuture != null)
return initFuture;
if (!Controlify.instance().config().globalSettings().loadVibrationNatives)
return initFuture = CompletableFuture.completedFuture(false);
if (attemptedLoad)
return initFuture = CompletableFuture.completedFuture(loaded);
attemptedLoad = true;
Path localLibraryPath = NATIVES_PATH.resolve(Target.CURRENT.getArtifactName());
if (Files.exists(localLibraryPath)) {
boolean success = loadAndStart(localLibraryPath);
if (success)
return initFuture = CompletableFuture.completedFuture(true);
Log.LOGGER.warn("Failed to load SDL2 from local file, attempting to re-download");
}
return initFuture = downloadAndStart(localLibraryPath);
}
private static boolean loadAndStart(Path localLibraryPath) {
try {
SdlNativeLibraryLoader.loadLibSDL2FromFilePathNow(localLibraryPath.toAbsolutePath().toString());
startSDL2();
loaded = true;
return true;
} catch (Throwable e) {
Log.LOGGER.error("Failed to start SDL2", e);
return false;
}
}
private static void startSDL2() {
// we have no windows, so all events are background events
SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1");
// accelerometer as joystick is not good UX. unexpected
SDL_SetHint(SDL_HINT_ACCELEROMETER_AS_JOYSTICK, "0");
// see first hint
SDL_SetHint(SDL_HINT_MAC_BACKGROUND_APP, "1");
// raw input requires controller correlation, which is impossible
// without calling JoystickUpdate, which we don't do.
SDL_SetHint(SDL_HINT_JOYSTICK_RAWINPUT, "0");
// better rumble
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI, "1");
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS4_RUMBLE, "1");
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS5_RUMBLE, "1");
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_STEAM, "1");
// initialise SDL with just joystick and gamecontroller subsystems
if (SDL_Init(SDL_INIT_JOYSTICK | SDL_INIT_GAMECONTROLLER) != 0) {
Log.LOGGER.error("Failed to initialise SDL2: " + SDL_GetError());
throw new RuntimeException("Failed to initialise SDL2: " + SDL_GetError());
}
DebugLog.log("Initialised SDL2");
}
private static CompletableFuture<Boolean> downloadAndStart(Path localLibraryPath) {
return downloadLibrary()
.thenCompose(success -> {
if (!success) {
return CompletableFuture.completedFuture(false);
}
return CompletableFuture.completedFuture(loadAndStart(localLibraryPath));
})
.thenCompose(success -> Minecraft.getInstance().submit(() -> success));
}
private static CompletableFuture<Boolean> downloadLibrary() {
Path path = NATIVES_PATH.resolve(Target.CURRENT.getArtifactName());
try {
Files.deleteIfExists(path);
Files.createDirectories(path.getParent());
Files.createFile(path);
} catch (Exception e) {
Log.LOGGER.error("Failed to delete existing SDL2 native library file", e);
return CompletableFuture.completedFuture(false);
}
String url = NATIVE_LIBRARY_URL + Target.CURRENT.getArtifactName();
var httpClient = HttpClient.newHttpClient();
var httpRequest = HttpRequest.newBuilder()
.GET()
.uri(URI.create(url))
.build();
// send the request asynchronously and track the progress on the download
AtomicReference<DownloadingSDLScreen> downloadScreen = new AtomicReference<>();
Minecraft minecraft = Minecraft.getInstance();
return httpClient.sendAsync(
httpRequest,
TrackingBodySubscriber.bodyHandler(
HttpResponse.BodyHandlers.ofFileDownload(path.getParent(), StandardOpenOption.WRITE),
new TrackingConsumer(
total -> {
DownloadingSDLScreen screen = new DownloadingSDLScreen(minecraft.screen, total, path);
downloadScreen.set(screen);
minecraft.execute(() -> minecraft.setScreen(screen));
},
(received, total) -> downloadScreen.get().updateDownloadProgress(received),
error -> {
if (error.isPresent()) {
Log.LOGGER.error("Failed to download SDL2 native library", error.get());
minecraft.execute(() -> downloadScreen.get().failDownload(error.get()));
} else {
Log.LOGGER.debug("Finished downloading SDL2 native library");
minecraft.execute(() -> downloadScreen.get().finishDownload());
}
}
)
)
).handle((response, throwable) -> {
if (throwable != null) {
Log.LOGGER.error("Failed to download SDL2 native library", throwable);
return false;
}
return true;
});
}
public static boolean isLoaded() {
return loaded;
}
public static boolean hasAttemptedLoad() {
return attemptedLoad;
}
public record Target(Util.OS os, boolean is64Bit, boolean isARM) {
public static final Target CURRENT = Util.make(() -> {
Util.OS os = Util.getPlatform();
String arch = System.getProperty("os.arch");
boolean is64bit = arch.contains("64");
boolean isARM = arch.contains("arm") || arch.contains("aarch");
return new Target(os, is64bit, isARM);
});
public boolean hasNativeLibrary() {
return NATIVE_LIBRARIES.containsKey(this);
}
public String getArtifactName() {
NativeFileInfo file = NATIVE_LIBRARIES.get(this);
return "libsdl4j-natives-" + SDL2_VERSION + "-" + file.downloadSuffix + "." + file.fileExtension;
}
public boolean isMacArm() {
return os == Util.OS.OSX && isARM;
}
}
public record NativeFileInfo(String folderName, String downloadSuffix, String fileExtension) {
public Path getNativePath() {
return getSearchPath()
.resolve(folderName)
.resolve("SDL2." + fileExtension);
}
public Path getSearchPath() {
return FabricLoader.getInstance().getGameDir()
.resolve("controlify-natives")
.resolve(SDL2_VERSION);
}
}
}

View File

@ -1,7 +1,9 @@
package dev.isxander.controlify.fixes.boatfix;
import dev.isxander.controlify.mixins.feature.patches.boatfix.BoatMixin;
/**
* @see dev.isxander.controlify.mixins.feature.fixes.boatfix.BoatMixin
* @see BoatMixin
*/
public interface AnalogBoatInput {
void setAnalogInput(float forward, float right);

View File

@ -5,6 +5,8 @@ import dev.isxander.controlify.ControllerManager;
import dev.isxander.controlify.controller.Controller;
import dev.isxander.controlify.controller.gamepad.GamepadController;
import dev.isxander.controlify.controller.gamepad.GamepadState;
import dev.isxander.controlify.controller.joystick.JoystickController;
import dev.isxander.controlify.controller.joystick.mapping.UnmappedJoystickMapping;
import net.minecraft.ChatFormatting;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
@ -15,11 +17,17 @@ import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;
public class ControllerCalibrationScreen extends Screen {
/**
* Controller calibration screen does a few things:
* <ul>
* <li>Calculates deadzones</li>
* <li>Does gyroscope calibration</li>
* <li>Detects triggers on unmapped joysticks</li>
* </ul>
*/
public class ControllerCalibrationScreen extends Screen implements DontInteruptScreen {
private static final int CALIBRATION_TIME = 100;
private static final ResourceLocation GUI_BARS_LOCATION = new ResourceLocation("textures/gui/bars.png");
@ -33,7 +41,7 @@ public class ControllerCalibrationScreen extends Screen {
protected boolean calibrating = false, calibrated = false;
protected int calibrationTicks = 0;
private final Map<Integer, double[]> deadzoneCalibration = new HashMap<>();
private final double[] axisData;
private GamepadState.GyroState accumulatedGyroVelocity = new GamepadState.GyroState();
public ControllerCalibrationScreen(Controller<?, ?> controller, Screen parent) {
@ -44,6 +52,7 @@ public class ControllerCalibrationScreen extends Screen {
super(Component.translatable("controlify.calibration.title"));
this.controller = controller;
this.parent = parent;
this.axisData = new double[controller.axisCount() * CALIBRATION_TIME];
}
@Override
@ -125,17 +134,17 @@ public class ControllerCalibrationScreen extends Screen {
if (stateChanged()) {
calibrationTicks = 0;
deadzoneCalibration.clear();
Arrays.fill(axisData, 0);
accumulatedGyroVelocity = new GamepadState.GyroState();
}
if (calibrationTicks < CALIBRATION_TIME) {
processDeadzoneData(calibrationTicks);
processAxisData(calibrationTicks);
processGyroData();
calibrationTicks++;
} else {
applyDeadzones();
calibrateAxis();
generateGyroCalibration();
calibrating = false;
@ -145,17 +154,16 @@ public class ControllerCalibrationScreen extends Screen {
controller.config().deadzonesCalibrated = true;
controller.config().delayedCalibration = false;
Controlify.instance().config().save();
// no need to save because of setCurrentController
Controlify.instance().setCurrentController(controller);
}
}
private void processDeadzoneData(int tick) {
private void processAxisData(int tick) {
var axes = controller.state().rawAxes();
for (int i = 0; i < axes.size(); i++) {
var axis = Math.abs(axes.get(i));
deadzoneCalibration.computeIfAbsent(i, k -> new double[CALIBRATION_TIME])[tick] = axis;
}
System.arraycopy(axes.stream().mapToDouble(a -> a).toArray(), 0, axisData, tick * axes.size(), axes.size());
}
private void processGyroData() {
@ -164,11 +172,30 @@ public class ControllerCalibrationScreen extends Screen {
}
}
private void applyDeadzones() {
deadzoneCalibration.forEach((i, data) -> {
var max = Arrays.stream(data).max().orElseThrow();
controller.config().setDeadzone(i, (float) max + 0.08f);
});
private void calibrateAxis() {
int axisCount = controller.axisCount();
for (int axis = 0; axis < axisCount; axis++) {
boolean triggerAxis = true;
float maxAbs = 0;
for (int tick = 0; tick < CALIBRATION_TIME; tick++) {
float axisValue = (float) axisData[tick * axisCount + axis];
if (axisValue != -1) {
triggerAxis = false;
}
maxAbs = Math.max(maxAbs, Math.abs(axisValue));
}
if (triggerAxis && controller instanceof JoystickController<?> joystick && joystick.mapping() instanceof UnmappedJoystickMapping mapping) {
joystick.config().setDeadzone(axis, 0.0f);
joystick.config().setTriggerAxis(axis, true);
mapping.setTriggerAxes(axis, true);
} else {
controller.config().setDeadzone(axis, maxAbs + 0.08f);
}
}
}
private void generateGyroCalibration() {

View File

@ -7,7 +7,7 @@ import dev.isxander.controlify.api.buttonguide.ButtonGuideApi;
import dev.isxander.controlify.api.buttonguide.ButtonGuidePredicate;
import dev.isxander.controlify.api.buttonguide.ButtonRenderPosition;
import dev.isxander.controlify.controller.Controller;
import dev.isxander.controlify.controller.sdl2.SDL2NativesManager;
import dev.isxander.controlify.driver.SDL2NativesManager;
import dev.isxander.controlify.gui.components.FakePositionPlainTextButton;
import dev.isxander.controlify.screenop.ScreenControllerEventListener;
import dev.isxander.controlify.utils.Animator;
@ -57,23 +57,12 @@ public class ControllerCarouselScreen extends Screen implements ScreenController
public static Screen createConfigScreen(Screen parent) {
var controlify = Controlify.instance();
if (!controlify.config().globalSettings().vibrationOnboarded) {
return new SDLOnboardingScreen(() -> new ControllerCarouselScreen(parent), yes -> {
if (yes) {
SDL2NativesManager.initialise();
if (controlify.config().globalSettings().delegateSetup) {
controlify.discoverControllers();
controlify.config().globalSettings().delegateSetup = false;
controlify.config().save();
}
}
});
} else if (Controlify.instance().config().globalSettings().delegateSetup) {
if (controlify.config().globalSettings().delegateSetup) {
controlify.discoverControllers();
controlify.config().globalSettings().delegateSetup = false;
controlify.config().save();
}
return new ControllerCarouselScreen(parent);
}

View File

@ -1,6 +1,5 @@
package dev.isxander.controlify.gui.screen;
import com.google.common.collect.Iterables;
import dev.isxander.controlify.Controlify;
import dev.isxander.controlify.api.bind.ControllerBinding;
import dev.isxander.controlify.bindings.BindContext;

View File

@ -0,0 +1,4 @@
package dev.isxander.controlify.gui.screen;
public interface DontInteruptScreen {
}

View File

@ -0,0 +1,129 @@
package dev.isxander.controlify.gui.screen;
import dev.isxander.controlify.Controlify;
import dev.isxander.controlify.driver.SDL2NativesManager;
import net.minecraft.ChatFormatting;
import net.minecraft.Util;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.MultiLineTextWidget;
import net.minecraft.client.gui.components.PlainTextButton;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.ClickEvent;
import net.minecraft.network.chat.CommonComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.HoverEvent;
import net.minecraft.resources.ResourceLocation;
import java.nio.file.Path;
import java.text.DecimalFormat;
import java.util.concurrent.CompletableFuture;
public class DownloadingSDLScreen extends Screen implements DontInteruptScreen {
private static final ResourceLocation GUI_BARS_LOCATION = new ResourceLocation("textures/gui/bars.png");
private final Screen screenOnFinish;
private final Path nativePath;
private long receivedBytes;
private final long totalBytes;
private final DecimalFormat format = new DecimalFormat("0.00 MB");
public DownloadingSDLScreen(Screen screenOnFinish, long totalBytes, Path nativePath) {
super(Component.translatable("controlify.downloading_sdl.title"));
this.screenOnFinish = screenOnFinish;
this.nativePath = nativePath;
this.totalBytes = totalBytes;
}
@Override
protected void init() {
Component filePathText = Component.literal(nativePath.getFileName().toString())
.withStyle(ChatFormatting.BLUE);
addRenderableWidget(new PlainTextButton(
width / 2 - font.width(filePathText) / 2,
(int) (30 + 9 * 2.5f + 40 + 5 * 2f + 10),
font.width(filePathText),
font.lineHeight,
filePathText,
btn -> Util.getPlatform().openFile(nativePath.toFile()),
font
));
addRenderableWidget(new MultiLineTextWidget(
width / 2 - (width - 50) / 2,
(int) (30 + 9 * 2.5f + 40 + 5 * 2f + 10 + 9*3),
Component.translatable("controlify.downloading_sdl.info"),
font
).setMaxWidth(width - 20).setCentered(true));
}
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
renderDirtBackground(graphics);
super.render(graphics, mouseX, mouseY, delta);
graphics.pose().pushPose();
graphics.pose().translate(width / 2f - font.width(this.getTitle()) / 2f * 2.5f, 30, 0);
graphics.pose().scale(2.5f, 2.5f, 1f);
graphics.drawString(font, this.getTitle(), 0, 0, -1);
graphics.pose().popPose();
drawProgressBar(graphics, width / 2, (int) (30 + 9 * 2.5f + 40), (float) ((double) receivedBytes / totalBytes));
String totalString = format.format(totalBytes / 1024f / 1024f);
graphics.drawString(
font,
totalString,
(int) (width / 2f + 182 * 2f / 2 - font.width(totalString)),
(int) (30 + 9 * 2f + 40 + 5 * 2f + 4),
11184810 // light gray
);
String receivedString = format.format(receivedBytes / 1024f / 1024f);
graphics.drawString(
font,
receivedString,
(int) (width / 2f - 182 * 2f / 2),
(int) (30 + 9 * 2f + 40 + 5 * 2f + 4),
11184810 // light gray
);
}
@Override
public void added() {
CompletableFuture<Boolean> askNativesFuture = Controlify.instance().askNatives();
if (askNativesFuture.isDone()) {
minecraft.setScreen(screenOnFinish);
}
}
public void updateDownloadProgress(long receivedBytes) {
this.receivedBytes = receivedBytes;
}
public void finishDownload() {
minecraft.setScreen(screenOnFinish);
}
public void failDownload(Throwable th) {
finishDownload();
}
private static void drawProgressBar(GuiGraphics graphics, int centerX, int y, float progress) {
int x = (int) (centerX - 182 * 2f / 2);
graphics.pose().pushPose();
graphics.pose().translate(x, y, 0);
graphics.pose().scale(2f, 2f, 1f);
graphics.blit(GUI_BARS_LOCATION, 0, 0, 0, 30, 182, 5);
graphics.blit(GUI_BARS_LOCATION, 0, 0, 0, 35, (int)(progress * 182), 5);
graphics.pose().popPose();
}
}

View File

@ -0,0 +1,34 @@
package dev.isxander.controlify.gui.screen;
import dev.isxander.controlify.Controlify;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
public class ModConfigOpenerScreen extends Screen {
private final Screen lastScreen;
public ModConfigOpenerScreen(Screen lastScreen) {
super(Component.empty());
this.lastScreen = lastScreen;
}
@Override
public void added() {
// need to make sure fabric api has registered all its events
// because calling setScreen before this will cause fapi to freak
// out that it has no remove event and crash the whole game lol
Minecraft minecraft = Minecraft.getInstance();
this.init(minecraft, minecraft.getWindow().getGuiScaledWidth(), minecraft.getWindow().getGuiScaledHeight());
Controlify.instance().askNatives()
.whenComplete((result, error) ->
minecraft.setScreen(ControllerCarouselScreen.createConfigScreen(lastScreen))
);
}
@Override
public void triggerImmediateNarration(boolean useTranslationsCache) {
}
}

View File

@ -1,7 +1,7 @@
package dev.isxander.controlify.gui.screen;
import dev.isxander.controlify.Controlify;
import dev.isxander.controlify.controller.sdl2.SDL2NativesManager;
import dev.isxander.controlify.driver.SDL2NativesManager;
import it.unimi.dsi.fastutil.booleans.BooleanConsumer;
import net.minecraft.ChatFormatting;
import net.minecraft.Util;
@ -12,7 +12,7 @@ import net.minecraft.network.chat.Component;
import java.util.function.Supplier;
public class SDLOnboardingScreen extends ConfirmScreen {
public class SDLOnboardingScreen extends ConfirmScreen implements DontInteruptScreen {
public SDLOnboardingScreen(Supplier<Screen> lastScreen, BooleanConsumer onAnswered) {
super(
yes -> {

View File

@ -22,7 +22,7 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.regex.Pattern;
public class SubmitUnknownControllerScreen extends Screen {
public class SubmitUnknownControllerScreen extends Screen implements DontInteruptScreen {
public static final String SUBMISSION_URL = "https://api-controlify.isxander.dev/api/v1/submit";
public static final Pattern NAME_PATTERN = Pattern.compile("^[\\w\\- ]{3,32}$");

View File

@ -3,18 +3,19 @@ package dev.isxander.controlify.hid;
import com.mojang.datafixers.util.Pair;
import dev.isxander.controlify.Controlify;
import dev.isxander.controlify.controller.ControllerType;
import dev.isxander.controlify.controller.sdl2.SDL2NativesManager;
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 net.minecraft.network.chat.Component;
import org.hid4java.*;
import org.libsdl.SDL;
import org.lwjgl.glfw.GLFW;
import java.util.*;
import java.util.concurrent.ArrayBlockingQueue;
import static io.github.libsdl4j.api.joystick.SdlJoystick.*;
public class ControllerHIDService {
private final HidServicesSpecification specification;
private HidServices services;
@ -155,8 +156,8 @@ public class ControllerHIDService {
private Optional<ControllerHIDInfo> fetchTypeFromSDL(int jid) {
if (SDL2NativesManager.isLoaded()) {
int vid = SDL.SDL_JoystickGetDeviceVendor(jid);
int pid = SDL.SDL_JoystickGetDeviceProduct(jid);
int vid = SDL_JoystickGetDeviceVendor(jid);
int pid = SDL_JoystickGetDeviceProduct(jid);
String path = GLFW.glfwGetJoystickGUID(jid);
if (vid != 0 && pid != 0) {

View File

@ -1,4 +1,4 @@
package dev.isxander.controlify.mixins.feature.fixes.boatfix;
package dev.isxander.controlify.mixins.feature.patches.boatfix;
import com.llamalad7.mixinextras.injector.ModifyExpressionValue;
import dev.isxander.controlify.fixes.boatfix.AnalogBoatInput;

View File

@ -1,4 +1,4 @@
package dev.isxander.controlify.mixins.feature.fixes.boatfix;
package dev.isxander.controlify.mixins.feature.patches.boatfix;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;

View File

@ -1,6 +1,7 @@
package dev.isxander.controlify.mixins.feature.settingsbutton;
import com.llamalad7.mixinextras.sugar.Local;
import dev.isxander.controlify.Controlify;
import dev.isxander.controlify.gui.screen.ControllerCarouselScreen;
import net.minecraft.client.Options;
import net.minecraft.client.gui.components.Button;
@ -9,6 +10,7 @@ import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.controls.ControlsScreen;
import net.minecraft.network.chat.Component;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.ModifyArg;
@ -22,7 +24,7 @@ public class ControlsScreenMixin extends OptionsSubScreen {
@Inject(method = "init", at = @At("RETURN"))
private void addControllerSettings(CallbackInfo ci, @Local(ordinal = 0) int leftX, @Local(ordinal = 1) int rightX, @Local(ordinal = 2) int currentY) {
addRenderableWidget(Button.builder(Component.translatable("controlify.gui.button"), btn -> minecraft.setScreen(ControllerCarouselScreen.createConfigScreen(this)))
addRenderableWidget(Button.builder(Component.translatable("controlify.gui.button"), btn -> this.openControllerSettings())
.pos(leftX, currentY)
.width(150)
.build());
@ -32,4 +34,12 @@ public class ControlsScreenMixin extends OptionsSubScreen {
private int modifyDoneButtonY(int y) {
return y + 24;
}
@Unique
private void openControllerSettings() {
Controlify.instance().askNatives()
.whenComplete((result, error) ->
minecraft.setScreen(ControllerCarouselScreen.createConfigScreen(lastScreen))
);
}
}

View File

@ -1,8 +1,32 @@
package dev.isxander.controlify.utils;
import dev.isxander.controlify.controller.Controller;
import dev.isxander.controlify.controller.ControllerType;
import dev.isxander.controlify.controller.gamepad.GamepadController;
import dev.isxander.controlify.hid.ControllerHIDService;
import dev.isxander.controlify.hid.HIDDevice;
import net.minecraft.util.Mth;
import java.util.HexFormat;
import java.util.Optional;
public class ControllerUtils {
public static String createControllerString(Controller<?, ?> controller) {
Optional<HIDDevice> hid = controller.hidInfo().flatMap(ControllerHIDService.ControllerHIDInfo::hidDevice);
HexFormat hexFormat = HexFormat.of().withPrefix("0x");
return String.format("'%s'#%s-%s (%s, %s: %s)",
controller.name(),
controller.joystickId(),
controller instanceof GamepadController ? "gamepad" : "joy",
hid.map(device -> hexFormat.toHexDigits(device.vendorID())).orElse("?"),
hid.map(device -> hexFormat.toHexDigits(device.productID())).orElse("?"),
controller.hidInfo().map(ControllerHIDService.ControllerHIDInfo::type)
.orElse(ControllerType.UNKNOWN)
.friendlyName()
);
}
public static float deadzone(float value, float deadzone) {
return (value - Math.copySign(Math.min(deadzone, Math.abs(value)), value)) / (1 - deadzone);
}

View File

@ -0,0 +1,60 @@
package dev.isxander.controlify.utils;
import java.net.http.HttpResponse;
import java.nio.ByteBuffer;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Flow;
public class TrackingBodySubscriber<T> implements HttpResponse.BodySubscriber<T> {
private final HttpResponse.BodySubscriber<T> delegate;
private final TrackingConsumer consumer;
private long receivedBytes;
private final long contentLengthIfKnown;
public TrackingBodySubscriber(HttpResponse.BodySubscriber<T> delegate, TrackingConsumer consumer, long contentLengthIfKnown) {
this.delegate = delegate;
this.consumer = consumer;
this.contentLengthIfKnown = contentLengthIfKnown;
}
@Override
public CompletionStage<T> getBody() {
return delegate.getBody();
}
@Override
public void onSubscribe(Flow.Subscription subscription) {
consumer.start().accept(contentLengthIfKnown);
delegate.onSubscribe(subscription);
}
@Override
public void onNext(List<ByteBuffer> item) {
receivedBytes += countBytes(item);
delegate.onNext(item);
consumer.progressConsumer().accept(receivedBytes, contentLengthIfKnown);
}
@Override
public void onError(Throwable throwable) {
consumer.onComplete().accept(Optional.of(throwable));
delegate.onError(throwable);
}
@Override
public void onComplete() {
consumer.onComplete().accept(Optional.empty());
delegate.onComplete();
}
private long countBytes(List<ByteBuffer> buffers) {
return buffers.stream().mapToLong(ByteBuffer::remaining).sum();
}
public static <T> HttpResponse.BodyHandler<T> bodyHandler(HttpResponse.BodyHandler<T> delegate, TrackingConsumer consumer) {
return (responseInfo) -> new TrackingBodySubscriber<>(delegate.apply(responseInfo), consumer, responseInfo.headers().firstValueAsLong("Content-Length").orElse(-1L));
}
}

View File

@ -0,0 +1,8 @@
package dev.isxander.controlify.utils;
import java.util.Optional;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
public record TrackingConsumer(Consumer<Long> start, BiConsumer<Long, Long> progressConsumer, Consumer<Optional<Throwable>> onComplete) {
}