Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
phases, EntityStore world model, ~all GC/CG headers. char create/delete,
private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
token), system-option + game-option + ESC system menu, private-shop 39-grid,
party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.
Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.
Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).
ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
25 KiB
M0 · gr2 解析器(libgr2 + gr2dump + fuzz)
总纲:
../PLAN.md§04 / §05 / §06。演示切片见../DEMO-PLAN.mdD1。 本文件是 M0 的详细施工文档。
目标
自研 libgr2(只读子集),证明能脱离 Granny 从 Metin2 真实 .gr2 读出骨架 / 蒙皮网格 / 骨骼权重 / 动画轨道,
且结构与 oracle 逐字段一致。产出后续所有里程碑用的测试资产集。
前置
- 无代码前置。
- 构建可全程无 oracle 推进:T1–T6、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_fixup、grn_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 全用 Oodle1(Format==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.h 的 CurveDataHeader |
| v6 兼容分支 | granny_back_compat.cpp |
SDK 是 2.9.12,读 v6 文件走 back-compat;扫一遍有无 v6 特有偏移 |
SDK 头注明
granny_29/ 2011 / v2.9.12。容器 struct(header / section / fixup / typetree / transform)在 2.6–2.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.5–1 天 · M ≈ 2–4 天 · 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==2(Oodle1);Texture 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_section:gr2_decompress展开到ExpandedDataSize(T2,Oodle1)→ 遍历grn_pointer_fixup[]把FromOffset处的值改写成To(section+offset) 的进程内指针 → 小端机跳过grn_mixed_marshalling_fixup。 - AC(
warrior_cheongrin.gr2= 92 770 B;warrior_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 展开到
ExpandedDataSize(T2 完成)。 - 所有
grn_pointer_fixup的From/To落在合法范围,0 越界(9166 全量)。 - root object 的
grn_reference落在合法范围。 - 注:"fixup 后能从 root 走到 skeleton" 不在 T1 验收 —— 需要 T3/T4,见"T1+T3+T4 集成检查点"。
- magic 识别(
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→ memcpy;format==2(Oodle1)→gr2_oodle1_decompress();format==1(Oodle0)/format==4(BitKnit,全样本 0 个)→ 留桩报错。- 3-block 结构:
stop0/1/2= section 的First16Bit/First8Bit/ExpandedDataSize(32 位 / 16 位 / 8 位 marshalling 区各一块,共享算术流)。 - 实测结果:
- 全部 9166 个文件、每个非空 section 展开到恰好
ExpandedDataSize(否则load()报错 → fuzz 计 crashed;实际crashed=0)。 - 内容锚(不依赖 oracle):展开后 Main section 里的内嵌 ASCII 串干净可读 ——
Granny Standard Exporter, SDK version 2.4.0.7、D:\Ymir Work\pc\warrior\warrior_cheongrin.DDS、类型成员名ExporterInfo/ExporterName等。解码错了这些会是乱码。 - (可选加强)与 granny2.dll 的
GrannyDecompressData逐字节对拍一次(oracle 就绪后)。
- 全部 9166 个文件、每个非空 section 展开到恰好
顺带实测发现(更新其它文档):
| 发现 | 影响 |
|---|---|
| 资产由 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。子集外 → 记日志、跳过、不崩。 - AC(
warrior_cheongrin.gr2root 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].Name→warrior_cheongrin读出 "Bip01"。
T4 · gr2_fileinfo [S] ✅(期望值待复核)
- 定位
FileInforoot,按名暴露 span 视图:Skeletons[] / VertexDatas[] / TriTopologies[] / Meshes[] / Materials[] / Textures[] / Models[] / TrackGroups[] / Animations[]。对外只给 POD 视图,不泄露内部指针。 - AC(
warrior_cheongrin.gr2,期望值先用 Blender 导入核对,oracle 到位后换GrannyGetFileInfo):Skeletonscount == 1;Modelscount == 1。Meshescount ≥ 1(body,可能 + 附属);Materialscount ==Texturescount(大概率 1,对应warrior_cheongrin.dds)。Animationscount == 0(角色本体不带动画,动画在action/*.gr2)。- 各 count (oracle) 与
GrannyGetFileInfo一致。 - 确认期望值:M0 第一天先用 Blender 导入
warrior_cheongrin.gr2记下真实 count,填进本节替换"大概率"。
T5 · gr2_skeleton + bind pose 自洽检查 [M] ✅
- 骨骼数组:
Name/ParentIndex/LocalTransform(granny_transformSRT)/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 的
- 抓"读矩阵带转置 / 手系错、且同样作用于正向读取"这类 oracle 对拍也发现不了的 bug。
- AC:
warrior_cheongrin全骨world_bind · invBind的max|Δ|< 当前阈值,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的效果:逐顶点拷到统一打包结构。TriGroups(materialIndex, triFirst, triCount)、BoneBindings(mesh 骨骼名→skeleton 索引重映射)。 - 代码可无 oracle 构建;验收对拍见 AC。
- AC(
warrior_cheongrin.gr2mesh 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的曲线子类型直方图,实现占比最高的 1–2 种(大概率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] ✅
- 遍历全部
.gr2(find "$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)
- 主:
libgr2dump vs 真 Granny —— ✅ 达成。oracle probe的GrannyGetFileInfo与gr2dump逐字段一致;tools/oracle_diff对拍 Granny 2.9.12 的GrannyGetWorldPose4x4Array+GrannyDeformVertices:23 用例(13 模型 bind + 8 动画帧,含 v7 / 双 root / 刚体)骨骼矩阵 ≤ 4.6e-5、蒙皮顶点 ≤ 6.5e-5(test/m2-numeric.json)。oracle 在 macOS + Wine + MinGW 跑(oracle/RUNBOOK.md)。 - 自洽:T5 bind pose 自洽 —— ✅
warrior_cheongrin6.1e-5PASS。全量 2013/2017 < 1e-3;4 个>=1e-1经 oracle 证实 libgr2 正确(assassin 对 Granny 3.1e-5),是自洽不变量本身对这几个双-root 资产不成立,非 libgr2 bug。 - fuzz:T9b 全量 9166 —— ✅ 零崩溃 + 零谓词失败。
- 变体可实现:✅ 无 BitKnit / 无 Oodle0;曲线全
OldCurveType,degree ≤ 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 确定性 = 0(byte-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 端口自泄露 SDK,9166/9166 展开 == ExpandedDataSize |
| T9b 加固吃掉大部分工期 | 已解:9166 零崩溃 + 零谓词失败。实际 T9b 几乎没触发额外加固(类型树通用遍历 + 懒解引用 + 全程边界检查一次到位) |
| section 用 Oodle0 | 不适用:全样本 0 个 |
| section 用 BitKnit | 不适用:全样本 0 个 |
| 自描述类型树递归 / 未知类型 | 已解:T3 通用遍历器不假设布局;全量 0 个未知成员类型 |
| 曲线子类型多样(B 样条拟合等) | 已解:全部 OldCurveType,degree ∈ {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-3,4 个异常定位到辅助骨子树(见上) |
DoD 清单
- T1 header + section 表:
gr2dump --sections跑通全部 9166 个文件、0 崩溃、total_size全对 - T2 Oodle1 解压:9166/9166 全 section 展开 ==
ExpandedDataSize;内嵌路径串解压后干净可读(内容锚) - T2 加强:与
GrannyDecompressData逐字节对拍一次(oracle 就绪后) - T1 fixup:pointer 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 FileInfo:
warrior_cheongrin.gr2→ skeletons=1 / models=1 / animations=0 / materials=4 / textures=2 / meshes=5(期望值待 Blender/oracle 复核) - T5 骨架 + bind pose 自洽:
warrior_cheongrinmax|Δ|=6.1e-5PASS(阈值 1e-3);全量 2017 个多骨 skeleton 中 2013 个 < 1e-3(4 个异常见下) - T6 网格:
warrior_cheongrin5 mesh 全PNT332_Skinned,顶点/索引/三角组自洽(triFirst+triCount*3==indices),bone-binding 0 悬空 - T9a/b:9166 个
.gr2零崩溃 + 零谓词失败(骨骼数/父索引/NaN/索引越界/权重和/bone-index 范围) - T9c:
test/fuzz-report.json(6 类直方图)+test/assets.list(18 个)产出 - T7a 曲线子类型统计完成(见下);T7b OldCurve 解码(d0 常量 / d1 线性 / d2 二次 B 样条 + 四元数归一)+
Animation::sample_local,dance_1等 3+ 采样点无非有限数 - 门禁·主:结构 dump vs oracle 逐字段一致(oracle 就绪后)
- T4/T5/T6 期望值 Blender/oracle 复核(替换"待复核"占位)
- T7b 精度:某采样点骨骼局部变换 vs oracle 层①
max|Δ| < ε(oracle 就绪后)
M0 实现结果(fuzz 全量 9166,test/fuzz-report.json)
| 维度 | 结果 |
|---|---|
| 解析成功 | 9166 / 9166,0 崩溃,0 谓词失败 |
| 格式版本 | v6 × 9155(magic GRNFileMV_Old)、v7 × 11(magic GRNFileMV_32Bit_LittleEndian,非 Old;含 shaman_lord / snow_dungeon zone / warrior_rabbit1) |
| section 压缩 | 非空 section 全 Oodle1(64162 段);每文件 1 个空 section(compression=none)。无 Oodle0,无 BitKnit |
| 顶点类型 | PNT332 × 4016(rigid)、PNT3322 × 309(rigid 双 UV)、PNT332_Skinned × 3118。无 PNT3322_Skinned,无未知类型 |
| 曲线格式 | 全部是 OldCurveType(Granny 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.gr2(PC + season1,同)、warrior_rabbit1_backup.gr2、hair_11_1.gr2。
这 4 个都是多 root skeleton(如 assassin:assasin_low + Bip01 两个 root),且 model 的 InitialPlacement 非单位。
后续更正(oracle 对拍后):这不是 libgr2 的 bug。
assassinbind 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)
ReferenceToVariantArray磁盘布局是{def* Type; int32 Count; void* Ptr}(Type 在前),不是{Count; Type; Ptr}。VertexData.Vertices用它。踩过一次。transform_track(Granny 2.4)={String Name; Inline PositionCurve; Inline OrientationCurve; Inline ScaleShearCurve}—— 无Flags成员,且 Position 在 Orientation 之前,与 2.9 SDK 的TransformTrackType不同。类型树遍历器按文件自带 typedef 解,不硬编码,所以不受影响;但硬编码偏移会错。- root 偏移的两种放法:多数 Metin2 skeleton 把 model-space 偏移放在
model.InitialPlacement,root 骨LocalTransform是单位;少数(如warrior_cheongrin)把偏移烘进 root 骨 local。self-check 必须world[root] = Composite(local[root]) · InitialPlacement。 - 一个文件可有多个 skeleton(本体 + 武器/挂点,各带自己的 1-骨 skeleton 和 model)。mesh 的
BoneBindings要对每个 skeleton 试解析、取悬空最少的那个。 - 组合矩阵语义(
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 用同一套。