聊聊 Mod 开发中行号的问题
有时候我们在 debug 过程中看调用堆栈,通常堆栈后都会指示一个行号,但是有人会发现这个行号在生产环境中对于 Minecraft 的类与开发环境里的行号是不能对应的,因此本篇文章会讲述如何正确获取行号。
本篇以 Forge 为例。
堆栈中的行号是怎么来的?
源代码每行都有对应的行号,然而实际运行的时候并不存在源代码,只有编译好的 class 二进制文件,因此行号是被编译器塞进 class 字节码中去的,而调用堆栈显示的行号就是字节码中被编译器塞进去的行号。
反编译器中的行号是什么?
我们在 debug 一些 mod 时通常会反编译这些 mod,然而有人会发现反编译器中显示的行号与堆栈中的行号并不对应,我们以如下报错举例:
java.util.NoSuchElementException: null
at java.util.ArrayDeque.getFirst(ArrayDeque.java:324) ~[?:1.8.0_51] {}
at vazkii.patchouli.client.book.text.SpanState.peekStyle(SpanState.java:76) ~[?:1.16.4-48] {re:classloading}
at vazkii.patchouli.client.book.text.Span.<init>(Span.java:30) ~[?:1.16.4-48] {re:classloading}
at vazkii.patchouli.client.book.text.BookTextParser.processCommands(BookTextParser.java:231) ~[?:1.16.4-48] {re:classloading}
at vazkii.patchouli.client.book.text.BookTextParser.parse(BookTextParser.java:207) ~[?:1.16.4-48] {re:classloading}
at vazkii.patchouli.client.book.gui.BookTextRenderer.build(BookTextRenderer.java:50) ~[?:1.16.4-48] {re:classloading}
at vazkii.patchouli.client.book.gui.BookTextRenderer.<init>(BookTextRenderer.java:45) ~[?:1.16.4-48] {re:classloading}
at vazkii.patchouli.client.book.gui.BookTextRenderer.<init>(BookTextRenderer.java:28) ~[?:1.16.4-48] {re:classloading}
at vazkii.patchouli.client.book.page.abstr.PageWithText.onDisplayed(PageWithText.java:26) ~[?:1.16.4-48] {re:classloading}
at vazkii.patchouli.client.book.gui.GuiBookEntry.setupPages(GuiBookEntry.java:154) ~[?:1.16.4-48] {re:classloading}
at vazkii.patchouli.client.book.gui.GuiBookEntry.func_231160_c_(GuiBookEntry.java:54) ~[?:1.16.4-48] {re:classloading}
at net.minecraft.client.gui.screen.Screen.func_231158_b_(Screen.java:325) ~[?:?] {re:mixin,pl:accesstransformer:B,pl:runtimedistcleaner:A,re:computing_frames,pl:accesstransformer:B,pl:runtimedistcleaner:A,re:classloading,pl:accesstransformer:B,pl:mixin:APP:quark.mixins.json:client.ScreenMixin,pl:mixin:A,pl:runtimedistcleaner:A}
at net.minecraft.client.Minecraft.func_147108_a(Minecraft.java:852) ~[?:?] {re:mixin,pl:accesstransformer:B,pl:runtimedistcleaner:A,re:classloading,pl:accesstransformer:B,xf:fml:customwindowtitle:CustomWindowTitle,pl:mixin:APP:charm.mixins.json:accessor.MinecraftAccessor,pl:mixin:APP:assets/botania/botania.mixins.json:AccessorMinecraft,pl:mixin:A,pl:runtimedistcleaner:A}
at vazkii.patchouli.client.book.BookContents.openLexiconGui(BookContents.java:83) ~[?:1.16.4-48] {re:classloading}
at vazkii.patchouli.client.book.gui.GuiBookEntry.displayOrBookmark(GuiBookEntry.java:228) ~[?:1.16.4-48] {re:classloading}
at vazkii.patchouli.client.book.gui.GuiBookEntryList.handleButtonEntry(GuiBookEntryList.java:167) ~[?:1.16.4-48] {re:classloading}
at vazkii.patchouli.client.book.gui.GuiBookEntryList$$Lambda$10790/1627619043.onPress(Unknown Source) ~[?:?] {}
at net.minecraft.client.gui.widget.button.Button.func_230930_b_(SourceFile:33) ~[?:?] {re:classloading}
at net.minecraft.client.gui.widget.button.AbstractButton.func_230982_a_(SourceFile:16) ~[?:?] {re:classloading}
at net.minecraft.client.gui.widget.Widget.func_231044_a_(Widget.java:136) ~[?:?] {re:classloading,pl:runtimedistcleaner:A}
at net.minecraft.client.gui.INestedGuiEventHandler.func_231044_a_(SourceFile:27) ~[?:?] {re:computing_frames,re:mixin,re:classloading}
at vazkii.patchouli.client.book.gui.GuiBook.mouseClickedScaled(GuiBook.java:301) ~[?:1.16.4-48] {re:classloading}
at vazkii.patchouli.client.book.gui.GuiBookEntryList.mouseClickedScaled(GuiBookEntryList.java:125) ~[?:1.16.4-48] {re:classloading}
at vazkii.patchouli.client.book.gui.GuiBook.func_231044_a_(GuiBook.java:278) ~[?:1.16.4-48] {re:classloading}
at net.minecraft.client.MouseHelper.lambda$mouseButtonCallback$0(MouseHelper.java:87) ~[?:?] {re:classloading,pl:runtimedistcleaner:A}
at net.minecraft.client.MouseHelper$$Lambda$8051/1190066743.run(Unknown Source) ~[?:?] {}
at net.minecraft.client.gui.screen.Screen.func_231153_a_(Screen.java:427) ~[?:?] {re:mixin,pl:accesstransformer:B,pl:runtimedistcleaner:A,re:computing_frames,pl:accesstransformer:B,pl:runtimedistcleaner:A,re:classloading,pl:accesstransformer:B,pl:mixin:APP:quark.mixins.json:client.ScreenMixin,pl:mixin:A,pl:runtimedistcleaner:A}
at net.minecraft.client.MouseHelper.func_198023_a(MouseHelper.java:85) ~[?:?] {re:classloading,pl:runtimedistcleaner:A}
at net.minecraft.client.MouseHelper.lambda$null$4(MouseHelper.java:175) ~[?:?] {re:classloading,pl:runtimedistcleaner:A}
at net.minecraft.client.MouseHelper$$Lambda$8050/373583099.run(Unknown Source) ~[?:?] {}
at net.minecraft.util.concurrent.ThreadTaskExecutor.execute(SourceFile:94) ~[?:?] {re:computing_frames,pl:accesstransformer:B,re:mixin,pl:accesstransformer:B,re:classloading,pl:accesstransformer:B}
at net.minecraft.client.MouseHelper.lambda$registerCallbacks$5(MouseHelper.java:174) ~[?:?] {re:classloading,pl:runtimedistcleaner:A}
at net.minecraft.client.MouseHelper$$Lambda$6714/1514018399.invoke(Unknown Source) ~[?:?] {}
at org.lwjgl.glfw.GLFWMouseButtonCallback$Container.invoke(GLFWMouseButtonCallback.java:81) ~[lwjgl-glfw-3.2.2.jar:build 10] {}
at bre2el.fpsreducer.handler.glfw.InputEventHandler$MouseButtonEventHandler.invoke(InputEventHandler.java:99) ~[?:mc1.16.4-1.18] {re:classloading}
at org.lwjgl.glfw.GLFWMouseButtonCallbackI.callback(GLFWMouseButtonCallbackI.java:36) ~[lwjgl-glfw-3.2.2.jar:build 10] {}
at org.lwjgl.system.JNI.invokeV(Native Method) ~[lwjgl-3.2.2.jar:build 10] {}
at org.lwjgl.glfw.GLFW.glfwPollEvents(GLFW.java:3101) ~[lwjgl-glfw-3.2.2.jar:build 10] {}
at com.mojang.blaze3d.systems.RenderSystem.flipFrame(SourceFile:102) ~[?:?] {re:classloading}
at net.minecraft.client.MainWindow.func_227802_e_(MainWindow.java:305) ~[?:?] {re:classloading,pl:runtimedistcleaner:A}
at net.minecraft.client.Minecraft.func_195542_b(Minecraft.java:996) [?:?] {re:mixin,pl:accesstransformer:B,pl:runtimedistcleaner:A,re:classloading,pl:accesstransformer:B,xf:fml:customwindowtitle:CustomWindowTitle,pl:mixin:APP:charm.mixins.json:accessor.MinecraftAccessor,pl:mixin:APP:assets/botania/botania.mixins.json:AccessorMinecraft,pl:mixin:A,pl:runtimedistcleaner:A}
at net.minecraft.client.Minecraft.func_99999_d(Minecraft.java:607) [?:?] {re:mixin,pl:accesstransformer:B,pl:runtimedistcleaner:A,re:classloading,pl:accesstransformer:B,xf:fml:customwindowtitle:CustomWindowTitle,pl:mixin:APP:charm.mixins.json:accessor.MinecraftAccessor,pl:mixin:APP:assets/botania/botania.mixins.json:AccessorMinecraft,pl:mixin:A,pl:runtimedistcleaner:A}
at net.minecraft.client.main.Main.main(Main.java:184) [?:?] {re:classloading,re:mixin,pl:runtimedistcleaner:A,pl:mixin:A,pl:runtimedistcleaner:A}
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[?:1.8.0_51] {}
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[?:1.8.0_51] {}
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[?:1.8.0_51] {}
at java.lang.reflect.Method.invoke(Method.java:497) ~[?:1.8.0_51] {}
at net.minecraftForge.fml.loading.FMLClientLaunchProvider.lambda$launchService$0(FMLClientLaunchProvider.java:51) [Forge-1.16.5-36.0.13.jar:36.0] {}
at net.minecraftForge.fml.loading.FMLClientLaunchProvider$$Lambda$485/1614427365.call(Unknown Source) [Forge-1.16.5-36.0.13.jar:36.0] {}
at cpw.mods.modlauncher.LaunchServiceHandlerDecorator.launch(LaunchServiceHandlerDecorator.java:37) [modlauncher-8.0.9.jar:?] {}
at cpw.mods.modlauncher.LaunchServiceHandler.launch(LaunchServiceHandler.java:54) [modlauncher-8.0.9.jar:?] {}
at cpw.mods.modlauncher.LaunchServiceHandler.launch(LaunchServiceHandler.java:72) [modlauncher-8.0.9.jar:?] {}
at cpw.mods.modlauncher.Launcher.run(Launcher.java:82) [modlauncher-8.0.9.jar:?] {}
at cpw.mods.modlauncher.Launcher.main(Launcher.java:66) [modlauncher-8.0.9.jar:?] {}
我们可以发现 vazkii.patchouli.client.book.text.SpanState
的第 76 行调用了 java.util.ArrayDeque.getFirst
,但是我们反编译之后发现第 76 行并不是这样的:(反编译器是 Luyten-0.5.4)
原因是反编译器中的行号仅仅只是用于指示反编译后的文件的行号,并没有其他作用,众所周知,反编译后的代码基本不等同于源代码。好在 Luyten 提供了一个非常有用的功能,能够显示原始代码中的行号(Settings -- Show Debug Line Numbers),照着这个行号,我们就能找到对应的地方:
为什么在开发环境中运行时的行号是准确的?
有人会发现生产环境中的调用堆栈行号无法和开发环境中 Minecraft 的类对应,但是在开发环境中运行游戏时调用堆栈行号都能正确对应。因此要回答这个问题,首先需要知道开发环境中的 Minecraft 源代码是怎么来的:
众所周知 Forge 在构建开发环境时需要反编译 Minecraft 以获得「源代码」,那么 Forge 反编译出的代码做了以下步骤:
- 由于反编译出的代码多多少少都会有一些错误,无法重新原样编译回去,所以需要修复这些反编译中的错误(这就是 MCPConfig 的功能之一,由人工修复并提供一个源代码 patch 文件,在构建开发环境反编译之后应用它以修复源代码中的错误);
- 应用修复之后,再打入 Forge 自身对 Minecraft 的修改的 patch;
- 最后利用 Srg2Source 反混淆;
这样就构成了我们在开发环境中看到的 Minecraft 所谓的「源代码」,但它并不等同于 mojang 那里的源代码。所有以上步骤还不足以让 class 字节码中的行号和反编译的行号对应起来,它们是为了给接下来这个步骤做铺垫 —— recompile,即重新编译之前生成好的代码,这样开发环境中 Minecraft 的二进制 class 文件和源代码 java 文件的行号就对应起来了。
根据以上条件,很容易就能知道为什么生产环境运行时的堆栈和开发环境中的不对应了:
- Forge 在生产环境中安装和运行时并不会有反编译和重编译的步骤,所以 Minecraft 的类显示的行号仍然是 mojang 那里的原始行号;
- 由于 EULA 的限制,现在 Forge 并不能直接分发修改后的 Minecraft 的 class 文件,因此 Forge 用了一个折中的方案:发布前先生成一个二进制 patch 文件,再由用户安装或运行时应用这个 patch 文件,这样做的效果基本等同于远古时期安装 mod 时直接替换 class 文件,所以如果是被 Forge 打过 patch 的类显示的行号理论上应该是能与开发环境对应的;
- OptiFine 修改 Minecraft 本体的方式与 Forge 类似,因此如果相关的类被 OptiFine 修改过,那么行号也会与开发环境对应不上;
- Mixin 等框架会为注入的方法分配新的行号,因此如果看到调用堆栈中有远超原来类的总行数的行号,那么这个方法极有可能是某些 Mod 通过 Mixin 添加的。
如何在生产环境中获得准确的行号?
由上一个问题可以知道,我们最好能拿到最终实际加载到 JVM 中去的类的二进制文件并进行反编译。
那么我们如何得到最终运行时的 class 二进制文件呢?
- 在 Forge 1.12.2 以前,可以添加以下 JVM 参数
-Dlegacy.debugClassLoading=true -Dlegacy.debugClassLoadingSave=true
启动游戏后会在工作目录下生成
CLASSLOADER_TEMP
文件夹,里面存放了所有已经被LaunchClassLoader
加载的 class 文件。 - 在 Forge 1.13 以后,可以添加以下 JVM 参数
-Dforge.logging.classtransformer.level=trace -Dforge.logging.marker.classdump=ACCEPT
启动游戏后会在 JVM 属性
java.io.tmpdir
指示的目录(通常是C:\Users\<用户名>\AppData\Local\Temp
(Windows) 或/tmp
(Un*x))下生成classDumpxxxxxxxxxxxxx
文件夹,里面存放了所有已经被 Mod 修改后的 class 文件。对于没有被 Mod 修改的 class,则可以在以下位置找到:
<工作目录>/libraries/net/minecraftforge/forge/<版本>/forge-<版本>-[client|server].jar
<工作目录>/libraries/net/minecraft/[client|server]/<版本>-<时间>/[client|server]-<版本>-<时间>-srg.jar
然后按上述使用 Luyten 的方式反编译它们就能获得准确的行号。
模组坟场