# 混淆名对照表(Spine 3.8.99 载荷) 这份文档记录**载荷里被混淆的类名/方法名/字段名分别对应什么**,以及**每一条是怎么推导出来的**。 以后要动编辑器内部的 scene2d 控件(加面板、读数据、导出),都先查这里,不要重新猜。 代码里对应的是 [`viewpanel/src/com/spineview/Obf.java`](viewpanel/src/com/spineview/Obf.java), 里面有同样的常量表,改代码时直接引用。 --- ## 一、推导办法(可复现) 1. **字节码指纹比对**:`tools/obfmap/MapMethods.java` - 把载荷类与**上游 libGDX 同签名方法**逐个比对:忽略名字,只比较 "操作码序列 + 常量 + 成员的描述符"(类名按"方法内首次出现的顺序"编号,绕开两边类名不同的问题)。 - 用法: ```bash CP=tools/asm/asm.jar:tools/asm/asm-tree.jar:tools/obfmap/build "$JAVA17" -cp "$CP" MapMethods <上游.class> <载荷.class> # 输出:载荷方法签名 -> 上游方法名(= 唯一匹配,~n/m = 按声明顺序消歧) ``` - 编译: ```bash "$JAVA17" -cp tools/asm/asm.jar:tools/asm/asm-tree.jar -d tools/obfmap/build tools/obfmap/MapMethods.java ``` 2. **上游源码核对**:拿 libGDX 1.11.0 的源码逐条看方法体语义 (`gdx/src/com/badlogic/gdx/scenes/...`),确认指纹给出的结论。 3. **运行期验证**:面板布局/按钮/文字这些已经在编辑器里真跑过,见文末"验证状态"。 > 上游版本怎么挑的:Cell/Table/Actor 的字段结构在 1.9.13 / 1.9.14 / 1.11.0 上一致, > 指纹命中率也相同,所以用 1.11.0 作为参照(字段类型 `Value`、`Cell.align` 是 `Integer`)。 --- ## 二️、最大的坑:**同名不同描述符** JVM 允许同一个类里存在"**名字相同、描述符不同**"的字段和方法,这个载荷把这一招用满了: - `dR`(Cell)里有 **30 多个字段**都叫 `D`/`B`/`L`/`I`,靠类型区分 (`eN D`(Value)、`dl D`(Actor)、`float D`、`et D`(Table)…); - `et`(Table)上 `D` 这个名字同时是 `add(Actor)`、`align(int)`、`pad(float)`、`pad(float×4)`… **所以:定位任何成员都必须"名字 + 描述符"一起判**,只看名字一定踩雷。已经踩过的两个: | 踩坑 | 表现 | 原因 | | --- | --- | --- | | `Label` 的 `D(CharSequence)` | 日志打印"读取完成 N 字符",**面板文字却一直不刷新** | 有 `D(CharSequence)V`(= `setText`)和 `D(CharSequence)Z`(= `textEquals`)两个,按"1 个 CharSequence 参数"找会挑到 `textEquals` | | `Table` 的 `I()` | 调到 `top()` 而不是 `row()` | `I()LdR;` = `row()`(返回 Cell)、`I()Let;` = `top()`(返回 Table),必须判返回类型 | --- ## 三、类对照 | 载荷名 | 真实类 | 依据 | | --- | --- | --- | | `dl` | `com.badlogic.gdx.scenes.scene2d.Actor` | 无父类;`do extends dl`;有 `stage`/`parent` 语义 | | `do` | `...scene2d.Group` | `extends dl`;children 是 `[Ldl;` | | `eX` | `...ui.WidgetGroup` | `extends do`;有 `setFillParent` 语义(见 `et.Z(boolean)`) | | `et` | `...ui.Table` | `extends eX`;有 `cells`(`fI`) / `rowDefaults`(Cell) | | `dR` | `...ui.Cell` | `et.add(Actor)` 的返回类型;`implements hg`(=Poolable) | | `dt` | `...scene2d.Stage` | `uk extends dt` | | `uk` | Stage 的实现(编辑器实际用的那个) | StageHook 挂在它的构造器上 | | `eN` | `...ui.Value` | 抽象类;`Cell` 的尺寸/间距字段都是它 | | `fI` | `com.badlogic.gdx.utils.Array` | `Table.cells` 的类型 | | `fu` | `...scene2d.utils.Drawable` | `Table.background(Drawable)` | | `hg` | `com.badlogic.gdx.utils.Pool.Poolable` | `Cell implements hg` | | `OG` | 编辑器单例持有者 | 静态字段 `D` = `com.esotericsoftware.spine.editor.Editor` | | `Fx` | 静态 Actor 锚点 | 静态字段里有 `dl` 实例,用来倒推 Stage | | `us` | 视图枚举(`Editor`/`Preview`/`Tree`/…) | `` 里建常量、按 `MV`/`ut` 分组 | | `tD` | 编辑器 UI 装配类 | 给每个视图 new 出面板 Actor 的地方 | --- ## 四、方法对照 ### 4.1 `et` = Table | 载荷签名 | 真实方法 | 依据 / 备注 | | --- | --- | --- | | `D(Ldl;)LdR;` | `add(Actor)` | 返回 Cell;运行期已用于把按钮/文字区放进面板 | | `D([Ldl;)Let;` | `add(Actor...)` | 返回 Table(varargs 版,别和上面混) | | `L()LdR;` | `add()` | 体 = `return add((Actor)null)` | | `I()LdR;` | **`row()`** | 体与上游 `Table.row()` 逐句一致:判 `cells.size`、`endRow()`、`invalidate()`、换 `rowDefaults`;**不调它所有控件都会挤在第一行** | | `D(I)LdR;` | `getCell(int)` | 不够就补 cell | | `B(Ldl;)LdR;` | `getCell(Actor)` | 遍历 cells 找 actor | | `S()LdR;` | `getRowDefaults()`(行默认值 Cell) | 体 = `return rowDefaults` | | `Z(Z)V` | **`setFillParent(boolean)`** | 体 = `this.C = bl`(纯赋值) | | `C(Z)V` | `setClip(boolean)` | 体 = `clip = bl; setTransform(bl); invalidate()` | | `B(Z)V` | `setDebug(boolean)` | 体 = `debug(bl ? Debug.all : Debug.none)`;**调错会让单元格描上彩色边框**(踩过) | | `I()Let;` | `top()` | 返回 Table | | `S()Let;` | `left()` | | | `L()Let;` | `center()` | | | `C()Let;` | `bottom()` | | | `Z()Let;` | `right()` | | | `D(I)Let;` | `align(int)` | | | `D(F)Let;` | `pad(float)` | | | `D(FFFF)Let;` | `pad(top,left,bottom,right)` | | | `B(F)Let;` | `padTop(float)` | 与 `L/I/S(F)Let;` = padLeft/padBottom/padRight 一组(同形状,按声明顺序对) | | `D(Ljava/lang/String;)Let;` | `background(String)` | | | `D(Lfu;)Let;` | `background(Drawable)` | | ### 4.2 `dR` = Cell 尺寸/间距(参数类型不同,靠描述符区分;都返回 Cell): | 载荷签名 | 真实方法 | | --- | --- | | `D(dl)LdR;` | `setActor(Actor)` | | `D(eN,eN)LdR;` | `size(Value width, Value height)` | | `D(F)LdR;` | `size(float)` | | `D(FF)LdR;` | `size(float,float)` | | `D(eN)LdR;` / `B(eN)LdR;` | `width(Value)` / `height(Value)` | | `B(F)LdR;` / `L(F)LdR;` | `width(float)` / `height(float)` | | `L(eN)LdR;` / `I(eN)LdR;` | `minWidth(Value)` / `minHeight(Value)` | | `B(eN,eN)LdR;` / `S(eN)LdR;` | `minSize(Value,Value)` / `minSize(Value)` | | `C(eN)LdR;` / `Z(eN)LdR;` | `prefWidth(Value)` / `prefHeight(Value)` | | `S(F)` / `C(F)` / `Z(F)` / `V(F)` | `minWidth(float)` / `minHeight(float)` / `prefWidth(float)` / `prefHeight(float)` | | `F(F)LdR;` 起连续 5 个 `(F)LdR;` | `maxWidth / maxHeight / padTop / padLeft / padBottom / padRight`(**同形状成组,按声明顺序对,未逐条验证**) | | `V(eN)LdR;` / `F(eN)LdR;` / `Code(eN)LdR;` | `space(Value)` / `spaceBottom(Value)` / `spaceRight(Value)` | | `Code(F)` / `SourceFile(F)` / `StackMapTable(F)` / `init(F)` / `LineNumberTable(F)` | `space(float)` / `spaceTop(float)` / `spaceLeft(float)` / `spaceBottom(float)` / `spaceRight(float)` | | `String(F)LdR;` | `pad(float)` | | `D(FFFF)LdR;` | `pad(top,left,bottom,right)` | 布局开关(**这几条是面板能排好版的关键**): | 载荷签名 | 真实方法 | 依据 | | --- | --- | --- | | `D()LdR;` | `fill()` | 体 = 两个 float 字段 = `1f`(上游 `fillX = fillY = onef`) | | `B()LdR;` / `L()LdR;` | `fillX()` / `fillY()` | 各设一个 float 字段 | | `StackMapTable()LdR;` | `expand()` | 体 = 两个 Integer 字段 = `onei`(上游 `expandX = expandY = onei`) | | `init()LdR;` / `LineNumberTable()LdR;` | `expandX()` / `expandY()` | | | `F()LdR;` | `grow()` | 指纹唯一命中 | | `Code()LdR;` / `SourceFile()LdR;` | `growX()` / `growY()` | 同形状成组 | | `C()LdR;` | `left()` | 体 = `align\|8 & ~16` | | `S()LdR;` | `top()` | 体 = `align\|2 & ~4` | | `Z()LdR;` | `bottom()` | 体 = `align\|4 & ~2` | | `V()LdR;` | `right()` | 体 = `align\|16 & ~8` | | `I()LdR;` | `center()` | 体 = `align = 1i` | | `D(I)LdR;` | `align(int)` | | | `D(Z)LdR;` / `B(Z)LdR;` / `D(ZZ)LdR;` | `fill/uniform` 系(**未逐条验证**) | | | `D()F`,`B()F`,`L()F`,`I()F` | `getMinWidth/getMinHeight/getPrefWidth/getPrefHeight` | | | `B()V` / `L()V` / `D()V` | `row()` / `clear()` / `reset()` | | 位掩码常量(都是 `Integer` 静态字段,值来自 libGDX `Align`): `C=1`(onei)、`Z=1`(center)、`V=2`(top)、`F=4`(bottom)、`Code=8`(left)、`SourceFile=16`(right)。 ### 4.3 `com.badlogic.gdx.scenes.scene2d.ui.Button`(类名保留,方法名混淆) | 载荷签名 | 真实方法 | | --- | --- | | `String()Z` | `isChecked()`(体 = `return isChecked`,读第 1 个 boolean 字段) | | `I(Z)V` | `setChecked(boolean)`(指纹唯一命中) | | `B(ZZ)V` | `setChecked(boolean, boolean)` —— 第二参是"是否派发事件",**复位时传 false**,否则点一次会读两次 | | `a_(Z)V` | `setDisabled(boolean)` | | `java()Z` | `isDisabled()` | | `Class()Z` / `Ljava()Z` | `isOver()` / `isPressed()`(体分别是 `clickListener.D()` / `clickListener.B()`,两者顺序未逐条验证) | ### 4.4 `com.badlogic.gdx.scenes.scene2d.ui.Label`(类名保留,方法名混淆) | 载荷签名 | 真实方法 | 备注 | | --- | --- | --- | | `D(Ljava/lang/CharSequence;)V` | `setText(CharSequence)` | | | `D(Ljava/lang/CharSequence;)Z` | `textEquals(CharSequence)` | **陷阱**:长得像 setText,调它文字永远不刷新 | | `D()LN;` | `getText()`(返回 `StringBuilder`) | | | `D(LabelStyle)V` / `D()LabelStyle` | `setStyle` / `getStyle` | | ### 4.5 `uk`/`dt` = Stage、`Fx`/`OG` = 入口 | 载荷名 | 真实含义 | 备注 | | --- | --- | --- | | `uk.` | Stage 实现类的构造器 | Stage 在编辑器里只是局部变量,只能钩构造器拿实例 | | `Fx` 的静态 `dl` 字段 | 一个静态 Actor | 由它 `Actor.stage` 反查 Stage(比钩构造器更省事) | | `OG.D` | 编辑器单例 | `com.esotericsoftware.spine.editor.Editor` | | `us.` | 视图枚举初始化 | 加新视图必须在 `$VALUES` 组装之后、分组列表生成之前插 | --- ## 四之二、命令系统(增删改查的入口) 编辑器的所有菜单动作都走同一套命令系统。链路(反编译源码里读出来的): ``` OG.D → com.esotericsoftware.spine.editor.Editor 单例 └─ public pE D; → 命令 / 热键系统(Editor. 里 new pe_0()) ├─ void D(String) → 按名字执行(名字 = hotkeys.txt / 菜单项名) ├─ pC D(String) → 按名字取菜单项(不执行) └─ private final ll D; → 菜单注册表:D(String,pC,String...) 注册、D(String) 查表 ``` 菜单项基类 `pC`(所有动作类都继承它,例如 `mn_1`=New Bone、`pb_0`=Delete): | 成员 | 含义 | | --- | --- | | `boolean D()` | **执行并返回"是否处理了"**(`pC` 基类实现 = `og_1.D.S.a_(); return true;`) | | `void D()` | 执行(无返回值);基类里是空实现,动作类各自覆盖 | | `String D` | 名字(`toString()` 返回它) | > 注意:`pE.D(String)` 只看"找得到找不到",**不看动作有没有真的生效**。 > 实测 `-Dspine.runCommand="New Bone"` 打印"执行完成"但骨骼树没变 —— 因为 > `mn`(New Bone)的字节码是: > ```java > VY sel = OG.D:LVZ.D; // Tree 视图 → 当前选中对象的属性面板 > if (sel instanceof XW) { ((XW)sel).D.setSelected("Bone"); return true; } // 没选中 → 什么都不做 > if (sel instanceof acK) { ((acK)sel).D.setSelected("Bone"); return true; } // 也不报错 > return false; > ``` > 也就是说 **这些动作全都作用在"当前选择"上**(改下拉框 / 点属性面板按钮), > 要先能设置选择,才谈得上增删改。详见 [`编辑器交互与数据API.md`](编辑器交互与数据API.md) §4。 可用的命令名(= `hotkeys.txt` 里的名字,`~/Library/Application Support/Spine/hotkeys.txt`): | 类别 | 名字 | | --- | --- | | 删除/改名 | `Delete`、`Rename`、`Duplicate`、`Cut`、`Copy`、`Paste` | | 新建 | `New Bone`、`New Slot`、`New Skin Placeholder`、`New Bounding Box`、`New Path`、`New Clipping`、`New IK Constraint`、`New Path Constraint`、`New Transform Constraint`、`New Skin`、`New Animation`、`New Event`、`New Folder`、`New Skeleton` | | 层级/顺序 | `Set Parent`、`Parent`、`Child`、`Previous Sibling`、`Next Sibling`、`Previous Sibling Bone`、`Next Sibling Bone`、`Draw Order Up`、`Draw Order Down`、`Tree Up`、`Tree Down` | | 显示 | `Visibility`、`Show All Bones/Slots`、`Expand`、`Collapse`、`Expand/Collapse`、`Tree Filter`、`Search` | | 选择类 | `Select - Bones`、`Select - Child Bones`、`Select - Descendant Bones`、`Select - Attachments`、`Select - Visible Attachments`、`Select - Draw Order` | | 其它 | `Undo`、`Redo`、`Rulers`、`Zoom To Fit`、`Setup/Animate Mode`、`Color`、`Replace` | **面板里的接口**:[`viewpanel/src/com/spineview/Commands.java`](viewpanel/src/com/spineview/Commands.java) (`Commands.run(loader, "New Bone")`),验证入口是启动参数 `-Dspine.runCommand="<命令名>"`。 ### 真正"建出一个能显示的骨骼"落在哪(⚠️ 2026-10-16 更正) **上一轮这里是错的**:`KP.B(target, "Bone")` 既不是"建对象 API", `target` 也不是"父级场景数据"。真实情况(`tools/decrypt/DumpSites.java` 逐站点解密的实测结果): ```java // 载荷 mn(New Bone)里的两个 indy 站点: IjFxVlIo : (Object)String -> static OG.Z(Ljava/lang/String;)Ljava/lang/String; // 字符串解密 hEjBGGtT : (Object,Object)V -> virtual KP.B(Ljava/lang/String;)V // ← 就是它 ``` - `KP`(载荷名)= `com.badlogic.gdx.scenes.scene2d.ui.SelectBox`(`extends eW`,字段里有 `SelectBox$SelectBoxStyle`); `KP.B(String)V` = `setSelected(T)`、`KP.B()Ljava/lang/String;` = `getSelected()`、`KP.B(I)V` = `setSelectedIndex(int)`。 - `((XW)sel).D` 解析到父类 `Yr.D:LKP;`,也就是**属性面板上的类型下拉框**; 设值会触发它的 ChangeListener(载荷类 `Yi` / `acM`),那里才真正 `new Gp()` / `GP.D(...)` / `VZ.D(...)`。 - 错因:`rewrite_source.py` 按混淆名改名、**丢掉描述符**,三个重载全被写成 `KP.B`。 调用点(可读源码):`mn_1`(New Bone)、`mo`(New Slot)、`mp`、`mq`、`ms_1`、`mt`、`mu_1`、 `mv_0`、`mx_0`(都是"改下拉框/点按钮"),以及两个"选中类型分支" `acM.java:98` / `yi_0.java:28`。 ### 场景对象的删除边界(需求确认) - `skeleton` 是**整个场景的根节点**:删掉它等于清空全部场景对象; - `root` 是**所有对象的根骨骼**:只要 skeleton 在,root 就必然在,**不允许删除**。 所以面板做删除时,选择集里要拦住 `root`(skeleton 可以删)。 --- ## 四之三、编辑器结构 / 场景模型(2026-10-16 新增,逐条有字节码出处) ### 推导办法(本轮用的就是这一条,比按名字改名可靠) ```bash # 1) 载荷方法全 public 化(indy 引导方法是 private 的,反射调不了) python3 tools/decrypt/make_public.py <载荷> /tmp/payload-public.zip # 2) 编译逐站点解码器 J8="$PWD/tools/jdk8u504-b01/Contents/Home/bin" ASM="$PWD/tools/asm/asm.jar:$PWD/tools/asm/asm-tree.jar" "$J8/javac" -cp "$ASM" -d tools/decrypt/build tools/decrypt/DumpSites.java # 3) 把每个 invokedynamic 站点的**密文**按 BSM 自己的算法解出来 "$J8/java" -cp "tools/decrypt/build:$ASM:/tmp/payload-public.zip" \ com.spineview.dec.DumpSites /tmp/payload-public.zip /tmp/sites.tsv [类名...] ``` 密文里存的就是 `[种类][类名长度][类名][方法名长度][方法名][描述符]`(`0xB8`=static、`0xB6`=virtual), 所以解码结果**带描述符**,重载不会混淆。全量结果在 `~/Desktop/spine398/sites.tsv`(55637 行)。 ### ⚠️ 坑:载荷里有大量"只差大小写"的类名 `OG`/`og`、`XW`/`xW`、`Yr`/`yR`、`Gp`/`GP`、`VZ`/`vz`、`VO`/`vo` … 是**不同的类**。 macOS 文件系统大小写不敏感,解到目录里会互相覆盖 —— 所以核对时必须 `javap -cp <载荷.zip> <类名>`(直接读 zip)或用 python 解析,别解到磁盘。 ### 类 | 载荷名 | 真实含义 | 依据 | | --- | --- | --- | | `OG` | **编辑器全局静态持有者**(34 个字面都叫 `D` 的静态字段:Editor / Tree 视图 VZ / Tree 控件 Uq / Skin / Stage / Prefs / 模型助手 UH…) | `javap -p OG`;`Uq.` 末尾 `putstatic OG.D:LUq;` | | `og` | "Tree View" 菜单动作(`ll.java:60` 注册) | `javap -p og`、`ll.java` | | `VZ` | **Tree 视图 Actor**(`extends et`(Table),`implements Runnable`) | `javap -p VZ` | | `Uq` | libGDX `Tree` 控件(`public final fC B` = Selection) | `javap -p Uq` | | `eM` / `Uo` | libGDX `Tree.Node` / 编辑器子类;`eM.D:Ljava/lang/Object;` = 节点对象 | `javap -p eM` | | `fC` | libGDX `Tree.Selection`(`D()` = getLastSelected,`D()LfI;` = items,`C()` = clear,`B()` = isEmpty) | 逐站点表 | | `fI` | libGDX `Array`(`Object[] D` + `int D`) | `javap -p fI` | | `Gj` | 场景对象基类 | `javap -p Gj` | | `GP` | **骨架对象**(`toString()` = 名字;`D(String)LGp;` 按名找骨骼) | `javap -p GP`、`GP.java:1502` | | `Gp` | **骨骼**(`D:LGp;` 父骨骼、`D:LGP;` 所属骨架、`L()V` 从模型移除) | `javap -p Gp`、`vq_0.java:45` | | `GU` | **插槽** | `javap -p GU` | | `GT` / `GO` | **附件**(GO 是实测挂在插槽节点上的那个) | `javap -p`、探针输出 | | `VY` → `VO` → `Yr` → `XW`/`acK`… | **属性面板控件**链(`extends Table`);`VZ.D:LVY` 就是"当前显示的那个" | `javap -p`、`vn_0.java:77,85` | | `VN`(可读名 `vn_0`) | 属性面板工厂:把每种对象的属性面板 `new` 出来注册 | `vn_0.java:77,85` | | `uh`(可读名 `uh_0`) | **模型编辑助手**(加骨骼/插槽/附件、新建事件/动画…) | `uh_0.java`、`javap -p uh` | | `un` | **撤销/重做栈**:`D()`/`B()` 成对包一次编辑,`B(Object)` 记录对象 | `javap -c un`、`vq_0.java:44-49` | | `pB` / `lo` / `pn` / `pt` / `md` / `mn` / `mo` | `Delete` / `Rename` / `Select - Bones` / `Select - Attachments` / `Export` / `New Bone` / `New Slot` 菜单动作 | `ll.java:136-206` | | `KP` | **`com.badlogic.gdx.scenes.scene2d.ui.SelectBox`**(属性面板上的类型下拉框) | `javap -p KP`、`DumpSites` 解码 | | `Yi`(可读名 `yi_0`) | 类型下拉框的 ChangeListener:**真正建对象的地方** | `yi_0.java:27-80` | | `Vq`(可读名 `vq_0`) | 删除确认框的"确定"回调:**真正的删除 + 撤销** | `vq_0.java:27-62` | ### 方法(都是"名字 + 描述符") | 载荷签名 | 真实方法 | 用途 | | --- | --- | --- | | `VZ.D(Ljava/lang/Object;)LUo;` | Tree 视图:按场景对象选中节点 | 设置"当前选择" | | `VZ.B()Ljava/lang/Object;` | Tree 视图:取当前选中对象 | 读"当前选择" | | `VZ.D(LfI;)LfI;` | 收集**已选中**对象的数组 | 多选查询 | | `VZ.D(LGP;ZZ)V` | 为某个模型对象插入/重建树节点 | 刷新(新建/删除后) | | `VZ.D(LVY;)V` | 切换当前显示的属性面板 | 内部 | | `VZ.invoke()V` | `L(null)` = 清搜索框 | 刷新 | | `Uq.D(Ljava/lang/Object;)LUo;` | libGDX `Tree.findNode` | 内部 | | `KP.B(Ljava/lang/String;)V` / `B()Ljava/lang/String;` / `B(I)V` | `SelectBox.setSelected` / `getSelected` / `setSelectedIndex` | 新建对象用 | | `un.D()V` / `un.B()V` / `B(Ljava/lang/Object;)V` | 撤销栈:开始 / 提交 / 记录对象 | 撤销 | | `GP.D(Ljava/lang/String;)LGp;` | 骨架:按名字找骨骼 | 查 | | `Gp.L()V` | 骨骼:从骨架里移除自己 | 删 | | `OG.D(LUo;)LGP;` | Tree.Node → 骨架对象 | 查 | | 结论 | 证据 | | --- | --- | | `et.D(Ldl;)LdR;` = `add`、`et.I()LdR;` = `row` | 面板从"按钮和文字挤在同一行"变成"按钮一行、文字区一行并铺满"(截图已确认) | | `dR.C()LdR;` = `left`、`dR.D()LdR;` = `fill`、`dR.StackMapTable()LdR;` = `expand` | 按钮靠左上、文字区撑满并出现滚动条 | | `et.Z(Z)V` = `setFillParent` | 换成它之后面板铺满停靠框(先前误调 `setDebug` 时只会描出彩色单元格边框) | | `Button.String()Z` = `isChecked`、`I(Z)V` = `setChecked` | 点击能触发一次读取;用前缀自校准曾配错组合,导致反复触发 | | `Label.D(CharSequence)V` = `setText` | 修掉 `textEquals` 之后面板文字才真正刷新 | | 命令系统入口 `Editor.pE.D(String)` 可达 | 面板成功拿到命令系统并执行了 `New Bone` / `Rulers`(日志有 `[cmd] 执行命令`) | 未逐条验证的(结构成组、按声明顺序推定):`Cell` 的 max/pad 系列、`Button` 的 `isOver/isPressed`、 `Cell` 的 `growX/growY`。要用到时,先用 `MapMethods` 重跑一遍,再在编辑器里实测。 **已知未打通**:命令"执行了但没生效"(`New Bone` / `Rulers` 都没看到界面变化)。 下一步要确认执行路径究竟是 `pC.boolean D()` 还是 `pC.void D()`,以及**怎么设置当前选择**。 --- ## 七、ScrollPane 的方法映射(2026-10-16 踩坑确认) | ScrollPane 载荷签名 | 真名 | 依据 | | --- | --- | --- | | `B(ZZ)V` | **`setScrollingDisabled(x, y)`** | MapMethods 对 gdx-1.11.0 指纹比对;**踩坑确认**:一直当它是 `setForceScroll`,传了 `(false, true)`,结果把纵向滚动关掉了 —— 现象就是"面板列表滚不动"。正解是 `(false, false)` 恢复双向滚动 | | `L(FFFF)V` | `scrollTo(x, y, width, height)` | 同上 | | `D(Ldl;)V` / `D(Ldl;Z)Z` | `addActor` / `removeActor` | 同上 | > 教训也写进技能了:**新确认的映射必须当场写进 `tools/deobf/anchors.tsv` 并同步 `names.tsv` / `src-named`**, > 不许只留在对话里(见 `.codex/skills/scope-discipline/SKILL.md` 末节)。 --- ## 八、图片附件(`GO`)与占位附件(`Hi`)(2026-10-16 实机确认) 这一节是"给已经建好的附件收尾"时确认的:重置图片附件的变换、删掉多余的 skin/占位附件。 ### 图片附件的 7 个 float 字段 | `GO` 字段 | 真名 | 依据 | | --- | --- | --- | | `D`(float 的那个) | **x** | ①编辑器自己的 JSON 写入器 `sb_0.java:669-675` 写 `"x"` 时读的就是它 ②`javap -c GO` 的 `D(FFFFF)V` 第一个 `putfield` 是它 | | `B`(float 的那个) | **y** | 同上(JSON 写 `"y"`、`D(FFFFF)V` 第 2 个 `putfield`) | | `S` | **rotation** | JSON 写 `"rotation"`;`D(FFFFF)V` 第 3 个参数 | | `L` | **scaleX** | JSON 写 `"scaleX"`;`D(FFFFF)V` 第 4 个参数 | | `I` | **scaleY** | JSON 写 `"scaleY"`;`D(FFFFF)V` 第 5 个参数 | | `C` / `Z` | **width / height** | JSON 写 `"width"/"height"`;实测 `C=157.0 Z=154.0` 与 `mega_dg4.png` 真实像素 157x154 一致 | > 注意:`GO` 上有多个同名字段 `D`(float / iU / pL / long / float[]), > 按名字 `getDeclaredField("D")` 会取错;必须挑**类型是 float 的那个**。 `GO.D(FFFFF)V` = 编辑器自己的 **setTransform(x, y, rotation, scaleX, scaleY)**,重置一个图片附件的变换就用它。 图片附件**没有 shear(斜切)字段**:region 只有 x/y/rotation/scaleX/scaleY。 ### 附件删除 / 占位附件 | 载荷签名 | 真名 | 依据 | | --- | --- | --- | | `Gn.L()V` | **delete(删掉这个附件)** | `javap -c Gn`:`this.GU.skeleton.removeAttachment(this, true, true)` | | `GP.L(LGn;ZZ)V` | **removeAttachment** | 字节码里 `getfield L:LfI; fI.B(Object,true)` 从附件表移除,并清掉 `GU`(插槽表 `Z:LfI`)上指向它的引用 | | `GP.L:LfI` / `GP.Z:LfI` | **attachments / slots** | 骨架的附件表(本次从 43 项减到 23 项)/ 插槽表 | | `Gn.I()Ljava/lang/String;` | **type** | 占位附件 `Hi`(可读源码 `hi_2.java`)直接 `return "placeholder"` | | `Hi`(载荷类) | **占位附件(skin placeholder)** | 它挂在骨架附件表里,但**既不是插槽的当前附件也不是静止附件**;`GP.removeAttachment` 会按 `Gn.D:LHi` 把引用它的附件级联删掉 | **本次实机结果**(不重启,JDK8 attach 热更,进程仍是同一个): - 20 个新建插槽的图片附件:`x/y/rotation` 从 `-99.49988 / -302.0 / 40.00014`、`scaleX/scaleY` 从 `1.997 / 1.532` → 全部重置为 `0/0/0` 与 `1/1`(宽度高度 157x154 保持不变)。 - 20 个 `Hi` 占位附件全部删除;骨架附件表 `43 → 23`(正好 23 个插槽各 1 个图片附件)。 - 删除占位附件**不会**连带删掉图片附件:日志里逐个核对了"删除后该插槽当前附件=GO"。 --- ## 九、权威对照:编辑器自带的 JSON 导出器(2026-10-16) **思路**:编辑器导出 Spine JSON 时,写 key 的那段代码里,key 后面紧跟的就是它读的那个字段。 所以"JSON key ↔ 混淆字段"的对照不用猜,直接读导出器即可。这一节就是那份对照,**已确认**。 **导出器是谁**:载荷类 `sB`(构造骨架 JSON 的执行者),两个关键方法 | 方法 | 干什么 | | --- | --- | | `sB.D(GP, boolean, boolean, gy, StringBuilder)` | 写骨架 JSON:`bones` / `slots` / `ik` / `transform` / `path` / `skins` / `events` / `animations` | | `sB.D(gy, String, Gn, boolean)` | 写一个附件(region / point / mesh / boundingbox / clipping) | **怎么拿到明文**(两条独立证据,顺序逐条一致): 1. `~/Desktop/spine398/spine398.jar` 是可读载荷(`DecAll` 产出:`ldc 密文` 已换成明文、解密调用已换成 `nop`)。 `javap -p -c -cp spine398.jar sB` 就能看到 `ldc "rotation"` 紧跟 `getfield Gp.F:F`。 2. 未解密的载荷走 `strings.tsv`(`class sB, method D`,281 条):同一 (类, 方法) 内**密文顺序 = 字节码顺序**, 与可读副本里的 key 顺序**逐条对得上**(该类的三段解密器 `za$Zr.J` / `aeB$Cp.I` / `aaQ$Wm.x` 各自成组、组内按方法顺序)。 ### 骨骼 `Gp` | 字段(带描述符) | JSON key | 备注 | | --- | --- | --- | | `D:Ljava/lang/String;` | name | | | `D:LGp;` | parent | 父骨骼 | | `C:F` | **length** | ⚠️ 之前写的 `Class:F=length` 作废 | | `F:F` | rotation | | | `Z:F` / `V:F` | x / y | | | `Code:F` / `SourceFile:F` | scaleX / scaleY | | | `StackMapTable:F` / `LineNumberTable:F` | shearX / shearY | | | `D:LiC;` | transform | `iC` = 继承模式枚举 | | `String:Z` | skin | 布尔:该骨骼是否覆盖当前 skin | | `B:Lcom/badlogic/gdx/graphics/Color;` | color | | > 同时**作废**了原先"静姿态那一组 `D/B/L/I/S/C` = setupX…"的推断:`C:F` 被导出器证明是 `length`, > 那一组整体不成立,现在这些 float 的语义**未定**(宁可显示混淆名,也不写错名)。 ### 插槽 `GU` | 字段(带描述符) | JSON key | 备注 | | --- | --- | --- | | `D:Ljava/lang/String;` | name | | | `D:LGp;` | bone | 所属骨骼 | | `B:Lcom/badlogic/gdx/graphics/Color;` | **color** | ⚠️ 是 `B`,不是 `D`(之前写错了) | | `I:Lcom/badlogic/gdx/graphics/Color;` | dark | 二色染色的暗色 | | `I:Z` | —("dark" 的开关) | 写 `dark` 之前先看它 | | `B:LGn;` | **attachment** | ⚠️ 是 `B`,不是 `D`(之前写错了) | | `D:Liz;` | blend | `iz` = 混合模式枚举 | ### 附件(基类 `Gn` + 各子类) | 类 | 字段(带描述符) | JSON key | | --- | --- | --- | | `Gn` | `D:Ljava/lang/String;` | name(附件名) | | `GO`(图片) | `D:Color` / `D:F` / `B:F` / `L:F` / `I:F` / `S:F` / `C:F` / `Z:F` / `B:Ljava/lang/String;` | color / x / y / scaleX / scaleY / rotation / width / height / path | | `GE`(点) | `B:Color` / `D:F` / `B:F` / `L:F` | color / x / y / rotation | | `Gx`(网格) | `B:Color` / `B:Ljava/lang/String;` / `D:Lga;` / `D:Lhp;` / `B:LHl;` / `L:I` / `D:Lgf;` / `StackMapTable:Z` / `D:F` / `B:F` | color / path / uvs / triangles / vertices / hull(=L/2) / edges / deform / width / height | | `Gx`(linkedmesh 分支) | `D:LGx;` / `D:LGT;` / `D:LHi;` / `D:Ljava/lang/String;` | parentMesh / skin / parentPlaceholder / parentName | | `Gr`(包围盒) | `I:Lga;` / `B:LHl;` / `B:Color` | vertexCount / vertices / color | | `Gs`(裁剪) | `I:Lga;` / `B:LHl;` / `B:Color` / `B:LGU;` | vertexCount / vertices / color / end(结束插槽) | ### 约束(顺带一起确认) | 类 | 字段 → key | | --- | --- | | `Gw`(IK) | `D:String`=name、`D:LGp;`=target、`D:F`=mix、`L:F`=softness、`F:Z`=uniform、`S:Z`=stretch、`Z:Z`=compress | | `GX`(变换) | name、target(`B:LGp;`)、rotation(`SourceFile:F`)、local(`S:Z`)、relative(`C:Z`)、x(`L:F`)、y(`I:F`)、scaleX(`StackMapTable:F`)、scaleY(`init:F`)、shearY(`LineNumberTable:F`)、rotateMix(`S:F`)、translateMix(`D:F`)、scaleMix(`Z:F`)、shearMix(`F:F`) | | `GD`(路径) | name、target(`D:LGU;` 是**插槽**)、positionMode(`D:Ljm;`)、spacingMode(`D:Ljo;`)、rotateMode(`D:Ljn;`)、rotation(`Z:F`)、position(`D:F`)、spacing(`V:F`)、rotateMix(`S:F`)、translateMix(`L:F`) | ### 落表位置 以上全部写进了 `tools/deobf/anchors.tsv`(末尾"【权威来源】JSON 导出器"一节),再合并进 `tools/deobf/names.tsv`(297 条),面板 Inspector 会按"真名(混淆名)"显示。 重命名载荷也已同步产出:`/tmp/spinework/payload-named.zip`(4176 个类,5247 处成员引用)。 --- ## 十、"操作类" API(2026-10-16 热更实测通过) 这一节是"不重启、纯用编辑器自身 API 加一个按钮:弹名称面板 → 按名字匹配附件 → 在当前选中骨骼下建 15 根骨骼(每根 1 插槽 1 附件)"那次任务里用到的 API。**每条都真跑过并核对过结果**,不是从字节码推的纸面结论; 字节码出处也一并写上。 ### 建对象 | 载荷签名 | 真名 | 依据 / 坑 | | --- | --- | --- | | `UH.D(LGU;LGp;Z;LfI;)LGU;` | **addSlot**(加插槽) | 实测建出插槽。⚠️ 它会把**模板插槽整个复制**,连模板插槽上挂的那份附件一起复制 —— 新插槽上于是挂着模板的附件(本次"插槽上的附件变换没改"就是这个原因) | | `UH.D(LGn;LGU;ZZLfI;)LGn;` | **addAttachmentCopy**(按模板复制一个附件挂到插槽) | 内部 `Gn.D(LGP;)LGn;` 复制模板附件,再登记到骨架附件表 `GP.L` | | `GP.D(LGp;)V` | **addBone** | 字节码 `bone.B = array.size; array.add(bone)` | | `OG.D(String;LfI;LGU;)String` | **uniqueName**(编辑器自己的不重名生成) | 实测 `OG.D("bone",骨骼数组,null)` 返回 `bone7` | | `GU.D(LGn;Z)V` | **setAttachment**(设插槽当前附件) | 字节码:第二参=是否记撤销;按 `Editor.V`(animate/setup)**只写 `D:LGn;` 或 `B:LGn;` 其中一个** → 想让两种模式都覆盖就两个字段都写 | | `VZ.D(LGP;ZZ)V` | **refresh**(重建层级树节点) | 每批建完都调,树里立刻能看到 | | `un.D()V` / `un.B()V` | **undoBegin / undoCommit** | 撤销帧的开始/提交 | | `un.Z()V` | **undo(撤销一步)** | 实测:`up` 的撤销栈 23→22、redo 栈 0→1、场景对象数同步回退。误删骨骼就是靠它连点撤回来的。同族的 `un.S()/un.C()/un.V()/un.I()` **不是**撤销(分别=跳历史下标/置标志/按下标重放/无效果) | ### 名称输入面板(编辑器自带对话框) | 载荷签名 | 真名 | 依据 / 坑 | | --- | --- | --- | | `xl`(抽象类) | **名称输入对话框基类**(标题/正文/名字框/OK/Cancel) | 结构实测:标题 + 正文 Label + `Kp` 名字框 + OK/Cancel 两个 ImageTextButton | | `xl.L(Ljava/lang/Object;)V` | **onOkCancel** | 收到 `Boolean`:TRUE=OK(读名字、查重、调下面的抽象方法);FALSE=Cancel 直接 return | | `xl.L(Ljava/lang/String;)V` | **onNameEntered(抽象)** | OK 且名字合法时被调用,参数=输入的名字。想拿名字就子类化它(本次用 ASM 现场生成子类) | | `xl.D(Ldt;)LvH;` | **show** | ⚠️ **光调它窗口不会挂进 Stage**(实测 parent 一直 null、屏幕上什么都不出现),必须再显式 `dt.D(Ldl;)V`(Stage.addActor) | | `xl.D:LKp;` | **nameField**(名字框) | `Kp` 是 TextField 子类:`B()String`=getText、`B(String)V`=setText | | `vH.B(Ljava/lang/Object;)V` | **okPressed** | `javap -c vH` 里唯一调 `L(Object)` 的地方=OK 按钮回调 | | `dT.D(Ldt;Ldk;)LdT;` | show 内部实现 | `vH.D(dt)` 里 `invokespecial` 到它;里面 `stage.D(this)V` 就是 Stage.addActor | ### Actor / Stage / WidgetGroup(定位、挂载、删除) | 载荷签名 | 真名 | 依据 / 坑 | | --- | --- | --- | | `dt.D(Ldl;)V` | **Stage.addActor** | 字节码 `this.D:Ldo;.addActor(actor)`(root.addActor)。用它把对话框挂上屏幕。历史上"面板列表被搬出面板"也是它干的(把 Actor 从原父级摘走) | | `dl.D:Ldo;` / `dl.D:Ldt;` | **parent / stage**(字段) | Actor 上两个同名字段,必须**按类型**取;`dl.D()Ldt;`=getStage | | `dl.b_()Z` | **remove** | ⚠️ `eX.b_()V`(invalidate)同名不同描述符;`getMethod("b_")` 会取到 void 那个 → "调了删除却没删掉"(本次踩到,改成按返回类型 boolean 挑才对) | | `eX.V(Z)V` | **setFillParent** | 与 `et.C(Z)V`=setClip、`et.B(Z)V`=setDebug 区分开 | | `dl.c_()F` `dl.d_()F` `dl.L()F` `dl.I()F` `dl.S()F` `dl.C()F` | **getX / getY / getWidth / getHeight / getTop / getRight** | 全部按 `javap -p -c dl` 对出来(c_=x、d_=y、L=w、I=h、S=y+h、C=x+w)。⚠️ `et`(Table) 自己声明了 `int L()`/`int I()`(列数/行数)→ 按名字取要**同时按返回类型 float 过滤** | | `dl.D(FFI)V` | **setPosition(x,y,alignment)** | **align=0 时是"以 (x,y) 为中心"**(不按 left/right 就按 center 算)→ 摆屏幕正中直接传 Stage 中心即可 | ### 混合模式枚举 | 字段 | 真名 | 依据 | | --- | --- | --- | | `iz.D` / `iz.B` / `iz.L` / `iz.I` | **normal / additive / multiply / screen** | `iz.` 里依次 `new iz("normal",0,770,1,771)`、`("additive",1,770,1,1)`、`("multiply",2,774,774,771)`、`("screen",3,1,1,769)`,常量名是常量池明文 | ### 面板自己的接口(复用,不重复造) | 方法 | 用途 | | --- | --- | | `com.spineview.ObjectPanel.wire(ClassLoader, Object, Runnable)` | 把新按钮接上点击(轮询 `isChecked` + 路由线程)——新按钮直接复用 | | `com.spineview.SceneObjects.listAllNodes(ClassLoader)` / `selected(ClassLoader)` / `nameOf(Object)` | 罗列场景对象 / 当前选中 / 取名字 | | `com.spineview.SceneActions.delete(ClassLoader, Object)` | 走编辑器删除流程(自带撤销),清理误建对象时用 | > 另外踩到两个**跟 API 无关但会浪费半天**的坑,记在这里: > 1) **HotSpot 同名 agent 类只加载一次** —— 改了 agent 代码再 attach 同一个类名,跑的还是旧代码;每次改代码都要换类名。 > 2) 编辑器字体**没有中文字形**,对话框/按钮文字写中文会显示成乱码 —— 一律用英文。 ### 对话框"显示不全 / 先闪左下角"的两个修正(2026-10-16 实测) | 载荷签名 | 真名 | 依据 / 坑 | | --- | --- | --- | | `eX.StackMapTable()F` / `eX.init()F` | **getPrefWidth / getPrefHeight** | `eX`(WidgetGroup) 六个 Layout 方法按声明顺序 = minW/minH/prefW/prefH/maxW/maxH。实测对话框 pref=**399x166**,但窗口刚建出来时是默认 **150x150** → 正文和按钮被裁。修法:`Actor.width/height = max(pref, min, 下限)` 再 `eX.b_()V`(invalidate) | | `eX.LineNumberTable()F` / `eX.String()F` | **getMinWidth / getMinHeight** | 同上(对话框 min=302x166) | | `eX.b_()V` | **invalidate** | 让内容按新尺寸重新布局(和 `dl.b_()Z`=remove 同名不同描述符) | | `dl.D(FFI)V` | **setPosition(x,y,alignment)** | **align=0 → 以 (x,y) 为中心**。"先出现在左下角、再跳到中间"的原因就是:先 `addActor`(默认 (0,0))渲染了一帧,350ms 后才居中。修法:**尺寸 + 居中都在同一次渲染回调里做完**(本次实测 399x170 @ (1665.5,937) → 中心正好 (1865,1022) = 3730x2044 的正中) | --- ## 十一、动画模型(2026-10-17 实测:新建动画 / 4 条骨骼轴 / 2 条插槽轴 / 关键帧 / 6 种曲线全部跑通) 这一节是"在动画面板下用编辑器自身 API 建 30 帧动画"时确认的整套模型。每条都真跑过并读回验证。 ### 骨架 / 动画 | 载荷签名 | 真名 | 依据 / 坑 | | --- | --- | --- | | `GP.I:LfI` | **animations(动画表)** | 元素是 `Gl`;新建动画后 `animations.add(gl)` | | `GP.D:LFT;` → `FT.D:F` | **projectSettings.fps** | 本项目 fps=**30**。⚠️ 编辑器时间轴刻度直接用**帧**:关键帧时间写 0 和 30,不要换算成秒 | | `Gl.D:LGP;` / `Gl.D:Ljava/lang/String;` | skeleton / name | | | `Gl.D:LfI` | **timelineGroups(时间轴组表)** | 元素是 `FH`。清空动画最稳的做法:直接换一个**新的空 fI**(`removeRange` 的两个 `(int,int)` 重载实测都不缩容) | | `Gl.D()V` | **register(注册到动画列表模型)** | 编辑器 New Animation 命令里就是这么做的 | | `Gl.D(Gp)FI` / `Gl.B(Gp)FI` / `Gl.L(Gp)FI` / `Gl.I(Gp)FI` | **旋转 / 位移 / 缩放 / 斜切轴** | 运行时类依次 `ih` / `io` / `ii` / `ij` | | `Gl.D(Gp,GU)FI` / `Gl.B(Gp,GU)FI` | **插槽颜色 / 附件切换轴** | 运行时类 `hU` / `ip` | | `Gl.D()FI` | **绘制顺序轴** | 运行时类 `hZ` | ### 时间轴 / 关键帧 | 载荷签名 | 真名 | 依据 / 坑 | | --- | --- | --- | | `FH.D:Gl` / `FH.D:Gp` / `FH.D:LfI` | animation / bone / timelines | `FH` 是按"骨骼或插槽"分组的一层 | | `FI.D:Ljava/lang/Class;` | **runtimeTimelineClass** | `ih`=Rotate、`io`=Translate、`ii`=Scale、`ij`=Shear、`hU`=SlotColor、`ip`=SlotAttachment、`hZ`=DrawOrder | | `FI.D:LfI` | **keys(关键帧表)** | 元素是 `FN` | | `FI.D(float)FN` | **findKey** | 按时间精确找关键帧(找不到返回 null) | | `FI.D(FN,boolean)V` | **insertKey** | 插入关键帧(第二参=是否记撤销) | | `FI.L()V` | **invalidateRuntime** | 标脏运行时轴(改完关键帧调它重建) | | `FI.D(float,int)Z` | **addKeyFromPose** | 按当前姿态在指定时间建关键帧;⚠️ 时间轴上**必须已经有关键帧**,否则抛 `frameCount must be > 0` | | `FN` 字段/方法 | 真名 | 依据 / 坑 | | --- | --- | --- | | `D:F` | **time** | 关键帧时间(帧) | | `D:FV` | **curve** | 曲线类型,见下 | | `B/L/I/S:F` | **curveC2x / c2y / c3x / c3y** | 贝塞尔四个控制值(新建关键帧默认 0.25 / 0 / 0.75 / 1) | | `D(float)` / `D()F` | **setAngle / getAngle** | 旋转轴的角度。⚠️ **不是** `FN.D():ga`! | | `B(float)` / `B()F` | **setX / getX** | 位移/缩放/斜切的 x | | `L(float)` / `L()F` | **setY / getY** | 位移/缩放/斜切的 y | | `D:Color` | **color** | 插槽颜色关键帧 | | `D:Gn` | **attachment** | 附件切换关键帧(null = 隐藏) | | `D:Or` / `D():ga` | **valueSchema / values** | **deform** 用的数值数组(旋转/位移/缩放/斜切不要写这里——曾因此"帧有值但不生效") | ### 曲线枚举 `FV`(名字取自 ``) | 字段 | 真名 | | --- | --- | | `FV.D` | **linear(线性)** | | `FV.B` | **curve(贝塞尔)** | | `FV.L` | **stepped(阶跃)** | "自动"不是单独的枚举值,而是 **curve + 一组控制值**(本次实测过 0.25,0.25,0.75,0.75 的对称自动;ease-in 用 0.42,0,1,1;ease-out 用 0,0,0.58,1)。 ### 面板按钮(正式源码) - `viewpanel/src/com/spineview/AnimationTools.java` - **Animate Random Props**:随机骨骼/插槽/附件 → `ani_api_test`:旋转 0°→90°、位移 (0,0)→(120,-60)、缩放 (1,1)→(1.6,0.6)、斜切 (0,0)→(25,15)、插槽颜色 (1,1,1,1)→(1,0.2,0.2,0.25)、附件 显示→隐藏;全部只在 0f/30f - **Curve Test 6 Modes**:`curve_test`:6 根骨骼各一条旋转轴(0°→45°,0f/30f),分别 linear / curve 平滑 / stepped / ease-in / ease-out / 对称自动