Files
Spine_lab/编辑器交互与数据API.md
tianmo 7fbb042aa6 编辑器交互/数据 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 增补类/方法对照与大小写同名类坑。
2026-10-16 14:38:25 +08:00

15 KiB
Raw Permalink Blame History

编辑器交互逻辑 & 数据 API 调研

目标:在动手写"面板里对场景对象增删改查"之前,先把编辑器用户交互链路和真正生成数据的代码摸清楚。 本文只记录已核实的事实 + 出处 + 还没弄清的地方。混淆名 ⇒ 真实名见 混淆名对照表.md。

⚠️ 上一轮的两条结论是错的,本文已改正(错因见 §7):

  1. "KP.B(父级, 类型名) 是建对象的 API" —— KP 是 libGDX SelectBox,B(String) 是 setSelected;
  2. "xw_2.D / acK.D 是骨架/slot 数据" —— 它们是选中对象的属性面板控件,不是场景数据。

0. 读载荷的三个坑(踩过,写下来免得再踩)

  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)。

1. 全局入口:OG(编辑器静态持有者)

OG 是一个只有静态字段/方法的类,编辑器的一切都挂在它上面(javap -p -cp <载荷> OG):

字段(载荷名 + 描述符) 真实含义
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 = 骨架对象)

出处:javap -p -cp <载荷> OG;Uq.<init> 结尾 putstatic OG.D:LUq;。


2. 交互层:工具(Tool)与菜单

所有工具/菜单都在菜单注册表 ll 的构造函数里注册(~/Desktop/spine398/src-readable/ll.java):

菜单名 动作类(CFR 名) 载荷类名
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

出处:ll.java:77,136,141,144-148,166-200,201-206。

关键点:这些动作几乎都只是"点某个界面按钮"或"改某个下拉框",真正的逻辑在那些控件的监听器里。


3. 层级树模型:节点 → 场景对象(✅ 已运行时验证)

运行时实测(探针见 viewpanel/src/com/spineview/SceneObjects.java,用 -Dspine.probe=objects 触发):

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)

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):

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):

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 的用法推断的),以及能否由面板安全地手工包一层。