# 00 · Oracle —— Windows 真值源工具 > 总纲:[`../PLAN.md`](../PLAN.md) §07。本文件是 oracle 这条**关键路径**的详细施工文档。 > oracle 不是一个里程碑,但它和 M0 并行起步,M0/M1/M2 的主验证全部依赖它。 --- ## 目标 在 Windows 侧建一个精简 harness,链接**锁定版本**的 `granny2.dll` + 抽出来的 `EterGrnLib`, 对"给定 `.gr2` + `.msa` + 时刻 `t`"能稳定导出: 1. 两层骨骼世界矩阵(① 裸 Granny,② 过完 EterGrnLib) 2. mesh 0 蒙皮后的顶点坐标 3. 确定态截图(固定相机 / 光 / model clock / 单动作) 这套 dump 是 Windows-only,永远留在 Windows。 ## 前置 - 无硬前置,可与 [M0](./M0-gr2-reader.md) 第一天并行。 - 需要一个会 Windows/MSVC + D3D9 的人(技能和写 libgr2 不同,见 PLAN §09 人力硬约束)。 ## 两条轨(关键点) oracle 分成两半,混在一起会误判工期: | 轨 | 任务 | 依赖 | 谁需要 | 状态 | |---|---|---|---|---| | **Oracle-Lite** —— 只用 `granny2.dll` C API | T1 · T3 · T5 · T6 · T7 · T8 | 一个 `granny2.dll` + 几百行 D3D9 | M0 / M1 / M2 的验证全靠它 | **必做,关键路径** | | **Oracle-Full** —— 加上抽出来的 `EterGrnLib` | T2 · T4 | 从完整 D3D9 客户端里拆 `EterGrnLib` + 依赖 | 只有正式移植的"层②"对拍 | **可延后**,demo/PoC 不阻塞 | > M2 的门禁只对**层①(裸 Granny)**。层② 是给正式移植准备的。所以 **Oracle-Full(T2/T4)不在 demo 关键路径**,`EterGrnLib` 拆不干净就整体延后。 ## 交付物 | 产物 | 位置 | 轨 | |---|---|---| | `granny_probe.exe`(读 gr2 → 打 FileInfo) | `oracle/` | Lite | | `oracle` CLI(`oracle dump -o out.bin`) | `oracle/` | Lite | | 最小 D3D9 渲染器(骨架+mesh→PNG,不依赖 EterGrnLib) | `oracle/render/` | Lite | | 锁定的 `granny2.dll` + 判定记录 | `oracle/vendor/` + `oracle/GRANNY-VERSION.md` | Lite | | dump 二进制格式规范(含坐标约定) | `oracle/FORMAT.md` | Lite | | 噪声地板测量 | `test/noise_floor.json` | Lite | | 自检脚本(CI Windows runner) | `oracle/selfcheck.*` | Lite | | `oracle_etergrn` 静态库 + 层② dump | `oracle/full/` | Full(延后)| --- ## 构建顺序(依赖图) ``` T1(锁 DLL + granny_probe) ─┬─▶ T3(层① dump) ─┬─▶ T5(蒙皮顶点 dump) ─▶ T8(噪声地板) │ │ │ │ └─▶ T7(自检, 先用 1e-4) ◀──┘ T8 后收紧 └─▶ T6(确定态截图 · 独立最小 D3D9) ← M1 视觉验证依赖 [延后 / Oracle-Full] T1 ─▶ T2(抽 EterGrnLib)[L] ─▶ T4(层② dump) ``` **推荐推进**:T1 → T3 → T7(早期最强信号)→ T5 → T6(M1 要)→ T8 → 收紧 T7。T2/T4 只在决定做正式移植时启动。 尺寸:**S** ≈ 0.5–1 天 · **M** ≈ 2–4 天 · **L** ≈ 1 周+ --- ## 任务分解 > 每个 T 的 AC 是可勾选项。`warrior_cheongrin.gr2` 的期望 count 与 [`M0-gr2-reader.md`](./M0-gr2-reader.md) T4 对齐(第一天用 Blender 导入核对后两边一起填实)。 ### T1 · 锁定 Granny 版本 + `granny_probe` **[S]** > **已知**(M0 T2 从解压后的 gr2 里读到):资产由 **Granny Standard Exporter SDK 2.4.0.7** 导出。候选 `granny2.dll` **优先试 2.4.x 线**(2.4.0.7 或最接近的 runtime)。9166 个里 11 个是文件格式 v7、其余 v6。 - 先写 `granny_probe.exe`(~30 行):`GrannyReadEntireFileFromMemory` → `GrannyGetFileInfo` → 打印 `Skeletons/Meshes/Materials/Textures/Animations` 的 count、`FromFileName`、每 skeleton 的骨骼数。 - 收集候选 `granny2.dll`(**2.4.x 优先**,再 2.6.x / 2.9.x / 2.11.x 兜底),各链一遍 `granny_probe` 跑 `warrior_cheongrin.gr2` + 3 个 zone 静态件 + 1 个 `action/*.gr2`。 - 选返回全部合理的那个,`git`-track 进 `oracle/vendor/granny2.dll`,判定写 `oracle/GRANNY-VERSION.md`(列出每个候选的输出)。 - **AC**(选定 DLL 下): - [ ] `warrior_cheongrin.gr2`:`Skeletons==1`、`Meshes>=1`、`Animations==0`、`FromFileName` 是可读路径串、骨骼数 ∈ [20, 120](人形合理范围)—— 与 [M0 T4](./M0-gr2-reader.md) 期望值一致。 - [ ] `action/dance_1.gr2`:`Animations==1`、`Duration` ∈ (0, 60] 秒。 - [ ] zone 静态件:`Skeletons==0` 或 1(rigid)、`Meshes>=1`。 - [ ] 3+ 文件全过,其余候选 DLL 的失败表现记进 `GRANNY-VERSION.md`。 ### T3 · dump 层①(裸 Granny) **[M]** - 链 `granny2.dll` C API。输入是**两个 gr2**:`model.gr2`(骨架 + ModelInstance)+ `anim.gr2`(`Animation`)。 - 链路:`GrannyReadEntireFileFromMemory(model)` → `GrannyInstantiateModel` → 读 `anim.gr2` 的 `Animations[0]` → `GrannyPlayControlledAnimation(startTime=0, anim, modelInstance)` → `GrannySetModelClock(modelInstance, t)` → `GrannySampleModelAnimations(...)` → `GrannyGetWorldPoseComposite4x4Array(worldPose, boneCount, 0, out4x4)`。 - 输出:`boneCount` + 每骨 `float[16]`(行主序,Granny 原生)。**坐标约定写死**进 `FORMAT.md`:手系、单位、根变换是否已 apply;libgr2 侧声明同一约定。 - **AC**: - [ ] 固定 `(model, anim, t)` 连续两次 dump **byte-identical**(确定性)。 - [ ] **正确性锚**:t=0 + identity 动画(或不 play 任何动画)时,层① 的每骨世界矩阵 == 该骨 `InverseWorld4x4` 求逆(`max|Δ| < 1e-4`,即 T7 折进来先跑一遍)。 - [ ] 骨骼数、骨骼名顺序与 `granny_probe` / Blender 一致。 ### T5 · dump 蒙皮顶点 **[S–M]** - `GrannyNewMeshBinding(mesh, srcSkel, animSkel)` + `GrannyNewMeshDeformer(...)` + `GrannyDeformVertices(deformer, boneMatrixCount, worldPose4x4, vertexCount, srcVerts, dstVerts)`。 - 另用 `GrannyCopyMeshVertices(mesh, PNT332Type, rawBuf)` 读原始顶点作对照。 - 输出 mesh 0 的 `vertexCount` + 每顶点 `float[3]`(要的话加法线)。 - **AC**: - [ ] bind pose(t=0 / identity)下,`GrannyDeformVertices` 的输出 == `GrannyCopyMeshVertices` 的原始顶点,`max‖Δ‖ < 1e-3`(T8 后收紧到 noise_floor)。 - [ ] 顶点数与 [M0 T6](./M0-gr2-reader.md) / Blender 一致。 - [ ] 走路动画某帧的顶点与层① 世界矩阵手算 LBS 的结果一致(自洽)。 ### T6 · 确定态截图 harness **[M]** —— M1 视觉验证依赖 - **独立最小 D3D9 渲染器**(~200–300 行,**不依赖 EterGrnLib**):`CreateDevice` → `CreateRenderTarget` 离屏 → 用 T3 的世界矩阵 + T5 的蒙皮顶点画三角 → `GetRenderTargetData` → 存 PNG(PNG-0 / 无压缩)。 - 强制确定态:注入固定 view/proj、单方向光固定、`GrannySetModelClock(t)` 精确、单动作无混合、无 idle sway(本来就不走客户端 `ActorInstance`,所以天然没有)。 - **AC**: - [ ] 同参数两次截图 byte-identical(或 SSIM = 1.0)。 - [ ] **视觉 sanity**:warrior 是人形、直立、面朝已知方向(+Z)、不炸开 —— 和 Blender 同相机渲染目视一致。 - [ ] 换 `t` 能看到姿势变化(动画链路真的接上了)。 ### T7 · oracle 自检(信任前必须过) **[S]** - 加载 gr2,dump t=0 层① 矩阵,对每骨验 `world_bind[i] · InverseWorld4x4[i] ≈ I`(`InverseWorld4x4` 来自文件)。 - **AC**: - [ ] 首轮:所有骨 `max|M − I| < 1e-4`(sanity 天花板)。 - [ ] T8 之后:收紧到 `max|M − I| < noise_floor.mat`。 - [ ] 过不了 = oracle 的坐标 / 读取有 bug,**下游全部作废**,回 T3。 ### T8 · 噪声地板 **[S]** - 同 `(model, anim, t)`:用选定 DLL 跑一遍 + 用一个相邻版本 `granny2.dll` 跑一遍(T1 收集的候选之一);再同版本重复 100 次。 - 记 `mat`(矩阵元素 `max|Δ|`)和 `vtx`(顶点 `max‖Δ‖`)到 `test/noise_floor.json`。 - **AC**: - [ ] `noise_floor.json` 产出,含 `mat` / `vtx` 两个值。 - [ ] **sanity**:`mat < 1e-3`、`vtx < 1e-3`(模型单位)。若明显更大 → oracle 还有非确定源没关(回 T3/T6),不是"地板高"。 - [ ] M2 的 `ε_mat` / `ε_vtx` = 对应值 × 安全余量(×10)。 --- ## 延后任务(Oracle-Full · 层②) > 只在决定做正式移植时启动。demo / PoC 不需要。 ### T2 · 抽 `EterGrnLib` 单独编 **[L]** - 从 `m2dev-client-src-main/src/` 拷 `EterGrnLib` + 依赖最小集(`EterBase` 大部分、`EterLib` 数学 / `GrannyLib` 封装、`SphereLib` 视情况)。 - Windows/MSVC CMake 编成 `oracle_etergrn`。`#ifdef` 掉 D3D 渲染依赖(`ModelInstanceRender.cpp` 等),目标只是"加载 gr2、建 `CGrannyModelInstance`、跑动作混合、拿骨骼矩阵"。 - **AC**:[ ] `oracle_etergrn` 链接通过;[ ] `new CGrannyModelInstance` + `SetModel(warrior_cheongrin.gr2)` 不崩。 - **风险**:`EterGrnLib` 依赖 `EterLib` 一大坨(`GrpDevice` 等)。拆不干净 → **整体放弃层②**,M0/M2 只对层① 本来就够。 ### T4 · dump 层②(过 EterGrnLib) **[M]** —— 依赖 T2 - `CGrannyModelInstance::Update(t)` → `GetBoneMatrixPointer()`,拿过完 LOD 骨骼裁剪 + `ActorInstanceBlend` 混合的最终矩阵。 - **AC**:[ ] 单动作无混合时,层② == 层①(noise_floor 内)——这是 sanity;[ ] 混合场景(两动作 blend)与客户端一致(正式移植时再细化)。 --- ## 门禁(Oracle-Lite = go / no-go) - **T1**:选定 DLL 下 3+ gr2 的 `GetFileInfo` 全过 AC,`GRANNY-VERSION.md` 留档。 - **T7 自检**:全骨 `max|M − I|` < 首轮 `1e-4`、T8 后 < `noise_floor.mat`。**这是硬门禁,不过则下游作废。** - **T3 / T5**:对测试资产集(M0 T9c 产出)产出稳定可复现,含正确性锚(t=0 == bind pose / 原始顶点)。 - **T6**:确定态截图两次一致 + 视觉 sanity(人形直立)+ 换 t 有姿势变化。 - **T8**:`noise_floor.json` 产出,`mat` / `vtx` 均 < `1e-3`(否则回 T3/T6 关非确定源)。 - Oracle-Full(T2/T4)**不是门禁**。 ## 验证 - **三方 tie-break**:层① dump vs Blender `io_scene_gr2` 在同 `t` 计算的世界矩阵(Blender 侧写个小脚本)。三方不一致时,**先怀疑 oracle**(DLL 版本 / 坐标约定 / T6 非确定源),再怀疑 libgr2。 - Blender 插件选型见 [M0 开工前 TODO](./M0-gr2-reader.md#开工前要定的-todo跑起来才能定非文档缺陷)。 ## 本步风险(从 PLAN §08 筛) | 风险 | 缓解 | |---|---| | granny2.dll 版本与资产不匹配 | T1 硬性锁定 + 每候选输出留档 | | `EterGrnLib` 拆不干净(依赖 `EterLib` 渲染层) | T2/T4 是 **Oracle-Full**,本就延后。拆不动 → 整体放弃层②,M0/M2 只对层① 够用 | | T6 需要一个 D3D9 渲染器 | 独立写 ~200–300 行离屏 D3D9,**不碰 EterGrnLib**(否则 T6 被 T2 阻塞,而 T6 是 M1 关键路径) | | 确定态漏了程序化位移 | oracle 不走客户端 `ActorInstance`,天然无 idle sway;T6/T8 两次不一致就说明还有源,逐个查 | | T7 阈值 / noise_floor 循环 | T7 首轮用 `1e-4` sanity 天花板;T8 测出真实地板后收紧 T7 | ## DoD 清单(Oracle-Lite) - [ ] `granny_probe.exe` + `oracle/vendor/granny2.dll` 锁定 + `GRANNY-VERSION.md`(含候选对比) - [ ] `oracle dump ` 可用;`oracle/FORMAT.md` 写清二进制布局 + 坐标约定 - [ ] T3 正确性锚:t=0 层① == bind pose(`< 1e-4`) - [ ] T5 正确性锚:bind pose 蒙皮 == 原始顶点 - [ ] T6 确定态截图两次一致 + 视觉 sanity + 换 t 有变化 - [ ] T7 自检脚本在 Windows CI runner 绿(首轮 `1e-4`,T8 后收紧) - [ ] `test/noise_floor.json` 产出(`mat`/`vtx` < `1e-3`),M2 引用它设 ε - [ ] 层① 与 Blender 三方对拍在测试资产集上一致 - [ ] (Oracle-Full,可空)T2/T4 状态记录:已做 / 延后 / 放弃