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

目标:在 Spine 编辑器窗口内部加一个自己的视图面板(按钮 + 文字显示),
点击读取场景对象,并像原生面板一样出现在 Views 菜单、可停靠可折叠。

本轮打通:
1. 编辑器在我们自己的进程里完整启动
   - 类加载器必须是 URLClassLoader 本身(自检要求)
   - Editor.main 必须在 AWT EDT 上调用
   - jvm.cfg 第 21 行要以两个空格结尾 + -DSpineJava
   - Editor.D:I 版本号、fonts/、/tzax 资源、Java8 版 launcher.Launcher 替身
2. 3.8.99 全量解密 → 可读源码
   - 字符串 17251/17461(伪造同名调用帧对齐 key)
   - 混淆动态派发还原 53871 处(调引导方法取 MemberName)
3. 自建视图
   - agent 在内存里给 us 枚举加 spineView 常量(不改磁盘,避开完整性自检)
   - 面板 Actor 由外部反射填进 us.spineView.D
   - 复用已有图标做别名(不新建图片资源)
   最终 "Spine View" 出现在 Views 菜单,可与 Tree 并排停靠、折叠展开

另外:
- ViewPatcher/ViewAgent:视图枚举与面板挂载的字节码插桩
- DecAll/ObMap/rewrite_source:字符串解密、派发还原、可读源码生成
- 阶段四-视图面板挂载.md:完整记录做法与踩过的坑

不纳入版本管理:rea/(独立仓库)、tools 下的 JDK/Ghidra 工具链、
Spine.app 与 spine-lab 副本、从载荷派生的批量产物。
This commit is contained in:
tianmo committed 2026-10-16 13:34:18 +08:00
commit 2e7ab7c33c
113 files changed
+25327

No files matched your search

