# 编辑器交互逻辑 & 数据 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 <类名>`)。 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.` 末尾 `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.` 结尾 `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 <载荷> [类名…]` | | `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` 的用法推断的),以及能否由面板安全地手工包一层。