Files
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

25 KiB
Raw Permalink Blame History

M0 · gr2 解析器(libgr2 + gr2dump + fuzz

总纲:../PLAN.md §04 / §05 / §06。演示切片见 ../DEMO-PLAN.md D1。 本文件是 M0 的详细施工文档。


目标

自研 libgr2(只读子集),证明能脱离 Granny 从 Metin2 真实 .gr2 读出骨架 / 蒙皮网格 / 骨骼权重 / 动画轨道, 且结构与 oracle 逐字段一致。产出后续所有里程碑用的测试资产集。

前置

  • 无代码前置。
  • 构建可全程无 oracle 推进T1T6、T7a、T8、T9a/b 的代码实现都不需要 oracle。
  • 需要 oracle 的只有验收对拍:门禁·主、T4/T6 的 count 对拍、T7b 的曲线结果对拍。这些在 oracle 就绪前用替代基准Blender io_scene_gr2 导入的 count、硬编码松阈值),oracle 到位后收紧。
  • 需要 T8 的噪声地板test/noise_floor.json,来自 oracle T8):T5 自洽检查的收紧阈值。未就绪前 T5 用硬编码 1e-3
  • oracle 那条线(00-oracle)并行,由会 Windows/D3D 的人推。

参考文件(字节级规格来源)

libgr2 的所有 struct 布局、枚举值、组合公式,从泄露 SDK 抄(只读、不复制代码,作规格): MobileSource/Cross Platform/Granny-3D-SDK-main/source/

要的东西 文件 关键内容
文件头 + magic 变体常量 granny_file_format.h / .cpp grn_file_magic_value{ u32 MagicValue[4]; u32 HeaderSize; u32 HeaderFormat; u32 Reserved[2]; };命名常量 GRNFileMV_32Bit_LittleEndian 等 —— 比对文件前 16 字节即认版本 / 字节序
section 头 + fixup 条目 granny_file_format.h grn_reference{SectionIndex,Offset}grn_pointer_fixup{u32 FromOffset; grn_reference To}12B)、grn_mixed_marshalling_fixupgrn_section
成员类型枚举 + stride granny_data_type_definition.h / .cpp GrannyReal32Member / Int32Member / ReferenceMember / InlineMember / StringMember / TransformMember / EndMember …数值从这里抄;stride 表自己按类型算
SRT → 4×4 组合 granny_transform.h / .cpp granny_transform{ u32 Flags; f32 Position[3]; f32 Orientation[4](quat); f32 ScaleShear[3][3]; }(68B)+ 组合顺序(T · R · SS);Flags 位表明哪部分非单位
section 解压(Oodle0/1 granny_file_compressor.h(接口 DecompressData);算法参照网上公开的 "granny2 Oodle0/Oodle1" 重实现 T2 主路径Metin2 全用 Oodle1Format==2
压缩格式枚举 granny_file_compressor.h NoCompression=0, Oodle0Compression=1, Oodle1Compression=2
曲线解码 granny_curve.cpp / granny_curve_fast.cpp / granny_compress_curve.cpp T7b 的算法参照;先看 granny_curve.cpp 的未压缩关键帧路径。曲线类型标签(T7a)看 granny_curve.hCurveDataHeader
v6 兼容分支 granny_back_compat.cpp SDK 是 2.9.12,读 v6 文件走 back-compat;扫一遍有无 v6 特有偏移

SDK 头注明 granny_29 / 2011 / v2.9.12。容器 structheader / section / fixup / typetree / transform)在 2.62.11 之间稳定,v6/v7 差异主要在 FileInfo 里装什么对象,不在容器。

约定

  • M0 全程用文件原始值,不加任何 basis fix / 单位缩放。T5 自洽检查、与 oracle 对拍、gr2dump 输出,全在 raw 空间。坐标系转换(Granny art-tool basis → 左手 Y-up)是 M1 渲染时的事。
  • oracle 侧 dump 也声明同一 raw 约定(见 00-oracle T3)。

交付物

