场景对象增删改查调研:命令系统入口 + 新建对象的真实落点

- Commands.java:Editor -> pE(命令系统) -> void D(String) 按名字执行命令
- Launcher:新增 -Dspine.runCommand="<命令名>" 便于验证
- SceneReader:Tree/选中项探针(actor 树按 SnapshotArray 结构遍历)
- 混淆名对照表.md:补命令系统一节、可执行命令名单、skeleton/root 删除边界、
  以及 New X 最终落到静态 KP.B(target, 类型名) 的结论与待办
This commit is contained in:
tianmo committed 2026-10-16 14:20:45 +08:00
1 parent 8f7b72271f
commit a0080428f9
4 files changed
+394 -7

No files matched your search

+88
View File
@@ -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.<init> 里 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;
}
}
}
+22 -1
View File
@@ -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) {
+200 -6
View File
@@ -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()) {