Autism-Client/build.gradle.kts
2026-06-15 06:11:26 +02:00

542 lines
23 KiB
Kotlin

import java.io.OutputStream
import java.util.zip.ZipFile
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath("org.apache.xmlgraphics:batik-transcoder:1.17")
classpath("org.apache.xmlgraphics:batik-codec:1.17")
}
}
plugins {
alias(libs.plugins.fabric.loom)
`maven-publish`
}
base {
archivesName = properties["archives_base_name"] as String
version = libs.versions.mod.version.get()
group = properties["maven_group"] as String
}
repositories {
maven {
name = "meteor-maven"
url = uri("https://maven.meteordev.org/releases")
}
mavenCentral()
}
dependencies {
minecraft(libs.minecraft)
implementation(libs.fabric.loader)
implementation(libs.fabric.api)
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")
// 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")
}
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)
}
}
}
}
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 26.1.2; 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")
if (sourcesJar.isFile) {
ZipFile(sourcesJar).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()
}
error("Missing Minecraft deobf sources jar and local mc-src fallback for $minecraftVersion")
}
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")
}
}
}
}
tasks {
processResources {
dependsOn(generateAutismInspectorMappings)
dependsOn(generateAutismPacketSchemas)
dependsOn(generateVanillaUiAssets)
val propertyMap = mapOf(
"version" to project.version,
"mc_version" to libs.versions.minecraft.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()
}
}