Files
Spine_lab/阶段四-视图面板挂载.md
tianmo 8f7b72271f 阶段四收尾:面板两行布局(row/expand/fill)+ 英文输出 + 混淆名对照表
- ViewInstaller:用 Table.row()/Cell.expand()+fill()/setFillParent 排出"
  按钮一行、可滚动文字区一行"的面板;修掉把 setDebug 当成 setFillParent 的误用
- Scene2dPanel:按钮 isChecked/setChecked 与 Label.setText 改用对照表定名,
  修掉"点一次反复触发"与"文字不刷新"两个 bug
- SceneReader:面板输出改英文(编辑器缺中文字形,中文会渲染成方块)
- 新增 tools/obfmap/MapMethods.java:载荷 vs 上游 libGDX 的字节码指纹比对工具
- 新增 混淆名对照表.md:类/方法/字段映射、推导办法、已踩的坑、验证状态
2026-10-16 14:06:42 +08:00

14 KiB
Raw Permalink Blame History

阶段四:在 Spine 3.8.99 编辑器里挂一个自建视图面板

目标:在 Spine 编辑器窗口内部加入一个自己的面板(一个按钮 + 一块文字显示区), 点击后读取场景对象;并且这个面板要像原生面板一样出现在 Views 菜单里,能停靠、能折叠展开。

本阶段结论:已经做到。视图 "Spine View" 会出现在 Views 菜单里,可以和 Tree 等面板并排停靠、折叠。


一、先让编辑器在"我们自己的进程"里跑起来

不经过官方启动器,用自己的入口类把 3.8.99 载荷拉起来。 下面每一条都是从反编译源码/字节码里读出根因之后才修好的,不是试出来的。

1. 编辑器窗口不是 AWT 窗口

Window.getWindows() 返回 0 个,但屏幕上明明有窗口。 原因:Spine 3.8.99 用 jglfw 直接建原生 NSWindow,界面是 libGDX scene2d 画的, 不是 Swing 组件树。→ 任何"往 JFrame 上加 Swing 面板"的思路都不成立。

2. Editor.main 必须在 AWT 事件线程上调用

源码里有一句:

if (java.awt.EventQueue.isDispatchThread() == false) { return; }

在 main 线程调它,它开门就返回、一个窗口都不建。 → 用 SwingUtilities.invokeAndWait(...) 包一层。

3. 编辑器要求自己的类加载器正好是 URLClassLoader

自检里有:

if (Class.getClassLoader(ah_0.class).getClass() != URLClassLoader.class) {
    OG.C("Another process has modified Spine's files, please reinstall Spine.");
    throw new UnsupportedOperationException();
}

用 -cp 跑时是 sun.misc.Launcher$AppClassLoader(URLClassLoader 的子类)→ 被判"文件被改过"。 → 必须自己 new URLClassLoader(urls, null),把载荷 zip 作为 URL 放进去。

4. "必须用捆绑运行时"的真相

Editor.main 里读 $java.home/lib/jvm.cfg:跳过 20 行、取第 21 行, 要求它以两个空格结尾,并且 System.getProperty("SpineJava") 非空;否则 Runtime.halt(1)。 → 给 JDK8 的 jre/lib/jvm.cfg 第 21 行补两个空格 + 启动时带 -DSpineJava=1。不用改字节码。

5. "安装已过时"对话框

Editor.D(kM) 一开头:

if (Editor.D < 19 && !options.D) { 弹"Reinstallation Required"; return; }

Editor.D:I 官方由启动器填。→ 反射把它设成 100。

6. 字体缺失导致 NPE

KH.D(name) 拼路径 = <载荷所在目录>/fonts/NotoSansSC-Regular.otf 之类。 缺了会打 "Launcher reinstall required, font not found: ..." 然后 NPE。 → 把启动器里的 fonts/ 拷到载荷同级的 fonts/。

7. 皮肤资源在 launcher.jar 里,但整包不能上类路径

