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