- 新工具 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 增补类/方法对照与大小写同名类坑。
15 KiB
编辑器交互逻辑 & 数据 API 调研
目标:在动手写"面板里对场景对象增删改查"之前,先把编辑器用户交互链路和真正生成数据的代码摸清楚。
本文只记录已核实的事实 + 出处 + 还没弄清的地方。混淆名 ⇒ 真实名见 混淆名对照表.md。
⚠️ 上一轮的两条结论是错的,本文已改正(错因见 §7):
- "
KP.B(父级, 类型名)是建对象的 API" ——KP是 libGDXSelectBox,B(String)是setSelected;- "
xw_2.D/acK.D是骨架/slot 数据" —— 它们是选中对象的属性面板控件,不是场景数据。
0. 读载荷的三个坑(踩过,写下来免得再踩)
- 类名大小写敏感,macOS 文件系统不敏感。载荷里同时存在
OG/og、XW/xW、Yr/yR、Gp/GP、VZ/vz这类只差大小写的类。 把 zip 解到 mac 的目录里会互相覆盖(javap -cp <目录>会给出自相矛盾的答案)。 → 一律直接从 zip 读(javap -cp <载荷.zip> <类名>,或python3 /tmp/clsdump.py <zip> <类名>)。 - 同名不同描述符:
OG上有 34 个都叫D的静态字段(D:LEditor;、D:LVZ;、D:LUq;、D:LSkin;…)。 反射取字段必须名字 + 类型一起判。 - 可读源码里的方法名不带描述符:
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。要建对象有两条路:
- 走编辑器自己的路(推荐):先
VZ.D(parentObject)选中父级,再Commands.run("New Bone")—— 编辑器自己改下拉框 → 触发监听器 → 建对象、插树节点、进撤销栈。 - 直接复刻监听器里的模型调用(
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. 上一轮两条错误结论的错因(复盘)
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。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. 还没弄清 / 还没验证的点
Commands.run("New Bone")在已选中父级时是否真能建出骨骼(本轮要实现并截图验证)。Delete的确认框是否对所有对象类型都弹(目前只知道骨骼会弹)。- 属性面板控件(
Yr/acW)上各按钮的语义(VO.D/VO.B/VO.L已确认是"某几个按钮",逐个对应关系未逐条验证)。 un.D()/un.B()的精确语义("开始/提交"是从vq_0的用法推断的),以及能否由面板安全地手工包一层。