编辑器要 /tzax/*.png(面板皮肤贴图),载荷里没有。 但是整个 launcher.jar 不能放进类路径:它里面的类是 Java 10 编译的(class 54), JDK8 加载会抛 UnsupportedClassVersionError,把 Prefs 初始化打断,编辑器直接起不来。 → 只把 launcher.jar 里的非 class 资源抽出来,作为一个资源目录加进 URLClassLoader。

8. 启动器的 Launcher 类必须存在

编辑器会反射读 com.esotericsoftware.spine.editor.launcher.Launcher 上的 clsid / clr / dat / info / dll;Class.forName 失败会被包成 RuntimeException 冒出去。 → 自己写一个 Java 8 编译的替身类,字段和同名静态方法都给上。

9. Stage 是局部变量

tD 里 new uk(...)(uk extends dt 就是 Stage),但它只存在局部变量里, 载荷里没有任何静态字段引用它。静态字段全扫过(用 Unsafe 读、不触发类初始化)也是 0 个。 → 只能钩构造器。(本阶段最后没走这条路,见第三节。)


二、3.8.99 全量解密 → 得到可读源码

没有可读源码,上面的坑一个都定位不到,所以先把这套东西做出来。

字符串解密

所有解密函数(PrefsPanes$Prefs$ToolbarPosition.f、aaQ$Wm.x 以及若干内联副本)第一步都算同一个 key:

key = (cpSize + 调用类名 + 调用方法名 + cpSize).hashCode();
// cpSize = SharedSecrets.getJavaLangAccess().getConstantPool(<调用方类>).getSize()

注意 cpSize 取的是 st[2](调用方类) 的常量池大小,跟解密函数自己所在类无关。

做法:把解密函数的访问标志改成 public(只改 access_flags,不动常量池,密文一个字节不受影响), 再在运行时造一个类名/方法名与原始调用方完全一致的合成类去调它, 这样 key 自然对上,完全不用自己实现 AES。

结果:17251 / 17461 条字符串解密成功。 产物:

  • spine398/strings.tsv —— 全量字符串映射
  • spine398/3.8.99.readable.zip / spine398/spine398.jar —— 字符串已替换成明文的载荷

混淆的动态派发(第二层)

编辑器里几乎所有方法调用都被编译成 invokedynamic,每个类一份引导方法(共 1421 个), 签名统一是 (Lookup, String 名字, MethodType, 密文) -> Object。

做法:按 JVM 的方式把它们逐个调一遍拿回 CallSite, 再顺着绑定句柄里的 MemberName 掏出真实方法名。

结果:53871 处 Editor.Ob("LjyFzpKa", 密文, …) 被还原成真实方法名。 产物:spine398/src-readable/ —— 4174 个 .java 的可读源码树(CFR 反编译)。

注意:源路径必须放大小写敏感的卷上。这套载荷里有 LPt4/lPT4、VP/Vp 这种只差大小写的类名, 放在默认的大小写不敏感卷上会互相覆盖,CFR 也会改名(td_0、og_1 之类就是这么来的)。


三、把面板做成"正式视图"

视图系统的结构(从字节码读出来的)

  • us 是视图枚举:us("meshTools", 4, "Mesh Tools", MV.L, ut.B)。 <clinit> 里建好全部常量 → 组装 $VALUES(字段 B:[Lus;)→ 再按 MV/ut 分成 左右两组列表(Views 菜单/布局就是从这两组来的)。
  • tD.<init> 给每个视图 new 一个 Actor,然后 getstatic us.X:Lus; … putfield us.D:Ldl; 把面板 Actor 写进枚举常量的 D 字段。

走过的弯路(都很值得记下来)

  1. 直接改磁盘上的载荷 → 编辑器自检失败 编辑器启动时把自己 jar 里所有 .class 的 SHA1 和签名清单比对, 一有出入就报 Spine's integrity check failed. → 改成 javaagent 在内存里插桩,磁盘文件一个字节不改。 (这也是为什么"破坏包"这条路走不通。)

  2. 往 tD.<init> 里插一条调用,会把整个类的初始化弄坏 现象:构造过程中抛 IOException: Stream closed(读皮肤贴图的 obt: 流)。 二分结果:

    • 插一条 NOP → 正常;
    • 插一条 System.setProperty(纯 JDK 类,不涉及我们的任何类)→ 崩;
    • 插一条调我们自己方法的 invokestatic → 也崩。 也就是说,和"加载我们的类"无关,只要插入带操作数的指令就坏。 试过用 ClassWriter(reader, 0)(沿用原始字节码)并手动抬高 max_stack —— 仍然坏。 → 结论:放弃钩 tD。这个 14 万字节的混淆类对指令插入极其敏感。
  3. 最终方案:枚举照常加,Actor 从外面填

    • agent 只改 us.<clinit>:造一个新常量 spineView(标题 "Spine View"), 然后在 $VALUES 组装完之后把它追加进数组(必须在分组列表生成之前, 否则 Views 菜单里看不到)。这一步是安全的。
    • 面板 Actor 由我们在编辑器起来之后,从外部反射填进 us.spineView.D,完全不碰 tD。
  4. 视图必须有图标 Skin.getDrawable("icon-tab" + 视图名) 在名字没注册时直接抛异常 (源码 Skin.java:302),会把整个界面带崩。 → 复用已有图标做别名(icon-tabTree),绝不新建图片资源。

  5. Skin 是"按类型分桶"存的 add(name, obj) 用的是 obj.getClass() 当桶,而 getDrawable(name) 是去 Drawable.class 这个桶里查 —— 两者对不上就会"找不到"。 → 用三参数 add(name, obj, Class),并把该 drawable 的所有接口和父类都注册一遍。

  6. 图标还有状态后缀 除 icon-tabSpineView 外,框架还会要 -inactive、-over、-down 等。 → 后缀列表补全,某个后缀没有对应图标就退回基础图标。

  7. 必须抢在"建面板"之前完成填装 框架在建面板时就读 us.spineView.D;晚了就是 null,视图会坏掉 (表现为点 Views 菜单里的 Spine View 没反应 / Tab not found: Spine View)。 → 用 2ms 轮询皮肤,一发现 icon-tabTree 就绪就立刻补别名 + 填 Actor。

  8. 面板内容不要用 addActor + 绝对定位 在停靠框架里这么写会抛 Tab not found: Spine View,把安装打断。 要用 Table 的单元格布局(混淆名 B = add、init = row)。

  9. 皮肤是一点点填的,"有图标"不代表"有按钮样式" 一开始我们一发现 icon-tabTree 就立刻造控件,结果 new TextButton(text, skin) 因为 TextButtonStyle 还没注册而抛 InvocationTargetException。 → 安装改成失败就隔 50ms 重试(最多 200 次),既不会太早、又尽量抢在框架建面板之前。


四、面板本身

  • 内容:一个 TextButton + 一个 Label(都是保名的 libGDX UI 类,方法名被混淆,按签名找)。
  • 按钮点击:ClickListener 这类都被混淆了,直接监听不现实。 改用运行时自校准:Button 里有一对 setChecked(boolean)/isChecked(), 把每个 setter 设成 true、看哪个无参 boolean getter 跟着变 true,就把这对认出来。 libGDX 的 Button 自带 ClickListener,点一下会翻转 isChecked, 所以轮询 isChecked 就等于监听点击。
  • 文字:全部用英文。这套运行时没装中文字体,中文会渲染成一排小方块。

五、跑起来的命令

JX=tools/jdk8u504-b01/Contents/Home/bin/java          # x86_64 JDK8(编辑器要 SharedSecrets)
ASM=tools/asm/asm.jar:tools/asm/asm-tree.jar           # agent 用
PAY="spine-lab/home/Library/Application Support/Spine/updates/3.8.99.orig"

"$JX" \
  -javaagent:tools/agent/view-agent.jar \              # 内存里给 us 加视图常量
  -DSpineJava=1 \                                      # 过运行时校验
  -Duser.home=spine-lab/home \
  -Dspine.payload="$PAY" \
  -Dspine.resources=viewpanel/resources \              # launcher.jar 里抽出来的 /tzax 等资源
  -Dspine.compat=viewpanel/compat-classes \            # Java8 版 launcher.Launcher 替身
  -Dspine.classes=viewpanel/build/classes \
  -cp "viewpanel/build/classes:$ASM" \
  com.spineview.Launcher sample/3.8.99/3899.spine

关键代码位置:

文件 作用
viewpanel/src/com/spineview/Launcher.java 入口:URLClassLoader + EDT + 版本号 + 后置安装
tools/agent/ViewAgent.java 内存插桩:给 us 加 spineView 枚举常量
viewpanel/src/com/spineview/ViewInstaller.java 补图标别名 + 造面板 Actor + 填进 us.spineView.D
viewpanel/src/com/spineview/Scene2dPanel.java 控件构造、按签名找方法、按钮点击链路
viewpanel/src/com/spineview/SceneReader.java 点按钮后读取并显示的内容
tools/decrypt/ 字符串解密 / 派发还原 / 可读载荷生成

六、当前状态与下一步

已完成:

  • 编辑器在我们自己的进程里完整启动(窗口、工程、层级树都正常)。
  • 3.8.99 全量解密 + 可读源码树。
  • "Spine View" 出现在 Views 菜单里,可以像原生面板一样停靠、折叠展开。

下一步:

  1. 面板内容目前是空的(Table 单元格布局在停靠框里的尺寸还要调)。
  2. SceneReader 现在读的是 UI 对象树,要换成真正的骨骼 / 插槽 / 附件数据。
  3. 布局稳定后,再考虑按 4.2.43 复刻(4.x 是加密载荷,这套注入通道不一定通用)。

七、面板内容与布局(本轮补齐)

上面的"下一步 1"已经做完了,而且顺带把两个会让人误判的坑挖了出来。

1. 布局:按钮一行、可滚动文字区一行

面板内容就是一个 Table:第一行放 TextButton(靠左),第二行放 ScrollPane(Label)(撑满)。 关键调用(混淆名出处见 混淆名对照表.md):

目的 调用 载荷里的名字
面板铺满停靠框 Table.setFillParent(true) et.Z(Z)V
加控件 Table.add(Actor) et.D(Ldl;)LdR;
换行 Table.row() et.I()LdR; —— 不调它按钮和文字就并排挤在一行
单元格靠左 Cell.left() dR.C()LdR;
单元格占满剩余空间 Cell.expand() + Cell.fill() dR.StackMapTable() + dR.D()

坑:Table 上还有 et.B(Z)V = setDebug(boolean),名字同样是单字母, 之前误当成 setFillParent 调过 —— 现象是单元格被描上彩色边框,布局没变。

2. 两个"看名字挑方法"踩出来的 bug

症状 真因 修法
点一次按钮被反复触发(日志刷屏) 运行期自校准扫到了继承链上无关的方法,配出一对假的 setChecked/isChecked 用对照表定名 Button.String()=isChecked、I(Z)=setChecked;自校准只作兜底
日志说"读取完成 N 字符",面板文字却一直不刷新 Label 上有 D(CharSequence)V(=setText) 和 D(CharSequence)Z(=textEquals),按"1 个 CharSequence 参数"找会挑中后者 连返回类型一起判:D(CharSequence) 且返回 void

3. 面板文字全部用英文

编辑器这套运行时只装了 NotoSans 拉丁字形,中文会渲染成一排小方块(tofu)。 所以 SceneReader 的输出改成英文(=== Scene actors === / === Objects reachable from Editor === 等)。

4. 现在的状态

点按钮 → SceneReader 读一遍 → 文字区刷新(可滚动查看)。截图确认:按钮靠左上, 文字区撑满并出现滚动条,内容全是英文。

还差的:SceneReader 读的还是 UI 对象树 / 编辑器可达对象,要换成真正的 骨骼 / 插槽 / 附件(这才是"给对象配粒子发射行为"的输入)。