- 新工具 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 增补类/方法对照与大小写同名类坑。
266 lines
15 KiB
Markdown
266 lines
15 KiB
Markdown
# 编辑器交互逻辑 & 数据 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` 的用法推断的),以及能否由面板安全地手工包一层。
|