Files
Spine_lab/混淆名对照表.md
tianmo 6580611012 动画面板:新增两个动画测试按钮 + 把动画模型 API 全部落进对照表
## 功能(面板源码,重启后仍在)
- 新增 AnimationTools:
  - 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 平滑(0.25,0,0.75,1) / stepped / ease-in(0.42,0,1,1) /
    ease-out(0,0,0.58,1) / 对称自动(0.25,0.25,0.75,0.75)
- ObjectPanel.build 里挂载这两个按钮,复用面板自己的 wire() 点击轮询

## 对照表(tools/deobf/anchors.tsv + 混淆名对照表.md 第十一节)
- GP.I=animations、GP.D:LFT;.D:F=fps、Gl=动画(name/skeleton/timelineGroups/register)
- Gl.D/B/L/I(Gp)=旋转/位移/缩放/斜切;Gl.D/B(Gp,GU)=插槽颜色/附件;Gl.D()=绘制顺序
- FH=组;FI=时间轴(runtimeTimelineClass、keys、findKey、insertKey、invalidateRuntime)
- FN=关键帧(time、curve、c2x/c2y/c3x/c3y、setAngle/setX/setY、Color、Gn)
- FV=曲线枚举(linear/curve/stepped;'自动'=curve+控制值)
- 踩坑记录:
  * 关键帧数值写在 FN.D()/B()/L() 的 float 字段,**不是** FN.D():ga(ga 是 deform 的),
    否则会出现'有帧但运行时不变'
  * 编辑器时间轴刻度就是**帧**,关键帧时间写 0/30,不要按 1 秒=30 帧换算
  * FI.D(float,int) 建帧要求时间轴已有关键帧(先有鸡问题)
  * fI.removeRange 两个 (int,int) 重载都不缩容 → 清空动画直接换新 fI
names.tsv 合并成 380 条,payload-named.zip 重产(7271 处成员引用)。

验证:全部在运行中的编辑器里逐条读回(时间轴/关键帧/曲线枚举/控制值),
重启后 pid 94663 单实例,日志确认 '[animtool] 两个动画按钮已加入'。
2026-10-17 12:57:17 +08:00

593 lines
39 KiB
Markdown
Raw Permalink 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.
# 混淆名对照表(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`/…) | `<clinit>` 里建常量、按 `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.<init>` | Stage 实现类的构造器 | Stage 在编辑器里只是局部变量,只能钩构造器拿实例 |
| `Fx` 的静态 `dl` 字段 | 一个静态 Actor | 由它 `Actor.stage` 反查 Stage(比钩构造器更省事) |
| `OG.D` | 编辑器单例 | `com.esotericsoftware.spine.editor.Editor` |
| `us.<clinit>` | 视图枚举初始化 | 加新视图必须在 `$VALUES` 组装之后、分组列表生成之前插 |
---
## 四之二、命令系统(增删改查的入口)
编辑器的所有菜单动作都走同一套命令系统。链路(反编译源码里读出来的):
```
OG.D → com.esotericsoftware.spine.editor.Editor 单例
└─ public pE D; → 命令 / 热键系统(Editor.<init> 里 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.<init>` 末尾 `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.<clinit>` 里依次 `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`(名字取自 `<clinit>`)
| 字段 | 真名 |
| --- | --- |
| `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 / 对称自动