产物 位置
libgr2 静态库(demo 子集) libgr2/
gr2dump CLI(结构化 dump + --sections + 可选 glTF tools/gr2dump/
gr2fuzz(全量 ~9166 解析 + 谓词 + 直方图) tools/gr2fuzz/
格式变体直方图报告 test/fuzz-report.json
测试资产集 test/assets.list

构建顺序(依赖图)

线性读 T1→T9 会在 T7↔T9 卡住。实际依赖:

T1(header+section ✓) ──▶ T2(Oodle1 解压[M]) ──▶ T3 ──▶ T4 ──▶ ┬──▶ T5
                                                              └──▶ T6
T3,T4 ──▶ T7a(分类曲线子类型)
T1..T6 + T7a ──▶ T9a(收集崩溃) ──▶ T9b(加固) ──▶ T9c(报告 + assets.list)
T9c ──▶ T7b(解码选定子集)
T4,T5,T6(+可选 T7b) ──▶ T8(gr2dump)
[并行] 00-oracle 全程;门禁·主 / T4·T6 count / T7b 结果 的验收依赖它

推荐推进T1→T3→T4 打通"能读到 FileInfo" → T5 自洽检查(最强早期信号,无 oracle)→ T6 → T7a → T9a/b(这里吃掉大部分工作量)→ T9c → T7b → T8 收尾。

尺寸标记:S ≈ 0.51 天 · M ≈ 24 天 · L ≈ 1 周+


任务分解

每个 T 下的"AC"是可勾选的验收标准(不是一句话)。带 (oracle) 的项在 oracle 就绪前用替代基准。

T1 · gr2_file —— header + section table + fixup [M]

状态:header + section 表解析已实现libgr2/src/gr2_file.cpp),gr2dump --sections 跑通全部 9166 个文件、0 崩溃。 剩下的是 **section 解压(Oodle1,见 T2**和 fixup 重定位(需展开后的数据)。

实测结论(写进代码注释,替换旧假设):

实测
magic GRNFileMV_Old{0xCAB067B8, 0x0FB16DF8, 0x7E8C7284, 0x1E00195E})—— 不是 GRNFileMV_32Bit_LittleEndian
格式版本 6;每文件固定 8 个标准 section
section 压缩 全部 Format==2Oodle1Texture section 例外(Format==0 且空)。HeaderFormat(=0)不是 section 压缩字段。
total_size 字段 == 文件实际大小(9166/9166
BitKnit 全样本 0 个
section 头 SectionArrayOffset 相对 grn_file_header 起点(= magic 结构之后 32B);每 grn_section = 11×u32 = 44B
  • grn_sectiongr2_decompress 展开到 ExpandedDataSizeT2Oodle1)→ 遍历 grn_pointer_fixup[]FromOffset 处的值改写成 To(section+offset) 的进程内指针 → 小端机跳过 grn_mixed_marshalling_fixup
  • ACwarrior_cheongrin.gr2 = 92 770 Bwarrior_cheongrin_lod_01.gr2 = 68 012 B):
    • magic 识别(GRNFileMV_Old → 32-bit LE);version == 6
    • total_size 字段 == 文件实际大小。
    • section 数 == 8,各 DataSize / ExpandedDataSize ≤ 文件大小,gr2dump --sections 输出正确。
    • 每个非空 section 成功 Oodle1 展开到 ExpandedDataSizeT2 完成)。
    • 所有 grn_pointer_fixupFrom / To 落在合法范围,0 越界(9166 全量)。
    • root object 的 grn_reference 落在合法范围。
    • "fixup 后能从 root 走到 skeleton" 不在 T1 验收 —— 需要 T3/T4,见"T1+T3+T4 集成检查点"。

T2 · gr2_decompress [M] —— 已实现并验证

