diff --git a/common/src/main/java/com/lambda/mixin/CrashReportMixin.java b/common/src/main/java/com/lambda/mixin/CrashReportMixin.java new file mode 100644 index 000000000..7438db47e --- /dev/null +++ b/common/src/main/java/com/lambda/mixin/CrashReportMixin.java @@ -0,0 +1,79 @@ +/* + * Copyright 2025 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; + +import com.lambda.Lambda; +import com.lambda.module.Module; +import com.lambda.module.ModuleRegistry; +import com.lambda.util.DynamicException; +import net.minecraft.client.MinecraftClient; +import net.minecraft.util.Util; +import net.minecraft.util.crash.CrashReport; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Mutable; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +// Modify the crash report behavior for dynamic remapping, Easter egg and github issue link +@Mixin(CrashReport.class) +public class CrashReportMixin { + @Mutable + @Shadow @Final private Throwable cause; + + @Inject(method = "(Ljava/lang/String;Ljava/lang/Throwable;)V", at = @At("TAIL")) + void injectConstructor(String message, Throwable cause, CallbackInfo ci) { + if (!Lambda.INSTANCE.isDebug() && MinecraftClient.getInstance() != null) { + this.cause = new DynamicException(cause); + } + } + + @Redirect(method = "asString()Ljava/lang/String;", at = @At(value = "INVOKE", target = "Lnet/minecraft/util/crash/CrashReport;addStackTrace(Ljava/lang/StringBuilder;)V")) + void injectString(CrashReport instance, StringBuilder stringBuilder) { + stringBuilder.append("If this issue is related to Lambda, check if other users have experienced this too, or create a new issue at https://github.com/lambda-client/lambda/issues.\n\n"); + + if (MinecraftClient.getInstance() != null) { + stringBuilder.append("Enabled modules:\n"); + + ModuleRegistry.INSTANCE.getModules() + .stream().filter(Module::isEnabled) + .forEach(m -> stringBuilder.append("\t").append(m.getName()).append("\n")); + } + + stringBuilder.append("\n"); + stringBuilder.append("-".repeat(43)); + stringBuilder.append("\n\n"); + + instance.addStackTrace(stringBuilder); + } + + @Inject(method = "generateWittyComment()Ljava/lang/String;", at = @At("HEAD"), cancellable = true) + private static void generateWittyComment(CallbackInfoReturnable cir) { + String[] strings = new String[]{"Who set us up the TNT?", "Everything's going to plan. No, really, that was supposed to happen.", "Uh... Did I do that?", "Oops.", "Why did you do that?", "I feel sad now :(", "My bad.", "I'm sorry, Dave.", "I let you down. Sorry :(", "On the bright side, I bought you a teddy bear!", "Daisy, daisy...", "Oh - I know what I did wrong!", "Hey, that tickles! Hehehe!", "I blame Dinnerbone.", "You should try our sister game, Minceraft!", "Don't be sad. I'll do better next time, I promise!", "Don't be sad, have a hug! <3", "I just don't know what went wrong :(", "Shall we play a game?", "Quite honestly, I wouldn't worry myself about that.", "I bet Cylons wouldn't have this problem.", "Sorry :(", "Surprise! Haha. Well, this is awkward.", "Would you like a cupcake?", "Hi. I'm Minecraft, and I'm a crashaholic.", "Ooh. Shiny.", "This doesn't make any sense!", "Why is it breaking :(", "Don't do that.", "Ouch. That hurt :(", "You're mean.", "This is a token for 1 free hug. Redeem at your nearest Mojangsta: [~~HUG~~]", "There are four lights!", "But it works on my machine.", "Popbob was here.", "The oldest anarchy server in Minecraft.", "Better luck next time..", "Fatal error occurred user is too based.", "Running premium software on a potato is not advised", "I don't know, ask that kilab guy", "Ah shit, here we go again.", "I will uhh, fix that sometime.", "Not a bug, a feature!", "You should try out Lambda on Windows XP.", "Blade did that."}; + + try { + cir.setReturnValue(strings[(int)(Util.getMeasuringTimeNano() % (long)strings.length)]); + } catch (Throwable var2) { + cir.setReturnValue("Witty comment unavailable :("); + } + } +} diff --git a/common/src/main/kotlin/com/lambda/command/commands/ReplayCommand.kt b/common/src/main/kotlin/com/lambda/command/commands/ReplayCommand.kt index 4d079f4cd..dfad53b69 100644 --- a/common/src/main/kotlin/com/lambda/command/commands/ReplayCommand.kt +++ b/common/src/main/kotlin/com/lambda/command/commands/ReplayCommand.kt @@ -27,8 +27,8 @@ import com.lambda.brigadier.executeWithResult import com.lambda.brigadier.required import com.lambda.command.LambdaCommand import com.lambda.module.modules.player.Replay +import com.lambda.util.FileUtils.listRecursive import com.lambda.util.FolderRegister -import com.lambda.util.FolderRegister.listRecursive import com.lambda.util.extension.CommandBuilder import kotlin.io.path.exists diff --git a/common/src/main/kotlin/com/lambda/module/modules/client/Network.kt b/common/src/main/kotlin/com/lambda/module/modules/client/Network.kt index 74ac5917f..833ed1694 100644 --- a/common/src/main/kotlin/com/lambda/module/modules/client/Network.kt +++ b/common/src/main/kotlin/com/lambda/module/modules/client/Network.kt @@ -31,6 +31,7 @@ import com.lambda.network.NetworkManager.updateToken import com.lambda.network.api.v1.endpoints.login import com.lambda.util.StringUtils.hash import com.lambda.util.extension.isOffline +import net.minecraft.SharedConstants import net.minecraft.client.network.AllowedAddressResolver import net.minecraft.client.network.ClientLoginNetworkHandler import net.minecraft.client.network.ServerAddress @@ -51,6 +52,9 @@ object Network : Module( val authServer by setting("Auth Server", "auth.lambda-client.org") val apiUrl by setting("API Server", "https://api.lambda-client.org") val apiVersion by setting("API Version", ApiVersion.V1) + val mappings by setting("Mappings", "https://mappings.lambda-client.org") + + val gameVersion = SharedConstants.getGameVersion().name private var hash: String? = null diff --git a/common/src/main/kotlin/com/lambda/module/modules/network/PacketLogger.kt b/common/src/main/kotlin/com/lambda/module/modules/network/PacketLogger.kt index c36c45880..c33dfc85a 100644 --- a/common/src/main/kotlin/com/lambda/module/modules/network/PacketLogger.kt +++ b/common/src/main/kotlin/com/lambda/module/modules/network/PacketLogger.kt @@ -30,6 +30,7 @@ import com.lambda.util.Communication import com.lambda.util.Communication.info import com.lambda.util.DynamicReflectionSerializer.dynamicString import com.lambda.util.FolderRegister +import com.lambda.util.FolderRegister.relativeMCPath import com.lambda.util.Formatting.getTime import com.lambda.util.text.* import kotlinx.coroutines.channels.BufferOverflow @@ -37,7 +38,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow import net.minecraft.network.packet.Packet import java.awt.Color import java.io.File -import java.nio.file.Path import java.time.format.DateTimeFormatter import kotlin.io.path.pathString @@ -83,7 +83,6 @@ object PacketLogger : Module( extraBufferCapacity = 1000, onBufferOverflow = BufferOverflow.DROP_OLDEST ) - private val File.relativePath: Path get() = mc.runDirectory.toPath().relativize(toPath()) init { runIO { @@ -105,7 +104,7 @@ object PacketLogger : Module( createNewFile() } val info = buildText { - clickEvent(ClickEvents.openFile(relativePath.pathString)) { + clickEvent(ClickEvents.openFile(relativeMCPath.pathString)) { literal("Packet logger started: ") color(Color.YELLOW) { literal(fileName) } literal(" (click to open)") @@ -113,7 +112,6 @@ object PacketLogger : Module( } this@PacketLogger.info(info) }.apply { - // ToDo: Add more rich and accurate data to the header StringBuilder().apply { appendLine(Communication.ascii) appendLine("${Lambda.SYMBOL} - Lambda ${Lambda.VERSION} - Packet Log") @@ -144,8 +142,8 @@ object PacketLogger : Module( file?.let { val info = buildText { literal("Stopped logging packets to ") - clickEvent(ClickEvents.openFile(it.relativePath.pathString)) { - color(Color.YELLOW) { literal(it.relativePath.pathString) } + clickEvent(ClickEvents.openFile(it.relativeMCPath.pathString)) { + color(Color.YELLOW) { literal(it.relativeMCPath.pathString) } literal(" (click to open)") } } diff --git a/common/src/main/kotlin/com/lambda/module/modules/player/MapDownloader.kt b/common/src/main/kotlin/com/lambda/module/modules/player/MapDownloader.kt index dc44bf8f5..6184d4c64 100644 --- a/common/src/main/kotlin/com/lambda/module/modules/player/MapDownloader.kt +++ b/common/src/main/kotlin/com/lambda/module/modules/player/MapDownloader.kt @@ -21,8 +21,8 @@ import com.lambda.event.events.TickEvent import com.lambda.event.listener.SafeListener.Companion.listen import com.lambda.module.Module import com.lambda.module.tag.ModuleTag +import com.lambda.util.FileUtils.locationBoundDirectory import com.lambda.util.FolderRegister -import com.lambda.util.FolderRegister.locationBoundDirectory import com.lambda.util.StringUtils.hashString import com.lambda.util.player.SlotUtils.combined import com.lambda.util.world.entitySearch diff --git a/common/src/main/kotlin/com/lambda/module/modules/player/Replay.kt b/common/src/main/kotlin/com/lambda/module/modules/player/Replay.kt index 9beb57b54..7e743e4f9 100644 --- a/common/src/main/kotlin/com/lambda/module/modules/player/Replay.kt +++ b/common/src/main/kotlin/com/lambda/module/modules/player/Replay.kt @@ -38,8 +38,8 @@ import com.lambda.sound.SoundManager.playSound import com.lambda.util.Communication.info import com.lambda.util.Communication.logError import com.lambda.util.Communication.warn +import com.lambda.util.FileUtils.locationBoundDirectory import com.lambda.util.FolderRegister -import com.lambda.util.FolderRegister.locationBoundDirectory import com.lambda.util.Formatting.asString import com.lambda.util.Formatting.getTime import com.lambda.util.KeyCode diff --git a/common/src/main/kotlin/com/lambda/network/LambdaHttp.kt b/common/src/main/kotlin/com/lambda/network/LambdaHttp.kt index 743f4240d..7ff7fcd0a 100644 --- a/common/src/main/kotlin/com/lambda/network/LambdaHttp.kt +++ b/common/src/main/kotlin/com/lambda/network/LambdaHttp.kt @@ -34,11 +34,20 @@ val LambdaHttp = HttpClient { } } -suspend inline fun HttpClient.download(url: String, file: File, block: HttpRequestBuilder.() -> Unit = {}) = - file.writeBytes(get(url, block).readRawBytes()) +suspend inline fun HttpClient.download(url: String, file: File, block: HttpRequestBuilder.() -> Unit = {}) { + val response = get(url, block) + check(response.status.isSuccess()) { "Download for $url failed with non 2xx status code" } -suspend inline fun HttpClient.download(url: String, output: OutputStream, block: HttpRequestBuilder.() -> Unit = {}) = - output.write(get(url, block).readRawBytes()) + file.writeBytes(response.readRawBytes()) +} + +suspend inline fun HttpClient.download(url: String, output: OutputStream, block: HttpRequestBuilder.() -> Unit = {}) { + val response = get(url, block) + check(response.status.isSuccess()) { "Download for $url failed with non 2xx status code" } + + output.write(response.readRawBytes()) +} suspend inline fun HttpClient.download(url: String, block: HttpRequestBuilder.() -> Unit) = get(url, block).readRawBytes() + diff --git a/common/src/main/kotlin/com/lambda/network/api/v1/endpoints/GetCape.kt b/common/src/main/kotlin/com/lambda/network/api/v1/endpoints/GetCape.kt index f1f710b57..dc11d8eb0 100644 --- a/common/src/main/kotlin/com/lambda/network/api/v1/endpoints/GetCape.kt +++ b/common/src/main/kotlin/com/lambda/network/api/v1/endpoints/GetCape.kt @@ -31,7 +31,7 @@ import java.util.UUID * Example: * - id: ab24f5d6-dcf1-45e4-897e-b50a7c5e7422 * - * response: [Cape] or error + * @return results of cape */ suspend fun getCape(uuid: UUID) = runCatching { LambdaHttp.get("$apiUrl/api/${apiVersion.value}/cape?id=$uuid").body() diff --git a/common/src/main/kotlin/com/lambda/network/api/v1/endpoints/LinkDiscord.kt b/common/src/main/kotlin/com/lambda/network/api/v1/endpoints/LinkDiscord.kt index 9808719fd..50e5e6148 100644 --- a/common/src/main/kotlin/com/lambda/network/api/v1/endpoints/LinkDiscord.kt +++ b/common/src/main/kotlin/com/lambda/network/api/v1/endpoints/LinkDiscord.kt @@ -32,7 +32,7 @@ import io.ktor.http.* * Example: * - token: OTk1MTU1NzcyMzYxMTQ2NDM4 * - * response: [Authentication] or error + * @return result of [Authentication] */ suspend fun linkDiscord(discordToken: String) = runCatching { LambdaHttp.post("${apiUrl}/api/${apiVersion.value}/link/discord") { diff --git a/common/src/main/kotlin/com/lambda/network/api/v1/endpoints/Login.kt b/common/src/main/kotlin/com/lambda/network/api/v1/endpoints/Login.kt index 60987a780..2a065dabb 100644 --- a/common/src/main/kotlin/com/lambda/network/api/v1/endpoints/Login.kt +++ b/common/src/main/kotlin/com/lambda/network/api/v1/endpoints/Login.kt @@ -32,7 +32,7 @@ import io.ktor.http.* * - username: Notch * - hash: 069a79f444e94726a5befca90e38aaf5 * - * response: [Authentication] or error + * @return result of [Authentication] */ suspend fun login(username: String, hash: String) = runCatching { LambdaHttp.post("${apiUrl}/api/${apiVersion.value}/login") { diff --git a/common/src/main/kotlin/com/lambda/network/api/v1/endpoints/SetCape.kt b/common/src/main/kotlin/com/lambda/network/api/v1/endpoints/SetCape.kt index 1fec095ae..dee8d57e3 100644 --- a/common/src/main/kotlin/com/lambda/network/api/v1/endpoints/SetCape.kt +++ b/common/src/main/kotlin/com/lambda/network/api/v1/endpoints/SetCape.kt @@ -30,7 +30,7 @@ import io.ktor.http.* * Example: * - id: galaxy * - * response: [Unit] or error + * @return nothing */ suspend fun setCape(id: String) = runCatching { val resp = LambdaHttp.put("$apiUrl/api/${apiVersion.value}/cape?id=$id") { diff --git a/common/src/main/kotlin/com/lambda/util/DynamicException.kt b/common/src/main/kotlin/com/lambda/util/DynamicException.kt new file mode 100644 index 000000000..8187be232 --- /dev/null +++ b/common/src/main/kotlin/com/lambda/util/DynamicException.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2025 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 + +import com.lambda.util.DynamicReflectionSerializer.remappedName +import java.io.PrintStream +import java.io.PrintWriter + +class DynamicException(original: Throwable) : Throwable(original) { + private val remappedStackTrace = original.stackTrace.remapClassNames() + + init { + stackTrace = remappedStackTrace + } + + private fun Array.remapClassNames() = + map { element -> + StackTraceElement( + element.className.remappedName, + element.methodName.remappedName, + element.fileName, + element.lineNumber + ) + }.toTypedArray() + + override fun printStackTrace(s: PrintStream) { + s.println(this) + remappedStackTrace.forEach { element -> + s.println("\tat $element") + } + } + + override fun printStackTrace(s: PrintWriter) { + s.println(this) + remappedStackTrace.forEach { element -> + s.println("\tat $element") + } + } + + override fun toString(): String = localizedMessage +} diff --git a/common/src/main/kotlin/com/lambda/util/DynamicReflectionSerializer.kt b/common/src/main/kotlin/com/lambda/util/DynamicReflectionSerializer.kt index 042280e6c..c49381e5e 100644 --- a/common/src/main/kotlin/com/lambda/util/DynamicReflectionSerializer.kt +++ b/common/src/main/kotlin/com/lambda/util/DynamicReflectionSerializer.kt @@ -17,7 +17,15 @@ package com.lambda.util +import com.lambda.Lambda +import com.lambda.Lambda.LOG +import com.lambda.core.Loadable +import com.lambda.module.modules.client.Network +import com.lambda.util.FileUtils.downloadIfNotPresent +import com.lambda.util.FolderRegister.cache +import com.lambda.util.extension.resolveFile import com.mojang.serialization.Codec +import kotlinx.coroutines.runBlocking import net.minecraft.block.BlockState import net.minecraft.client.resource.language.TranslationStorage import net.minecraft.item.ItemStack @@ -34,7 +42,7 @@ import java.lang.reflect.Field import java.lang.reflect.InaccessibleObjectException import java.util.* -object DynamicReflectionSerializer { +object DynamicReflectionSerializer : Loadable { // Classes that should not be recursively serialized private val skipables = setOf( Codec::class.java, @@ -62,25 +70,63 @@ object DynamicReflectionSerializer { private const val INDENT = 2 - // ToDo: To make this work in production, every field could be remapped. + private val mappings = runBlocking { + "${Network.mappings}/${Network.gameVersion}" + .downloadIfNotPresent(cache.resolveFile(Network.gameVersion)) + .map { file -> + val standardMappings = file.readLines() + .map { it.split(' ') } + .filter { it.size == 2 } + .associate { (obf, deobf) -> obf to deobf } + + buildMap { + putAll(standardMappings) + + standardMappings.forEach { (obf, deobf) -> + put(obf.split('$').last(), deobf) + if ('$' !in obf) return@forEach + put(obf.replace('$', '.'), deobf) + val parts = obf.split('$') + if (!parts.all { it.startsWith("class_") }) return@forEach + (1 until parts.size).forEach { i -> + put("${parts.take(i).joinToString("$")}.${parts.drop(i).joinToString("$")}", deobf) + } + } + } + } + .getOrElse { + LOG.error("Unable to download deobfuscated qualifiers", it) + emptyMap() + } + } + + + val String.remappedName get() = mappings.getOrDefault(this, this) + + fun Class.dynamicName(remap: Boolean) = + if (remap) canonicalName.remappedName else simpleName + fun Field.dynamicName(remap: Boolean) = + if (remap) name.remappedName else name + fun Any.dynamicString( maxRecursionDepth: Int = 6, currentDepth: Int = 0, indent: String = "", visitedObjects: MutableSet = HashSet(), builder: StringBuilder = StringBuilder(), + remap: Boolean = !Lambda.isDebug, ): String { if (visitedObjects.contains(this)) { - builder.appendLine("$indent${javaClass.simpleName} (Circular Reference)") + builder.appendLine("$indent${javaClass.dynamicName(remap)} (Circular Reference)") return builder.toString() } visitedObjects.add(this) - builder.appendLine("$indent${javaClass.simpleName}") + builder.appendLine("$indent${javaClass.dynamicName(remap)}") val fields = javaClass.declaredFields + javaClass.superclass?.declaredFields.orEmpty() fields.forEach { field -> - processField(field, indent, builder, currentDepth, maxRecursionDepth, visitedObjects) + processField(field, indent, builder, currentDepth, maxRecursionDepth, visitedObjects, remap) } return builder.toString() @@ -93,6 +139,7 @@ object DynamicReflectionSerializer { currentDepth: Int, maxRecursionDepth: Int, visitedObjects: MutableSet, + remap: Boolean, ) { if (skipFields.any { it.isAssignableFrom(field.type) }) return @@ -102,34 +149,35 @@ object DynamicReflectionSerializer { return } val fieldValue = field.get(this) - val fieldIndent = indent + " ".repeat(INDENT) - builder.appendLine("$fieldIndent${field.name}: ${fieldValue.formatFieldValue()}") + val fieldIndent = "$indent${" ".repeat(INDENT)}" + builder.appendLine("$fieldIndent${field.dynamicName(remap)}: ${fieldValue.formatFieldValue(remap)}") if (currentDepth < maxRecursionDepth && fieldValue != null && !field.type.isPrimitive - && !field.type.isArray && - !field.type.isEnum && - skipables.none { it.isAssignableFrom(field.type) } + && !field.type.isArray + && !field.type.isEnum + && skipables.none { it.isAssignableFrom(field.type) } ) { fieldValue.dynamicString( maxRecursionDepth, currentDepth + 1, - fieldIndent + " ".repeat(INDENT), + "$fieldIndent${" ".repeat(INDENT)}", visitedObjects, builder, + remap ) } } - private fun Any?.formatFieldValue(): String = + private fun Any?.formatFieldValue(remap: Boolean): String = when (this) { is String -> "\"${this}\"" - is Collection<*> -> "[${joinToString(", ") { it.formatFieldValue() }}]" - is Array<*> -> "[${joinToString(", ") { it.formatFieldValue() }}]" + is Collection<*> -> "[${joinToString(", ") { it.formatFieldValue(remap) }}]" + is Array<*> -> "[${joinToString(", ") { it.formatFieldValue(remap) }}]" is Map<*, *> -> "{${ entries.joinToString(", ") { (k, v) -> - "${k.formatFieldValue()}: ${v.formatFieldValue()}" + "${k.formatFieldValue(remap)}: ${v.formatFieldValue(remap)}" } }}" @@ -137,6 +185,12 @@ object DynamicReflectionSerializer { is Identifier -> "$namespace:$path" is NbtCompound -> asString() is RegistryEntry<*> -> "${value()}" - else -> this?.toString() ?: "null" + else -> { + if (this?.javaClass?.canonicalName?.contains("minecraft") == true) + "${this.javaClass.dynamicName(remap)}@${Integer.toHexString(hashCode())}" + else this?.toString() ?: "null" + } } + + override fun load() = "Loaded ${mappings.size} deobfuscated qualifier" } diff --git a/common/src/main/kotlin/com/lambda/util/FileUtils.kt b/common/src/main/kotlin/com/lambda/util/FileUtils.kt new file mode 100644 index 000000000..ff493947f --- /dev/null +++ b/common/src/main/kotlin/com/lambda/util/FileUtils.kt @@ -0,0 +1,204 @@ +/* + * Copyright 2025 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 + +import com.lambda.Lambda.mc +import com.lambda.network.LambdaHttp +import com.lambda.network.download +import com.lambda.util.StringUtils.sanitizeForFilename +import io.ktor.client.request.* +import java.io.File +import java.net.InetSocketAddress +import kotlin.math.sign +import kotlin.time.Duration + +object FileUtils { + /** + * Returns a sequence of all the files in a tree that matches the [predicate] + */ + fun File.listRecursive(predicate: (File) -> Boolean): Sequence = walk().filter(predicate) + + /** + * Retrieves or creates a directory based on the current network connection and world dimension. + * + * The directory is determined by the host name of the current network connection (or "singleplayer" if offline) + * and the dimension key of the current world. These values are sanitized for use as filenames and combined + * to form a path under the current file. If the directory does not exist, it will be created. + * + * @receiver The base directory where the location-bound directory will be created. + * @return A `File` object representing the location-bound directory. + * + * The path is structured as: + * - `[base directory]/[host name]/[dimension key]` + * + * Example: + * If playing on a server with hostname "example.com" and in the "overworld" dimension, the path would be: + * - `[base directory]/example.com/overworld` + */ + fun File.locationBoundDirectory(): File { + val hostName = (mc.networkHandler?.connection?.address as? InetSocketAddress)?.hostName ?: "singleplayer" + val path = resolve( + hostName.sanitizeForFilename() + ).resolve( + mc.world?.dimensionKey?.value?.path?.sanitizeForFilename() ?: "unknown" // TODO: Change with utils when merged to master + ) + path.mkdirs() + return path + } + + /** + * Executes the [block] if the file is older than the given [duration] + */ + fun File.isOlderThan(duration: Duration, block: (File) -> Unit) = + ifExists { if (duration.inWholeMilliseconds < System.currentTimeMillis() - lastModified()) block(this) } + + /** + * Returns whether the receiver file is older than [duration] + */ + fun File.isOlderThan(duration: Duration) = + duration.inWholeMilliseconds < System.currentTimeMillis() - lastModified() + + /** + * Executes the [block] if the receiver file exists and is not empty + */ + inline fun File.ifExists(block: (File) -> Unit): File { + if (length() > 0) block(this) + return this + } + + /** + * Ensures the current file exists by creating it if it does not. + * + * If the file already exists, it will not be recreated. The necessary + * parent directories will be created if they do not exist. + * + * @param block Lambda executed if the file doesn't exist or the file is empty + */ + inline fun File.createIfNotExists(block: (File) -> Unit): File { + if (length() == 0L) block(this) + + parentFile.mkdirs() + createNewFile() + + return this + } + + /** + * Executes the [block] if the receiver file does not exist or is empty. + */ + inline fun File.ifNotExists(block: (File) -> Unit): File { + if (length() == 0L) block(this) + return this + } + + /** + * Modifies the receiver file if the downloaded file compare check succeeds + * + * @receiver The destination file to write the bytes to + * + * @param url The url to download the file from + * @param compare Compare method. -1 if remote is larger. 0 if both file have the same size. 1 if local is larger + * @param block Configuration block for the request + * + * @return An exception or the file + */ + suspend fun File.downloadCompare( + url: String, + compare: Int, + block: HttpRequestBuilder.() -> Unit = {}, + ) = runCatching { + createIfNotExists { + val bytes = readBytes() + val remote = LambdaHttp.download(url, block) + val sign = (bytes.size - remote.size).sign + + if (sign == compare) writeBytes(remote) + } + } + + /** + * Downloads the given file url if the file is not present + * + * @receiver The destination file to write the bytes to + * + * @param url The url to download the file from + * @param block Configuration block for the request + * + * @return An exception or the file + */ + suspend fun File.downloadIfNotPresent( + url: String, + block: HttpRequestBuilder.() -> Unit = {}, + ) = runCatching { createIfNotExists { LambdaHttp.download(url, this, block) } } + + /** + * Downloads the given file url if the file is not present + * + * @receiver The url to download the file from + * + * @param file The destination file to write the bytes to + * @param block Configuration block for the request + * + * @return An exception or the file + */ + suspend fun String.downloadIfNotPresent( + file: File, + block: HttpRequestBuilder.() -> Unit = {}, + ) = runCatching { file.createIfNotExists { LambdaHttp.download(this, file, block) } } + + /** + * Lambda that downloads the given file url if the file is not present + * + * @receiver The destination file to write the bytes to + * @param block Configuration block for the request + * + * @return A lambda that returns an exception or the file + */ + fun File.downloadIfNotPresent(block: HttpRequestBuilder.() -> Unit = {}): suspend (String) -> Result = + { url -> runCatching { createIfNotExists { LambdaHttp.download(url, this, block) } } } + + /** + * Downloads the given file url if the file is present + * + * @receiver The destination file to write the bytes to + * + * @param url The url to download the file from + * @param block Configuration block for the request + * + * @return An exception or the file + */ + suspend fun File.downloadIfPresent( + url: String, + block: HttpRequestBuilder.() -> Unit = {}, + ) = runCatching { ifExists { LambdaHttp.download(url, this, block) } } + + /** + * Downloads the given file url if the file is present + * + * @receiver The url to download the file from + * + * @param file The destination file to write the bytes to + * @param block Configuration block for the request + * + * @return An exception or the file + */ + suspend fun String.downloadIfPresent( + file: File, + block: HttpRequestBuilder.() -> Unit = {}, + ) = runCatching { file.ifExists { LambdaHttp.download(this, file, block) } } +} diff --git a/common/src/main/kotlin/com/lambda/util/FolderRegister.kt b/common/src/main/kotlin/com/lambda/util/FolderRegister.kt index 39044dff7..a61d03ee6 100644 --- a/common/src/main/kotlin/com/lambda/util/FolderRegister.kt +++ b/common/src/main/kotlin/com/lambda/util/FolderRegister.kt @@ -24,9 +24,7 @@ import com.lambda.util.FolderRegister.lambda import com.lambda.util.FolderRegister.minecraft import com.lambda.util.FolderRegister.packetLogs import com.lambda.util.FolderRegister.replay -import com.lambda.util.StringUtils.sanitizeForFilename import java.io.File -import java.net.InetSocketAddress import java.nio.file.Path import kotlin.io.path.createDirectories import kotlin.io.path.notExists @@ -51,6 +49,8 @@ object FolderRegister : Loadable { val structure: Path = lambda.resolve("structure") val maps: Path = lambda.resolve("maps") + val File.relativeMCPath: Path get() = minecraft.relativize(toPath()) + override fun load(): String { val folders = listOf(lambda, config, packetLogs, replay, cache, capes, structure, maps) val createdFolders = folders.mapNotNull { @@ -62,45 +62,4 @@ object FolderRegister : Loadable { "Created directories: ${createdFolders.joinToString { minecraft.parent.relativize(it).toString() }}" } else "Loaded ${folders.size} directories" } - - /** - * Ensures the current file exists by creating it if it does not. - * - * If the file already exists, it will not be recreated. The necessary - * parent directories will be created if they do not exist. - */ - fun File.createIfNotExists(): File = also { parentFile.mkdirs(); createNewFile() } - - /** - * Returns a sequence of all the files in a tree that matches the [predicate] - */ - fun File.listRecursive(predicate: (File) -> Boolean) = walk().filter(predicate) - - /** - * Retrieves or creates a directory based on the current network connection and world dimension. - * - * The directory is determined by the host name of the current network connection (or "singleplayer" if offline) - * and the dimension key of the current world. These values are sanitized for use as filenames and combined - * to form a path under the current file. If the directory does not exist, it will be created. - * - * @receiver The base directory where the location-bound directory will be created. - * @return A `File` object representing the location-bound directory. - * - * The path is structured as: - * - `[base directory]/[host name]/[dimension key]` - * - * Example: - * If playing on a server with hostname "example.com" and in the "overworld" dimension, the path would be: - * - `[base directory]/example.com/overworld` - */ - fun File.locationBoundDirectory(): File { - val hostName = (mc.networkHandler?.connection?.address as? InetSocketAddress)?.hostName ?: "singleplayer" - val path = resolve( - hostName.sanitizeForFilename() - ).resolve( - mc.world?.dimensionKey?.value?.path?.sanitizeForFilename() ?: "unknown" // TODO: Change with utils when merged to master - ) - path.mkdirs() - return path - } } diff --git a/common/src/main/kotlin/com/lambda/util/extension/Other.kt b/common/src/main/kotlin/com/lambda/util/extension/Other.kt index 979df28f7..8b2ce2012 100644 --- a/common/src/main/kotlin/com/lambda/util/extension/Other.kt +++ b/common/src/main/kotlin/com/lambda/util/extension/Other.kt @@ -23,9 +23,6 @@ import net.minecraft.client.texture.TextureManager import net.minecraft.util.Identifier import java.io.File import java.nio.file.Path -import kotlin.contracts.ExperimentalContracts -import kotlin.contracts.InvocationKind -import kotlin.contracts.contract val GameProfile.isOffline get() = properties.isEmpty diff --git a/common/src/main/resources/lambda.mixins.common.json b/common/src/main/resources/lambda.mixins.common.json index d83de51b3..133a8b1a4 100644 --- a/common/src/main/resources/lambda.mixins.common.json +++ b/common/src/main/resources/lambda.mixins.common.json @@ -55,8 +55,9 @@ "world.ClientChunkManagerMixin", "world.ClientWorldMixin", "world.StructureTemplateMixin", + "world.ExplosionMixin", "world.WorldMixin", - "world.ExplosionMixin" + "CrashReportMixin" ], "injectors": { "defaultRequire": 1