Files
mtgodot-poc/docs/reference/PLAN.md
T
shenleiandClaude Opus 5 fb4d2d222b docs: 文档审计——修正失效链接与过期结论
按 docs/ 目录逐篇核对,只改与事实/路径不符的部分,不动尚未验证的计划条目:

- `docs/steps/**` 在早前整理时已迁到 `docs/reference/steps/**`,全仓的旧路径
  引用(PLAN.md / README.md / M0-gr2-reader.md 及 libgr2 的头注释)一并改正。
- CLIENT-GAP.md / CLIENT-GAP-FIX.md / BACKLOG.md / MIDREVIEW.md:把已经落地的
  条目从「待办」改为已完成,删掉与代码现状矛盾的描述。
- ANDROID-TESTING.md / CLIENT-PORT.md / CLIENT-ROADMAP.md / GODOT-POC-PLAN.md /
  SHINSOO-WORLD-RENDERING.md / PARITY-GAP.md:同上,另补当前实际的构建/测试入口。
- THIRD-PARTY.md:补齐实际在用的第三方来源与许可说明。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJugvEJwz3FK4hw9ti3SRb
2026-09-08 17:15:17 +09:00

559 lines
50 KiB
Markdown
Raw 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.
# Metin2 跨平台渲染引擎 PoC — 完整方案
> 内部研究方案,不对外公开。
> 基于对 `m2dev-client-src-main`、`MobileSource` 及 `m2dev-client-main/assets` 中真实 `.gr2` 资源的实测编写。
> 所有版本号、格式偏移、API 数量均来自源码与文件抽查。
| | |
|---|---|
| 状态 | PoC 规划 |
| 技术栈 | C++20 · **bgfx**RHI · sokol_app(仅窗口/输入) |
| 目标平台 | macOS · iOS · Android(并行) |
| 判定周期 | ≈ 23 个月 → go / no-go |
| 复用 | EterBase · EterPack · EterImageLib · `MobileSource``.sc` 着色器草稿 |
| 自研 | libgr2gr2 v6 读取器) |
> **选型变更记录**RHI 从 sokol_gfx 改为 **bgfx**。原因见 [§03](#03--技术选型)——`MobileSource` 里已存在一套面向 bgfx 的 Metin2 着色器草稿,bgfx 更 shipping 级、Metal/GLES/Vulkan 更成熟,且这不是一次性 PoC 而是最终移植的同一条代码路径。窗口/主循环/输入计划用 `sokol_app.h` 并把原生 handle 经 `bgfx::PlatformData` 交给 bgfx —— **这个交接不是 sokol_app 常规用法,是一处真风险**,M1 独立子门禁验证,啃不下退回"桌面 SDL2 + 移动端各写最小原生壳"[§03 窗口层](#窗口层sokol_app-与-bgfx-的交接是一处真风险))。
---
## 01 · 目标与判定标准
PoC 不是"移植一小部分客户端",而是把跨平台方案里所有真正未知的环节压到一条最短路径上,跑通即视为整体可行。
### 必须证明的四件事
1. **脱离 Granny 读 gr2** — 自研加载器能从 Metin2 真实的 `.gr2` 中读出骨架、蒙皮网格、骨骼权重与动画轨道。
2. **动画与蒙皮正确** — 骨骼动画采样 + 线性混合蒙皮的结果,与 Windows 客户端逐帧数值一致。
3. **跨平台 RHI 成立** — 同一套渲染代码经 bgfx 在 macOS + iOS 真机 + Android 真机上出画面,且三端一致。
4. **移动端性能达标** — 基准场景在中端机上达到目标帧率、显存与启动时间。
### PoC 明确排除
地形与户外场景、特效与粒子、UI 与 Python 脚本层、网络、阴影、水面、天空盒、LOD 切换策略、多角色 AI。
这些都不含跨平台的新风险,属于后续正式移植的工作量而非可行性问题。
### 判定
PoC 的价值是**降低不确定性**,go 和 no-go 都是有效产出。
> **进度(2026-08-29**M0 完成(9166/9166 解析零崩溃)。M1/M2 代码完成,
> **数值保真已对拍真 Granny 2.9.12**(骨骼世界矩阵 + 蒙皮顶点 ≤ 6.5e-5,见 `oracle/`)。
> M3 iOS 端已交叉编译 + 模拟器运行 + 渲染与桌面一致;Android 脚手架待 NDK + 真机。
> **PoC 的核心技术问题(能否脱离 Granny 正确读 + 播 Metin2 gr2)= 已用数值证据回答「能」**。
> 剩余是真机验证(性能 / 生命周期 / mediump+ Android 端。
> **画质改进 pass(档 1 + 档 2)已完成**MSAA x4 / mip 链 / sRGB 线性着色 / 半球环境 + 3 方向光 / 双面;
> libgr2 加 `granny_material` 链解析 → 逐 tri_group 正确贴图(原来整模型套一张瞎猜的 dds)+ alpha-test 镂空。
> 无回归(gr2fuzz 9166 · render_fuzz 9166 · oracle 23/23)。细节见 `docs/reference/steps/M1-static-render.md`「画质改进 pass」。
- **里程碑 M3 全部门禁通过** = "C++ + bgfx、复用 EterGrnLib 逻辑" 的跨平台方案可行,进入正式移植规划。
- **门禁失败要区分两类**
- **工程性延期** —— 数值差 5%、某个曲线子类型没实现、某平台一个 API 用错。这不是"方案不行",是一个有明确修法、几天到两周能解的 bug。记下来继续。
- **方案性死路** —— 例如资产大量用 BitKnit 且烘焙退路也不可行、或 EterGrnLib 的动作混合逻辑无法脱离 D3D 复刻。这才触发"转 ozz-animation / Rust 重写 / 放弃"的评估。
- 最终交付一页纸结论,明说是哪类、卡在哪层。
### 定位澄清:这不是"跨平台版的 JTX Graphics"
两者是正交的两个轴,不要混淆:
| | JTX Graphics(及同类视觉 mod | 本方案 |
|---|---|---|
| 改的是 | **画质**:程序化天气/昼夜/天空、后处理 | **平台**Granny→libgr2、D3D9→bgfx、Win32→跨平台壳 |
| 平台 | 仍是 Windows / D3D9 / HLSL,焊死 | macOS / iOS / Android |
| 画面目标 | 比原版更好看 | 和原版一样(先求跑对,不追求提升) |
| 一句话 | 同平台,换皮升级 | 同画质,换平台 |
关系:本方案的核心动作就是把 Metin2 的固定管线换成可编程着色器(bgfx `.sc`)。做完之后,再叠一层类 JTX 的视觉增强会容易得多,而且因为着色器经 `shaderc` 编到 Metal/GLES/Vulkan**任何视觉增强天生就是三端的**。正确顺序是"先重构后端(本方案)→ 再叠视觉层";反过来(先在 D3D9 上堆画质再想跨平台)是死路。JTX Graphics 本身闭源付费、无公开源码,不纳入技术选型。
---
## 02 · 架构总览
分层清晰、复用边界明确:底层文件与资产解码搬用 MobileSource 的对应代码(注意那套代码**写了但从未链接成功**,M1 是它第一次真正跑),中间的 gr2 解析全部自研,上层渲染走单一 RHI(bgfx)。
### 分层
| 层 | 内容 |
|---|---|
| 平台壳 | `sokol_app.h`(窗口 / 主循环 / 输入,四平台一份) → 原生 handle 经 `bgfx::PlatformData` 交给 bgfx(交接是风险,见 [§03](#窗口层sokol_app-与-bgfx-的交接是一处真风险) |
| RHI | **bgfx**(经 `bgfx.cmake`Metal / GLES3 / Vulkan / D3D11 · `shaderc``.sc` → 多 profile · RHI 薄封装 ≈ `StateManager` |
| 渲染器 | 场景·相机 / 动画采样 / 蒙皮 CPU→GPU / 材质·贴图绑定 |
| 资产 | **libgr2(自研)** / EterImageLib(复用) / msm·msa 解析 / EterPack(复用,M3 前或 M4 接入) |
| 基础 | EterBase`CFileBase` / `CMappedFile`(复用) / 数学:glm |
### 数据流
```
.gr2/.dds/.msm/.msa → libgr2 · EterImageLib · msm/msa → 中间表示(骨架·网格·曲线·材质)
→ 动画采样 → 世界姿势 → 蒙皮 → RHI(bgfx) → Metal / GLES3 / Vulkan
```
### 复用 / 自研 / 替换
| 模块 | 处理 | 说明 |
|---|---|---|
| EterBase | 复用(**M1 首次验证** | `CFileBase` / `CMappedFile` 的 AAsset 分支 MobileSource **写了但没链接过**;CRC32、lzo、内存映射搬过来,M1 第一次真跑,可能有坑。 |
| EterPack | 复用 | eterpack 读取 + LZO 解压 + 解密。PoC 从散装文件起步,M3 前或 M4 再接(打真机时可能提前,见 [§08](#08--风险登记册) 平台行)。 |
| EterImageLib | 复用(**M1 首次验证** | `CDXTCImage`DXT1/3/5 软解到 RGBA8)、`CTGAImage`,Android 解压分支同样没跑过。移动端无 S3TC 硬件支持,运行时解压是必经路径。 |
| `MobileSource` `.sc` 着色器 | 复用为基线 | `shaders.rar/shaders/` 里那套面向 bgfx 的草稿(见 [§04](#着色器来源mobilesource-的-sc-草稿))。lit / textured / 2D 着色器 + `texture_stage.sh` 可直接改;蒙皮 / fog / 顶点色 / 跨平台编译步骤要补。 |
| EterGrnLib | 按源码复刻 | 代码焊死在 `granny_*``D3DXMATRIX` 上,不能直接编。当权威规格:网格→VB/IB 布局、材质调色板、LOD 控制、动作混合、挂点,照它的调用序列在 libgr2 + glm 上重写。 |
| granny2 / Granny SDK | 替换 | 自研 `libgr2`。泄露的 Granny 源码仅作格式参照,不链接、不进仓库产物。 |
| D3D9 / d3dx9 | 替换 | PoC 不碰完整 EterLib 渲染层。着色器从 `.sc` 草稿改,配一层 bgfx 封装。 |
| MSWindow / DirectInput | 替换 | `sokol_app.h` 提供窗口 / 主循环 / 输入,四平台统一;渲染交给 bgfx。 |
> **`MobileSource` 里的两条渲染路径**:一条是手写的 `GrpOpenGL` D3D8→GLES shim(已进 CMake,~40% 文件参与编译,从未链接成功);另一条是 bgfx 路径(未接线,但有更完整的 `.sc` 着色器集)。本方案接续 bgfx 那条路径的着色器成果,不用 shim。
>
> **`reuse/` 是冻结的 vendored 快照 + 本项目的修复提交在其之上**,不是对 `../MobileSource` 的引用 —— 那套代码从没链接过,M1 一定会改它,必须版本可控。同理 `.sc` 着色器也拷进 `app/shaders/` 冻结。
---
## 03 · 技术选型
### 为什么是 bgfx(而不是 sokol_gfx / wgpu
- **最难的活已有人用 bgfx 干了一半** — `shaders.rar` 里那套 `vs/fs_pnt``pdt``pt``terrain` + `texture_stage.sh` + `varying.def.sc` 就是冲 bgfx 写的。选 bgfx = 用 `shaderc` 编一下几乎直接能用;选 sokol = 得把 `.sc` 逐个翻成 sokol-shdc 的 GLSL,还要重写 helper。
- **这不是一次性 PoC** — 目标是真做跨平台客户端,PoC 就是最终移植的同一条代码路径。RHI 选型要背几年,选一个 shipping 级、社区大、Metal/GLES/Vulkan 都成熟的更划算。
- **引擎结构对口** — Metin2 整套是 FVF + texture-stage + 多光源 + alpha-test 的固定管线思维,bgfx 的 vertex layout / `varying.def.sc` / state flags 跟这套心智模型贴得近。
- **调试设施** — 内置 stats HUD、`dbgTextPrintf`、RenderDoc 钩子、`BGFX_DEBUG_*`,正是验证阶段要用的;`bimg` / `texturec` 量产转 ASTC/ETC2 也用得上。
- **构建摩擦已消除** — 用 `bgfx.cmake`(社区维护的 CMake 封装),集成就是 `add_subdirectory` 的事,`bx` / `bimg` / `bgfx` 三个仓库一起拉。
### 窗口层:sokol_app 与 bgfx 的交接是一处真风险
bgfx 自己不管窗口。计划做法:`sokol_app` 拿到原生 window handle → 填 `bgfx::PlatformData``bgfx::init`,让 bgfx 自己建设备。
**但这不是 sokol_app 的常规用法**sokol_app 和 bgfx 都是"整个 app 框架",都想拥有 swapchain / context。sokol_app 在 iOS/Android 会自建 EAGL/EGL context 和 `MTKView` / `GLSurfaceView`,要让 bgfx 接管得专门压制这些(可能要 `SOKOL_NO_ENTRY` + 只用 sokol_app 的窗口/输入部分)。桌面上更稳的组合其实是 **bgfx + SDL2 / GLFW**
因此:**M1 给这个交接一个独立子门禁**macOS 上 bgfx 经 sokol_app 的 handle 出画面 + 输入可用);若两周内啃不下,退回"桌面 SDL2 + iOS/Android 各写最小原生壳"。
### 着色器
`MobileSource/Cross Platform/shaders.rar/shaders/*.sc` 为基线,用 bgfx 的 `shaderc` 编译。
不再手写 sokol-shdc GLSL。详见 [§04 着色器来源](#着色器来源mobilesource-的-sc-草稿)。
- 目标 profile`--platform osx -p metal`macOS)、`--platform ios -p metal`iOS)、`--platform android -p 300_es`Android GLES3)、`--platform windows -p s_5_0`Windows 对照)。
- 产物 `.bin``bin2c` 嵌成 C 数组,`bgfx::createShader` 加载。
- `bgfx_shader.sh` 随 bgfx submodule 自动就位。**`.sc` 草稿写于 2025-12,针对某个 bgfx 版本 —— `bgfx.cmake` 的 submodule 就 pin 到那个或相近版本,否则宏 / uniform 命名可能对不上。**
- `MobileSource` 里的 `compile_shaders.bat` 路径写死、只出 Vulkan 一个 profile**要换成 CMake 的可移植 custom command**。
### 构建
- 单一 `CMakeLists.txt` + 三份 toolchainiOS`ios.toolchain.cmake`)、Android NDK、macOS 原生。
- `third_party/``bgfx.cmake`(含 `bx` / `bimg` / `bgfx` submodule)、`sokol`(仅 `sokol_app.h`)、`glm`
- C++20`-fno-exceptions` 视复用代码情况而定(EterBase 用了少量异常,先保留)。
- 数学库用 **glm**:列主序,与 GL/Metal/bgfx 一致;替换 `D3DXMATRIX` 时注意 D3DX 是行主序,上传前转置或全程列主序。
- CI 三端并行:macOS 原生、iOS 模拟器、Android 模拟器。
---
## 04 · 关键模块
### libgr2 — gr2 v6 读取器(自研,核心)
只读,覆盖:
- **文件头 + section 表** — 定位各段;实现 section 解压(预期未压缩或 Oodle0/1)。
- **自描述类型树遍历** — 不硬编码结构,按文件内嵌的 `data_type_definition` 递归解析,指针/引用重定位(relocation/marshalling 表)。
- **`FileInfo` 根对象** — 取出 `Textures[]``Materials[]``Skeletons[]``VertexDatas[]``TriTopologies[]``Meshes[]``Models[]``Animations[]`
- **骨架** — 骨骼数组(名称、父索引、`LocalTransform``InverseWorld4x4`)。
- **网格** — 顶点(`PNT332` / `PNT3322` 及带骨骼索引+权重的蒙皮变体)、索引、三角组(按材质分段)、`BoneBindings`
- **动画** — `TrackGroups` → 每骨的 position / orientation / scaleshear 曲线;曲线子类型解码(关键帧数组、B 样条拟合等)。
对照实现:泄露 Granny 源码里的 `granny_data_type_definition.*``granny_file_info.*``granny_curve*.cpp` 当算法参照读;不复制代码。
### 资源加载
- **贴图** — `.dds``CDXTCImage` 解到 RGBA8,用 `bgfx::createTexture2D` 上传(移动端显存 ×4,PoC 可接受)。量产阶段换离线转 ASTC / ETC2 或 Basis Universalbgfx 的 `bimg` / `texturec` 就能做)。
- **文件访问** — 直接用 `CMappedFile`,桌面走 mmapAndroid 走 `AAsset_getBuffer`MobileSource 已实现)。M0M3 用散装文件,M4 切 eterpack。
### 动画与蒙皮
- **采样** — 按局部时钟对每骨曲线插值 → 局部变换 → 沿父链累积得世界矩阵(对应 `GrannyBuildWorldPose` / `GrannyGetWorldPoseComposite4x4Array`)。
- **过渡** — ease-in/out 曲线、loop count、raw local clock 的语义,照 `EterGrnLib/Motion*``ModelInstanceMotion.cpp` 的调用序列复刻。
- **蒙皮** — M2 先 CPU 线性混合蒙皮(`skinMatrix = world[bone] * invBind[bone]`,逐顶点 4 权重加权);M4 移到顶点着色器(骨骼矩阵走 bgfx uniform 数组或 texture)。
- **rigid mesh** — `GrannyMeshIsRigid` 为真的网格不做蒙皮,只按挂载骨骼刚体变换 —— 分支照搬。
### 着色器来源:`MobileSource` 的 `.sc` 草稿
`shaders.rar/shaders/` 是一份**没写完的草稿**,能用的部分省事,缺的部分正好是 PoC 核心。
| 文件 | 是什么 | 对我们 |
|---|---|---|
| `varying.def.sc` | bgfx attribute/varying 声明:`a_position/a_color0/a_texcoord0/a_normal``v_normal/v_texcoord0/v_worldPos/v_texcoord1` | 结构直接用,**但没有骨骼索引/权重**,要加 `a_indices` / `a_weight` |
| `vs_pnt.sc` / `fs_pnt.sc` | position-normal-texcoord。FS 是重头:8 光源前向光照 + 全套 `D3DCMP_*` alpha-test + `applyTextureStage` | **lit mesh 着色器基线**M1M2 直接改 |
| `vs_pdt.sc` / `fs_pdt.sc` | position-diffuse-texcoordMetin2 最常用 FVF | 带顶点色的网格 |
| `vs_pc.sc` / `fs_pc.sc` | position-color,无纹理 | UI 图元 |
| `vs_pt.sc` / `pt2``fs_pt.sc` / `pt2` | position-texcoord2D 图像/精灵 | UI 贴图 |
| `vs_terrain.sc` / `fs_terrain.sc` | 地形 splatting,很小 | 基本占位,PoC 不用 |
| **`texture_stage.sh`** | 把 D3D 固定功能 `SetTextureStageState` 完整翻译成 shaderCOLOROP/ALPHAOPMODULATE、MODULATE2X/4X、ADD、ADDSIGNED、SUBTRACT、BLEND…)、arg 选择(TEXTURE/DIFFUSE/CURRENT/TFACTOR)、COMPLEMENT/ALPHAREPLICATE 标志,uniform 驱动 | **最值钱的一块**,但它是一份**未经校验的重实现** —— 见下方验证要求 |
> **`texture_stage.sh` 必须专门验证**:它是别人写的 D3D 固定功能重实现,MODULATE2X 的 clamp、arg complement、多 stage 串联都可能有细微错。M1/M2 要拿**一个已知的多 stage 材质**(从测试资产集里挑一个用了 `D3DTOP_MODULATE2X` 或 `ADDSIGNED` 的)单独和 oracle 对拍,不能只靠整场景 SSIM 兜。
**状态 / 要补的**
- `.bin` 输出大多 31–34 字节,是空/失败产物 —— 忽略,从 `.sc` 重编。
- `compile_shaders.bat` 路径写死、只出 Vulkan 一个 profile —— 换成 CMake custom command,出 metal / 300_es / s_5_0 三套。
- **没有蒙皮** —— 没有骨骼矩阵、没有 `a_indices` / `a_weight`。新写 `vs_pnt_skinned.sc`(骨骼矩阵走 uniform 数组或 textureVS 里做 LBS)。这是 M2 的核心。
- `vs_pnt` 丢了 `a_color0`(Metin2 网格常带顶点色),要补。
- `v_texcoord1`(第二 UVlightmap/detail)声明了没用。
- **没有 fog** —— Metin2 大量用 D3D 雾,要加。
一句话:这套 `.sc` 把"固定管线光照 + alpha-test + texture-stage → shader"做掉了大半,蒙皮、fog、顶点色、跨平台编译步骤要自己补。
### msm / msa — Metin2 包装格式
`.msm` 描述一个模型:引用的 `.gr2`、贴图、材质类型、挂点。`.msa` 描述动作:引用的动画 gr2、混合参数、事件。
格式简单,解析器逻辑就在 `RaceManager.cpp` / `EterGrnLib/Util.cpp` 里,照抄。
**要有验证门**`.msm` / `.msa` 是人可读的文本文件。M2 加一条子检查——dump 解析结果(挂点名、动作列表、混合参数),和源文本逐项目视核对。否则 `.msa` 混合参数读错 → 动画错 → M2 矩阵 diff 失败,你会去 debug libgr2 / 采样,真 bug 却在这里。
### RHI 封装
一层 ≈ 200 行的薄封装,把"设置纹理 / 设置顶点缓冲 / 设置变换 / 画一批索引三角形"映射到
`bgfx::setVertexBuffer` / `setTexture` / `setUniform` / `setState` / `submit`,语义对齐 `StateManager`
目的是让从 `EterGrnLib` 复刻过来的渲染逻辑几乎逐行对应。
### 平台壳
- `sokol_app.h` 的单回调主循环,四平台一份 `main`;原生 handle 经 `bgfx::PlatformData` 交给 `bgfx::init`。**这个交接本身是风险,见 [§03 窗口层](#窗口层sokol_app-与-bgfx-的交接是一处真风险)M1 独立子门禁。**
- **Android 上下文丢失** — 切后台 GL context 连同 GPU 资源可能失效。在 sokol_app 的 `SUSPENDED` / `RESUMED` 事件里按 bgfx 的重置流程处理(`bgfx::reset` + 必要时重建资源)—— **M3 就处理,不要拖到最后**
- **iOS** — 全静态链接,无 `dlopen`Metal drawable 生命周期照 sokol_app 处理;资源打进 `.bundle`
- **资产上真机的方式要在 M3 前定**:9166 个散装 `.gr2` + 一堆 `.dds` 不能直接堆进 APK / `.bundle`(体积、iOS 限制)。要么 M3 前提前接 eterpack(现排 M4),要么只把测试资产集打进去。M3 交付里"原样交叉编译"没算这一步。
---
## 05 · gr2 格式要点
抽查 `warrior_cheongrin_lod_01.gr2` 的文件头:
```
偏移 字节 含义
0x00 B8 67 B0 CA F8 6D B1 0F 84 72 8C 7E … 16 字节 magic:32 位小端 · 文件格式版本 6
0x10 B8 01 00 00 头部长度 = 0x1B8
0x20 06 00 00 00 格式版本字段 = 6
0x24 AC 09 01 00 TotalSize = 68012 = 文件实际大小 ✓ 解析正确
0x28 65 7A 20 53 CRC32
0x2C 38 00 00 00 section 表偏移 = 0x38
```
### 已确认(M0 T1–T9 全部实现并全量实测:9166/9166 解析成功、0 崩溃、0 非退化谓词失败)
> `libgr2` = header/section/fixup + 自描述类型树遍历器 + FileInfo + 骨架(含 bind-pose 自洽检查)
> + 蒙皮网格 + 动画曲线解码 + 采样。`gr2dump` / `gr2fuzz` 见 `tools/`。报告见 `test/fuzz-report.json`。
- **magic = `GRNFileMV_Old`**(不是 `GRNFileMV_32Bit_LittleEndian`),32 位小端。**9155 个是格式 v6,11 个是 v7**(容器兼容,都能读)。
- 每文件固定 **8 个标准 section**Main / RigidVertex / RigidIndex / DeformableVertex / DeformableIndex / Texture / Discardable / Unloaded)。
- **section 用 Oodle1 压缩**(每段 `Format==2`Texture section 例外,空)。之前"未压缩"是把 `HeaderFormat`=0)错当成 section 压缩。**没有 BitKnit**(全样本 0 个)。**Oodle1 解码器已从泄露 SDK 端口进 `libgr2``oodle1.c`),全量验证通过。**
- 资产由 **Granny Standard Exporter SDK 2.4.0.7** 导出 —— oracle 的 `granny2.dll` 应锁 2.4.x 线(见 [`steps/00-oracle.md`](./steps/00-oracle.md) T1)。
- `total_size` 字段 == 文件实际大小;解压后每个 section 恰好 `ExpandedDataSize`。内嵌路径串(`D:\Ymir Work\...`)解压后干净可读 —— 内容正确性的 oracle-free 锚点。
- 客户端共 **9166 个 .gr2**,散装在磁盘,PoC 阶段无需解包。
- 引擎实际用到的 Granny API 约 **90 个**,全部是"读文件 / 采样动画 / 变形顶点",无任何建模或导出。
- 顶点类型实测:`PNT332`rigid4016 mesh)、`PNT3322`rigid 双 UV309)、`PNT332_Skinned`(3118)。**无未知类型**。蒙皮变体带 `BoneWeights`NormUInt8×4 或 Real32×4+ `BoneIndices`UInt8×4 或 packed UInt32)。
- **曲线全部是 `OldCurveType`**Granny 2.4`{Int32 Degree; RefToArray Knots; RefToArray Controls}`**无压缩变体、无 `curve2`/`CurveData` variant**)。degree ∈ {0,1,2}**无 3**)、dim ∈ {0,3,4,9}。`gr2_anim.cpp` 全覆盖(常量 / 线性 / 二次 B 样条 + 四元数归一)→ **§08 的"曲线子类型超出范围"风险消除,无需烘焙退路**。
- **非单位 scaleshear 普遍**2267 个 skeleton 至少 1 骨、全语料 13298 骨带非单位 scale-shear。→ 组合公式走完整 `R3·SS3`(已实现);M2/M3 蒙皮按完整仿射,**不可用 `world·invBind` 的正交近似**。
- **多 skeleton / 多 model**:一个文件可含本体 + 武器挂点各自的 skeleton(如 `redthief2_soldier2` = 3 skeleton / 3 model)。mesh `BoneBindings` 对每个 skeleton 试解析取悬空最少者。
- **root 偏移两种放法**:多数 skeleton 把 model-space 偏移放在 `model.InitialPlacement`root 骨 local 为单位);少数烘进 root 骨 local。世界姿势 = `Composite(local[root]) · InitialPlacement`
- `EterGrnLib` 已用 `#if GrannyProductMinorVersion == 4 / 7 / 8 / 9 / 11` 兼容多版本 —— 实测资产就是 **2.4**,与 libgr2 的选择一致。
### M0 抽样统计结果(`test/fuzz-report.json`,全量 9166
- 格式版本:v6 × 9155、v7 × 11。section 压缩:非空段 100% Oodle1**0 Oodle0 / 0 BitKnit** → §08 BitKnit 退路项不触发。
- bind-pose 自洽(不依赖 oracle):多骨 skeleton 2013/2017 `max|Δ| < 1e-3``warrior_cheongrin` 6.1e-5);4 个 `>=1e-1` 已定位到辅助骨子树的 `InverseWorld4x4` 未随 `InitialPlacement` 更新(含 1 个文件名带 `_backup`),非阻塞。
- 曲线子类型直方图(degree·dim):pos `d0·d3`(322k)/`d2·d3`(41k)rot `d0·d4`(79k)/`d2·d4`(210k)scale `d0·d9`(42k)/`d1·d9`(3k);大量 `d0·dim0`(恒等轨道)。
### M0 第一件事:锁定 oracle 用的 Granny 版本
资产是文件格式 v6(约 2.6–2.9 era),但 `m2dev-client-src` 头文件写 2.11.8。**oracle 必须用实际能正确加载这批资产的那个 `granny2.dll` 版本**去 dump,否则是拿错误的参考去校 libgr2。M0 起手要确认:这批 gr2 在哪个 Granny 版本下 `GrannyGetFileInfo` 返回完全正常(骨骼数、顶点数、动画时长都合理),把那个 DLL 固定进 `oracle/`
---
## 06 · 里程碑与验证门
五个里程碑,每个都有明确的交付物和一道 go/no-go 门禁。单人全职到 M3 结束约 2–3 个月。
里程碑的**验证逻辑**与 RHI 无关;换 bgfx 只在 M1 多了一道"sokol_app + bgfx 交接"的子门禁。
> **分册**:本节是索引,每个里程碑的文件级施工文档在 [`steps/`](./steps/)
> [`00-oracle`](./steps/00-oracle.md)(关键路径,M0 起)·
> [`M0-gr2-reader`](./steps/M0-gr2-reader.md) ·
> [`M1-static-render`](./steps/M1-static-render.md) ·
> [`M2-anim-skinning`](./steps/M2-anim-skinning.md) ·
> [`M3-mobile`](./steps/M3-mobile.md) ·
> [`M4-realistic-load`](./steps/M4-realistic-load.md)。
> M0+M1 的可演示 demo 施工图另见 [`DEMO-PLAN.md`](./DEMO-PLAN.md)。
### M0 · gr2 解析器(独立 CLI) — 1–2 周
- **交付** — ① 锁定 oracle 的 Granny 版本(见 [§05](#m0-第一件事锁定-oracle-用的-granny-版本));② `gr2dump``warrior_*` 系列 + 若干 zone 静态件,产出 libgr2 的结构化 dump(骨架 / 网格 / 动画曲线)**和** glTF;③ 对全量 9166 个 `.gr2` 跑 parse-fuzz,产出格式变体直方图;④ 从 fuzz 元数据里筛出测试资产集(见 [§07](#测试资产集精选约-15-个))并写进 `test/assets.list`
- **门禁** — libgr2 结构化 dump 与 oracle 的 `GrannyGetFileInfo` dump **逐字段一致**(见验证);fuzz 零崩溃且**产物通过非退化谓词**;变体分布落在可实现范围(无 BitKnit,或有明确离线转换退路);测试资产集已产出。
- **验证** —
- **自洽检查(不依赖 oracle,先跑)**:从 `LocalTransform` 链重建 bind pose 世界矩阵,验证每骨 `world_bind[i] · InverseWorld4x4[i] ≈ I`。这一条抓"读矩阵时带了转置 / 手系错、且同样作用于正向读取"这类 oracle 对拍也发现不了的 bug。
- **主**libgr2 结构化 dump vs oracle dump 逐字段对拍 —— 骨骼数 / 父索引 / 名称 / `LocalTransform` / `InverseWorld4x4` / 顶点数 / 索引数 / 每骨曲线的关键帧数 / 动画时长,全部相等或 < 噪声地板。
- **辅**:导出 glTF 在 Blender 目视骨架拓扑 + 绑定姿势网格。**注意 glTF 只能证拓扑和 bind pose,证不了动画曲线解码对**(glTF 动画表达不了 Granny 的 ease 曲线 / scale-shear / 常量轨道压缩)—— 动画正确性只认上面那条主验证。
- **fuzz 非退化谓词**:骨骼数 ∈ [1, 512];每骨父索引 < 自身索引或 = -1;变换无 NaN/Inf;顶点数 > 0;所有索引 < 顶点数;每顶点权重和 ∈ [0.99, 1.01];骨骼索引在范围内。不满足即计一次 fail。
- **bootstrap 关系**:M0 自身验证用硬编码引导集(`warrior_*` + 若干 zone);`test/assets.list` 一旦产出,M1 及之后一律用它。
### M1 · macOS 静态渲染 — 23 周
- **交付** — sokol_app 窗口 + bgfxMetal),渲染 gr2 静态网格(绑定姿势)+ 贴图,轨道相机;着色器从 `vs/fs_pnt` 草稿改;`texture_stage.sh` 接一个已知多 stage 材质。
- **子门禁 1(交接)** — bgfx 经 sokol_app 的原生 handle 在 macOS 出画面 + 输入可用(见 [§03 窗口层](#窗口层sokol_app-与-bgfx-的交接是一处真风险))。啃不下则退回 SDL2 方案。
- **子门禁 2(几何)** — 网格拓扑、UV 正确;无背面剔除错误 / 法线翻转;一个多 stage 材质的着色结果与 oracle 对上。
- **验证** —
- **对比场景两侧都喂同一张预解码 RGBA**(绕开 DXT),让截图 diff 只反映几何 / 光照 / texture-stage,不被软解 vs 硬件 S3TC 的 bit 级差异污染。
- oracle 侧要能进入确定态:固定相机矩阵注入、固定光、绑定姿势、无程序化摇摆 —— 这需要 oracle build 的少量改造,M1 一并做。
- 与 oracle 截图像素 diff + SSIM;开法线可视化模式自检。
### M2 · 骨骼动画 + CPU 蒙皮 — 2–3 周
- **交付** — 解析 `.msm` / `.msa`,构建世界姿势,线性混合蒙皮,播放 idle / walk,支持 loop 与 ease;补 `vs_pnt_skinned.sc`;装配一个**多部件角色**(身体 + 至少一个额外 gr2,如头发/时装)+ 一个**挂点武器**。
- **门禁(分级,按序)** —
0. **`.msm` / `.msa` 解析子检查**:dump 解析结果和源文本逐项目视核对(挂点名、动作列表、混合参数)。
1. **骨骼世界矩阵**与 oracle 的**层 ①(裸 Granny`GrannyGetWorldPoseComposite4x4Array` 直出)**逐帧对拍,max|Δ| < ε_mat —— 隔离"曲线采样 + 世界姿势累积",先过。**M2 只对层 ①**;层 ②(过完 `EterGrnLib``ActorInstanceBlend` + LOD 骨骼裁剪)不在 PoC 范围,留到正式移植。
2. **蒙皮顶点坐标**与 oracle 逐帧对拍,‖Δ‖ < ε_vtx —— 隔离"蒙皮 + 顶点格式"。
3. 多部件装配:挂点武器的世界变换与 oracle 一致(`GrannyFindBoneByName` + 挂点矩阵链复刻对)。
4. **LOD 一致性**:同一模型 LOD 0–3 共享骨骼绑定,切换无跳变(顶点在切换帧的位移 < 阈值)。
- **验证** — 数值层对拍(N 帧 × M 顶点、全部测试资产集,不是单文件;tie-break 见 [§07 数值层](#数值层))+ 视觉层(3 个确定姿势,差异分类见 [§07 视觉层](#视觉层))。
- **动画曲线退路** — 若 M0 统计发现曲线子类型超出可实现范围,改走"oracle 离线烘焙成密集关键帧"(见 [§08](#08--风险登记册) 动画行),libgr2 只读烘焙格式,M2 照常验证。
### M3 · iOS + Android 真机 — 12 周(与 M2 尾段并行)
- **交付** — M2 工程交叉编译(**含资产上真机的方案**,见 [§04 平台壳](#平台壳) —— 提前接 eterpack 或只打测试资产集,不是"原样交叉编译"就完事),两端真机运行基准场景;CI 三端冒烟。
- **门禁** —
- 三端渲染差异分类通过(见 [§07 视觉层](#视觉层));
- 简化角色 1 个 ≥ 60fps、20 个 ≥ 30fps,满配角色 1 个 ≥ 60fps、8 个 ≥ 30fps(机型见 [§07 性能层](#性能层真机));冷启动 < 3s
- 无 bgfx / 图形验证层报错;
- **Android 切后台 ×100 恢复正常(无崩、无黑屏);iOS 切后台 / 锁屏 / 来电后恢复正常(丢 Metal drawable 的处理)**
- **mediump 精度专项**:顶点着色器骨骼矩阵 / 位置用 highp 前后对比,确认无抖动 / 爆顶点。
- **验证** — 三端快照互拍 + 真机性能采集(帧时间 p99、显存、RSS、加载耗时)。
### M4 · 逼近真实负载(可选) — 2 周
- **交付** — GPU 蒙皮;改走 eterpack 加载;多角色 + 一块地面 + 简单光照;LOD 切换。
- **门禁** — GPU 蒙皮结果与 CPU 一致;50 角色可交互帧率;eterpack 路径与散文件结果一致。
- **验证** — CPU / GPU 蒙皮对拍;扩展性能曲线(1 / 8 / 20 / 50 角色)。
---
## 07 · 验证方法论
核心思路:把 Windows 客户端当作"真值预言机"(oracle),新实现的每一层输出都要能和它对拍。分数值、视觉、性能三个层次,配自动化回归。
### 预言机:插桩的 Windows 客户端
在 Windows 侧构建一个精简 harness,链接**锁定版本的** `granny2.dll` + `EterGrnLib`(版本确定见 [§05](#m0-第一件事锁定-oracle-用的-granny-版本)),能对"给定 `.gr2` + `.msa` + 时刻 `t`"导出:
- **两层骨骼矩阵**:① 裸 Granny 层(`GrannyGetWorldPoseComposite4x4Array` 直出);② 过完 `EterGrnLib` 的 LOD 骨骼裁剪 + `ActorInstanceBlend` 混合后的最终矩阵。libgr2 + 我们的采样先对①,装到引擎后对②。
- mesh 0 蒙皮后的顶点坐标;
- 确定态截图(固定相机矩阵注入、固定光、精确 model clock、单动作无混合、关掉待机摇摆 —— 需 oracle build 少量改造)。
**坐标空间必须两侧都写死**oracle dump 和 libgr2 输出都声明用同一约定(左手 Y-up、单位、根变换是否已 apply)。§08 的 basis 转换高危项,就靠这条数值锚点来抓 —— 目视抓不住 90° 轴交换或 ×100 缩放。
**oracle 自身要先过自检**(在被信任之前):加载一个 gr2、dump t=0 的骨骼矩阵,应与该 gr2 存的 bind pose`InverseWorld4x4` 求逆)一致。过不了这条,下游全建在沙子上 —— 这是 M0 的隐性前置门禁。
这套 dump 是 Windows-only,永远留在 Windows。`oracle/` 的搭建(把 `EterGrnLib` 从完整客户端里单独抽出来编、两层矩阵 dump、确定态改造)**从 M0 起,是关键路径**,需要一个会 Windows / D3D 构建的人,[§09 工作量表](#工作量)里单列。
### 数值层
**阈值不是拍的,要先测噪声地板**:把 oracle 跑两遍(或 oracle vs 另一个已知正确的 Granny 实现),看同一输入的输出抖动有多大,ε 设在地板之上一个安全余量。否则正确代码也会 fail —— 60 骨链上 float32 累积、Granny 内部用 float、我们用 double 再转,1e-4 / 1e-3 这种数很可能比噪声还紧。
| 对象 | 隔离的是 | 比对方式 | 阈值 |
|---|---|---|---|
| gr2 结构(骨骼数 / 父索引 / 名称 / `LocalTransform` / 顶点数 / 索引数 / 每骨关键帧数 / 动画时长) | 解析正确性 | libgr2 vs oracle dump vs Blender 导入,三方一致 | 相等(浮点字段 < 噪声地板) |
| 骨骼世界矩阵(先对裸 Granny 层,装进引擎后对 EterGrnLib 层) | **动画采样 + 世界姿势累积** | 逐元素与 oracle 相减,取 max\|Δ\| | < ε_mat(噪声地板 × 余量) |
| 蒙皮顶点坐标 | **蒙皮 + 顶点格式**(在矩阵已对上的前提下) | N 帧 × M 顶点,`‖v_ours v_oracle‖` | < ε_vtx |
**必须按序**:先让骨骼矩阵对上,再看顶点。否则动画错 + 蒙皮错可能相互抵消、最终顶点却"对",掩盖两个 bug。
在整个测试资产集上跑,不是单文件。
**tie-break**:矩阵 / 顶点对拍默认 oracle = 真值。但如果 libgr2 与 oracle 差超过 ε、结果又都合理,需要第三个独立参照来判 —— 在测试资产集上,用 Blender `io_scene_gr2` 计算的世界矩阵(或第二个开源 gr2 库)做仲裁。这也能抓出 oracle 自己的 bug(DLL 版本选错、确定态改造引入坐标错)。
### 视觉层
- **确定性场景** — 固定相机矩阵、固定光、固定姿势(idle 第 0 帧、走路中段、旋转量大的姿势)。让 **oracle** 进入这个确定态需要改 oracle build(注入相机矩阵、精确 model clock、关程序化摇摆、单动作),这块工作在 M1。
- **贴图从对比里剔除** — 对比场景两侧都喂同一张预解码 RGBA。客户端用 GPU 硬件 S3TC、PoC 用 `CDXTCImage` 软解,两者 bit 级有差,会让整张图偏移、SSIM 掉分,原因跟要验证的几何/蒙皮无关。
- **单一 SSIM 阈值不够,要差异分类** — 同一帧 Metal vs GLES 会有"正确但可见"的差别(各向异性过滤强度、mip 选择、gamma)。流程:`SSIM ≥ 0.98` 直接过;`0.950.98` 之间进人工/规则分类,判定差异是否全落在"渲染器合理差异"集合里,是则过、否则 fail;`< 0.95` 直接 fail。
- **三端互拍** — 三个端之间应比各自与 oracle 更接近(同一 shaderc 源、同一逻辑)。
- **失效模式清单** — 法线 / 背面翻转、UV 接缝、绑错骨骼(肢体飞出)、顶点塌陷(权重错)、z-fighting、alpha 排序、gamma / 色彩空间、左右手系镜像、整体缩放错(basis 转换,数值层才是主抓手)。
### 性能层(真机)
- **两种角色都要测** — ① 简化角色(≈ 5k 三角、≈ 60 骨、单 draw call);② **满配角色**(多部件装配 + 武器 + 时装 + 挂点特效,1–2 万三角、多材质多 draw call)。只用简化角色外推真实负载会乐观。每种从 1 个放大到 8 / 20 / 50 个。
- **基准硬件写死具体机型**(不是"级别")—— 例如 iPhone 11A13+ Pixel 6Mali-G78+ 一台 Adreno 机(如 Redmi Note 系列),三种主流移动 GPU 架构各一。
- **指标** — 帧时间 p50 / p99ms)、FPS、draw call、GPU 显存(Xcode GPU report / Android GPU Inspector / `adb dumpsys meminfo`)、冷启动到首帧、单个 `.gr2` 加载耗时、峰值 RSS。
- **PoC 通过线**:简化角色 1 个 ≥ 60fps、20 个 ≥ 30fps**满配角色 1 个 ≥ 60fps、8 个 ≥ 30fps**;单角色加载 < 30ms;冷启动 < 3s;基准 RSS < 300MB。
- M4 加做 CPU vs GPU 蒙皮对比。
### 自动化 / CI
- **`gr2-fuzz`** — 解析全部 9166 个 `.gr2`,不许崩溃,产物通过非退化谓词(骨骼数 ∈ [1,512]、父索引 < 自身或 -1、无 NaN/Inf、索引 < 顶点数、权重和 ∈ [0.99,1.01]、骨骼索引在范围内);输出格式变体直方图。这一步最先抓出"某批文件是另一种变体"。
- **快照回归** — 在 macOS CI runner 上用 bgfx 离屏渲染确定性场景,PNG 与提交的 golden 带容差比对。
- **三端冒烟** — CI 里 iOS 模拟器 + Android 模拟器构建并启动,断言"到达首帧 + 连续 N 帧无 bgfx / GL / Metal 验证层报错"(打开 `BGFX_DEBUG_*`、Metal API validation、GLES `KHR_debug`)。
### 测试资产集(精选约 15 个)
**由 M0 的 fuzz 元数据 + 人工挑选产出**,写进 `test/assets.list`,后续所有里程碑都跑这一套:
- 刚体静态件(zone 建筑)
- 单材质蒙皮角色(`warrior_novice`
- 多材质 + 带 alpha 的蒙皮角色(时装)
- **多部件装配角色 + 挂点武器**(考验 `.msm` 多 gr2 组装 + `GrannyFindBoneByName` + 挂点矩阵)
- 高骨骼数 / 长动画
- 同一模型的全部 4 级 LOD(考验 LOD 间骨骼绑定一致性)
- 一个用了 `D3DTOP_MODULATE2X``ADDSIGNED` 的多 stage 材质(专验 `texture_stage.sh`
- 特效 / 挂点网格
- fuzz 阶段标出的异常个例(曲线子类型、压缩变体、超大骨骼数等的代表)
---
## 08 · 风险登记册
严重度:**高** 可能否决方案或大幅拖延 · **中** 需专门投入 · **低** 有成熟解法。
| 领域 | 风险 | 影响 / 缓解 | 严重度 |
|---|---|---|---|
| **oracle** | granny2.dll 版本与资产格式不匹配(资产 v6,src 头写 2.11.8 | 拿错误参考校 libgr2,全盘失真。**M0 起手锁定"能正确加载这批资产的 DLL 版本"**,固定进 `oracle/`(见 [§05](#m0-第一件事锁定-oracle-用的-granny-版本))。 | **高** |
| gr2 | **section 全部 Oodle1 压缩**(M0 T1 实测确认,非可选);个别文件可能混 Oodle0 / BitKnit | Oodle0/1 有公开重实现(~200 行 LZ 变体),M0 T2 必做;BitKnit 无公开实现 —— 9166 全样本 0 个,若真遇到 → Windows 侧 Granny SDK 离线转未压缩。 | 中 |
| gr2 | 自描述类型树递归 / 未知类型 / 重定位表 | 写通用类型树遍历器(不假设布局)+ 与 SDK dump 逐字段对拍。 | 中 |
| 动画 | Granny 曲线压缩格式多样(关键帧数组、D3/D4nK 量化、B 样条拟合) | 三层退路:① M0 统计实际用到的子类型,多数只需实现关键帧 + 简单量化;② `granny_curve*.cpp` 当算法参照;③ **子类型超范围就走"oracle 离线烘焙成密集关键帧"(每骨每帧一个 TRS),libgr2 只读烘焙格式,PoC 完全绕开曲线解码,代价是动画数据变大 + 一个烘焙工具**。加了 ③ 后此项可视为"中"。 | **高** → 中(有 ③ 兜底) |
| 动画 | ease-in/out 曲线、loop、局部时钟语义 | 照 `EterGrnLib/Motion*``ModelInstanceMotion.cpp` 的调用序列逐行复刻。 | 中 |
| 蒙皮 | 骨骼绑定顺序 / mesh binding 到骨架的重映射 | 复刻 `GrannyNewMeshBinding` 的按名匹配逻辑;M2 门禁先过骨骼矩阵、再过顶点,逐级隔离。 | 中 |
| 坐标系 | Granny 轴向 / 单位 → 运行时约定的 basis 转换(一个 90° 轴交换或 ×100 缩放目视看不出) | **主抓手是数值锚点**oracle dump 与 libgr2 输出都声明同一坐标约定(手系、单位、根变换是否 apply),骨骼矩阵逐元素对拍即可暴露;复刻 `GrannyConvertSingleObject`;朝向明确的资产做辅助目视。 | **高** |
| 集成 | sokol_app + bgfx 都想拥有 swapchain/context,非 sokol_app 常规用法 | M1 独立子门禁验证交接;退路是桌面 SDL2 + iOS/Android 各写最小原生壳(见 [§03 窗口层](#窗口层sokol_app-与-bgfx-的交接是一处真风险))。 | 中 |
| 验证 | `texture_stage.sh` 是未校验的 D3D 固定功能重实现(MODULATE2X clamp、arg complement、多 stage 串联) | 用一个已知多 stage 材质在 M1/M2 单独和 oracle 对拍,不靠整场景 SSIM 兜。 | 中 |
| 验证 | 数值阈值 ε 拍脑袋,正确代码也可能 fail | 先跑 oracle 两遍测噪声地板,ε 设在地板 + 余量之上(见 [§07 数值层](#数值层))。 | 中 |
| 贴图 | 移动端无 S3TC / DXT 硬件支持(确定项) | PoC 运行时解压 DXT→RGBA8(复用 EterImageLib,显存 ×4);量产改离线转 ASTC / ETC2 或 Basis Universalbgfx `bimg`/`texturec`)。 | **高** |
| 贴图 | NPOT / mipmap 链 / sRGB | GLES3 / Metal 均支持 NPOT;统一线性工作流 + sRGB 纹理视图。 | 低 |
| RHI | 深度范围 0–1 vs −1–1、裁剪空间 Y 翻转、行 / 列主序矩阵 | bgfx 有 `bgfx::getCaps()->homogeneousDepth` / `originBottomLeft` 抹平后端差异;矩阵全程列主序(glm);离屏 RT 用 bgfx 的约定。 | 中 |
| 着色器 | `shaderc` 跨编译到 metal / 300_es / s_5_0 行为差异 | 单一 `.sc` 源;开 bgfx / Metal / GLES 验证层;三端快照对拍。比手写多份 GLSL 更省心。 | 低 |
| 着色器 | `.sc` 草稿缺蒙皮 / fog / 顶点色,且 `.bin` 是废产物 | 从 `.sc` 重编;新写 `vs_pnt_skinned.sc`,补 fog 与 `a_color0``compile_shaders.bat` 换成 CMake custom command。属已知工作量,非未知风险。 | 低 |
| 精度 | 移动 GPU mediump 存不下骨骼矩阵 / 大坐标 | 顶点着色器里骨骼矩阵与位置用 highp;必要时把模型原点归一。 | 中 |
| 平台 | Android GL context 丢失后 GPU 资源需重建(确定项) | 在 sokol_app 挂起 / 恢复事件里按 bgfx 的 `reset` 流程处理;**M3 就做,别拖到最后**。 | **高** |
| 平台 | iOS 全静态链接、无 dlopen、后台丢 Metal drawable | 全静态;Metal 生命周期照 sokol_app 处理。 | 中 |
| 平台 | 9166 个散装资产上真机的方式没定(APK/bundle 体积、iOS 限制) | M3 前定:提前接 eterpack,或只打测试资产集。M3 交付要显式包含这一步。 | 中 |
| 构建 | bgfx 依赖 `bx` / `bimg` / `bgfx` 三仓 + 单一 CMake 出三端 | 用 `bgfx.cmake` 封装,submodule 固定版本;一开始就配好 toolchain + CI 三端并行。 | 中 |
| 授权 | libgr2 部分模块是从泄露 Granny SDK **直接端口**`oodle1.c` = `radlz.c`/`radarith.c`/`arithbit.c` 的解码路径逐行移植,非"看规格重写");其余(文件头 / 类型树 / 骨架)是照 struct 定义写的 | 比 clean-room 弱。对"内部自用、不公开、非商业"可接受;`oodle1.c` 顶部注明来源。**一旦要公开 / 商用**:`oodle1.c` 必须换成真正的 clean-room 实现或第三方 LZgr2 里的 Oodle1 只是个简单 LZ+算术编码,可重写),Granny 运行时逻辑评估 ozz-animation 替换或采购授权。 | 中 |
| 范围 | PoC 蔓延到地形 / 特效 / UI | 严守"骨骼蒙皮 + 三端跑通"边界;地形 / 特效 / Python 明确排除在 M4 之外。 | 中 |
| 范围 | **Python/UI 层是仅次于 gr2 的第二大未知,被本 PoC 排除** | CPython 3 在 iOS(无 JIT / 全静态 / 脚本预编译)跑 Metin2 那 2000+ 个 `.py` + `EterPythonLib` C 扩展 + 依赖 D3D 图元的 `ui.py` 窗口系统 —— **这是 PoC 之后要立刻做的"第二个 PoC"**,不是"已知可行的工作量"。 | **高**(对完整移植) |
---
## 09 · 工作量 · 人力 · 目录
### 工作量
| 工作项 | 单人全职 | 2 人小队 |
|---|---|---|
| **oracle 工具**Windows:抽出 EterGrnLib 单独编、锁 Granny 版本、两层矩阵 + 顶点 dump、确定态改造、自检) | 1–2 周(与 M0 并行,含在关键路径) | 由会 Windows/D3D 的人专责 |
| M0 gr2 解析器 + fuzz + 结构化对拍 + 筛测试资产集 | 1–2 周 | 合计 ≈ 68 周(libgr2 / oracle+验证台 / RHI+壳 三线并行) |
| M1 macOS 静态渲染(含 bgfx↔sokol_app 交接子门禁 24 天) | 23 周 | |
| M2 动画 + CPU 蒙皮(曲线解码是关键路径) | 2–3 周 | |
| M3 iOS + Android 真机 | 12 周 | |
| **到 go/no-go 结论** | **≈ 23 个月** | **≈ 68 周** |
| M4 逼近真实负载(可选) | +2 周 | +1–2 周 |
**人力硬约束**:oracle 那条线需要一个能在 Windows 上搭 D3D9 客户端构建、把 `EterGrnLib` 拆出来单独链的人 —— 这和写 libgr2 / RHI 的技能不同,单人做要来回切换,小队要专人。
关键路径是 **libgr2 曲线解码(M0 → M2+ oracle 工具(M0** 两条并行。
### 建议目录结构
```
xrender-poc/
third_party/
bgfx.cmake/ bx/ bimg/ bgfx/ submodule,含 shaderc / bin2c
sokol/ 仅 sokol_app.h
glm/
reuse/ EterBase/ EterPack/ EterImageLib/ ← 冻结快照 + 本项目修复提交(非引用)
libgr2/
include/ src/ gr2_file.* gr2_types.* gr2_skeleton.*
gr2_mesh.* gr2_anim.* gr2_decompress.*
engine/ rhi.*(包 bgfx:: skinning.* animation.* material.* scene.* camera.*
formats/ msm.* msa.* race.* ← 照 EterGrnLib/RaceManager 复刻
app/
main.c 窗口壳:默认 sokol_app → bgfx::PlatformData → bgfx::init
交接过不了则切 SDL2(桌面)+ 各平台最小原生壳(见 §03)
shaders/ *.sc + texture_stage.sh + varying.def.sc
← 从 ../MobileSource/Cross Platform/shaders.rar/shaders 种子
+ vs_pnt_skinned.sc(新写)
shaders.cmake 可移植编译步骤:shaderc → metal / 300_es / s_5_0 → bin2c
platform/ macos/ ios/ android/ (壳工程 + toolchain
oracle/ Windows-only:锁定版 granny2.dll + EterGrnLib
dump 两层骨骼矩阵 + 蒙皮顶点 + 确定态截图;坐标约定写死
tools/ gr2dump/ (M0 CLI) gr2fuzz/ snapshot_compare/ anim_bake/(曲线退路)
test/ assets.listM0 产出) golden/ oracle_dumps/ noise_floor.json
cmake/ ios.toolchain.cmake android helpers
CMakeLists.txt
```
### 下一步
**M0** 起,两件事并行:
1. **锁定 oracle 的 Granny 版本** —— 确认这批 v6 资产在哪个 `granny2.dll``GrannyGetFileInfo` 完全正常,固定进 `oracle/`
2. **写 `gr2dump`** —— 输出 `warrior_cheongrin*.gr2` 的结构化 dump(骨架 / 网格 / 曲线)+ glTF;结构化 dump 与 oracle 逐字段对拍(这是主验证),glTF 仅供 Blender 目视骨架和 bind pose。
这是单块价值最高、且不依赖任何 RHI / 平台决策的第一块砖。
---
## 10 · 最终验收与结论证据包
"最终成果如何验证"分两层,PoC 与完整移植的验收方式不同。
| | PoC 的最终成果 | 完整移植的最终成果 |
|---|---|---|
| 是什么 | **一份 go/no-go 结论 + 证据包**(不是能玩的客户端) | 能上架的三端 Metin2 客户端 |
| 验证目标 | "骨骼网格这条链在三端成立" 站得住、可复现 | 功能对等 + 三端一致 + 性能 / 稳定性达标 |
### 10.1 PoC 结论证据包
PoC "通过"的判据不是"看着对了",而是**能交给别人独立重跑并核对的一个包**。仓库 `test/` 下产出:
**① 可复现验证套件(CI 一条绿灯 = 全部过)**
- `gr2fuzz` 报告:9166 / 9166 解析成功 + 格式变体直方图 —— 证明"能读全部资产",不是挑了几个好文件。
- `numeric_diff` 报告:测试资产集 × N 帧 × M 顶点,骨骼矩阵 `max|Δ|` 与蒙皮顶点 `‖Δ‖` 的分布(直方图 + p99 + max),全部 < 阈值。
- `snapshot` 对比:确定性场景在 Windows-oracle / macOS / iOS / Android 四组截图 + 两两 SSIM 矩阵。
- `perf` 报告:每台真机 1 / 8 / 20 / 50 角色的帧时间 p50/p99、FPS、显存、RSS、冷启动、gr2 加载耗时,逐项标注是否越线。
- 三端冒烟:`macos / ios-sim / android-sim` 三个 CI job 全过,无 bgfx / Metal / GLES 验证层报错。
**② 门禁汇总表** —— M0–M3 每道门禁一个明确 pass/fail(见 [§06](#06--里程碑与验证门)),最终成果 = 全绿的汇总。
**③ 反证清单(negative evidence** —— 把当初担心会 block 的点逐条列出实测结果。**下表是待填模板,结论由 M0–M3 产出,不是现在已知的事实**:
| 担心点 | 实测结果(TBD) |
|---|---|
| oracle 用的 Granny 版本 + oracle 自检 | 待 M0 |
| libgr2 `InverseWorld4x4` 自洽(`world_bind · invBind ≈ I` | 待 M0 |
| Granny 曲线子类型分布 / 是否需烘焙退路 | 待 M0 fuzz |
| 非单位 scaleshear 用量(影响蒙皮公式 + mediump | 待 M0 fuzz |
| section 压缩类型分布 | 待 M0 fuzz |
| `.msm` / `.msa` 解析对拍源文本 | 待 M2 |
| 坐标系 basis 转换(数值锚点 + Blender tie-break | 待 M1M2 |
| sokol_app + bgfx 交接 | 待 M1 子门禁 |
| `texture_stage.sh` 多 stage 材质对拍 | 待 M1 |
| ε 噪声地板实测值 | 待 M2 |
| Android context loss 恢复(×100 | 待 M3 |
| iOS 全静态 + 后台 / 锁屏 / 来电恢复 | 待 M3 |
| 移动 GPU mediump 精度专项 | 待 M3 |
**④ 一页纸结论** —— go / no-go
- **go** → 附"正式移植还缺什么"清单(地形 / 特效 / UI / Python 的工作量估计)。
- **no-go** → 具体死在哪层 + 备选路径(ozz-animation 替换 / Rust 重写 / 动画烘焙退路)。
### 10.2 完整移植后的验收(**属 PoC 之后另立项目,不在本方案范围**)
以下 8 条是 PoC 通过、决定正式移植后要另写的验收纲要,本方案不覆盖其计划与工作量。PoC 只证了骨骼网格这一条。
1. **资产全覆盖回归** —— 不是 15 个测试资产,而是全部 gr2 / 全部地图 / 全部特效 / 全部 UI 脚本跑一遍,自动截图 + 与 Windows 客户端**逐场景对拍**(把 oracle 从"单帧 dump"扩成"整机录制回放 + 全场景截图")。
2. **功能对等清单** —— 登录 → 选人 → 进游戏 → 战斗 → 交易 → 公会 → 商城…每个 Python phase 在三端都能走通(手动 + 录制回放)。
3. **三端一致性** —— 同一操作序列,三端渲染 + UI 布局 + 逻辑结果一致。
4. **性能预算** —— 目标机型上完整场景(城市、BOSS 战、大量玩家)达到帧率 / 内存 / 发热 / 耗电 / 包体 / 流量预算。
5. **稳定性** —— 三端各跑 N 小时 monkey / soak;崩溃率 < 阈值;context loss、来电、切后台、锁屏、热重启全过。
6. **兼容性矩阵** —— iOS 最低版本 × 机型;Android GPUAdreno / Mali / PowerVR)× API level × 厂商 ROM。
7. **上架前检查** —— iOS 无私有 API、隐私清单、包大小;Android 64 位、target SDK、权限。
8. **回归防线** —— CI 每次改动跑快照回归 + 性能基准,防"某次改动让 Mali 上花屏"这类回归。
### 10.3 贯穿始终的一条原则
**Windows 客户端是唯一真值源。** 任何"三端自己看着对"都不算数,必须能和 Windows 逐帧 / 逐场景对拍。
这套 oracle 基础设施从 M0 就开始建(先只 dump 骨骼 + 顶点 + 单帧),移植阶段扩成整机录制回放 —— 是整个项目最值的一笔投资。