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