package com.spineview; import java.io.File; import java.net.URL; import java.net.URLClassLoader; /** * 3.8.99 专用入口:先启动探针,再把控制权交给编辑器。 * * 之所以能这么做,是因为 3.8.99 的载荷是明文的 —— 我们可以把它当作普通 classpath 用, * 自己当入口类,完全不需要改任何字节码、也不需要注入。 * * 用法: * /bin/java -cp <本目录 class> \ * -Dspine.payload=<载荷 zip> -Dspine.classes=<本目录 class> \ * com.spineview.Launcher * * 两个关键点(都是从反编译源码里读出来的,不是猜的): * 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 urls = new java.util.ArrayList(); 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 versionFields = new java.util.ArrayList(); 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. 里 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 seen = java.util.Collections.newSetFromMap( new java.util.IdentityHashMap()); java.util.ArrayDeque queue = new java.util.ArrayDeque(); java.util.ArrayDeque depths = new java.util.ArrayDeque(); 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) { // 跳过 } } } } }