# 阶段四:在 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)`。 `` 里建好全部常量 → 组装 `$VALUES`(字段 `B:[Lus;`)→ 再按 `MV`/`ut` 分成 左右两组列表(Views 菜单/布局就是从这两组来的)。 - `tD.` 给每个视图 new 一个 Actor,然后 `getstatic us.X:Lus; … putfield us.D:Ldl;` 把面板 Actor 写进枚举常量的 `D` 字段。 ### 走过的弯路(都很值得记下来) 1. **直接改磁盘上的载荷 → 编辑器自检失败** 编辑器启动时把自己 jar 里所有 `.class` 的 SHA1 和签名清单比对, 一有出入就报 `Spine's integrity check failed.` → 改成 **javaagent 在内存里插桩**,磁盘文件一个字节不改。 (这也是为什么"破坏包"这条路走不通。) 2. **往 `tD.` 里插一条调用,会把整个类的初始化弄坏** 现象:构造过程中抛 `IOException: Stream closed`(读皮肤贴图的 `obt:` 流)。 二分结果: - 插一条 **NOP** → 正常; - 插一条 **`System.setProperty`**(纯 JDK 类,不涉及我们的任何类)→ 崩; - 插一条调我们自己方法的 `invokestatic` → 也崩。 也就是说,和"加载我们的类"无关,**只要插入带操作数的指令就坏**。 试过用 `ClassWriter(reader, 0)`(沿用原始字节码)并手动抬高 `max_stack` —— 仍然坏。 → **结论:放弃钩 `tD`**。这个 14 万字节的混淆类对指令插入极其敏感。 3. **最终方案:枚举照常加,Actor 从外面填** - agent 只改 `us.`:造一个新常量 `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 对象树 / 编辑器可达对象**,要换成真正的 **骨骼 / 插槽 / 附件**(这才是"给对象配粒子发射行为"的输入)。