编辑器交互/数据 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 增补类/方法对照与大小写同名类坑。
This commit is contained in:
tianmo committed 2026-10-16 14:38:25 +08:00
1 parent 2b23ce59db
commit 7fbb042aa6
6 files changed
+990 -126

No files matched your search

+27
View File
@@ -128,6 +128,33 @@ public final class Launcher {
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");
@@ -0,0 +1,285 @@
package com.spineview;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Set;
/**
* 场景对象(骨骼 / 插槽 / 附件…)的读取入口。
*
* 关键事实(都是从载荷字节码里读出来的,见 `混淆名对照表.md`):
*
* - `OG` 是编辑器的"全局静态持有者",它上面有一串**同名不同描述符**的 `D` 字段:
* `D:LEditor;`(编辑器实例)、`D:LVZ;`(Tree 视图 Actor)、`D:LUq;`(libGDX Tree 控件)、
* `D:LSkin;`、`D:LPrefs;`、`D:LStage;` … 所以取字段必须**按类型名挑**,不能只看名字。
* - 层级树的节点是 libGDX `Tree.Node`(载荷名 `eM`,编辑器子类 `Uo`),
* 节点上挂的"场景对象"是 `Gj` 的子类(载荷名 `GP` / `Gp` / `GU` / `GT` …)。
* - `VZ.D(fI)fI` 会把树里所有节点的对象收集进一个 `Array`(载荷名 `fI`)里返回,
* 这是"列出全部场景对象"的最省事入口。
*/
public final class SceneObjects {
/** com.esotericsoftware.spine.editor.Editor(保名类) */
static final String EDITOR = "com.esotericsoftware.spine.editor.Editor";
/** Tree 视图的 Actor(载荷名 VZ,extends Table) */
static final String TREE_VIEW = "VZ";
/** libGDX Tree 控件(载荷名 Uq,extends com.badlogic.gdx.scenes.scene2d.ui.Tree) */
static final String TREE = "Uq";
/** libGDX Tree.Node(载荷名 eM;编辑器子类 Uo) */
static final String NODE = "eM";
/** libGDX Array(载荷名 fI) */
static final String ARRAY = "fI";
/** 场景对象基类(载荷名 Gj) */
static final String OBJECT_BASE = "Gj";
private SceneObjects() {
}
/** 从 OG 的静态字段里按**类型**取一个实例(OG 上一堆同名字段,只能靠类型区分)。 */
static Object global(ClassLoader loader, String typeName) throws Exception {
Class<?> holder = Class.forName("OG", true, loader);
for (Class<?> type = holder; 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;
field.setAccessible(true);
Object value = field.get(null);
if (value != null) return value;
}
}
return null;
}
/** OG 上可能有多个同类型字段(例如 Skin),这里全部收一遍。 */
static List<Object> globals(ClassLoader loader, String typeName) throws Exception {
List<Object> out = new ArrayList<Object>();
Class<?> holder = Class.forName("OG", true, loader);
for (Field field : holder.getDeclaredFields()) {
if (!Modifier.isStatic(field.getModifiers())) continue;
if (!field.getType().getName().equals(typeName)) continue;
field.setAccessible(true);
Object value = field.get(null);
if (value != null) out.add(value);
}
return out;
}
/**
* 列出树里全部节点对象 —— `VZ.D(fI)fI`(把每个 node 的对象 add 进传进去的 Array)。
*
* 注意:这个方法名 `D` 在 VZ 上有很多重载,必须按 (fI)->fI 这个描述符挑。
*/
public static List<Object> listAll(ClassLoader loader) throws Exception {
Object treeView = global(loader, TREE_VIEW);
if (treeView == null) throw new IllegalStateException("拿不到 Tree 视图(OG 上没有 VZ 字段)");
Class<?> arrayClass = Class.forName(ARRAY, true, loader);
Object array = arrayClass.getDeclaredConstructor().newInstance();
Method collect = null;
for (Class<?> type = treeView.getClass(); type != null && type != Object.class;
type = type.getSuperclass()) {
for (Method method : type.getDeclaredMethods()) {
Class<?>[] parameters = method.getParameterTypes();
if (parameters.length != 1) continue;
if (parameters[0] != arrayClass || method.getReturnType() != arrayClass) continue;
collect = method;
break;
}
if (collect != null) break;
}
if (collect == null) throw new IllegalStateException("VZ 上没有 (fI)->fI 的收集方法");
collect.setAccessible(true);
Object filled = collect.invoke(treeView, array);
return new ArrayList<Object>(java.util.Arrays.asList(arrayItems(filled)));
}
/** 当前选中的场景对象(`VZ.B()Object` = 树上 Selection 的 lastSelected 的 object)。 */
public static Object selected(ClassLoader loader) throws Exception {
Object treeView = global(loader, TREE_VIEW);
if (treeView == null) return null;
for (Method method : treeView.getClass().getDeclaredMethods()) {
if (!method.getName().equals("B")) continue;
if (method.getParameterTypes().length != 0) continue;
if (method.getReturnType() != Object.class) continue;
method.setAccessible(true);
return method.invoke(treeView);
}
return null;
}
/** 选中某个场景对象(`VZ.D(Object)Uo` = Tree.findNode + 选中)。返回是否选中成功。 */
public static boolean select(ClassLoader loader, Object sceneObject) throws Exception {
Object treeView = global(loader, TREE_VIEW);
if (treeView == null || sceneObject == null) return false;
Class<?> nodeClass = Class.forName(NODE, true, loader);
for (Method method : treeView.getClass().getDeclaredMethods()) {
Class<?>[] parameters = method.getParameterTypes();
if (parameters.length != 1 || parameters[0] != Object.class) continue;
if (method.getReturnType() != nodeClass) continue;
method.setAccessible(true);
return method.invoke(treeView, sceneObject) != null;
}
return false;
}
/** 场景对象的中文类型不在需求里(面板一律英文),这里给英文缩写归类。 */
public static String kind(Object sceneObject) {
if (sceneObject == null) return "null";
String name = sceneObject.getClass().getName();
if (name.equals("GP")) return "skeleton";
if (name.equals("Gp")) return "bone";
if (name.equals("GU")) return "slot";
if (name.equals("GT")) return "attachment";
return name;
}
/** 对象的显示名(`Gj` 子类的 toString() 就是名字)。 */
public static String nameOf(Object sceneObject) {
if (sceneObject == null) return "(none)";
try {
return String.valueOf(sceneObject.toString());
} catch (Throwable error) {
return sceneObject.getClass().getName();
}
}
/** 探针:把树里所有对象 + 层级列出来,用于确认"查"的链路。 */
public static String describe(ClassLoader loader) {
StringBuilder out = new StringBuilder();
try {
Object tree = global(loader, TREE);
out.append("tree widget = ").append(tree == null ? "null" : tree.getClass().getName())
.append('\n');
List<Object> all = listAll(loader);
out.append("scene objects = ").append(all.size()).append('\n');
for (int i = 0; i < all.size() && i < 100; i++) {
Object item = all.get(i);
out.append(" [").append(i).append("] ").append(item.getClass().getName())
.append(" ").append(kind(item)).append(" name=").append(nameOf(item))
.append('\n');
}
Object current = selected(loader);
out.append("selected = ").append(current == null ? "null" : nameOf(current))
.append('\n');
out.append("--- node tree ---\n");
dumpNodes(loader, out);
} catch (Throwable error) {
out.append("describe failed: ").append(error).append('\n');
error.printStackTrace(System.err);
}
return out.toString();
}
/** 顺着 libGDX Tree 控件的 root 节点把层级打印出来(节点上挂着场景对象)。 */
private static void dumpNodes(ClassLoader loader, StringBuilder out) throws Exception {
Object tree = global(loader, TREE);
if (tree == null) return;
Class<?> nodeClass = Class.forName(NODE, true, loader);
Class<?> arrayClass = Class.forName(ARRAY, true, loader);
List<Object> roots = new ArrayList<Object>();
for (Class<?> type = tree.getClass(); type != null && type != Object.class;
type = type.getSuperclass()) {
for (Field field : type.getDeclaredFields()) {
if (field.getType() != arrayClass) continue;
field.setAccessible(true);
Object value = field.get(tree);
if (value == null) continue;
Object[] items = arrayItems(value);
for (Object item : items) {
if (item != null && nodeClass.isInstance(item)) roots.add(item);
}
}
}
Set<Object> seen = java.util.Collections.newSetFromMap(new IdentityHashMap<Object, Boolean>());
for (Object root : roots) walkNode(root, out, 0, seen);
}
private static void walkNode(Object node, StringBuilder out, int depth, Set<Object> seen) {
if (node == null || depth > 8 || !seen.add(node)) return;
Object object = nodeObject(node);
if (depth <= 6) {
out.append(indent(depth)).append(node.getClass().getName()).append(" -> ")
.append(object == null ? "null" : object.getClass().getName())
.append(" ").append(kind(object)).append(" name=").append(nameOf(object))
.append('\n');
}
for (Object child : nodeChildren(node)) walkNode(child, out, depth + 1, seen);
}
/** Tree.Node 上的"场景对象"字段(声明在载荷类 eM 上、类型 Object 的那个 D)。 */
static Object nodeObject(Object node) {
for (Class<?> type = node.getClass(); type != null && type != Object.class;
type = type.getSuperclass()) {
if (!type.getName().equals(NODE)) continue;
for (Field field : type.getDeclaredFields()) {
if (field.getType() != Object.class) continue;
try {
field.setAccessible(true);
return field.get(node);
} catch (Throwable ignored) {
return null;
}
}
}
return null;
}
/** Tree.Node 上的 children 数组(声明在 eM 上、类型 fI 的那个 D)。 */
static List<Object> nodeChildren(Object node) {
List<Object> out = new ArrayList<Object>();
for (Class<?> type = node.getClass(); type != null && type != Object.class;
type = type.getSuperclass()) {
if (!type.getName().equals(NODE)) continue;
for (Field field : type.getDeclaredFields()) {
if (!field.getType().getName().equals(ARRAY)) continue;
try {
field.setAccessible(true);
Object[] items = arrayItems(field.get(node));
for (Object item : items) if (item != null) out.add(item);
} catch (Throwable ignored) {
// 跳过
}
}
}
return out;
}
/** 把 libGDX Array(载荷名 fI:Object[] items + int size)还原成元素数组。 */
static Object[] arrayItems(Object value) {
if (value instanceof Object[]) return (Object[]) value;
if (value == null || value.getClass().isArray()) return new Object[0];
Object[] items = null;
int size = -1;
for (Class<?> type = value.getClass(); type != null && type != Object.class;
type = type.getSuperclass()) {
if (!type.getName().equals(ARRAY)) continue;
for (Field field : type.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) {
// 跳过
}
}
break;
}
if (items == null) return new Object[0];
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 String indent(int depth) {
StringBuilder builder = new StringBuilder();
for (int i = 0; i < depth; i++) builder.append(" ");
return builder.toString();
}
}