diff --git a/.gitignore b/.gitignore index 32858aa..10d7a38 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* +build/ +.idea/ +*.iml +.gradle/ diff --git a/LICENSE b/LICENSE index 853b46d..92d6197 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2016 +Copyright (c) 2016 octarine-noise Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 6c24010..e8e82f9 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,108 @@ -# simpledeobf -simple deobfuscator for Minecraft mods +### What is this? + +**simpledeobf** is a very simple and primitive command-line deobfuscator for +Minecraft mods. You can use it to create dev versions of mod releases +for debugging. Or anything else, really - it all depends on what inputs and +mapping files you use. Feel free to get creative with it. + +### How to use it? + +simpledeobf will need files from your Gradle cache which are created by +ForgeGradle, so it's best to start with a ForgeGradle workspace already +set up for your target Minecraft version. + +Command-line options: + +* `--output` The path to the resulting jar. You can only have one. +* `--input` The input jar you wish to deobfuscate. Can be repeated, but the +files from all inputs will just end up in a single output jar. +* `--mapFile` The path to the MCP mapping file, found in your Gradle cache +somewhere under `minecraft/de/oceanlabs/mcp`. You need to use the one that +corresponds to the namespaces you are converting from and to, and also +matches the ones you use in your project. Can be repeated, later files will +overwrite mappings from previous ones. +* `--map` Defines a single explicit mapping. This option is treated as if +it was a line in the MCP mapping file. Takes precedence over mappings +read from MCP files. Can be repeated. +* `--ref` The path to a reference jar. Can be repeated. The classes inside +are read only to determine the class hierarchy, which may be needed to properly +deobfuscate certain jar files. The reference jar must be in the same namespace +as the input. + + More precisely: you will need to provide a proper class hierarchy +to deobfuscate overridden methods whose declaring classes or interfaces are +2 or more levels above the overriding class. + + When in doubt, try using a Minecraft jar of the proper namespace. +* `--defaultPkg` move all classes from the default package into this one. Having +classes in the default package will mess with source attachment in the IDE. +* `--forcePublic` make all fields, methods and inner classes public. The +poor man's access transformer. +* `--help` or `-?` displays a quick overview of these options. + +Have your favourite brand of decompiler ready, and be prepared to dig through +the result, and make several iterations before you end up with something +usable. + +Also there's no guarantee that it's even possible to create a workable dev jar +for any given mod. If it uses reflection and/or class transformation, chances +are good that it will just crash anyway, unless it's specifically made to be +environment agnostic. + +### Have OptiFine, will debug + +As an example, here is a guide on getting OptiFine into your dev +environment with simpledeobf. This is *the* prime use-case of simpledeobf, +and the reason for its existence. + +The following is the actual command I use for the deobfuscation itself, +so you'll need to change the directory names. It's a single command, but I +broke it up into lines for readability. +``` +java -jar simpledeobf-0.5.jar +--input h:\Minecraft\mods\obf\OptiFine_1.8.8_HD_U_H2.jar +--output h:\Minecraft\mods\mcp\OptiFine_1.8.8_HD_U_H2-dev.jar +--mapFile h:\Minecraft\.gradle\caches\minecraft\de\oceanlabs\mcp\mcp_stable\20\srgs\notch-mcp.srg +--ref h:\Minecraft\.gradle\caches\minecraft\net\minecraft\minecraft_merged\1.8.8\minecraft_merged-1.8.8.jar +--map="CL: bet$1 net/minecraft/client/entity/AbstractClientPlayer$1" +--map="CL: b$8 net/minecraft/crash/CrashReport$8" +--defaultPkg optifineroot +--forcePublic +``` +This will give you a jar with the `stable_20` mappings. The 2 `map` options are +needed because OptiFine declares some extra inner classes that are not present +in vanilla and have no mappings. If you want to do this for a different Minecraft +version, you'll have to change these. Just start without manual mappings, and +check if there are still obfuscated classes in the result. Check in the MCP +files what the outer class is, and add a mapping. Rinse and repeat. + +Open the resulting jar file, and delete the `net/minecraftforge` directory. +There are dummy classes inside that are normally not loaded, but will cause +problems in a dev environment. They need to go. + +Now you have to create a tweaker and class transformer, because the default +OptiFine ones will not work properly in a dev environment. +[Here is mine](https://github.com/octarine-noise/BetterFoliage/blob/c0be72bb37311508c68db5bd3b09d2f99a76614c/src/main/kotlin/optifine/OptifineTweakerDevWrapper.kt) +that I use in Better Foliage, you need something similar. The point is to +change dots to slashes in the class names, so the OptiFine transformer can find +the deobfuscated class files. + +Edit the `META-INF/MANIFEST.MF` file. Change the `TweakClass` option to the +tweaker you just made. + +The jar is now ready. If you also want source attachment, which is highly +recommended, just decompile it with your favourite tool (I prefer JD-GUI), and +save a source jar. Make sure that *"Realign line numbers"* or the equivalent +option is turned on. + +Drop the jar in the mods folder of your workspace, manually add it to the project +dependencies after all the Gradle stuff, set its source attachment, and you're +ready. You can start your project with OptiFine thrown in the mix, debug, +set breakpoints, and everything. + +**Note:** Debugging *into* one of the vanilla classes that OptiFine overwrites +may or may not give you some headache under Eclipse. I can only confirm it works +fine under IDEA, which allows you to switch between sources on the fly if multiple +jar files declare a class. A popup comes up saying *"Alternative source available +for the class blah blah blah"*, and you can switch to the OptiFine jar, which +contains the class actually executing. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..0ad81fb --- /dev/null +++ b/build.gradle @@ -0,0 +1,34 @@ +apply plugin: 'kotlin' + +group = 'com.octarine' +version = '0.5' + +repositories { mavenCentral() } + +buildscript { + ext.kotlin_version = '1.0.0' + repositories { + mavenCentral() + } + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +dependencies { + compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + compile 'org.ow2.asm:asm-debug-all:5.0.4' + compile 'net.sf.jopt-simple:jopt-simple:4.9' +} + +jar { + manifest { + attributes "Main-Class": "com.octarine.simpledeobf.Main" + } + + configurations.compile.each { dep -> + from(project.zipTree(dep)){ + exclude 'META-INF', 'META-INF/**' + } + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..b48d7a0 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'simpledeobf' + diff --git a/src/main/kotlin/com/octarine/simpledeobf/Main.kt b/src/main/kotlin/com/octarine/simpledeobf/Main.kt new file mode 100644 index 0000000..04ceb77 --- /dev/null +++ b/src/main/kotlin/com/octarine/simpledeobf/Main.kt @@ -0,0 +1,99 @@ +@file:JvmName("Main") +package com.octarine.simpledeobf + +import joptsimple.OptionException +import joptsimple.OptionParser +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.tree.ClassNode +import java.io.File +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream + +fun main(args : Array) { + + try { + val parser = OptionParser() + val inputFile = parser.accepts("input", "Path to input JAR file").withRequiredArg().ofType(File::class.java).required() + val outputFile = parser.accepts("output", "Path to output JAR file").withRequiredArg().ofType(File::class.java).required() + val referenceFile = parser.accepts("ref", "Path to reference JAR file").withRequiredArg().ofType(File::class.java) + val mappingFile = parser.accepts("mapFile", "Path to mapping file").withRequiredArg().ofType(File::class.java) + val mapping = parser.accepts("map", "Manual mapping entry").withRequiredArg().ofType(String::class.java) + val defaultPkg = parser.accepts("defaultPkg", "Map default package").withRequiredArg().ofType(String::class.java) + val forcePublic = parser.accepts("forcePublic", "Force everything to be public") + val help = parser.acceptsAll(listOf("?", "help")).forHelp() + + val options = parser.parse(*args) + + if (options.has(help)) { + parser.printHelpOn(System.out) + System.exit(0) + } + + if (options.valuesOf(outputFile).size != 1) { + println("Maximum of 1 output file is allowed") + System.exit(1) + } + + if (options.valuesOf(defaultPkg).size > 1) { + println("Maximum of 1 default package is allowed") + System.exit(1) + } + + val mapper = SimpleRemapper(options.valueOf(defaultPkg)) + options.valuesOf(mappingFile).forEach { + println("Reading mappings from: ${it.absolutePath}") + mapper.readMappingFile(it) + } + options.valuesOf(mapping).forEach { mapper.readMappingLine(it) } + options.valuesOf(referenceFile).forEach { + println("Reading hiererchy from: ${it.absolutePath}") + mapper.hierarchyReader.visitAllFromFile(it) + } + options.valuesOf(inputFile).forEach { + println("Reading hiererchy from: ${it.absolutePath}") + mapper.hierarchyReader.visitAllFromFile(it) + } + + val destJar = options.valuesOf(outputFile)[0].let { + if (it.exists()) it.delete() + ZipOutputStream(FileOutputStream(it)) + } + + for (srcFile in options.valuesOf(inputFile)) { + println("Processing input file: ${srcFile.absolutePath}") + val srcJar = ZipFile(srcFile) + for (srcEntry in srcJar.entries()) { + if (srcEntry.name.endsWith(".class")) { + print(" processing: ${srcEntry.name} ") + val srcBytes = srcJar.getInputStream(srcEntry).readBytes() + val srcClass = ClassNode().apply { ClassReader(srcBytes).accept(this, ClassReader.EXPAND_FRAMES) } + val destClass = mapper.remapClass(srcClass, options.has(forcePublic)) + val destBytes = ClassWriter(0).apply { destClass.accept(this) }.toByteArray() + + println(if (!srcEntry.name.startsWith(destClass.name)) "-> ${destClass.name}.class" else "") + destJar.putNextEntry(ZipEntry("${destClass.name}.class")) + destJar.write(destBytes) + destJar.closeEntry() + } else { + println(" copying: ${srcEntry.name}") + destJar.putNextEntry(ZipEntry(srcEntry.name)) + srcJar.getInputStream(srcEntry).copyTo(destJar) + destJar.closeEntry() + } + } + } + + println("Conversion finished") + destJar.close() + } catch(e: OptionException) { + println(e.message) + System.exit(1) + } catch(e: FileNotFoundException) { + println(e.message) + System.exit(1) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/octarine/simpledeobf/Remapper.kt b/src/main/kotlin/com/octarine/simpledeobf/Remapper.kt new file mode 100644 index 0000000..16078aa --- /dev/null +++ b/src/main/kotlin/com/octarine/simpledeobf/Remapper.kt @@ -0,0 +1,110 @@ +package com.octarine.simpledeobf + +import org.objectweb.asm.* +import org.objectweb.asm.commons.Remapper +import org.objectweb.asm.commons.RemappingClassAdapter +import org.objectweb.asm.tree.ClassNode +import java.io.File +import java.util.* +import java.util.zip.ZipFile + +class SimpleRemapper(val defaultPkg: String?) : Remapper() { + + val String.partOwner: String get() = this.substring(0, this.lastIndexOf("/")) + val String.partName: String get() = this.substring(this.lastIndexOf("/") + 1) + + val mappings = HashMap() + val hierarchy = HashMap() + val hierarchyReader = SimpleHierarchyReader() + + inner class SimpleHierarchyReader : ClassVisitor(Opcodes.ASM5) { + override fun visit(version: Int, access: Int, name: String, signature: String?, superName: String?, interfaces: Array?) { + hierarchy.put(name, ClassHierarchy(superName, interfaces)) + } + + fun visitAllFromFile(file: File) { + val jarFile = ZipFile(file) + for (srcEntry in jarFile.entries()) { + if (srcEntry.name.endsWith(".class")) { + val srcBytes = jarFile.getInputStream(srcEntry).readBytes() + ClassReader(srcBytes).accept(this, ClassReader.EXPAND_FRAMES) + } + } + } + } + + fun remapClass(srcClass: ClassNode, forcePublic: Boolean) = ClassNode().apply { + srcClass.accept(PublicAccessRemappingClassAdapter(this, this@SimpleRemapper, forcePublic)) + } + + fun readMappingFile(file: File) { + if (!file.exists()) throw Exception("Mappings file doesn't exist: ${file.absolutePath}") + file.readLines().forEach { readMappingLine(it) } + } + + fun readMappingLine(line: String) { + val tokens = line.split(" ") + if (tokens[0] == "CL:") addClassMapping(tokens[1], tokens[2]) + if (tokens[0] == "FD:") addFieldMapping(tokens[1].partOwner, tokens[1].partName, tokens[2].partOwner, tokens[2].partName) + if (tokens[0] == "MD:") addMethodMapping(tokens[1].partOwner, tokens[1].partName, tokens[2], tokens[3].partOwner, tokens[3].partName, tokens[4]) + } + + fun addClassMapping(fromName: String, toName: String) { + mappings.put(fromName, ClassMapping(toName)) + } + + fun addFieldMapping(fromOwner: String, fromName: String, toOwner: String, toName: String) { + mappings[fromOwner]?.fields?.put(fromName, toName) + } + + fun addMethodMapping(fromOwner: String, fromName: String, fromDesc: String, toOwner: String, toName: String, toDesc: String) { + mappings[fromOwner]?.methods?.put(fromName to fromDesc, toName) + } + + override fun map(typeName: String): String? = (mappings[typeName]?.mappedName ?: typeName).let { + if (it.contains("/") || defaultPkg == null) it else "$defaultPkg/$it" + } + + override fun mapFieldName(owner: String, name: String, desc: String?): String? { + mappings[owner]?.let { it.fields[name] }?.let { return it } + hierarchy[owner]?.superName?.let { return mapFieldName(it, name, desc) } + return name + } + + override fun mapMethodName(owner: String, name: String, desc: String) = + mapMethodNameInternal(owner, name, desc) ?: name + + fun mapMethodNameInternal(owner: String, name: String, desc: String): String? { + mappings[owner]?.let { it.methods[name to desc] }?.let { return it } + val h = hierarchy[owner] ?: return null + if (h.superName != null) mapMethodNameInternal(h.superName, name, desc)?.let { return it } + if (h.interfaces != null) for (interfaceName in h.interfaces) { + mapMethodNameInternal(interfaceName, name, desc)?.let { return it } + } + return null + } +} + +class PublicAccessRemappingClassAdapter(cv: ClassVisitor, remapper: Remapper, val force: Boolean) : RemappingClassAdapter(cv, remapper) { + + val Int.toPublic: Int get() = (this and 0xFFF8) or 0x1 + + override fun visitMethod(access: Int, name: String?, desc: String?, signature: String?, exceptions: Array?): MethodVisitor? { + return super.visitMethod(if (force) access.toPublic else access, name, desc, signature, exceptions) + } + + override fun visitField(access: Int, name: String?, desc: String?, signature: String?, value: Any?): FieldVisitor? { + return super.visitField(if (force) access.toPublic else access, name, desc, signature, value) + } + + override fun visitInnerClass(name: String?, outerName: String?, innerName: String?, access: Int) { + super.visitInnerClass(name, outerName, innerName, if (force) access.toPublic else access) + } +} + +class ClassMapping(val mappedName: String) { + val fields = HashMap() + val methods = HashMap, String>() +} + +class ClassHierarchy(val superName: String?, val interfaces: Array?) {} \ No newline at end of file