编辑器交互/数据 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:
1 parent
2b23ce59db
commit
7fbb042aa6
6 files changed
+990
-126
No files matched your search
@@ -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 <载荷> <out.tsv> [类名...]
|
||||
*/
|
||||
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<String> filters = new ArrayList<String>();
|
||||
for (int i = 2; i < args.length; i++) filters.add(args[i]);
|
||||
|
||||
List<String[]> rows = new ArrayList<String[]>();
|
||||
try (ZipFile zip = new ZipFile(payload.toFile())) {
|
||||
Enumeration<? extends ZipEntry> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<String> filters = new ArrayList<String>();
|
||||
for (int i = 1; i < args.length; i++) filters.add(args[i]);
|
||||
List<Site> sites = new ArrayList<Site>();
|
||||
try (ZipFile zip = new ZipFile(payload)) {
|
||||
Enumeration<? extends ZipEntry> 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<String, Method> bootstrapCache = new HashMap<String, Method>();
|
||||
MethodHandles.Lookup[] lookupCache = new MethodHandles.Lookup[1];
|
||||
Map<String, String> resolved = new LinkedHashMap<String, String>();
|
||||
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<String> 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<String, Method> 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<MethodHandles.Lookup> 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.<init>` 末尾 `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 → 骨架对象 | 查 |
|
||||
|
||||
| 结论 | 证据 |
|
||||
| --- | --- |
|
||||
|
||||
+241
-99
@@ -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 <zip> <类名>`)。
|
||||
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.<init>` 末尾 `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.<init>` 结尾 `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 <载荷> <out.tsv> [类名…]` |
|
||||
| `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` 的用法推断的),以及能否由面板安全地手工包一层。
|
||||
Reference in new issue
Block a user