- 新工具 tools/decrypt/DumpSites.java:逐 indy 站点解出『类.方法 + 描述符』(带描述符, 解决 rewrite_source.py 丢描述符导致的重载混淆);ResolveSites.java 走运行时 CallSite 交叉验证。 - 结论改正:KP = libGDX SelectBox,KP.B(String)V = setSelected;XW/acW/acK/Yr 是 『属性面板控件』而不是场景数据;场景对象是树节点上的 GP/Gp/GU/GO。 - 补齐:当前选择怎么设(VZ.D(Object))、谁刷新界面(VZ.D(GP,ZZ) / rW.B / fC.C)、 撤销栈(载荷类 un 的 D()/B() 成对包一层)、删除边界(root 的删除按钮本来就是 disabled)、 查/选/改名/删除/导出的真实实现与出处。 - 新增运行时探针 -Dspine.probe=objects(SceneObjects.java),实测打印整棵场景对象树。 - 文档:编辑器交互与数据API.md 重写;混淆名对照表.md 增补类/方法对照与大小写同名类坑。
270 lines
12 KiB
Java
270 lines
12 KiB
Java
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 {
|
||
// 诊断:这套运行时自己退出时不留崩溃报告,所以把"谁在什么时候抛了什么"记下来。
|
||
// Runtime.halt 不会走关闭钩子,System.exit / 信号会 —— 靠这个区分。
|
||
Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
|
||
@Override
|
||
public void uncaughtException(Thread thread, Throwable error) {
|
||
System.err.println("[spineview] 未捕获异常 线程=" + thread.getName()
|
||
+ " 时间=" + new java.util.Date());
|
||
error.printStackTrace(System.err);
|
||
}
|
||
});
|
||
Runtime.getRuntime().addShutdownHook(new Thread("spineview-shutdown") {
|
||
@Override
|
||
public void run() {
|
||
System.err.println("[spineview] 开始关闭(System.exit 或信号触发的钩子)时间="
|
||
+ new java.util.Date());
|
||
}
|
||
});
|
||
|
||
// 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 填好。
|
||
final ClassLoader commandLoader = viewLoader;
|
||
for (int i = 0; i < 200; i++) {
|
||
if (ViewInstaller.installForLoader(viewLoader)) {
|
||
// 验证用:-Dspine.probe=objects 会在界面稳定后把"树里的场景对象"整份打到日志里。
|
||
final String probe = System.getProperty("spine.probe");
|
||
if (probe != null) {
|
||
Thread prober = new Thread(new Runnable() {
|
||
@Override
|
||
public void run() {
|
||
try {
|
||
Thread.sleep(6000);
|
||
} catch (InterruptedException ignored) {
|
||
return;
|
||
}
|
||
try {
|
||
System.err.println("[probe] ==== " + probe + " ====");
|
||
if ("objects".equals(probe)) {
|
||
System.err.println(SceneObjects.describe(viewLoader));
|
||
} else {
|
||
System.err.println("[probe] 未知探针: " + probe);
|
||
}
|
||
} catch (Throwable error) {
|
||
System.err.println("[probe] 失败: " + error);
|
||
error.printStackTrace(System.err);
|
||
}
|
||
}
|
||
}, "spineview-probe");
|
||
prober.setDaemon(true);
|
||
prober.start();
|
||
}
|
||
// 验证用:-Dspine.runCommand="New Bone" 会在界面稳定后执行这条编辑器命令
|
||
// (名字取自 hotkeys.txt / 菜单项,链路见 Commands)。
|
||
final String command = System.getProperty("spine.runCommand");
|
||
if (command != null) {
|
||
Thread runner = new Thread(new Runnable() {
|
||
@Override
|
||
public void run() {
|
||
try {
|
||
Thread.sleep(6000);
|
||
} catch (InterruptedException ignored) {
|
||
return;
|
||
}
|
||
Commands.run(commandLoader, command);
|
||
}
|
||
}, "spineview-command");
|
||
runner.setDaemon(true);
|
||
runner.start();
|
||
}
|
||
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) {
|
||
// 跳过
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|