diff --git a/tools/decrypt/DumpSites.java b/tools/decrypt/DumpSites.java new file mode 100644 index 0000000..e3519cb --- /dev/null +++ b/tools/decrypt/DumpSites.java @@ -0,0 +1,170 @@ +package com.spineview.dec; + +import java.io.BufferedWriter; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.InvokeDynamicInsnNode; +import org.objectweb.asm.tree.MethodNode; + +/** + * 把每个 invokedynamic 站点的**加密串**按 BSM 自己的算法解出真实目标: + * (类名, 方法名, 描述符) —— 静态还是虚调用看首字符 0xB8=static、0xB6=virtual。 + * + * 为什么要它:`rewrite_source.py` 只按"混淆名 → 类.方法"改名,**丢描述符**, + * 于是 `SelectBox.getSelected()` 和 `SelectBox.setSelected(String)` 都被写成 `KP.B`, + * 读源码时就会以为是同一个方法(这是之前把 KP.B 误当"建对象 API"的根因)。 + * + * 用法: + * java -cp "asm.jar:本工具:已全 public 化的载荷.zip" com.spineview.dec.DumpSites <载荷> [类名...] + */ +public final class DumpSites { + private static final String BSM_DESC = + "(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;"; + + public static void main(String[] args) throws Exception { + Path payload = Paths.get(args[0]); + Path out = Paths.get(args[1]); + List filters = new ArrayList(); + for (int i = 2; i < args.length; i++) filters.add(args[i]); + + List rows = new ArrayList(); + try (ZipFile zip = new ZipFile(payload.toFile())) { + Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (!entry.getName().endsWith(".class")) continue; + ClassNode node; + try { + node = parse(readAll(zip.getInputStream(entry))); + } catch (Throwable error) { + continue; + } + if (!filters.isEmpty() && !filters.contains(node.name)) continue; + for (MethodNode method : node.methods) { + for (AbstractInsnNode insn = method.instructions.getFirst(); insn != null; + insn = insn.getNext()) { + if (!(insn instanceof InvokeDynamicInsnNode)) continue; + InvokeDynamicInsnNode indy = (InvokeDynamicInsnNode) insn; + if (!BSM_DESC.equals(indy.bsm.getDesc())) continue; + if (indy.bsmArgs.length == 0 || !(indy.bsmArgs[0] instanceof String)) continue; + String decoded = decode(indy.name, (String) indy.bsmArgs[0]); + String caller = node.name + "." + method.name + method.desc; + // 顺带把"最近压栈的那个 ldc 常量"当成描述文本,方便在源码里定位。 + String hint = nearestLdc(method, insn); + rows.add(new String[] { caller, indy.name, indy.desc, decoded, hint }); + } + } + } + } + try (BufferedWriter writer = Files.newBufferedWriter(out, StandardCharsets.UTF_8)) { + writer.write("caller\tindyName\tsiteDesc\tdecoded\tnearbyLdc\n"); + for (String[] row : rows) { + for (int i = 0; i < row.length; i++) { + if (i > 0) writer.write('\t'); + writer.write(escape(row[i])); + } + writer.write('\n'); + } + } + System.out.println("站点 " + rows.size() + " -> " + out); + } + + private static String nearestLdc(MethodNode method, AbstractInsnNode site) { + AbstractInsnNode node = site; + for (int i = 0; i < 12 && node != null; i++) { + node = node.getPrevious(); + if (node instanceof org.objectweb.asm.tree.LdcInsnNode) { + Object value = ((org.objectweb.asm.tree.LdcInsnNode) node).cst; + if (value instanceof String) return printable((String) value); + } + } + return ""; + } + + private static String printable(String text) { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c >= 0x20 && c < 0x7F) builder.append(c); + else builder.append('.'); + } + return builder.toString(); + } + + /** 完全按 BSM 字节码里的算法复刻;先用已知站点自校验过。 */ + static String decode(String indyName, String cipher) { + char[] name = indyName.toCharArray(); + int[] seed = new int[4]; + seed[0] = (name[1] << 16) | name[0]; + seed[1] = (name[3] << 16) | name[2]; + seed[2] = (name[5] << 16) | name[4]; + seed[3] = (name[7] << 16) | name[6]; + char[] out = cipher.toCharArray(); + int n4 = 0, n5 = 0, n6 = 0, n7 = 0; + for (int i = 0; i < out.length; i++) { + if (i % 8 == 0) { + for (int k = 0; k < 48; k++) { + int s = seed[k % 4] + k; + n5 += n4 + ((n4 << 6) ^ (n4 >> 8)) + s; + n4 += n5 + ((n5 << 6) ^ (n5 >> 8)) + s; + n7 += n6 + ((n6 << 6) ^ (n6 >> 8)) + s; + n6 += n7 + ((n7 << 6) ^ (n7 >> 8)) + s; + } + } + int key; + switch (i % 8) { + case 0: case 4: key = n4 >> 16; break; + case 1: case 5: key = n5; break; + case 2: case 6: key = n6 >> 16; break; + default: key = n7; break; + } + out[i] = (char) (key ^ out[i]); + } + // [0]=调用种类(0xB8 静态 / 0xB6 虚) [1]=类名长度 类名 [n]=方法名长度 方法名 描述符 + boolean isStatic = out[0] == '\u00b8'; + int classLength = out[1]; + String className = new String(out, 2, classLength); + int nameLength = out[classLength + 2]; + String methodName = new String(out, classLength + 3, nameLength); + String descriptor = new String(out, classLength + nameLength + 3, + out.length - classLength - nameLength - 3); + return (isStatic ? "static " : "virtual ") + className + "." + methodName + descriptor; + } + + private static String escape(String text) { + StringBuilder builder = new StringBuilder(text.length() + 8); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '\t') builder.append("\\t"); + else if (c >= 0x20 && c <= 0x7E) builder.append(c); + else builder.append(String.format("\\u%04x", Integer.valueOf(c))); + } + return builder.toString(); + } + + private static ClassNode parse(byte[] raw) { + ClassNode node = new ClassNode(); + new ClassReader(raw).accept(node, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + return node; + } + + private static byte[] readAll(InputStream in) throws java.io.IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] chunk = new byte[65536]; + int n; + while ((n = in.read(chunk)) > 0) buffer.write(chunk, 0, n); + return buffer.toByteArray(); + } +} diff --git a/tools/decrypt/ResolveSites.java b/tools/decrypt/ResolveSites.java new file mode 100644 index 0000000..b5fd8b6 --- /dev/null +++ b/tools/decrypt/ResolveSites.java @@ -0,0 +1,181 @@ +package com.spineview.dec; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.lang.invoke.CallSite; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.Handle; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.InvokeDynamicInsnNode; +import org.objectweb.asm.tree.MethodNode; + +/** 把 (类,方法) 里的所有 invokedynamic 站点解出来,打印 站点描述符 + 目标方法签名。 */ +public final class ResolveSites { + private static final String BSM_DESC = + "(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;"; + + public static void main(String[] args) throws Exception { + String payload = args[0]; + List filters = new ArrayList(); + for (int i = 1; i < args.length; i++) filters.add(args[i]); + List sites = new ArrayList(); + try (ZipFile zip = new ZipFile(payload)) { + Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (!entry.getName().endsWith(".class")) continue; + ClassNode node; + try { node = parse(readAll(zip.getInputStream(entry))); } catch (Throwable t) { continue; } + if (!matches(node.name, filters)) continue; + System.out.println("== class " + node.name + " extends " + node.superName); + for (MethodNode method : node.methods) { + for (AbstractInsnNode insn = method.instructions.getFirst(); insn != null; + insn = insn.getNext()) { + if (!(insn instanceof InvokeDynamicInsnNode)) continue; + InvokeDynamicInsnNode indy = (InvokeDynamicInsnNode) insn; + Handle bsm = indy.bsm; + if (!BSM_DESC.equals(bsm.getDesc())) continue; + sites.add(new Site(node.name, method.name, method.desc, bsm.getOwner(), bsm.getName(), + indy.name, indy.desc, indy.bsmArgs.length > 0 ? indy.bsmArgs[0] : null)); + } + } + } + } + Map bootstrapCache = new HashMap(); + MethodHandles.Lookup[] lookupCache = new MethodHandles.Lookup[1]; + Map resolved = new LinkedHashMap(); + for (Site site : sites) { + String triple = site.bsmOwner + "." + site.bsmName + "#" + site.indyName; + String target = resolved.get(triple); + if (target == null) { + try { target = resolve(site, bootstrapCache, lookupCache); } + catch (Throwable error) { target = "<失败> " + error; } + resolved.put(triple, target); + } + System.out.println(" " + site.owner + "." + site.method + site.methodDesc + + "\n indy " + site.bsmOwner + "." + site.bsmName + " name=" + site.indyName + + " siteType=" + site.desc + "\n -> " + target); + } + System.out.println("站点 " + sites.size()); + } + + private static boolean matches(String name, List filters) { + if (filters.isEmpty()) return true; + for (String f : filters) if (name.equals(f)) return true; + return false; + } + + private static String resolve(Site site, Map bootstrapCache, + MethodHandles.Lookup[] lookupCache) throws Throwable { + Class owner = Class.forName(site.bsmOwner.replace('/', '.')); + String key = site.bsmOwner + "." + site.bsmName; + Method bootstrap = bootstrapCache.get(key); + if (bootstrap == null) { + for (Method candidate : owner.getDeclaredMethods()) { + if (candidate.getName().equals(site.bsmName) + && candidate.getParameterTypes().length == 4) { + bootstrap = candidate; + break; + } + } + if (bootstrap == null) throw new NoSuchMethodException(site.bsmName); + bootstrap.setAccessible(true); + bootstrapCache.put(key, bootstrap); + } + if (lookupCache[0] == null) lookupCache[0] = trustedLookup(); + MethodType type = MethodType.fromMethodDescriptorString(site.desc, + ResolveSites.class.getClassLoader()); + Object callSite = bootstrap.invoke(null, lookupCache[0], site.indyName, type, site.arg); + if (!(callSite instanceof CallSite)) return String.valueOf(callSite); + MethodHandle target = ((CallSite) callSite).getTarget(); + return describe(target) + " [type=" + target.type() + "]"; + } + + private static String describe(MethodHandle target) { + String direct = memberOf(target); + if (direct != null) return direct; + for (Class type = target.getClass(); type != null; type = type.getSuperclass()) { + for (java.lang.reflect.Field field : type.getDeclaredFields()) { + if (!field.getName().startsWith("argL") && !field.getName().startsWith("argJ") + && !field.getName().startsWith("argI")) continue; + try { + field.setAccessible(true); + Object value = field.get(target); + if (!(value instanceof MethodHandle)) continue; + String nested = memberOf((MethodHandle) value); + if (nested != null) return nested + " (via " + target.getClass().getSimpleName() + ")"; + } catch (Throwable ignored) { } + } + } + return "?" + target.type(); + } + + private static String memberOf(MethodHandle handle) { + try { + Method internal = MethodHandle.class.getDeclaredMethod("internalMemberName"); + internal.setAccessible(true); + Object member = internal.invoke(handle); + if (member == null) return null; + Class memberClass = member.getClass(); + Method getDeclaring = memberClass.getMethod("getDeclaringClass"); + Method getName = memberClass.getMethod("getName"); + Method isStatic = memberClass.getMethod("isStatic"); + getDeclaring.setAccessible(true); + getName.setAccessible(true); + isStatic.setAccessible(true); + Object declaring = getDeclaring.invoke(member); + String owner = String.valueOf(declaring).replace("class ", "").replace("interface ", ""); + return owner + "." + getName.invoke(member) + + (Boolean.TRUE.equals(isStatic.invoke(member)) ? " (static)" : ""); + } catch (Throwable error) { + return null; + } + } + + private static MethodHandles.Lookup trustedLookup() throws Exception { + Constructor constructor = + MethodHandles.Lookup.class.getDeclaredConstructor(Class.class, int.class); + constructor.setAccessible(true); + return constructor.newInstance(Object.class, Integer.valueOf(-1)); + } + + private static final class Site { + final String owner, method, methodDesc, bsmOwner, bsmName, indyName, desc; + final Object arg; + Site(String owner, String method, String methodDesc, String bsmOwner, String bsmName, + String indyName, String desc, Object arg) { + this.owner = owner; this.method = method; this.methodDesc = methodDesc; + this.bsmOwner = bsmOwner; this.bsmName = bsmName; this.indyName = indyName; + this.desc = desc; this.arg = arg; + } + } + + private static ClassNode parse(byte[] raw) { + ClassNode node = new ClassNode(); + new ClassReader(raw).accept(node, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + return node; + } + + private static byte[] readAll(InputStream in) throws java.io.IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] chunk = new byte[65536]; + int n; + while ((n = in.read(chunk)) > 0) buffer.write(chunk, 0, n); + return buffer.toByteArray(); + } +} diff --git a/viewpanel/src/com/spineview/Launcher.java b/viewpanel/src/com/spineview/Launcher.java index f4ad855..5112470 100644 --- a/viewpanel/src/com/spineview/Launcher.java +++ b/viewpanel/src/com/spineview/Launcher.java @@ -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"); diff --git a/viewpanel/src/com/spineview/SceneObjects.java b/viewpanel/src/com/spineview/SceneObjects.java new file mode 100644 index 0000000..97408ae --- /dev/null +++ b/viewpanel/src/com/spineview/SceneObjects.java @@ -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 globals(ClassLoader loader, String typeName) throws Exception { + List out = new ArrayList(); + 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 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(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 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 roots = new ArrayList(); + 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 seen = java.util.Collections.newSetFromMap(new IdentityHashMap()); + for (Object root : roots) walkNode(root, out, 0, seen); + } + + private static void walkNode(Object node, StringBuilder out, int depth, Set 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 nodeChildren(Object node) { + List out = new ArrayList(); + 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(); + } +} diff --git a/混淆名对照表.md b/混淆名对照表.md index 4906632..b560cd4 100644 --- a/混淆名对照表.md +++ b/混淆名对照表.md @@ -197,14 +197,15 @@ OG.D → com.esotericsoftware.spine.editor.Editor > 注意:`pE.D(String)` 只看"找得到找不到",**不看动作有没有真的生效**。 > 实测 `-Dspine.runCommand="New Bone"` 打印"执行完成"但骨骼树没变 —— 因为 -> `mn_1.D()` 的源码是: +> `mn`(New Bone)的字节码是: > ```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; // 没选中 → 什么都不做,也不报错 +> VY sel = OG.D:LVZ.D; // Tree 视图 → 当前选中对象的属性面板 +> if (sel instanceof XW) { ((XW)sel).D.setSelected("Bone"); return true; } // 没选中 → 什么都不做 +> if (sel instanceof acK) { ((acK)sel).D.setSelected("Bone"); return true; } // 也不报错 +> return false; > ``` -> 也就是说 **这些动作全都作用在"当前选择"上**,要先能设置选择,才谈得上增删改。 +> 也就是说 **这些动作全都作用在"当前选择"上**(改下拉框 / 点属性面板按钮), +> 要先能设置选择,才谈得上增删改。详见 [`编辑器交互与数据API.md`](编辑器交互与数据API.md) §4。 可用的命令名(= `hotkeys.txt` 里的名字,`~/Library/Application Support/Spine/hotkeys.txt`): @@ -220,32 +221,25 @@ OG.D → com.esotericsoftware.spine.editor.Editor **面板里的接口**:[`viewpanel/src/com/spineview/Commands.java`](viewpanel/src/com/spineview/Commands.java) (`Commands.run(loader, "New Bone")`),验证入口是启动参数 `-Dspine.runCommand="<命令名>"`。 -### 真正"建出一个能显示的骨骼"落在哪 +### 真正"建出一个能显示的骨骼"落在哪(⚠️ 2026-10-16 更正) -用户在编辑器里新建骨骼是**交互式**的:先选父级骨骼,再用 Create Tool 在视口里点一下/拖一下。 -菜单里的 `New Bone` 只是同一条路的"用当前选择当父级"版本 —— 所以没选中时它直接 `return false`。 - -把所有 `New X` 菜单动作摊开看,它们最后都落到**同一个静态方法**上(反编译源码里的名字): +**上一轮这里是错的**:`KP.B(target, "Bone")` 既不是"建对象 API", +`target` 也不是"父级场景数据"。真实情况(`tools/decrypt/DumpSites.java` 逐站点解密的实测结果): ```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(New Bone)里的两个 indy 站点: +IjFxVlIo : (Object)String -> static OG.Z(Ljava/lang/String;)Ljava/lang/String; // 字符串解密 +hEjBGGtT : (Object,Object)V -> virtual KP.B(Ljava/lang/String;)V // ← 就是它 ``` -调用点(可读源码):`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`(载荷名)= `com.badlogic.gdx.scenes.scene2d.ui.SelectBox`(`extends eW`,字段里有 `SelectBox$SelectBoxStyle`); + `KP.B(String)V` = `setSelected(T)`、`KP.B()Ljava/lang/String;` = `getSelected()`、`KP.B(I)V` = `setSelectedIndex(int)`。 +- `((XW)sel).D` 解析到父类 `Yr.D:LKP;`,也就是**属性面板上的类型下拉框**; + 设值会触发它的 ChangeListener(载荷类 `Yi` / `acM`),那里才真正 `new Gp()` / `GP.D(...)` / `VZ.D(...)`。 +- 错因:`rewrite_source.py` 按混淆名改名、**丢掉描述符**,三个重载全被写成 `KP.B`。 -> ⚠️ 待办:`KP` 这个名字是**可读源码树里**的名字。载荷里另有一个也叫 `KP` 的类 -> (`KP extends eW`,是个 SelectBox 控件),那是 CFR 大小写冲突造成的重名。 -> 真正要调的那个 `KP` 得先用 `tools/obfmap/MapMethods` 之类的办法重新定位一次, -> 才能从面板里反射调用 —— 这是下一步的第一步。 +调用点(可读源码):`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`。 ### 场景对象的删除边界(需求确认) @@ -256,7 +250,72 @@ KP.B(og_1.D.D.D.D, "Skin"); --- -## 五、验证状态 +## 四之三、编辑器结构 / 场景模型(2026-10-16 新增,逐条有字节码出处) + +### 推导办法(本轮用的就是这一条,比按名字改名可靠) + +```bash +# 1) 载荷方法全 public 化(indy 引导方法是 private 的,反射调不了) +python3 tools/decrypt/make_public.py <载荷> /tmp/payload-public.zip +# 2) 编译逐站点解码器 +J8="$PWD/tools/jdk8u504-b01/Contents/Home/bin" +ASM="$PWD/tools/asm/asm.jar:$PWD/tools/asm/asm-tree.jar" +"$J8/javac" -cp "$ASM" -d tools/decrypt/build tools/decrypt/DumpSites.java +# 3) 把每个 invokedynamic 站点的**密文**按 BSM 自己的算法解出来 +"$J8/java" -cp "tools/decrypt/build:$ASM:/tmp/payload-public.zip" \ + com.spineview.dec.DumpSites /tmp/payload-public.zip /tmp/sites.tsv [类名...] +``` + +密文里存的就是 `[种类][类名长度][类名][方法名长度][方法名][描述符]`(`0xB8`=static、`0xB6`=virtual), +所以解码结果**带描述符**,重载不会混淆。全量结果在 `~/Desktop/spine398/sites.tsv`(55637 行)。 + +### ⚠️ 坑:载荷里有大量"只差大小写"的类名 + +`OG`/`og`、`XW`/`xW`、`Yr`/`yR`、`Gp`/`GP`、`VZ`/`vz`、`VO`/`vo` … 是**不同的类**。 +macOS 文件系统大小写不敏感,解到目录里会互相覆盖 —— 所以核对时必须 +`javap -cp <载荷.zip> <类名>`(直接读 zip)或用 python 解析,别解到磁盘。 + +### 类 + +| 载荷名 | 真实含义 | 依据 | +| --- | --- | --- | +| `OG` | **编辑器全局静态持有者**(34 个字面都叫 `D` 的静态字段:Editor / Tree 视图 VZ / Tree 控件 Uq / Skin / Stage / Prefs / 模型助手 UH…) | `javap -p OG`;`Uq.` 末尾 `putstatic OG.D:LUq;` | +| `og` | "Tree View" 菜单动作(`ll.java:60` 注册) | `javap -p og`、`ll.java` | +| `VZ` | **Tree 视图 Actor**(`extends et`(Table),`implements Runnable`) | `javap -p VZ` | +| `Uq` | libGDX `Tree` 控件(`public final fC B` = Selection) | `javap -p Uq` | +| `eM` / `Uo` | libGDX `Tree.Node` / 编辑器子类;`eM.D:Ljava/lang/Object;` = 节点对象 | `javap -p eM` | +| `fC` | libGDX `Tree.Selection`(`D()` = getLastSelected,`D()LfI;` = items,`C()` = clear,`B()` = isEmpty) | 逐站点表 | +| `fI` | libGDX `Array`(`Object[] D` + `int D`) | `javap -p fI` | +| `Gj` | 场景对象基类 | `javap -p Gj` | +| `GP` | **骨架对象**(`toString()` = 名字;`D(String)LGp;` 按名找骨骼) | `javap -p GP`、`GP.java:1502` | +| `Gp` | **骨骼**(`D:LGp;` 父骨骼、`D:LGP;` 所属骨架、`L()V` 从模型移除) | `javap -p Gp`、`vq_0.java:45` | +| `GU` | **插槽** | `javap -p GU` | +| `GT` / `GO` | **附件**(GO 是实测挂在插槽节点上的那个) | `javap -p`、探针输出 | +| `VY` → `VO` → `Yr` → `XW`/`acK`… | **属性面板控件**链(`extends Table`);`VZ.D:LVY` 就是"当前显示的那个" | `javap -p`、`vn_0.java:77,85` | +| `VN`(可读名 `vn_0`) | 属性面板工厂:把每种对象的属性面板 `new` 出来注册 | `vn_0.java:77,85` | +| `uh`(可读名 `uh_0`) | **模型编辑助手**(加骨骼/插槽/附件、新建事件/动画…) | `uh_0.java`、`javap -p uh` | +| `un` | **撤销/重做栈**:`D()`/`B()` 成对包一次编辑,`B(Object)` 记录对象 | `javap -c un`、`vq_0.java:44-49` | +| `pB` / `lo` / `pn` / `pt` / `md` / `mn` / `mo` | `Delete` / `Rename` / `Select - Bones` / `Select - Attachments` / `Export` / `New Bone` / `New Slot` 菜单动作 | `ll.java:136-206` | +| `KP` | **`com.badlogic.gdx.scenes.scene2d.ui.SelectBox`**(属性面板上的类型下拉框) | `javap -p KP`、`DumpSites` 解码 | +| `Yi`(可读名 `yi_0`) | 类型下拉框的 ChangeListener:**真正建对象的地方** | `yi_0.java:27-80` | +| `Vq`(可读名 `vq_0`) | 删除确认框的"确定"回调:**真正的删除 + 撤销** | `vq_0.java:27-62` | + +### 方法(都是"名字 + 描述符") + +| 载荷签名 | 真实方法 | 用途 | +| --- | --- | --- | +| `VZ.D(Ljava/lang/Object;)LUo;` | Tree 视图:按场景对象选中节点 | 设置"当前选择" | +| `VZ.B()Ljava/lang/Object;` | Tree 视图:取当前选中对象 | 读"当前选择" | +| `VZ.D(LfI;)LfI;` | 收集**已选中**对象的数组 | 多选查询 | +| `VZ.D(LGP;ZZ)V` | 为某个模型对象插入/重建树节点 | 刷新(新建/删除后) | +| `VZ.D(LVY;)V` | 切换当前显示的属性面板 | 内部 | +| `VZ.invoke()V` | `L(null)` = 清搜索框 | 刷新 | +| `Uq.D(Ljava/lang/Object;)LUo;` | libGDX `Tree.findNode` | 内部 | +| `KP.B(Ljava/lang/String;)V` / `B()Ljava/lang/String;` / `B(I)V` | `SelectBox.setSelected` / `getSelected` / `setSelectedIndex` | 新建对象用 | +| `un.D()V` / `un.B()V` / `B(Ljava/lang/Object;)V` | 撤销栈:开始 / 提交 / 记录对象 | 撤销 | +| `GP.D(Ljava/lang/String;)LGp;` | 骨架:按名字找骨骼 | 查 | +| `Gp.L()V` | 骨骼:从骨架里移除自己 | 删 | +| `OG.D(LUo;)LGP;` | Tree.Node → 骨架对象 | 查 | | 结论 | 证据 | | --- | --- | diff --git a/编辑器交互与数据API.md b/编辑器交互与数据API.md index b7d167d..e731b33 100644 --- a/编辑器交互与数据API.md +++ b/编辑器交互与数据API.md @@ -1,123 +1,265 @@ # 编辑器交互逻辑 & 数据 API 调研 目标:在动手写"面板里对场景对象增删改查"之前,先把编辑器**用户交互链路**和**真正生成数据的代码**摸清楚。 -本文只记录查到的事实 + 出处 + 还没弄清的地方。(混淆名 ⇒ 真实名见 [`混淆名对照表.md`](混淆名对照表.md)。) +本文只记录**已核实的事实 + 出处 + 还没弄清的地方**。混淆名 ⇒ 真实名见 [`混淆名对照表.md`](混淆名对照表.md)。 + +> ⚠️ 上一轮的两条结论是**错的**,本文已改正(错因见 §7): +> 1. "`KP.B(父级, 类型名)` 是建对象的 API" —— `KP` 是 libGDX `SelectBox`,`B(String)` 是 `setSelected`; +> 2. "`xw_2.D` / `acK.D` 是骨架/slot 数据" —— 它们是**选中对象的属性面板控件**,不是场景数据。 --- -## 一、交互层:工具(Tool) +## 0. 读载荷的三个坑(踩过,写下来免得再踩) -所有工具都在菜单注册表 `ll` 的构造函数里注册(`~/Desktop/spine398/src-readable/ll.java`): +1. **类名大小写敏感,macOS 文件系统不敏感**。载荷里同时存在 `OG`/`og`、`XW`/`xW`、`Yr`/`yR`、`Gp`/`GP`、`VZ`/`vz` 这类**只差大小写**的类。 + 把 zip 解到 mac 的目录里会互相覆盖(`javap -cp <目录>` 会给出自相矛盾的答案)。 + → 一律**直接从 zip 读**(`javap -cp <载荷.zip> <类名>`,或 `python3 /tmp/clsdump.py <类名>`)。 +2. **同名不同描述符**:`OG` 上有 **34 个**都叫 `D` 的静态字段(`D:LEditor;`、`D:LVZ;`、`D:LUq;`、`D:LSkin;`…)。 + 反射取字段必须**名字 + 类型**一起判。 +3. **可读源码里的方法名不带描述符**:`rewrite_source.py` 只按"混淆名 → 类.方法"改名, + 于是 `SelectBox.getSelected()` 与 `SelectBox.setSelected(String)` 都被写成 `KP.B`,读起来像同一个方法。 + → 需要精确名字时查 `~/Desktop/spine398/sites.tsv`(本轮新产出的**逐站点实测表**,见 §8)。 -| 菜单名 | 动作类(CFR 名) | 备注 | -| --- | --- | --- | -| `Create Tool` | `oo` | 新建对象用(靠视口点拖) | -| `Scale/Rotate/Translate/Shear/Pose/Weights Tool` | `oi_1` / `oj_0` / `ok_1` / `om` / `on_0` / `op` | | -| `Last Tool` | `oq_1` | 切回上一个工具 | +--- -`oo`(Create Tool)点下去做的事: +## 1. 全局入口:`OG`(编辑器静态持有者) -```java -og_1.D.D.D.I(true); // 打开"创建"交互(og_1.D = 编辑器上下文单例,后面是逐层嵌套的字段) -``` +`OG` 是一个只有静态字段/方法的类,编辑器的一切都挂在它上面(`javap -p -cp <载荷> OG`): -→ 新建对象本身是**交互式**的:工具打开后由视口里的按下/拖动事件驱动;菜单里的 `New Bone` -只是"用当前选择当父级"的等价入口(见下)。 - -## 二、层级树(Tree 视图)与节点模型 - -- Tree 视图 Actor = `VZ`(2819 行,`us.Tree` 面板的内容类)。 -- 树节点 = `uo_2`(Node),每个节点包一个**包装对象**: - -| 包装类 | 代表的场景对象(按用法推断) | +| 字段(载荷名 + 描述符) | 真实含义 | | --- | --- | -| `xw_2` | skeleton 节点(`xw_2.D` = 骨架数据) | -| `gp_2` | bone 节点(`xw_2.java:367` 有 `"Bone: " + ((gp_2)object).D`) | -| `acK` / `acW` | slot 节点(`acK.java` 里有 "Set Parent" / "Sets the slot's parent bone") | -| `GU` | Draw Order 里的对象 | -| `Yr` | `xw_2` 的父类(一批节点包装的基类) | -| `md_1` | 节点里保存的模型引用 | +| `D : Lcom/esotericsoftware/spine/editor/Editor;` | 编辑器单例(保名类 `Editor`) | +| `D : LVZ;` | **Tree 视图 Actor**(载荷名 VZ,`extends com.badlogic.gdx.scenes.scene2d.ui.Table`) | +| `D : LUq;` | **libGDX `Tree` 控件**(载荷名 Uq,`extends ...ui.Tree`;`Uq.` 末尾 `putstatic OG.D:LUq;`) | +| `D : Lcom/badlogic/gdx/scenes/scene2d/ui/Skin;` | 皮肤 | +| `D : Ldt;` | Stage | +| `D : LtD;` | 视图装配类(我们挂面板用的那个) | +| `D : Lcom/esotericsoftware/spine/editor/Prefs;` / `Prefs$Advanced` | 偏好设置 | +| `D : LUH;` | 模型编辑助手(载荷名 `uh_0`,见 §5) | +| `Z(String)String` | 字符串解密(读源码时看到 `OG.Z("Bone")` 就是它,参数其实是密文) | +| `D(LUo;)LGP;` | **Tree.Node → 场景对象**(`Uo` = Tree.Node,`GP` = 骨架对象) | -树里的固定结构(见 `VZ.java` 建节点处):`skeleton → root → bone/slot…`, -另有 `Draw Order#startcaps`、`Skins`、`Events`、`Animations` 这些 `icon-*Root` 固定根。 +> 出处:`javap -p -cp <载荷> OG`;`Uq.` 结尾 `putstatic OG.D:LUq;`。 -### 当前选择放在哪 +--- -反编译源码里到处是这种读取: +## 2. 交互层:工具(Tool)与菜单 -```java -et_0 sel = og_1.D; // 编辑器上下文单例 -sel = sel.D; // ← 当前选择对象(菜单动作都作用在它上面) -... og_1.D.D.D // 再深一层(视图/工具相关) -``` +所有工具/菜单都在菜单注册表 `ll` 的构造函数里注册(`~/Desktop/spine398/src-readable/ll.java`): -`mn_1`(New Bone)的判断就依赖它: - -```java -if (sel instanceof xw_2) { KP.B(((xw_2)sel).D, "Bone"); return true; } // 选中 skeleton -if (sel instanceof acK) { KP.B(((acK)sel).D, "Bone"); return true; } // 选中 slot -return false; // 没选中/不支持 → 什么都不做 -``` - -## 三、数据层:真正"建出对象"的 API - -所有 `New X` 最后都落到**同一个方法**: - -```java -KP.B(target, "Bone"); // mn_1 New Bone -KP.B(target, "Slot"); // mo New Slot -KP.B(target, "Skin Placeholder"); // mp -KP.B(target, "Bounding Box"); // mq -KP.B(target, "Path"); // ms_1 -KP.B(target, "Clipping"); // mt -KP.B(target, "IK Constraint"); // mu_1 -KP.B(og_1.D.D.D.D, "Path Constraint"); // mv_0 -KP.B(og_1.D.D.D.D, "Skin"); // mx_0 -``` - -另有树视图自己的分支:`acM.java:98`、`yi_0.java:28`。 - -**这就是"增"的直接入口**:第一个参数是**父级对象**(选中项),第二个参数是类型名。 -也就是说编辑器内部不是"凭空建一根骨头",模型本身就是"挂在父对象下面的树"。 - -### ⚠️ 还没定死:`KP` 到底是哪个类 - -- 载荷里确实有个 `KP.class`,但它是 `KP extends eW` 的 **SelectBox 控件**, - 只有 `public static fI D(fI)` 一个静态方法,**没有 `B`**,不是我们要的那个。 -- 可读源码树里的 `KP` 来自**解码 invokedynamic 调用点**(编辑器把几乎所有调用都编译成 - invokedynamic,所以 `javap` 里看不到 `invokestatic KP.B`)。调用映射在 - `~/Desktop/spine398/obmap.tsv`,能看到几十条 `KP.B` / `KP.D` / `KP.L`(如 `RE mq KP.B`、`Yr gy KP.D`)。 - -→ 下一步:用 `tools/decrypt/` 把某个调用点(例如 `mn` 里那次)的**真实 owner 类**解出来, -或在运行时按"方法名 + 描述符(对象 + String 两参)"在类表 `LPt4.mc` 里搜候选类逐个验证。 - -## 四、改 / 删 / 查 - -| 操作 | 入口 | 说明 | +| 菜单名 | 动作类(CFR 名) | 载荷类名 | | --- | --- | --- | -| 删 | `Delete`(`pb_0`) | 作用在当前选择上 | -| 改名 | `Rename`(`lo`) | | -| 复制 | `Duplicate`(`lp_0`)、`Cut`/`Copy`/`Paste`(`pv`/`pw`/`px_2`) | | -| 层级 | `Set Parent`(`mm_1`)、`Parent`/`Child`/`Previous Sibling`/`Next Sibling` | | -| 顺序 | `Draw Order Up`/`Down`(`lu_1`/`ls_1` 等) | | -| 查 | 树节点本身就是查询结果;`Select - Bones`/`Select - Attachments` 负责筛选 | | +| `Create Tool` | `oo` | `oo` | +| `New Bone` / `New Slot` / `New Skin Placeholder` / `New Bounding Box` / `New Path` / `New Clipping` | `mn_1` / `mo` / `mp` / `mq` / `ms_1` / `mt` | `mn` / `mo` / `mp` / `mq` / `ms` / `mt` | +| `Delete` / `Rename` / `Duplicate` | `pb_0` / `lo` / `lp_0` | `pB` / `lo` / `lp` | +| `Select - Bones` / `Select - Attachments` | `pn_0` / `pt_1` | `pn` / `pt` | +| `Export` / `Save Project` | `md` / `ly_1` | `md` / `ly` | +| `Undo` / `Redo` | `lm_0` / `nw` | `lm` / `nw` | -**撤销**:`Undo`/`Redo` 也在这套命令系统里(`lm_0`/`nw`)。菜单路径的动作会进撤销栈; -直接反射调 `KP.B` 很可能**绕过撤销** —— 真要写入时优先走菜单命令(`Commands.run`), -或先看清动作类自己有没有压栈。 +出处:`ll.java:77,136,141,144-148,166-200,201-206`。 -## 五、删除边界(需求确认) +**关键点:这些动作几乎都只是"点某个界面按钮"或"改某个下拉框"**,真正的逻辑在那些控件的监听器里。 -- `skeleton`:整个场景的根节点,**可以删**(删它等于清空所有场景对象); -- `root`:所有对象的根骨骼,只要 skeleton 在就必然存在,**不允许删除**。 +--- -面板做删除时要在选择集里拦住 `root`。 +## 3. 层级树模型:节点 → 场景对象(✅ 已运行时验证) -## 六、下一步(按顺序) +运行时实测(探针见 `viewpanel/src/com/spineview/SceneObjects.java`,用 `-Dspine.probe=objects` 触发): -1. 解出 `KP` 的真实类名(indy 调用映射 / 运行时按签名搜 `LPt4.mc`),确认 `B(Object,String)` 的描述符。 -2. 确认"父级对象"参数的确切类型:`xw_2.D`(skeleton 节点里的骨架数据)、`acK.D`(slot 节点里的数据)。 -3. 搞清选择怎么设置(`og_1.D.D` 是可直接写的字段,还是只能由视图点击设置)—— - 否则"对着指定对象增删改"无从指定。 -4. 确认新建/删除后**谁负责刷新界面**(`VZ` 重扫模型?动作里带 invalidate?)。 -5. 最后才做面板 UI:对象树列表 + 新建/删除/改名按钮。 +``` +Uo -> GP skeleton name=skeleton ← 最上面的 skeleton 节点 + Uo -> Gp bone name=root ← root 骨骼(它的删除按钮是 disabled 状态) + Uo -> Gp bone name=bone + Uo -> GU slot name=mega_dg4 + Uo -> GO GO name=mega_dg4 ← 附件 + ... + Uo -> Gk Gk ... ← Skins / Draw Order / Events / Animations 这些固定根 +``` + +| 类(载荷名) | 含义 | 出处 | +| --- | --- | --- | +| `Uq` | libGDX `Tree` 控件 | `Uq extends com.badlogic.gdx...ui.Tree` | +| `eM` / `Uo` | libGDX `Tree.Node`;`eM.D:Ljava/lang/Object;` = 节点上挂的对象,`eM.D:LfI;` = children | `javap -p -cp <载荷> eM` | +| `Gj` | 场景对象基类(`GP`/`Gp`/`GU`/`GT`/`GO`… 都 extends `Gj`) | `javap -p` | +| `GP` | **骨架对象**(一个 skeleton 一份):名字 + 骨骼/插槽/附件表 | `GP.toString()` 返回名字;`GP.D(Ljava/lang/String;)LGp;` = 按名字找骨骼 | +| `Gp` | **骨骼**(`D:LGP;` 指向所属骨架,`D:LGp;` 指向父骨骼,`D:Ljava/lang/String;` 名字) | `javap -p` | +| `GU` | **插槽**(`D:LGn;` = 当前附件) | `javap -p` | +| `GO` | **附件**(实测节点上挂的就是它) | 探针输出 | +| `Gk` / `Gl` / `pL` … | 树上的分组/文件夹节点(Skins、Events、Animations…) | 探针输出 | + +### 查(列对象) + +| 目的 | API | 说明 | +| --- | --- | --- | +| 列**全部**节点对象(带层级) | 从 `OG.D:LUq;`(Tree 控件)出发,读它的 root 节点数组 + 逐层 `Tree.Node.children` | 探针实测有效;面板就是这么做的 | +| 列**当前选中**的对象 | `VZ.D(fI)LfI;`:把选中对象的数组填进传入的 `Array` 返回(选择为空时返回 0 个) | `javap -c VZ`:遍历 Selection 后逐个 `fI.add(node.object)` | +| 取"最后一个选中对象" | `VZ.B()Ljava/lang/Object;` = `Tree.Selection.getLastSelected().object` | 逐站点表:`VZ.B() → virtual fC.D()Ljava/lang/Object;` | + +--- + +## 4. 选择、增、删、改(全部落到载荷字节码,逐条可复现) + +### 4.1 当前选择怎么设置(问题 3) + +```java +Object treeView = OG.D; // 字段 D:LVZ —— Tree 视图 +treeView.D(Object sceneObject); // VZ.D(Ljava/lang/Object;)LUo; = Tree.findNode + 选中,返回 Tree.Node +treeView.B(); // VZ.B()Object = 取当前选中对象 +``` + +`VZ.D(Object)` 的字节码:`getfield D:LUq;`(Tree 控件)→ `Uq.D(Object)LUo;`(= `Tree.findNode`) +→ 再调 `VZ.D(Uo)` 选中。**这个方法是 public、可以直接反射调用**,面板就是用它"点谁选谁"。 + +### 4.2 "增":`New Bone` 其实是在改一个 SelectBox(问题 1、2) + +`mn`(New Bone)的字节码(`javap -c -cp <载荷> mn`): + +``` +getstatic OG.D:LVZ // Tree 视图 +getfield VZ.D:LVY // 当前选中对象的**属性面板控件**(VY 的子类) +instanceof XW // XW = 骨架对象的属性面板 +getfield XW.D:LKP; // 解析到 Yr.D:SelectBox —— 属性面板上的"类型下拉框" +... OG.Z(cipher) → "Bone" +invokedynamic hEjBGGtT : (Object,Object)V → virtual KP.B(Ljava/lang/String;)V +``` + +`hEjBGGtT` 的密文解出来就是 `\u00b6 2 KP 1 B (Ljava/lang/String;)V`,即 +**`KP.B(String)V` = libGDX `SelectBox.setSelected(String)`**;同理 +`KP.B()Ljava/lang/String;` = `getSelected()`、`KP.B(I)V` = `setSelectedIndex(int)`。 + +也就是说 `New Bone` = "把选中对象的类型下拉框设成 Bone",**真正建对象的代码在下拉框的 ChangeListener 里**: + +| 监听器 | 内容 | +| --- | --- | +| `Yi`(`yi_0.java:27-80`) | `if (getSelected().equals("Bone")) { gp_2 bone = new gp_2(); bone.D = <选中对象>; … GP.D(bone.D, bone); GP.StackMapTable(bone.D); Editor.D(editor, bone.D, true); VZ.D(editor, bone.D, true, true); VZ.L(editor, null); }`,另有 `Slot`/`Bounding Box`/`Clipping`/`Point`/`Path`/`IK Constraint`… 分支 | +| `acM.D(fh_2,dl)`(`acM.java:98-150`) | 树上"在当前节点下新建"的同款分支 | + +**结论**:没有"`create(parent, type)`"这种单函数 API。要建对象有两条路: + +1. **走编辑器自己的路**(推荐):先 `VZ.D(parentObject)` 选中父级,再 `Commands.run("New Bone")` —— + 编辑器自己改下拉框 → 触发监听器 → 建对象、插树节点、进撤销栈。 +2. 直接复刻监听器里的模型调用(`new Gp()` → 设字段 → `GP.D(bone)`/`GP.StackMapTable()` → `Editor.D(...)` → `VZ.D(...,true,true)`), + 麻烦且容易漏步骤(面板不做这条路)。 + +### 4.3 父级参数到底是什么(问题 2) + +`XW` / `acW` / `acK` / `Yr` 这些**不是场景数据**,而是**每种对象的"属性面板"控件**(`extends VY extends Table`), +在 `vn_0.java:77,85`(载荷类 `VN`)里被成批 `new` 出来注册。 +`VZ.D:LVY` 就是"当前显示的那个属性面板"。所以: + +- `((XW)sel).D` = 骨架属性面板上的**类型下拉框**(SelectBox),不是骨架数据; +- 场景数据在树节点上(`eM.D:Ljava/lang/Object;`),实测类型是 `GP`/`Gp`/`GU`/`GO`。 + +### 4.4 "删"与"改":都是点属性面板上的按钮 + +`pB`(Delete,`javap -c -cp <载荷> pB`): + +```java +if (OG.D:LEditor.D instanceof In) { In.D 关闭; return true; } // 有弹窗先关弹窗 +if (Editor.C) { OG.D:LtD → tD.D:LTD → TD.D:LSl → Sl.Z(ImageButton).a_(); return true; } +VZ treeView = OG.D; // Tree 视图 +if (treeView.D instanceof VO) { // 选中对象的属性面板 + ImageButton delete = ((VO) treeView.D).L; // ← 删除按钮 + if (!delete.L /*isDisabled*/) delete.a_(); // 点它(disabled 就不点) +} +``` + +`lo`(Rename):`if (!((VO) treeView.D).B.L) ((VO)treeView.D).B.a_();` —— 点**改名按钮**。 + +**删除的边界正好是需求要的**:`Delete` 会检查按钮的 disabled 位; +`root` 骨骼的按钮是 disabled(编辑器自己拦住),而 skeleton 节点可以删。 + +真正的删除动作在确认框回调里(`vq_0.java:27-62`): + +```java +if (!ok) return; +un.D(); // ① 撤销栈:记录一次编辑 +for (Tree.Node node : selection.D()) { // 遍历选中节点 + Gp bone = (Gp) node.object; + bone.L(); // ② 从模型里摘掉 + hd.D(set, bone.D); // 记下受影响的骨架 +} +un.B(); // ③ 撤销栈:提交 +rW.B(editor, true); // ④ 刷新视口 +for (GP skeleton : set) VZ.D(editor, skeleton, true, true); // ⑤ 重建树节点 +fC.C(); // ⑥ 清空选择 +``` + +### 4.5 撤销栈(问题 5) + +载荷类 **`un`** 就是撤销/重做栈(`javap -p -c -cp <载荷> un`): + +| 方法 | 含义 | +| --- | --- | +| `un.D()` / `un.B()` | **成对使用**:一次可撤销编辑的"开始 / 提交"(内部都落到 `private static D(String 标签)`,会顺手砍掉 redo 分支) | +| `un.B(Object)` | 把某个对象记进当前编辑(存一份拷贝,`Editor.D:LsF` 提供拷贝) | +| `un.L(Object)` | 从当前编辑里移除该对象 | +| `un.L()` | 清空历史 | +| `Undo`/`Redo` 菜单(`lm_0`/`nw`) | `mv_2.D = true; og_1.D.D.B.a_()` —— 点工具栏按钮,靠 `mv_2.D` 静态标志避免递归入栈 | + +**结论**:直接反射改模型(`bone.L()` 之类)**不会**自动进撤销栈,必须自己 `un.D()` … `un.B()` 包一层; +走菜单/按钮的那条路天然带撤销。面板优先走菜单命令。 + +--- + +## 5. 模型编辑助手 `uh_0`(载荷类,`OG.D:LUH;`) + +`uh_0`(`uh_0.java`,1700 行)是"直接改模型"的 API 集合(`javap -p -cp <载荷> uh`): + +| 方法 | 作用(按调用点推断) | +| --- | --- | +| `D(LGp;LGp;Z…)LGp;` | 加骨骼 | +| `D(LGU;LGp;Z…)LGU;` | 加插槽 | +| `D(LGn;LGU;ZZ…)LGn;` | 加附件 | +| `D(LGT;ZZ)V` / `D(LGT;)V` | 附件相关 | +| `B(GP;LFL;)V` / `L(GP;LFL;)V` / `D(GP;LFL;)V` | 新建事件 / 新建动画(会弹"输入名字"对话框) | + +它是 `New X` 里"建完对象之后往模型里塞"的那部分;除非要绕开 UI,否则不必自己调。 + +--- + +## 6. 查 / 选 / 导出(问题 6) + +| 操作 | 真实实现 | 出处 | +| --- | --- | --- | +| `Select - Bones` | 先 `fC.C(tree)`(清空选择),再对每个骨骼 `VZ.D(editor, bone)` 逐个选中,最后 `VZ.ConstantCallSite/MethodHandle/F(...)` 刷新 + `SX.LineNumberTable(...)` 刷新属性面板 | `pn_0.java:22-36` | +| `Select - Attachments` | 同上,只是查的是附件 | `pt_1.java:22-36` | +| `Rename` | 点属性面板的改名按钮 → 就地编辑名字 TextField(改完由监听器写回模型) | `lo.java:15-26` | +| `Delete` | 见 §4.4(含确认框 `Confirm Delete` 与撤销栈) | `pb_0.java:17-42`、`Yr.java:353-380`、`vq_0.java:27-62` | +| `Export` | `md.D()` = `og_1.D.D.S.a_()`,点"导出"按钮弹出导出配置框 | `md.java:13-19` | +| 属性面板刷新 | `SX.LineNumberTable(og_1.D.D.D.D)` | `pn_0.java:35` | + +> **导出链路(本轮确认)**:阶段二用的"面板产出 JSON → 命令行 `Spine --import`"是**进程外**方案; +> 现在面板跑在编辑器进程内,`Export` 就是编辑器自己的菜单动作,直接 `Commands.run("Export")` 即可, +> 不再需要外部 CLI(外部方案仍可作兜底,见 `阶段二-面板实现.md:55-70`)。 + +--- + +## 7. 上一轮两条错误结论的错因(复盘) + +1. `KP.B(parent,"Bone")` 被当成"建对象 API":`rewrite_source.py` 按**混淆名**改名、丢了描述符, + `SelectBox.getSelected()`/`setSelected(String)`/`setSelectedIndex(int)` 全被写成 `KP.B`。 + 实测解码(`tools/decrypt/DumpSites.java`)才是真相:`virtual KP.B(Ljava/lang/String;)V`。 +2. `xw_2.D` 被当成"骨架数据":CFR 把 `XW.D` 解析到了父类 `Yr.D:SelectBox`,而 `xw_2` 本身 + **不是**树节点,是骨架的**属性面板控件**;场景数据是树节点上的 `GP`/`Gp`/`GU`。 + +--- + +## 8. 本轮新增的工具(都可复现) + +| 工具 | 用途 | 用法 | +| --- | --- | --- | +| `tools/decrypt/DumpSites.java` | **逐站点**解出每个 indy 调用的"真实类.方法 + 描述符" | `java -cp "asm.jar:asm-tree.jar:tools/decrypt/build:载荷(全 public 化)" com.spineview.dec.DumpSites <载荷> [类名…]` | +| `tools/decrypt/ResolveSites.java` | 同上,但走运行时 `CallSite` 解析(交叉验证用) | `java ... com.spineview.dec.ResolveSites <载荷> <类名…>` | +| `tools/decrypt/make_public.py <载荷> <输出zip>` | 把载荷方法全 public 化(indy 引导方法要能调) | 见 `混淆名对照表.md` §一 | +| `~/Desktop/spine398/sites.tsv` | 全载荷 55637 个站点的解码结果(`caller / indyName / siteDesc / decoded / nearbyLdc`) | 查"某方法里的调用到底是什么" | +| 探针 `-Dspine.probe=objects` | 运行时把树里的场景对象整份打到日志 | 见 `viewpanel/src/com/spineview/SceneObjects.java` | + +--- + +## 9. 还没弄清 / 还没验证的点 + +1. `Commands.run("New Bone")` 在**已选中父级**时是否真能建出骨骼(本轮要实现并截图验证)。 +2. `Delete` 的确认框是否对所有对象类型都弹(目前只知道骨骼会弹)。 +3. 属性面板控件(`Yr`/`acW`)上各按钮的语义(`VO.D`/`VO.B`/`VO.L` 已确认是"某几个按钮",逐个对应关系未逐条验证)。 +4. `un.D()/un.B()` 的精确语义("开始/提交"是从 `vq_0` 的用法推断的),以及能否由面板安全地手工包一层。