状态:done。 libgr2/src/oodle1.c —— 从泄露 SDK 的 granny_oodle1_compression.cpp + radlz.c + radarith.c + arithbit.c 端口了解码路径(自适应算术编码 + LZ)。约 450 行 C。

  • format==0 → memcpyformat==2Oodle1)→ gr2_oodle1_decompress()format==1Oodle0/ format==4(BitKnit,全样本 0 个)→ 留桩报错。
  • 3-block 结构:stop0/1/2 = section 的 First16Bit / First8Bit / ExpandedDataSize32 位 / 16 位 / 8 位 marshalling 区各一块,共享算术流)。
  • 实测结果
    • 全部 9166 个文件、每个非空 section 展开到恰好 ExpandedDataSize(否则 load() 报错 → fuzz 计 crashed;实际 crashed=0)。
    • 内容锚(不依赖 oracle:展开后 Main section 里的内嵌 ASCII 串干净可读 —— Granny Standard Exporter, SDK version 2.4.0.7D:\Ymir Work\pc\warrior\warrior_cheongrin.DDS、类型成员名 ExporterInfo / ExporterName 等。解码错了这些会是乱码。
    • (可选加强)与 granny2.dll 的 GrannyDecompressData 逐字节对拍一次(oracle 就绪后)。

顺带实测发现(更新其它文档):

发现 影响
资产由 Granny Standard Exporter SDK 2.4.0.7 导出 00-oracle T1 的候选 granny2.dll 应锁定 2.4.x 线,不是 2.9 / 2.11
9166 个里 11 个是格式 v7,其余 v6 容器兼容,libgr2 两者都能读;version != 6 只 warn 不 fail

T3 · gr2_typetree —— 自描述类型树遍历器 [M]

  • granny_data_type_definition 数组:{u32 MemberType, char* Name, def* ReferenceType, i32 ArrayWidth, i32 Extra[3], void* Ignored}32-bit 指针 4B),MemberType==0(End) 结尾。枚举值 + stride 表从 granny_data_type_definition.h/.cpp 抄。
  • walk(void* obj, const TypeDef* type, Visitor&):按成员类型算 stride、递归 Reference / ReferenceToArray不硬编码 struct 布局,上层按成员名取字段。
  • 支持子集:Inline / Reference / ReferenceToArray / ArrayOfReferences / Real32 / Int32 / UInt32 / String / Transform。子集外 → 记日志、跳过、不崩。
  • ACwarrior_cheongrin.gr2 root object):
    • 遍历不崩,无越界读(9166 全量)。
    • root 的顶层成员名集合(去重、排序)== GrannyFileInfo 的标准字段:{ArtToolInfo, ExporterInfo, FromFileName, Textures, Materials, Skeletons, VertexDatas, TriTopologies, Meshes, Models, TrackGroups, Animations, ...}(以 granny_file_info.h 的实际 struct 为准)。
    • gr2dump --members 打印 (name, MemberType, ArrayWidth),与 granny_file_info.h 一致。
    • 未知成员类型 0 个(全量)。

T1+T3+T4 集成检查点

  • 从 root 跟指针无崩走到 Skeletons[0].Bones[0].Namewarrior_cheongrin 读出 "Bip01"。

