## 功能(面板源码,重启后仍在)
- 新增 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] 两个动画按钮已加入'。
39 KiB
混淆名对照表(Spine 3.8.99 载荷)
这份文档记录载荷里被混淆的类名/方法名/字段名分别对应什么,以及每一条是怎么推导出来的。 以后要动编辑器内部的 scene2d 控件(加面板、读数据、导出),都先查这里,不要重新猜。
代码里对应的是 viewpanel/src/com/spineview/Obf.java,
里面有同样的常量表,改代码时直接引用。
一、推导办法(可复现)
- 字节码指纹比对:
tools/obfmap/MapMethods.java- 把载荷类与上游 libGDX 同签名方法逐个比对:忽略名字,只比较 "操作码序列 + 常量 + 成员的描述符"(类名按"方法内首次出现的顺序"编号,绕开两边类名不同的问题)。
- 用法:
CP=tools/asm/asm.jar:tools/asm/asm-tree.jar:tools/obfmap/build "$JAVA17" -cp "$CP" MapMethods <上游.class> <载荷.class> # 输出:载荷方法签名 -> 上游方法名(= 唯一匹配,~n/m = 按声明顺序消歧) - 编译:
"$JAVA17" -cp tools/asm/asm.jar:tools/asm/asm-tree.jar -d tools/obfmap/build tools/obfmap/MapMethods.java
- 上游源码核对:拿 libGDX 1.11.0 的源码逐条看方法体语义
(
gdx/src/com/badlogic/gdx/scenes/...),确认指纹给出的结论。 - 运行期验证:面板布局/按钮/文字这些已经在编辑器里真跑过,见文末"验证状态"。
上游版本怎么挑的: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)的字节码是: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§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
(Commands.run(loader, "New Bone")),验证入口是启动参数 -Dspine.runCommand="<命令名>"。
真正"建出一个能显示的骨骼"落在哪(⚠️ 2026-10-16 更正)
上一轮这里是错的:KP.B(target, "Bone") 既不是"建对象 API",
target 也不是"父级场景数据"。真实情况(tools/decrypt/DumpSites.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 新增,逐条有字节码出处)
推导办法(本轮用的就是这一条,比按名字改名可靠)
# 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) |
怎么拿到明文(两条独立证据,顺序逐条一致):
~/Desktop/spine398/spine398.jar是可读载荷(DecAll产出:ldc 密文已换成明文、解密调用已换成nop)。javap -p -c -cp spine398.jar sB就能看到ldc "rotation"紧跟getfield Gp.F:F。- 未解密的载荷走
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 无关但会浪费半天的坑,记在这里:
- HotSpot 同名 agent 类只加载一次 —— 改了 agent 代码再 attach 同一个类名,跑的还是旧代码;每次改代码都要换类名。
- 编辑器字体没有中文字形,对话框/按钮文字写中文会显示成乱码 —— 一律用英文。
对话框"显示不全 / 先闪左下角"的两个修正(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 / 对称自动
- Animate Random Props:随机骨骼/插槽/附件 →