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" ],