# Spine 加功能 · 阶段一分析 目标:在 Spine 编辑器内部增加自己的功能(最终形态是「粒子系统」面板),不破坏原有功能,且不改动用户真实的 `/Applications/Spine.app`。 --- ## 1. 技术栈结论 | 层 | 结论 | 依据 | | --- | --- | --- | | 原生外壳 | Objective-C / Cocoa(AppKit) | `Spine` 二进制只依赖 Foundation/AppKit/Cocoa,Info.plist 里 `DTXcode 1540` | | 启动方式 | 自定义 launcher,`dlopen` + `dlsym` 后直接 `JNI_CreateJavaVM` | 二进制字符串与反汇编 | | 运行时 | 内置 OpenJDK;x86_64 为 Azul Zulu 10.0.2,Apple Silicon 为 arm64 定制运行时 | `libjvm` 版本字符串 | | 界面/渲染 | Java + libGDX(Scene2D UI)+ LWJGL/GLFW + OpenAL + FreeType | 编辑器载荷里的 `com/badlogic/gdx/**`、`org/lwjgl/**` 引用 | | 资源管线 | 内置 ffmpeg / oxipng | `launcher/2/bin/` | --- ## 2. 打包与加固(关键障碍) 1. **`launcher.jar` 实际未被使用**:把它移走后程序照常启动。 2. **启动类与编辑器类内嵌在 `libjvm` 里**:arm64 运行时的 `libjvm.dylib` 约 56MB,其中 `__data` 段近 49MB,明显是内嵌的(加密)类档案。 3. **JVMTI agent 加载被裁掉**:`-agentpath` 会被解析但不加载 agent(`Agent_OnLoad` 字符串都不存在),`-javaagent` 同理无效。 4. **classpath 注入无效**:追加 `-Djava.class.path` 不生效;自定义类加载器 `s.LPt4` 自己管理类来源。 5. **类被混淆**:libGDX 核心类与编辑器类被塞进 `s.*` 包(例如 Actor = `s.AmK`、Group = `s.Xgs`),但部分类保留原名(`TextButton`、`Label`、`ImageButton`…)。 6. 编辑器载荷缓存在 `~/Library/Application Support/Spine/updates/<版本>`,4.0 之后为加密数据。 --- ## 3. 我们采用的注入方案(已跑通) 因为常规插件/agent/classpath 全部被堵死,改为在原生层注入: 1. **原生化注入器** `native-hook/hook.c` - 通过 `DYLD_INSERT_LIBRARIES` 载入; - fishhook 式重绑定 `_dlsym`(含 `_dyld_register_func_for_add_image`,覆盖后加载的 `libjvm`); - 拦截 `JNI_CreateJavaVM`,拿到 `JavaVM*`。 2. **用 JNI 直接注入类** - `DefineClass` 把我们的类定义进 JVM(绕开 classpath/加载器限制); - `RegisterNatives` 暴露 `defineEditorClasses` / `capturedClass` / `readField` 给我们的 Java 引导类。 3. **引导类** `spine-lab/build/plugin-src/com/spineparticle/Boot.java` - 等编辑器 UI 起来后,用 JNI 读私有字段遍历对象图; - 目前已经能定位 UI 控件、修改它们的状态。 **已验证能力**:把左侧工具栏第一个按钮的文字改成「粒子系统」,界面上真实渲染出来了。 --- ## 4. 运行环境隔离(不影响用户真实安装) - 实验室副本:`spine-lab/Spine.app` - 数据目录隔离:注入 `-Duser.home=spine-lab/home` - 启动脚本:`spine-lab/启动实验室Spine.command`、`spine-lab/run-lab.sh` - 日志:`/tmp/spine-lab-inject.txt`(引导类)、`/tmp/spine-native-hook.log`(原生 hook) --- ## 5. 运行期已探明的对象结构 | 对象 | 真实类名 | 说明 | | --- | --- | --- | | Actor 基类 | `s.AmK` | 所有 UI 控件的基类 | | Group(容器) | `s.Xgs` | 出现在 2919 个 Actor 字段上,确认为父容器类 | | Stage 候选 | `s.okh` / `s.RUj` 等 | 有 `addActor(Actor)`+`draw()`+`act(float)`,非 Actor | | 编辑器类加载器 | `s.LPt4` | 内部 `mc` 字段是 `ConcurrentHashMap`,**含 2365 个类名**,可枚举全部类 | | 「骨骼」文字持有者 | `Label` | 位于工具栏按钮内 | | UI 根 Group | `s.Xgs` | 从 Label 沿 `parent` 爬到的顶点 | | 面板标题标签页 | `s.XDB` | **实际是 `ImageTextButton`**,不是面板本体 | 父链示例(工具栏按钮):`Label → s.fgD → s.nHS → s.NXg → s.nHS → s.YDW → s.BTV → s.KJW → s.ILX ×3 → s.FCx → s.Xgs` 父链示例(层级树面板标题):`s.zFH → s.XDB(ImageTextButton) → s.nHS → s.KJW → s.ILX → s.FCx → s.Xgs` --- ## 6. 已解决 / 未解决 **已解决** - 代码能进 Spine 的 JVM 并执行; - 可枚举全部类(2365 个); - 可读任意对象的私有字段(JNI 绕过模块限制); - 可定位并**修改正在渲染的现有控件**(工具栏按钮文字已改,界面生效)。 **未解决** - 手工 `addActor` 塞进去的新控件(按钮副本、面板副本)**不渲染**; - 原因推测:libGDX 的 Table 布局、Skin 样式与 layout 缓存要求走编辑器自己的注册/布局路径; - 面板的本体("标题栏 + 内容"的组合容器)尚未定位,目前只找到了它的标题标签页; - 面板管理器/注册 API 还没确定(静态扫描给出过 `s.JBH._(String,nHS)->void` 这类候选)。 --- ## 7. 下一步计划(阶段二) 1. 利用「预览」面板的**关闭 → 打开**动作做差分:在注入端对对象快照做前后对比,锁定该动作涉及的对象; 2. 由该对象向上/向侧找到面板本体与其停靠容器; 3. 确认注册 API(候选签名 `(String, 容器)`),注册我们自己的「粒子」面板; 4. 面板内容先用占位控件,验证"面板出现 + 可交互"后,再接入粒子系统数据。 --- ## 8. 产物清单 ``` native-hook/ hook.c 原生注入器(fishhook + JNI_CreateJavaVM + DefineClass) hook.dylib 编译产物(arm64) agent/ JVMTI agent 探针(备用,本运行时不可用) spine-lab/ Spine.app/ 实验室副本(未改动用户真实安装) home/ 隔离的数据目录 build/plugin-src/ 引导类源码(com/spineparticle/Boot.java) build/boot/ 编译产物(当前注入使用的类) 启动实验室Spine.command 双击启动 阶段一分析.md 本文档 ``` --- ## 9. 阶段二进度:面板机制定位(实测) **实验方法**:注入端每 6 秒对对象图做一次快照,对比每个 Actor 的数值指纹(float/int/boolean 等字段), 由用户手工在「视图」菜单里反复开关面板(插槽颜色 / 权重 / 皮肤 / 网格工具 / 层级树 / 指标 / 轮廓 / 预览)。 **结论** 1. 开关面板**不会创建新对象**(每轮都是 `新增=0`,但 `变化>0`)——面板在启动时已全部创建,开关只改变状态与布局。 2. 反复变化的类集中在 **`s.fOM`**(多个不同实例,对应不同面板)与 `com.badlogic.gdx.scenes.scene2d.ui.ImageTextButton`(面板标签页)。 3. `s.fOM` 实例的父链一致为: ``` s.fOM → s.FEZ → s.BTV → s.KJW → s.ILX ×5 → s.FCx → s.Xgs(UI 根) ``` 即:**`s.fOM` = 面板内容节点,`s.FEZ` = 面板停靠区域**。 4. 面板标签页是 `ImageTextButton`,父链为 `s.nHS → s.NXg → s.nHS → s.YDW → s.BTV → s.KJW → …`。 **下一步** - 方案 A:新建 `s.fOM` 实例 + 对应标签页,按上述父链挂进 `s.FEZ` 与标签栏; - 方案 B(更稳):找到面板管理器的注册 API(静态扫描曾出现 `s.JBH._(String,nHS)->void` 这类候选), 通过它注册,让布局/标签栏/视图菜单一并由编辑器自己处理。 --- ## 10. 阶段二结论与最终选型(已落地) **实测结论** - 新增控件(`addActor`)**不渲染**; - 改写编辑器内部对象(面板标签等)**会破坏其内部一致性并触发 fatal error**(当天累计多份崩溃报告); - 面板没有运行期注册 API(扫遍 2365 个类只找到布局用的 `s.oxX`,没有"注册面板"的入口)。 因此"在编辑器窗口内部新增面板"这条原生路线,在这套**被二次打包并加固**的运行时上代价过高、风险过大。 **最终选型:方案 A(已实现)** 粒子面板做成**独立原生面板窗口**,外观与编辑器一致,并在启动时**自动贴到 Spine 窗口右侧**: ``` spine-lab/panel/Panel.swift 面板源码(Swift / AppKit,暗色 NSWindow,置顶) spine-lab/panel/SpineParticlePanel 编译产物 spine-lab/启动Spine+粒子面板.command 一键启动:Spine 实验室实例 + 面板 ``` 面板内容(原型):粒子系统列表、参数滑条(发射速率/生命周期/初速度/重力/缩放)、 「+ 新建粒子」「导出动画数据」按钮。定位方式是 `CGWindowListCopyWindowInfo` 找到 Spine 窗口后贴合其右侧。 **后续可扩展** - 面板内接入真正的粒子编辑器(可直接复用 `SpineParticle` 项目里已有的 Vue 逻辑,改成面板窗口内的 WebView 或重写为 AppKit 控件); - 通过文件/命令与 Spine 联动:面板导出 Spine JSON 动画数据 → 用已验证的连接器流程 `Spine --import` 导入编辑器。