diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 51fb061be..c6adf006c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -53,7 +53,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: 25 - distribution: temurin + distribution: microsoft - name: Set up Gradle uses: gradle/actions/setup-gradle@v6 @@ -89,11 +89,54 @@ jobs: path: versions/**/build/libs/* name: ${{ matrix.target }} -# run: -# needs: build -# runs-on: ubuntu-latest -# name: Run Minecraft -# -# strategy: -# matrix: -# target: ${{ fromJson(needs.retrieve-targets.outputs.targets) }} + client_game_test: + needs: generate-build-matrix + runs-on: ubuntu-latest + name: Client test with gradle + + strategy: + fail-fast: false + matrix: + target: ${{ fromJson(needs.generate-build-matrix.outputs.matrix).include.*.target }} + graphics_backend: + - opengl + - vulkan + + continue-on-error: ${{ contains(matrix.target, 'exp') }} + + steps: + - uses: actions/checkout@v6 + - name: Set up JDK + uses: actions/setup-java@v5 + with: + java-version: 25 + distribution: microsoft + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Make gradlew executable + run: chmod +x ./gradlew + - name: Overwrite current version + run: printf "%s" "${{ matrix.target }}" | cut -d':' -f1 | tr -d '\n' > ./versions/current + - name: Install drivers + run: | + sudo apt-get update + sudo apt-get install -y mesa-vulkan-drivers xvfb + + - name: Setup stonecutter + run: ./gradlew "Refresh active project" + env: + CI_SINGLE_BUILD: ${{ matrix.target }} + + - name: Run client tests + run: ./gradlew runClientGameTest --stacktrace --args="--graphicsBackend ${{ matrix.graphics_backend }}" + env: + CI_SINGLE_BUILD: ${{ matrix.target }} + + - name: Upload screenshots + uses: actions/upload-artifact@v7 + if: always() + with: + name: Test Screenshots (${{ matrix.target }}) (${{ matrix.graphics_backend }}) + path: '**/run/clientGameTest/secreenshots' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6a64b7d12..27ce7677d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/setup-java@v5 with: java-version: 25 - distribution: temurin + distribution: microsoft - uses: actions/checkout@v7 diff --git a/.github/workflows/update-gamecontrollerdb.yml b/.github/workflows/update-gamecontrollerdb.yml index 88a188b2b..98ad09d9f 100644 --- a/.github/workflows/update-gamecontrollerdb.yml +++ b/.github/workflows/update-gamecontrollerdb.yml @@ -22,7 +22,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: '25' - distribution: 'temurin' + distribution: 'microsoft' - name: Set up Gradle uses: gradle/actions/setup-gradle@v6 diff --git a/build.gradle.kts b/build.gradle.kts index 3cb95948f..489d60b14 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,13 +16,15 @@ base.archivesName = "controlify" java.toolchain.languageVersion = JavaLanguageVersion.of(25) +val fabricApiBom = dependencies.platform("net.fabricmc.fabric-api:fabric-api-bom:${property("dep.fapi")}") + dependencies { minecraft("com.mojang:minecraft:$minecraftVersion") fabricLoader(libs.fabric.loader) neoforgeImplementation("net.neoforged:neoforge:${property("dep.neoforge")}") - implementation(platform("net.fabricmc.fabric-api:fabric-api-bom:${property("dep.fapi")}")) - fabricImplementation(platform("net.fabricmc.fabric-api:fabric-api-bom:${property("dep.fapi")}")) + implementation(fabricApiBom) + fabricImplementation(fabricApiBom) fabricImplementation("net.fabricmc.fabric-api:fabric-resource-loader-v1") fabricImplementation("net.fabricmc.fabric-api:fabric-networking-api-v1") fabricImplementation("net.fabricmc.fabric-api:fabric-command-api-v2") @@ -54,40 +56,40 @@ dependencies { neoforgeApi("dev.isxander:yet-another-config-lib:${property("dep.yacl-neoforge")}") ifPresent("dep.mod-menu") { - fabricImplementation("maven.modrinth:modmenu:$it") + fabricCompileOnly("maven.modrinth:modmenu:$it") } ifPresent("dep.sodium") { compileOnly("net.caffeinemc:sodium-fabric:$it") { exclude(group = "net.fabricmc.fabric-api") } - fabricImplementation("net.caffeinemc:sodium-fabric:$it") - neoforgeImplementation("net.caffeinemc:sodium-neoforge:$it") - neoforgeImplementation("net.caffeinemc:sodium-neoforge-mod:$it") + fabricCompileOnly("net.caffeinemc:sodium-fabric:$it") + neoforgeCompileOnly("net.caffeinemc:sodium-neoforge:$it") + neoforgeCompileOnly("net.caffeinemc:sodium-neoforge-mod:$it") } ifPresent("dep.iris") { compileOnly("maven.modrinth:iris:$it") - fabricImplementation("maven.modrinth:iris:$it") + fabricCompileOnly("maven.modrinth:iris:$it") } ifPresent("dep.iris-neoforge") { - neoforgeImplementation("maven.modrinth:iris:$it") + neoforgeCompileOnly("maven.modrinth:iris:$it") } ifPresent("dep.rso") { compileOnly("maven.modrinth:reeses-sodium-options:$it") - fabricImplementation("maven.modrinth:reeses-sodium-options:$it") + fabricCompileOnly("maven.modrinth:reeses-sodium-options:$it") } ifPresent("dep.rso-neoforge") { - neoforgeImplementation("maven.modrinth:reeses-sodium-options:$it") + neoforgeCompileOnly("maven.modrinth:reeses-sodium-options:$it") } ifPresent("dep.svc") { compileOnly("maven.modrinth:simple-voice-chat:$it") - fabricImplementation("maven.modrinth:simple-voice-chat:$it") + fabricCompileOnly("maven.modrinth:simple-voice-chat:$it") } ifPresent("dep.svc-neoforge") { - neoforgeImplementation("maven.modrinth:simple-voice-chat:$it") + neoforgeCompileOnly("maven.modrinth:simple-voice-chat:$it") } ifPresent("dep.fancy-menu") { @@ -255,6 +257,54 @@ if (includeNatives) { } } +/// Testing + +fabricApi { + @Suppress("UnstableApiUsage") + configureTests { + createSourceSet = true + modId = "controlify_test" + enableGameTests = false + enableClientGameTests = true + eula = true + } +} + +sourceSets.named { it in listOf("gametest", "test") }.configureEach { + compileClasspath += sourceSets.fabric.get().output + runtimeClasspath += sourceSets.fabric.get().output +} +sourceSets.named("gametest") { + compileClasspath += sourceSets.test.get().output + runtimeClasspath += sourceSets.test.get().output +} +configurations.named("testCompileClasspath") { + extendsFrom(configurations.named("fabricCompileClasspath")) +} +configurations.named("gametestCompileClasspath") { + extendsFrom(configurations.named("testCompileClasspath")) +} +configurations.named("testRuntimeClasspath") { + extendsFrom(configurations.named("fabricRuntimeClasspath")) +} +configurations.named("gametestRuntimeClasspath") { + extendsFrom(configurations.named("testRuntimeClasspath")) +} + +val gametestImplementation = configurations.named("gametestImplementation") + +dependencies { + testImplementation(libs.fabric.loader.junit) + + gametestImplementation(fabricApiBom) + gametestImplementation("net.fabricmc.fabric-api:fabric-gametest-api-v1") + gametestImplementation("net.fabricmc.fabric-api:fabric-client-gametest-api-v1") +} + +tasks.test { + useJUnitPlatform() +} + /// Publishing publishMods { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bbaf17e59..676edd5a9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ mod_publish_plugin = "2.1.1" [libraries] fabric_loader = { module = "net.fabricmc:fabric-loader", version.ref = "fabric_loader" } +fabric_loader_junit = { module = "net.fabricmc:fabric-loader-junit", version.ref = "fabric_loader" } sdl_java_api = { module = "dev.isxander.sdl:sdl-api", version.ref = "sdl_java" } sdl_java_backend_ffm = { module = "dev.isxander.sdl:sdl-backend-ffm", version.ref = "sdl_java" } steamdeck4j = { module = "dev.isxander:steamdeck4j", version.ref = "steamdeck4j" } diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/CEntityTypes.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/CEntityTypes.java new file mode 100644 index 000000000..6276a15eb --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/CEntityTypes.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.gametest.framework; + +//? if >=26.2 { +import net.minecraft.world.entity.EntityTypes; + +public final class CEntityTypes extends EntityTypes { +} +//?} else { +/*import com.google.common.collect.ImmutableSet; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MobCategory; +import net.minecraft.world.flag.FeatureFlagSet; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.storage.loot.LootTable; + +import java.util.Optional; + +public final class CEntityTypes extends EntityType { + private CEntityTypes(EntityFactory factory, MobCategory category, boolean serialize, boolean summon, boolean fireImmune, boolean canSpawnFarFromPlayer, ImmutableSet immuneTo, EntityDimensions dimensions, float spawnDimensionsScale, int clientTrackingRange, int updateInterval, String descriptionId, Optional> lootTable, FeatureFlagSet requiredFeatures, boolean allowedInPeaceful) { + super(factory, category, serialize, summon, fireImmune, canSpawnFarFromPlayer, immuneTo, dimensions, spawnDimensionsScale, clientTrackingRange, updateInterval, descriptionId, lootTable, requiredFeatures, allowedInPeaceful); + } +} +*///?} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/CTestUtil.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/CTestUtil.java new file mode 100644 index 000000000..3638c119a --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/CTestUtil.java @@ -0,0 +1,209 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.gametest.framework; + +import dev.isxander.controlify.Controlify; +import dev.isxander.controlify.config.settings.GlobalSettings; +import dev.isxander.controlify.config.settings.profile.ProfileSettings; +import dev.isxander.controlify.controller.ControllerEntity; +import dev.isxander.controlify.controller.gyro.GyroState; +import dev.isxander.controlify.controllermanager.ControllerManager; +import dev.isxander.controlify.gametest.mixin.ControlifySettingsAccessor; +import dev.isxander.controlify.utils.MinecraftUtil; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import net.fabricmc.fabric.api.resource.v1.ResourceLoader; +import net.fabricmc.fabric.api.resource.v1.pack.PackActivationType; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.SharedConstants; +import net.minecraft.client.gui.screens.LoadingOverlay; +import net.minecraft.client.gui.screens.Overlay; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.packs.repository.PackRepository; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +@SuppressWarnings("UnstableApiUsage") +public final class CTestUtil { + + private CTestUtil() { + } + + public static Identifier id(String path) { + return Identifier.fromNamespaceAndPath("controlify_test", path); + } + + /// Resets Controlify's global, active-profile and device settings while + /// preserving the runtime invariants of the active profile and connected + /// controllers. + public static void resetControlifySettings(ClientGameTestContext context) { + context.runOnClient(_ -> { + Controlify controlify = Controlify.instance(); + var config = controlify.config(); + var settings = config.getSettings(); + var settingsAccessor = (ControlifySettingsAccessor) settings; + + int activeProfileIndex = config.getActiveProfileIndex(); + if (activeProfileIndex < 0) { + throw new IllegalStateException("Controlify has no active profile"); + } + + GlobalSettings globalDefaults = GlobalSettings.defaults(); + // The profile lock stays on the active index, so keep the preferred + // profile consistent with it rather than blindly restoring index zero. + globalDefaults.preferredProfile = activeProfileIndex; + settingsAccessor.controlify_test$setGlobalSettings(globalDefaults); + + ProfileSettings profileDefaults = ProfileSettings.createDefault(); + settingsAccessor.controlify_test$getProfileSettings().clear(); + settings.putProfileSettings(activeProfileIndex, profileDefaults); + + var connectedControllers = controlify.getControllerManager() + .map(ControllerManager::getConnectedControllers) + .orElseGet(List::of); + var connectedUids = connectedControllers.stream() + .map(ControllerEntity::uid) + .collect(Collectors.toSet()); + + // Keep the existing objects for connected devices because input + // components cache their DeviceSettings reference when attached. + var deviceSettings = settingsAccessor.controlify_test$getDeviceSettings(); + deviceSettings.keySet().retainAll(connectedUids); + for (ControllerEntity controller : connectedControllers) { + var device = settings.getOrCreateDeviceSettings(controller.uid()); + device.name = controller.name(); + device.lastSeen = System.currentTimeMillis(); + device.controllerType = controller.info().type().namespace(); + device.gyroCalibration.offset = new GyroState(); + device.mapping = null; + + controller.setSettings(profileDefaults); + } + + controlify.applyControllerSelection(false); + config.markDirty(); + }); + } + + public static void registerBuiltinResourcePack(Identifier packId) { + ResourceLoader.registerBuiltinPack( + packId, + FabricLoader.getInstance().getModContainer("controlify_test").orElseThrow(), + PackActivationType.NORMAL + ); + } + + public static ReversibleCallback applyResourcePack(ClientGameTestContext context, Identifier packId) { + return applyResourcePack(context, packId.toString()); + } + + public static ReversibleCallback applyResourcePack(ClientGameTestContext context, String packId) { + updatePackRepo(context, packRepo -> packRepo.addPack(packId)); + + return () -> { + updatePackRepo(context, packRepo -> packRepo.removePack(packId)); + }; + } + + private static void updatePackRepo(ClientGameTestContext context, Consumer updater) { + context.runOnClient(minecraft -> { + PackRepository packRepo = minecraft.getResourcePackRepository(); + updater.accept(packRepo); + // this initiates reload + minecraft.options.updateResourcePacks(packRepo); + }); + + // this initiates reload too, but if one is already occurring, it attaches to the current one + reloadResources(context); + } + + public static void reloadResources(ClientGameTestContext context) { + CompletableFuture reloadInstance = context.computeOnClient(minecraft -> { + var future = minecraft.reloadResourcePacks(); + if (MinecraftUtil.getOverlay() instanceof LoadingOverlay) { + return future; + } else { + throw new IllegalStateException("Reload did not initiate"); + } + }); + + // may take a while on a CI + context.waitFor(_ -> reloadInstance.isDone(), SharedConstants.TICKS_PER_SECOND * 30); + if (reloadInstance.isCompletedExceptionally()) { + throw new IllegalStateException("The reload failed", reloadInstance.exceptionNow()); + } + waitForOverlay(context, null); + } + + public static ReversibleCallback setLanguage(ClientGameTestContext context, String code) { + String previousLanguageCode = context.computeOnClient(minecraft -> { + String previous = minecraft.getLanguageManager().getSelected(); + minecraft.getLanguageManager().setSelected(code); + minecraft.options.languageCode = code; + return previous; + }); + + reloadResources(context); + + return () -> { + context.runOnClient(minecraft -> { + minecraft.getLanguageManager().setSelected(previousLanguageCode); + minecraft.options.languageCode = previousLanguageCode; + }); + + reloadResources(context); + }; + } + + public static int waitForOverlay(ClientGameTestContext context, @Nullable Class overlayClass) { + if (overlayClass == null) { + return context.waitFor(_ -> MinecraftUtil.getOverlay() == null); + } else { + return context.waitFor(_ -> overlayClass.isInstance(MinecraftUtil.getOverlay())); + } + } + + public static ServerPlayer getPrincipalPlayer(MinecraftServer server) { + // fabric gametest always calls player Player0 + return server.getPlayerList().getPlayer("Player0"); + } + + public static T summonEntityAtPlayer(MinecraftServer server, EntityType entityType, Consumer preAdd) { + ServerPlayer player = getPrincipalPlayer(server); + ServerLevel level = player.level(); + + T entity = entityType.create(level, EntitySpawnReason.COMMAND); + entity.teleportTo(player.getX(), player.getY(), player.getZ()); + preAdd.accept(entity); + level.addFreshEntity(entity); + return entity; + } + + public static T summonEntityAtPlayer(MinecraftServer server, EntityType entityType) { + return summonEntityAtPlayer(server, entityType, _ -> {}); + } + + public static void clearToasts(ClientGameTestContext context) { + context.runOnClient(minecraft -> { + //? if >=26.2 { + minecraft.gui.toastManager().clear(); + //?} else { + /*minecraft.getToastManager().clear(); + *///?} + }); + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/ControlifyGameTestContext.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/ControlifyGameTestContext.java new file mode 100644 index 000000000..a8d448de3 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/ControlifyGameTestContext.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.gametest.framework; + +import dev.isxander.controlify.Controlify; +import dev.isxander.controlify.InputMode; +import dev.isxander.controlify.gametest.mixin.SDLControllerManagerAccessor; +import dev.isxander.sdl.Sdl; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; + +@SuppressWarnings("UnstableApiUsage") +public class ControlifyGameTestContext { + private final ClientGameTestContext context; + private final Sdl sdl; + + public ControlifyGameTestContext(ClientGameTestContext context) { + this.context = context; + this.sdl = ((SDLControllerManagerAccessor) Controlify.instance().getControllerManager().orElseThrow()).controlify_test$getSdl(); + } + + public VirtualControllerBuilder virtualControllerBuilder() { + return new VirtualControllerBuilder(this.context, this, this.sdl); + } + + public void setInputMode(InputMode inputMode) { + Controlify.instance().setInputMode(inputMode); + context.waitTick(); + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/ControllerInputDefinition.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/ControllerInputDefinition.java new file mode 100644 index 000000000..37778d9d9 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/ControllerInputDefinition.java @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.gametest.framework; + +import java.util.Set; + +import static dev.isxander.sdl.SdlGamepad.*; + +public record ControllerInputDefinition( + Set buttons, + Set axes +) { + public int numButtons() { + return this.buttons.size(); + } + + public int numAxes() { + return this.axes.size(); + } + + /** + * SDL packs the canonical buttons selected by {@code buttonMask} into dense + * joystick button indices. Convert a gamepad button enum to that raw index. + */ + public int joystickButton(int gamepadButton) { + if (!this.buttons.contains(gamepadButton)) { + throw new IllegalArgumentException("Gamepad button is not available: " + gamepadButton); + } + + return (int) this.buttons.stream() + .filter(button -> button < gamepadButton) + .count(); + } + + /** + * SDL packs the canonical axes selected by {@code axisMask} into dense + * joystick axis indices. Convert a gamepad axis enum to that raw index. + */ + public int joystickAxis(int gamepadAxis) { + if (!this.axes.contains(gamepadAxis)) { + throw new IllegalArgumentException("Gamepad axis is not available: " + gamepadAxis); + } + + return (int) this.axes.stream() + .filter(axis -> axis < gamepadAxis) + .count(); + } + + public int buttonMask() { + int mask = 0; + + for (int button : this.buttons) { + mask |= 1 << button; + } + + return mask; + } + + public int axisMask() { + int mask = 0; + + for (int axis : this.axes) { + mask |= 1 << axis; + } + + return mask; + } + + public static final ControllerInputDefinition XINPUT = new ControllerInputDefinition( + Set.of( + SDL_GAMEPAD_BUTTON_SOUTH, + SDL_GAMEPAD_BUTTON_EAST, + SDL_GAMEPAD_BUTTON_WEST, + SDL_GAMEPAD_BUTTON_NORTH, + + SDL_GAMEPAD_BUTTON_BACK, + SDL_GAMEPAD_BUTTON_START, + + SDL_GAMEPAD_BUTTON_LEFT_STICK, + SDL_GAMEPAD_BUTTON_RIGHT_STICK, + + SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, + SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, + + SDL_GAMEPAD_BUTTON_DPAD_UP, + SDL_GAMEPAD_BUTTON_DPAD_DOWN, + SDL_GAMEPAD_BUTTON_DPAD_LEFT, + SDL_GAMEPAD_BUTTON_DPAD_RIGHT + ), + Set.of( + SDL_GAMEPAD_AXIS_LEFTX, + SDL_GAMEPAD_AXIS_LEFTY, + SDL_GAMEPAD_AXIS_RIGHTX, + SDL_GAMEPAD_AXIS_RIGHTY, + SDL_GAMEPAD_AXIS_LEFT_TRIGGER, + SDL_GAMEPAD_AXIS_RIGHT_TRIGGER + ) + ); + + public static final ControllerInputDefinition ALL_SDL_GAMEPAD_INPUTS = new ControllerInputDefinition( + Set.of( + SDL_GAMEPAD_BUTTON_SOUTH, + SDL_GAMEPAD_BUTTON_EAST, + SDL_GAMEPAD_BUTTON_WEST, + SDL_GAMEPAD_BUTTON_NORTH, + + SDL_GAMEPAD_BUTTON_BACK, + SDL_GAMEPAD_BUTTON_GUIDE, + SDL_GAMEPAD_BUTTON_START, + + SDL_GAMEPAD_BUTTON_LEFT_STICK, + SDL_GAMEPAD_BUTTON_RIGHT_STICK, + + SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, + SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, + + SDL_GAMEPAD_BUTTON_DPAD_UP, + SDL_GAMEPAD_BUTTON_DPAD_DOWN, + SDL_GAMEPAD_BUTTON_DPAD_LEFT, + SDL_GAMEPAD_BUTTON_DPAD_RIGHT, + + SDL_GAMEPAD_BUTTON_MISC1, + + SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1, + SDL_GAMEPAD_BUTTON_LEFT_PADDLE1, + SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2, + SDL_GAMEPAD_BUTTON_LEFT_PADDLE2, + + SDL_GAMEPAD_BUTTON_TOUCHPAD, + + SDL_GAMEPAD_BUTTON_MISC2, + SDL_GAMEPAD_BUTTON_MISC3, + SDL_GAMEPAD_BUTTON_MISC4, + SDL_GAMEPAD_BUTTON_MISC5, + SDL_GAMEPAD_BUTTON_MISC6 + ), + Set.of( + SDL_GAMEPAD_AXIS_LEFTX, + SDL_GAMEPAD_AXIS_LEFTY, + SDL_GAMEPAD_AXIS_RIGHTX, + SDL_GAMEPAD_AXIS_RIGHTY, + SDL_GAMEPAD_AXIS_LEFT_TRIGGER, + SDL_GAMEPAD_AXIS_RIGHT_TRIGGER + ) + ); +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/GamepadEffectDriver.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/GamepadEffectDriver.java new file mode 100644 index 000000000..b14486f32 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/GamepadEffectDriver.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.gametest.framework; + +import java.nio.ByteBuffer; + +public interface GamepadEffectDriver { + E receiveEffect(ByteBuffer effect); + + ByteBuffer sendEffect(E effect); + + class Noop implements GamepadEffectDriver { + @Override + public ByteBuffer receiveEffect(ByteBuffer effect) { + return effect; + } + + @Override + public ByteBuffer sendEffect(ByteBuffer effect) { + return effect; + } + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/ReversibleCallback.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/ReversibleCallback.java new file mode 100644 index 000000000..950901370 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/ReversibleCallback.java @@ -0,0 +1,12 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.gametest.framework; + +public interface ReversibleCallback extends AutoCloseable { + @Override + void close(); +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerContext.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerContext.java new file mode 100644 index 000000000..76a63d7db --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerContext.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.gametest.framework; + +import dev.isxander.controlify.Controlify; +import dev.isxander.controlify.controller.ControllerEntity; +import dev.isxander.controlify.controllermanager.SDLControllerManager; +import dev.isxander.controlify.driver.sdl.SDLException; +import dev.isxander.controlify.utils.CUtil; +import dev.isxander.sdl.Sdl; +import dev.isxander.sdl.SdlGamepad; +import dev.isxander.sdl.SdlJoystickHandle; +import dev.isxander.sdl.SdlJoystickId; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; + +import java.lang.foreign.Arena; +import java.lang.foreign.ValueLayout; + +@SuppressWarnings("UnstableApiUsage") +public class TestControllerContext implements AutoCloseable { + private final ClientGameTestContext context; + private final Sdl sdl; + private final SdlJoystickHandle joystickHandle; + private final SdlJoystickId joystickId; + private final ControllerInputDefinition inputs; + private final TestControllerState state; + + public TestControllerContext( + ClientGameTestContext context, + Sdl sdl, + SdlJoystickHandle joystickHandle, + SdlJoystickId joystickId, + ControllerInputDefinition inputs, + TestControllerState state + ) { + this.context = context; + this.sdl = sdl; + this.joystickHandle = joystickHandle; + this.joystickId = joystickId; + this.inputs = inputs; + this.state = state; + } + + public ControllerEntity getControllerEntity() { + return Controlify.instance().getControllerManager().orElseThrow() + .getConnectedControllers().stream() + .filter(c -> c.info().ucid().equals(new SDLControllerManager.SDLUniqueControllerID(this.joystickId))) + .findAny() + .orElseThrow(); + } + + public void holdButton(int button) { + this.setButton(button, true); + } + + public void releaseButton(int button) { + this.setButton(button, false); + } + + public void tapButton(int button) { + this.holdButton(button); + this.context.waitTick(); + this.releaseButton(button); + this.context.waitTick(); + } + + private void setButton(int button, boolean down) { + this.sdl.joystick().SDL_SetJoystickVirtualButton( + this.joystickHandle, + this.inputs.joystickButton(button), + down + ); + } + + public void holdAxis(int axis, float state) { + short stateShort = CUtil.mapFloatToShort(state); + + this.sdl.joystick().SDL_SetJoystickVirtualAxis( + this.joystickHandle, + this.inputs.joystickAxis(axis), + stateShort + ); + } + + public void releaseAxis(int axis) { + this.holdAxis(axis, 0f); + } + + public void tapAxis(int axis, float state) { + this.holdAxis(axis, state); + context.waitTick(); + this.releaseAxis(axis); + } + + public void sendSensorData(int sensorType, float... data) { + try (var arena = Arena.ofConfined()) { + var memory = arena.allocateFrom(ValueLayout.JAVA_FLOAT, data); + var floatBuffer = memory.asByteBuffer().asFloatBuffer(); + + this.sdl.joystick().SDL_SendJoystickVirtualSensorData( + this.joystickHandle, + sensorType, + 0, + floatBuffer + ); + } + } + + public void sendGyroData(float x, float y, float z) { + this.sendSensorData(SdlGamepad.SDL_SENSOR_GYRO, x, y, z); + } + + public TestControllerStateView state() { + return this.state; + } + + @Override + public void close() { + this.sdl.joystick().SDL_CloseJoystick(this.joystickHandle); + + if (!this.sdl.joystick().SDL_DetachVirtualJoystick(joystickId)) { + throw SDLException.useSDLError(this.sdl, "Failed to detach virtual joystick"); + } + + // wait for controlify event loop to realise its gone + this.context.waitTick(); + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerState.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerState.java new file mode 100644 index 000000000..de2ad48e9 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerState.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.gametest.framework; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.nio.ByteBuffer; + +public class TestControllerState implements TestControllerStateView { + private int playerIndex; + private short lowFrequencyRumble, highFrequencyRumble; + private short leftTriggerRumble, rightTriggerRumble; + private byte ledRed, ledGreen, ledBlue; + private @Nullable ByteBuffer latestEffect; + private @Nullable E effectState; + private boolean sensorsEnabled; + + private final GamepadEffectDriver effectDriver; + + public TestControllerState(GamepadEffectDriver effectDriver) { + this.effectDriver = effectDriver; + } + + @Override + public int getPlayerIndex() { + return this.playerIndex; + } + + public void setPlayerIndex(int playerIndex) { + this.playerIndex = playerIndex; + } + + @Override + public short getLowFrequencyRumble() { + return this.lowFrequencyRumble; + } + + @Override + public short getHighFrequencyRumble() { + return highFrequencyRumble; + } + + public void setRumble(short lowFrequencyRumble, short highFrequencyRumble) { + this.lowFrequencyRumble = lowFrequencyRumble; + this.highFrequencyRumble = highFrequencyRumble; + } + + @Override + public short getLeftTriggerRumble() { + return leftTriggerRumble; + } + + @Override + public short getRightTriggerRumble() { + return rightTriggerRumble; + } + + public void setTriggerRumble(short leftTriggerRumble, short rightTriggerRumble) { + this.leftTriggerRumble = leftTriggerRumble; + this.rightTriggerRumble = rightTriggerRumble; + } + + @Override + public byte getLedRed() { + return ledRed; + } + + @Override + public byte getLedGreen() { + return ledGreen; + } + + @Override + public byte getLedBlue() { + return ledBlue; + } + + public void setLed(byte red, byte green, byte blue) { + this.ledRed = red; + this.ledGreen = green; + this.ledBlue = blue; + } + + @Override + public @Nullable ByteBuffer getLatestEffect() { + return latestEffect; + } + + public void setLatestEffect(@NotNull ByteBuffer latestEffect) { + this.latestEffect = latestEffect; + this.effectState = this.effectDriver.receiveEffect(latestEffect); + } + + @Override + public E getGamepadEffectState() { + return effectState; + } + + @Override + public boolean getSensorsEnabled() { + return sensorsEnabled; + } + + public void setSensorsEnabled(boolean sensorsEnabled) { + this.sensorsEnabled = sensorsEnabled; + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerStateView.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerStateView.java new file mode 100644 index 000000000..1ba1b2afc --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerStateView.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.gametest.framework; + +import java.nio.ByteBuffer; + +public interface TestControllerStateView { + int getPlayerIndex(); + + short getLowFrequencyRumble(); + short getHighFrequencyRumble(); + + short getLeftTriggerRumble(); + short getRightTriggerRumble(); + + byte getLedRed(); + byte getLedGreen(); + byte getLedBlue(); + + ByteBuffer getLatestEffect(); + E getGamepadEffectState(); + + boolean getSensorsEnabled(); + + default boolean hasRumble() { + return getLowFrequencyRumble() != 0 || getHighFrequencyRumble() != 0; + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/VirtualControllerBuilder.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/VirtualControllerBuilder.java new file mode 100644 index 000000000..6c91737d3 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/VirtualControllerBuilder.java @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.gametest.framework; + +import dev.isxander.controlify.driver.sdl.SDLException; +import dev.isxander.controlify.test.framework.SdlVirtualJoystickDescBuilder; +import dev.isxander.sdl.*; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import org.jetbrains.annotations.Nullable; + +import java.nio.ByteBuffer; +import java.util.Set; + +@SuppressWarnings("UnstableApiUsage") +public class VirtualControllerBuilder { + private final ClientGameTestContext context; + private final ControlifyGameTestContext controlify; + private final Sdl sdl; + + private final SdlVirtualJoystickDescBuilder descBuilder; + private ControllerInputDefinition inputs = new ControllerInputDefinition(Set.of(), Set.of()); + private @Nullable String gamepadMapping = null; + + public VirtualControllerBuilder(ClientGameTestContext context, ControlifyGameTestContext controlify, Sdl sdl) { + this.context = context; + this.controlify = controlify; + this.sdl = sdl; + this.descBuilder = new SdlVirtualJoystickDescBuilder(); + } + + public VirtualControllerBuilder withXbox() { + this.withInputs(ControllerInputDefinition.XINPUT); + this.descBuilder + .withName("Virtual Xbox Controller") + .withVendorId(0x45e) + .withProductId(0x2ff); + return this; + } + + public VirtualControllerBuilder withDualsense() { + this.withInputs(ControllerInputDefinition.ALL_SDL_GAMEPAD_INPUTS); + this.descBuilder + .withName("Virtual Dualsense Controller") + .withVendorId(0x54c) + .withProductId(0xce6); + return this; + } + + public VirtualControllerBuilder withGyro() { + this.descBuilder + .withSensor(SdlGamepad.SDL_SENSOR_GYRO, 2000); + return this; + } + + public VirtualControllerBuilder withTouchpad(int numFingers) { + this.descBuilder + .withTouchpad(numFingers); + return this; + } + + public VirtualControllerBuilder withInputs(ControllerInputDefinition inputs) { + this.inputs = inputs; + this.descBuilder + .withGamepadType() + .withNumButtons(inputs.numButtons()) + .withNumAxes(inputs.numAxes()) + .withButtonMask(inputs.buttonMask()) + .withAxisMask(inputs.axisMask()); + return this; + } + + public VirtualControllerBuilder withGamepadMapping(String mapping) { + this.gamepadMapping = mapping; + return this; + } + + public TestControllerContext attach() { + return this.attachWithDriver(new GamepadEffectDriver.Noop()); + } + + public TestControllerContext attachWithDriver(@Nullable GamepadEffectDriver driver) { + var state = new TestControllerState<>(driver); + + SdlVirtualJoystickDesc virtualJoystickDesc = this.descBuilder + .withSetPlayerIndex((_, playerIndex) -> state.setPlayerIndex(playerIndex)) + .withRumble((_, low, high) -> { + state.setRumble(low, high); + return true; + }) + .withRumbleTriggers((_, left, right) -> { + state.setTriggerRumble(left, right); + return true; + }) + .withSetLed((_, red, green, blue) -> { + state.setLed(red, green, blue); + return true; + }) + .withSendEffect((_, effect) -> { + // copy to a heap-allocated buffer + var buffer = ByteBuffer.allocate(effect.remaining()); + buffer.put(effect).flip().asReadOnlyBuffer(); + + state.setLatestEffect(buffer); + return true; + }) + .withSetSensorsEnabled((_, enabled) -> { + state.setSensorsEnabled(enabled); + return true; + }) + .build(); + + SdlJoystickId jid = this.sdl.joystick().SDL_AttachVirtualJoystick(virtualJoystickDesc); + if (jid.value() == 0) { + throw SDLException.useSDLError(sdl, "Failed to attach virtual joystick"); + } + + if (this.gamepadMapping != null) { + this.sdl.gamepad().SDL_SetGamepadMapping(jid, this.gamepadMapping); + } + + SdlJoystickHandle handle = this.sdl.joystick().SDL_OpenJoystick(jid); + if (handle.isNull()) { + throw SDLException.useSDLError(sdl, "Failed to open virtual joystick"); + } + + // Allow Controlify to poll and add controller + this.context.waitTick(); + + return new TestControllerContext<>(this.context, this.sdl, handle, jid, this.inputs, state); + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/mixin/ControlifySettingsAccessor.java b/src/gametest/java/dev/isxander/controlify/gametest/mixin/ControlifySettingsAccessor.java new file mode 100644 index 000000000..9b627f2ab --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/mixin/ControlifySettingsAccessor.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.gametest.mixin; + +import dev.isxander.controlify.config.settings.ControlifySettings; +import dev.isxander.controlify.config.settings.GlobalSettings; +import dev.isxander.controlify.config.settings.device.DeviceSettings; +import dev.isxander.controlify.config.settings.profile.ProfileSettings; +import it.unimi.dsi.fastutil.ints.Int2ObjectSortedMap; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +import java.util.Map; + +@Mixin(ControlifySettings.class) +public interface ControlifySettingsAccessor { + @Accessor("globalSettings") + void controlify_test$setGlobalSettings(GlobalSettings globalSettings); + + @Accessor("profileSettings") + Int2ObjectSortedMap controlify_test$getProfileSettings(); + + @Accessor("deviceSettings") + Map controlify_test$getDeviceSettings(); +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerAccessor.java b/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerAccessor.java new file mode 100644 index 000000000..bd8a23aa6 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerAccessor.java @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.gametest.mixin; + +import dev.isxander.controlify.controllermanager.SDLControllerManager; +import dev.isxander.sdl.Sdl; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(SDLControllerManager.class) +public interface SDLControllerManagerAccessor { + @Accessor("sdl") + Sdl controlify_test$getSdl(); +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerMixin.java b/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerMixin.java new file mode 100644 index 000000000..fa6ab7d8c --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerMixin.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.gametest.mixin; + +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import dev.isxander.controlify.controller.ControllerEntity; +import dev.isxander.controlify.controllermanager.SDLControllerManager; +import dev.isxander.controlify.controllermanager.UniqueControllerID; +import dev.isxander.controlify.hid.ControllerHIDInfo; +import dev.isxander.controlify.utils.log.ControlifyLogger; +import dev.isxander.sdl.Sdl; +import dev.isxander.sdl.SdlJoystickId; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; + +import java.util.Optional; + +@Mixin(SDLControllerManager.class) +public class SDLControllerManagerMixin { + @Shadow + @Final + private Sdl sdl; + + /// Only load virtual joysticks in test environments + @WrapMethod(method = "createController") + private Optional preventNonVirtual( + UniqueControllerID ucid, + ControllerHIDInfo hidInfo, + ControlifyLogger controllerLogger, + Operation> original + ) { + SdlJoystickId jid = ((SDLControllerManager.SDLUniqueControllerID) ucid).jid(); + + if (!sdl.joystick().SDL_IsJoystickVirtual(jid)) { + controllerLogger.warn("Suppressing creation of controller as the test environment permits only virtual controllers"); + return Optional.empty(); + } + + return original.call(ucid, hidInfo, controllerLogger); + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/KeyboardLayoutTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/KeyboardLayoutTests.java new file mode 100644 index 000000000..78085c018 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/KeyboardLayoutTests.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.gametest.tests; + +import dev.isxander.controlify.gametest.framework.CTestUtil; +import dev.isxander.controlify.gametest.framework.ControlifyGameTestContext; +import dev.isxander.sdl.SdlGamepad; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import net.fabricmc.fabric.api.client.gametest.v1.screenshot.TestScreenshotComparisonAlgorithm; +import net.fabricmc.fabric.api.client.gametest.v1.screenshot.TestScreenshotComparisonOptions; +import net.minecraft.client.gui.screens.ChatScreen; +import net.minecraft.resources.Identifier; + +@SuppressWarnings("UnstableApiUsage") +public class KeyboardLayoutTests implements FabricClientGameTest, ClientModInitializer { + private static final Identifier RESOURCE_PACK_ID = CTestUtil.id("keyboard_layout_test"); + + @Override + public void onInitializeClient() { + CTestUtil.registerBuiltinResourcePack(RESOURCE_PACK_ID); + } + + @Override + public void runTest(ClientGameTestContext context) { + var controlify = new ControlifyGameTestContext(context); + + try (var world = context.worldBuilder() + .create()) { + world.getClientLevel().waitForChunksRender(); + + runLocaleTest(context, controlify); + } + } + + /// Ensures that the keyboard layout changes appropriately based on the + /// current language setting of the client. + /// + /// Applies a resource pack that makes en_us and en_gb very clearly different, + /// and swaps locales, comparing screenshots. + private void runLocaleTest(ClientGameTestContext context, ControlifyGameTestContext controlify) { + try (var controller = controlify.virtualControllerBuilder() + .withXbox() + .attach()) { + try (var _ = CTestUtil.applyResourcePack(context, RESOURCE_PACK_ID)) { + // Test with default en_us language + + // open on-screen keyboard + controller.tapButton(SdlGamepad.SDL_GAMEPAD_BUTTON_DPAD_UP); + context.waitForScreen(ChatScreen.class); + + // check american keyboard layout + context.assertScreenshotContains(TestScreenshotComparisonOptions + .of("keyboard_layout_en_us") + .save() + .withAlgorithm(TestScreenshotComparisonAlgorithm.meanSquaredDifference(0.001f))); + + // press back button to close on-screen keyboard + controller.tapButton(SdlGamepad.SDL_GAMEPAD_BUTTON_EAST); + context.waitForScreen(null); + + // Test with en_gb language + try (var _ = CTestUtil.setLanguage(context, "en_gb")) { + // open on-screen keyboard + controller.tapButton(SdlGamepad.SDL_GAMEPAD_BUTTON_DPAD_UP); + context.waitForScreen(ChatScreen.class); + + // check british keyboard layout + context.assertScreenshotContains(TestScreenshotComparisonOptions + .of("keyboard_layout_en_gb") + .save() + .withAlgorithm(TestScreenshotComparisonAlgorithm.meanSquaredDifference(0.001f))); + } + // change language without closing chat screen and check en_us loaded + context.assertScreenshotContains(TestScreenshotComparisonOptions + .of("keyboard_layout_en_us") + .save() + .withAlgorithm(TestScreenshotComparisonAlgorithm.meanSquaredDifference(0.001f))); + + // press back button to close on-screen keyboard + controller.tapButton(SdlGamepad.SDL_GAMEPAD_BUTTON_EAST); + context.waitForScreen(null); + } + } + + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/ManyHotplugTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/ManyHotplugTests.java new file mode 100644 index 000000000..2794c7992 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/ManyHotplugTests.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.gametest.tests; + +import dev.isxander.controlify.gametest.framework.CTestUtil; +import dev.isxander.controlify.gametest.framework.ControlifyGameTestContext; +import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; + +/// Ensures that there are no limits to the amount of controllers the game can witness in its lifetime. +/// There is the worry that for example SDL can only issue a certain amount of IDs, like #SDL-16. +/// This makes sure that is not the case. +@SuppressWarnings("UnstableApiUsage") +public class ManyHotplugTests implements FabricClientGameTest { + private static final int HOTPLUG_COUNT = 32; + + @Override + public void runTest(ClientGameTestContext context) { + var controlify = new ControlifyGameTestContext(context); + + for (int i = 0; i < HOTPLUG_COUNT; i++) { + try (var controller = controlify.virtualControllerBuilder() + .withXbox() + .attach()) { + context.waitTicks(3); + + // finds within ControllerManager if it exists + controller.getControllerEntity(); + } + CTestUtil.clearToasts(context); + } + + CTestUtil.resetControlifySettings(context); + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/ResetControlifySettingsTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/ResetControlifySettingsTests.java new file mode 100644 index 000000000..ea8fc70c3 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/ResetControlifySettingsTests.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.gametest.tests; + +import dev.isxander.controlify.Controlify; +import dev.isxander.controlify.controller.gyro.GyroState; +import dev.isxander.controlify.gametest.framework.CTestUtil; +import dev.isxander.controlify.gametest.framework.ControlifyGameTestContext; +import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; + +/// Ensures that {@link CTestUtil#resetControlifySettings(ClientGameTestContext)} +/// works appropriately, not leaving behind any stale references. +@SuppressWarnings("UnstableApiUsage") +public class ResetControlifySettingsTests implements FabricClientGameTest { + @Override + public void runTest(ClientGameTestContext context) { + var controlifyContext = new ControlifyGameTestContext(context); + + try (var controller = controlifyContext.virtualControllerBuilder() + .withXbox() + .attach()) { + var controlify = Controlify.instance(); + var controllerEntity = controller.getControllerEntity(); + var settings = controlify.config().getSettings(); + var cachedDeviceSettings = settings.getOrCreateDeviceSettings(controllerEntity.uid()); + + cachedDeviceSettings.gyroCalibration.offset = new GyroState(1f, 2f, 3f); + settings.globalSettings().mixedInput = true; + + CTestUtil.resetControlifySettings(context); + + if (settings.globalSettings().mixedInput) { + throw new AssertionError("Global settings were not reset"); + } + if (cachedDeviceSettings != settings.getOrCreateDeviceSettings(controllerEntity.uid())) { + throw new AssertionError("Connected controller device settings reference was replaced"); + } + if (!cachedDeviceSettings.gyroCalibration.offset.equals(new GyroState())) { + throw new AssertionError("Device settings were not reset"); + } + if (controllerEntity.settings() != controlify.config().getActiveProfile()) { + throw new AssertionError("Controller was not rebound to the default active profile"); + } + } + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java new file mode 100644 index 000000000..d864eed37 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java @@ -0,0 +1,191 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.gametest.tests; + +import dev.isxander.controlify.gametest.framework.CEntityTypes; +import dev.isxander.controlify.gametest.framework.CTestUtil; +import dev.isxander.controlify.gametest.framework.ControlifyGameTestContext; +import dev.isxander.controlify.gametest.framework.TestControllerContext; +import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import net.fabricmc.fabric.api.client.gametest.v1.context.TestSingleplayerContext; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.LightningBolt; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.level.Level; + +/// Tests the various in-game ways that a controller would receive a rumble effect. +/// To ensure all triggers are working properly. +/// +/// Current untested rumble triggers are: +/// - block breaking +/// - fishing (fish biting) +/// - item break +/// - ender dragon death +/// - wither death +/// - walking across slow block (e.g. soul sand) +/// - using items +/// - landing in water from a height +@SuppressWarnings("UnstableApiUsage") +public class RumbleTests implements FabricClientGameTest { + @Override + public void runTest(ClientGameTestContext context) { + var controlify = new ControlifyGameTestContext(context); + + try (var controller = controlify.virtualControllerBuilder() + .withXbox() + .attach()) { + try (var world = context.worldBuilder() + .setUseConsistentSettings(true) + .create()) { + world.getClientLevel().waitForChunksDownload(); + + runTest(context, controlify, controller, world, "Lightning bolt", this::testLightningBoltRumble); + runTest(context, controlify, controller, world, "Explosion", this::testExplosionRumble); + runTest(context, controlify, controller, world, "Outgoing damage", this::testOutgoingDamage); + runTest(context, controlify, controller, world, "Incoming damage", this::testIncomingDamage); + } + } + } + + /// Summons lightning bolt near the player, which should cause a rumble effect. + private void testLightningBoltRumble( + ClientGameTestContext context, + ControlifyGameTestContext controlify, + TestControllerContext controller, + TestSingleplayerContext world + ) { + world.getServer().runOnServer(server -> { + ServerPlayer player = CTestUtil.getPrincipalPlayer(server); + ServerLevel level = player.level(); + + LightningBolt bolt = CEntityTypes.LIGHTNING_BOLT.create(level, EntitySpawnReason.COMMAND); + bolt.snapTo(player.position()); + bolt.setVisualOnly(true); + level.addFreshEntity(bolt); + }); + + context.waitFor(_ -> controller.state().hasRumble(), 10); + } + + /// Summons an explosion near the player, which should cause a rumble. + /// Summons a second, distant, explosion, which should cause a lesser rumble. + private void testExplosionRumble( + ClientGameTestContext context, + ControlifyGameTestContext controlify, + TestControllerContext controller, + TestSingleplayerContext world + ) { + // Test that explosion causes rumble + + + world.getServer().runOnServer(server -> { + ServerPlayer player = CTestUtil.getPrincipalPlayer(server); + ServerLevel level = player.level(); + + level.explode(player, player.getX(), player.getY(), player.getZ(), 6f, Level.ExplosionInteraction.NONE); + }); + + context.waitFor(_ -> controller.state().hasRumble(), 10); + float highFreqRumble = controller.state().getHighFrequencyRumble(); + float lowFreqRumble = controller.state().getLowFrequencyRumble(); + + cleanupTest(context, controlify, controller, world); + + + // Test that distant explosion causes lesser rumble + + + world.getServer().runOnServer(server -> { + ServerPlayer player = CTestUtil.getPrincipalPlayer(server); + ServerLevel level = player.level(); + + level.explode(player, player.getX(), player.getY() + 10.0, player.getZ(), 6f, Level.ExplosionInteraction.NONE); + }); + + context.waitFor(_ -> controller.state().hasRumble(), 10); + if (controller.state().getHighFrequencyRumble() > highFreqRumble + || controller.state().getLowFrequencyRumble() > lowFreqRumble) { + throw new AssertionError("Distant explosion rumble should rumble with less intensity."); + } + } + + /// Makes the player apply damage to a mob, which should cause a rumble. + private void testOutgoingDamage( + ClientGameTestContext context, + ControlifyGameTestContext controlify, + TestControllerContext controller, + TestSingleplayerContext world + ) { + // Attack cow and wait for rumble + + + var cow = world.getServer().computeOnServer(server -> + CTestUtil.summonEntityAtPlayer(server, CEntityTypes.COW, c -> c.setNoAi(true))); + + context.waitTick(); + + context.runOnClient(minecraft -> minecraft.player.attack(cow)); + + context.waitFor(_ -> controller.state().hasRumble(), 10); + } + + /// Makes a mob apply damage to the player, which should cause a rumble. + private void testIncomingDamage( + ClientGameTestContext context, + ControlifyGameTestContext controlify, + TestControllerContext controller, + TestSingleplayerContext world + ) { + // Make slime attack player and wait for rumble + + + world.getServer().runOnServer(server -> { + ServerPlayer player = CTestUtil.getPrincipalPlayer(server); + var slime = CTestUtil.summonEntityAtPlayer(server, CEntityTypes.SLIME, c -> c.setNoAi(true)); + slime.getAttribute(Attributes.ATTACK_DAMAGE).setBaseValue(0.5); + slime.doHurtTarget(player.level(), player); + }); + + context.waitFor(_ -> controller.state().hasRumble(), 10); + } + + private void cleanupTest( + ClientGameTestContext context, + ControlifyGameTestContext controlify, + TestControllerContext controller, + TestSingleplayerContext world + ) { + // Wait for rumble to stop + context.waitFor(_ -> !controller.state().hasRumble()); + + world.getServer().runCommand("kill @e[type=!minecraft:player]"); + } + + private void runTest( + ClientGameTestContext context, + ControlifyGameTestContext controlify, + TestControllerContext controller, + TestSingleplayerContext world, + String testName, + RumbleTest test + ) { + try { + test.run(context, controlify, controller, world); + } catch (Throwable e) { + throw new AssertionError("Test '" + testName + "' failed", e); + } + + cleanupTest(context, controlify, controller, world); + } + + private interface RumbleTest { + void run(ClientGameTestContext context, ControlifyGameTestContext controlify, TestControllerContext controller, TestSingleplayerContext world); + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/VirtualControllerInputTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/VirtualControllerInputTests.java new file mode 100644 index 000000000..0eacd788a --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/VirtualControllerInputTests.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.gametest.tests; + +import dev.isxander.controlify.controller.input.GamepadInputs; +import dev.isxander.controlify.gametest.framework.CTestUtil; +import dev.isxander.controlify.gametest.framework.ControlifyGameTestContext; +import dev.isxander.sdl.SdlGamepad; +import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.resources.Identifier; + +import java.util.Map; + +/// A regression test: +/// +/// Tests the test framework's virtual button sending translates to the correct button IDs. +/// Without explicit mapping from logical button indices, sometimes a button would appear as +/// another on the mod side. +@SuppressWarnings("UnstableApiUsage") +public class VirtualControllerInputTests implements FabricClientGameTest { + private static final Map XINPUT_BUTTONS = Map.ofEntries( + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_SOUTH, GamepadInputs.SOUTH_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_EAST, GamepadInputs.EAST_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_WEST, GamepadInputs.WEST_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_NORTH, GamepadInputs.NORTH_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_BACK, GamepadInputs.BACK_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_START, GamepadInputs.START_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_LEFT_STICK, GamepadInputs.LEFT_STICK_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_RIGHT_STICK, GamepadInputs.RIGHT_STICK_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, GamepadInputs.LEFT_SHOULDER_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, GamepadInputs.RIGHT_SHOULDER_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_DPAD_UP, GamepadInputs.DPAD_UP_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_DPAD_DOWN, GamepadInputs.DPAD_DOWN_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_DPAD_LEFT, GamepadInputs.DPAD_LEFT_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_DPAD_RIGHT, GamepadInputs.DPAD_RIGHT_BUTTON) + ); + + @Override + public void runTest(ClientGameTestContext context) { + var controlify = new ControlifyGameTestContext(context); + + try (var controller = controlify.virtualControllerBuilder() + .withXbox() + .attach()) { + var controllerEntity = controller.getControllerEntity(); + var input = controllerEntity.input().orElseThrow(); + + for (var expected : XINPUT_BUTTONS.entrySet()) { + controller.holdButton(expected.getKey()); + context.waitFor(_ -> input.rawStateNow().isButtonDown(expected.getValue()), 10); + + for (var other : XINPUT_BUTTONS.values()) { + if (!other.equals(expected.getValue()) && input.rawStateNow().isButtonDown(other)) { + throw new AssertionError( + "Pressed " + expected.getValue() + " but also received " + other + ); + } + } + + controller.releaseButton(expected.getKey()); + context.waitFor(_ -> !input.rawStateNow().isButtonDown(expected.getValue()), 10); + } + } + + CTestUtil.resetControlifySettings(context); + CTestUtil.clearToasts(context); + context.setScreen(TitleScreen::new); + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/package-info.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/package-info.java new file mode 100644 index 000000000..e2c82f848 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.gametest.tests; + +import org.jspecify.annotations.NullMarked; diff --git a/src/gametest/resources/controlify_test.mixins.json b/src/gametest/resources/controlify_test.mixins.json new file mode 100644 index 000000000..b07d13e7b --- /dev/null +++ b/src/gametest/resources/controlify_test.mixins.json @@ -0,0 +1,20 @@ +{ + "package": "dev.isxander.controlify.gametest.mixin", + "required": true, + "minVersion": "0.8", + "mixinextras": { + "minVersion": "0.5.0" + }, + "injectors": { + "defaultRequire": 1 + }, + "compatibilityLevel": "JAVA_25", + "overwrites": { + "requireAnnotations": true + }, + "mixins": [ + "ControlifySettingsAccessor", + "SDLControllerManagerAccessor", + "SDLControllerManagerMixin" + ] +} diff --git a/src/gametest/resources/fabric.mod.json b/src/gametest/resources/fabric.mod.json new file mode 100644 index 000000000..6c6687ab3 --- /dev/null +++ b/src/gametest/resources/fabric.mod.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "id": "controlify_test", + "version": "1.0.0", + "name": "Controlify Test", + "icon": "icon.png", + "mixins": [ + "controlify_test.mixins.json" + ], + "entrypoints": { + "fabric-client-gametest": [ + "dev.isxander.controlify.gametest.tests.ManyHotplugTests", + "dev.isxander.controlify.gametest.tests.ResetControlifySettingsTests", + "dev.isxander.controlify.gametest.tests.VirtualControllerInputTests", + "dev.isxander.controlify.gametest.tests.KeyboardLayoutTests", + "dev.isxander.controlify.gametest.tests.RumbleTests" + ], + "client": [ + "dev.isxander.controlify.gametest.tests.KeyboardLayoutTests" + ] + } +} diff --git a/src/gametest/resources/resourcepacks/keyboard_layout_test/assets/controlify/keyboard_layout/full/en_gb.json b/src/gametest/resources/resourcepacks/keyboard_layout_test/assets/controlify/keyboard_layout/full/en_gb.json new file mode 100644 index 000000000..81cee5708 --- /dev/null +++ b/src/gametest/resources/resourcepacks/keyboard_layout_test/assets/controlify/keyboard_layout/full/en_gb.json @@ -0,0 +1,6 @@ +{ + "width": 7, + "keys": [ + ["b", "r", "i", "t", "i", "s", "h"] + ] +} diff --git a/src/gametest/resources/resourcepacks/keyboard_layout_test/assets/controlify/keyboard_layout/full/en_us.json b/src/gametest/resources/resourcepacks/keyboard_layout_test/assets/controlify/keyboard_layout/full/en_us.json new file mode 100644 index 000000000..938424245 --- /dev/null +++ b/src/gametest/resources/resourcepacks/keyboard_layout_test/assets/controlify/keyboard_layout/full/en_us.json @@ -0,0 +1,6 @@ +{ + "width": 7, + "keys": [ + ["a", "m", "e", "r", "i", "c", "a"] + ] +} diff --git a/src/gametest/resources/resourcepacks/keyboard_layout_test/pack.mcmeta b/src/gametest/resources/resourcepacks/keyboard_layout_test/pack.mcmeta new file mode 100644 index 000000000..d76e3c75c --- /dev/null +++ b/src/gametest/resources/resourcepacks/keyboard_layout_test/pack.mcmeta @@ -0,0 +1,9 @@ +{ + "pack": { + "description": "Keyboard layout test", + "supported_formats": [34, 10000], + "pack_format": 64, + "min_format": 34, + "max_format": [10000, 0] + } +} diff --git a/src/gametest/resources/templates/keyboard_layout_en_gb.png b/src/gametest/resources/templates/keyboard_layout_en_gb.png new file mode 100644 index 000000000..f00da5946 Binary files /dev/null and b/src/gametest/resources/templates/keyboard_layout_en_gb.png differ diff --git a/src/gametest/resources/templates/keyboard_layout_en_us.png b/src/gametest/resources/templates/keyboard_layout_en_us.png new file mode 100644 index 000000000..32739f6f2 Binary files /dev/null and b/src/gametest/resources/templates/keyboard_layout_en_us.png differ diff --git a/src/main/java/dev/isxander/controlify/Controlify.java b/src/main/java/dev/isxander/controlify/Controlify.java index eafc6c9d8..d22f9b828 100644 --- a/src/main/java/dev/isxander/controlify/Controlify.java +++ b/src/main/java/dev/isxander/controlify/Controlify.java @@ -387,7 +387,7 @@ private void onControllerAdded(ControllerEntity controller, boolean hotplugged) config().markDirty(); String selectedUid = config().getActiveProfile().controllerUid; - if (selectedUid != null && selectedUid.equals(controller.uid())) { + if (selectedUid == null || selectedUid.equals(controller.uid())) { this.setCurrentController(controller, true); } diff --git a/src/main/java/dev/isxander/controlify/utils/CUtil.java b/src/main/java/dev/isxander/controlify/utils/CUtil.java index 44bf485a8..52d1b5e26 100644 --- a/src/main/java/dev/isxander/controlify/utils/CUtil.java +++ b/src/main/java/dev/isxander/controlify/utils/CUtil.java @@ -143,6 +143,13 @@ public static float mapShortToFloat(short value) { + Mth.clampedMap(value, 0, Short.MAX_VALUE, 0f, 1f); } + public static short mapFloatToShort(float value) { + value = Mth.clamp(value, -1f, 1f); + + float scale = 32767.5f - 0.5f * Math.signum(value); + return (short) Math.round(value * scale); + } + public static boolean isInWithLocalFallback(TypedInstance type, HolderSet items) { return items.unwrapKey() .map(tag -> PlatformClientUtil.isInWithLocalFallback(tag, type.typeHolder())) diff --git a/src/test/java/dev/isxander/controlify/test/ControlifyUnitTest.java b/src/test/java/dev/isxander/controlify/test/ControlifyUnitTest.java new file mode 100644 index 000000000..0acf2515d --- /dev/null +++ b/src/test/java/dev/isxander/controlify/test/ControlifyUnitTest.java @@ -0,0 +1,16 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.test; + + +import org.junit.jupiter.api.Test; + +public class ControlifyUnitTest { + @Test + void unitTest() { + } +} diff --git a/src/test/java/dev/isxander/controlify/test/framework/SdlVirtualJoystickDescBuilder.java b/src/test/java/dev/isxander/controlify/test/framework/SdlVirtualJoystickDescBuilder.java new file mode 100644 index 000000000..91649aa81 --- /dev/null +++ b/src/test/java/dev/isxander/controlify/test/framework/SdlVirtualJoystickDescBuilder.java @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.test.framework; + +import dev.isxander.sdl.*; + +import java.util.ArrayList; +import java.util.List; + +public class SdlVirtualJoystickDescBuilder { + private int type; + private int vendorId; + private int productId; + private int numAxes; + private int numButtons; + private int numBalls; + private int numHats; + private int buttonMask; + private int axisMask; + private String name; + private final List touchpads = new ArrayList<>(); + private final List sensors = new ArrayList<>(); + private SdlPointer userdata; + private SdlCallbacks.VirtualJoystickUpdateCallback update = (_) -> {}; + private SdlCallbacks.VirtualJoystickSetPlayerIndexCallback setPlayerIndex = (_, _) -> {}; + private SdlCallbacks.VirtualJoystickRumbleCallback rumble = (_, _, _) -> false; + private SdlCallbacks.VirtualJoystickRumbleTriggersCallback rumbleTriggers = (_, _, _) -> false; + private SdlCallbacks.VirtualJoystickSetLedCallback setLed = (_, _, _, _) -> false; + private SdlCallbacks.VirtualJoystickSendEffectCallback sendEffect = (_, _) -> false; + private SdlCallbacks.VirtualJoystickSetSensorsEnabledCallback setSensorsEnabled = (_, _) -> false; + private SdlCallbacks.VirtualJoystickCleanupCallback cleanup = (_) -> {}; + + public SdlVirtualJoystickDescBuilder withType(int type) { + this.type = type; + return this; + } + + public SdlVirtualJoystickDescBuilder withGamepadType() { + return this.withType(SdlJoystick.SDL_JOYSTICK_TYPE_GAMEPAD); + } + + public SdlVirtualJoystickDescBuilder withVendorId(int vendorId) { + this.vendorId = vendorId; + return this; + } + + public SdlVirtualJoystickDescBuilder withProductId(int productId) { + this.productId = productId; + return this; + } + + public SdlVirtualJoystickDescBuilder withNumAxes(int numAxes) { + this.numAxes = numAxes; + return this; + } + + public SdlVirtualJoystickDescBuilder withNumButtons(int numButtons) { + this.numButtons = numButtons; + return this; + } + + public SdlVirtualJoystickDescBuilder withNumBalls(int numBalls) { + this.numBalls = numBalls; + return this; + } + + public SdlVirtualJoystickDescBuilder withNumHats(int numHats) { + this.numHats = numHats; + return this; + } + + public SdlVirtualJoystickDescBuilder withButtonMask(int buttonMask) { + this.buttonMask = buttonMask; + return this; + } + + public SdlVirtualJoystickDescBuilder withAxisMask(int axisMask) { + this.axisMask = axisMask; + return this; + } + + public SdlVirtualJoystickDescBuilder withName(String name) { + this.name = name; + return this; + } + + public SdlVirtualJoystickDescBuilder withTouchpad(SdlVirtualJoystickTouchpadDesc touchpad) { + this.touchpads.add(touchpad); + return this; + } + + public SdlVirtualJoystickDescBuilder withTouchpad(int numFingers) { + return this.withTouchpad(new SdlVirtualJoystickTouchpadDesc(numFingers)); + } + + public SdlVirtualJoystickDescBuilder withSensor(SdlVirtualJoystickSensorDesc sensor) { + this.sensors.add(sensor); + return this; + } + + public SdlVirtualJoystickDescBuilder withSensor(int type, float rate) { + return this.withSensor(new SdlVirtualJoystickSensorDesc(type, rate)); + } + + public SdlVirtualJoystickDescBuilder withUserdata(SdlPointer userdata) { + this.userdata = userdata; + return this; + } + + public SdlVirtualJoystickDescBuilder withUpdate(SdlCallbacks.VirtualJoystickUpdateCallback update) { + this.update = update; + return this; + } + + public SdlVirtualJoystickDescBuilder withSetPlayerIndex(SdlCallbacks.VirtualJoystickSetPlayerIndexCallback setPlayerIndex) { + this.setPlayerIndex = setPlayerIndex; + return this; + } + + public SdlVirtualJoystickDescBuilder withRumble(SdlCallbacks.VirtualJoystickRumbleCallback rumble) { + this.rumble = rumble; + return this; + } + + public SdlVirtualJoystickDescBuilder withRumbleTriggers(SdlCallbacks.VirtualJoystickRumbleTriggersCallback rumbleTriggers) { + this.rumbleTriggers = rumbleTriggers; + return this; + } + + public SdlVirtualJoystickDescBuilder withSetLed(SdlCallbacks.VirtualJoystickSetLedCallback setLed) { + this.setLed = setLed; + return this; + } + + public SdlVirtualJoystickDescBuilder withSendEffect(SdlCallbacks.VirtualJoystickSendEffectCallback sendEffect) { + this.sendEffect = sendEffect; + return this; + } + + public SdlVirtualJoystickDescBuilder withSetSensorsEnabled(SdlCallbacks.VirtualJoystickSetSensorsEnabledCallback setSensorsEnabled) { + this.setSensorsEnabled = setSensorsEnabled; + return this; + } + + public SdlVirtualJoystickDescBuilder withCleanup(SdlCallbacks.VirtualJoystickCleanupCallback cleanup) { + this.cleanup = cleanup; + return this; + } + + public SdlVirtualJoystickDesc build() { + return new SdlVirtualJoystickDesc( + this.type, + this.vendorId, + this.productId, + this.numAxes, + this.numButtons, + this.numBalls, + this.numHats, + this.buttonMask, + this.axisMask, + this.name, + this.touchpads.toArray(SdlVirtualJoystickTouchpadDesc[]::new), + this.sensors.toArray(SdlVirtualJoystickSensorDesc[]::new), + this.userdata, + this.update, + this.setPlayerIndex, + this.rumble, + this.rumbleTriggers, + this.setLed, + this.sendEffect, + this.setSensorsEnabled, + this.cleanup + ); + } +}