目标:在 Spine 编辑器窗口内部加一个自己的视图面板(按钮 + 文字显示), 点击读取场景对象,并像原生面板一样出现在 Views 菜单、可停靠可折叠。 本轮打通: 1. 编辑器在我们自己的进程里完整启动 - 类加载器必须是 URLClassLoader 本身(自检要求) - Editor.main 必须在 AWT EDT 上调用 - jvm.cfg 第 21 行要以两个空格结尾 + -DSpineJava - Editor.D:I 版本号、fonts/、/tzax 资源、Java8 版 launcher.Launcher 替身 2. 3.8.99 全量解密 → 可读源码 - 字符串 17251/17461(伪造同名调用帧对齐 key) - 混淆动态派发还原 53871 处(调引导方法取 MemberName) 3. 自建视图 - agent 在内存里给 us 枚举加 spineView 常量(不改磁盘,避开完整性自检) - 面板 Actor 由外部反射填进 us.spineView.D - 复用已有图标做别名(不新建图片资源) 最终 "Spine View" 出现在 Views 菜单,可与 Tree 并排停靠、折叠展开 另外: - ViewPatcher/ViewAgent:视图枚举与面板挂载的字节码插桩 - DecAll/ObMap/rewrite_source:字符串解密、派发还原、可读源码生成 - 阶段四-视图面板挂载.md:完整记录做法与踩过的坑 不纳入版本管理:rea/(独立仓库)、tools 下的 JDK/Ghidra 工具链、 Spine.app 与 spine-lab 副本、从载荷派生的批量产物。
9.3 KiB
阶段二 · 粒子面板实现(把力量放在已经跑通的那条线上)
阶段一把「往编辑器内部塞控件 / 掏对象」的路走完了:addActor 不渲染、改内部控件会 fatal error、
面板没有运行期注册 API。阶段二换了思路:不再动编辑器内部结构,只做四件事,
全部落在已经验证成功的两条链路上(窗口内 AppKit 面板 + Spine --import 导出链路)。
0. 本轮结论速览
| 项 | 状态 | 证据 |
|---|---|---|
| 面板可拖动 / 可折叠,不遮右侧面板 | ✅ 已实现并在编辑器窗口内挂载成功 | /tmp/spine-panel.log:面板已挂进编辑器窗口(340x474);拖动/折叠由自测台断言 |
| 面板内粒子参数 + 自有数据模型 | ✅ 已实现(26 项参数、多发射器、JSON 落盘) | 自测台 15 项断言全过,面板截图见 /tmp/spine-panel-dragged.png |
| 定向少量读取拿选中对象 | ⚠️ 通道已通、边界已收紧;编辑器类加载器还没抓到,读不到时面板会明说 | 探针返回 count=-1(/tmp/spine-panel.log 的 auto probe) |
面板产出 Spine JSON → Spine --import |
✅ 端到端跑通 | 自测台生成 SpineParticle.json → CLI 生成 SpineParticle.spine |
1. 面板:可拖动 / 可折叠 / 不遮挡
native-hook/ui.m 重写:
- 面板是叠加在编辑器
contentView上的原生NSView(不缩小、不重排编辑器自己的视图, 因此不会把右侧面板挤到别处); - 整块空白处按住即可拖动,拖出界会被夹回窗口内(至少留 120px 抓得回来);
- 标题栏右侧「折叠 ▾ / 展开 ▸」把面板收成只剩 30px 标题栏;
- 位置、高度、折叠状态随数据模型一起存盘,下次启动原样恢复;
- 编辑器重建
contentView时(每 5 秒检查一次子视图)自动重新挂载。
默认落在窗口左下角,不盖右侧面板;觉得碍事就拖走或折起来。
2. 粒子参数与数据模型(我们自己的结构)
native-hook/panel_model.{h,m}:
SPEmitter:目标类型(骨骼/插槽/附件)、目标名字、发射模式(持续/爆发)、速率、爆发数量、 延迟、发射时长、发射形状(点/圆/矩形/锥形)、半径/矩形尺寸、生命周期 min-max、 初速度 min-max、发射角 min-max、重力与方向、缩放 min-max、透明度起止、旋转速度 min-max、图片名;SPDocument:发射器列表 + Spine 版本 + fps + 工程名 + 面板状态,落盘到$SPINE_USER_HOME/Library/Application Support/Spine/spine-particles/particles.json(实验室运行写spine-lab/home,真实安装不被碰);- 面板里改任何一项只动这个结构,完全不接触编辑器的对象,所以怎么改都不会崩。
3. 选中对象:定向少量读取
native-hook/selection.c 用 JNI 从 Gdx.app 出发做有硬上限的浅层读取:
深度 ≤ 3、对象 ≤ 220、每类字段 ≤ 64、不展开数组与 Java 容器、全程清异常;
结果按「字段名/类名像不像选择」打分,列成候选交给人在面板里点选,
避免去猜混淆过的字段名。探针诊断写在 /tmp/spine-selection-probe.txt。
当前限制:需要一个「编辑器类加载器」引用才能按名字找 com.badlogic.gdx.Gdx。
抓取通道(dlsym("JNI_OnLoad") / dlsym("Java_..."))已经保留,但本机这次运行里
编辑器还没触发这些查询,探针只能返回「读不到」。读不到时面板显示
「没读到选中对象(编辑器里先选中骨骼/插槽再试)」,目标名也可以直接手填。
4. 导出:JSON → Spine --import
native-hook/panel_import.m 调用的命令行与已验证的连接器完全一致:
Spine --update 4.3.xx --input SpineParticle.json --output SpineParticle.spine --import SpineParticle
要点(都是实测踩出来的):
- 子进程必须剔除
DYLD_INSERT_LIBRARIES/SPINE_PLUGIN_*,否则 CLI 会跟着注入器一起崩; - 可执行文件默认取「当前正在跑的这个 Spine」,可用
SPINE_PANEL_EXECUTABLE覆盖; - 输出目录
$SPINE_USER_HOME/Desktop/SpineParticle-Export/(实验室运行写spine-lab/home/Desktop/...)。
面板导出的 JSON 结构对齐已验证的导出器:skeleton/bones/slots/skins/animations,
把粒子按帧烘焙成骨骼(translate/rotate/scale)+ 每个粒子的插槽(区域附件 + rgba 淡出)。
5. 实测到的崩溃与定位(重要)
现象:只要 hook.dylib 挂上,Spine 启动阶段就会 SIGSEGV(libjvm+0x2977c8,
arm64 符号被裁;x86_64 同偏移落在 Dependencies::assert_common_3 一带),
连阶段一留下的旧 dylib 也一样崩——这不是本轮改动引入的。
二分结果(每次启动观察 50–70 秒,进程活下来才算过):
| 变体 | 结果 |
|---|---|
| 空 dylib(只有一个 constructor) | 存活 |
只重绑定 _dlsym,什么都不拦 |
存活 |
拦 JNI_CreateJavaVM 但直接转交 |
存活 |
拦 JNI_CreateJavaVM + 注入 JVM 选项 |
存活 |
+ 拦 JNI_OnLoad |
存活 |
+ dlsym("Java_...") 里做类加载器探针 |
存活 |
+ 拦 ClassLoader.defineClass1 / 给 JNIEnv 表打补丁 |
崩溃 |
结论:经典的「dump 类字节码」那套钩子(defineClass1 拦截、DefineClass 打补丁)
是崩溃源。它只是排查用的诊断功能,面板不需要,已改为默认关闭,
要排查时用 SPINE_HOOK_DUMP_CLASSES=1 显式打开。关掉后编辑器启动正常,
面板也如期挂进窗口。
6. 文件清单
native-hook/
hook.c 注入器:dlsym 重绑定、JVM 选项注入、面板线程;类 dump 钩子默认关闭
hook_internal.h hook.c 暴露给探针的最小接口
selection.c 选中对象的定向少量读取(带硬上限)
ui.m 粒子面板(可拖动 / 可折叠 / 参数控件 / 导出按钮)
panel_model.{h,m} 粒子数据模型 + 存盘 + Spine JSON 烘焙
panel_import.{h,m} Spine CLI 导入(剔除注入环境变量)
build.sh 编译 hook.dylib
test/panel_harness.m 面板自测台(不启动 Spine:拖动/折叠/导出/导入 + 截图)
7. 跑法
# 编译注入器
./native-hook/build.sh
# 面板自测(不碰 Spine,15 项断言 + 三张截图)
clang -fobjc-arc -I native-hook -I native-hook/agent/include -framework Cocoa \
-o /tmp/panel_harness native-hook/test/panel_harness.m native-hook/ui.m \
native-hook/panel_model.m native-hook/panel_import.m
SPINE_USER_HOME=/tmp/spine-panel-test \
SPINE_PANEL_EXECUTABLE="$PWD/spine-lab/Spine.app/Contents/MacOS/Spine" /tmp/panel_harness
# 编辑器实例(隔离 home,面板应出现在窗口左下角)
spine-lab/启动实验室Spine.command # 或 ./spine-lab/run-lab.sh
环境变量:SPINE_PANEL_AUTO_PROBE=1 启动后自动跑一次选中探针并写日志;
SPINE_PANEL_EXECUTABLE 指定 CLI 用的 Spine;SPINE_HOOK_DUMP_CLASSES=1 打开类 dump(会崩,仅排查用)。
8. 下一步(按收益排序)
- 抓住「编辑器类加载器」:把探针挂到编辑器真正加载 JNI 库的时刻(或改成从
JNI_OnLoad拿 VM 后再延迟重试),拿到它以后选中读取就能真正生效; - 面板里加图片资源选择(用现有
SpineParticle项目的图片库),让导出带图; - 导入成功后自动在编辑器里打开生成的
.spine(open -a Spine <file>)。
9. 读取选中对象:实测出来的安全边界(重要)
面板已经改成「一个按钮 + 一块文字区」,按钮就是读当前选中对象。为了这条链路做了一轮 严格实测,结论如下(每次都是真启动编辑器、观察进程是否存活 + 有没有 Fatal error 弹窗):
| 做法 | 结果 |
|---|---|
| 注入 dylib、挂面板、拖动/折叠 | ✅ 稳定 |
在 dlsym("Java_...") / JNI_OnLoad 回调里 FindClass 抓类加载器 |
✅ 稳定(已成功抓到编辑器类加载器,认得 com.badlogic.gdx.scenes.scene2d.ui.TextButton) |
在编辑器上下文里 FindClass 做类体检(9 个类名) |
✅ 完成(日志 report: 类体检完成) |
调编辑器类加载器的 loadClass(class_name) 找类 |
❌ 必崩(Fatal error 弹窗) |
| 反射读该加载器的字段(类名表 Map) | ❌ 必崩 |
| 在 JNI 回调里做反射 / 读对象字段 | ❌ 崩(回调时 JVM 持内部锁) |
外部线程 Thread.getAllStackTraces() |
❌ 崩 |
能拿到的:知道编辑器自己的类加载器是谁、能在它的上下文里按名字找到类。 拿不到的:从我们自己的线程或 JNI 回调里去读对象内容——这套加固运行时会直接把进程打死。
因此 hook.c 里加了 SPINE_PANEL_ENABLE_QUERY 开关,默认关闭,
面板点下去只会显示「深度读取已停用」,不会再把 Spine 打崩。
结论与下一步
唯一有成功先例的路线是阶段一验证过的那条:把我们的类 DefineClass 进编辑器自己的类加载器,
让代码跑在 Java 侧线程上,用普通 Java 反射去读(阶段一就是这样读到私有字段、甚至改掉了
工具栏按钮文字)。这条路需要能编译 Java 类(本机目前没有 JDK,需要装一个;阶段一当时用的是
/tmp 下的 JDK 17)。