mirror of
https://github.com/AutismDevelopment/Autism-Client.git
synced 2026-10-28 17:29:46 +00:00
Refresh the launcher, HUD, metadata, and user-facing branding while preserving internal IDs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2253 lines
116 KiB
Kotlin
2253 lines
116 KiB
Kotlin
import java.io.DataInputStream
|
|
import java.io.OutputStream
|
|
import java.io.PrintWriter
|
|
import java.io.StringWriter
|
|
import java.net.URL
|
|
import java.net.URLClassLoader
|
|
import java.nio.channels.FileChannel
|
|
import java.nio.channels.FileLock
|
|
import java.nio.channels.OverlappingFileLockException
|
|
import java.nio.file.Files
|
|
import java.nio.file.StandardCopyOption
|
|
import java.nio.file.StandardOpenOption
|
|
import java.util.IdentityHashMap
|
|
import java.util.zip.ZipEntry
|
|
import java.util.zip.ZipFile
|
|
import java.util.zip.ZipInputStream
|
|
import java.util.zip.ZipOutputStream
|
|
import org.objectweb.asm.ClassReader
|
|
import org.objectweb.asm.ClassWriter
|
|
import org.objectweb.asm.Opcodes
|
|
import org.objectweb.asm.tree.AbstractInsnNode
|
|
import org.objectweb.asm.tree.ClassNode
|
|
import org.objectweb.asm.tree.IincInsnNode
|
|
import org.objectweb.asm.tree.InsnList
|
|
import org.objectweb.asm.tree.InsnNode
|
|
import org.objectweb.asm.tree.JumpInsnNode
|
|
import org.objectweb.asm.tree.LabelNode
|
|
import org.objectweb.asm.tree.LookupSwitchInsnNode
|
|
import org.objectweb.asm.tree.MethodInsnNode
|
|
import org.objectweb.asm.tree.MethodNode
|
|
import org.objectweb.asm.tree.TableSwitchInsnNode
|
|
import org.objectweb.asm.tree.VarInsnNode
|
|
import org.objectweb.asm.util.CheckClassAdapter
|
|
|
|
buildscript {
|
|
repositories {
|
|
mavenCentral()
|
|
}
|
|
dependencies {
|
|
classpath("org.apache.xmlgraphics:batik-transcoder:1.17")
|
|
classpath("org.apache.xmlgraphics:batik-codec:1.17")
|
|
// litePatchVariant bytecode surgery (Java 25 = class-file v69 needs ASM 9.8).
|
|
classpath("org.ow2.asm:asm:9.8")
|
|
classpath("org.ow2.asm:asm-tree:9.8")
|
|
}
|
|
}
|
|
|
|
plugins {
|
|
alias(libs.plugins.fabric.loom)
|
|
`maven-publish`
|
|
}
|
|
|
|
base {
|
|
archivesName = properties["archives_base_name"] as String
|
|
// Version = "<mod>-<mc>" (e.g. 3.1-26.2). Dev/source builds add a "-dev" suffix so they're distinguishable
|
|
// from a tagged release; a release is built with: gradlew build -Prelease
|
|
version = libs.versions.mod.version.get() + "-" + libs.versions.minecraft.get() +
|
|
(if (project.hasProperty("release")) "" else "-dev")
|
|
group = properties["maven_group"] as String
|
|
}
|
|
|
|
repositories {
|
|
mavenCentral()
|
|
maven { url = uri("https://api.modrinth.com/maven") }
|
|
}
|
|
|
|
dependencies {
|
|
|
|
minecraft(libs.minecraft)
|
|
implementation(libs.fabric.loader)
|
|
implementation(libs.fabric.api)
|
|
// Compile-only: mixins for Lithium's collision sweeper (runtime-optional, plugin-gated).
|
|
compileOnly(libs.lithium)
|
|
// Compile-only: ReplayMod ReplayStudio types for the team-parser compat mixin (runtime-optional).
|
|
compileOnly("maven.modrinth:replaymod:26.2-2.6.27")
|
|
|
|
implementation("net.java.dev.jna:jna:5.13.0")
|
|
implementation("net.java.dev.jna:jna-platform:5.13.0")
|
|
implementation("io.netty:netty-handler-proxy:4.1.118.Final")
|
|
implementation("io.netty:netty-codec-socks:4.1.118.Final")
|
|
include("io.netty:netty-handler-proxy:4.1.118.Final")
|
|
include("io.netty:netty-codec-socks:4.1.118.Final")
|
|
implementation("de.florianreuth:waybackauthlib:1.1.0")
|
|
include("de.florianreuth:waybackauthlib:1.1.0")
|
|
implementation("com.github.weisj:jsvg:2.1.0")
|
|
include("com.github.weisj:jsvg:2.1.0")
|
|
|
|
// Eclipse Paho MQTT client (standalone, no transitive deps) — powers the redundant MQTT relay mesh
|
|
// (EMQX/HiveMQ public brokers over secure WebSocket) so the encrypted matchmaking transport survives any
|
|
// single public service timing out. Bundled jar-in-jar like the other runtime libs.
|
|
implementation("org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5")
|
|
include("org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5")
|
|
|
|
// Bundle a newer MixinExtras (jar-in-jar) so Fabric Loader loads it over the old 0.5.0 it ships
|
|
// with. Required because ViaFabricPlus (and our own WrapOperation/ModifyExpressionValue mixins)
|
|
// need MixinExtras >= 0.5.3; without this, joining a server crashes during mixin application.
|
|
implementation("io.github.llamalad7:mixinextras-fabric:0.5.4")
|
|
include("io.github.llamalad7:mixinextras-fabric:0.5.4")
|
|
|
|
testImplementation(platform("org.junit:junit-bom:5.11.4"))
|
|
testImplementation("org.junit.jupiter:junit-jupiter")
|
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
|
|
|
}
|
|
|
|
tasks.test {
|
|
useJUnitPlatform()
|
|
}
|
|
|
|
val generatedAutismResourcesDir = layout.buildDirectory.dir("generated/resources/autism/main")
|
|
|
|
data class SourceFile(val path: String, val text: String)
|
|
data class FieldSpec(val name: String, val type: String, val kind: String, val editable: Boolean)
|
|
data class PacketSpec(
|
|
val className: String,
|
|
val protocol: String,
|
|
val direction: String,
|
|
val codecStyle: String,
|
|
val packetType: String,
|
|
val source: String,
|
|
val complete: Boolean,
|
|
val fields: List<FieldSpec>
|
|
)
|
|
|
|
sourceSets {
|
|
main {
|
|
resources.srcDir(generatedAutismResourcesDir)
|
|
}
|
|
}
|
|
|
|
val generateVanillaUiAssets by tasks.registering {
|
|
// Semantic feature icons used by the vanilla-friendly UI. Structural
|
|
// actions such as close and reorder are rendered as text symbols.
|
|
val iconSourceDir = file("assets/icons")
|
|
val outputDir = generatedAutismResourcesDir.map { it.dir("assets/autismclient") }
|
|
|
|
inputs.dir(iconSourceDir)
|
|
outputs.dir(outputDir)
|
|
|
|
doLast {
|
|
val targetRoot = outputDir.get().asFile
|
|
targetRoot.parentFile.resolve("yu" + "ng" + "light").deleteRecursively()
|
|
targetRoot.deleteRecursively()
|
|
val iconTargetDir = targetRoot.resolve("textures/gui/vanillaui/icons")
|
|
|
|
iconTargetDir.mkdirs()
|
|
|
|
val pngTranscoderClass = Class.forName("org.apache.batik.transcoder.image.PNGTranscoder")
|
|
val transcoderInputClass = Class.forName("org.apache.batik.transcoder.TranscoderInput")
|
|
val transcoderOutputClass = Class.forName("org.apache.batik.transcoder.TranscoderOutput")
|
|
val transcodingHintsKeyClass = Class.forName("org.apache.batik.transcoder.TranscodingHints\$Key")
|
|
|
|
val transcoder = pngTranscoderClass.getDeclaredConstructor().newInstance()
|
|
val widthField = pngTranscoderClass.getField("KEY_WIDTH")
|
|
val heightField = pngTranscoderClass.getField("KEY_HEIGHT")
|
|
val addHint = pngTranscoderClass.getMethod("addTranscodingHint", transcodingHintsKeyClass, Any::class.java)
|
|
addHint.invoke(transcoder, widthField.get(null), 256f)
|
|
addHint.invoke(transcoder, heightField.get(null), 256f)
|
|
|
|
val inputCtor = transcoderInputClass.getConstructor(String::class.java)
|
|
val outputCtor = transcoderOutputClass.getConstructor(OutputStream::class.java)
|
|
val transcode = pngTranscoderClass.getMethod("transcode", transcoderInputClass, transcoderOutputClass)
|
|
|
|
iconSourceDir.listFiles { file -> file.isFile && file.extension.equals("svg", ignoreCase = true) }
|
|
?.sortedBy { it.name.lowercase() }
|
|
?.forEach { svg ->
|
|
val outputFile = iconTargetDir.resolve(svg.nameWithoutExtension.lowercase() + ".png")
|
|
outputFile.outputStream().use { out ->
|
|
val input = inputCtor.newInstance(svg.toURI().toString())
|
|
val output = outputCtor.newInstance(out)
|
|
transcode.invoke(transcoder, input, output)
|
|
}
|
|
}
|
|
|
|
// Raster icons committed directly as PNG (e.g. the Matchmaking logo) are copied as-is; the engine
|
|
// recolors/whitens them at draw time just like the SVG-derived icons.
|
|
iconSourceDir.listFiles { file -> file.isFile && file.extension.equals("png", ignoreCase = true) }
|
|
?.sortedBy { it.name.lowercase() }
|
|
?.forEach { png ->
|
|
png.copyTo(iconTargetDir.resolve(png.nameWithoutExtension.lowercase() + ".png"), overwrite = true)
|
|
}
|
|
}
|
|
}
|
|
|
|
val generateAutismInspectorMappings by tasks.registering {
|
|
val mappingFiles = fileTree(".gradle/loom-cache/source_mappings") {
|
|
include("**/*.tiny")
|
|
}
|
|
val outputFile = generatedAutismResourcesDir.map { it.file("autism-inspector-mappings.tsv") }
|
|
|
|
inputs.files(mappingFiles)
|
|
outputs.file(outputFile)
|
|
|
|
doLast {
|
|
run {
|
|
val target = outputFile.get().asFile
|
|
target.parentFile.mkdirs()
|
|
target.writeText(
|
|
"# Official Mojang mappings are used for Minecraft ${libs.versions.minecraft.get()}; no Yarn aliases are generated.\n",
|
|
Charsets.UTF_8
|
|
)
|
|
return@doLast
|
|
}
|
|
|
|
val tinyFile = mappingFiles.files
|
|
.filter { it.isFile }
|
|
.maxByOrNull { it.lastModified() }
|
|
?: error("Missing Loom source mappings under ${project.file(".gradle/loom-cache/source_mappings").absolutePath}")
|
|
|
|
val lines = tinyFile.readLines(Charsets.UTF_8)
|
|
val header = lines.firstOrNull { it.startsWith("tiny\t") }
|
|
?: error("Invalid tiny mapping file: ${tinyFile.absolutePath}")
|
|
val namespaces = header.split('\t').drop(3)
|
|
val namedIndex = namespaces.indexOf("named")
|
|
val intermediaryIndex = namespaces.indexOf("intermediary")
|
|
require(namedIndex >= 0 && intermediaryIndex >= 0) {
|
|
"Tiny mapping header must expose named and intermediary namespaces: $header"
|
|
}
|
|
|
|
fun readNamespace(parts: List<String>, baseOffset: Int, namespaceIndex: Int): String {
|
|
val index = baseOffset + namespaceIndex
|
|
return if (index in parts.indices) parts[index].trim() else ""
|
|
}
|
|
|
|
val namedToIntermediary = linkedMapOf<String, String>()
|
|
val classAliases = linkedMapOf<String, String>()
|
|
|
|
for (raw in lines) {
|
|
val trimmed = raw.trimStart('\t')
|
|
if (!trimmed.startsWith("c\t")) continue
|
|
val parts = trimmed.split('\t')
|
|
val namedName = readNamespace(parts, 1, namedIndex)
|
|
val intermediaryName = readNamespace(parts, 1, intermediaryIndex)
|
|
if (namedName.isBlank() || intermediaryName.isBlank()) continue
|
|
namedToIntermediary[namedName] = intermediaryName
|
|
classAliases[intermediaryName.replace('/', '.')] = namedName.substringAfterLast('/').replace('$', '.')
|
|
}
|
|
|
|
fun remapDescriptorToIntermediary(descriptor: String): String {
|
|
if (descriptor.isBlank()) return descriptor
|
|
val classRef = Regex("L([^;]+);")
|
|
return classRef.replace(descriptor) { match ->
|
|
val namedInternalName = match.groupValues[1]
|
|
val intermediaryInternalName = namedToIntermediary[namedInternalName] ?: namedInternalName
|
|
"L$intermediaryInternalName;"
|
|
}
|
|
}
|
|
|
|
fun storeAlias(
|
|
target: MutableMap<String, String>,
|
|
ambiguous: MutableSet<String>,
|
|
key: String,
|
|
alias: String
|
|
) {
|
|
if (key.isBlank() || alias.isBlank() || ambiguous.contains(key)) return
|
|
val existing = target[key]
|
|
if (existing == null) {
|
|
target[key] = alias
|
|
return
|
|
}
|
|
if (existing != alias) {
|
|
target.remove(key)
|
|
ambiguous.add(key)
|
|
}
|
|
}
|
|
|
|
val fieldAliases = linkedMapOf<String, String>()
|
|
val methodAliases = linkedMapOf<String, String>()
|
|
val ambiguousFieldKeys = linkedSetOf<String>()
|
|
val ambiguousMethodKeys = linkedSetOf<String>()
|
|
|
|
var currentOwner = ""
|
|
for (raw in lines) {
|
|
val trimmed = raw.trimStart('\t')
|
|
if (trimmed.isBlank()) continue
|
|
val parts = trimmed.split('\t')
|
|
when (parts.firstOrNull()) {
|
|
"c" -> {
|
|
currentOwner = readNamespace(parts, 1, intermediaryIndex).replace('/', '.')
|
|
}
|
|
|
|
"f" -> {
|
|
if (currentOwner.isBlank()) continue
|
|
val descriptor = if (parts.size > 1) remapDescriptorToIntermediary(parts[1]) else ""
|
|
val namedName = readNamespace(parts, 2, namedIndex)
|
|
val intermediaryName = readNamespace(parts, 2, intermediaryIndex)
|
|
if (namedName.isBlank() || intermediaryName.isBlank()) continue
|
|
storeAlias(fieldAliases, ambiguousFieldKeys, "$currentOwner#$intermediaryName#$descriptor", namedName)
|
|
}
|
|
|
|
"m" -> {
|
|
if (currentOwner.isBlank()) continue
|
|
val descriptor = if (parts.size > 1) remapDescriptorToIntermediary(parts[1]) else ""
|
|
val namedName = readNamespace(parts, 2, namedIndex)
|
|
val intermediaryName = readNamespace(parts, 2, intermediaryIndex)
|
|
if (namedName.isBlank() || intermediaryName.isBlank()) continue
|
|
if (namedName == "<init>" || namedName == "<clinit>") continue
|
|
storeAlias(methodAliases, ambiguousMethodKeys, "$currentOwner#$intermediaryName#$descriptor", namedName)
|
|
}
|
|
}
|
|
}
|
|
|
|
val authAliasFragments = listOf(
|
|
"session", "accesstoken", "refreshtoken", "authtoken",
|
|
"clientsession", "playersession", "publicsession"
|
|
)
|
|
fun isAuthAlias(alias: String): Boolean {
|
|
val normalized = alias.lowercase().replace(Regex("[^a-z0-9]"), "")
|
|
return authAliasFragments.any { normalized.contains(it) }
|
|
}
|
|
|
|
val filteredClassAliases = classAliases.filter { (_, alias) -> !isAuthAlias(alias) }
|
|
val blockedOwners = classAliases.keys.filter { owner ->
|
|
val alias = classAliases[owner] ?: return@filter false
|
|
isAuthAlias(alias)
|
|
}.toSet()
|
|
|
|
val target = outputFile.get().asFile
|
|
target.parentFile.mkdirs()
|
|
target.printWriter(Charsets.UTF_8).use { out ->
|
|
out.println("# Yarn deobfuscation mappings")
|
|
filteredClassAliases.toSortedMap().forEach { (owner, alias) ->
|
|
out.println("C\t$owner\t$alias")
|
|
}
|
|
fieldAliases.toSortedMap().forEach { (key, alias) ->
|
|
val parts = key.split('#', limit = 3)
|
|
if (parts.size == 3 && !blockedOwners.contains(parts[0]) && !isAuthAlias(alias)) {
|
|
out.println("F\t${parts[0]}\t${parts[1]}\t${parts[2]}\t$alias")
|
|
}
|
|
}
|
|
methodAliases.toSortedMap().forEach { (key, alias) ->
|
|
val parts = key.split('#', limit = 3)
|
|
if (parts.size == 3 && !blockedOwners.contains(parts[0]) && !isAuthAlias(alias)) {
|
|
out.println("M\t${parts[0]}\t${parts[1]}\t${parts[2]}\t$alias")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
val generateAutismPacketSchemas by tasks.registering {
|
|
val minecraftVersion = libs.versions.minecraft.get()
|
|
val outputFile = generatedAutismResourcesDir.map { it.file("autism-packet-schemas.tsv") }
|
|
|
|
outputs.file(outputFile)
|
|
|
|
doLast {
|
|
fun normalizeWhitespace(value: String): String = value.replace(Regex("\\s+"), " ").trim()
|
|
|
|
fun splitTopLevelComma(value: String): List<String> {
|
|
val out = mutableListOf<String>()
|
|
var depth = 0
|
|
var start = 0
|
|
for (i in value.indices) {
|
|
when (value[i]) {
|
|
'<', '(', '[', '{' -> depth++
|
|
'>', ')', ']', '}' -> if (depth > 0) depth--
|
|
',' -> if (depth == 0) {
|
|
out += value.substring(start, i).trim()
|
|
start = i + 1
|
|
}
|
|
}
|
|
}
|
|
val tail = value.substring(start).trim()
|
|
if (tail.isNotBlank()) out += tail
|
|
return out
|
|
}
|
|
|
|
fun protocolFromPath(path: String): String {
|
|
val normalized = path.replace('\\', '/')
|
|
val marker = "/network/protocol/"
|
|
val idx = normalized.indexOf(marker)
|
|
val tail = if (idx >= 0) normalized.substring(idx + marker.length) else normalized.substringAfter("net/minecraft/network/protocol/", "")
|
|
return tail.substringBefore('/').ifBlank { "unknown" }
|
|
}
|
|
|
|
fun directionFromName(simpleName: String): String = when {
|
|
simpleName.startsWith("Clientbound") -> "S2C"
|
|
simpleName.startsWith("Serverbound") -> "C2S"
|
|
else -> "ANY"
|
|
}
|
|
|
|
fun kindForType(type: String): String {
|
|
val lower = type.lowercase()
|
|
return when {
|
|
lower == "byte" || lower == "short" || lower == "int" || lower == "long" || lower == "float" || lower == "double" -> "number"
|
|
lower == "boolean" -> "boolean"
|
|
lower == "string" -> "string"
|
|
lower.contains("itemstack") || lower.contains("hashedstack") -> "item"
|
|
lower.contains("component") -> "component"
|
|
lower.contains("identifier") || lower.contains("resourcekey") -> "identifier"
|
|
lower.contains("holder<") || lower == "holder" -> "holder"
|
|
lower.contains("blockpos") || lower.contains("chunkpos") -> "position"
|
|
lower.contains("vec3") || lower.contains("positionmoverotation") -> "vector"
|
|
lower.contains("uuid") -> "uuid"
|
|
lower.startsWith("optional<") || lower.contains(".optional<") -> "optional"
|
|
lower.startsWith("list<") || lower.startsWith("set<") || lower.contains("list<") || lower.contains("set<") -> "list"
|
|
lower.startsWith("map<") || lower.contains("map<") || lower.contains("int2objectmap") -> "map"
|
|
lower.contains("bitset") -> "bitset"
|
|
lower.contains("enumset") || lower.contains("relative") || lower.contains("containerinput") -> "enum"
|
|
else -> "object"
|
|
}
|
|
}
|
|
|
|
fun editableFor(kind: String): Boolean = kind in setOf("number", "boolean", "string", "identifier", "enum", "uuid")
|
|
|
|
fun parseComponent(raw: String): FieldSpec? {
|
|
val cleaned = normalizeWhitespace(raw)
|
|
.replace(Regex("^@[\\w.]+(?:\\([^)]*\\))?\\s+"), "")
|
|
val idx = cleaned.lastIndexOf(' ')
|
|
if (idx <= 0 || idx >= cleaned.length - 1) return null
|
|
val type = cleaned.substring(0, idx).trim()
|
|
val name = cleaned.substring(idx + 1).trim().removeSuffix("...")
|
|
if (!name.matches(Regex("[A-Za-z_$][A-Za-z0-9_$]*"))) return null
|
|
val kind = kindForType(type)
|
|
return FieldSpec(name, type, kind, editableFor(kind))
|
|
}
|
|
|
|
fun parseFields(text: String): Pair<String, List<FieldSpec>> {
|
|
val record = Regex("""public\s+record\s+\w+\s*\((.*?)\)\s*implements""", setOf(RegexOption.DOT_MATCHES_ALL))
|
|
.find(text)
|
|
if (record != null) {
|
|
val fields = splitTopLevelComma(record.groupValues[1]).mapNotNull(::parseComponent)
|
|
return "record" to fields
|
|
}
|
|
|
|
val fields = Regex("""(?m)^\s*(?:private|protected)\s+final\s+([^;=]+?)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*;""")
|
|
.findAll(text)
|
|
.mapNotNull { match ->
|
|
val type = normalizeWhitespace(match.groupValues[1])
|
|
val name = match.groupValues[2]
|
|
val kind = kindForType(type)
|
|
FieldSpec(name, type, kind, editableFor(kind))
|
|
}
|
|
.toList()
|
|
return if (fields.isNotEmpty()) "fields" to fields else "fallback" to emptyList()
|
|
}
|
|
|
|
fun loadSources(): List<SourceFile> {
|
|
val sourcesJar = file("${System.getProperty("user.home")}/.gradle/caches/fabric-loom/minecraftMaven/net/minecraft/minecraft-merged-deobf/$minecraftVersion/minecraft-merged-deobf-$minecraftVersion-sources.jar")
|
|
val loomCacheSourceJar = file(".gradle/loom-cache/minecraftMaven/net/minecraft")
|
|
.takeIf { it.isDirectory }
|
|
?.walkTopDown()
|
|
?.firstOrNull { file ->
|
|
file.isFile &&
|
|
file.name.endsWith("-$minecraftVersion-sources.jar") &&
|
|
file.invariantSeparatorsPath.contains("/minecraft-merged-")
|
|
}
|
|
val sourceArchive = when {
|
|
sourcesJar.isFile -> sourcesJar
|
|
loomCacheSourceJar != null -> loomCacheSourceJar
|
|
else -> null
|
|
}
|
|
if (sourceArchive != null) {
|
|
ZipFile(sourceArchive).use { zip ->
|
|
return zip.entries().asSequence()
|
|
.filter { !it.isDirectory }
|
|
.filter { it.name.startsWith("net/minecraft/network/protocol/") && it.name.endsWith("Packet.java") }
|
|
.map { entry ->
|
|
SourceFile(entry.name, zip.getInputStream(entry).bufferedReader(Charsets.UTF_8).use { it.readText() })
|
|
}
|
|
.toList()
|
|
}
|
|
}
|
|
|
|
val localRoot = file("mc-src$minecraftVersion/libs/net.minecraft.minecraft-merged-deobf/net/minecraft/network/protocol")
|
|
if (localRoot.isDirectory) {
|
|
return localRoot.walkTopDown()
|
|
.filter { it.isFile && it.name.endsWith("Packet.java") }
|
|
.map { file ->
|
|
SourceFile(file.relativeTo(localRoot.parentFile.parentFile.parentFile.parentFile).invariantSeparatorsPath, file.readText(Charsets.UTF_8))
|
|
}
|
|
.toList()
|
|
}
|
|
|
|
logger.warn("Minecraft packet sources are unavailable for $minecraftVersion; generating an empty packet schema resource and using runtime fallback decoding.")
|
|
return emptyList()
|
|
}
|
|
|
|
fun packetTypeOf(text: String): String {
|
|
return Regex("""return\s+([A-Za-z0-9_]+PacketTypes\.[A-Z0-9_]+)\s*;""")
|
|
.find(text)
|
|
?.groupValues
|
|
?.get(1)
|
|
?: ""
|
|
}
|
|
|
|
fun codecStyleOf(text: String): String = when {
|
|
text.contains("StreamCodec.unit") -> "UNIT"
|
|
text.contains("StreamCodec.composite") -> "COMPOSITE"
|
|
text.contains("Packet.codec") -> "PACKET_CODEC"
|
|
else -> "CUSTOM"
|
|
}
|
|
|
|
fun escape(value: String): String = value
|
|
.replace('\t', ' ')
|
|
.replace('\n', ' ')
|
|
.replace('|', '/')
|
|
.replace('~', '-')
|
|
|
|
val specs = loadSources().mapNotNull { source ->
|
|
val pkg = Regex("""package\s+([A-Za-z0-9_.]+)\s*;""").find(source.text)?.groupValues?.get(1) ?: return@mapNotNull null
|
|
val simpleName = Regex("""public\s+(?:abstract\s+)?(?:record|class)\s+([A-Za-z0-9_]+Packet)\b""")
|
|
.find(source.text)
|
|
?.groupValues
|
|
?.get(1)
|
|
?: return@mapNotNull null
|
|
val codecStyle = codecStyleOf(source.text)
|
|
val parsed = parseFields(source.text)
|
|
val sourceKind = if (codecStyle == "UNIT") "unit" else parsed.first
|
|
val complete = codecStyle == "UNIT" || parsed.second.isNotEmpty()
|
|
PacketSpec(
|
|
"$pkg.$simpleName",
|
|
protocolFromPath(source.path),
|
|
directionFromName(simpleName),
|
|
codecStyle,
|
|
packetTypeOf(source.text),
|
|
sourceKind,
|
|
complete,
|
|
if (codecStyle == "UNIT") emptyList() else parsed.second
|
|
)
|
|
}.sortedBy { it.className }
|
|
|
|
val target = outputFile.get().asFile
|
|
target.parentFile.mkdirs()
|
|
target.printWriter(Charsets.UTF_8).use { out ->
|
|
out.println("# Generated from Minecraft $minecraftVersion packet sources. Fields preserve source/record order where available.")
|
|
out.println("# class\tprotocol\tdirection\tcodec\tpacketType\tsource\tcomplete\tfields(name~type~kind~editable|...)")
|
|
for (spec in specs) {
|
|
val fields = spec.fields.joinToString("|") { field ->
|
|
listOf(field.name, field.type, field.kind, field.editable.toString()).joinToString("~", transform = ::escape)
|
|
}
|
|
val columns = listOf(
|
|
spec.className,
|
|
spec.protocol,
|
|
spec.direction,
|
|
spec.codecStyle,
|
|
spec.packetType,
|
|
spec.source,
|
|
spec.complete.toString()
|
|
).joinToString("\t", transform = ::escape)
|
|
out.println("$columns\t$fields")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
val shippedResourceExtensions = setOf("png", "mcmeta", "ttf", "json", "ogg", "fsh", "vsh", "svg", "bin")
|
|
val shippedResourceRootFiles = setOf("fabric.mod.json", "autism.mixins.json")
|
|
|
|
val verifyShippedResources by tasks.registering {
|
|
group = "verification"
|
|
description = "Fails the build if a non-asset file in src/main/resources would be packaged into the jar."
|
|
val resourceRoot = layout.projectDirectory.dir("src/main/resources").asFile
|
|
inputs.dir(resourceRoot)
|
|
doLast {
|
|
if (!resourceRoot.exists()) return@doLast
|
|
val offenders = resourceRoot.walkTopDown().filter { it.isFile }.mapNotNull { file ->
|
|
val relative = file.relativeTo(resourceRoot).invariantSeparatorsPath
|
|
when {
|
|
!relative.contains('/') ->
|
|
if (relative in shippedResourceRootFiles) null
|
|
else "$relative (unexpected file at the resources root)"
|
|
!relative.startsWith("assets/") ->
|
|
"$relative (only assets/ is packaged)"
|
|
file.extension.lowercase() !in shippedResourceExtensions ->
|
|
"$relative (.${file.extension} is not an allowed asset type)"
|
|
else -> null
|
|
}
|
|
}.toList()
|
|
if (offenders.isNotEmpty()) {
|
|
throw GradleException(
|
|
"These files would be packaged into the jar:\n"
|
|
+ offenders.joinToString("\n") { " - $it" }
|
|
+ "\n\nMove them out of src/main/resources, or add the extension to shippedResourceExtensions."
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
tasks {
|
|
processResources {
|
|
dependsOn(verifyShippedResources)
|
|
dependsOn(generateAutismInspectorMappings)
|
|
dependsOn(generateAutismPacketSchemas)
|
|
dependsOn(generateVanillaUiAssets)
|
|
val propertyMap = mapOf(
|
|
"version" to project.version,
|
|
"mc_version" to libs.versions.minecraft.get(),
|
|
"fabric_api_version" to libs.versions.fabric.api.get()
|
|
)
|
|
|
|
inputs.properties(propertyMap)
|
|
|
|
filteringCharset = "UTF-8"
|
|
|
|
exclude("addon-template.mixins.json")
|
|
exclude("assets/template/**")
|
|
|
|
filesMatching("fabric.mod.json") {
|
|
expand(propertyMap)
|
|
}
|
|
}
|
|
|
|
jar {
|
|
inputs.property("archivesName", project.base.archivesName.get())
|
|
|
|
from("LICENSE") {
|
|
rename { "${it}_${inputs.properties["archivesName"]}" }
|
|
}
|
|
|
|
// ModMenu API is a compile-only soft dependency: we ship local stubs of its two API
|
|
// interfaces so we can compile the integration without a cross-version ModMenu artifact, but
|
|
// we must NOT bundle them — at runtime the real ModMenu provides them (and if ModMenu is
|
|
// absent, our integration entrypoint is simply never loaded).
|
|
exclude("com/terraformersmc/**")
|
|
}
|
|
|
|
java {
|
|
sourceCompatibility = JavaVersion.VERSION_25
|
|
targetCompatibility = JavaVersion.VERSION_25
|
|
toolchain {
|
|
languageVersion.set(JavaLanguageVersion.of(25))
|
|
}
|
|
}
|
|
|
|
withType<JavaCompile> {
|
|
options.encoding = "UTF-8"
|
|
options.release.set(25)
|
|
options.compilerArgs.add("-Xlint:deprecation")
|
|
options.compilerArgs.add("-Xlint:unchecked")
|
|
options.compilerArgs.add("-Xlint:-restricted")
|
|
}
|
|
}
|
|
|
|
// Publish the Loom-remapped jar to the local Maven repo so the standalone addon-template (and any
|
|
// third-party addon) can depend on it via `modImplementation("com.autismclient:autism:<version>")`.
|
|
// Run: ./gradlew publishToMavenLocal
|
|
publishing {
|
|
publications {
|
|
create<MavenPublication>("mavenJava") {
|
|
groupId = "com.autismclient"
|
|
artifactId = "autism"
|
|
version = project.version.toString()
|
|
from(components["java"])
|
|
}
|
|
}
|
|
repositories {
|
|
mavenLocal()
|
|
}
|
|
}
|
|
|
|
// Pin the templates' versions to this client's. Runs on publishToMavenLocal.
|
|
val syncAddonTemplateVersions by tasks.registering {
|
|
group = "addon templates"
|
|
description = "Pin addon-templates/*/gradle/libs.versions.toml to this client's versions."
|
|
|
|
val clientCatalog = rootDir.resolve("gradle/libs.versions.toml")
|
|
val templateCatalogs = listOf("minimal", "advanced").map {
|
|
rootDir.resolve("addon-templates/$it/gradle/libs.versions.toml")
|
|
}
|
|
inputs.file(clientCatalog)
|
|
outputs.files(templateCatalogs)
|
|
|
|
doLast {
|
|
val client = clientCatalog.readText()
|
|
fun readVersion(key: String): String {
|
|
val m = Regex("(?m)^\\s*" + Regex.escape(key) + "\\s*=\\s*\"([^\"]*)\"").find(client)
|
|
?: throw GradleException("Key '$key' not found in $clientCatalog")
|
|
return m.groupValues[1]
|
|
}
|
|
// the template's own mod-version is left alone
|
|
val values = linkedMapOf(
|
|
"minecraft" to readVersion("minecraft"),
|
|
"fabric-loader" to readVersion("fabric-loader"),
|
|
"fabric-api" to readVersion("fabric-api"),
|
|
"loom" to readVersion("loom"),
|
|
"autism" to readVersion("mod-version")
|
|
)
|
|
templateCatalogs.forEach { file ->
|
|
if (!file.exists()) throw GradleException("Missing template catalog: $file")
|
|
var text = file.readText()
|
|
values.forEach { (key, value) ->
|
|
val re = Regex("(?m)^(\\s*" + Regex.escape(key) + "\\s*=\\s*\")[^\"]*(\")")
|
|
if (re.containsMatchIn(text)) {
|
|
text = re.replace(text) { mr -> mr.groupValues[1] + value + mr.groupValues[2] }
|
|
} else {
|
|
logger.warn("syncAddonTemplateVersions: '$key' not found in ${file.name}; skipped.")
|
|
}
|
|
}
|
|
file.writeText(text)
|
|
}
|
|
logger.lifecycle(
|
|
"Synced addon templates: minecraft=${values["minecraft"]}, fabric-loader=${values["fabric-loader"]}, " +
|
|
"fabric-api=${values["fabric-api"]}, loom=${values["loom"]}, autism=${values["autism"]}"
|
|
)
|
|
}
|
|
}
|
|
|
|
tasks.named("publishToMavenLocal") {
|
|
dependsOn(syncAddonTemplateVersions)
|
|
}
|
|
|
|
// Scaffold/scan tasks call addon-toolkit.py (these need Python 3). Building needs no Python.
|
|
fun resolvePythonCommand(): List<String> {
|
|
val isWindows = System.getProperty("os.name").lowercase().contains("win")
|
|
val candidates = if (isWindows) listOf(listOf("py", "-3"), listOf("python"), listOf("python3"))
|
|
else listOf(listOf("python3"), listOf("python"))
|
|
for (candidate in candidates) {
|
|
try {
|
|
if (ProcessBuilder(candidate + "--version").redirectErrorStream(true).start().waitFor() == 0) return candidate
|
|
} catch (_: Exception) {
|
|
}
|
|
}
|
|
throw GradleException("Python 3 was not found on PATH (needed for the addon toolkit tasks).")
|
|
}
|
|
|
|
fun toolkitArgs(project: Project, action: String): List<String> {
|
|
val args = mutableListOf(
|
|
project.rootDir.resolve("addon-templates/addon-toolkit.py").absolutePath,
|
|
action,
|
|
"--non-interactive",
|
|
)
|
|
// gradleProperty, not findProperty: names like "project"/"name" clash with Project getters.
|
|
fun prop(name: String) = project.providers.gradleProperty(name).orNull?.takeIf { it.isNotBlank() }
|
|
args += listOf("--template", prop("template") ?: "minimal")
|
|
mapOf(
|
|
"name" to "--name", "out" to "--output", "project" to "--project", "addonId" to "--addon-id",
|
|
"package" to "--package", "author" to "--author", "description" to "--description",
|
|
"mavenGroup" to "--maven-group", "archiveName" to "--archive-name", "addonVersion" to "--addon-version",
|
|
).forEach { (propName, flag) -> prop(propName)?.let { args += listOf(flag, it) } }
|
|
if (project.providers.gradleProperty("advanced").isPresent) args += "--advanced"
|
|
if (project.providers.gradleProperty("yes").isPresent) args += "--yes"
|
|
if (project.providers.gradleProperty("noPublish").isPresent) args += "--no-publish"
|
|
return args
|
|
}
|
|
|
|
listOf(
|
|
Triple("newAddon", "setup", "Scaffold a new addon. Props: -Pname= -Ptemplate=minimal|advanced -Pout= (optional -PaddonId/-Ppackage/-Pauthor/-Padvanced)."),
|
|
Triple("scanAddon", "scan", "Validate one addon project. Prop: -Pproject=<path>."),
|
|
Triple("validateAddons", "validate", "Validate the in-repo addon system and shipped templates."),
|
|
Triple("cleanAddon", "clean", "Delete an addon project's build/.gradle. Props: -Pproject=<path> -Pyes."),
|
|
).forEach { (taskName, action, taskDescription) ->
|
|
tasks.register(taskName) {
|
|
group = "addon toolkit"
|
|
description = taskDescription
|
|
doLast {
|
|
val command = resolvePythonCommand() + toolkitArgs(project, action)
|
|
val process = ProcessBuilder(command).directory(project.rootDir).redirectErrorStream(true).start()
|
|
process.inputStream.bufferedReader().forEachLine { logger.lifecycle(it) }
|
|
val exit = process.waitFor()
|
|
if (exit != 0) throw GradleException("addon toolkit '$action' failed (exit code $exit).")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pure Gradle, no Python: publish the API, then build each shipped template via its own wrapper.
|
|
tasks.register("buildAllTemplates") {
|
|
group = "addon toolkit"
|
|
description = "Publish the API locally and build both shipped templates (no Python needed)."
|
|
dependsOn("publishToMavenLocal")
|
|
doLast {
|
|
val isWindows = System.getProperty("os.name").lowercase().contains("win")
|
|
for (name in listOf("minimal", "advanced")) {
|
|
val dir = project.rootDir.resolve("addon-templates/$name")
|
|
val wrapper = dir.resolve(if (isWindows) "gradlew.bat" else "gradlew")
|
|
if (!wrapper.isFile) throw GradleException("Missing Gradle wrapper: $wrapper")
|
|
val cmd = if (!isWindows && !wrapper.canExecute()) listOf("sh", wrapper.absolutePath, "build")
|
|
else listOf(wrapper.absolutePath, "build")
|
|
logger.lifecycle("Building $name template")
|
|
val process = ProcessBuilder(cmd).directory(dir).redirectErrorStream(true).start()
|
|
process.inputStream.bufferedReader().forEachLine { logger.lifecycle(it) }
|
|
if (process.waitFor() != 0) throw GradleException("$name template build failed.")
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LITE variant: turns the FINAL release jar (post guardian weaving) into the stripped-down
|
|
// "GEC Client Lite" build in four stages, with no recompilation so the jars can never drift.
|
|
// The pipeline uses NO ProGuard and NO obfuscation tooling of any kind - every stage is our own
|
|
// code (ASM for bytecode surgery, plain zip + constant-pool analysis for shrinking):
|
|
// litePatchVariant - copies the full jar, patches AutismLiteVariant.ENABLED to a compile-time
|
|
// constant true, and folds every literal enabled() gate with its own exact
|
|
// ASM pass (branch fold + dead-code sweep, CheckClassAdapter-verified).
|
|
// liteShrinkJar - our own reachability shrink: seeds (entrypoint, addon API, pinned classes,
|
|
// applied mixins) -> transitive constant-pool closure, then copies surviving
|
|
// classes WHOLE. Nothing external renames, specializes or rewrites bytecode;
|
|
// a kept class is byte-identical to the full jar's, a dropped one is simply
|
|
// absent. Also strips LocalVariableTables (stale after dead-code removal).
|
|
// liteJar - repackages the shrunk jar: stripped-feature assets out, stripped-mixin
|
|
// classes + json entries out, unreferenced nested libraries out (data-driven),
|
|
// marker resource + transformed mixin json + fabric.mod.json edits in.
|
|
// verifyLiteJar - fails the build on any regression: marker, pinned classes, asset/mixin
|
|
// leaks, known-dead classes that must NOT survive, size ceiling, a
|
|
// dangling-reference guard (the NoClassDefFoundError vector), macro/config
|
|
// parity, and an LVT guard.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
val liteStripMixins = listOf(
|
|
// Multi / POV pilot (the Multi system is stripped from lite)
|
|
"AutismBotPlayerInfoMixin", "AutismBotPilotMixin", "AutismBotEquipmentMixin",
|
|
"AutismPilotLocalPlayerRenderMixin", "AutismPilotChatMixin", "AutismPilotLocalInputMixin",
|
|
"AutismPilotEditRerouteMixin", "AutismPilotHandItemMixin", "AutismMultiPovChatMixin",
|
|
// title/menu-only (custom main menu is stripped from lite; splash mixins stay for panic cosmetics)
|
|
"AutismTitleScreenSupportMixin", "AutismGuiRendererPanoramaMixin",
|
|
"AutismPanoramaOverlayMixin", "AutismLogoRendererMixin",
|
|
// cheat-module-only mixins whose module classes are physically excluded from the lite jar
|
|
// (verified: nothing kept in lite references them; the lithium entries need their package
|
|
// prefix so the json line filter matches)
|
|
"AutismCrystalViewModelMixin", "AutismCrystalViewRendererMixin", "AutismPlayerNoPhysicsMixin",
|
|
"AutismBlockCollisionsMixin", "lithium.AutismLithiumSweeperBlockPosMixin",
|
|
"lithium.AutismLithiumSweeperVoxelShapeMixin",
|
|
// stripped-module render mixins: their only writers (NoRender/Viewmodel/Chams modules) are all
|
|
// physically excluded, so every injection is pure dead weight in lite. AutismNoRenderTotemMixin
|
|
// stays - it is also the hook for AutoTotem's kept No Render setting. The NoRenderState /
|
|
// ViewmodelState / chams state classes stay pinned (referenced by kept mixins).
|
|
"AutismNoRenderHudMixin", "AutismNoRenderNauseaMixin", "AutismNoRenderHurtcamMixin",
|
|
"AutismNoRenderClientLevelMixin", "AutismNoRenderScreenEffectMixin",
|
|
"AutismNoRenderBossHealthOverlayMixin", "AutismNoRenderScreenMixin",
|
|
"AutismNoRenderEatParticleMixin", "AutismNoRenderFogMixin", "AutismNoRenderFogEnvMixin",
|
|
"AutismNoRenderWeatherMixin", "AutismNoRenderWeatherStateMixin", "AutismNoRenderSkyMixin",
|
|
"AutismNoRenderCloudMixin", "AutismNoRenderTimeMixin", "AutismNoRenderWorldBorderMixin",
|
|
"AutismNoRenderBlockBreakMixin", "AutismNoRenderBeaconMixin", "AutismNoRenderEnchantTableMixin",
|
|
"AutismNoRenderSignMixin", "AutismNoRenderMapMixin", "AutismNoRenderBannerMixin",
|
|
"AutismNoRenderParticleMixin", "AutismNoRenderBlockSeedMixin", "AutismNoRenderBlockOffsetMixin",
|
|
"AutismNoRenderBlockEntityMixin", "AutismNoRenderArmorLayerMixin", "AutismNoRenderHeadLayerMixin",
|
|
"AutismNoRenderEntityFlagsMixin", "AutismNoRenderSpawnerMixin", "AutismNoRenderDeadEntityMixin",
|
|
"AutismNoRenderEntityRendererMixin", "AutismNoRenderSpawnPacketMixin", "AutismNoRenderGlintMixin",
|
|
"AutismNoRenderObfuscationMixin",
|
|
"AutismViewmodelMixin", "AutismViewmodelSwingMixin", "AutismViewmodelStrideMixin",
|
|
"AutismChamsLivingEntityMixin", "AutismChamsSubmitMixin", "AutismChamsCapeLayerMixin",
|
|
"AutismEntityRenderStateChamsMixin"
|
|
)
|
|
|
|
// The shrink (liteShrinkJar) removes dead classes on its own once the gates fold; the mixin
|
|
// class-file exclusions in liteJar below stay as belt-and-braces so a keep-rule mistake cannot
|
|
// leave a json-stripped mixin behind silently (verifyLiteJar checks both sides).
|
|
|
|
// Assets excluded from the lite jar (~10.7 MB): every one of them is only referenced by
|
|
// stripped/mixin-stripped code or by paths the lite gates close (loading overlay, welcome, title).
|
|
val liteStripAssets = listOf(
|
|
"assets/autismclient/textures/gui/title/background/",
|
|
"assets/autismclient/captcha/",
|
|
"assets/autismclient/sounds.json", "assets/autismclient/sounds/",
|
|
"assets/autismclient/icons/window/",
|
|
"assets/autismclient/textures/gui/title/loading_logo.png",
|
|
"assets/autismclient/textures/gui/title/autism_client_logo.png",
|
|
"assets/autismclient/textures/gui/title/autism_client_logo.png.mcmeta",
|
|
"assets/autismclient/textures/gui/title/button_text/",
|
|
"assets/autismclient/textures/gui/title/icons/essential.png",
|
|
"assets/autismclient/textures/gui/title/icons/modmenu.png",
|
|
"assets/autismclient/textures/gui/title/icons/discord.png",
|
|
"assets/autismclient/textures/gui/title/icons/accessibility.png",
|
|
"assets/autismclient/textures/gui/title/icons/language.png",
|
|
// donate.png + autismclient_welcome.png STAY: the first-run donate dialog shows in lite too.
|
|
"assets/autismclient/textures/gui/hud/autismclient.png",
|
|
"assets/autismclient/textures/gui/hud/autismclient_hud.png",
|
|
"assets/autismclient/textures/gui/hud/autismclient.svg",
|
|
"assets/autismclient/textures/gui/autism/",
|
|
"assets/autismclient/shaders/core/",
|
|
"assets/autismclient/textures/gui/accounts/share.png",
|
|
"assets/autismclient/textures/gui/vanillaui/icons/matchmaking.png",
|
|
"assets/autismclient/textures/gui/vanillaui/icons/multi.png",
|
|
"assets/autismclient/textures/gui/vanillaui/icons/profiles.png",
|
|
"assets/autismclient/textures/gui/vanillaui/icons/mainmenucategory.png",
|
|
"assets/autismclient/textures/gui/vanillaui/icons/chatcategory.png",
|
|
"assets/autismclient/textures/gui/icons/chevron_left.png",
|
|
"META-INF/services/autismclient.util.mm.guardian.Guardian"
|
|
)
|
|
|
|
// Nested libraries are stripped DATA-DRIVEN by liteJar: any bundled lib with zero references from
|
|
// surviving classes (paho once the mm fold lands, etc.) is excluded and its fabric.mod.json
|
|
// declaration removed. liteKeepLibs below pins the ones that must always stay.
|
|
|
|
// The pinned must-stay list: classes the audits proved are referenced by lite-executed code
|
|
// (always-applied mixins, per-frame/per-tick paths, eager superinterfaces, config save paths).
|
|
// verifyLiteJar fails the build if any of these is missing from the lite jar - that is what makes
|
|
// an over-aggressive strip impossible to ship.
|
|
val liteKeepPinned = listOf(
|
|
"autismclient/modules/Module.class", "autismclient/modules/ModuleCategory.class",
|
|
"autismclient/modules/ModuleRegistry.class", "autismclient/modules/BuiltinModules.class",
|
|
"autismclient/modules/BuiltinModules\$HideModule.class", "autismclient/modules/AutismModule.class",
|
|
"autismclient/modules/PackHideState.class", "autismclient/modules/PackFreecamState.class",
|
|
"autismclient/modules/AutoTotemModule.class", "autismclient/modules/AutoArmorModule.class",
|
|
"autismclient/modules/BedDefenderModule.class", "autismclient/modules/SafeWalkModule.class",
|
|
"autismclient/modules/KillAuraModule.class", "autismclient/modules/FreeLookModule.class",
|
|
"autismclient/modules/GhostBlockModule.class",
|
|
"autismclient/modules/TeamsModule.class", "autismclient/modules/TpClickModule.class",
|
|
"autismclient/modules/HoleEspModule.class", "autismclient/modules/ScaffoldModule.class",
|
|
"autismclient/modules/ModuleOreSim.class", "autismclient/modules/TrajectoriesModule.class",
|
|
// ModuleEspMesh is hit from StorageSnapshot.EMPTY's static initializer - any static access to
|
|
// ModuleWorldRenderer would fail class init in lite without it (bootstrap + per-frame tracers).
|
|
"autismclient/modules/ModuleEspMesh.class",
|
|
"autismclient/modules/AirPlaceModule.class", "autismclient/modules/InventoryTweaksModule.class",
|
|
"autismclient/modules/GoldenLeverModule.class", "autismclient/modules/NameCensorModule.class",
|
|
"autismclient/modules/AntiVanishModule.class", "autismclient/modules/AutoSignModule.class",
|
|
"autismclient/modules/AutoLoginModule.class", "autismclient/modules/AntiHungerModule.class",
|
|
"autismclient/modules/BoatFlyModule.class", "autismclient/modules/EntityControlModule.class",
|
|
"autismclient/modules/AirJumpModule.class", "autismclient/modules/AutismAntiBot.class",
|
|
"autismclient/modules/BuiltinModules\$SneakModule.class", "autismclient/modules/BuiltinModules\$FastBreakModule.class",
|
|
"autismclient/modules/BuiltinModules\$FlightModule.class", "autismclient/modules/BuiltinModules\$SprintModule.class",
|
|
"autismclient/modules/BuiltinModules\$SpeedModule.class",
|
|
"autismclient/gui/screen/AutismPanicTitleScreen.class", "autismclient/gui/screen/AutismVoiceChatPromptScreen.class",
|
|
"autismclient/util/multi/PacketTeleportController.class", "autismclient/util/multi/MultiProxyVerifier.class",
|
|
"autismclient/util/multi/MultiManager.class", "autismclient/util/multi/MultiSession.class",
|
|
"autismclient/util/multi/MultiProfile.class",
|
|
"autismclient/util/multi/MultiPacketPolicy.class", "autismclient/util/multi/MultiAutoAccept.class",
|
|
"autismclient/util/multi/MultiQuickAction.class", "autismclient/util/multi/MultiTakeoverState.class",
|
|
"autismclient/util/multi/MultiPilot.class", "autismclient/util/multi/MultiPilotTruth.class",
|
|
"autismclient/util/multi/MultiPovModuleController.class", "autismclient/util/multi/MultiConnectionMarker.class",
|
|
"autismclient/util/multi/MultiConnectionContext.class",
|
|
"autismclient/util/AutismTheme.class",
|
|
"autismclient/util/AutismThemeTextures.class", "autismclient/util/AutismFabricatorOverlay.class",
|
|
// AutismMarquee is hit from AutismHudManager's own static initializer (spotifyTextCacheKey) -
|
|
// stripping it crashed class init on the first screen click in lite.
|
|
"autismclient/util/AutismMarquee.class",
|
|
"autismclient/util/AutismSvgHudLogo.class"
|
|
)
|
|
|
|
val liteKeepLibs = listOf(
|
|
"META-INF/jars/mixinextras-fabric-0.5.4.jar",
|
|
"META-INF/jars/netty-codec-socks-4.1.118.Final.jar",
|
|
"META-INF/jars/netty-handler-proxy-4.1.118.Final.jar",
|
|
"META-INF/jars/waybackauthlib-1.1.0.jar",
|
|
"META-INF/jars/jsvg-2.1.0.jar"
|
|
)
|
|
|
|
// The inverse of the pin list: classes/libs that the fold + shrink MUST remove. If any of these
|
|
// is present in the lite jar, a lite gate lost its literal call-site form (or a new ungated
|
|
// referrer appeared) and the shrink silently kept a stripped feature alive - fail the build.
|
|
// NOTE: dead modules whose ONLY anchor was the old override rule (CrystalAura, AnchorAura,
|
|
// Surround, ...) now die entirely - our reachability walk has no such rule, and after their
|
|
// lite-gated bodies fold nothing references them. The multi-macro interpreter core
|
|
// (MultiMacroRun/MultiMacroHost) is different: MultiSession.macroRun's field descriptor anchors
|
|
// it from lite-live engine code (paced-teleport validation); re-typing that field is a deep
|
|
// engine refactor deliberately not done for ~0.3 MB. Not listed here by design.
|
|
// Macro parity exemption: the ONLY macro-package classes allowed to exist in the full jar but
|
|
// not in lite (the guard below fails the build on any other). Each entry must carry its proof.
|
|
// Adding a class here is a deliberate "this does not belong in lite" decision - never use it to
|
|
// silence a real leak.
|
|
val liteMacroParityExempt = listOf(
|
|
// Constructed only from registry paths that are themselves dead in lite (the WaitGui action
|
|
// type is unconstructable even in the full jar; entity conditions run through a different,
|
|
// lite-live path - nothing alive constructs these). The member pass correctly removes them.
|
|
"autismclient/util/macro/MacroConditionRegistry\$EntityCondition.class",
|
|
"autismclient/util/macro/MacroConditionRegistry\$GuiCloseCondition.class",
|
|
// Referenced only by the stripped AutoFish module/cluster and the dead module menu.
|
|
"autismclient/util/macro/MacroConditionUtil.class",
|
|
// Thrown/caught only from paths that are dead in lite (never from lite-live bindings code).
|
|
"autismclient/util/macro/MacroDynamicBindings\$MissingDynamicValueException.class"
|
|
)
|
|
|
|
val liteKnownDead = listOf(
|
|
"autismclient/modules/BuiltinModules\$ParkourModule.class",
|
|
"autismclient/modules/BuiltinModules\$AdminToolsModule.class",
|
|
"autismclient/commands/impl/IrcCommand.class",
|
|
"autismclient/gui/mm/MatchmakingPanel.class",
|
|
"autismclient/gui/screen/AutismTitleScreen.class",
|
|
"autismclient/gui/screen/AutismModuleScreen.class",
|
|
"autismclient/util/mm/MatchmakingManager.class",
|
|
"autismclient/util/mm/relay/MqttRelay.class",
|
|
"autismclient/util/AutismProfileManager.class",
|
|
"autismclient/util/AutismAdminToolsOverlay.class",
|
|
"META-INF/jars/org.eclipse.paho.client.mqttv3-1.2.5.jar"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Member-level reachability (phase 2 of the shrink): drops unused methods and fields from kept
|
|
// classes, so a dead feature's members stop anchoring its classes (descriptor refs and method
|
|
// bodies are what kept DupeRadar/Spotify/geo lookup alive in lite-live classes). Conservative by
|
|
// design - over-keep beats under-keep every time:
|
|
// WHOLESALE classes (kept complete): mixin seeds + nested, api.**, the entrypoint, enums,
|
|
// records, interfaces, annotations, and the gson config models (AutismConfig+nesteds,
|
|
// AutismPacketPreset, ServerPluginScanCache$CacheFile) - framework contracts and reflective
|
|
// models are never pruned.
|
|
// SEEDS for the call walk: every method of wholesale classes, every <clinit>, and every no-arg
|
|
// <init> (gson/reflection instantiation safety).
|
|
// WALK: a kept method keeps every method it calls (JVM lookup: owner, then supertypes), every
|
|
// field it touches, every invokedynamic-referenced method, plus every same-signature method in
|
|
// kept supertypes AND kept subtypes (override safety). Synthetic/bridge methods are always
|
|
// kept - dropping those is where subtle dispatch bugs live.
|
|
// verifyLiteJar's method-level dangling guard re-proves the closure (every kept call site
|
|
// resolves), so a walk bug fails the build instead of the game.
|
|
// ---------------------------------------------------------------------------
|
|
private data class LiteMember(val owner: String, val name: String, val desc: String)
|
|
|
|
// Library-callback contracts: methods the JVM or library code calls where NO call site exists in
|
|
// our bytecode (string concat invokes toString virtually, HashMap invokes equals/hashCode,
|
|
// sort executors invoke run/call/compare/get/apply/accept/test, resource blocks invoke close,
|
|
// iteration invokes iterator). Pruning one of these is an AbstractMethodError at runtime with no
|
|
// dangling call site to catch it (verified in-game: a codec's toString died exactly so).
|
|
private val liteCallbackContracts = setOf(
|
|
"toString()Ljava/lang/String;", "equals(Ljava/lang/Object;)Z", "hashCode()I",
|
|
"clone()Ljava/lang/Object;", "finalize()V",
|
|
"run()V", "call()Ljava/lang/Object;", "get()Ljava/lang/Object;",
|
|
"accept(Ljava/lang/Object;)V", "apply(Ljava/lang/Object;)Ljava/lang/Object;",
|
|
"test(Ljava/lang/Object;)Z", "compareTo(Ljava/lang/Object;)I",
|
|
"compare(Ljava/lang/Object;Ljava/lang/Object;)I",
|
|
"iterator()Ljava/util/Iterator;", "close()V"
|
|
)
|
|
|
|
fun litePruneMembers(classBytes: Map<String, ByteArray>, reachable: Set<String>, mixinSeeds: Set<String>, classSeeds: Set<String>): Map<String, ByteArray> {
|
|
val nodes = LinkedHashMap<String, ClassNode>()
|
|
for (name in reachable) {
|
|
val cn = ClassNode()
|
|
ClassReader(classBytes.getValue(name)).accept(cn, 0)
|
|
nodes[name] = cn
|
|
}
|
|
|
|
val superCache = HashMap<String, List<String>>()
|
|
fun allSupers(name: String): List<String> = superCache.getOrPut(name) {
|
|
val out = LinkedHashSet<String>()
|
|
val queue = ArrayDeque<String>()
|
|
fun add(c: String?) {
|
|
if (c != null && c in nodes && out.add(c)) queue.addLast(c)
|
|
}
|
|
add(nodes[name]?.superName)
|
|
nodes[name]?.interfaces?.forEach(::add)
|
|
while (queue.isNotEmpty()) {
|
|
val c = queue.removeFirst()
|
|
add(nodes[c]?.superName)
|
|
nodes[c]?.interfaces?.forEach(::add)
|
|
}
|
|
out.toList()
|
|
}
|
|
// Reverse map: supertype -> kept subtypes (for the override closure).
|
|
val subtypesOf = HashMap<String, MutableList<String>>()
|
|
for (name in nodes.keys) {
|
|
for (s in allSupers(name)) subtypesOf.getOrPut(s) { mutableListOf() }.add(name)
|
|
}
|
|
|
|
val wholesale = HashSet<String>()
|
|
for ((name, cn) in nodes) {
|
|
if (mixinSeeds.any { name == it || name.startsWith("$it\$") }) { wholesale += name; continue }
|
|
if (name.startsWith("autismclient/api/")) { wholesale += name; continue }
|
|
if (name == "autismclient/AutismClientMod" || name == "autismclient/util/AutismLiteVariant") { wholesale += name; continue }
|
|
if (name.startsWith("autismclient/util/AutismConfig")) { wholesale += name; continue }
|
|
if (name == "autismclient/util/AutismPacketPreset" ||
|
|
name == "autismclient/util/ServerPluginScanCache\$CacheFile" ||
|
|
name == "autismclient/util/AutismWaypoints\$Waypoint" ||
|
|
name == "autismclient/util/AutismPresetManager\$PresetEntry" ||
|
|
name == "autismclient/util/AutismPayloadJsonSupport\$EncodedPayload") { wholesale += name; continue }
|
|
if ((cn.access and (Opcodes.ACC_ENUM or Opcodes.ACC_INTERFACE or Opcodes.ACC_ANNOTATION)) != 0) {
|
|
wholesale += name
|
|
}
|
|
}
|
|
|
|
val keptMethods = HashSet<LiteMember>()
|
|
val keptFields = HashSet<LiteMember>()
|
|
val methodQueue = ArrayDeque<LiteMember>()
|
|
|
|
fun findMethod(owner: String, name: String, desc: String): LiteMember? {
|
|
nodes[owner]?.methods?.forEach { if (it.name == name && it.desc == desc) return LiteMember(owner, name, desc) }
|
|
for (s in allSupers(owner)) {
|
|
nodes[s]?.methods?.forEach { if (it.name == name && it.desc == desc) return LiteMember(s, name, desc) }
|
|
}
|
|
return null
|
|
}
|
|
fun keepMethod(m: LiteMember) {
|
|
if (keptMethods.add(m)) methodQueue.addLast(m)
|
|
}
|
|
fun keepField(owner: String, name: String, desc: String) {
|
|
if (owner in nodes) keptFields += LiteMember(owner, name, desc)
|
|
else for (s in allSupers(owner)) if (s in nodes) { keptFields += LiteMember(s, name, desc); return }
|
|
}
|
|
|
|
// Framework-callback classes: a class rooted in a type OUTSIDE the jar (a Minecraft Screen,
|
|
// a netty ChannelDuplexHandler, a brigadier ArgumentType, ClassValue, LinkedHashMap, records,
|
|
// ...) can be called back virtually by that framework with no call site visible in our
|
|
// bytecode, so nothing in the walk would ever reach such a method. Seed ALL of their methods
|
|
// (exactly like wholesale) so the walk traverses INTO them and keeps everything they call.
|
|
// (Verified in-game twice: pruned Screen.init/tick, ChannelDuplexHandler.channelRead/write,
|
|
// ClassValue.computeValue and brigadier ArgumentType.parse were silent breakage; then a kept
|
|
// but unwalked codec decode() lost its callees - NoSuchMethodError at startup.)
|
|
val externalRooted = HashSet<String>()
|
|
for (name in nodes.keys) {
|
|
val visited = LinkedHashSet<String>()
|
|
val queue = ArrayDeque<String>()
|
|
queue.addLast(name)
|
|
var external = false
|
|
while (queue.isNotEmpty() && !external) {
|
|
val cn = nodes[queue.removeFirst()] ?: continue
|
|
val supers = listOfNotNull(cn.superName) + cn.interfaces.filterIsInstance<String>()
|
|
for (s in supers) {
|
|
if (s !in nodes) {
|
|
if (s != "java/lang/Object") { external = true; break }
|
|
} else if (visited.add(s)) {
|
|
queue.addLast(s)
|
|
}
|
|
}
|
|
}
|
|
if (external) externalRooted += name
|
|
}
|
|
|
|
for ((name, cn) in nodes) {
|
|
for (mn in cn.methods) {
|
|
if (name in wholesale || name in externalRooted ||
|
|
mn.name == "<clinit>" || (mn.name == "<init>" && mn.desc == "()V")) {
|
|
keepMethod(LiteMember(name, mn.name, mn.desc))
|
|
}
|
|
}
|
|
}
|
|
|
|
while (methodQueue.isNotEmpty()) {
|
|
val cur = methodQueue.removeFirst()
|
|
val cn = nodes[cur.owner] ?: continue
|
|
val mn = cn.methods.firstOrNull { it.name == cur.name && it.desc == cur.desc } ?: continue
|
|
for (insn in mn.instructions) {
|
|
when (insn) {
|
|
is org.objectweb.asm.tree.MethodInsnNode -> findMethod(insn.owner, insn.name, insn.desc)?.let(::keepMethod)
|
|
is org.objectweb.asm.tree.FieldInsnNode -> keepField(insn.owner, insn.name, insn.desc)
|
|
is org.objectweb.asm.tree.InvokeDynamicInsnNode -> {
|
|
fun handle(h: org.objectweb.asm.Handle?) {
|
|
if (h != null) findMethod(h.owner, h.name, h.desc)?.let(::keepMethod)
|
|
}
|
|
handle(insn.bsm)
|
|
for (arg in insn.bsmArgs) if (arg is org.objectweb.asm.Handle) handle(arg)
|
|
}
|
|
}
|
|
}
|
|
// Override closure: same signature in every kept supertype and every kept subtype.
|
|
for (s in allSupers(cur.owner)) {
|
|
nodes[s]?.methods?.forEach { if (it.name == cur.name && it.desc == cur.desc) keepMethod(LiteMember(s, it.name, it.desc)) }
|
|
}
|
|
subtypesOf[cur.owner]?.forEach { sub ->
|
|
nodes[sub]?.methods?.forEach { if (it.name == cur.name && it.desc == cur.desc) keepMethod(LiteMember(sub, it.name, it.desc)) }
|
|
}
|
|
}
|
|
|
|
// Prune: wholesale classes ship whole; external-rooted classes keep all methods (their fields
|
|
// are still prunable - nothing dispatches to a field virtually); others keep only the walked
|
|
// members. Synthetic methods are NOT exempted - they are kept only when walked-to (a kept
|
|
// invokedynamic keeps its lambda; everything else that is never called is dead, bridge
|
|
// dispatch falls back to the override).
|
|
val prunedBytes = LinkedHashMap<String, ByteArray>()
|
|
for ((name, cn) in nodes) {
|
|
if (name !in wholesale) {
|
|
if (name !in externalRooted) {
|
|
cn.methods.removeIf { mn ->
|
|
mn.name != "<clinit>" &&
|
|
(mn.name + mn.desc) !in liteCallbackContracts &&
|
|
LiteMember(name, mn.name, mn.desc) !in keptMethods
|
|
}
|
|
}
|
|
cn.fields.removeIf { fn -> LiteMember(name, fn.name, fn.desc) !in keptFields }
|
|
}
|
|
val cw = ClassWriter(0)
|
|
cn.accept(cw)
|
|
prunedBytes[name] = cw.toByteArray()
|
|
}
|
|
|
|
// Final class-level closure over the pruned bytes: classes only reachable through pruned
|
|
// members (dead feature methods) drop out too. Seeds = the class-pass seeds + wholesale.
|
|
val finalReachable = LinkedHashSet<String>()
|
|
classSeeds.filter { it in prunedBytes }.forEach { finalReachable += it }
|
|
wholesale.filter { it in prunedBytes }.forEach { finalReachable += it }
|
|
val queue = ArrayDeque(finalReachable)
|
|
while (queue.isNotEmpty()) {
|
|
val c = queue.removeFirst()
|
|
val bytes = prunedBytes[c] ?: continue
|
|
for (r in liteReferencedClasses(bytes)) {
|
|
if (r in prunedBytes && finalReachable.add(r)) queue.addLast(r)
|
|
}
|
|
}
|
|
|
|
return prunedBytes.filterKeys { it in finalReachable }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared constant-pool scan: every class a .class file references (CONSTANT_Class entries plus
|
|
// class names inside field/method descriptor Utf8s - both are JVM verifier-load vectors). Used by
|
|
// liteJar's data-driven nested-lib strip and by verifyLiteJar's dangling-reference guard.
|
|
// Over-approximation is intentional: a false hit fails the build loudly, never ships a dangle.
|
|
// ---------------------------------------------------------------------------
|
|
val liteDescRef = Regex("L([A-Za-z0-9_/\$]+);")
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Usage-precise class reference scan: every class a .class file references through CODE and
|
|
// descriptors (supertypes, field/method signatures incl. generics, instructions, exception
|
|
// handlers, stack-map frames, annotations) - which is also exactly what the JVM verifier may
|
|
// load while linking the class. The InnerClasses and EnclosingMethod attributes are deliberately
|
|
// NOT counted: they are reflection metadata that the JVM never eagerly loads, and counting them
|
|
// would anchor every nested class (e.g. all BuiltinModules$* modules) through its outer class
|
|
// even when nothing uses it. Used by liteShrinkJar's reachability walk, liteJar's data-driven
|
|
// nested-lib strip and verifyLiteJar's dangling-reference guard.
|
|
// ---------------------------------------------------------------------------
|
|
fun liteReferencedClasses(bytes: ByteArray): Set<String> {
|
|
val refs = HashSet<String>()
|
|
val cn = ClassNode()
|
|
ClassReader(bytes).accept(cn, org.objectweb.asm.ClassReader.EXPAND_FRAMES)
|
|
fun addDesc(desc: String?) {
|
|
if (desc == null) return
|
|
liteDescRef.findAll(desc).forEach { refs += it.groupValues[1] }
|
|
}
|
|
fun addType(t: org.objectweb.asm.Type?) {
|
|
if (t != null) addDesc(t.descriptor)
|
|
}
|
|
cn.superName?.let { refs += it }
|
|
refs += cn.interfaces
|
|
addDesc(cn.signature)
|
|
cn.visibleAnnotations?.forEach { addDesc(it.desc) }
|
|
cn.invisibleAnnotations?.forEach { addDesc(it.desc) }
|
|
for (f in cn.fields) {
|
|
addDesc(f.desc)
|
|
addDesc(f.signature)
|
|
f.visibleAnnotations?.forEach { addDesc(it.desc) }
|
|
f.invisibleAnnotations?.forEach { addDesc(it.desc) }
|
|
}
|
|
fun addHandle(h: org.objectweb.asm.Handle?) {
|
|
if (h == null) return
|
|
refs += h.owner
|
|
addDesc(h.desc)
|
|
}
|
|
for (m in cn.methods) {
|
|
addDesc(m.desc)
|
|
addDesc(m.signature)
|
|
m.exceptions?.forEach { refs += it }
|
|
m.visibleAnnotations?.forEach { addDesc(it.desc) }
|
|
m.invisibleAnnotations?.forEach { addDesc(it.desc) }
|
|
m.tryCatchBlocks?.forEach { tcb -> tcb.type?.let { refs += it } }
|
|
for (insn in m.instructions) {
|
|
when (insn) {
|
|
is org.objectweb.asm.tree.TypeInsnNode -> addDesc(insn.desc)
|
|
is org.objectweb.asm.tree.MethodInsnNode -> { refs += insn.owner; addDesc(insn.desc) }
|
|
is org.objectweb.asm.tree.FieldInsnNode -> { refs += insn.owner; addDesc(insn.desc) }
|
|
is org.objectweb.asm.tree.MultiANewArrayInsnNode -> addDesc(insn.desc)
|
|
is org.objectweb.asm.tree.LdcInsnNode -> if (insn.cst is org.objectweb.asm.Type) addType(insn.cst as org.objectweb.asm.Type)
|
|
is org.objectweb.asm.tree.InvokeDynamicInsnNode -> {
|
|
addDesc(insn.desc)
|
|
addHandle(insn.bsm)
|
|
for (arg in insn.bsmArgs) {
|
|
when (arg) {
|
|
is org.objectweb.asm.Handle -> addHandle(arg)
|
|
is org.objectweb.asm.Type -> addType(arg)
|
|
}
|
|
}
|
|
}
|
|
is org.objectweb.asm.tree.FrameNode -> {
|
|
insn.local?.forEach { if (it is String) refs += it }
|
|
insn.stack?.forEach { if (it is String) refs += it }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return refs
|
|
}
|
|
|
|
// Naive byte-substring scan (gate pre-filter for litePatchVariant).
|
|
fun liteContainsSubarray(haystack: ByteArray, needle: ByteArray): Boolean {
|
|
if (needle.isEmpty() || haystack.size < needle.size) return false
|
|
outer@ for (i in 0..haystack.size - needle.size) {
|
|
for (j in needle.indices) if (haystack[i + j] != needle[j]) continue@outer
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
fun liteClassUniverse(zip: ZipFile): Set<String> { val out = HashSet<String>()
|
|
zip.entries().asSequence().forEach { e ->
|
|
when {
|
|
e.name.endsWith(".class") -> out += e.name.removeSuffix(".class")
|
|
e.name.startsWith("META-INF/jars/") && e.name.endsWith(".jar") -> {
|
|
ZipInputStream(zip.getInputStream(e)).use { inner ->
|
|
generateSequence { inner.nextEntry }.forEach { ie ->
|
|
if (ie.name.endsWith(".class")) out += ie.name.removeSuffix(".class")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Bytecode surgery for litePatchVariant: ENABLED gets a ConstantValue=true attribute (so the gate
|
|
// fold sees a compile-time constant at every literal enabled() call site), compute() shrinks to
|
|
// `return true`, and the static initializer (its only job was compute()) empties out. Fails
|
|
// loudly if the class shape ever drifts - a silent no-patch would ship an unshrunk lite jar.
|
|
fun litePatchVariantClass(bytes: ByteArray): ByteArray {
|
|
val cn = ClassNode()
|
|
ClassReader(bytes).accept(cn, 0)
|
|
var enabledPatched = false
|
|
var computePatched = false
|
|
for (f in cn.fields) {
|
|
if (f.name == "ENABLED" && f.desc == "Z") {
|
|
f.value = 1 // ConstantValue: boolean true
|
|
enabledPatched = true
|
|
}
|
|
}
|
|
for (m in cn.methods) {
|
|
when (m.name) {
|
|
"compute" -> {
|
|
val ins = InsnList()
|
|
ins.add(InsnNode(Opcodes.ICONST_1))
|
|
ins.add(InsnNode(Opcodes.IRETURN))
|
|
m.instructions = ins
|
|
m.tryCatchBlocks?.clear()
|
|
m.localVariables?.clear()
|
|
computePatched = true
|
|
}
|
|
"<clinit>" -> {
|
|
val ins = InsnList()
|
|
ins.add(InsnNode(Opcodes.RETURN))
|
|
m.instructions = ins
|
|
m.tryCatchBlocks?.clear()
|
|
m.localVariables?.clear()
|
|
}
|
|
}
|
|
}
|
|
require(enabledPatched && computePatched) {
|
|
"litePatchVariant: AutismLiteVariant shape drifted (ENABLED/compute not found)"
|
|
}
|
|
val cw = ClassWriter(ClassWriter.COMPUTE_MAXS)
|
|
cn.accept(cw)
|
|
return cw.toByteArray()
|
|
}
|
|
|
|
// Dead-code elimination can leave a STALE LocalVariableTable behind (entries whose slots no
|
|
// longer match the rewritten instruction stream) - and the JVM validates LVTs at class load
|
|
// (ClassFormatError, in-game). Local variable tables are optional debug info, so every class in
|
|
// the shrunk jar gets them stripped deterministically instead of trusting any rewriter. Line
|
|
// numbers stay.
|
|
fun liteStripLocalVars(bytes: ByteArray): ByteArray {
|
|
val cn = ClassNode()
|
|
ClassReader(bytes).accept(cn, 0)
|
|
var touched = false
|
|
for (m in cn.methods) {
|
|
if (!m.localVariables.isNullOrEmpty()) {
|
|
m.localVariables = null
|
|
touched = true
|
|
}
|
|
}
|
|
if (!touched) return bytes
|
|
val cw = ClassWriter(0) // frames copied verbatim; we only deleted an attribute
|
|
cn.accept(cw)
|
|
return cw.toByteArray()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LITE gate fold (our own bytecode transform - the ONLY optimization in the pipeline; there is
|
|
// no ProGuard or any other external shrink/obfuscation tool anywhere). It replaces what an
|
|
// optimizer would do with the exact, minimal transform the lite shrink needs:
|
|
// [invokestatic AutismLiteVariant.enabled()Z] [ifeq L] -> deleted (never jumps, fall through)
|
|
// [invokestatic AutismLiteVariant.enabled()Z] [ifne L] -> [goto L]
|
|
// [invokestatic enabled()Z] [istore n] -> [iconst_1][istore n] (hoisted boolean:
|
|
// the slot now provably holds constant 1; same stack shape, so frames stay valid)
|
|
// [iload n][ifeq|ifne L] on such a proven slot -> folded like the direct shape, but
|
|
// ONLY when no LabelNode (merge point), no MethodInsnNode, and no rewrite of slot n sits
|
|
// between the store and this load - otherwise the load is left alone
|
|
// then a mark-and-sweep dead-code pass removes the instructions the fold made unreachable, so
|
|
// their constant-pool references disappear and liteShrinkJar's reachability walk can drop the
|
|
// dead classes. Any gate shape it does not recognize is simply left alone (feature survives -
|
|
// safe, never broken).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fun liteCommonSuper(a: String, b: String, cl: ClassLoader): String {
|
|
return try {
|
|
val ca = Class.forName(a.replace('/', '.'), false, cl)
|
|
val cb = Class.forName(b.replace('/', '.'), false, cl)
|
|
when {
|
|
ca.isAssignableFrom(cb) -> a
|
|
cb.isAssignableFrom(ca) -> b
|
|
ca.isInterface || cb.isInterface -> "java/lang/Object"
|
|
else -> {
|
|
var c: Class<*> = ca
|
|
while (!c.isAssignableFrom(cb)) c = c.superclass ?: return "java/lang/Object"
|
|
c.name.replace('.', '/')
|
|
}
|
|
}
|
|
} catch (t: Throwable) {
|
|
"java/lang/Object"
|
|
}
|
|
}
|
|
|
|
// Mark-and-sweep DCE over a single method's instruction list. LabelNodes are never removed (so
|
|
// line numbers and exception ranges stay structurally sound); exception ranges whose try region
|
|
// became empty are dropped. Handler blocks are rooted conservatively (kept even when their try
|
|
// region is dead) - at worst a few dead refs survive, never a broken class.
|
|
fun liteDeadCodeEliminate(mn: MethodNode) {
|
|
val insns = mn.instructions
|
|
val reachable = IdentityHashMap<AbstractInsnNode, Boolean>()
|
|
val work = ArrayDeque<AbstractInsnNode>()
|
|
fun mark(i: AbstractInsnNode?) {
|
|
if (i != null && reachable.put(i, true) == null) work.add(i)
|
|
}
|
|
mark(insns.first)
|
|
for (tcb in mn.tryCatchBlocks) mark(tcb.handler)
|
|
while (work.isNotEmpty()) {
|
|
val i = work.removeFirst()
|
|
when (i) {
|
|
is JumpInsnNode -> {
|
|
if (i.opcode != Opcodes.GOTO) mark(i.next)
|
|
mark(i.label)
|
|
}
|
|
is TableSwitchInsnNode -> { mark(i.dflt); i.labels.forEach(::mark) }
|
|
is LookupSwitchInsnNode -> { mark(i.dflt); i.labels.forEach(::mark) }
|
|
else -> when (i.opcode) {
|
|
Opcodes.RETURN, Opcodes.IRETURN, Opcodes.LRETURN,
|
|
Opcodes.FRETURN, Opcodes.DRETURN, Opcodes.ARETURN, Opcodes.ATHROW -> {}
|
|
else -> mark(i.next)
|
|
}
|
|
}
|
|
}
|
|
var p = insns.first
|
|
while (p != null) {
|
|
val next = p.next
|
|
if (p.opcode >= 0 && !reachable.containsKey(p)) insns.remove(p)
|
|
p = next
|
|
}
|
|
mn.tryCatchBlocks.removeIf { tcb ->
|
|
var q: AbstractInsnNode? = tcb.start.next
|
|
var hasCode = false
|
|
while (q != null && q !== tcb.end) {
|
|
if (q.opcode >= 0) { hasCode = true; break }
|
|
q = q.next
|
|
}
|
|
!hasCode
|
|
}
|
|
// Removed instructions leave stale local-variable slots behind; drop the table (the JVM
|
|
// validates it at class load - stale LVTs are a ClassFormatError at runtime).
|
|
mn.localVariables = null
|
|
}
|
|
|
|
// Folds every literal enabled() gate in a class. Returns the rewritten bytes, or null when the
|
|
// class contains no foldable gate (caller copies the original bytes through unchanged).
|
|
fun liteFoldGates(bytes: ByteArray, cl: ClassLoader): ByteArray? {
|
|
val cn = ClassNode()
|
|
ClassReader(bytes).accept(cn, 0)
|
|
var changed = false
|
|
for (mn in cn.methods) {
|
|
var methodChanged = false
|
|
val insns = mn.instructions
|
|
// Hoisted gates: `boolean x = enabled();` - after Pattern A the slot provably holds
|
|
// constant 1, and Pattern B below folds branch-on-slot uses of it.
|
|
val provenStores = ArrayList<VarInsnNode>()
|
|
var p = insns.first
|
|
while (p != null) {
|
|
val next = p.next
|
|
if (p is MethodInsnNode && p.opcode == Opcodes.INVOKESTATIC &&
|
|
p.owner == "autismclient/util/AutismLiteVariant" && p.name == "enabled" && p.desc == "()Z") {
|
|
if (next is JumpInsnNode && (next.opcode == Opcodes.IFEQ || next.opcode == Opcodes.IFNE)) {
|
|
val after = next.next
|
|
if (next.opcode == Opcodes.IFEQ) {
|
|
insns.remove(next) // enabled()==true: never jump, fall through
|
|
} else {
|
|
insns.set(next, JumpInsnNode(Opcodes.GOTO, next.label)) // always jump
|
|
}
|
|
insns.remove(p)
|
|
methodChanged = true
|
|
p = after
|
|
continue
|
|
}
|
|
if (next is VarInsnNode && next.opcode == Opcodes.ISTORE) {
|
|
// Pattern A: [enabled][istore n] -> [iconst_1][istore n]. iconst_1 has the
|
|
// same stack shape as the ()Z call, so recomputed frames are unaffected.
|
|
insns.set(p, InsnNode(Opcodes.ICONST_1))
|
|
provenStores.add(next)
|
|
methodChanged = true
|
|
}
|
|
}
|
|
p = next
|
|
}
|
|
// Pattern B: fold [iload n][ifeq|ifne L] on a slot PROVEN constant 1 at this load. The
|
|
// proof holds only for a linear stretch from the store: any LabelNode (a branch target /
|
|
// merge point where n could differ), any MethodInsnNode, or any rewrite of slot n between
|
|
// store and load disqualifies the load. When in doubt, leave it - a surviving feature is
|
|
// safe, a wrong fold is a silent behavior change.
|
|
for (store in provenStores) {
|
|
val slot = store.`var`
|
|
var q = store.next
|
|
while (q != null) {
|
|
if (q is LabelNode || q is MethodInsnNode) break
|
|
if (q is VarInsnNode && q.`var` == slot && q.opcode == Opcodes.ISTORE) break
|
|
if (q is IincInsnNode && q.`var` == slot) break
|
|
val qNext = q.next
|
|
if (q is VarInsnNode && q.opcode == Opcodes.ILOAD && q.`var` == slot &&
|
|
qNext is JumpInsnNode && (qNext.opcode == Opcodes.IFEQ || qNext.opcode == Opcodes.IFNE)) {
|
|
val after = qNext.next
|
|
if (qNext.opcode == Opcodes.IFEQ) {
|
|
insns.remove(qNext) // slot==1: never jump, fall through
|
|
} else {
|
|
insns.set(qNext, JumpInsnNode(Opcodes.GOTO, qNext.label)) // always jump
|
|
}
|
|
insns.remove(q)
|
|
methodChanged = true
|
|
q = after
|
|
continue
|
|
}
|
|
q = qNext
|
|
}
|
|
}
|
|
if (methodChanged) {
|
|
liteDeadCodeEliminate(mn)
|
|
changed = true
|
|
}
|
|
}
|
|
if (changed) {
|
|
// Dead-lambda sweep: when a lambda's creation site (its invokedynamic) was folded away,
|
|
// the synthetic lambda$ method stays in the class and its BODY still references the
|
|
// stripped cluster (whole-class shrinking cannot remove unused methods). Remove any
|
|
// synthetic lambda$... method that no remaining invokedynamic in the class references.
|
|
val liveHandles = HashSet<String>()
|
|
for (mn in cn.methods) {
|
|
for (insn in mn.instructions) {
|
|
if (insn is org.objectweb.asm.tree.InvokeDynamicInsnNode) {
|
|
for (arg in insn.bsmArgs) {
|
|
if (arg is org.objectweb.asm.Handle) liveHandles += arg.owner + "." + arg.name + arg.desc
|
|
}
|
|
}
|
|
}
|
|
}
|
|
cn.methods.removeIf { mn ->
|
|
mn.name.startsWith("lambda$") && (mn.access and Opcodes.ACC_SYNTHETIC) != 0 &&
|
|
(cn.name + "." + mn.name + mn.desc) !in liveHandles
|
|
}
|
|
}
|
|
if (!changed) return null
|
|
val cw = object : ClassWriter(ClassWriter.COMPUTE_FRAMES) {
|
|
override fun getCommonSuperClass(t1: String, t2: String): String = liteCommonSuper(t1, t2, cl)
|
|
}
|
|
cn.accept(cw)
|
|
val out = cw.toByteArray()
|
|
// Verify before shipping: a broken fold must fail the build, never the game.
|
|
val report = StringWriter()
|
|
CheckClassAdapter.verify(ClassReader(out), cl, false, PrintWriter(report))
|
|
val text = report.toString()
|
|
require(text.isBlank()) { "liteFoldGates produced invalid bytecode for ${cn.name}:\n$text" }
|
|
return out
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cross-process execution lock for the LITE pipeline. Multiple agent sessions can build this
|
|
// project at the same time; without an interlock the lite stages tear each other's intermediates
|
|
// (a patched.jar read mid-write = ZipException, the full jar rebuilt under verifyLiteJar,
|
|
// Windows file-lock failures on the libs jar). FileChannel.tryLock is an OS-level lock, so it
|
|
// guards across separate Gradle daemons / CLI processes - a JVM ReentrantLock would not.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Acquires the exclusive lite build lock (build/liteWork/.lock) or fails fast.
|
|
fun liteBuildLockAcquire(): FileLock {
|
|
val lockFile = layout.buildDirectory.file("liteWork/.lock").get().asFile
|
|
lockFile.parentFile.mkdirs()
|
|
val channel = FileChannel.open(lockFile.toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE)
|
|
val lock = try {
|
|
channel.tryLock()
|
|
} catch (e: OverlappingFileLockException) {
|
|
null // already held by THIS JVM (e.g. a stale lock from a failed task in this daemon)
|
|
}
|
|
if (lock == null) {
|
|
channel.close()
|
|
throw GradleException(
|
|
"Another LITE build is running (lock file: ${lockFile.absolutePath}). " +
|
|
"Wait for it to finish before running litePatchVariant/liteShrinkJar/liteJar/verifyLiteJar.")
|
|
}
|
|
return lock
|
|
}
|
|
|
|
fun liteBuildLockRelease(lock: FileLock) {
|
|
try {
|
|
lock.release()
|
|
} finally {
|
|
lock.channel().close()
|
|
}
|
|
}
|
|
|
|
// Runs [block] under the exclusive lite build lock; the lock is always released (try/finally).
|
|
fun <T> withLiteBuildLock(block: () -> T): T {
|
|
val lock = liteBuildLockAcquire()
|
|
try {
|
|
return block()
|
|
} finally {
|
|
liteBuildLockRelease(lock)
|
|
}
|
|
}
|
|
|
|
tasks.register("litePatchVariant") {
|
|
group = "build"
|
|
description = "Copy the full jar and patch AutismLiteVariant.ENABLED to a compile-time constant true."
|
|
// The full build (guardian secret bake, mixin injection) must be finished first.
|
|
dependsOn("build")
|
|
|
|
val fullJar = layout.buildDirectory.file("libs/${base.archivesName.get()}-${project.version}.jar")
|
|
val out = layout.buildDirectory.file("liteWork/patched.jar")
|
|
inputs.file(fullJar)
|
|
outputs.file(out)
|
|
|
|
doLast {
|
|
withLiteBuildLock {
|
|
val patched = out.get().asFile
|
|
patched.parentFile.mkdirs()
|
|
// Atomic publish: a concurrent build must never read a torn patched.jar - write to a
|
|
// temp file, then move it into place (same temp+move pattern as the LVT strip below).
|
|
val patchedTmp = File(patched.parentFile, patched.name + ".tmp")
|
|
val target = "autismclient/util/AutismLiteVariant.class"
|
|
val gateMarker = "autismclient/util/AutismLiteVariant".toByteArray(Charsets.US_ASCII)
|
|
// Loader for frame computation + bytecode verification: the full jar's universe (the
|
|
// fold never changes hierarchies) plus the library classpaths.
|
|
val urls = ArrayList<URL>()
|
|
urls.add(fullJar.get().asFile.toURI().toURL())
|
|
project.configurations.getByName("compileClasspath").files.forEach { if (it.isFile) urls.add(it.toURI().toURL()) }
|
|
project.configurations.getByName("runtimeClasspath").files.forEach { if (it.isFile) urls.add(it.toURI().toURL()) }
|
|
val cl = URLClassLoader(urls.toTypedArray(), ClassLoader.getSystemClassLoader())
|
|
var patchedCount = 0
|
|
var foldedClasses = 0
|
|
ZipFile(fullJar.get().asFile).use { zip ->
|
|
ZipOutputStream(patchedTmp.outputStream().buffered()).use { zout ->
|
|
zip.entries().asSequence().forEach { e ->
|
|
zout.putNextEntry(ZipEntry(e.name))
|
|
if (!e.isDirectory) {
|
|
var bytes = zip.getInputStream(e).readBytes()
|
|
if (e.name == target) {
|
|
bytes = litePatchVariantClass(bytes)
|
|
patchedCount++
|
|
} else if (e.name.endsWith(".class") && liteContainsSubarray(bytes, gateMarker)) {
|
|
val folded = liteFoldGates(bytes, cl)
|
|
if (folded != null) {
|
|
bytes = folded
|
|
foldedClasses++
|
|
}
|
|
}
|
|
zout.write(bytes)
|
|
}
|
|
zout.closeEntry()
|
|
}
|
|
}
|
|
}
|
|
if (patchedCount != 1) throw GradleException("litePatchVariant: $target not found in the full jar")
|
|
Files.move(patchedTmp.toPath(), patched.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
|
logger.lifecycle("litePatchVariant: ENABLED patched to constant true, " +
|
|
"gates folded in $foldedClasses classes -> ${patched.name}")
|
|
}
|
|
}
|
|
}
|
|
|
|
tasks.register("liteShrinkJar") {
|
|
group = "build"
|
|
description = "Shrink the patched jar with our own reachability analysis (no ProGuard, no obfuscation tooling at all)."
|
|
dependsOn("litePatchVariant")
|
|
|
|
val patched = layout.buildDirectory.file("liteWork/patched.jar")
|
|
val out = layout.buildDirectory.file("liteWork/shrunk.jar")
|
|
inputs.file(patched)
|
|
inputs.property("liteStripMixins", liteStripMixins)
|
|
inputs.property("liteKeepPinned", liteKeepPinned)
|
|
outputs.file(out)
|
|
|
|
doLast {
|
|
withLiteBuildLock {
|
|
// Reachability shrink, no external tool: seeds -> transitive constant-pool reference
|
|
// closure (liteReferencedClasses, the same over-approximating scan the verifier's
|
|
// dangling-reference guard uses - it can only keep MORE than strictly needed, never
|
|
// less). Surviving classes are copied WHOLE: mixin contracts, gson models, enums and
|
|
// any reflection inside survivors are intact by construction, and there is nothing
|
|
// that can rename, specialize or corrupt bytecode the way an optimizer can.
|
|
val entryBytes = LinkedHashMap<String, ByteArray>()
|
|
val classBytes = HashMap<String, ByteArray>()
|
|
ZipFile(patched.get().asFile).use { zip ->
|
|
zip.entries().asSequence().forEach { e ->
|
|
if (e.isDirectory) return@forEach
|
|
val bytes = zip.getInputStream(e).readBytes()
|
|
entryBytes[e.name] = bytes
|
|
if (e.name.endsWith(".class")) classBytes[e.name.removeSuffix(".class")] = bytes
|
|
}
|
|
}
|
|
|
|
// Seeds: the fabric entrypoint, the addon API (external addons resolve it by name),
|
|
// the pinned lite-live classes, and every mixin the transformed lite config applies
|
|
// (their nested classes are covered by the $-prefix pass below).
|
|
val seeds = HashSet<String>()
|
|
seeds += "autismclient/AutismClientMod"
|
|
seeds += "autismclient/util/AutismLiteVariant"
|
|
for (name in classBytes.keys) {
|
|
if (name.startsWith("autismclient/api/")) seeds += name
|
|
}
|
|
liteKeepPinned.forEach { seeds += it.removeSuffix(".class") }
|
|
|
|
val jsonText = entryBytes.getValue("autism.mixins.json").toString(Charsets.UTF_8)
|
|
@Suppress("UNCHECKED_CAST")
|
|
val json = groovy.json.JsonSlurper().parseText(jsonText) as Map<String, Any?>
|
|
val mixinPackage = (json["package"] as? String) ?: "autismclient.mixin"
|
|
@Suppress("UNCHECKED_CAST")
|
|
val client = (json["client"] as? List<Any?>) ?: emptyList<Any?>()
|
|
val keptMixins = client.filterIsInstance<String>()
|
|
.filter { name -> liteStripMixins.none { name.endsWith(it) } && !name.startsWith("guardian.") }
|
|
.map { "${mixinPackage.replace('.', '/')}/${it.replace('.', '/')}" }
|
|
.toMutableList()
|
|
(json["plugin"] as? String)?.let { plugin ->
|
|
val fqn = (if (plugin.contains('.')) plugin else "$mixinPackage.$plugin").replace('.', '/')
|
|
// The mixin config plugin is framework-reflected: the mixin processor calls
|
|
// onLoad/shouldApplyMixin/getMixins reflectively at runtime, so the reachability
|
|
// walk can never see those calls - the class must ship WHOLE (all members and
|
|
// nested classes), or startup dies with AbstractMethodError on any transform.
|
|
keptMixins += fqn
|
|
seeds += fqn
|
|
}
|
|
seeds += keptMixins
|
|
val mixinSeedPrefixes = keptMixins.map { "$it$" }
|
|
for (name in classBytes.keys) {
|
|
if (mixinSeedPrefixes.any { name.startsWith(it) }) seeds += name
|
|
}
|
|
|
|
// The closure: seeded classes plus everything they reference, transitively.
|
|
val reachable = LinkedHashSet(seeds)
|
|
val queue = ArrayDeque(seeds)
|
|
while (queue.isNotEmpty()) {
|
|
val c = queue.removeFirst()
|
|
val bytes = classBytes[c] ?: continue
|
|
for (r in liteReferencedClasses(bytes)) {
|
|
if (r in classBytes && reachable.add(r)) queue.addLast(r)
|
|
}
|
|
}
|
|
|
|
// Member-level pass: drop unused methods/fields from kept classes (see litePruneMembers)
|
|
// so dead feature members stop anchoring their classes (DupeRadar, Spotify, geo lookup).
|
|
val pruned = litePruneMembers(classBytes, reachable, keptMixins.toSet(), seeds)
|
|
|
|
// Write shrunk.jar atomically: every non-class entry + every surviving class
|
|
// (LVT-stripped - stale after the fold, and optional debug info anyway).
|
|
val tmp = File(out.get().asFile.parentFile, out.get().asFile.name + ".tmp")
|
|
var keptClasses = 0
|
|
var strippedLvt = 0
|
|
ZipOutputStream(tmp.outputStream().buffered()).use { zout ->
|
|
for ((name, bytes) in entryBytes) {
|
|
if (name.endsWith(".class")) {
|
|
val prunedClass = pruned[name.removeSuffix(".class")] ?: continue
|
|
keptClasses++
|
|
val stripped = liteStripLocalVars(prunedClass)
|
|
if (stripped !== prunedClass) strippedLvt++
|
|
zout.putNextEntry(ZipEntry(name))
|
|
zout.write(stripped)
|
|
} else {
|
|
zout.putNextEntry(ZipEntry(name))
|
|
zout.write(bytes)
|
|
}
|
|
zout.closeEntry()
|
|
}
|
|
}
|
|
Files.move(tmp.toPath(), out.get().asFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
|
val mb = out.get().asFile.length() / 1024.0 / 1024.0
|
|
logger.lifecycle("liteShrinkJar: kept $keptClasses/${classBytes.size} classes " +
|
|
"(${keptMixins.size} mixin seeds, dropped ${classBytes.size - keptClasses}), " +
|
|
"stripped LocalVariableTable from $strippedLvt classes - shrunk jar = ${"%.1f".format(mb)} MB")
|
|
}
|
|
}
|
|
}
|
|
|
|
tasks.register<Jar>("liteJar") {
|
|
group = "build"
|
|
description = "Repackage the shrunk jar as the final LITE variant (assets/mixins/libs out, marker in)."
|
|
dependsOn("liteShrinkJar")
|
|
|
|
val libsDir = layout.buildDirectory.dir("libs")
|
|
val shrunkJar = layout.buildDirectory.file("liteWork/shrunk.jar")
|
|
inputs.file(shrunkJar)
|
|
inputs.property("liteStripMixins", liteStripMixins)
|
|
inputs.property("liteStripAssets", liteStripAssets)
|
|
|
|
destinationDirectory.set(libsDir)
|
|
archiveBaseName.set("Autism Lite")
|
|
archiveVersion.set(project.version.toString())
|
|
// No zero-byte entries for directories the strip emptied (jar hygiene).
|
|
includeEmptyDirs = false
|
|
|
|
// Nested libraries with zero references from surviving classes, computed in doFirst (the
|
|
// shrunk jar only exists at execution time). Drives both the jar exclusions and the
|
|
// fabric.mod.json jars-array edit below.
|
|
val deadLibs = mutableListOf<String>()
|
|
|
|
// The lite build lock must span the WHOLE task execution (doFirst -> copy -> doLast), and
|
|
// a Jar task's copy action cannot be wrapped in one try/finally - so it is acquired at the
|
|
// top of doFirst and released in the doLast finally below.
|
|
val liteExecLock = arrayOfNulls<FileLock>(1)
|
|
|
|
// Copy the shrunk jar's entries (deferred), minus the stripped-feature assets, the dead mixin
|
|
// classes (belt-and-braces: the shrink should have removed them already) and the dead libs.
|
|
from({ zipTree(shrunkJar.get().asFile) }) {
|
|
exclude("autism.mixins.json") // whole-file-transformed copy is generated in doFirst instead
|
|
liteStripAssets.forEach { prefix ->
|
|
if (prefix.endsWith("/")) exclude(prefix + "**") else exclude(prefix)
|
|
}
|
|
// The stripped mixin classes themselves, nested classes included (json-stripping alone
|
|
// saves zero bytes; a future FooMixin$1.class must not slip through either).
|
|
liteStripMixins.forEach { name ->
|
|
if (name.startsWith("lithium.")) {
|
|
exclude("autismclient/mixin/lithium/${name.removePrefix("lithium.")}.class")
|
|
exclude("autismclient/mixin/lithium/${name.removePrefix("lithium.")}\$*.class")
|
|
} else {
|
|
exclude("autismclient/mixin/$name.class")
|
|
exclude("autismclient/mixin/$name\$*.class")
|
|
}
|
|
}
|
|
}
|
|
|
|
// The lite marker: one empty resource, written out at execution time.
|
|
val markerDir = layout.buildDirectory.dir("liteJarMarker")
|
|
from(markerDir)
|
|
|
|
// fabric.mod.json: the modmenu entrypoint goes (its config screen links theme/module menu), and
|
|
// every dead nested lib loses its jars-array declaration so metadata matches contents. Name,
|
|
// icon and the rest of the metadata stay byte-identical to the full jar. Single-line JSON =
|
|
// text replaces; deadLibs is populated in doFirst before this filter runs during the copy.
|
|
filesMatching("fabric.mod.json") {
|
|
filter { text ->
|
|
var out = text.replace(",\"modmenu\":[\"autismclient.compat.AutismModMenuIntegration\"]", "")
|
|
for (lib in deadLibs) {
|
|
out = out.replace("{\"file\":\"$lib\"},", "").replace(",{\"file\":\"$lib\"}", "")
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
// autism.mixins.json: Gradle's text filter is a LINEFilter (one line per call, re-joined with
|
|
// the platform separator) - useless for a whole-file structural edit. So the original entry is
|
|
// excluded in the main from-spec above and a whole-file-transformed copy is generated in
|
|
// doFirst and re-added via markerDir below.
|
|
doFirst {
|
|
liteExecLock[0] = liteBuildLockAcquire()
|
|
// Data-driven nested-lib strip: a bundled library referenced by NO surviving class is dead
|
|
// weight (the shrink removed its last referrers). The fixpoint starts from outer-class
|
|
// references and follows lib-to-lib edges (e.g. handler-proxy needs codec-socks at runtime
|
|
// even though our bytecode never names codec-socks) - a lib is dead only when NOTHING
|
|
// reachable references it. Excluding here also feeds the fabric.mod.json edit above.
|
|
ZipFile(shrunkJar.get().asFile).use { zip ->
|
|
val outerRefs = HashSet<String>()
|
|
zip.entries().asSequence().filter { it.name.endsWith(".class") }.forEach { e ->
|
|
outerRefs += liteReferencedClasses(zip.getInputStream(e).readBytes())
|
|
}
|
|
// entryName -> (its classes, classes its classes reference)
|
|
val nested = zip.entries().asSequence()
|
|
.filter { it.name.startsWith("META-INF/jars/") && it.name.endsWith(".jar") }
|
|
.map { libEntry ->
|
|
val classBytes = ZipInputStream(zip.getInputStream(libEntry)).use { inner ->
|
|
generateSequence { inner.nextEntry }
|
|
.filter { it.name.endsWith(".class") }
|
|
.map { it.name.removeSuffix(".class") to inner.readBytes() }.toList()
|
|
}
|
|
val refs = HashSet<String>()
|
|
classBytes.forEach { refs += liteReferencedClasses(it.second) }
|
|
Triple(libEntry.name, classBytes.map { it.first }, refs as Set<String>)
|
|
}.toList()
|
|
val aliveLibs = HashSet<String>()
|
|
var frontier: Set<String> = outerRefs
|
|
while (true) {
|
|
val newAlive = nested.filter { it.first !in aliveLibs && it.second.any(frontier::contains) }
|
|
if (newAlive.isEmpty()) break
|
|
newAlive.forEach { aliveLibs += it.first }
|
|
frontier = newAlive.fold(HashSet<String>()) { acc, lib -> acc.apply { addAll(lib.third) } }
|
|
}
|
|
nested.filter { it.first !in aliveLibs }.forEach { lib ->
|
|
deadLibs += lib.first
|
|
exclude(lib.first)
|
|
logger.lifecycle("liteJar: nested lib ${lib.first.substringAfterLast('/')} " +
|
|
"unreferenced after shrink - stripped (${lib.second.size} classes)")
|
|
}
|
|
}
|
|
|
|
val dir = markerDir.get().asFile
|
|
dir.mkdirs()
|
|
dir.resolve("autism-lite.marker").writeText("")
|
|
|
|
// Whole-file transform of the built mixins config: drop the lite-strip entries AND the
|
|
// dead guardian entries from the client list (their impl already lives in excluded
|
|
// util/mm), then re-emit - strict-JSON valid by construction.
|
|
val mixinsOut = dir.resolve("autism.mixins.json")
|
|
ZipFile(shrunkJar.get().asFile).use { zip ->
|
|
val text = zip.getInputStream(zip.getEntry("autism.mixins.json")).readBytes().toString(Charsets.UTF_8)
|
|
@Suppress("UNCHECKED_CAST")
|
|
val json = groovy.json.JsonSlurper().parseText(text) as MutableMap<String, Any?>
|
|
@Suppress("UNCHECKED_CAST")
|
|
val client = (json["client"] as? MutableList<Any?>) ?: mutableListOf()
|
|
client.removeAll { name ->
|
|
name is String && (liteStripMixins.any { name.endsWith(it) } || name.startsWith("guardian."))
|
|
}
|
|
json["client"] = client
|
|
mixinsOut.writeText(groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(json)))
|
|
}
|
|
}
|
|
|
|
// The pinned K_build sidecar travels with the lite jar under its own name (VPS tooling expects
|
|
// jar + sidecar together; the same secret backs both jars).
|
|
doLast {
|
|
try {
|
|
val sidecar = libsDir.get().file("${base.archivesName.get()}-${project.version}.jar.kbuild").asFile
|
|
val target = libsDir.get().file("Autism Lite-${project.version}.jar.kbuild").asFile
|
|
if (sidecar.isFile) sidecar.copyTo(target, overwrite = true)
|
|
else logger.warn("liteJar: no .kbuild sidecar found at $sidecar (unprotected build?) - skipped.")
|
|
} finally {
|
|
liteExecLock[0]?.let { liteBuildLockRelease(it) }
|
|
liteExecLock[0] = null
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LITE contents verifier: runs automatically after liteJar and FAILS the build if the lite jar
|
|
// ever regresses. This is what makes "edit the main mod freely" safe: if a future change drops
|
|
// the marker, leaks a stripped asset/mixin back in, drops a pinned class, or breaks the class
|
|
// reference closure (the NoClassDefFoundError vector that bit us once), the build says so
|
|
// instead of shipping a quietly broken lite jar.
|
|
// ---------------------------------------------------------------------------
|
|
tasks.register("verifyLiteJar") {
|
|
group = "verification"
|
|
description = "Fail the build if the LITE jar's contents regress (marker, asset/mixin strip, pinned classes, reference closure, metadata)."
|
|
dependsOn("liteJar")
|
|
|
|
val liteJarFile = layout.buildDirectory.file("libs/Autism Lite-${project.version}.jar")
|
|
val fullJarFile = layout.buildDirectory.file("libs/${base.archivesName.get()}-${project.version}.jar")
|
|
inputs.file(liteJarFile)
|
|
inputs.file(fullJarFile)
|
|
inputs.property("liteStripMixins", liteStripMixins)
|
|
inputs.property("liteStripAssets", liteStripAssets)
|
|
inputs.property("liteKeepPinned", liteKeepPinned)
|
|
inputs.property("liteKeepLibs", liteKeepLibs)
|
|
inputs.property("liteKnownDead", liteKnownDead)
|
|
inputs.property("liteMacroParityExempt", liteMacroParityExempt)
|
|
|
|
doLast {
|
|
withLiteBuildLock {
|
|
val jar = liteJarFile.get().asFile
|
|
fun fail(msg: String): Nothing = throw GradleException("verifyLiteJar: $msg")
|
|
if (!jar.isFile) fail("$jar does not exist")
|
|
|
|
val entries = ZipFile(jar).use { zip ->
|
|
zip.entries().asSequence().map { it.name }.toList()
|
|
}
|
|
|
|
if (!entries.contains("autism-lite.marker")) {
|
|
fail("autism-lite.marker missing - every runtime gate would stay off in the lite jar")
|
|
}
|
|
|
|
// Strip assertions: nothing from the asset/mixin strip lists may be present.
|
|
val leakedAssets = liteStripAssets.filter { prefix -> entries.any { it.startsWith(prefix) } }
|
|
if (leakedAssets.isNotEmpty()) fail("stripped assets leaked into the lite jar: $leakedAssets")
|
|
val leakedMixinClasses = liteStripMixins.filter { name ->
|
|
val base = if (name.startsWith("lithium.")) "autismclient/mixin/lithium/${name.removePrefix("lithium.")}"
|
|
else "autismclient/mixin/$name"
|
|
entries.any { it == "$base.class" || (it.startsWith("$base\$") && it.endsWith(".class")) }
|
|
}
|
|
if (leakedMixinClasses.isNotEmpty()) fail("stripped mixin classes leaked into the lite jar: $leakedMixinClasses")
|
|
|
|
// Pinned must-stay assertions: an over-aggressive copy fails here, not at runtime.
|
|
val missingPinned = liteKeepPinned.filter { !entries.contains(it) }
|
|
if (missingPinned.isNotEmpty()) fail("pinned classes missing from the lite jar: $missingPinned")
|
|
val missingLibs = liteKeepLibs.filter { !entries.contains(it) }
|
|
if (missingLibs.isNotEmpty()) fail("required bundled libraries missing from the lite jar: $missingLibs")
|
|
|
|
// Macro parity: the macro system (editor included) ships in lite by design. A new macro
|
|
// action/condition registered the conventional way (AutismMacro.createActionFromTag case
|
|
// + ActionFieldRegistry schema) is statically referenced from lite-live code and lands in
|
|
// lite automatically - so any macro-package class present in the full jar but missing
|
|
// from lite means someone registered ONLY in dead-in-lite code or reflectively, and the
|
|
// action would silently not exist in lite. Fail loudly instead.
|
|
val macroPackages = listOf("autismclient/util/macro/", "autismclient/gui/macro/")
|
|
val fullEntries = ZipFile(fullJarFile.get().asFile).use { zip ->
|
|
zip.entries().asSequence().map { it.name }.toList()
|
|
}
|
|
val macroMissing = fullEntries
|
|
.filter { name -> macroPackages.any { name.startsWith(it) } && name.endsWith(".class") }
|
|
.filter { it !in liteMacroParityExempt && !entries.contains(it) }
|
|
if (macroMissing.isNotEmpty()) {
|
|
fail("macro classes present in full but missing from lite (a new macro action/condition " +
|
|
"would silently not ship in lite - register it via AutismMacro.createActionFromTag + " +
|
|
"ActionFieldRegistry, or audit it into liteMacroParityExempt): ${macroMissing.take(10)}")
|
|
}
|
|
// The exemption audit is two-directional: an exempt class that IS present in lite means
|
|
// the audit is stale (the class became lite-live again, or never actually left) and the
|
|
// exemption entry is now hiding a leak instead of documenting one. Fail loudly either way.
|
|
val exemptPresent = liteMacroParityExempt.filter { entries.contains(it) }
|
|
if (exemptPresent.isNotEmpty()) {
|
|
fail("liteMacroParityExempt classes present in the lite jar (stale exemption hiding a " +
|
|
"leak - re-audit the exemption list): $exemptPresent")
|
|
}
|
|
|
|
// Config-model parity: every AutismConfig nested model class in the full jar must exist in
|
|
// lite. A missing one means gson binds that part of the config as generic maps (silently
|
|
// degrading persistence) - this tripwire fires before that ships.
|
|
val configModelMissing = fullEntries
|
|
.filter { it.startsWith("autismclient/util/AutismConfig\$") && it.endsWith(".class") }
|
|
.filter { !entries.contains(it) }
|
|
if (configModelMissing.isNotEmpty()) {
|
|
fail("AutismConfig model classes present in full but missing from lite (gson config " +
|
|
"persistence would silently degrade): ${configModelMissing.take(10)}")
|
|
}
|
|
|
|
// Known-dead assertions: the fold + shrink MUST have removed these. Their presence means a
|
|
// lite gate lost its literal call-site form (or a new ungated referrer appeared) and a
|
|
// stripped feature silently survived the shrink.
|
|
val undead = liteKnownDead.filter { entries.contains(it) }
|
|
if (undead.isNotEmpty()) fail("classes that must have shrunk away are present in the lite jar (a lite gate is not folding): $undead")
|
|
|
|
// LocalVariableTable guard: liteShrinkJar strips these because dead-code elimination
|
|
// can leave their slot references stale (the JVM validates them at class load).
|
|
// Attribute-level check (an orphan constant-pool Utf8 without the attribute is harmless).
|
|
val lvtLeaks = mutableListOf<String>()
|
|
ZipFile(jar).use { zip ->
|
|
zip.entries().asSequence().filter { it.name.endsWith(".class") }.forEach { e ->
|
|
var hasLvt = false
|
|
ClassReader(zip.getInputStream(e).readBytes()).accept(object : org.objectweb.asm.ClassVisitor(Opcodes.ASM9) {
|
|
override fun visitMethod(access: Int, name: String?, desc: String?, signature: String?,
|
|
exceptions: Array<out String>?): org.objectweb.asm.MethodVisitor {
|
|
return object : org.objectweb.asm.MethodVisitor(Opcodes.ASM9) {
|
|
override fun visitLocalVariable(n: String?, d: String?, s: String?,
|
|
start: org.objectweb.asm.Label?, end: org.objectweb.asm.Label?, index: Int) {
|
|
hasLvt = true
|
|
}
|
|
}
|
|
}
|
|
}, 0)
|
|
if (hasLvt) lvtLeaks += e.name
|
|
}
|
|
}
|
|
if (lvtLeaks.isNotEmpty()) {
|
|
fail("${lvtLeaks.size} classes still carry LocalVariableTable attributes (invalid after optimization): ${lvtLeaks.take(3)}")
|
|
}
|
|
|
|
// Dangling-reference guard. The JVM verifier resolves the FROM type of every invoke,
|
|
// assignment and cast in a class being loaded - even on bootstrap-gated dead branches
|
|
// (register(new ParkourModule()) loads ParkourModule to prove it <: Module) - so any
|
|
// class that is REFERENCED but MISSING from the jar is a runtime NoClassDefFoundError.
|
|
// Prove the closure holds: every class referenced from any shipped class must ship too,
|
|
// or be external (JDK / Minecraft / Fabric), where "shipped in the full jar" decides
|
|
// what is not external. Scanning every shipped class once covers transitivity for free:
|
|
// if A->B and B->C, the B->C edge is checked when B itself is scanned. (Shared scanners:
|
|
// liteReferencedClasses / liteClassUniverse above.)
|
|
val fullUniverse = ZipFile(fullJarFile.get().asFile).use { liteClassUniverse(it) }
|
|
val dangling = sortedMapOf<String, List<String>>()
|
|
ZipFile(jar).use { zip ->
|
|
val liteUniverse = liteClassUniverse(zip)
|
|
zip.entries().asSequence().filter { it.name.endsWith(".class") }.forEach { e ->
|
|
val missing = liteReferencedClasses(zip.getInputStream(e).readBytes())
|
|
.filter { it in fullUniverse && it !in liteUniverse }
|
|
if (missing.isNotEmpty()) dangling[e.name.removeSuffix(".class")] = missing
|
|
}
|
|
}
|
|
if (dangling.isNotEmpty()) {
|
|
val detail = dangling.entries.take(10).joinToString("\n ") { (c, m) -> "$c -> $m" }
|
|
fail("referenced-but-missing classes in the lite jar (the JVM verifier would throw " +
|
|
"NoClassDefFoundError in-game): ${dangling.size} referrer(s), first:\n $detail")
|
|
}
|
|
|
|
// Method-level dangling guard: the member pass (litePruneMembers) drops unused methods
|
|
// and fields, so prove every kept call site RESOLVES - a method/field the member walk
|
|
// dropped while its caller stayed is a runtime NoSuchMethodError/NoSuchFieldError.
|
|
// Resolution is judged against the FULL jar: a member resolvable through the full
|
|
// jar's hierarchy but missing from lite was pruned -> dangling; a member that does not
|
|
// resolve even in the full jar belongs to Minecraft/JDK and is external. (The previous
|
|
// version treated a hierarchy escaping the jar as satisfied - every hierarchy ends at
|
|
// java/lang/Object, so the guard could never fire. A pruned static helper that a kept
|
|
// codec called proved that in-game: NoSuchMethodError at startup.)
|
|
val liteNodes = HashMap<String, ClassNode>()
|
|
ZipFile(jar).use { zip ->
|
|
zip.entries().asSequence().filter { it.name.endsWith(".class") }.forEach { e ->
|
|
val cn = ClassNode()
|
|
ClassReader(zip.getInputStream(e).readBytes()).accept(cn, 0)
|
|
liteNodes[e.name.removeSuffix(".class")] = cn
|
|
}
|
|
}
|
|
val fullNodes = HashMap<String, ClassNode>()
|
|
ZipFile(fullJarFile.get().asFile).use { zip ->
|
|
zip.entries().asSequence().filter { it.name.endsWith(".class") }.forEach { e ->
|
|
val cn = ClassNode()
|
|
ClassReader(zip.getInputStream(e).readBytes())
|
|
.accept(cn, ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES)
|
|
fullNodes[e.name.removeSuffix(".class")] = cn
|
|
}
|
|
}
|
|
// Walk owner + supertypes; escaping the scanned universe means "not found here".
|
|
fun resolvesIn(nodes: Map<String, ClassNode>, owner: String, name: String, desc: String, field: Boolean): Boolean {
|
|
val seen = HashSet<String>()
|
|
val q = ArrayDeque<String>()
|
|
q.add(owner)
|
|
while (q.isNotEmpty()) {
|
|
val c = q.removeFirst()
|
|
if (!seen.add(c)) continue
|
|
val node = nodes[c] ?: return false
|
|
val hit = if (field) node.fields.any { it.name == name && it.desc == desc }
|
|
else node.methods.any { it.name == name && it.desc == desc }
|
|
if (hit) return true
|
|
node.superName?.let(q::addLast)
|
|
q.addAll(node.interfaces.filterIsInstance<String>())
|
|
}
|
|
return false
|
|
}
|
|
val memberDangling = ArrayList<String>()
|
|
for ((owner, cn) in liteNodes) {
|
|
for (mn in cn.methods) {
|
|
for (insn in mn.instructions) {
|
|
when (insn) {
|
|
is org.objectweb.asm.tree.MethodInsnNode -> {
|
|
if (insn.owner in liteNodes &&
|
|
!resolvesIn(liteNodes, insn.owner, insn.name, insn.desc, false) &&
|
|
resolvesIn(fullNodes, insn.owner, insn.name, insn.desc, false)) {
|
|
memberDangling += "$owner.${mn.name} -> ${insn.owner}.${insn.name}${insn.desc}"
|
|
}
|
|
}
|
|
is org.objectweb.asm.tree.FieldInsnNode -> {
|
|
if (insn.owner in liteNodes &&
|
|
!resolvesIn(liteNodes, insn.owner, insn.name, insn.desc, true) &&
|
|
resolvesIn(fullNodes, insn.owner, insn.name, insn.desc, true)) {
|
|
memberDangling += "$owner.${mn.name} -> ${insn.owner}.${insn.name}"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (memberDangling.isNotEmpty()) {
|
|
fail("dangling member references in the lite jar (a caller kept a method/field the " +
|
|
"member pass dropped - runtime NoSuchMethodError/NoSuchFieldError): ${memberDangling.take(8)}")
|
|
}
|
|
|
|
fun entryText(path: String): String {
|
|
ZipFile(jar).use { zip ->
|
|
val entry = zip.getEntry(path) ?: fail("$path missing from the lite jar")
|
|
return zip.getInputStream(entry).readBytes().toString(Charsets.UTF_8)
|
|
}
|
|
}
|
|
|
|
val fabricJson = entryText("fabric.mod.json")
|
|
if (!fabricJson.contains("\"name\":\"GEC Client\"")) {
|
|
fail("fabric.mod.json name drifted - the game title/icon must not change in lite")
|
|
}
|
|
if (fabricJson.contains("AutismModMenuIntegration")) {
|
|
fail("fabric.mod.json still has the modmenu entrypoint (its config screen links stripped UI)")
|
|
}
|
|
// Metadata must match contents both ways: every bundled lib in the jar is declared in
|
|
// fabric.mod.json, and every declared lib is in the jar (the data-driven strip in liteJar
|
|
// removes both sides together).
|
|
val nestedLibs = entries.filter { it.startsWith("META-INF/jars/") && it.endsWith(".jar") }
|
|
val undeclared = nestedLibs.filter { !fabricJson.contains("\"file\":\"$it\"") }
|
|
if (undeclared.isNotEmpty()) fail("lite fabric.mod.json does not declare bundled libraries present in the jar: $undeclared")
|
|
val declared = Regex("\"file\":\"(META-INF/jars/[^\"]+)\"").findAll(fabricJson).map { it.groupValues[1] }.toList()
|
|
val declaredGone = declared.filter { !entries.contains(it) }
|
|
if (declaredGone.isNotEmpty()) fail("lite fabric.mod.json declares bundled libraries missing from the jar: $declaredGone")
|
|
try {
|
|
groovy.json.JsonSlurper().parseText(fabricJson)
|
|
} catch (t: Throwable) {
|
|
fail("fabric.mod.json is not valid JSON: ${t.message}")
|
|
}
|
|
|
|
val mixinsJson = entryText("autism.mixins.json")
|
|
val leakedMixins = liteStripMixins.filter { mixinsJson.contains("\"$it\"") }
|
|
if (leakedMixins.isNotEmpty()) fail("strip-listed mixins back in the lite config: $leakedMixins")
|
|
try {
|
|
groovy.json.JsonSlurper().parseText(mixinsJson)
|
|
} catch (t: Throwable) {
|
|
fail("autism.mixins.json is not valid JSON after filtering: ${t.message}")
|
|
}
|
|
|
|
// Mixin member-parity guard: every mixin class lite applies (transformed json entries
|
|
// AND the config plugin) must ship with ALL of its full-jar methods. The mixin
|
|
// framework resolves handlers, accessors and the plugin's lifecycle methods
|
|
// (onLoad/shouldApplyMixin/getMixins) reflectively at runtime, so a pruned member is
|
|
// an AbstractMethodError at startup (verified in-game: the plugin's onLoad once died
|
|
// exactly like that).
|
|
val mixinsJsonObj = groovy.json.JsonSlurper().parseText(mixinsJson) as Map<String, Any?>
|
|
val mixinPkg = ((mixinsJsonObj["package"] as? String) ?: "autismclient.mixin").replace('.', '/')
|
|
@Suppress("UNCHECKED_CAST")
|
|
val mixinClassNames = ((mixinsJsonObj["client"] as? List<Any?>)?.filterIsInstance<String>() ?: emptyList())
|
|
.map { "$mixinPkg/${it.replace('.', '/')}" }.toMutableList()
|
|
(mixinsJsonObj["plugin"] as? String)?.let { plugin ->
|
|
mixinClassNames += (if (plugin.contains('.')) plugin else "$mixinPkg.$plugin").replace('.', '/')
|
|
}
|
|
val fullMixinNodes = HashMap<String, ClassNode>()
|
|
ZipFile(fullJarFile.get().asFile).use { zip ->
|
|
for (name in mixinClassNames) {
|
|
val entry = zip.getEntry("$name.class") ?: continue
|
|
val cn = ClassNode()
|
|
ClassReader(zip.getInputStream(entry).readBytes()).accept(cn, 0)
|
|
fullMixinNodes[name] = cn
|
|
}
|
|
}
|
|
val memberParity = ArrayList<String>()
|
|
for ((name, fullNode) in fullMixinNodes) {
|
|
val liteNode = liteNodes[name] ?: run {
|
|
memberParity += "$name (class missing from lite)"
|
|
continue
|
|
}
|
|
for (mn in fullNode.methods) {
|
|
// Compiler-synthetic methods (lambda$...$) are not framework contract: they are
|
|
// invoked through invokedynamic handles, and when a lambda's creation site is
|
|
// lite-gated and folded, the dead-lambda sweep removes it on purpose.
|
|
if ((mn.access and Opcodes.ACC_SYNTHETIC) != 0) continue
|
|
if (liteNode.methods.none { it.name == mn.name && it.desc == mn.desc }) {
|
|
memberParity += "$name.${mn.name}${mn.desc}"
|
|
}
|
|
}
|
|
}
|
|
if (memberParity.isNotEmpty()) {
|
|
fail("mixin classes missing methods the framework needs (AbstractMethodError at startup): ${memberParity.take(6)}")
|
|
}
|
|
|
|
// Callback-contract guards: two families of methods die without a visible call site.
|
|
// 1) liteCallbackContracts - JVM/library invocations (string concat -> toString,
|
|
// HashMap -> equals/hashCode, executors -> run/call, sort -> compare, ...).
|
|
// 2) external-rooted classes - framework callbacks invoked virtually by code outside
|
|
// the jar (Screen.init/tick, netty channelRead/write, ClassValue.computeValue,
|
|
// brigadier ArgumentType.parse, record constructors, ...).
|
|
// Both are exempt from pruning in liteShrinkJar; this proves the exemptions held.
|
|
// (Verified in-game: a codec's pruned toString was an AbstractMethodError at startup;
|
|
// verified by diff: the netty tap's channelRead was silently pruned before the fix.)
|
|
val liteExternalRooted = HashSet<String>()
|
|
for (name in liteNodes.keys) {
|
|
val visited = LinkedHashSet<String>()
|
|
val queue = ArrayDeque<String>()
|
|
queue.addLast(name)
|
|
var external = false
|
|
while (queue.isNotEmpty() && !external) {
|
|
val cn = liteNodes[queue.removeFirst()] ?: continue
|
|
val supers = listOfNotNull(cn.superName) + cn.interfaces.filterIsInstance<String>()
|
|
for (s in supers) {
|
|
if (s !in liteNodes) {
|
|
if (s != "java/lang/Object") { external = true; break }
|
|
} else if (visited.add(s)) {
|
|
queue.addLast(s)
|
|
}
|
|
}
|
|
}
|
|
if (external) liteExternalRooted += name
|
|
}
|
|
val contractGone = ArrayList<String>()
|
|
for ((name, liteNode) in liteNodes) {
|
|
val checkAll = name in liteExternalRooted
|
|
val fullNode = fullNodes[name] ?: continue
|
|
for (mn in fullNode.methods) {
|
|
if ((mn.access and (Opcodes.ACC_PRIVATE or Opcodes.ACC_SYNTHETIC)) != 0) continue
|
|
if ((checkAll || (mn.name + mn.desc) in liteCallbackContracts) &&
|
|
liteNode.methods.none { it.name == mn.name && it.desc == mn.desc }) {
|
|
contractGone += "$name.${mn.name}${mn.desc}"
|
|
}
|
|
}
|
|
}
|
|
if (contractGone.isNotEmpty()) {
|
|
fail("implicitly-called methods pruned (AbstractMethodError at runtime): ${contractGone.take(6)}")
|
|
}
|
|
|
|
val mb = jar.length() / 1024.0 / 1024.0
|
|
if (mb > 7.0) {
|
|
fail("lite jar regressed to ${"%.1f".format(mb)} MB (> 7.0 MB ceiling) - the shrink is not doing its job")
|
|
}
|
|
logger.lifecycle("verifyLiteJar: contents OK (marker, ${liteStripMixins.size} mixins stripped, " +
|
|
"${liteStripAssets.size} asset exclusions held, ${liteKeepPinned.size} pinned classes + " +
|
|
"${liteKeepLibs.size} libraries present, ${liteKnownDead.size} known-dead absent, " +
|
|
"reference closure intact, metadata intact) - lite jar = ${"%.1f".format(mb)} MB")
|
|
}
|
|
}
|
|
}
|
|
|
|
tasks.named("liteJar") {
|
|
finalizedBy("verifyLiteJar")
|
|
}
|