diff --git a/src/main/java/com/lambda/mixin/network/ClientCommonNetworkHandlerMixin.java b/src/main/java/com/lambda/mixin/network/ClientCommonNetworkHandlerMixin.java
new file mode 100644
index 000000000..418c6b30c
--- /dev/null
+++ b/src/main/java/com/lambda/mixin/network/ClientCommonNetworkHandlerMixin.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2026 Lambda
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.lambda.mixin.network;
+
+import com.lambda.module.modules.combat.AutoDisconnect;
+import com.lambda.module.modules.combat.autodisconnect.AutoDisconnectScreen;
+import com.lambda.module.modules.combat.DisconnectDetails;
+import net.minecraft.client.gui.screen.Screen;
+import net.minecraft.client.network.ClientCommonNetworkHandler;
+import net.minecraft.network.DisconnectionInfo;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
+
+@Mixin(value = ClientCommonNetworkHandler.class)
+public class ClientCommonNetworkHandlerMixin {
+ @Inject(method = "createDisconnectedScreen", at = @At("HEAD"), cancellable = true)
+ private void createDisconnectedScreen(DisconnectionInfo info, CallbackInfoReturnable cir) {
+ DisconnectDetails details = AutoDisconnect.INSTANCE.consumeDetails();
+ if (details != null) {
+ cir.setReturnValue(new AutoDisconnectScreen(details));
+ }
+
+ }
+
+}
diff --git a/src/main/java/com/lambda/mixin/render/ConnectScreenMixin.java b/src/main/java/com/lambda/mixin/render/ConnectScreenMixin.java
new file mode 100644
index 000000000..a07ac0560
--- /dev/null
+++ b/src/main/java/com/lambda/mixin/render/ConnectScreenMixin.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2026 Lambda
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.lambda.mixin.render;
+
+import com.lambda.module.modules.combat.AutoDisconnect;
+import com.lambda.module.modules.combat.MultiplayerReconnectTarget;
+import net.minecraft.client.gui.screen.multiplayer.ConnectScreen;
+import net.minecraft.client.network.CookieStorage;
+import net.minecraft.client.network.ServerAddress;
+import net.minecraft.client.network.ServerInfo;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+@Mixin(value = ConnectScreen.class)
+public class ConnectScreenMixin {
+
+ @Inject(method = "connect(Lnet/minecraft/client/MinecraftClient;Lnet/minecraft/client/network/ServerAddress;Lnet/minecraft/client/network/ServerInfo;Lnet/minecraft/client/network/CookieStorage;)V", at= @At("HEAD"))
+ private void connectHead(
+ net.minecraft.client.MinecraftClient client,
+ ServerAddress address,
+ ServerInfo info,
+ CookieStorage cookieStorage,
+ CallbackInfo ci
+ ) {
+ AutoDisconnect.INSTANCE.setLastReconnectTarget(new MultiplayerReconnectTarget(address, info, cookieStorage));
+ }
+}
diff --git a/src/main/java/com/lambda/mixin/render/GameRendererMixin.java b/src/main/java/com/lambda/mixin/render/GameRendererMixin.java
index 6c5fb87d6..7798c6de2 100644
--- a/src/main/java/com/lambda/mixin/render/GameRendererMixin.java
+++ b/src/main/java/com/lambda/mixin/render/GameRendererMixin.java
@@ -27,15 +27,20 @@
import com.lambda.module.modules.render.Bobbing;
import com.lambda.module.modules.render.NoRender;
import com.lambda.module.modules.render.Zoom;
+import com.lambda.util.render.CursorOverrideProvider;
import com.llamalad7.mixinextras.injector.ModifyExpressionValue;
import com.llamalad7.mixinextras.injector.ModifyReturnValue;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import com.mojang.blaze3d.buffers.GpuBufferSlice;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.gui.DrawContext;
+import net.minecraft.client.gui.cursor.Cursor;
import net.minecraft.client.render.Camera;
import net.minecraft.client.render.GameRenderer;
import net.minecraft.client.render.RenderTickCounter;
import net.minecraft.client.render.WorldRenderer;
+import net.minecraft.client.util.Window;
import net.minecraft.client.util.ObjectAllocator;
import net.minecraft.item.ItemStack;
import org.joml.Matrix4f;
@@ -49,6 +54,8 @@
@Mixin(GameRenderer.class)
public class GameRendererMixin {
+ private static boolean lambda$forcedCursorActive;
+
@Inject(method = "updateCrosshairTarget(F)V", at = @At("HEAD"), cancellable = true)
private void updateTargetedEntityInvoke(float tickDelta, CallbackInfo info) {
if (EventFlow.post(new RenderEvent.UpdateTarget()).isCanceled()) {
@@ -89,6 +96,29 @@ private void onGuiRenderComplete(RenderTickCounter tickCounter, boolean tick, Ca
DearImGui.INSTANCE.render();
}
+ @WrapOperation(method = "render", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/gui/DrawContext;applyCursorTo(Lnet/minecraft/client/util/Window;)V"))
+ private void applyCursorOverride(DrawContext context, Window window, Operation original) {
+ original.call(context, window);
+
+ MinecraftClient client = MinecraftClient.getInstance();
+ if (client.currentScreen instanceof CursorOverrideProvider provider) {
+ int mouseX = (int) client.mouse.getScaledX(window);
+ int mouseY = (int) client.mouse.getScaledY(window);
+ Cursor cursor = provider.getCursorOverride(mouseX, mouseY);
+
+ if (cursor != null) {
+ cursor.applyTo(window);
+ lambda$forcedCursorActive = true;
+ return;
+ }
+ }
+
+ if (lambda$forcedCursorActive) {
+ Cursor.DEFAULT.applyTo(window);
+ lambda$forcedCursorActive = false;
+ }
+ }
+
@Inject(method = "shouldRenderBlockOutline()Z", at = @At("HEAD"), cancellable = true)
private void injectShouldRenderBlockOutline(CallbackInfoReturnable cir) {
if (BlockOutline.INSTANCE.isEnabled()) cir.setReturnValue(false);
diff --git a/src/main/java/com/lambda/mixin/world/IntegratedServerLoaderMixin.java b/src/main/java/com/lambda/mixin/world/IntegratedServerLoaderMixin.java
new file mode 100644
index 000000000..eb9192fd9
--- /dev/null
+++ b/src/main/java/com/lambda/mixin/world/IntegratedServerLoaderMixin.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2026 Lambda
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.lambda.mixin.world;
+
+import com.lambda.module.modules.combat.AutoDisconnect;
+import com.lambda.module.modules.combat.SingleplayerReconnectTarget;
+import net.minecraft.server.integrated.IntegratedServerLoader;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+@Mixin(IntegratedServerLoader.class)
+public class IntegratedServerLoaderMixin {
+ @Inject(method = "start(Ljava/lang/String;Ljava/lang/Runnable;)V", at = @At("HEAD"))
+ private void onStart(String name, Runnable onCancel, CallbackInfo ci) {
+ AutoDisconnect.INSTANCE.setLastReconnectTarget(new SingleplayerReconnectTarget(name));
+ }
+
+}
diff --git a/src/main/java/com/lambda/util/render/CursorOverrideProvider.java b/src/main/java/com/lambda/util/render/CursorOverrideProvider.java
new file mode 100644
index 000000000..dc78e3a43
--- /dev/null
+++ b/src/main/java/com/lambda/util/render/CursorOverrideProvider.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2026 Lambda
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.lambda.util.render;
+
+import net.minecraft.client.gui.cursor.Cursor;
+
+public interface CursorOverrideProvider {
+ Cursor getCursorOverride(int mouseX, int mouseY);
+}
diff --git a/src/main/kotlin/com/lambda/module/modules/combat/AutoDisconnect.kt b/src/main/kotlin/com/lambda/module/modules/combat/AutoDisconnect.kt
index ef5ab52c1..4d57ed068 100644
--- a/src/main/kotlin/com/lambda/module/modules/combat/AutoDisconnect.kt
+++ b/src/main/kotlin/com/lambda/module/modules/combat/AutoDisconnect.kt
@@ -17,6 +17,7 @@
package com.lambda.module.modules.combat
+import com.lambda.Lambda
import com.lambda.context.SafeContext
import com.lambda.event.events.PlayerEvent
import com.lambda.event.events.TickEvent
@@ -28,6 +29,7 @@ import com.lambda.sound.SoundManager.playSound
import com.lambda.util.Communication
import com.lambda.util.Communication.prefix
import com.lambda.util.Formatting.format
+import com.lambda.util.NamedEnum
import com.lambda.util.combat.CombatUtils.hasDeadlyCrystal
import com.lambda.util.combat.DamageUtils.isFallDeadly
import com.lambda.util.extension.fullHealth
@@ -39,51 +41,79 @@ import com.lambda.util.text.highlighted
import com.lambda.util.text.literal
import com.lambda.util.text.text
import com.lambda.util.world.fastEntitySearch
+import net.minecraft.client.network.CookieStorage
+import net.minecraft.client.network.ServerAddress
+import net.minecraft.client.network.ServerInfo
+import net.minecraft.client.texture.NativeImageBackedTexture
+import net.minecraft.client.util.ScreenshotRecorder
import net.minecraft.entity.damage.DamageSource
import net.minecraft.entity.damage.DamageTypes
import net.minecraft.entity.effect.StatusEffects
import net.minecraft.entity.mob.CreeperEntity
import net.minecraft.entity.player.PlayerEntity
import net.minecraft.item.Items
+import net.minecraft.network.message.LastSeenMessageList
+import net.minecraft.network.packet.c2s.play.ChatMessageC2SPacket
+import net.minecraft.network.packet.c2s.play.UpdateSelectedSlotC2SPacket
import net.minecraft.sound.SoundEvents
import net.minecraft.text.Text
+import net.minecraft.util.Identifier
import net.minecraft.world.GameMode
import java.awt.Color
+import java.time.Instant
+import java.util.BitSet
object AutoDisconnect : Module(
name = "AutoDisconnect",
description = "Automatically disconnects when in danger or on low health",
tag = ModuleTag.COMBAT,
) {
- private val health by setting("Health", true, "Disconnect from the server when health is below the set limit.")
- private val minimumHealth by setting("Min Health", 10, 1..36, 1, "Set the minimum health threshold for disconnection.", unit = " half-hearts") { health }
- private val yLevel by setting("Y Level", false, "Disconnect from the server when the player is below a certain y level")
- private val minimumYLevel by setting("Minimum Y Level", 50, 0..319, 1, "The minimum y level the player can be at before disconnecting") { yLevel }
- private val falls by setting("Falls", false, "Disconnect if the player will die of fall damage")
- private val fallDistance by setting("Falls Time", 10, 0..30, 1, "Number of blocks fallen before disconnecting for fall damage.", unit = " blocks") { falls }
- private val crystals by setting("Crystals", false, "Disconnect if an End Crystal explosion would be lethal.")
- private val creeper by setting("Creepers", true, "Disconnect when an ignited Creeper is nearby.")
- private val totem by setting("Totem", false, "Disconnect if the number of Totems of Undying is below the required amount.")
- private val minTotems by setting("Min Totems", 2, 1..10, 1, "Set the minimum number of Totems of Undying required to prevent disconnection.") { totem }
- private val players by setting("Players", false, "Disconnect if a nearby player is detected within the set distance.")
- private val minPlayerDistance by setting("Player Distance", 64, 32..128, 4, "Set the distance to detect players for disconnection.") { players }
- private val friends by setting("Friends", false, "Exclude friends from triggering player-based disconnections.") { players }
+ private const val INVALID_HOTBAR_SLOT = 42
+ private const val IMPOSSIBLE_CHAT_TIMESTAMP = -1L
- private val onDamage by setting("On Damage", false, "Disconnect from the server when you take damage.")
+ private enum class Group(override val displayName: String) : NamedEnum {
+ General("General"),
+ DisconnectConditions("Conditions"),
+ }
+
+ private val health by setting("Health", true, "Disconnect from the server when health is below the set limit.").group(Group.DisconnectConditions)
+ private val minimumHealth by setting("Min Health", 10, 1..36, 1, "Set the minimum health threshold for disconnection.", unit = " half-hearts") { health }.group(Group.DisconnectConditions)
+ private val yLevel by setting("Y Level", false, "Disconnect from the server when the player is below a certain y level").group(Group.DisconnectConditions)
+ private val minimumYLevel by setting("Minimum Y Level", 50, 0..319, 1, "The minimum y level the player can be at before disconnecting") { yLevel }.group(Group.DisconnectConditions)
+ private val falls by setting("Falls", false, "Disconnect if the player will die of fall damage").group(Group.DisconnectConditions)
+ private val fallDistance by setting("Falls Time", 10, 0..30, 1, "Number of blocks fallen before disconnecting for fall damage.", unit = " blocks") { falls }.group(Group.DisconnectConditions)
+ private val crystals by setting("Crystals", false, "Disconnect if an End Crystal explosion would be lethal.").group(Group.DisconnectConditions)
+ private val creeper by setting("Creepers", true, "Disconnect when an ignited Creeper is nearby.").group(Group.DisconnectConditions)
+ private val totem by setting("Totem", false, "Disconnect if the number of Totems of Undying is below the required amount.").group(Group.DisconnectConditions)
+ private val minTotems by setting("Min Totems", 2, 1..10, 1, "Set the minimum number of Totems of Undying required to prevent disconnection.") { totem }.group(Group.DisconnectConditions)
+ private val players by setting("Players", false, "Disconnect if a nearby player is detected within the set distance.").group(Group.DisconnectConditions)
+ private val minPlayerDistance by setting("Player Distance", 64, 32..128, 4, "Set the distance to detect players for disconnection.") { players }.group(Group.DisconnectConditions)
+ private val friends by setting("Friends", false, "Exclude friends from triggering player-based disconnections.") { players }.group(Group.DisconnectConditions)
+
+ private val onDamage by setting("On Damage", false, "Disconnect from the server when you take damage.").group(Group.DisconnectConditions)
// ToDo: Only those DamageTypes are reported by the server. why?
- private val generic by setting("Generic", false, "Disconnect from the server when you get generic damage. (will always trigger!)") { onDamage }
- private val inFire by setting("Burning", false, "Disconnect from the server when you take fire damage.") { onDamage }
- private val lava by setting("Lava", false, "Disconnect from the server when you get lava.") { onDamage }
- private val hotFloor by setting("Hot Floor", false, "Disconnect from the server when you get hot floor.") { onDamage }
- private val drown by setting("Drown", false, "Disconnect from the server when you get drown.") { onDamage }
- private val cactus by setting("Cactus", false, "Disconnect from the server when you get cactus.") { onDamage }
- private val fall by setting("Fall", false, "Disconnect from the server when you fall.") { onDamage }
- private val outOfWorld by setting("Out of World", false, "Disconnect from the server when you get out of the world.") { onDamage }
- private val wither by setting("Wither", false, "Disconnect from the server when you get wither damage.") { onDamage }
- private val stalagmite by setting("Stalagmite", false, "Disconnect from the server when you get stalagmite damage.") { onDamage }
- private val arrow by setting("Arrow", false, "Disconnect from the server when you get arrow damage.") { onDamage }
- private val trident by setting("Trident", false, "Disconnect from the server when you get trident damage.") { onDamage }
+ private val generic by setting("Generic", false, "Disconnect from the server when you get generic damage. (will always trigger!)") { onDamage }.group(Group.DisconnectConditions)
+ private val inFire by setting("Burning", false, "Disconnect from the server when you take fire damage.") { onDamage }.group(Group.DisconnectConditions)
+ private val lava by setting("Lava", false, "Disconnect from the server when you get lava.") { onDamage }.group(Group.DisconnectConditions)
+ private val hotFloor by setting("Hot Floor", false, "Disconnect from the server when you get hot floor.") { onDamage }.group(Group.DisconnectConditions)
+ private val drown by setting("Drown", false, "Disconnect from the server when you get drown.") { onDamage }.group(Group.DisconnectConditions)
+ private val cactus by setting("Cactus", false, "Disconnect from the server when you get cactus.") { onDamage }.group(Group.DisconnectConditions)
+ private val fall by setting("Fall", false, "Disconnect from the server when you fall.") { onDamage }.group(Group.DisconnectConditions)
+ private val outOfWorld by setting("Out of World", false, "Disconnect from the server when you get out of the world.") { onDamage }.group(Group.DisconnectConditions)
+ private val wither by setting("Wither", false, "Disconnect from the server when you get wither damage.") { onDamage }.group(Group.DisconnectConditions)
+ private val stalagmite by setting("Stalagmite", false, "Disconnect from the server when you get stalagmite damage.") { onDamage }.group(Group.DisconnectConditions)
+ private val arrow by setting("Arrow", false, "Disconnect from the server when you get arrow damage.") { onDamage }.group(Group.DisconnectConditions)
+ private val trident by setting("Trident", false, "Disconnect from the server when you get trident damage.") { onDamage }.group(Group.DisconnectConditions)
+
+ private val hideDetails by setting("Hide Details on Disconnect Screen", false, "Initially hide all details on the disconnect screen").group(Group.General)
+ private val invalidHotbarDisconnect by setting("Invalid Hotbar Disconnect", false, "Sends an invalid hotbar selection to force the server to kick the player").group(Group.General)
+ private val attackSelfDisconnect by setting("Attack Self Disconnect", false, "Sends an attack self packet to force the server to kick the player").group(Group.General)
+ private val impossibleTimestampChatDisconnect by setting("Impossible Timestamp Chat Disconnect", false, "Sends a chat message with an impossible timestamp to force the server to kick the player").group(Group.General)
+
+ private var disconnectDetails: DisconnectDetails? = null
+ var lastReconnectTarget: ReconnectTarget? = null
+ var isTakingScreenshot: Boolean = false
init {
setModulePriority(-100)
@@ -153,10 +183,60 @@ object AutoDisconnect : Module(
}
private fun SafeContext.disconnect(reasonText: Text, reason: Reason? = null) {
- if (player.gameMode != GameMode.SURVIVAL && player.gameMode != GameMode.ADVENTURE) return
+ if (player.gameMode != GameMode.SURVIVAL && player.gameMode != GameMode.ADVENTURE || isTakingScreenshot) return
if (reason == Reason.Health || reason == Reason.Totem) disable()
- connection.connection.disconnect(generateInfo(reasonText))
- playSound(SoundEvents.BLOCK_ANVIL_LAND)
+ isTakingScreenshot = true
+ ScreenshotRecorder.takeScreenshot(Lambda.mc.framebuffer, 1) { image ->
+ val imageIdentifier = Identifier.of("lambda", "auto_disconnect_screenshot")
+ val texture = NativeImageBackedTexture({ "auto-disconnect-screenshot" }, image)
+ mc.textureManager.registerTexture(imageIdentifier, texture)
+ disconnectDetails = DisconnectDetails(
+ imageIdentifier = imageIdentifier,
+ imageHeight = image.height,
+ imageWidth = image.width,
+ reason = reasonText,
+ details = generateInfo(reasonText),
+ hideDetails = hideDetails
+ )
+
+ sendForcedDisconnectPackets()
+ connection.connection.disconnect(generateInfo(reasonText))
+
+ playSound(SoundEvents.BLOCK_ANVIL_LAND)
+ isTakingScreenshot = false
+ }
+ }
+
+ private fun SafeContext.sendForcedDisconnectPackets() {
+ if (invalidHotbarDisconnect) {
+ connection.sendPacket(UpdateSelectedSlotC2SPacket(INVALID_HOTBAR_SLOT))
+ }
+
+ if (attackSelfDisconnect) {
+ interaction.attackEntity(player, player)
+ }
+
+ if (impossibleTimestampChatDisconnect) {
+ connection.sendPacket(
+ ChatMessageC2SPacket(
+ "",
+ Instant.ofEpochSecond(IMPOSSIBLE_CHAT_TIMESTAMP),
+ 0L,
+ null,
+ LastSeenMessageList.Acknowledgment(
+ 0,
+ BitSet.valueOf(ByteArray(LastSeenMessageList.MAX_ENTRIES)),
+ LastSeenMessageList.Acknowledgment.NO_CHECKSUM
+ )
+ )
+ )
+ }
+ }
+
+ fun consumeDetails(): DisconnectDetails? {
+ val details = disconnectDetails
+ disconnectDetails = null
+ return details
}
private fun SafeContext.generateInfo(text: Text) = buildText {
@@ -272,3 +352,24 @@ object AutoDisconnect : Module(
})
}
}
+
+data class DisconnectDetails(
+ val imageIdentifier: Identifier,
+ val imageHeight: Int,
+ val imageWidth: Int,
+ val reason: Text,
+ val details: Text,
+ val hideDetails: Boolean
+)
+
+sealed interface ReconnectTarget
+
+data class MultiplayerReconnectTarget(
+ val address: ServerAddress,
+ val info: ServerInfo,
+ val cookieStorage: CookieStorage
+) : ReconnectTarget
+
+data class SingleplayerReconnectTarget(
+ val levelName: String
+) : ReconnectTarget
diff --git a/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnectScreen.kt b/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnectScreen.kt
new file mode 100644
index 000000000..39ef80a5a
--- /dev/null
+++ b/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnectScreen.kt
@@ -0,0 +1,540 @@
+/*
+ * Copyright 2026 Lambda
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.lambda.module.modules.combat.autodisconnect
+
+import com.lambda.module.modules.combat.AutoDisconnect
+import com.lambda.module.modules.combat.DisconnectDetails
+import com.lambda.module.modules.combat.MultiplayerReconnectTarget
+import com.lambda.module.modules.combat.SingleplayerReconnectTarget
+import com.lambda.util.render.CursorOverrideProvider
+import net.minecraft.client.gl.RenderPipelines
+import net.minecraft.client.gui.Click
+import net.minecraft.client.gui.DrawContext
+import net.minecraft.client.gui.cursor.Cursor
+import net.minecraft.client.gui.cursor.StandardCursors
+import net.minecraft.client.gui.screen.Screen
+import net.minecraft.client.gui.screen.TitleScreen
+import net.minecraft.client.gui.screen.multiplayer.ConnectScreen
+import net.minecraft.client.gui.screen.multiplayer.MultiplayerScreen
+import net.minecraft.client.gui.screen.world.SelectWorldScreen
+import net.minecraft.client.gui.widget.ButtonWidget
+import net.minecraft.client.gui.widget.ScrollableTextWidget
+import net.minecraft.text.Text
+import kotlin.math.min
+
+class AutoDisconnectScreen(private val details: DisconnectDetails) :
+ Screen(Text.literal("Disconnected: ").append(details.reason)) {
+ //state
+ private val parent = TitleScreen()
+ private var showDetails = !details.hideDetails
+
+ //text
+ private lateinit var detailText: ScrollableTextWidget
+
+ //buttons
+ private lateinit var toggleDetailsButton: ButtonWidget
+ private lateinit var fullScreenButton: ButtonWidget
+ private lateinit var reconnectButton: ButtonWidget
+ private lateinit var titleScreenButton: ButtonWidget
+ private lateinit var worldListButton: ButtonWidget
+ private lateinit var quitButton: ButtonWidget
+
+ //image
+ private var previewBounds = Bounds.EMPTY
+ private var keepTextureOnRemove = false
+ private var textureReleased = false
+
+ override fun init() {
+ super.init()
+
+ detailText = addDrawableChild(
+ ScrollableTextWidget(0, 0, 0, 0, details.details, textRenderer)
+ )
+
+ toggleDetailsButton = addButton(detailToggleText()) { showDetails = !showDetails; updateDetailVisibility() }
+ fullScreenButton = addButton(Text.literal("View Full Screen")) { openImagePreview() }
+ reconnectButton = addButton(Text.literal("Reconnect")) { reconnect() }
+ titleScreenButton = addButton(Text.literal("Title Screen")) { releaseTexture(); client?.setScreen(parent) }
+ worldListButton = addButton(listButtonText()) { openWorldListScreen() }
+ quitButton = addButton(Text.literal("Rage Quit")) { releaseTexture(); this.client.scheduleStop() }
+
+ updateLayout()
+ updateDetailVisibility()
+ updateReconnectButton()
+ }
+
+ override fun render(context: DrawContext, mouseX: Int, mouseY: Int, deltaTicks: Float) {
+ renderDarkening(context) //aka, render the page background
+ context.drawTextWithShadow(textRenderer, title, MARGIN, MARGIN, 0xFFFFFFFF.toInt())
+ super.render(context, mouseX, mouseY, deltaTicks)
+
+ if (showDetails) {
+ drawScreenshot(context, previewBounds)
+ }
+ }
+
+ override fun mouseClicked(click: Click, doubled: Boolean): Boolean {
+ if (showDetails && previewBounds.contains(click.x().toInt(), click.y().toInt())) {
+ openImagePreview()
+ return true
+ }
+
+ return super.mouseClicked(click, doubled)
+ }
+
+ override fun close() {
+ releaseTexture()
+ client?.setScreen(parent)
+ }
+
+ override fun removed() {
+ if (keepTextureOnRemove) {
+ keepTextureOnRemove = false
+ return
+ }
+
+ releaseTexture()
+ }
+
+ private fun openImagePreview() {
+ keepTextureOnRemove = true
+ client?.setScreen(ImagePreviewScreen(this, details))
+ }
+
+ private fun reconnect() {
+ releaseTexture()
+ when (val target = AutoDisconnect.lastReconnectTarget) {
+ is MultiplayerReconnectTarget -> {
+ ConnectScreen.connect(parent, client, target.address, target.info, false, target.cookieStorage)
+ }
+
+ is SingleplayerReconnectTarget -> {
+ client.createIntegratedServerLoader().start(target.levelName) {
+ client.setScreen(SelectWorldScreen(parent))
+ }
+ }
+
+ null -> return
+ }
+ }
+
+ private fun openWorldListScreen() {
+ releaseTexture()
+ client?.setScreen(
+ when (AutoDisconnect.lastReconnectTarget) {
+ is SingleplayerReconnectTarget -> SelectWorldScreen(parent)
+ else -> MultiplayerScreen(parent)
+ }
+ )
+ }
+
+ private fun addButton(text: Text, onPress: (ButtonWidget) -> Unit): ButtonWidget {
+ val buttonWidth = textRenderer.getWidth(text) + BUTTON_EXTRA_WIDTH
+ return addDrawableChild(
+ ButtonWidget.builder(text, onPress)
+ .dimensions(0, 0, buttonWidth.coerceAtLeast(MIN_BUTTON_WIDTH), BUTTON_HEIGHT)
+ .build()
+ )
+ }
+
+ private fun updateLayout() {
+ val titleBottom = MARGIN + textRenderer.fontHeight + SECTION_GAP
+ val columnGap = SECTION_GAP
+ val columnWidth = ((width - MARGIN * 2 - columnGap) / 2).coerceAtLeast(0)
+ val leftColumnX = MARGIN
+ val rightColumnX = leftColumnX + columnWidth + columnGap
+ val bottomButtons = listOf(reconnectButton, titleScreenButton, worldListButton, quitButton)
+ val bottomRowsHeight = measureButtonRowsHeight(bottomButtons)
+ val bottomButtonsY = (height - MARGIN - bottomRowsHeight)
+ .coerceAtLeast(titleBottom + BUTTON_HEIGHT + CONTROL_CONTENT_GAP)
+ val bodyTop = titleBottom + BUTTON_HEIGHT + CONTROL_CONTENT_GAP
+ val bodyBottom = bottomButtonsY - SECTION_GAP
+ val bodyHeight = (bodyBottom - bodyTop).coerceAtLeast(0)
+
+ toggleDetailsButton.x = leftColumnX
+ toggleDetailsButton.y = titleBottom
+ fullScreenButton.x = rightColumnX
+ fullScreenButton.y = titleBottom
+
+ layoutButtons(bottomButtonsY, bottomButtons)
+
+ detailText.x = leftColumnX
+ detailText.y = bodyTop
+ detailText.width = columnWidth
+ detailText.height = bodyHeight
+
+ previewBounds = fitImage(
+ maxX = rightColumnX,
+ maxY = bodyTop,
+ maxWidth = columnWidth,
+ maxHeight = bodyHeight
+ )
+ }
+
+ private fun layoutButtons(y: Int, buttons: List) {
+ var currentY = y
+ val rows = buttonRows(buttons)
+
+ rows.forEach { row ->
+ var currentX = MARGIN
+
+ row.buttons.forEach { button ->
+ button.x = currentX
+ button.y = currentY
+ currentX += button.width + BUTTON_GAP
+ }
+
+ currentY += BUTTON_HEIGHT + BUTTON_GAP
+ }
+ }
+
+ private fun measureButtonRowsHeight(buttons: List): Int {
+ val rowCount = buttonRows(buttons).size
+
+ return rowCount * BUTTON_HEIGHT + (rowCount - 1).coerceAtLeast(0) * BUTTON_GAP
+ }
+
+ private fun buttonRows(buttons: List): List {
+ if (buttons.isEmpty()) return emptyList()
+
+ val rows = mutableListOf()
+ val rowButtons = mutableListOf()
+ var currentX = MARGIN
+
+ buttons.forEach { button ->
+ if (currentX > MARGIN && currentX + button.width > width - MARGIN) {
+ rows += ButtonRow(rowButtons.toList(), currentX - MARGIN - BUTTON_GAP)
+ rowButtons.clear()
+ currentX = MARGIN
+ }
+
+ rowButtons += button
+ currentX += button.width + BUTTON_GAP
+ }
+
+ rows += ButtonRow(rowButtons.toList(), currentX - MARGIN - BUTTON_GAP)
+ return rows
+ }
+
+ private fun updateDetailVisibility() {
+ val detailsVisible = showDetails
+ detailText.visible = detailsVisible
+ fullScreenButton.active = detailsVisible
+ fullScreenButton.visible = detailsVisible
+ toggleDetailsButton.message = detailToggleText()
+ }
+
+ private fun updateReconnectButton() {
+ reconnectButton.active = AutoDisconnect.lastReconnectTarget != null
+ }
+
+ private fun detailToggleText() =
+ Text.literal(if (showDetails) "Hide Details" else "Show Details")
+
+ private fun listButtonText() =
+ Text.literal(
+ when (AutoDisconnect.lastReconnectTarget) {
+ is SingleplayerReconnectTarget -> "World List"
+ else -> "Server List"
+ }
+ )
+
+ private fun fitImage(maxX: Int, maxY: Int, maxWidth: Int, maxHeight: Int): Bounds {
+ if (maxWidth <= 0 || maxHeight <= 0 || details.imageWidth <= 0 || details.imageHeight <= 0) {
+ return Bounds.EMPTY
+ }
+
+ val scale = min(
+ maxWidth.toDouble() / details.imageWidth.toDouble(),
+ maxHeight.toDouble() / details.imageHeight.toDouble()
+ )
+ val imageWidth = (details.imageWidth * scale).toInt().coerceAtLeast(1)
+ val imageHeight = (details.imageHeight * scale).toInt().coerceAtLeast(1)
+
+ return Bounds(
+ x = maxX + (maxWidth - imageWidth) / 2,
+ y = maxY,
+ width = imageWidth,
+ height = imageHeight
+ )
+ }
+
+ private fun drawScreenshot(context: DrawContext, bounds: Bounds) {
+ if (bounds.isEmpty) return
+
+ context.drawTexture(
+ RenderPipelines.GUI_TEXTURED,
+ details.imageIdentifier,
+ bounds.x,
+ bounds.y,
+ 0f,
+ 0f,
+ bounds.width,
+ bounds.height,
+ details.imageWidth,
+ details.imageHeight,
+ details.imageWidth,
+ details.imageHeight
+ )
+ }
+
+ private fun releaseTexture() {
+ if (textureReleased) return
+ client?.textureManager?.destroyTexture(details.imageIdentifier)
+ textureReleased = true
+ }
+
+ private class ImagePreviewScreen(
+ private val parent: Screen,
+ private val details: DisconnectDetails
+ ) : Screen(Text.literal("Screenshot Preview")), CursorOverrideProvider {
+ private var viewportBounds = Bounds.EMPTY
+ private var fitBounds = Bounds.EMPTY
+ private var imageBounds = Bounds.EMPTY
+ private var zoom = 1.0
+ private var panX = 0.0
+ private var panY = 0.0
+
+ override fun init() {
+ val text = "Back"
+ val buttonWidth = textRenderer.getWidth(text) + BUTTON_EXTRA_WIDTH
+ addDrawableChild(
+ ButtonWidget.builder(Text.literal(text)) {
+ client?.setScreen(parent)
+ }.dimensions(PREVIEW_PADDING, PREVIEW_PADDING, buttonWidth.coerceAtLeast(MIN_BUTTON_WIDTH), BUTTON_HEIGHT).build()
+ )
+
+ updateImageBounds()
+ }
+
+ override fun render(context: DrawContext, mouseX: Int, mouseY: Int, deltaTicks: Float) {
+ renderDarkening(context)
+ context.drawCenteredTextWithShadow(
+ textRenderer,
+ PREVIEW_INSTRUCTIONS,
+ width / 2,
+ PREVIEW_PADDING + (BUTTON_HEIGHT - textRenderer.fontHeight) / 2,
+ 0xFFFFFFFF.toInt()
+ )
+ drawScreenshot(context)
+ super.render(context, mouseX, mouseY, deltaTicks)
+ }
+
+ override fun close() {
+ client?.setScreen(parent)
+ }
+
+ override fun getCursorOverride(mouseX: Int, mouseY: Int): Cursor? {
+ return if (zoom > MIN_ZOOM && viewportBounds.contains(mouseX, mouseY)) {
+ StandardCursors.RESIZE_ALL
+ } else {
+ null
+ }
+ }
+
+ override fun mouseScrolled(mouseX: Double, mouseY: Double, horizontalAmount: Double, verticalAmount: Double): Boolean {
+ if (verticalAmount == 0.0 || fitBounds.isEmpty) {
+ return super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount)
+ }
+
+ val oldBounds = imageBounds
+ val oldZoom = zoom
+ zoom = (zoom * if (verticalAmount > 0.0) ZOOM_STEP else 1.0 / ZOOM_STEP)
+ .coerceIn(MIN_ZOOM, MAX_ZOOM)
+
+ if (zoom == oldZoom) return true
+
+ if (zoom == MIN_ZOOM) {
+ panX = 0.0
+ panY = 0.0
+ } else if (!oldBounds.isEmpty) {
+ val cursorRatioX = ((mouseX - oldBounds.x) / oldBounds.width.toDouble()).coerceIn(0.0, 1.0)
+ val cursorRatioY = ((mouseY - oldBounds.y) / oldBounds.height.toDouble()).coerceIn(0.0, 1.0)
+ val zoomedWidth = fitBounds.width * zoom
+ val zoomedHeight = fitBounds.height * zoom
+ val centeredX = fitBounds.x + (fitBounds.width - zoomedWidth) / 2.0
+ val centeredY = fitBounds.y + (fitBounds.height - zoomedHeight) / 2.0
+
+ panX = mouseX - centeredX - cursorRatioX * zoomedWidth
+ panY = mouseY - centeredY - cursorRatioY * zoomedHeight
+ }
+
+ updateImageBounds()
+ return true
+ }
+
+ override fun mouseDragged(click: Click, deltaX: Double, deltaY: Double): Boolean {
+ if (zoom <= MIN_ZOOM || click.button() != 0) {
+ return super.mouseDragged(click, deltaX, deltaY)
+ }
+
+ panX += deltaX
+ panY += deltaY
+ updateImageBounds()
+ return true
+ }
+
+ private fun fitImage(): Bounds {
+ val availableTop = PREVIEW_PADDING + BUTTON_HEIGHT + PREVIEW_PADDING
+ val availableHeight = height - availableTop - PREVIEW_PADDING
+ val availableWidth = width - PREVIEW_PADDING * 2
+
+ viewportBounds = Bounds(PREVIEW_PADDING, availableTop, availableWidth, availableHeight)
+
+ if (availableWidth <= 0 || availableHeight <= 0) {
+ viewportBounds = Bounds.EMPTY
+ return Bounds.EMPTY
+ }
+
+ val scale = min(
+ availableWidth.toDouble() / details.imageWidth.toDouble(),
+ availableHeight.toDouble() / details.imageHeight.toDouble()
+ )
+ val imageWidth = (details.imageWidth * scale).toInt().coerceAtLeast(1)
+ val imageHeight = (details.imageHeight * scale).toInt().coerceAtLeast(1)
+
+ return Bounds(
+ x = PREVIEW_PADDING + (availableWidth - imageWidth) / 2,
+ y = availableTop + (availableHeight - imageHeight) / 2,
+ width = imageWidth,
+ height = imageHeight
+ )
+ }
+
+ private fun updateImageBounds() {
+ fitBounds = fitImage()
+
+ if (fitBounds.isEmpty) {
+ imageBounds = Bounds.EMPTY
+ return
+ }
+
+ clampPan()
+
+ val zoomedWidth = (fitBounds.width * zoom).toInt().coerceAtLeast(1)
+ val zoomedHeight = (fitBounds.height * zoom).toInt().coerceAtLeast(1)
+
+ imageBounds = Bounds(
+ x = (fitBounds.x + (fitBounds.width - zoomedWidth) / 2.0 + panX).toInt(),
+ y = (fitBounds.y + (fitBounds.height - zoomedHeight) / 2.0 + panY).toInt(),
+ width = zoomedWidth,
+ height = zoomedHeight
+ )
+ }
+
+ private fun clampPan() {
+ if (zoom == MIN_ZOOM || viewportBounds.isEmpty || fitBounds.isEmpty) {
+ panX = 0.0
+ panY = 0.0
+ return
+ }
+
+ val zoomedWidth = fitBounds.width * zoom
+ val zoomedHeight = fitBounds.height * zoom
+ val centeredX = fitBounds.x + (fitBounds.width - zoomedWidth) / 2.0
+ val centeredY = fitBounds.y + (fitBounds.height - zoomedHeight) / 2.0
+
+ panX = clampAxis(
+ pan = panX,
+ centeredStart = centeredX,
+ contentSize = zoomedWidth,
+ viewportStart = viewportBounds.x.toDouble(),
+ viewportSize = viewportBounds.width.toDouble()
+ )
+ panY = clampAxis(
+ pan = panY,
+ centeredStart = centeredY,
+ contentSize = zoomedHeight,
+ viewportStart = viewportBounds.y.toDouble(),
+ viewportSize = viewportBounds.height.toDouble()
+ )
+ }
+
+ private fun clampAxis(
+ pan: Double,
+ centeredStart: Double,
+ contentSize: Double,
+ viewportStart: Double,
+ viewportSize: Double
+ ): Double {
+ if (contentSize <= viewportSize) {
+ return viewportStart + (viewportSize - contentSize) / 2.0 - centeredStart
+ }
+
+ val minPan = viewportStart + viewportSize - contentSize - centeredStart
+ val maxPan = viewportStart - centeredStart
+ return pan.coerceIn(minPan, maxPan)
+ }
+
+ private fun drawScreenshot(context: DrawContext) {
+ if (imageBounds.isEmpty) return
+
+ context.drawTexture(
+ RenderPipelines.GUI_TEXTURED,
+ details.imageIdentifier,
+ imageBounds.x,
+ imageBounds.y,
+ 0f,
+ 0f,
+ imageBounds.width,
+ imageBounds.height,
+ details.imageWidth,
+ details.imageHeight,
+ details.imageWidth,
+ details.imageHeight
+ )
+ }
+ }
+
+ private data class Bounds(
+ val x: Int,
+ val y: Int,
+ val width: Int,
+ val height: Int
+ ) {
+ val isEmpty: Boolean
+ get() = width <= 0 || height <= 0
+
+ fun contains(pointX: Int, pointY: Int) =
+ pointX in x until x + width && pointY in y until y + height
+
+ companion object {
+ val EMPTY = Bounds(0, 0, 0, 0)
+ }
+ }
+
+ private data class ButtonRow(
+ val buttons: List,
+ val width: Int
+ )
+
+ private companion object {
+ const val MARGIN = 16
+ const val SECTION_GAP = 8
+ const val BUTTON_GAP = 4
+ const val CONTROL_CONTENT_GAP = 2
+ const val BUTTON_HEIGHT = 16
+ const val BUTTON_EXTRA_WIDTH = 8
+ const val MIN_BUTTON_WIDTH = 32
+ const val PREVIEW_PADDING = 2
+ val PREVIEW_INSTRUCTIONS: Text = Text.literal("Scroll to zoom, click and drag to pan")
+ const val MIN_ZOOM = 1.0
+ const val MAX_ZOOM = 8.0
+ const val ZOOM_STEP = 1.2
+ }
+}
diff --git a/src/main/resources/lambda.mixins.json b/src/main/resources/lambda.mixins.json
index 33829e0d4..925a6782d 100644
--- a/src/main/resources/lambda.mixins.json
+++ b/src/main/resources/lambda.mixins.json
@@ -23,12 +23,14 @@
"input.MouseMixin",
"items.BlockItemMixin",
"items.FilledMapItemMixin",
+ "network.ClientCommonNetworkHandlerMixin",
"network.ClientConnectionMixin",
"network.ClientLoginNetworkMixin",
"network.ClientPlayNetworkHandlerMixin",
"network.HandshakeC2SPacketMixin",
"network.LoginHelloC2SPacketMixin",
"network.LoginKeyC2SPacketMixin",
+ "render.AbstractBlockRenderContextMixin",
"render.AbstractTerrainRenderContextMixin",
"render.ArmorFeatureRendererMixin",
"render.BlockEntityRendererMixin",
@@ -43,6 +45,7 @@
"render.ChatScreenMixin",
"render.ChunkBorderDebugRendererMixin",
"render.ChunkOcclusionDataBuilderMixin",
+ "render.ConnectScreenMixin",
"render.DrawContextMixin",
"render.ElytraFeatureRendererMixin",
"render.EntityRendererMixin",
@@ -63,7 +66,6 @@
"render.RenderLayersMixin",
"render.ScreenHandlerMixin",
"render.ScreenMixin",
- "render.AbstractBlockRenderContextMixin",
"render.SodiumBlockRendererMixin",
"render.SodiumFluidRendererImplMixin",
"render.SodiumLightDataAccessMixin",
@@ -88,6 +90,7 @@
"world.ClientChunkManagerMixin",
"world.ClientWorldMixin",
"world.DirectionMixin",
+ "world.IntegratedServerLoaderMixin",
"world.StructureTemplateMixin",
"world.WorldMixin"
],