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

165 lines
9.3 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.
# 阶段二 · 粒子面板实现(把力量放在已经跑通的那条线上)
阶段一把「往编辑器内部塞控件 / 掏对象」的路走完了:`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. 跑法
```zsh
# 编译注入器
./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)。