Files
Spine_lab/viewpanel/src/com/spineview/Launcher.java
T
tianmo 7fbb042aa6 编辑器交互/数据 API 调研完成:解出 KP=SelectBox、选中链路、增删改真实落点与撤销栈
- 新工具 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 增补类/方法对照与大小写同名类坑。
2026-10-16 14:38:25 +08:00

270 lines
12 KiB
Java
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.
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) {
// 跳过
}
}
}
}
}