From a0080428f9d915f9a384cb96ea28160314c99952 Mon Sep 17 00:00:00 2001 From: tianmo Date: Fri, 16 Oct 2026 14:20:45 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9C=BA=E6=99=AF=E5=AF=B9=E8=B1=A1=E5=A2=9E?= =?UTF-8?q?=E5=88=A0=E6=94=B9=E6=9F=A5=E8=B0=83=E7=A0=94=EF=BC=9A=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E7=B3=BB=E7=BB=9F=E5=85=A5=E5=8F=A3=20+=20=E6=96=B0?= =?UTF-8?q?=E5=BB=BA=E5=AF=B9=E8=B1=A1=E7=9A=84=E7=9C=9F=E5=AE=9E=E8=90=BD?= =?UTF-8?q?=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Commands.java:Editor -> pE(命令系统) -> void D(String) 按名字执行命令 - Launcher:新增 -Dspine.runCommand="<命令名>" 便于验证 - SceneReader:Tree/选中项探针(actor 树按 SnapshotArray 结构遍历) - 混淆名对照表.md:补命令系统一节、可执行命令名单、skeleton/root 删除边界、 以及 New X 最终落到静态 KP.B(target, 类型名) 的结论与待办 --- viewpanel/src/com/spineview/Commands.java | 88 ++++++++ viewpanel/src/com/spineview/Launcher.java | 23 ++- viewpanel/src/com/spineview/SceneReader.java | 206 ++++++++++++++++++- 混淆名对照表.md | 84 ++++++++ 4 files changed, 394 insertions(+), 7 deletions(-) create mode 100644 viewpanel/src/com/spineview/Commands.java diff --git a/viewpanel/src/com/spineview/Commands.java b/viewpanel/src/com/spineview/Commands.java new file mode 100644 index 0000000..1764f16 --- /dev/null +++ b/viewpanel/src/com/spineview/Commands.java @@ -0,0 +1,88 @@ +package com.spineview; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; + +/** + * 用**编辑器自己的命令系统**执行菜单动作 —— 增删改查都从这里走。 + * + * 链路(都是从反编译源码里读出来的,见 `混淆名对照表.md`): + * ``` + * com.esotericsoftware.spine.editor.Editor (OG.D 里的单例) + * └─ public pE D; ← 命令 / 热键系统(Editor. 里 new) + * ├─ public final void D(String) ← 按名字执行(名字就是 hotkeys.txt 里的名字) + * └─ public final pC D(String) ← 按名字取菜单项(不执行) + * ``` + * 可用的名字直接看 `~/Library/Application Support/Spine/hotkeys.txt`,例如: + * `New Bone` / `New Slot` / `New Bounding Box` / `New Path` / `New Clipping` / + * `New IK Constraint` / `New Animation` / `New Event` / `New Folder` / + * `Delete` / `Rename` / `Duplicate` / `Cut` / `Copy` / `Paste` / `Set Parent` / + * `Draw Order Up` / `Draw Order Down` / `Visibility` / `Expand` / `Collapse` … + * + * 注意:这些动作都作用在**编辑器当前的选择**上(Delete/Rename/Duplicate 等), + * 所以后续要先能设置选择(Tree 的选中项),才谈得上"对着哪个对象增删改"。 + */ +public final class Commands { + + /** 命令系统的载荷类名(CFR 把它重命名成 pe_0,真实类名是 pE)。 */ + private static final String COMMANDS = "pE"; + + private Commands() { + } + + /** 从编辑器单例上取命令系统对象(Editor 的实例字段)。 */ + public static Object find(ClassLoader loader) throws Exception { + Object editor = SceneReader.findEditor(loader); + if (editor == null) return null; + for (Class type = editor.getClass(); type != null && type != Object.class; + type = type.getSuperclass()) { + for (Field field : type.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) continue; + if (!field.getType().getName().equals(COMMANDS)) continue; + field.setAccessible(true); + Object value = field.get(editor); + if (value != null) return value; + } + } + return null; + } + + /** + * 执行一条命令(名字取自 hotkeys.txt / 菜单项)。 + * 返回 false 表示没找到命令或执行抛了异常(异常已经打进日志)。 + */ + public static boolean run(ClassLoader loader, String name) { + try { + Object commands = find(loader); + if (commands == null) { + System.err.println("[cmd] 没找到命令系统(未拿到 Editor 实例?)"); + return false; + } + // pE 上有一个参数的 String 方法有四个(返回 pC / void / ArrayList / String), + // 只有返回 void 的那个是"执行"。 + Method execute = null; + for (Method method : commands.getClass().getMethods()) { + if (!method.getName().equals("D")) continue; + if (method.getParameterTypes().length != 1) continue; + if (method.getParameterTypes()[0] != String.class) continue; + if (method.getReturnType() != void.class) continue; + execute = method; + break; + } + if (execute == null) { + System.err.println("[cmd] 命令系统上没找到 void D(String)"); + return false; + } + System.err.println("[cmd] 执行命令: " + name); + execute.setAccessible(true); + execute.invoke(commands, name); + System.err.println("[cmd] 命令完成: " + name); + return true; + } catch (Throwable error) { + System.err.println("[cmd] 命令失败: " + name + " -> " + error); + error.printStackTrace(System.err); + return false; + } + } +} diff --git a/viewpanel/src/com/spineview/Launcher.java b/viewpanel/src/com/spineview/Launcher.java index 7009460..f4ad855 100644 --- a/viewpanel/src/com/spineview/Launcher.java +++ b/viewpanel/src/com/spineview/Launcher.java @@ -125,8 +125,29 @@ public final class Launcher { } // 皮肤是一点点填的:图标有了不代表按钮样式也有了,所以失败就隔一会儿重试, // 但要尽快成功——框架"建面板"那一步之前必须把 Actor 填好。 + final ClassLoader commandLoader = viewLoader; for (int i = 0; i < 200; i++) { - if (ViewInstaller.installForLoader(viewLoader)) return; + if (ViewInstaller.installForLoader(viewLoader)) { + // 验证用:-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) { diff --git a/viewpanel/src/com/spineview/SceneReader.java b/viewpanel/src/com/spineview/SceneReader.java index cea20bb..bc7732c 100644 --- a/viewpanel/src/com/spineview/SceneReader.java +++ b/viewpanel/src/com/spineview/SceneReader.java @@ -22,6 +22,10 @@ public final class SceneReader { public static String read(ClassLoader editorLoader, Object stage) { StringBuilder out = new StringBuilder(); + // 找"当前选中项"的专用探针(-Dspine.probeTree=1 时只输出它,便于只看这一段)。 + if (System.getProperty("spine.probeTree") != null) { + return probeTrees(stage); + } // 面板里的文字全部用英文:编辑器这套运行时只装了 NotoSans 拉丁字形, // 中文会渲染成一排小方块(tofu)。 out.append("=== Scene actors ===\n"); @@ -54,6 +58,129 @@ public final class SceneReader { return out.toString(); } + /** + * 探针:在 actor 树里找 `Tree` 控件(这个类名是保名的),把它的节点结构有界地 dump 出来。 + * + * 目的:编辑器的层级树里挂着"骨骼 / 插槽 / 附件"的对象,找到它就能拿到 + * **当前选中的那个对象**,这是面板要用的真正输入。 + * 只做有界遍历(深度 + 元素个数都限死)—— 这套运行时对大范围反射很敏感,扫猛了会崩。 + */ + static String probeTrees(Object stage) { + StringBuilder out = new StringBuilder(); + out.append("=== Tree widgets (selection probe) ===\n"); + int[] budget = new int[] {3}; + int[] visited = new int[] {0}; + try { + Object root = firstFieldOfType(stage, "do"); + out.append("stage=").append(stage.getClass().getName()) + .append(" root=").append(root == null ? "null" : root.getClass().getName()).append('\n'); + findTrees(root, out, 0, budget, visited); + out.append("visited actors=").append(visited[0]).append('\n'); + } catch (Throwable error) { + out.append("probe failed: ").append(error).append('\n'); + } + System.err.println(out); + return out.toString(); + } + + /** Stage 的 root 是 Group(载荷里 do),它才是 UI 树真正的根。 */ + private static Object firstFieldOfType(Object target, String typeName) { + for (Class type = target.getClass(); type != null && type != Object.class; + type = type.getSuperclass()) { + for (Field field : type.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) continue; + if (!field.getType().getName().equals(typeName)) continue; + try { + field.setAccessible(true); + Object value = field.get(target); + if (value != null) return value; + } catch (Throwable ignored) { + // 跳过 + } + } + } + return null; + } + + private static void findTrees(Object actor, StringBuilder out, int depth, int[] budget, + int[] visited) { + if (actor == null || depth > 20 || budget[0] <= 0 || visited[0] > 800) return; + visited[0]++; + if (visited[0] <= 120) { + out.append(indent(depth)).append(actor.getClass().getName()).append('\n'); + } + if (actor.getClass().getName().startsWith("com.badlogic.gdx.scenes.scene2d.ui.Tree")) { + budget[0]--; + out.append("--- ").append(actor.getClass().getName()).append(" ---\n"); + dumpFields(actor, out, 1, 3, 12); + } + Object children = children(actor); + if (children instanceof Object[]) { + for (Object child : (Object[]) children) { + findTrees(child, out, depth + 1, budget, visited); + } + } + } + + /** 列出对象的字段:标量直接给值,数组/集合只列前几个元素的类名,最多往下 maxDepth 层。 */ + private static void dumpFields(Object target, StringBuilder out, int depth, int maxDepth, + int maxItems) { + if (target == null || depth > maxDepth) return; + for (Class type = target.getClass(); type != null && type != Object.class; + type = type.getSuperclass()) { + for (Field field : type.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) continue; + Object value; + try { + field.setAccessible(true); + value = field.get(target); + } catch (Throwable error) { + continue; + } + out.append(indent(depth)).append(field.getName()).append(':') + .append(field.getType().getSimpleName()).append(" = "); + if (value == null) { + out.append("null\n"); + } else if (value instanceof Number || value instanceof Boolean + || value instanceof CharSequence) { + out.append(value).append('\n'); + } else if (value.getClass().isArray() || arrayItems(value) != null) { + Object[] items = arrayItems(value); + out.append(value.getClass().getSimpleName()).append(" size=").append(items.length) + .append(" ["); + for (int i = 0; i < Math.min(items.length, maxItems); i++) { + Object item = items[i]; + out.append(i > 0 ? ", " : "") + .append(item == null ? "null" : item.getClass().getSimpleName()); + } + out.append("]\n"); + // 把前几个元素也展开一层:Tree 的节点对象就在里面。 + for (int i = 0; i < Math.min(items.length, 2); i++) { + Object item = items[i]; + if (item == null) continue; + String name = item.getClass().getName(); + if (name.startsWith("com.esotericsoftware") || !name.contains(".")) { + out.append(indent(depth + 1)).append("[").append(i).append("] ") + .append(name).append('\n'); + dumpFields(item, out, depth + 2, maxDepth, maxItems); + } + } + } else { + out.append(value.getClass().getName()).append('\n'); + } + if (depth < maxDepth && value != null && !value.getClass().isArray() + && !(Number.class.isAssignableFrom(value.getClass())) + && !(value instanceof Boolean) && !(value instanceof CharSequence)) { + String name = value.getClass().getName(); + // 只往"编辑器 / spine 自己的类"里钻,JDK 和 libGDX 内部对象就停住。 + if (name.startsWith("com.esotericsoftware") || !name.contains(".")) { + dumpFields(value, out, depth + 1, maxDepth, maxItems); + } + } + } + } + } + /** 顺着 Stage 的 actor 树把对象列出来(scene2d 里的一切都是 Actor)。 */ private static int listActors(Object actor, StringBuilder out, int depth, int count) { if (actor == null || depth > 3 || count > 80) return count; @@ -70,23 +197,90 @@ public final class SceneReader { return count; } + /** + * 取 Actor 的子节点。 + * + * libGDX 的 `Group` 用 `SnapshotArray`(载荷里叫 `hq`)装子节点,里面的 `Array` + * 又是"`Object[] items` + `int size`"两个字段 —— 而字段名被混淆成一堆同名, + * 所以这里**按结构找**:先找 `Object[]` 字段,再配一个 int 当 size。 + */ private static Object children(Object actor) { - for (Class type = actor.getClass(); type != null; type = type.getSuperclass()) { + for (Class type = actor.getClass(); type != null && type != Object.class; + type = type.getSuperclass()) { for (Field field : type.getDeclaredFields()) { - String name = field.getType().getName(); - if (!name.startsWith("[L") || !name.endsWith("dl;")) continue; + if (Modifier.isStatic(field.getModifiers())) continue; + Object value; try { field.setAccessible(true); - Object value = field.get(actor); - if (value instanceof Object[]) return value; + value = field.get(actor); } catch (Throwable ignored) { - // 跳过 + continue; } + Object[] items = arrayItems(value); + if (items != null && holdsActors(items)) return items; } + // 只看 Group(do)这一层为止:再往上到 Actor(dl)会撞上 listeners 数组(也是 Object[]), + // 那个不是子节点。 + if (type.getName().equals("do")) break; } return null; } + /** 子节点数组里装的应该是 Actor(载荷名 dl);不满足就说明这不是 children。 */ + private static boolean holdsActors(Object[] items) { + for (Object item : items) { + if (isActor(item)) return true; + } + return items.length == 0; + } + + private static boolean isActor(Object value) { + if (value == null) return false; + for (Class type = value.getClass(); type != null && type != Object.class; + type = type.getSuperclass()) { + if (type.getName().equals("dl")) return true; + } + return false; + } + + /** 把 `Object[]` 或"装着 Object[] 的容器"(Array/SnapshotArray)还原成元素数组。 */ + private static Object[] arrayItems(Object value) { + if (value instanceof Object[]) return (Object[]) value; + if (value == null || value.getClass().isArray()) return null; + // libGDX 的 Array(载荷名 fI)才是"真正的表":`Object[] items` + `int size`。 + // SnapshotArray(hq)继承它,自己另外还有 snapshot/recycled 两个数组和 snapshots 计数 + // —— 所以必须只认 fI 这一层的字段,否则会拿错数组、size 也拿成 snapshots。 + Class arrayClass = null; + for (Class type = value.getClass(); type != null && type != Object.class; + type = type.getSuperclass()) { + if (type.getName().equals("fI")) { + arrayClass = type; + break; + } + } + if (arrayClass == null) return null; + Object[] items = null; + int size = -1; + for (Field field : arrayClass.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) continue; + try { + field.setAccessible(true); + if (field.getType() == Object[].class) { + items = (Object[]) field.get(value); + } else if (field.getType() == int.class) { + size = field.getInt(value); + } + } catch (Throwable ignored) { + // 跳过 + } + } + if (items == null || size < 0) return null; + int count = size >= 0 && size <= items.length ? size : items.length; + Object[] out = new Object[count]; + System.arraycopy(items, 0, out, 0, count); + return out; + } + private static void collectComponents(java.awt.Container container, StringBuilder out, int depth) { if (container == null || depth > 3) return; for (java.awt.Component child : container.getComponents()) { diff --git a/混淆名对照表.md b/混淆名对照表.md index 227ad7d..4906632 100644 --- a/混淆名对照表.md +++ b/混淆名对照表.md @@ -175,6 +175,87 @@ JVM 允许同一个类里存在"**名字相同、描述符不同**"的字段和 --- +## 四之二、命令系统(增删改查的入口) + +编辑器的所有菜单动作都走同一套命令系统。链路(反编译源码里读出来的): + +``` +OG.D → com.esotericsoftware.spine.editor.Editor 单例 + └─ public pE D; → 命令 / 热键系统(Editor. 里 new pe_0()) + ├─ void D(String) → 按名字执行(名字 = hotkeys.txt / 菜单项名) + ├─ pC D(String) → 按名字取菜单项(不执行) + └─ private final ll D; → 菜单注册表:D(String,pC,String...) 注册、D(String) 查表 +``` + +菜单项基类 `pC`(所有动作类都继承它,例如 `mn_1`=New Bone、`pb_0`=Delete): + +| 成员 | 含义 | +| --- | --- | +| `boolean D()` | **执行并返回"是否处理了"**(`pC` 基类实现 = `og_1.D.S.a_(); return true;`) | +| `void D()` | 执行(无返回值);基类里是空实现,动作类各自覆盖 | +| `String D` | 名字(`toString()` 返回它) | + +> 注意:`pE.D(String)` 只看"找得到找不到",**不看动作有没有真的生效**。 +> 实测 `-Dspine.runCommand="New Bone"` 打印"执行完成"但骨骼树没变 —— 因为 +> `mn_1.D()` 的源码是: +> ```java +> et_0 sel = og_1.D.D; // 当前选择 +> if (sel instanceof xw_2) { KP.B(...D, "Bone"); return true; } +> if (sel instanceof acK) { KP.B(...D, "Bone"); return true; } +> return false; // 没选中 → 什么都不做,也不报错 +> ``` +> 也就是说 **这些动作全都作用在"当前选择"上**,要先能设置选择,才谈得上增删改。 + +可用的命令名(= `hotkeys.txt` 里的名字,`~/Library/Application Support/Spine/hotkeys.txt`): + +| 类别 | 名字 | +| --- | --- | +| 删除/改名 | `Delete`、`Rename`、`Duplicate`、`Cut`、`Copy`、`Paste` | +| 新建 | `New Bone`、`New Slot`、`New Skin Placeholder`、`New Bounding Box`、`New Path`、`New Clipping`、`New IK Constraint`、`New Path Constraint`、`New Transform Constraint`、`New Skin`、`New Animation`、`New Event`、`New Folder`、`New Skeleton` | +| 层级/顺序 | `Set Parent`、`Parent`、`Child`、`Previous Sibling`、`Next Sibling`、`Previous Sibling Bone`、`Next Sibling Bone`、`Draw Order Up`、`Draw Order Down`、`Tree Up`、`Tree Down` | +| 显示 | `Visibility`、`Show All Bones/Slots`、`Expand`、`Collapse`、`Expand/Collapse`、`Tree Filter`、`Search` | +| 选择类 | `Select - Bones`、`Select - Child Bones`、`Select - Descendant Bones`、`Select - Attachments`、`Select - Visible Attachments`、`Select - Draw Order` | +| 其它 | `Undo`、`Redo`、`Rulers`、`Zoom To Fit`、`Setup/Animate Mode`、`Color`、`Replace` | + +**面板里的接口**:[`viewpanel/src/com/spineview/Commands.java`](viewpanel/src/com/spineview/Commands.java) +(`Commands.run(loader, "New Bone")`),验证入口是启动参数 `-Dspine.runCommand="<命令名>"`。 + +### 真正"建出一个能显示的骨骼"落在哪 + +用户在编辑器里新建骨骼是**交互式**的:先选父级骨骼,再用 Create Tool 在视口里点一下/拖一下。 +菜单里的 `New Bone` 只是同一条路的"用当前选择当父级"版本 —— 所以没选中时它直接 `return false`。 + +把所有 `New X` 菜单动作摊开看,它们最后都落到**同一个静态方法**上(反编译源码里的名字): + +```java +KP.B(target, "Bone"); // 新建骨骼(target = 当前选择对象) +KP.B(target, "Slot"); +KP.B(target, "Skin Placeholder"); +KP.B(target, "Bounding Box"); +KP.B(target, "Path"); +KP.B(target, "Clipping"); +KP.B(target, "IK Constraint"); +KP.B(og_1.D.D.D.D, "Path Constraint"); +KP.B(og_1.D.D.D.D, "Skin"); +``` + +调用点(可读源码):`mn_1`(New Bone)、`mo`(New Slot)、`mp`、`mq`、`ms_1`、`mt`、`mu_1`、 +`mv_0`、`mx_0`,以及树上的两个"选中类型分支" `acM.java:98` / `yi_0.java:28`。 + +> ⚠️ 待办:`KP` 这个名字是**可读源码树里**的名字。载荷里另有一个也叫 `KP` 的类 +> (`KP extends eW`,是个 SelectBox 控件),那是 CFR 大小写冲突造成的重名。 +> 真正要调的那个 `KP` 得先用 `tools/obfmap/MapMethods` 之类的办法重新定位一次, +> 才能从面板里反射调用 —— 这是下一步的第一步。 + +### 场景对象的删除边界(需求确认) + +- `skeleton` 是**整个场景的根节点**:删掉它等于清空全部场景对象; +- `root` 是**所有对象的根骨骼**:只要 skeleton 在,root 就必然在,**不允许删除**。 + +所以面板做删除时,选择集里要拦住 `root`(skeleton 可以删)。 + +--- + ## 五、验证状态 | 结论 | 证据 | @@ -184,7 +265,10 @@ JVM 允许同一个类里存在"**名字相同、描述符不同**"的字段和 | `et.Z(Z)V` = `setFillParent` | 换成它之后面板铺满停靠框(先前误调 `setDebug` 时只会描出彩色单元格边框) | | `Button.String()Z` = `isChecked`、`I(Z)V` = `setChecked` | 点击能触发一次读取;用前缀自校准曾配错组合,导致反复触发 | | `Label.D(CharSequence)V` = `setText` | 修掉 `textEquals` 之后面板文字才真正刷新 | +| 命令系统入口 `Editor.pE.D(String)` 可达 | 面板成功拿到命令系统并执行了 `New Bone` / `Rulers`(日志有 `[cmd] 执行命令`) | 未逐条验证的(结构成组、按声明顺序推定):`Cell` 的 max/pad 系列、`Button` 的 `isOver/isPressed`、 `Cell` 的 `growX/growY`。要用到时,先用 `MapMethods` 重跑一遍,再在编辑器里实测。 +**已知未打通**:命令"执行了但没生效"(`New Bone` / `Rulers` 都没看到界面变化)。 +下一步要确认执行路径究竟是 `pC.boolean D()` 还是 `pC.void D()`,以及**怎么设置当前选择**。