T4 · gr2_fileinfo [S] (期望值待复核)

  • 定位 FileInfo root,按名暴露 span 视图:Skeletons[] / VertexDatas[] / TriTopologies[] / Meshes[] / Materials[] / Textures[] / Models[] / TrackGroups[] / Animations[]。对外只给 POD 视图,不泄露内部指针。
  • ACwarrior_cheongrin.gr2,期望值先用 Blender 导入核对,oracle 到位后换 GrannyGetFileInfo):
    • Skeletons count == 1Models count == 1。
    • Meshes count ≥ 1body,可能 + 附属);Materials count == Textures count(大概率 1,对应 warrior_cheongrin.dds)。
    • Animations count == 0(角色本体不带动画,动画在 action/*.gr2)。
    • 各 count (oracle)GrannyGetFileInfo 一致。
    • 确认期望值M0 第一天先用 Blender 导入 warrior_cheongrin.gr2 记下真实 count,填进本节替换"大概率"。

T5 · gr2_skeleton + bind pose 自洽检查 [M]

  • 骨骼数组:Name / ParentIndex / LocalTransformgranny_transform SRT/ InverseWorld4x4。SRT→4×4 组合顺序照 granny_transform.cpp
  • 自洽检查(纯本地,不依赖 oracle):沿父索引累积 LocalTransform 重建 world_bind[i],验 world_bind[i] · InverseWorld4x4[i] ≈ I
    • 阈值:oracle 的 noise_floor.json 就绪前用 max|Δ| < 1e-3;就绪后收紧到 noise_floor × 余量
  • 抓"读矩阵带转置 / 手系错、且同样作用于正向读取"这类 oracle 对拍也发现不了的 bug。
  • AC
    • warrior_cheongrin 全骨 world_bind · invBindmax|Δ| < 当前阈值,PASS。
    • 骨骼数、ParentIndex 数组、骨骼名列表 (oracle 或 Blender) 一致。
    • LocalTransform.Flags 分布打印(有多少骨带非单位 orientation / scaleshear)。

T6 · gr2_mesh [M]

  • 识别顶点类型:PNT332 / PNT3322 / 带 BoneWeights(4×u8)+BoneIndices(4×u8) 的蒙皮变体(映射表见 DEMO-PLAN §7.3)。
  • 实现 CopyMeshVertices / CopyMeshIndices 的效果:逐顶点拷到统一打包结构。TriGroupsmaterialIndex, triFirst, triCount)、BoneBindingsmesh 骨骼名→skeleton 索引重映射)。
  • 代码可无 oracle 构建;验收对拍见 AC。
  • ACwarrior_cheongrin.gr2 mesh 0):
    • 顶点类型正确识别(是蒙皮变体,含 weights+indices)。
    • 顶点数 / 索引数 / 三角组数 (oracle 或 Blender) 一致。
    • BoneBindings 里每个骨骼名都能在 skeleton 里查到(0 个悬空)。
    • 每三角组的 triFirst + triCount*3 ≤ 索引总数

T7a · 曲线子类型分类 [S] —— T9 依赖

  • 只做识别 + 分类,不解码:遍历 TrackGroups → TransformTracks,读出每个 PositionCurve / OrientationCurve / ScaleShearCurve 的曲线类型标签(CurveDataHeader.Format)。
  • AC[ ] gr2fuzz 能对任意 action/*.gr2 输出"用了哪些曲线子类型 + 每种出现次数";[ ] 未知类型有标签、不崩。

T7b · 曲线解码(选定子集) [M] —— T9c 之后 (精度待 oracle 对拍)

  • 依据 fuzz-report.json 的曲线子类型直方图,实现占比最高的 12 种(大概率 granny_curve.cpp 的未压缩关键帧 DaKeyframes* + 线性插值 / slerp)。
  • 其余子类型 → 记入报告,走 M2 的 oracle 烘焙退路。
  • AC
    • warrior/action/ 里至少 3 个只用已实现子类型的动画,能读出每骨曲线并在若干采样点求值。
    • track 数 / 动画时长 (oracle) 一致。
    • 某采样点的骨骼局部变换 (oracle 层① @同 t) max|Δ| < ε(ε 同 T5 阈值策略)。

T8 · gr2dump CLI [S] --gltf 未实现,非门禁)

  • gr2dump <file.gr2> [--sections] [--gltf out.glb]
  • stdout:骨架树(缩进)、每 mesh 顶点/索引/三角组/顶点布局、每 animation 时长+track数+曲线子类型直方图、bind pose 自洽 PASS/FAIL + max 偏差。--sections 打 section 表。
  • --gltf辅助、非门禁、可延后):写骨架 + 第一个 mesh 的 bind pose+ 若 T7b 实现了,第一个 animation)。tinygltf 或手写 JSON+bin —— 不用 cgltf(写支持弱)。仅供 Blender 目视。
  • AC[ ] gr2dump warrior_cheongrin.gr2 输出与上述 T4/T5/T6 的期望值一致;[ ] --gltf 产物能被选定的 Blender 插件导入(若已实现)。

T9a · fuzz 收集崩溃 [M]

  • 遍历全部 .gr2find "$XRENDER_ASSET_ROOT" -name '*.gr2';路径含空格 ymir work;预期 ~9166 个)。
  • 每个文件在子进程 / try 里跑 libgr2 全流程,捕获崩溃 / 异常 / 断言,记 (路径, 阶段, 错误)
  • AC:[ ] 跑完全量,产出崩溃清单(首轮预期有几十~几百条);[ ] 崩溃按"阶段(header/section/typetree/skeleton/mesh/curve)× 错误类型"聚类。

T9b · 逐类加固 [L] —— M0 的主要工作量 9166 零崩溃 / 零谓词失败)

  • 按 T9a 的聚类逐个修 libgr2:多出来的顶点类型、类型树里的未知成员、section 变体、边界数据。
  • 每修一类,重跑 T9a 确认该类清零、无回归。
  • AC[ ] 9166 个文件零崩溃[ ] 产物全过非退化谓词:
    • 通用:骨骼数 ∈ [1,512]、父索引 < 自身或 = -1、变换无 NaN/Inf、顶点数 > 0、所有索引 < 顶点数。
    • 仅蒙皮顶点格式:骨骼索引在范围内;权重和 ∈ [0.99,1.01](3 显式 + 1 隐式的先补齐第 4 个)。rigid mesh 跳过这两条。

T9c · 报告 + 测试资产集 [S]

  • 输出 test/fuzz-report.json:文件格式版本 / section 压缩类型 / 顶点类型 / 曲线子类型(来自 T7a / 非单位 scaleshear 用量 的直方图。
  • 人工挑 + 报告元数据 → test/assets.list(约 15 个,清单见 PLAN §07 测试资产集)。
  • AC[ ] fuzz-report.json 五类直方图齐全;[ ] assets.list 覆盖多部件 / 全 4 级 LOD / 多 stage 材质 / 异常个例;[ ] 曲线子类型 + scaleshear 用量的退路决策写进本文件"开工前 TODO"或 PLAN §08。

门禁(go / no-go

  • libgr2 dump vs 真 Granny —— 达成oracle probeGrannyGetFileInfogr2dump 逐字段一致;tools/oracle_diff 对拍 Granny 2.9.12GrannyGetWorldPose4x4Array + GrannyDeformVertices23 用例(13 模型 bind + 8 动画帧,含 v7 / 双 root / 刚体)骨骼矩阵 ≤ 4.6e-5、蒙皮顶点 ≤ 6.5e-5test/m2-numeric.json)。oracle 在 macOS + Wine + MinGW 跑(oracle/RUNBOOK.md)。
  • 自洽T5 bind pose 自洽 —— warrior_cheongrin 6.1e-5 PASS。全量 2013/2017 < 1e-34 个 >=1e-1 经 oracle 证实 libgr2 正确assassin 对 Granny 3.1e-5),是自洽不变量本身对这几个双-root 资产不成立,非 libgr2 bug。
  • fuzzT9b 全量 9166 —— 零崩溃 + 零谓词失败
  • 变体可实现 无 BitKnit / 无 Oodle0;曲线全 OldCurveTypedegree ≤ 2,T7b 全覆盖。不做的变体见 libgr2/README.md「明确不做」(均 0 出现,坏输入干净报错不崩)。
  • 产出 test/{fuzz-report,m2-numeric,noise_floor}.json + test/assets.list + test/oracle_dumps/
  • noise_floor test/noise_floor.json。同-DLL 确定性 = 0byte-identical);跨实现地板 = mat 4.6e-5 / vtx 6.5e-5;跨版本一档待另一个 granny2.dll。

M0 结论:全部门禁达成(含 vs 真 Granny 逐字段对拍)。

验证

  • bootstrap 关系M0 自身验证用硬编码引导集(warrior_* + 若干 zone);assets.list 产出后,M1+ 一律用它。
  • 辅助--gltf 拖进 Blender 目视骨架拓扑 + bind pose 网格。glTF 证不了动画曲线解码(表达不了 ease / scale-shear / 常量轨道压缩)。

开工前要定的 TODO(跑起来才能定,非文档缺陷)

  • [T4/T5/T6 期望值] Blender 导入核对 —— M0 第一天用选定的 Blender 插件导入 warrior_cheongrin.gr2,记下真实的 skeleton / mesh / material / vertex / bone count,替换各 T 里 "大概率" 的占位期望值,写进 test/README.md
  • [T7b] 曲线子类型清单 —— 具体做哪 1–2 种,要 T7a + T9c 的 fuzz-report.json 才知道。T7b 开工前 gr2_anim.cpp 先只做 granny_curve.cpp 的未压缩关键帧 + 线性插值。
  • [验证] Blender io_scene_gr2 插件 —— 有多个同名插件(SWTOR 版、Metin2 fork),轴向约定各异。M0 第一周:定一个、确认能导入 warrior_cheongrin.gr2、记录轴向约定到 test/README.md。定不下来 → 三方 tie-break 暂时只靠 oracle,或找第二个开源 gr2 库。

本步风险(从 PLAN §08 筛)

风险 状态 / 缓解
section 全是 Oodle1(实测 9166),T2 从 [S] 变 [M] 已解oodle1.c 端口自泄露 SDK9166/9166 展开 == ExpandedDataSize
T9b 加固吃掉大部分工期 已解:9166 零崩溃 + 零谓词失败。实际 T9b 几乎没触发额外加固(类型树通用遍历 + 懒解引用 + 全程边界检查一次到位)
section 用 Oodle0 不适用:全样本 0 个
section 用 BitKnit 不适用:全样本 0 个
自描述类型树递归 / 未知类型 已解:T3 通用遍历器不假设布局;全量 0 个未知成员类型
曲线子类型多样(B 样条拟合等) 已解:全部 OldCurveTypedegree ∈ {0,1,2}、dim ∈ {0,3,4,9}。T7b 全覆盖(degree 3 全样本 0 个)
非单位 scaleshear 普遍存在 确认普遍2267 skeleton / 13298 骨):组合公式已用完整 R3·SS3;通知 M2/M3 蒙皮走完整仿射
InverseWorld4x4 读错但一致 已兜住T5 自洽检查全量跑,2013/2017 < 1e-34 个异常定位到辅助骨子树(见上)

DoD 清单

  • T1 header + section 表:gr2dump --sections 跑通全部 9166 个文件、0 崩溃、total_size 全对
  • T2 Oodle1 解压:9166/9166 全 section 展开 == ExpandedDataSize;内嵌路径串解压后干净可读(内容锚)
  • T2 加强:与 GrannyDecompressData 逐字节对拍一次(oracle 就绪后)
  • T1 fixuppointer fixup 索引建立((section<<32|from)->Ref),懒解引用,0 越界(9166 全量)
  • T3 类型树遍历器:root object 顶层成员名集合 == granny_file_info 标准字段(13 个),gr2dump --members 打印一致
  • T1+T3+T4 集成检查点:从 root 跟指针无崩走到 Skeletons[0].Bones[0].Name 读出字符串(warrior_cheongrin → "Bip01"
  • T4 FileInfowarrior_cheongrin.gr2 → skeletons=1 / models=1 / animations=0 / materials=4 / textures=2 / meshes=5期望值待 Blender/oracle 复核
  • T5 骨架 + bind pose 自洽:warrior_cheongrin max|Δ|=6.1e-5 PASS(阈值 1e-3);全量 2017 个多骨 skeleton 中 2013 个 < 1e-34 个异常见下)
  • T6 网格:warrior_cheongrin 5 mesh 全 PNT332_Skinned,顶点/索引/三角组自洽(triFirst+triCount*3==indices),bone-binding 0 悬空
  • T9a/b9166 个 .gr2 零崩溃 + 零谓词失败(骨骼数/父索引/NaN/索引越界/权重和/bone-index 范围)
  • T9ctest/fuzz-report.json6 类直方图)+ test/assets.list18 个)产出
  • T7a 曲线子类型统计完成(见下);T7b OldCurve 解码(d0 常量 / d1 线性 / d2 二次 B 样条 + 四元数归一)+ Animation::sample_localdance_1 等 3+ 采样点无非有限数
  • 门禁·主:结构 dump vs oracle 逐字段一致(oracle 就绪后)
  • T4/T5/T6 期望值 Blender/oracle 复核(替换"待复核"占位)
  • T7b 精度:某采样点骨骼局部变换 vs oracle 层① max|Δ| < εoracle 就绪后)

M0 实现结果(fuzz 全量 9166test/fuzz-report.json

维度 结果
解析成功 9166 / 91660 崩溃,0 谓词失败
格式版本 v6 × 9155magic GRNFileMV_Old)、v7 × 11magic GRNFileMV_32Bit_LittleEndian,非 Old;含 shaman_lord / snow_dungeon zone / warrior_rabbit1
section 压缩 非空 section 全 Oodle1(64162 段);每文件 1 个空 sectioncompression=none)。无 Oodle0,无 BitKnit
顶点类型 PNT332 × 4016rigid)、PNT3322 × 309rigid 双 UV)、PNT332_Skinned × 3118。PNT3322_Skinned,无未知类型
曲线格式 全部是 OldCurveTypeGranny 2.4{Int32 Degree; RefToArray Knots; RefToArray Controls},无压缩变体、无 curve2/CurveData variant)。子类型 = (degree, dim)
pos d0·dim3(322k) / d0·dim0(2.5k,恒等) / d2·dim3(41k)
rot d0·dim0(77k) / d0·dim4(79k,常量四元数) / d2·dim4(210k)
scale d0·dim0(321k) / d0·dim9(42k,常量 3×3) / d1·dim9(3k,线性 3×3)无 degree 3
ScaleShear 2267 个 skeleton 至少 1 骨带非单位 scaleshear,全语料 13298 骨。普遍存在 → 组合公式必须走完整 R3·SS3(已实现,warrior self-check 15 个 ss 骨仍 6.1e-5
bind pose 自洽 多骨 skeleton<1e-4 × 1835、<1e-3 × 178、>=1e-1 × 4

bind pose 自洽 4 个异常>=1e-1):assassin.gr2PC + season1,同)、warrior_rabbit1_backup.gr2hair_11_1.gr2。 这 4 个都是多 root skeleton(如 assassinassasin_low + Bip01 两个 root),且 model 的 InitialPlacement 非单位。

后续更正(oracle 对拍后):这不是 libgr2 的 bug。assassin bind pose 的 骨骼世界矩阵 + 蒙皮顶点与真 Granny 2.9.12 逐字段一致(3.1e-5 / 4.6e-5(见 00-oracle / oracle/RUNBOOK.md)。 world[i]·InverseWorld4x4[i] ≈ I 这个自洽不变量本身对这几个资产不成立 —— 它们的 InverseWorld4x4 是相对「不含 InitialPlacement 的参考」烘的,而 world = local链 · InitialPlacement。是资产属性,不是读取错误。 自洽检查因此是个比 oracle 对拍弱的信号:能抓转置 / 手系 bug,但对「InverseWorld 参考系不一致」会误报。warrior_cheongrin 等单 root 模型不受影响(IP 为单位,两者等价)。

关键格式发现(补 PLAN §05 / DEMO-PLAN §7

  1. ReferenceToVariantArray 磁盘布局是 {def* Type; int32 Count; void* Ptr}Type 在前),不是 {Count; Type; Ptr}VertexData.Vertices 用它。踩过一次。
  2. transform_trackGranny 2.4= {String Name; Inline PositionCurve; Inline OrientationCurve; Inline ScaleShearCurve} —— 无 Flags 成员,且 Position 在 Orientation 之前,与 2.9 SDK 的 TransformTrackType 不同。类型树遍历器按文件自带 typedef 解,不硬编码,所以不受影响;但硬编码偏移会错。
  3. root 偏移的两种放法:多数 Metin2 skeleton 把 model-space 偏移放在 model.InitialPlacementroot 骨 LocalTransform 是单位;少数(如 warrior_cheongrin)把偏移烘进 root 骨 local。self-check 必须 world[root] = Composite(local[root]) · InitialPlacement
  4. 一个文件可有多个 skeleton(本体 + 武器/挂点,各带自己的 1-骨 skeleton 和 model)。mesh 的 BoneBindings 要对每个 skeleton 试解析、取悬空最少的那个。
  5. 组合矩阵语义(granny_transform.cpp BuildCompositeTransform4x4 + granny_matrix_operations.cpp ColumnMatrixMultiply4x3Impl):行主序、平移在 elem 12–14、world[i] = Composite(local[i]) · world[parent]、上 3×3 = transpose(R3·SS3)。self-check 用同一套。