Files
Spine_lab/编辑器交互与数据API.md
T
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

266 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 编辑器交互逻辑 & 数据 API 调研
目标:在动手写"面板里对场景对象增删改查"之前,先把编辑器**用户交互链路**和**真正生成数据的代码**摸清楚。
本文只记录**已核实的事实 + 出处 + 还没弄清的地方**。混淆名 ⇒ 真实名见 [`混淆名对照表.md`](混淆名对照表.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)
```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` 的用法推断的),以及能否由面板安全地手工包一层。