+203
View File
@@ -0,0 +1,203 @@
package com.spineview;
import java.io.File;
import java.net.URL;
import java.net.URLClassLoader;
/**
* 3.8.99 专用入口:先启动探针,再把控制权交给编辑器。
*
* 之所以能这么做,是因为 3.8.99 的载荷是明文的 —— 我们可以把它当作普通 classpath 用,
* 自己当入口类,完全不需要改任何字节码、也不需要注入。
*
* 用法:
* <x86_64 JDK>/bin/java -cp <本目录 class> \
* -Dspine.payload=<载荷 zip> -Dspine.classes=<本目录 class> \
* com.spineview.Launcher <project.spine>
*
* 两个关键点(都是从反编译源码里读出来的,不是猜的):
* 1) Editor.main 开头有 `if (!EventQueue.isDispatchThread()) return;`,必须在 AWT 事件线程上调用。
* 2) 编辑器自检里有 `if (getClassLoader(Editor.class).getClass() != URLClassLoader.class) 报错退出;`
* —— 所以不能用 -cp(那是 AppClassLoader),必须自己 new 一个 URLClassLoader。
*/
public final class Launcher {
public static void main(String[] args) throws Exception {
// 1) 先把探针放起来(它自己会开后台线程,不阻塞)
try {
SceneScan.start();
} catch (Throwable error) {
System.err.println("[spineview] 启动探针失败: " + error);
}
// 2) 再把控制权交给真正的编辑器。
// Editor.main 里有一句 `if (!EventQueue.isDispatchThread()) return;`,
// 所以必须在 AWT 事件分发线程上调用它,否则它开门就返回、什么窗口都不建。
String payloadPath = System.getProperty("spine.payload");
if (payloadPath == null) throw new IllegalStateException("缺少 -Dspine.payload=<载荷 zip>");
URL payload = new File(payloadPath).toURI().toURL();
String classesPath = System.getProperty("spine.classes");
// 编辑器会去 /tzax/*.png 找皮肤贴图,而它在启动器的 launcher.jar 里,载荷里没有。
// 注意:整个 launcher.jar 不能上类路径 —— 它里面的类是 Java 10 编译的(class 54),
// JDK8 加载会抛 UnsupportedClassVersionError,把 Prefs 初始化打断、编辑器根本建不出窗口。
// 所以只用从它里面抽出来的**资源目录**。
String resources = System.getProperty("spine.resources");
String compat = System.getProperty("spine.compat");
String launcherJar = System.getProperty("spine.launcher.jar");
java.util.List<URL> urls = new java.util.ArrayList<URL>();
urls.add(payload);
if (classesPath != null) urls.add(new File(classesPath).toURI().toURL());
if (compat != null) urls.add(new File(compat).toURI().toURL());
if (resources != null) urls.add(new File(resources).toURI().toURL());
if (launcherJar != null) urls.add(new File(launcherJar).toURI().toURL());
URLClassLoader editorLoader = new URLClassLoader(urls.toArray(new URL[0]), null);
System.err.println("[spineview] 编辑器类加载器 = " + editorLoader.getClass().getName());
System.err.println("[spineview] 皮肤资源 /tzax/down-ur.png = "
+ editorLoader.findResource("tzax/down-ur.png"));
final Class<?> editor = Class.forName("com.esotericsoftware.spine.editor.Editor", true, editorLoader);
// Editor.D:I 是"启动器版本号",反编译源码里 `Editor.D(kM)` 一开头就是:
// if (D < 19 && !options.D) { 弹"安装已过时"对话框; return; }
// 官方是原生库/启动器把这个字段填上的,我们自己跑就得补一下,否则永远进不到编辑器。
java.util.List<java.lang.reflect.Field> versionFields =
new java.util.ArrayList<java.lang.reflect.Field>();
for (java.lang.reflect.Field field : editor.getDeclaredFields()) {
java.lang.reflect.Modifier.toString(field.getModifiers());
if (field.getType() != int.class) continue;
if (!java.lang.reflect.Modifier.isStatic(field.getModifiers())) continue;
field.setAccessible(true);
System.err.println("[spineview] Editor 静态 int 字段: " + field.getName()
+ " = " + field.getInt(null));
if (field.getName().equals("D") || field.getName().startsWith("launcher")) {
field.setInt(null, 100);
versionFields.add(field);
}
}
System.err.println("[spineview] 已把 " + versionFields.size() + " 个 Editor.D:I 设为 100");
final String[] arguments = args;
javax.swing.SwingUtilities.invokeAndWait(new Runnable() {
@Override
public void run() {
try {
editor.getMethod("main", String[].class).invoke(null, (Object) arguments);
} catch (Throwable error) {
error.printStackTrace(System.err);
}
}
});
for (java.lang.reflect.Field field : versionFields) {
System.err.println("[spineview] main 返回后 Editor.D:I = " + field.getInt(null));
}
// 视图已经由 view-agent 在内存里注册成"Spine View"(Views 菜单里能勾选/展开),
// 这里不再额外往场景上贴悬浮控件,避免和正式视图重复。
// 等编辑器界面搭好之后,把面板 Actor 填进 us.spineView.D(视图常量已经注册好了)。
final ClassLoader viewLoader = editorLoader;
Thread viewThread = new Thread(new Runnable() {
@Override
public void run() {
// 皮肤一就绪就立刻补图标别名 + 把面板 Actor 填进 us.spineView.D。
// 必须在编辑器"建面板"那一步之前完成,否则框架拿到的 Actor 是 null,
// 点击 Views 菜单里的 Spine View 就不响应。
if (!ViewInstaller.aliasIcon(viewLoader)) {
System.err.println("[view] 没能抢到皮肤时间窗");
return;
}
// 皮肤是一点点填的:图标有了不代表按钮样式也有了,所以失败就隔一会儿重试,
// 但要尽快成功——框架"建面板"那一步之前必须把 Actor 填好。
for (int i = 0; i < 200; i++) {
if (ViewInstaller.installForLoader(viewLoader)) return;
try {
Thread.sleep(50);
} catch (InterruptedException ignored) {
return;
}
}
System.err.println("[view] 重试 200 次仍未装上");
}
}, "spineview-install-view");
viewThread.setDaemon(true);
viewThread.start();
// Editor.main 可能建完窗口就返回;主线程得挂住,否则 JVM 会因为没有非守护线程直接退出。
new java.util.concurrent.CountDownLatch(1).await();
}
/** OG.D 就是编辑器单例(Editor.<clinit> 里 new Editor() 挂上去的)。 */
private static Object findEditorInstance(ClassLoader loader) throws Exception {
Class<?> holder = Class.forName("OG", true, loader);
for (java.lang.reflect.Field field : holder.getDeclaredFields()) {
if (!field.getName().equals("D")) continue;
if (!java.lang.reflect.Modifier.isStatic(field.getModifiers())) continue;
field.setAccessible(true);
Object value = field.get(null);
if (value != null && value.getClass().getName()
.equals("com.esotericsoftware.spine.editor.Editor")) {
return value;
}
}
return null;
}
private static void dump(String label, Object target, int depth) {
if (target == null || depth > 1) return;
System.err.println("[probe] " + label + " : " + target.getClass().getName());
for (java.lang.reflect.Field field : target.getClass().getDeclaredFields()) {
try {
field.setAccessible(true);
Object value = field.get(target);
String text = value == null ? "null" : value.getClass().getName();
if (value instanceof Number || value instanceof Boolean || value instanceof String) {
text = String.valueOf(value);
}
System.err.println("[probe] " + field.getType().getSimpleName() + " "
+ field.getName() + " = " + text);
} catch (Throwable error) {
// 忽略
}
}
}
/** 有界广度遍历,把编辑器实例能摸到的"自家对象"列出来,用来看谁是场景数据。 */
private static void walk(Object root) {
java.util.Set<Object> seen = java.util.Collections.newSetFromMap(
new java.util.IdentityHashMap<Object, Boolean>());
java.util.ArrayDeque<Object> queue = new java.util.ArrayDeque<Object>();
java.util.ArrayDeque<Integer> depths = new java.util.ArrayDeque<Integer>();
queue.add(root);
depths.add(Integer.valueOf(0));
int printed = 0;
while (!queue.isEmpty() && printed < 400) {
Object current = queue.poll();
int depth = depths.poll().intValue();
if (current == null || !seen.add(current)) continue;
if (current.getClass().isArray()) continue;
String name = current.getClass().getName();
if (name.startsWith("java.") || name.startsWith("javax.") || name.startsWith("sun.")
|| name.startsWith("org.lwjgl") || name.startsWith("com.badlogic")) continue;
java.lang.reflect.Field[] fields = current.getClass().getDeclaredFields();
StringBuilder shape = new StringBuilder();
for (java.lang.reflect.Field field : fields) {
shape.append(field.getType().getSimpleName()).append(' ');
}
System.err.println("[walk] " + depth + " " + name + " 字段数=" + fields.length
+ " 类型集合=" + shape);
printed++;
if (depth >= 2) continue;
for (java.lang.reflect.Field field : fields) {
if (java.lang.reflect.Modifier.isStatic(field.getModifiers())) continue;
try {
field.setAccessible(true);
Object value = field.get(current);
if (value == null) continue;
queue.add(value);
depths.add(Integer.valueOf(depth + 1));
} catch (Throwable ignored) {
// 跳过
}
}
}
}
}