Files
Spine_lab/阶段四-视图面板挂载.md
T
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

270 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 阶段四:在 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 事件线程上调用
源码里有一句:
```java
if (java.awt.EventQueue.isDispatchThread() == false) { return; }
```
在 main 线程调它,它开门就返回、一个窗口都不建。
→ 用 `SwingUtilities.invokeAndWait(...)` 包一层。
### 3. 编辑器要求自己的类加载器**正好**是 `URLClassLoader`
自检里有:
```java
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)` 一开头:
```java
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:
```java
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` 就等于监听点击**。
- 文字:全部用**英文**。这套运行时没装中文字体,中文会渲染成一排小方块。
---
## 五、跑起来的命令
```bash
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`](混淆名对照表.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 对象树 / 编辑器可达对象**,要换成真正的
**骨骼 / 插槽 / 附件**(这才是"给对象配粒子发射行为"的输入)。