Files
Spine_lab/阶段二-面板实现.md
tianmo 2e7ab7c33c 阶段四:Spine 3.8.99 编辑器里挂上自建视图面板
目标:在 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 副本、从载荷派生的批量产物。
2026-10-16 13:34:18 +08:00

9.3 KiB
Raw Permalink Blame History

阶段二 · 粒子面板实现(把力量放在已经跑通的那条线上)

阶段一把「往编辑器内部塞控件 / 掏对象」的路走完了: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. 下一步(按收益排序)

  1. 抓住「编辑器类加载器」:把探针挂到编辑器真正加载 JNI 库的时刻(或改成从 JNI_OnLoad 拿 VM 后再延迟重试),拿到它以后选中读取就能真正生效;
  2. 面板里加图片资源选择(用现有 SpineParticle 项目的图片库),让导出带图;
  3. 导入成功后自动在编辑器里打开生成的 .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)。