阶段四: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:
commit
2e7ab7c33c
113 files changed
+25327
No files matched your search
@@ -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) {
|
||||
// 跳过
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user