Metin2 game client (P0–P11) + mobile asset pipeline

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
This commit is contained in:
shen
2026-08-31 20:02:12 +09:00
co-authored by Claude Sonnet 5
parent f4917a2b3b
commit 47baf6c0c6
414 changed files with 69568 additions and 385 deletions
+205
View File
@@ -0,0 +1,205 @@
# mtgodot-poc — 未完成工作清单
> 更新:2026-08-29 · 配套 [`GODOT-POC-PLAN.md`](./GODOT-POC-PLAN.md)(计划)、[`MIDREVIEW.md`](./MIDREVIEW.md)(中期评审)、
> [`SHINSOO-WORLD-RENDERING.md`](./SHINSOO-WORLD-RENDERING.md)(世界渲染实施)、[`PARITY-GAP.md`](./PARITY-GAP.md)(距参考图的保真化差距)。
>
> POC 的核心问题——**Godot + 自研 loader 能否正确读 + 渲 + 播 Metin2 `.gr2` 骨骼模型**——
> 在 macOS 上已答「能」:数值对拍 Granny ≤6.5e-5`warrior` `dance_1` 的 CPU 与 GPU(自写着色器)
> 蒙皮两条路径已**抽帧核对**t=7 / t=19)一致;`sura`/`assassin`/`shaman`(含 v7)加载渲染正常(未逐帧对拍)。
> 本文件是「离一个可用客户端还差什么」的分层清单,供正式移植排期。
>
> **状态**:✅ 已做 · 🟡 部分 · ⬜ 未做 · ⏭ 推迟到正式移植(不影响 Phase 1 中期评审 / Phase 2 bring-up · ❌ 本 POC 明确不做
>
> **规模**(粗估,单人 / 对本代码库熟悉后,置信度低 —— 排期时须自行细化):
> S ≈ 1–2 天 · M ≈ 1–2 周 · L ≈ 3 周+ · XL ≈ 独立子项目
>
> ID 稳定,可在 commit / issue 里引用(`A1` `B3` …)。B 段取代
> [`GODOT-POC-PLAN.md`](./GODOT-POC-PLAN.md) §M2.5 T2.5.6 的差距清单(那处不再单独维护)。
---
## A. Phase 1 收尾(SM
| ID | 项 | 状态 | 规模 | 说明 / 依赖 |
|---|---|---|---|---|
| A1 | 发型 mesh 实际挂接 | 🟡 | M | ✅ CPU 路径:`Metin2Model::set_hair_gr2()` + `_load_hair()` —— 加载发型 gr2,骨骼**按名重映射**到 base skeletonwarrior 74/74 匹配),hair mesh 并入 `cpu_skin` 每帧蒙皮,`alpha_scissor` 镂空材质。✅ GPU 蒙皮路径的 hair(`_build_gpu_mesh` 并入 + `ARRAY_BONES` remap,无需 shader 改)。✅ 武器挂点(C5);`.msm target_skin` 换色。 |
| A2 | anim player 与 model 共享逐帧矩阵 | ✅ | S | `Metin2Model::current_skin_matrices()` 缓存完整 affine deformer 矩阵:reload 初始化为 bind poseCPU/GPU 路径逐帧刷新;发型 / 武器 C++ 桥接可直接复用。 |
| A3 | `_frame_model` 相机框选 | ✅ | S | 已改为按所有 surface 顶点的 1%–99% 分位盒取景并加 8% 边距;assassin headless 冒烟得到稳定 `trimmed-mesh` 框选,孤立几何和远端挂点不再拖偏相机。 |
| A4 | 静态(无动画)展示路径统一 | 🟡 | S | 无 anim player 时走 `Skeleton3D` bind pose(与动画时的 `cpu_skin`/`gpu_skin` 不同代码路径)。实测 M1 bind pose 正确(`skin_matrix ≈ I`,见 `test/shear-bones-survey.md`),非 bug;为一致性 + 少一条代码路径,宜统一走蒙皮路径的 bind pose。 |
| A5 | CPU 路径每帧全量重建 mesh | 🟡 | S–M | `cpu_mesh->clear_surfaces()+add_surface_from_arrays` 每帧全量(**疑似** 30 角色 24fps 的主因 —— 未 profile 确认,见 F4)。改成原地 `Mesh::surface_update_vertex_region`,或人群直接切 `MTGODOT_GPUSKIN=1`。 |
| A6 | `ShaderMaterial` 静态 Shader 退出泄漏 | ✅ | S | Shader cache 由扩展在 scene-level terminator 显式 `unref()`CPU/GPU headless 退出不再报告该静态引用泄漏。 |
| A7 | GPU 蒙皮跑 stress harness | 🟡 | S | 已确认 `_run_stress` 会让每个实例进入 bone-texture GPU LBS,且 headless 2 角色冒烟成功;报告文案已纠正。headless dummy renderer 的帧数无性能意义,仍缺前台 Metal 人群基线。 |
| A8 | loader 健壮性 | 🟡 | S | 解压失败、section 越界与 `build_fileinfo` 失败现会严格传播;新增 4 类损坏输入 CTest,全量真实语料仍为 9166/9166。仍缺 0 骨 / 索引越界 / NaN 的桥接层用例及 H1 draw 冒烟。 |
| A9 | `reload()` 遗留蒙皮状态 | ✅ | S | 重载/切模型前现在清空 `cpu_mesh`、GPU 标志、bone texture、parts/material cache 与 `.msm` 派生状态,避免复用旧模型资源或 CPU/GPU 双重蒙皮。 |
| A10 | 动画 mesh 裁剪盒 | ✅ | S | CPU LBS 每帧按有限的变形后顶点刷新 `custom_aabb`;GPU 路径继续使用保守扩展盒。 |
---
## B. 材质 / 观感保真(M2.5M;取代 GODOT-POC-PLAN §M2.5 T2.5.6
**现状**:贴图 = gr2 material 绑定(`Mesh::material_textures[matidx]`mesh-local`use_gr2_materials=true` 默认)
→ 找不到时 `_resolve_texture` 文件名启发式(face/hair/stem 关键字 + 目录扫描)。blend / cull / alpha
全靠 `guess_blend()` 按**表面名关键字**猜。着色器 `SRC_MIX` / `SRC_SKIN`skinned/
`SRC_ADD` / `SRC_ADD_SKIN`additive;静态与 GPU 蒙皮均覆盖)。
**前置 B1**libgr2 已有 `MaterialInfo{name, diffuse_texture, all_textures, map_count, alpha_blend, two_sided}` +
`Mesh::material_textures`,其中 blend/two-sided 仍是名字、贴图数与 ExtendedData 的不完整推断;任意 D3D stage 状态不在 GR2 中。
| ID | 项 | 状态 | 规模 | 说明 |
|---|---|---|---|---|
| B1 | libgr2 暴露 blend / cull / alpha 数据 | 🟡 | M | Metin2 gr2 **不带 D3DRS** —— 客户端靠 Name(`Blend*` + MapCount>1) + ExtendedData("Two-sided")。libgr2 `MaterialInfo` 已加 `map_count` / `alpha_blend` / `two_sided`(名字 + map 推断,`static_object.cpp` 已用来切 `TRANSPARENCY_ALPHA` / `CULL_DISABLED`)。gr2fuzz 9166/9166 不回归。⬜ 精确解析 ExtendedData 变体,并由 B2 端口原客户端实际使用的固定功能材质分支。 |
| B2 | 原客户端固定功能材质状态兼容 | ⬜ | M | 现在只近似单 stage `MODULATE(TEXTURE, DIFFUSE)`。先盘点 `GameLib/ActorInstanceRender.cpp``EterGrnLib/ModelInstanceRender.cpp``EterGrnLib/Material.cpp` 在目标资产上实际触发的 blend/alpha-test/sphere-map/多贴图分支,再扩 `SRC_MIX`GR2 不承载任意 D3D stage 状态,不按未经证实的 op 清单猜实现。 |
| B3 | alpha-test(镂空) | 🟡 | S | hair 表面名 → `AlphaTest`B4 里 `decide_blend` 仍保留这条,GR2 材质 flag 不区分镂空 vs 不透明)。⬜ 改成贴图 alpha 分布判定;阈值硬编码 0.5,应取材质 `D3DCMP_*` ref。 |
| B4 | blend 模式取自材质 | ✅ 首版 | S | `metin2_model` `decide_blend()`**优先 `gr2::MaterialInfo.alpha_blend`**`dump_materials` 端口 EterGrnLib Material.cpp:233 = Name `Blend*` + MapCount>1),仅 hair 镂空 / effect 加法这两类 GR2 没编码的走表面名兜底。`decide_two_sided()``MaterialInfo.two_sided` ‖ hair/cloak/cape/skirt/leaf 名。`match_material()` 按 diffuse_texture/name basename 回查。两处(body + weapon)都改。m2_material 加 `cull_disabled` shader 变体(`shader_for(add,skin,two_sided)`)。⬜ 精确 ExtendedData 变体、alpha-test ref 取材质 `D3DCMP_*`。 |
| B5 | 每材质多张贴图(spec / mask / …) | ⬜ | M | 现在只解析 diffuse。`Mesh::material_textures` + libgr2 材质 API 扩成 map。 |
| B6 | 贴图采样设置(filter / wrap / mip / 各向异性) | ⬜ | S | 现在 shader 里写死 `filter_linear_mipmap, repeat_enable`。 |
| B7 | 顶点色 | ✅ 排查=不适用 | — | 全资产 `.gr2`PC/monster2/effect)无 `DiffuseColor`/`Color0` 顶点成员(grep assets = 0)。Metin2 逐顶点色 = 地形雾(`MapOutdoorRenderSTP` 运行时动态 VB,非资产,Godot `Environment` 雾已覆盖)+ `.mse` `colorfactor`/粒子色(属 E7/§1.2)。libgr2 / `build_mesh` 无需动。详见 PARITY §2.6。 |
| B8 | 切线帧(若确有法线贴图资产) | ⬜ | M | PNT332 无切线。xrender 时期结论:Metin2 资产**基本不用**法线贴图 —— 先排查有没有,再决定是否做(`SurfaceTool::generate_tangents()` 或从 UV 算)。 |
| B9 | 镜面 / 自发光 | 🟡 | M | Sphere-map 高光管线已端口(`SRC_MIX`/`SRC_SKIN``spec_map`/`spec_power`/`spec_enable`,复现 `Material.cpp:305 __ApplySpecularRenderState`:不透明面、相机空间 `reflect().xy``spheremap.jpg``EMISSION += sphere*tex.a*power`)。`Metin2Model.specular_power` 属性,默认 0 休眠 → 需装备层用 `item_proto bSpecular/100` 逐部位驱动。`SRC_ADD` / `SRC_ADD_SKIN` 已覆盖 additive 发光的一种,且 additive 动画面不再漏蒙皮。 |
| B10 | 背面剔除 / 双面 | 🟡 | S | 世界静态对象(W3/B1)已按 `two_sided` + 贴图名切 `CULL_DISABLED`。角色 `m2_material` shader 仍写死 `cull_back``MaterialDesc::two_sided` 死字段)。需给角色也按材质切。 |
| B11 | winding 逐模型验 | ⬜ | S | `flip_winding` 默认 false(4 个骨架验过,未全量)。 |
---
## C. 动画运行时(SL
| ID | 项 | 状态 | 规模 | 说明 |
|---|---|---|---|---|
| C1 | root motion`Accumulation` | 🟡 | S | `.msa` 已解析、`get_accumulation()` 暴露;未作用到节点 transform。 |
| C2 | 动画混合 / crossfade | 🟡 | M | ✅ `Metin2AnimPlayer.blend_time`(默认 0.15s):切 `anim_path` 时旧 clip 冻结,逐骨对旧/新 `world_pose` slerp+lerp`skin = invWorld · world_blended` 重建;`mul4x3` 复用 libgr2 语义。权重曲线改为 **`2w²−w³`ease-in),对齐客户端 `GrannySetControlEaseInCurve(t0,t1,0,0,1,1)` Hermite**(原用 smoothstep)。⬜ shear 骨过渡期精确保持、受控帧核对时长、非循环 clip 结束回退(现 `playing=false` 定格,`apply_pose` 已 clamp,够用)。 |
| C3 | additive 动画 / 部分身体 mask | ⬜ | M | 上半身动作叠在移动上等。 |
| C4 | `.msa` loop / 一次性动作 | 🟡 | S | 已增加整段 `loop` 属性:false 时夹到末帧、停止并发 `playback_finished`;实际 `.msa LoopData`count/cancel/start/end)已解析并由 `get_loop_data()` 暴露,真实资产冒烟通过。剩余:按原客户端规则执行片段循环次数/取消窗口;整段 loop/once 仍需由动作注册层指定,不能从 `LoopData` 猜。 |
| C5 | 挂点系统 | 🟡 | M | ✅ 武器挂手骨:`Metin2Model::set_weapon_gr2()` / `set_weapon_bone()`(默认 `equip_right_hand`),子 `MeshInstance3D` 每帧取 `gr2::sample_pose``world_pose[bone]``Metin2AnimPlayer::apply_pose` 驱动;对齐客户端 `GetBoneMatrixPointer`)。⬜ 特效挂骨、左手/双持/盾、`.mse` trail。 |
| C6 | motion event 类型语义 | 🟡 首版 | M | `game_scene._on_local_motion_event` 分派(对齐 `RaceMotionData EMotionEventType`):有 `sound``_audio.play_at`;有 `effect``fx.spawn_at(basename)``EffectPosition`type 2 `SCREEN_WAVING``cam.shake`type 3 `SCREEN_FLASHING` → 屏闪 ColorRecttype 6/9 `FLY/WARP` → 钩子(skill_fx 已单独处理飞行物)。`eterngrn_polish_test.gd` 覆盖。⬜ `.msa` type 数字与运行时枚举的历史偏差核对、武器拖尾(C 未列,见 WeaponTrace)、投掷点精确。 |
| C7 | 播放速率(数据驱动) | ⬜ | S | `time_scale` 有,未按动作类型 / 装备驱动。 |
| C8 | IK / look-at / 踩地校正 | ⬜ | L | 无。 |
| C9 | LOD 选择 | 🟡 | M | ✅ 距离切 mesh`lod_distances` 默认 `[18,42,90]`m,重建 `cpu_mesh`+材质+蒙皮)。**+ 淡入淡出**:切档时把旧 mesh 快照进 `LodGhost` MeshInstance3D(冻结),shader `lod_fade` uniformmix/skin)在 ~0.18s 内旧 1→0 / 新 0→1`LODController::BlendRenderWithOneTexture`);距离阈值加 15% 迟滞防抖。⬜ GPU skin 路径的 LOD、受控帧核对。 |
---
## D. 模型组合(SL
| ID | 项 | 状态 | 规模 | 说明 |
|---|---|---|---|---|
| D1 | 多部件装配(身体 + 武器 + 发型 + 时装 + 翅膀,共享骨架) | ⬜ | L | `.msm` + 物品系统。核心:多 `MeshInstance3D` 绑同一骨架 + 骨名重映射;部位遮挡 / 隐藏规则。 |
| D2 | 武器模型(独立 `.gr2` 挂手骨) | 🟡 | M | ✅ 右手单持跑通(见 C5rigid mesh 分支 `build_mesh` 已验,warrior + `00040.gr2` idle/run 跟手,贴图按材质绑定名解析)。⬜ 左手/双持/盾、`.msm`/物品系统给路径、`_lod` 变体。 |
| D3 | 贴图换色(`SourceSkin``TargetSkin` | 🟡 | S | ✅ 发型:`Metin2Model.hair_skin`TargetSkin dds 覆盖发型 albedo,对齐 `SetMaterialImagePointer`);`get_hair_options()` 返回绝对 `source_skin`/`target_skin``formats.msm_hair` CTest。⬜ 护甲/时装部位的 skin 表。 |
| D4 | 装备 / 时装换装系统 | ⬜ | L | 材质槽、部位互斥、隐藏规则。D1 的上层。 |
---
## E. 渲染功能(SXL
> `E7``E10`(特效 / 地形 / 水 / 植被)+ 地图加载 / 环境 / 第三人称场景的完整实施规格见
> [`SHINSOO-WORLD-RENDERING.md`](./SHINSOO-WORLD-RENDERING.md)W0W8 / R1 / R2,≈3.55.5 月起,
> Phase 2 三设备 bring-up 之后启动;自用,无正式门禁,W0 可提前并行)。对应的地图 / 环境 / 资产解析
> 工作项见下方 `E11``E13`,以及 `G7` / `H7` / `I5`。
| ID | 项 | 状态 | 规模 | 说明 |
|---|---|---|---|---|
| E1 | 阴影调优 | 🟡 | SM | `DirectionalLight3D` shadow 已开,出图看着正常;range / cascade / bias 未按 `unit_scale=0.01` 系统性调,需验漏影 / 摩尔纹。✅ 分层(PARITY §3.3):树 + `shadowflag≠1` 静态物体不投实时阴影(靠烘焙 `shadowmap.dds`),`shadowflag=1` + 角色投实时;`tree_shadows`/`static_shadows` 开关。 |
| E2 | 环境 | 🟡 | M | 角色演示:procedural sky + sky ambient。**世界(W5**`extension/src/environment_builder.cpp``.msenv``Sun`(方向光)+ `WorldEnv`Gradient 三段天空 / Material.Ambient / 深度雾 foglevel / Filmic+adjustment)。⬜ Character 光、云层、lens flare、昼夜。SHINSOO §9-W5。 |
| E3 | 后处理 | 🟡 | M | tonemapFilmic+ adjustment 调色 + glowintensity 0.35+ SSAOradius 1.2+ MSAA 4× 已开。⬜ 按受控参考帧标定曝光/对比/饱和/glow/SSAOTAA/FXAA 按平台取舍。 |
| E4 | 雾 | 🟡 | S | 角色演示 `MTGODOT_FOG=1`。世界(W5):`environment_builder``.msenv foglevel` 设深度雾 begin/end + 雾色。⬜ 与 `.wtr` 水雾 / 高度雾绑定。 |
| E5 | 透明排序 | 🟡 | M | `render_priority` 启发式。自重叠 alpha(头发 / 飘带)可能排错;需按深度排序或 OIT。 |
| E6 | Decal / 投影器 | ⬜ | S | 血迹 / 阴影贴花。 |
| E7 | 特效 / 粒子系统(`.mse`) | ⏭ | LXL | 真实资产是 ASCII Group/List 文本,原客户端 `EffectLib/EffectData.cpp``ParticleSystemData.cpp``EffectMesh.cpp``SimpleLightData.cpp` 已有 parser/语义真值。W7 首版尚未端口。任务是把 Particle/Mesh/Light/时间事件和挂点映射到 Godot,不是逆向私有二进制;先做最终机位必要子集,不阻塞 Phase 2 bring-up。SHINSOO §9-W7。 |
| E8 | 地形 | 🟡 | XL | ✅ 几何(W1):`formats/terrain_mesh.cpp` + `Metin2World`A1 20/20 `ArrayMesh` + UV`sample_height` 对拍 areadata z)。✅ splat 数据(W2):`formats/splat.cpp` 端口 `RAW_GenerateSplat``terrain_splat.cpp` 用 2 张 RGBA 权重图 + `Texture2DArray` 出图。⚠️ 当前 `min(8, splat.layers.size())`,而 A1 单区块最多 13 个非零层,会静默丢层;UV 也仍是错误的 `128/scale`,应改为区块归一化 U=`8*UScale`、V=`-8*VScale`。✅ 流式首版:逐区块节点 + 3×3 装卸 + `get_perf()`。⬜ 全活动图层、原始 UV/offset、源分辨率与 mip、动态角色阴影落地形、碰撞/LOD/worker/Mobile。SHINSOO §9-W1..W2/W8。 |
| E9 | 水 | 🟡 | L | W7`water_builder.cpp` 已按 `water.wtr` 逐层生成行 span 掩膜网格,`SRC_WATER` 仍是程序化波纹/菲涅耳。原客户端 parity 缺口是 `special/water/01..30.dds` 约 70ms 翻页、相机空间 UV、按水深逐顶点 alpha 和轻微高度动画。反射/折射/泡沫/normal map 属于参考帧确认后的增强;行列合并只减少几何量,不会减少现有每层一个 surface 的 draw call。SHINSOO §9-W7。 |
| E10 | SpeedTree 植被 | 🟡 | L | `.spt` 几何 loader 位于闭源 IDV `.lib`;实物为带符号的 COFF x86-64,不能链进 macOS/移动端且分发需确认授权。真实资产共 118 个。✅ R2 proxy`formats/spt.cpp` 嗅探 bark/composite atlas`tree_placeholder.cpp` 使用真实 DDS、确定性枝干、多组交叉叶簇和风摆;按 treefile 缓存共享 mesh + `MultiMeshInstance3D`A1 368 棵 / 14 树种),并已移除非原版的逐实例随机 yaw/scale。⬜ 用 `SpeedTreeRT.h` + `.lib` 验证 Windows x64 离线 exporter,再补真实 branch/frond/leaf、LOD/impostor。SHINSOO §9-W4。 |
| E11 | 地图文本格式解析(MapSetting / TextureSet / `.msenv` | ✅ | M | `formats/map_setting.cpp` + `texture_set.cpp` + `environment.cpp``.msenv` Group/List 文本树:方向光 / 材质 / `foglevel` / SkyBox+cloud / Gradient list / LensFlare)。`m2_tokvec` = `LoadMultipleTextData` 等价。CTest `formats.map_formats` + `tools/map_probe` 过真实 A14×5 / 17 层 / `foglevel 6` / 10 行 Gradient)。 |
| E12 | 区块二进制 + AreaData 解析(`height.raw` / `tile.raw` / `attr.atr` / `water.wtr` / `areadata.txt` / `areaambiencedata.txt` | ✅ | M | `formats/terrain_files.cpp`magic 2634/5426 + 尺寸校验,真值取自 `Terrain.cpp`+ `area_data.cpp`(含 `areaproperty` / `areaambiencedata`)。A1 全 20 区块 / 975 对象过 `map_probe`areadata `position` = **地图全局 cm**W3 修正,SHINSOO §5.6)。 |
| E13 | Property CRC 注册表 + 五类对象实例化(Tree / Building / Effect / Ambience / DungeonBlock | 🟡 | L | ✅ 注册表(`formats/property.cpp`A1 1330 CRC136/136 命中)。✅ Building/DungeonBlock`static_object.cpp` `get_static_mesh()`(复用 `gr2_bridge`,跨实例共享 mesh`CULL_BACK` + leaf/fence 关键字 alpha-scissor+ `place_objects()`A1 601 实例 / 119 mesh / 0 缺;位置=**地图全局 cm**,`ShadowFlag` / `portal_ids` meta / `.mdatr` 计数)。✅ Tree(W4,见 E10)。✅ `ypr` 轴共轭 bug 已修(`object_basis_godot()`,roll=朝向不再翻滚,影响 66% A1 物体;CTest 钉约定)。⬜ Effect/Ambience、逐建筑朝向对原客户端复核、`.mdatr` 碰撞体、LOD。SHINSOO §9-W3。 |
---
## F. 平台(Phase 2 —— 三设备 bring-upMac / 一加 13 / iPhone 16,自用,ML
| ID | 项 | 状态 | 规模 | 说明 |
|---|---|---|---|---|
| F0 | vendored 原生依赖 | ✅ | S | libsodium / libzstd / miniLZO 从 Homebrew 改为**源码内建**`extension/third_party/`pinned submodule + vendored miniLZO)—— 同一棵树可交叉编 NDK / iOS SDK。别名 `mt3p::sodium` / `mt3p::zstd` / `mt3p::minilzo`,无源码改动,macOS 9/9 CTest + 真实 item/mob_proto 解码逐字一致。详见 `docs/THIRD-PARTY.md`。miniLZO 是 GPL —— 自用可接受(同 oodle1.c),发布前须换。 |
| F1 | iOSiPhone 16 / Metal | 🟡 | M | ✅ 交叉编译打通:`./build-ios.sh``libmtgodot.ios.template_{debug,release}.a`arm64 / minos 15.0),全依赖(libsodium/zstd/miniLZO/libgr2/formats/mtnet/godot-cpp)随之交叉编过;iOS 强制 extension 为 STATIC(不能 dlopen),`.gdextension` 加了 `[dependencies] ios.*` 归档清单。⬜ 剩 Godot 4.7 iOS 导出模板 + 导出 preset 链 `project/bin/ios/*.a` + 免费 provisioning 签名 + iPhone 16 真机跑 + Release/strip。预留 23 天。详见 `docs/PLATFORMS.md`。 |
| F2 | Android(一加 13 / Vulkan | 🟡 | M | ✅ CMake 已接 `CMAKE_SYSTEM_NAME==Android`SHARED `.so` + `arm64` 名字后缀);`./build-android.sh` 走 NDK 自带 `android.toolchain.cmake``arm64-v8a` / `android-24`)。⬜ 本机无 NDK —— 装 NDK 后 `./build-android.sh``.so`,再 Godot Android 导出 presetGradle+ 一加 13 真机跑。**只验 Vulkan**,不做 Compatibility(GLES3) / 老机。详见 `docs/PLATFORMS.md`。 |
| F3 | 三端一致性 | ⬜ | S | 同工程 / 同 t / 同机位截图,iPhone 16 · 一加 13 · Mac 对齐。 |
| F4 | 移动端性能 / 帧预算 | 🟡 | M | ✅ **GPU 纹理压缩**`extension/src/texture_util.{h,cpp}` `make_color_texture()` —— 移动端(`OS.has_feature("mobile")`)或 `MTGODOT_TEXCOMP=1` 时对颜色贴图运行时 ASTC 8x8~2bpp vs RGBA8 32bpp),编码失败回退 RGBA8;桌面默认关,逐调用点保留原 mipmap 标志 → OFF 时与旧路径逐字节一致。接入:角色皮肤(`_load_dds`)、建筑 albedo`static_object`)、树皮/树冠(`tree_placeholder`);splat 控制图 / 阴影图 / 水 / HUD 保持 RGBA8。macOS+iOS 编过,ctest 9/9,桌面渲染无变化,`MTGODOT_TEXCOMP=1` 角色渲染正常。⬜ 剩:一加 13 / iPhone 16 单 + N 角色 bench`MTGODOT_GPUSKIN=1` 人群)、构建成本(~170ms/角色,A5/G2)、移动端画质核对。 |
| F5 | App 生命周期 | 🟡 | SM | ✅ `project/app_lifecycle.gd`AppLifecycle 节点):把 `NOTIFICATION_APPLICATION_PAUSED/RESUMED` / `FOCUS_IN/OUT` / `OS_MEMORY_WARNING` / `WM_GO_BACK_REQUEST` 收成信号,默认动作 = 后台暂停树 + 停 BGM + 降 `max_fps`,前台还原;`bind(m2client, audio)` 一键接。`M2Client`C++):`_notification` 收 PAUSED/RESUMED → `suspend()/resume()`(挂起时不 pump socket),`reconnect()` 用存好的凭据重登;新信号 `suspended`/`resumed`。已接 `login.gd`(回前台若在游戏中自动重连)+ `world_demo.gd`。headless smokesuspend/resume/reconnect 绑定 + 幂等 + 信号)通过,ctest 9/9,iOS 编过。渲染上下文丢失由 Godot Vulkan 自恢复,GPU 资源全 RenderingServer 托管,无需处理。⬜ 剩真机验证后台 30s→掐连接→回前台重连、iOS 内存告警实际触发。 |
| F6 | 打包 | ⬜ | SM | macOS / iOS / Android export preset、资产打包策略、iOS 签名。自用,无商店上架。 |
| F8 | 实体点选精度 | ✅ 首版 | S | `player_controller._ray_pick_t()`:射线 vs 每个 pickable 下所有 `MeshInstance3D` 的**世界 AABB 合并 + slab 相交**(逐实体精确,大怪 / 小怪不再被固定 1.4 m 半径误判;`grow(0.15)` 宽容度)。无网格回退旧胶囊近似。对齐 `EterGrnLib/ModelInstanceCollisionDetection::Intersect` 的**结果**,不复刻逐三角。`eterngrn_polish_test.gd` 覆盖(命中 t / 未命中 -1 / 擦边 / 无网格回退)。⬜ 逐三角 / 骨骼包围体、`GetHeight`。 |
| F7 | 触屏输入 / 手势相机 | ✅ | S | `game_camera.gd`:单指拖拽环绕(8px deadzone,低于视作点击)/ 双指捏合缩放;`player_controller.gd`:单指轻点(≤12px 位移 & ≤0.35s)= 点地 / 点选,拖拽或双指手势时不触发。`project.godot``emulate_mouse_from_touch`(避免与轻点重复触发),桌面鼠标路径不变。两脚本 `--check-only` + 项目导入无错。 |
| F8 | `godot-cpp` 钉稳定版 | ✅ | S | gitlink 精确锁定 `101ae38034304346a46ea9ea84ae156d3e860496``.gitmodules` 已移除移动的 `master` branch 配置;目标 API 固定为 Godot 4.7。 |
---
## G. 资产管线(SM
| ID | 项 | 状态 | 规模 | 说明 |
|---|---|---|---|---|
| G1 | eterpack 读取器 | 🟡 | ML | ✅ **本 fork 不是老 `.eix/.epk`** —— 是 `PackLib/Pack.cpp` 的单文件包(header + XChaCha20 加密索引 + zstd 数据,可选逐文件加密,硬编码 `PACK_KEY`)。`extension/src/pack/eterpack.{h,cpp}``EterPack`(读,name-field 从 header 反推以兼容真实 `PackMaker.exe` 包)+ `write_pack`(写)+ `packtool` CLIpack/unpack/list,跨平台 PackMaker 替代)。`pack.roundtrip` CTest + 真实 45 文件 `textureset/` 目录明文/加密往返 `diff` 一致(含 CP949 中文名 + 嵌套目录)。⬜ 接进 `AssetResolver`(从包解析而非散文件)+ mmap 大包。 |
| G2 | 跨实例资产缓存 / 去重 | 🟡 | M | 角色:每实例重建。世界(W8):`Metin2World``PropertyRegistry` / `AssetResolver` / `StaticMeshCache`(建筑 gr2→meshA1 119 份)/ DDS 解码缓存跨区块装卸复用。SHINSOO §9-W8。 |
| G3 | 异步 / 多线程加载 | 🟡 | M | 角色 `Metin2Model` 全同步。世界(W8):`Metin2World` 流式装载分摊到 `stream_budget` 个区块/帧(主线程),未上 worker thread。SHINSOO §9-W8。 |
| G4 | `.dds` 原生 import 路径 | 🟡 | S | M1 T1.3 定为运行时 `dxt.cpp`level 0 + `generate_mipmaps()`)。Godot 原生 import 分支没接。 |
| G5 | GPU 压缩纹理 / mip 质量 / sRGB 通盘过 | ⬜ | SM | 现在软解 RGBA8 上传,GPU 端不压缩;移动端显存 / 带宽要压(BC7 / ASTC)。 |
| G6 | DXT 软解补 level-N mip | 🟡 | S | mtgodot `dxt.cpp` 只解 level 0xrender 版有完整 mip 链,可回移)。 |
| G7 | 跨 pack 虚拟路径索引与覆盖优先级(`AssetResolver` | 🟡 | M | ✅ `formats/asset_resolver.cpp`:规范化(`\``/` / 去盘符 / 小写 / `.``..`+ `ymir work/` 后缀索引 + `by_rel` + pack 优先级(基础包在前,`metin2_patch_*` 在后,`stable_sort` + 先注册者胜)+ 冲突计数。A1 索引 54212 文件、17/17 地表贴图可解析。⬜ 冲突明细导出 + 优先级快照进测试。是 G1(eterpack)的运行时前置。SHINSOO §4.2 / §9-W0。 |
---
## H. 测试 / 工程(SM
| ID | 项 | 状态 | 规模 | 说明 |
|---|---|---|---|---|
| H1 | 全语料过 Godot loader 的 fuzz | ⬜ | SM | `gr2fuzz` 只覆盖 libgr2 解析(9166/9166)。缺「把每个 `.gr2` 塞进 `Metin2Model` + 蒙皮 + draw」的冒烟(对标 xrender 的 `render_fuzz`)。 |
| H2 | 全 race / 怪物 / NPC / 坐骑抽样 | 🟡 | S | 现在只抽查 ~6 个 PC 模型 + `anim_probe` 4 个 race。 |
| H3 | 带容差的 golden 视觉回归 + CI | 🟡 | M | `compare.py` 已对 Godot 非零退出、超时、旧/缺截图、0 surface、缺动画自检和 NaN 作硬失败;CPU/GPU 像素差仍是 advisory。缺校准后的结构化像素 / 直方图门禁 + 可截图的 CI renderer。 |
| H4 | 扩展侧 dump 骨骼矩阵 / 顶点做对拍 | ⬜ | S | 现在数值对拍靠 libgr2 层的 `oracle_diff`。加 `MTGODOT_DUMP=<path>` 从扩展导出,闭合「mtgodot == Granny」全链。 |
| H5 | Android NDK 上构建 tools | ⬜ | S | `gr2dump` / `gr2fuzz` / `oracle_diff` 目前 macOS。 |
| H6 | 逐材质 oracle 对拍(纸面确认) | ⬜ | S | `material_textures` 已按渲染结果验(warrior/sura/assassin/shaman 全对),缺 `run-diff-suite.sh` 级别的逐材质数值确认。 |
| H7 | A1 地图格式 / 数值 / golden 回归 | ⏭ | M | 地图数据无外部 oracle(不像 `.gr2` 有 Granny DLL):移植 `Terrain.cpp`/`Area.cpp` 公式做自洽 + `minimap.dds` 目视 + 受控机位 golden。`formats/tests/map_formats_test.cpp` 进 CTest`world-a1-r1/r2.png``compare.py`。SHINSOO §11。 |
---
## I. libgr2 覆盖面(SM
| ID | 项 | 状态 | 规模 | 说明 |
|---|---|---|---|---|
| I1 | >4 骨权重 / 8-bone-weight | ✅ | S | `libgr2::Mesh` 已记录源 `BoneWeights` 槽数,`gr2fuzz` 纳入直方图。9166 文件中的 3118 个 skinned mesh 全部声明 4 槽,当前语料无需 `ARRAY_FLAG_USE_8_BONE_WEIGHTS`。新资产若出现其它槽数会在报告中显现。 |
| I2 | 多骨架 `.gr2` | ⬜ | SM | libgr2 的 `extract_mesh` **会**按 BoneBindings 悬空最少给每个 mesh 选对骨架。缺口在桥接:`Metin2Model` 只把 `fi.skeletons[0]` 实例化成 `Skeleton3D` —— 绑到 `skeletons[1]`(武器 / 挂点骨架)的 mesh 会错绑。需按 mesh 的实际骨架建多个 Skeleton3D 或合并。 |
| I3 | 变形目标 / morph | ⬜ | S | libgr2 不解析 `granny_mesh.MorphTargets`。Metin2 用骨骼驱动面部,大概率 N/A —— 确认后标 ❌ 或补。 |
| I4 | 游戏相机 | 🟡 | M | 世界(W6):`project/world_demo.gd` 第三人称跟随(yaw/pitch/dist + 沿视线 `sample_height` 防穿)+ WASD + 贴地 + `attr.atr` 阻挡(逐轴)+ wait/walk/run 状态机 + NPC `Label3D`。⬜ 鼠标转视角交互壳、遮挡淡出、机位 preset;`SpringArm3D`/物理射线要完整覆盖建筑,先依赖 E13 的 `.mdatr`/静态对象碰撞体,当前地形碰撞只能解决地形防穿。SHINSOO §9-W6。 |
| I5 | 地图 / 全局 / Godot 坐标统一转换模块(`mtgodot::coord` | 🟡 | S | ✅ `formats/m2_coord.{h,cpp}` 纯数学核心:`position_to_godot` / `direction_to_godot` / `tile_id` / `tile_dir` / `chunk_origin_cm` / `height_raw_to_cm`,与 `make_conv``-90°X` + `0.01`)一致,CTest 覆盖。✅ `ypr_basis`Metin2 空间,D3D 端口)+ `object_basis_godot`(共轭到 Godot Y-up,放置用;修了漏轴转换 bug),CTest 覆盖。⬜ extension 侧 godot 薄封装。SHINSOO §6。 |
---
## 已明确不做(本 POC`GODOT-POC-PLAN.md` §01
UI / Python 脚本层、多角色 AI、网络、玩法逻辑、eterpack(Phase 1 用散文件 —— 但 G1 是正式移植必做)。
`.mse` 特效 / 地形 / 水 / SpeedTree 在 E 段标 `⏭`:正式移植必做、独立立项,但**不影响 Phase 1 中期评审结论**,也不阻塞 Phase 2 的三设备 bring-up。
---
## 排期
> 规模粗估、置信度低(见开头);下面的「周」是相对量级,不是承诺。
### Sprint 1 —— 小项收尾(~1 周)
已完成:`A3` · `A6` · `F8` · `I1`,并补完 `A9`/`A10` 正确性问题。
剩余:`A5`CPU 路径原地更新)· `A7`(前台 Metal stress 基线)· `A8`+`H1`(桥接层畸形用例与全语料 draw 冒烟)。
### Sprint 2 —— 组合基础(~2 周)
`A2` 已完成。剩余:`A1`(发型挂接,M)· `A4`(静态路径统一)· `I2`(多骨架排查)。
### Phase 2 —— 平台(三设备 bring-up,自用)
`F1``F7`:一加 13Vulkan+ iPhone 16Metal,静态链接)跑起来 + 三端一致 + 两台真机性能留档(旗舰基线)+ 生命周期 + 触屏相机。**无 Compatibility / 老机、无对外 go/no-go。**
### Phase 2 之后(正式移植,粗排,各阶段数周–数月)
1. **材质保真** `B1`(扩 libgr2 API)→ `B2``B11` · `H6`
2. **动画 + 组合** `C2`/`C3`/`C4`/`C5`/`C6` · `D1``D4`
3. **资产管线** `G1``G3` · `G5`(移动端纹理压缩)。
4. **独立子项目([`SHINSOO-WORLD-RENDERING.md`](./SHINSOO-WORLD-RENDERING.md)Phase 2 bring-up 之后)**
`G7`+`E11`+`E12`+`I5`W0 路径 / 格式 / 坐标)→ `E8`(地形 geometry+splat)→ `E13`Property 对象)→
`E10`(树)→ `E2`(环境)→ `I4`(相机)= R1;再 `E9`(水)+ `E7`(必要 `.mse`+ UI + `H7` = R2。
5. **打磨** `C8`IK)· `C9`LOD)· `E1`/`E2`/`E3`/`E5`(阴影 / 环境 / 后处理 / 透明排序)· `H3`CI)· `I4`(游戏相机)。
+495
View File
@@ -0,0 +1,495 @@
# m2dev-client → mtgodot-poc 1:1 功能差距
> 对照范围:`../m2dev-client-src-main/`(客户端源码)、`../m2dev-client-main/`
>(可执行客户端与配置)以及本工程当前实现。
>
> 本文按“协议、数据、交互、表现均与原客户端等价”的严格 1:1 标准审计;
> `CLIENT-ROADMAP.md` 中的“P0–P10 首版完成”只表示主要纵向流程已有首版,不等于 1:1 完成。
> 渲染细节另见 `PARITY-GAP.md`,动画、材质和资产管线另见 `BACKLOG.md`,网络实现另见
> `CLIENT-PORT.md`。
>
> 更新日期:2026-08-31(增量 38
>
> **状态列图例**:✅ 达到本文末“1:1 完成判据” · 🟡 有首版 / 部分语义 · ⬜ 未实现 ·
> ❌ 明确不做(超出 m2dev-client 参考范围,除非另立需求)。
>
> ### 不做清单(❌)
>
> - **拍卖行 / auction house**m2dev-client 无此系统 —— `0x08xx` 段只有
> `SHOP`/`MYSHOP`/`SAFEBOX_*`/`MALL_*`,全源码 / 协议头 / 资产零 `auction` 命中。
> 要做即“新造协议 + 配套服务端”,不属 1:1 移植。**标注为不做**,除非后续明确提需求。
> - **反作弊(anti-cheat**`CG_HACK`(0x0B03) 客户端异常上报,以及桌面外壳的
> `ProcessCRC` / `ProcessScanner` / 内存扫描等。自用客户端不需要,**标注为不做**,
> 除非服务端强制校验才回头做最小上报。
>
> ### 变更记录
>
> - **2026-08-31 增量 30EterGrnLib 对齐复核)**:查代码确认以下已落地,表内相关行同步。
> ① **材质 blend / two-sided 改读 GR2 材质信息**`metin2_model::decide_blend/decide_two_sided`
> 优先 `gr2::MaterialInfo.alpha_blend/two_sided` = `dump_materials` 端口 `Material.cpp:233`
> body + weapon 两处;`m2_material` 加 `cull_disabled` shader 变体)。
> ② **LOD 淡入**`LodGhost` 冻结旧 mesh + `lod_fade` uniform0.18s,距离阈值 15% 迟滞)。
> ③ **crossfade 权重改 ease-in `2w²−w³`**,对齐 `GrannySetControlEaseInCurve(0,0,1,1)`。
> ④ **`.msa` motion event 类型分派**`game_scene._on_local_motion_event`sound / effect /
> SCREEN_WAVING 震屏 / SCREEN_FLASHING 屏闪 / FLY·WARP 钩子)。
> ⑤ **角色接触阴影**`project/ui/char_shadow.gd`:强制 `cast_shadow=ON` + 脚下渐变 Decal
> `player_view` / `mob_view` 调用)。
> ⑥ **点选精度**`player_controller._ray_pick_t`:射线 vs 子 mesh 合并世界 AABB slab 相交,
> 无 mesh 回退胶囊)。
> ⑦ **武器挂点复核**:隔离探针实测 weapon MI 原点 == `equip_right_hand` 骨骼原点
> Δ≤0.06 m(旧「偏 10 m」为 cpu_skin / MapCoord 修复前的失效结论);新增 grip
> pre-transform `weapon_pre = mul4x3(weaponInvWorld[0], weaponLocal[0])`(对齐
> `ModelInstanceUpdate.cpp:171` 的 `weaponComposite[0]`),30 cm 位移夹断退回原始手部位姿。
> `03150` 类网格在 .gr2 里就离骨骼原点 ~3 m(资产缺陷,非变换)。
> 新增测试 `eterngrn_polish_test.gd``ctest 10/10` + 29 套 GDScript + iOS 全绿。
> - **2026-08-31 增量 38ESC 系统菜单 + 游戏设置窗)**:
> ① **`system_menu_ui.gd`**`systemdialog.py` 真 uiscriptthinboard 8 键)= `uisystem.py`
> `SystemDialog.__LoadSystemMenu_Default` 的 1:1`system_option_button` → `system_option_ui.open()`
> `game_option_button` → `game_option_ui.open()``change_button` → `client.say(0,"/phase_select")`
> = `net.ExitGame()` 里 `SendChatPacket("/phase_select")`),`logout_button` → `"/logout"`
> = `net.LogOutGame()`),`mall_button` → `"/in_game_mall"``exit_button` → `get_tree().quit()`
> `help_button` → 占位提示,`cancel_button` / 标题栏 → 关。ESC 现在开这个菜单(`game_scene`
> 把 ESC 从直接开 system_option 改成开 system_menu)。
> ② **`game_option_ui.gd`**`gameoptiondialog.py` 真 uiscript= `uigameoption.py`
> `block_{exchange,party,guild,whisper,friend,party_request}_button`(toggle) →
> `client.say(0,"/setblockmode " + str(mask ^ bit))``EBlockAction` 位 1<<0..1<<5,本地
> `_block_mode` 跟踪 + 持久化,服务器回包同步待补);`pvp_{peace,revenge,guild,free}`(radio) →
> `"/pkmode {0,1,4,2}"``__OnClickPvPMode*`);`name_color`/`target_board`/`view_chat`/
> `always_show_name`/`show_damage`/`salestext` radio → 持久化 `user://system_option.cfg [gameopt]`
> `systemSetting.Set*Flag` 等价;渲染侧钩子待补)。
> 新增 `system_menu_ui_test.gd`(两窗装载 + `/setblockmode` 位运算 + `/pkmode` + `/phase_select`/
> `/logout`/`/in_game_mall` + 显示开关持久化 + 二次实例读 cfg)。C++ 无改动;`ctest 10/10` +
> 34 套 GDScript + 导入 + iOS 全绿。
> - **2026-08-31 增量 37(组队成员信息板 + `CG_PARTY_SET_STATE`**
> ① **C++**`wire.h` 加 `CGPartySetState{u16 hdr,u16 len,u32 pid,u8 role,u8 on}`(断言 10
> + `EPartyRole` 枚举(NORMAL 0/LEADER 1/ATTACKER 2/TANKER 3/BUFFER 4/SKILL_MASTER 5/
> BERSERKER 6/DEFENDER 7);`game_client.send_party_set_state(pid,role,on)`
> `M2Client.party_set_state(pid,role,on)` + bind`get_party()` dict 补 `state`(完整角色字节)
> 和 `affects[7]`。
> ② **`party_ui.gd`** 从「名字 + 一条 HP」重做成 1:1 迁移 `assets/root/uiparty.py`
> `PartyMemberInfoBoard` + `partymemberinfoboard.py` 布局:每员 strip = 角色状态按钮
> (队长可点 → 弹角色菜单 普通/攻击/坦克/狂战/辅助/宗师/防御 → `party_set_state`;普通 =
> 清当前角色 `on=false`+ 踢出 → `party_leave` = `SendPartyRemovePacket`+ 名字(+★队长)
> + HP gauge + `affects[7]` 非零 → 附加效果 chip(tooltip 带值,槽位名暂定)。点名字 →
> `set_target(vid)`。顶部:EXP 分配开关(`party_set_distribute` 0 不均分 / 1 均分)+
> 组队治疗(`party_use_skill(PARTY_SKILL_HEAL=1, 0)`)。队长判定 = `get_party()` 里本地
> `get_main_vid()` 那条的 `leader`。角色菜单按党技能等级门控(Tanker≥10…Defender≥40)暂略。
> ③ **修 `p8_test` 隐患**`_init` 原来 `_run()` 未 `await``_run` 第一个 `await process_frame`
> 之后的所有断言(含增量 34 的 `shop_ex` 多货架)从未真正生效 —— 改成 `await _run()` 后
> 暴露 `shop_ui.refresh()` 的 `queue_free` 延迟 bug(旧 row 当帧未消失 → `get_child_count`
> 多计),已改 `remove_child` + `queue_free`。
> `ctest 10/10` + 33 套 GDScript + 导入 + iOS 全绿。
> - **2026-08-31 增量 36(系统设置窗 + 私人商店/选魔石补真 uiscript**
> ① **系统设置窗** `project/ui/system_option_ui.gd` —— `UiScript`+`UiBuild` 装
> `assets/uiscript/uiscript/systemoptiondialog.py`;控件绑定逐字对照
> `assets/root/uisystemoption.py``music/sound_volume_controller`(sliderbar) →
> `snd.SetMusicVolume/SetSoundVolume`(接 `Audio.master_bgm/master_sfx` + 即时改在播 BGM 声道);
> `camera_short/long`(radio) → `game_camera.max_dist`(近 11 / 远 20,并夹当前 dist);
> `fog_level0/1/2`(浓/中/淡 radio) → `Environment.fog_enabled` + `fog_density`
> `[0.055/0.018/0.004]``tiling_cpu/gpu`+`apply`(原 CPU/GPU 分块渲染)Godot 渲染器无对应,
> 保留占位。设置持久化到 `user://system_option.cfg`= 原 `systemSetting` 配置文件),
> `setup()` 时读回即时生效,`.msenv` 换 `Environment` 后 `_on_main_set` 重新 `_apply_all`。
> `game_scene`ESC 呼出(`ui_manager` 先关最顶层窗并吃掉事件 → 到 `game_scene` 说明无窗打开)。
> 新增 `system_option_ui_test.gd`(装载 + 音量/镜头/雾绑定 + cfg 持久化 + 二次实例读 cfg)。
> ② **私人商店窗改走真 uiscript** `privateshopbuilder.py`board + TitleBar + NameLine +
> `ItemSlot` 5×8=40 grid + Ok/Close):40 格从 `_win.nodes` 索引,`NameLine`(text) 上盖
> `LineEdit`,背包候选面板挂窗右侧(原客户端靠主背包拖拽,此处点选)。`itemStock` 交互模型
> (拿起 / 落位 / 价格弹窗 / 排序 + display_pos / 撤下 / 개설 / 철수)不变。测试拆出独立
> `private_shop_ui_test.gd`uiscript,缺资产跳过),`shop_cube_mall_test` 私人商店段移除。
> ③ **选魔石窗** `select_item_ui.gd` 早已走真 uiscript `selectitemwindow.py`(增量 33),无需改。
> C++ 无改动;`ctest 10/10` + 33 套 GDScript + 导入 + iOS 全绿。
> - **2026-08-31 增量 35(私人商店开设窗 = `PrivateShopBuilder` 交互模型)**`private_shop_ui.gd`
> 从「前 5 行 checkbox」重做成 1:1 复刻 `assets/root/uiprivateshopbuilder.py` 的
> `itemStock` 模型:左侧背包候选(点一件“拿起”)+ 右侧 40 格(`shop.SHOP_SLOT_COUNT`5×8);
> 拿着件点空格 → 价格输入(`uiCommon.MoneyInputDialog`)→ 落位(`AddPrivateShopItemStock` +
> `itemStock[targetSlot]=(src)`);点已占用格 → 撤下(`OnSelectItemSlot` /
> `DelPrivateShopItemStock`)。개설(OkButton) 把 stock 按格号排序、`display_pos = 格号`、
> 上限 39`PRIVATE_SHOP_ITEM_MAX_NUM` = `TPacketCGMyShop::bCount` cap)打包成
> `M2Client.open_private_shop(sign, [{vnum,count,inv_cell,price,display_pos}])`
> = `TPacketCGMyShop` + `TShopItemTable`×N,字段 1:1)。`shop_cube_mall_test` 私人商店段
> 重写(拿起 / 落位 / 价格弹窗 / 排序 + display_pos / 撤下 / 철수)。C++ 无改动;
> `ctest 10/10` + 31 套 GDScript + 导入 + iOS 全绿。
> NOTE: 面板仍手绘(与 mall_ui / cube_ui 一致),真 `.sub` 版 `privateshopbuilder.py` uiscript 待后续。
> - **2026-08-31 增量 34`SHOP_GC_START_EX` 多货架商店)**`entity_store` 新增
> `SHOP_GC_START_EX`(10) 解析 —— `TPacketGCShopStartEx{u32 owner_vid, u8 tab_count}`
> + `tab_count` × `{ShopTabHead(char name[32]+u8 coin_type=33B), ShopItem[40]}``wire.h`
> 加 `ShopTabHead` + 断言 33`SHOP_TAB_NAME_MAX=32`/`SHOP_TAB_COUNT_MAX=3`)。
> `ShopEntry` 加 `pos`(货架内槽位,买位置用),`ShopTab{name,coin_type,items}` +
> `m_shop_tabs``SHOP_GC_START` 也改成按固定 40 槽数组填 `pos``SHOP_GC_END` 清货架。
> `shop_items()` 仍是 tab 0 的镜像(旧调用不动)。`M2Client.get_shop()` → `{vid, open,
> tabs:[{name, coin_type, items:[{pos,vnum,price,count}]}]}`。`shop_ui.gd``tabs>1` 显示
> 货架按钮,买位置 = `tabIdx*SHOP_SLOT_COUNT(40)+slotPos`1:1 对齐 `uishop.py`
> `GetIndexFromSlotPos`)。`net_entity_test` 加双货架合成包断言(名称 / coin_type / 槽位保留 /
> tab0 镜像 / END 清空),`p8_test` 加多货架 UI + `buy pos 5` / `buy pos 43` 断言。
> `ctest 10/10` + 31 套 GDScript + 导入 + iOS 全绿。
> - **2026-08-31 增量 33(选魔石窗,样板第 2 例)**:新增 `project/ui/select_item_ui.gd`
> ——`UiScript`+`UiBuild` 装载 `assets/uiscript/uiscript/selectitemwindow.py`5×8
> `grid_table`);填格逻辑逐字照 `assets/root/uiselectitem.py.RefreshSlot`:遍历背包前
> `INVENTORY_PAGE_SIZE*2` 格,只留 `item.IsMetin``proto.type == ITEM_TYPE_METIN` = 10
> 且 `GetItemGrade <= 2`= 物品**内部名**最后一位数字,非数字按 0)的物品,最多 54 个。
> 点格 → `client.script_select_item(inventoryCell)`= `net.SendSelectItemPacket`
> `CG_SCRIPT_SELECT_ITEM` 0x0903+ 关窗;ExitButton / 关闭 → `script_select_item(0)`
> `uiselectitem.Close`)。触发链 1:1`quest_dialog.parse_script` 识别 EventManager
> `[SELECT_ITEM]` token → 新增 `select_item_requested` 信号(对应 `PythonEventManager`
> `EVENT_TYPE_SELECT_ITEM` → `interfacemodule.BINARY_OpenSelectItemWindow`)→
> `game_scene` 接到 `select_item_ui.open()`。新增 `select_item_ui_test.gd`token 解析 +
> 过滤 + 选包 + 关窗发 0);`ctest 10/10` + 31 套 GDScript + 导入 + iOS 全绿。
> - **2026-08-31 增量 32(角色状态窗 = 1:1 窗口迁移样板)**:新增
> `project/ui/char_status_ui.gd`——直接用 `UiScript`+`UiBuild` 装载
> `assets/uiscript/uiscript/characterwindow.py` 真布局;数值绑定逐字对照
> `assets/root/uicharacter.py.RefreshStatus`Level/Exp/RestExp、HP/SP、
> STR/DEX/HTH/INT、ATT=`(min|max)+ATT_BONUS+ATTACKER_BONUS`、
> DEF=`DEF_GRADE(+DEF_BONUS)`、MATT=`MAG_ATT+(min|max)MAGIC_WEP`、MDEF、
> ASPD/MSPD/CSPD/EREPointTypes 索引取自 m2dev `Packet.h`)。加点 / 按钮发
> `client.say(0,"/stat ht"|"/stat- ht" …)`,与原 `statusPlusCommandDict` /
> `statusMinusCommandDict` 完全一致;`POINT_STAT>0` 才显示加点按钮。STATUS/SKILL/
> EMOTICON/QUEST 四页做 `SetState` 页签切换(技能 / 表情 / 任务的数据仍在各自专用窗)。
> 名称 / 帮会 / 职业头像取 `get_entity(get_main_vid())`。`game_scene` 挂 V / C 热键。
> 新增 `char_status_ui_test.gd``ctest 10/10` + 30 套 GDScript + 导入 + iOS 全绿。
> 这是「原 `interfacemodule.py` 逐窗口迁移」的样板,后续选道具网格 / 商店 START_EX /
> 私人商店 39 格照抄这套(uiscript 装载 + 逐字数值绑定 + 原聊天命令 / 封包)。
> - **2026-08-31 增量 31(真服 E2E 二轮扫,单会话)**:以
> `MT_NET_TRACE=1 MT_E2E_SWEEP=1 ./build/extension/net_e2e` 完成一次
> auth→选人→PHASE_GAME 会话;出站明文 / 加密字节均已落盘。10 个安全探针全部发送成功、
> 450ms 后仍在线、`unknown_header=0``CG_CHARACTER_POSITION`(5B)、
> `CG_SYNC_POSITION`(16B)、`CG_SCRIPT_SELECT_ITEM`(8B)、`CG_QUEST_CANCEL`(4B)、
> `CG_PARTY_USE_SKILL`(9B)、`CG_FLY_TARGETING`/`CG_ADD_FLY_TARGETING`(16B)、
> `CG_FISHING`(5B)、`CG_SHOOT`(5B)、`CG_USE_SKILL`(12B)。`CG_QUEST_CANCEL` 触发 1 条
> quest-change;钓鱼收到真服 `Please choose a Fishing Pole.`(无鱼竿的预期业务拒绝),其余
> 无业务事件但均无断连 / 无解析错误。被动 `GC_LAND_LIST` 436B 在登录洪流中真服收到并解析。
> 扫描暴露一个代码缺口:`GC_FLY_TARGETING`/`GC_ADD_FLY_TARGETING` 与
> `GC_CHANGE_SKILL_GROUP` 已有 `EntityStore` 实现却未列入 `GameClient` 世界分派,现已补路由并由
> `net_entity_test` 复核。`CG_DUNGEON`、`CG_WARP`、改名 / 符号 / 物品变更等破坏性或独立
> 连接协议本轮未发送,仍保持“待真服验”。
> - **2026-08-31 增量 5(实现复核)**:修正文档中已经落后的功能状态。
> **情侣状态**已不再是“整体未做”:`GC_LOVER_INFO`(70B) / `GC_LOVE_POINT_UPDATE`(5B)
> 已接入 `EntityStore`,并通过 `M2Client.get_lover()` / `lover_changed` 驱动常驻
> `love_ui`C++ 状态测试和 `love_ui_test.gd` 均有覆盖;仍缺完整情侣交互与真服验收。
> **角色 / 装备渲染**已补本地主角的身体盔甲 / 时装 parts、头盔 / 假发 / 发型、右手武器、
> 左手盾牌、`item_proto.specular/100` 球面高光、角色 LOD 迟滞 + 0.18s 淡入、动作切换
> ease-in、`.msa` 音效 / 特效 / 震屏 / 屏闪事件和角色接触阴影首版。**地图 / 材质**行同步
> 记录 16 层 splat、原 UV / 源分辨率、水纹序列 / 水深 alpha、terrain patch 剔除和物体
> Y-up 旋转修复。协议审计总缺口仍为 **24 个 header**,但 `GC_LOVER_*` 已从“声明未消费”
> 清单移除;输入行修正为已支持 F1–F4 与数字键 1–9。
> 本次同时补充“参考模块覆盖索引”,把协议表之外的聊天、TextTail、HUD、UI 运行时、输入 / 相机、
> 登录串场和桌面外壳差距单独列出。
> - **2026-08-31 增量 6(位置同步上行)**:补齐 `CG_CHARACTER_POSITION`(0x0A60) /
> `CG_SYNC_POSITION`(0x0303) 的 wire 结构、尺寸断言、`GameClient` 发送接口和
> `M2Client.character_position()` / `sync_positions()` API;回环测试覆盖姿态包、最多 16 个
> 坐标元素、字段值和空批次 / 超限拒绝。两项已从“缺失 header”移入“已声明但待核”——仍需
> 接入原版的自动碰撞纠偏时机、姿态动作和真服务器端到端验收。
> - **2026-08-31 增量 7(任务应答协议)**:补齐 `CG_SCRIPT_SELECT_ITEM`(0x0903) /
> `CG_QUEST_CANCEL`(0x0906) 的 wire 结构、尺寸断言、`GameClient` 发送接口和
> `M2Client.script_select_item()` / `quest_cancel()` API;回环测试验证选择值与取消包。
> 同时让 `QuestDialog` 支持基础 `[INPUT]` 文本框和 Escape 取消;缺失 header 清单由 22 项
> 降为 20 项。任务窗口仍需接入选道具网格、原版 IME / 输入校验和完整 `EventManager` 标签语义。
> - **2026-08-31 增量 8(组队技能上行)**:补齐 `CG_PARTY_USE_SKILL`(0x0705) 的 wire
> 结构、尺寸断言、`GameClient.send_party_use_skill()` 和 `M2Client.party_use_skill()`
> 回环测试验证技能索引 / 目标 VID。缺失 header 清单由 20 项降为 19 项;队伍技能的
> 冷却、权限和队员世界标记仍待补齐。
> - **2026-08-31 增量 9(飞行目标广播)**:补齐 `GC_FLY_TARGETING`(0x0411) /
> `GC_ADD_FLY_TARGETING`(0x0412) 的 20B wire 结构;`EntityStore` 持久化 shooter 的目标
> VID / 坐标并产生追加标记,`M2Client.fly_targeting` 信号向 Godot 暴露;C++ 实体测试覆盖
> 设置目标、追加目标和坐标回退。缺失 header 清单由 19 项降为 17 项。
> - **2026-08-31 增量 10(技能组切换)**:补齐 `GC_CHANGE_SKILL_GROUP`(0x021D) 的 5B
> wire 结构、`EntityStore` 技能组状态 / dirty 标记、`M2Client.get_skill_group()` 和
> `skill_group_changed` 信号;切换会同时使技能快照失效,C++ 实体测试覆盖状态更新。
> 缺失 header 清单由 17 项降为 16 项。
> - **2026-08-31 增量 11(角色改名)**:补齐 `CG_CHANGE_NAME`(0x010B) /
> `GC_CHANGE_NAME`(0x010C) 的固定长度 wire 结构、`GameClient` 改名发送与角色槽位更新、
> `M2Client.change_name()` 及 `char_name_changed` 信号;回环测试覆盖名称字节和 pid 广播。
> 缺失 header 清单由 16 项降为 14 项。
> - **2026-08-31 增量 12(公会创建请求)**:补齐 `GC_REQUEST_MAKE_GUILD`(0x0731) 的
> 无负载包解析,`GameClient` 产生请求事件,`M2Client.guild_make_requested` 向 Godot 暴露,
> 回环测试覆盖事件消费。缺失 header 清单由 14 项降为 13 项。
> - **2026-08-31 增量 13(主动传送请求)**:补齐 `CG_WARP`(0x0305) 的 4B wire 结构、
> `GameClient.send_warp()` 和 `M2Client.request_warp()`;回环测试验证上行请求及现有
> `GC_WARP` 目标回传。缺失 header 清单由 13 项降为 12 项。
> - **2026-08-31 增量 14(挖矿动作广播)**:补齐 `GC_DIG_MOTION`(0x0308) 的 13B wire
> 结构、`EntityStore` 挖矿动作事件、`M2Client.dig_motion` 信号和 `NetWorld` 朝向 / 动画
> 元数据处理;实体、回环和 Godot 桥接测试覆盖。缺失 header 清单由 12 项降为 11 项。
> - **2026-08-31 增量 15(公会符号下载)**:补齐 `CG_SYMBOL_CRC`(0x0723) / `GC_SYMBOL_DATA`(0x0732)
> 的 16B / 8B 结构和原始数据流;`MarkClient` 支持 CRC 请求与变长符号文件接收,
> `M2Client.download_guild_symbol()` / `get_guild_symbol()` / `guild_symbol_ready` 已接入,
> 回环测试覆盖 CRC 字段、guild id 和数据长度。缺失 header 清单由 11 项降为 9 项。
> - **2026-08-31 增量 16(钓鱼协议)**:补齐 `CG_FISHING`(0x0B01) / `GC_FISHING`(0x0B10)
> 的 5B / 10B wire 结构、旋转方向换算、`EntityStore` 事件和 `M2Client.fishing()` /
> `fishing_event`;回环与 Godot 桥接测试覆盖。缺失 header 清单由 9 项降为 7 项。
> - **2026-08-31 增量 17(副本协议)**:补齐 `CG_DUNGEON`(0x0B02) / `GC_DUNGEON`(0x0B11)
> 的 4B / 5B wire 结构,支持目的地坐标变长 body,接入 `M2Client.request_dungeon()` /
> `dungeon_event`;实体、回环和 Godot 桥接测试覆盖。缺失 header 清单由 7 项降为 5 项。
> - **2026-08-31 增量 18(领地与观战者)**:补齐 `GC_LAND_LIST`(0x0B12) 的 4B 头 + 24B
> 元素变长解析,以及 `GC_OBSERVER_ADD/REMOVE/MOVE`(0x0B200x0B22) 的 12B / 8B wire
> 结构;`EntityStore` 保存领地和观战者状态,`M2Client.get_land_areas()` /
> `get_observers()` 与 `land_areas_changed` / `observer_event` 已接入;小地图现在消费观战者
> 状态并绘制紫色菱形标记。实体、回环、Godot 桥接和 `p9_test.gd` 覆盖。缺失 header 清单由
> 5 项降为 **1 项**(仅剩 `CG_TEXT`,参考端未发现实际发送方)。
> - **2026-08-31 增量 19(钓鱼动作消费)**`NetWorld` 和 `NetPlay` 现在消费
> `fishing_event` 的 START / STOP / REACT / SUCCESS / FAIL 子头,记录方向并尝试驱动远端及
> 本地主角的钓鱼动作;FISH 子头按参考客户端语义保留为鱼获物品事件,不误当作角色 VID。
> `combat_fx_test.gd`、`netbridge_test.gd` 覆盖动作分发。
> - **2026-08-31 增量 20(钓鱼结果日志)**`ChatUI` 消费 `GC_FISHING` 的 SUCCESS / FAIL /
> FISH 事件,将命中、失败和鱼获物品写入系统 / 战斗日志;`chat_test.gd` 覆盖结果路由。
> - **2026-08-31 增量 21(小地图领地标记)**:小地图继续消费 `GC_LAND_LIST`,把全局厘米坐标
> 的领地矩形绘制为公会金色 / 中性青色边框;`p9_test.gd` 补充领地数据供 headless 重绘路径
> 自检。观战者紫色菱形与领地边框均已接入,仍缺原版 minimap 贴图和 Atlas 交互。
> - **2026-08-31 增量 22(小地图缩放)**`minimap.gd` 新增 `set_scale()` / `get_scale()`
> 对齐原 `CPythonMiniMap::SetScale` 的像素 / 米缩放语义;鼠标滚轮在小地图区域内调整缩放,
> `p9_test.gd` 验证缩放会改变世界坐标投影。仍缺原版贴图、Atlas 拖动和坐标查询窗口。
> - **2026-08-31 增量 23(飞行目标消费)**`NetWorld` 为每个 shooter 保存
> `FLY_TARGETING` / `ADD_FLY_TARGETING` 目标队列;`GC_CREATE_FLY` 在终点 VID 缺失时回退到
> 队列中的目标 VID 或全局厘米坐标,补上原版 `SetFlyTarget` / `AddFlyTarget` 的关键语义。
> `netbridge_test.gd` 覆盖无终点 VID 的投射物创建;真实弹道模型、命中时机和特效仍待补齐。
> - **2026-08-31 增量 24(弓技能发射时机)**`SkillTable.is_ranged()` 根据
> `weapon_limit=BOW` 识别远程技能;`GameScene` 为弓技能暂存 `CG_SHOOT`,在 `.msa` 的 FLY
> motion event 到达时发送,超时自动清理,避免近战技能误发。`skill_test.gd` 与
> `eterngrn_polish_test.gd` 覆盖远近程识别及一次性发射。
> - **2026-08-31 增量 25Atlas 大地图窗口)**:新增 `AtlasUI`,复用地图区块
> `minimap.dds` 构建 Atlas 底图;支持 M 键打开 / 关闭、地图名称、玩家位置(全局厘米)显示、
> 左键拖动和右键复位。无底图资源时仍保留可交互坐标查询;`atlas_test.gd` 与
> `gamescene_test.gd` 覆盖窗口装配和输入路径。
> - **2026-08-31 增量 26(队伍地图标记)**:圆形小地图和 Atlas 从 `get_party()` / 实体快照
> 区分队员,绘制绿色队员点、队长环和 Atlas 队员标记;`p9_test.gd` / `atlas_test.gd` 覆盖
> 队伍标记绘制路径。队伍世界标记已不再是纯 UI 列表,仍需按原版颜色和离线状态逐项校准。
> - **2026-08-31 增量 27(输入差距复核)**:根据 `game_camera.gd` 当前实现修正文档:桌面右键
> 拖拽环绕、滚轮缩放和触屏单指 / 双指手势已经存在,不再列为缺失功能;剩余差距改为相机
> 模式、灵敏度 / 键位持久化及窗口冲突规则。
> - **2026-08-31 增量 28(移动速度校准)**`GC_CHANGE_SPEED` 已暴露实体的
> `moving_speed``NetPlay` 在主角 `entity_info` 到达时调用 `PlayerController.set_server_speed()`
> 以原版 100 为基准缩放本地步行 / 奔跑预测(0.253.0 限幅),`netplay_test.gd` 覆盖 150 → 1.5×。
> - **2026-08-31 增量 29(攻击速度校准)**`GC_CHARACTER_ADD[2]` / `GC_CHARACTER_UPDATE`
> 的 `attack_speed` 已进入 `EntityStore` / `M2Client.entity_dict()`;本地主角的
> `POINT_ATT_SPEED` 也通过 `get_points()` 暴露。`NetPlay` 按 m2dev 的 `attack_speed / 100.0`
> 动作速率换算自动攻击间隔(0.25–3.0 倍限幅,100 → 0.6 秒),并由 `netplay_test.gd` /
> `net_entity_test` 覆盖实体字段和 150 → 0.4 秒。仍需用真实 `.msa` motion duration、武器 / 坐骑
> 规则和服务端攻击合法性逐帧校准。
> - **2026-08-31 增量 4(真机验证)**`net_e2e` 加 post-2026-08-30 协议探针(env 门禁
> `MT_E2E_CHARCREATE` / `MT_E2E_MYSHOP` / `MT_E2E_CUBE`)。真服 `192.168.21.203`
> 确认 1:1`CG_CHARACTER_CREATE`(77B/u16 job) + `GC_PLAYER_CREATE_FAILURE`(5B) +
> `GC_EMPIRE`(5B) + `GUILD_GC_SKILL_INFO`(17B body) + `GUILD_GC_WAR` + `CG_MYSHOP`
> (38B+13B×N) 全部被真服接受 / 解析正确,无 unknown header。Cube/MALL 送出路径通但
> 需 NPC 交互;`CG_CHARACTER_DELETE` / 龙魂精炼 / 会徽上传(破坏性)仍未真机验。
> - **2026-08-31 增量 3**:两个 ⬜ 项落地首版 —— **私人商店 / 道具商城**
> `CG_MYSHOP` / `GC_MALL_OPEN|SET|DEL` / `CG_MALL_CHECKOUT` + `private_shop_ui` /
> `mall_ui`)、**Cube 제작**`GC_CHAT/COMMAND` 的 `cube …` 总线 → `CubeState` +
> `cube_ui`)。仍缺能量条、原版拖放 UI 细节。
> **拍卖行**经核查 m2dev-client 无此系统 → 标注 ❌ 不做(见上「不做清单」)。
> **反作弊**`CG_HACK` + `ProcessCRC`/`ProcessScanner`)→ 标注 ❌ 不做。
> **「协议覆盖审计」段**改为逐条清单:脚本核对 `wire.h` vs `Packet.h`,主网络层
> 仍缺 **24 个 header**(位置同步上行 / 战斗广播 / 任务 / 社交 / 改名 / 世界玩法整组;
> `CG_HACK` 已移入「不做」),另有 3 个「已声明未消费 / 待核」。
> - **2026-08-31 增量 2**
> - 角色创建 / 删除流程完成首版并接近 1:1(原为“暂不纳入”)——见新表行
> 「选人 / 建号 / 删号」。
> - 怪物 / NPC 真模型渲染修复:`root/npclist.txt`vnum→代号→目录)解析 +
> `net_world.catch_up()` 补拉协程化 `setup()` 漏掉的初始 spawn 洪流。世界玩法行
> 的“NPC / 怪物首版”由“能出占位胶囊”提升为“能出真模型 + 贴图 + 动作 + 名字牌 /
> 血条”。
> - `GC_EMPIRE`(0x0109) 已暴露为 `M2Client.get_empire()`;选人页显示国家名 + 国旗。
> - 关键 1:1 差距表新增“状态”列。
## 结论
`mtgodot-poc` 已经不是早期“只能登录、移动和攻击”的骨架:登录到 PHASE_GAME、基础战斗、
背包 / 装备、技能 UI、任务、聊天、组队 / 好友、商店 / 交易 / 仓库、公会、精炼、龙魂、
地图和部分特效都已有可运行首版。
当前的主要差距已从“有没有功能”转为:
1. 部分关键协议只实现了表现包或收包,没有完成原客户端的完整上行语义;
2. 数据模型已扩到 24 个 wear 位置和独立 16 格腰带,并接入部分装备 / 时装换模,但原版
costume / belt 窗口、拖放、tooltip 和部位规则仍不完整;
3. 多数玩法窗口是简化实现,未复现原 Python UI 的完整行为;
4. EffectLib、SpeedTree、材质和世界表现仍有明确占位或近似;
5. 打包资产路径、桌面配置和输入体验尚未形成原客户端等价闭环。
因此当前应称为“核心功能纵切完成”,不能称为 `m2dev-client` 的 1:1 客户端。
## 已具备的首版能力
- 登录、服务器 / 频道选择、频道负载状态、断线重连、选人、**建号 / 删号**、进入游戏;
- Metin2 风选人页(背景 / 3D 角色 / 职业书法字 / 国家 + 国旗 / 四维条 / ◀▶ 槽位 / 建删弹窗);
- 基础移动、攻击、选目标、生命 / 法力 / 经验、伤害飘字、affect、死亡与复活窗口;
- 背包、基础装备换模、地面物品、技能窗口和快捷栏读取;
- 聊天、私聊、基础任务对话和任务日志;
- 组队、好友、情侣名 / 爱意值状态、NPC 商店、交易、仓库;
- 公会、公会技能、公会战、会徽上 / 下载、精炼、龙魂精炼;
- 地形、建筑、水体、**NPC / 怪物真模型(vnum→npclist.txt→目录)**、小地图、频道、昼夜和天气首版;
- GR2 模型 / 动画、换装首版、武器 / 盾挂点、距离 LOD / 淡入、角色接触阴影、
`item_proto` 驱动的球面高光和 `.mse` 粒子首版。
这些项目只说明已有入口和基础闭环;是否 1:1 仍以下表为准。
## 关键 1:1 差距
| 优先级 | 状态 | 模块 | 当前实现 | 需要迁移的原客户端语义 |
|---|---|---|---|---|
| P1 | 🟡 | 选人 / 建号 / 删号 / 改名 | `CG_CHARACTER_CREATE`(0x0201) / `CG_CHARACTER_DELETE`(0x0202) / `CG_CHANGE_NAME`(0x010B) / `GC_PLAYER_CREATE_SUCCESS`(0x020C) / `GC_PLAYER_CREATE_FAILURE`(0x020D) / `GC_PLAYER_DELETE_SUCCESS`(0x020E) / `GC_PLAYER_DELETE_WRONG_SOCIAL_ID`(0x020F) / `GC_CHANGE_NAME`(0x010C) 全部有尺寸断言;`GameClient` 发送接口、角色槽位补丁和 `drain_*_events()``M2Client` 同名建删改 API、4 个建删结果信号及 `char_name_changed``net.loopback_flow` 覆盖建删和改名名称字节 / pid 广播。**真机验证(2026-08-31`net_e2e MT_E2E_CHARCREATE=1`**:建号、`GC_PLAYER_CREATE_FAILURE``GC_EMPIRE` 已验。选人页有建号 / 删号弹窗与国家国旗 | `CG_CHARACTER_DELETE` 破坏性,真机未验;改名 UI / 改名卡消耗和重复名错误提示未接;建号缺外形 / 发型选择与属性再分配 UI(现发 `shape=0` + 职业基础四维);缺 `SELECT_EMPIRE` 选国界面;原版选人台座、镜头动画、`OnCreateFailure` 完整错误码文案 |
| P0 | 🟡 | 技能施放协议 | `CG_USE_SKILL``CG_FLY_TARGETING``CG_ADD_FLY_TARGETING``CG_SHOOT` 已有尺寸断言、原生发送接口和 loopback 字节验证;服务端 `GC_FLY_TARGETING` / `GC_ADD_FLY_TARGETING` 已进入 `EntityStore`,通过 `M2Client.fly_targeting` 暴露,且已补入 `GameClient` 世界分派;`GC_CHANGE_SKILL_GROUP` 会更新 `get_skill_group()` 并使技能快照失效;快捷栏先发 `use_skill(skill_id,target_vid)`,再同步 `CG_MOVE` 技能动作;弓技能会在 `.msa` FLY 事件发送 `CG_SHOOT`。真服单会话已发送 `CG_USE_SKILL` / `CG_FLY_TARGETING` / `CG_ADD_FLY_TARGETING` / `CG_SHOOT`,均无断连 / unknown | 仍需实现范围 / 多目标选择器来自动驱动 `ADD_FLY_TARGETING`;冷却、目标合法性、真实弹道和客户端表现也须按技能类型细分;尚未在真服观察到对应 GC 飞行目标推送 |
| P0 | 🟡 | 快捷栏持久化 | 已覆盖 36 槽(4 页 × 9 格)、F1–F4 切页和 1–9 激活;拖放 / 清除 / 交换会分别发送 `CG_QUICKSLOT_ADD/DEL/SWAP`,服务器 `GC_*` 可完整恢复;技能冷却读取对应 `.msk``CoolTimeFormula` | 仍需补命令 / 表情快捷槽、物品拖放来源、鼠标物品态及原 UI 的完整快捷栏交互;冷却还未叠加原客户端的施法速度与所有特殊技能规则 |
| P0 | 🟡 | 物品操作 | 已有移动、使用、丢弃、拾取;Shift 选“来源→目标”发 `ITEM_USE_TO_ITEM`Ctrl+右键向当前目标 `ITEM_GIVE`Alt+右键以数量框发 `ITEM_DROP2``ITEM_OWNERSHIP` 会实时更新地面名条;`GC_VIEW_EQUIP` 已保存 11 格检查快照并弹出装备查看窗 | 仍需复刻原鼠标物品态、操作确认 / 错误提示、宝石专用规则,以及装备查看窗的原版槽位图标、3D 纸娃娃与交互 |
| P0 | 🟡 | 装备 / 背包数据模型 | 常规背包维持 90 格;运行时模型已扩展到 24 wear 位置,并将腰带改为独立 16 格;背包右侧动态呈现能力 / 时装 / 戒指 / 腰带和 4×4 腰带格,发包使用原客户端 `INVENTORY` 全局 cell`90 + wear` / `152 + belt`),按 `values[0]` 的原腰带等级规则锁格。`EquipModel` 已把本地主角 `parts[]`(时装优先)和装备槽接到身体 shape / TargetSkin、头盔 / 假发 / 发型、右手武器、左手盾牌,并用 `item_proto.specular/100` 驱动护甲球面高光 | 仍需复刻原 costume / belt 窗口的槽位贴图、分页、tooltip 和鼠标物品态;补齐饰品 / 翅膀等多部件遮挡规则、远端角色同等换装以及逐部位高光 / 卸装复位,不以当前兼容性扩展面板代替原 UI |
| P0 | 🟡 | 移动与战斗同步 | 有 `GC_MOVE`、本地预测、攻击;`M2Client.character_position()` 可发送姿态回报,`M2Client.sync_positions()` 可按原包批量发送最多 16 个坐标,`M2Client.request_warp()` 可发送 `CG_WARP` 主动传送请求;`GC_SYNC_POSITION` 批量坐标纠偏、`GC_CHANGE_SPEED``GC_WALK_MODE``GC_CHARACTER_POSITION``GC_DIG_MOTION` 已恢复到实体状态,`NetPlay``moving_speed/100` 校准主角步行 / 奔跑预测,挖矿广播可驱动目标朝向 / dig 动画,`WALK_MODE` 会驱动远端 walk/run 动画选择;真服单会话已发送 `CG_CHARACTER_POSITION`(5B) 与 `CG_SYNC_POSITION`(16B),均无断连 / unknown | 仍需按原客户端同步窗口、碰撞纠偏触发时机与保留动作处理,而不是只靠距离阈值和 lerp;姿态动作还没有完整 UI / 动画语义 |
| P0 | 🟡 | PVP / 决斗 / 阵营 | `GC_PVP``DUEL_START` 已进入状态层,并以 `pvp_changed` / `duel_started` 信号提供给 Godot;挑战 / 战斗 / 复仇关系会在世界实体上显示标签;实体已有部分 empire / alignment / pk 字段 | 仍需 PK 模式上行、原版名字颜色、可攻击判定、阵营规则和原版目标板 / 决斗 UI |
| P1 | 🟡 | Actor 战斗运行时 | 有骨骼动画、基础 combo / 硬直 / 相机抖动;动作切换按 Granny `EaseInCurve(0,0,1,1)``2w²−w³` 逐骨 crossfade;角色 mesh 支持距离 LOD、15% 迟滞和约 0.18s 新旧 mesh 淡入(`LodGhost` + `lod_fade` uniform);右手武器 / 左手盾按 `weaponComposite[0] = weaponInvWorld[0]·weaponLocal[0]` grip 复合挂 `equip_right_hand`(探针实测 Δ≤0.06 m,非标准米级偏移文件夹断回退);`.msa` motion event 已分派声音、EffectPosition 特效、震屏和屏闪(FLY / WARP 留钩子);玩家 / 怪物强制实时投影并附脚底接触阴影 Decal;实体 `attack_speed` 与本地 `POINT_ATT_SPEED` 已按原版 100 基准影响攻击请求间隔 | 仍需用真实 `.msa` motion duration、武器 / 坐骑规则和服务端合法性校准攻击节奏;另补 combo 取消窗口、精确受击 / 击退、角色碰撞、同步碰撞、武器拖尾、剩余 motion event / 骨骼挂点语义、GPU skin LOD、双持 / 左手武器和目标锁定相机;`03150` 类偏移网格需 per-weapon 偏移数据或修 .gr2 |
| P1 | 🟡 | 飞行物与命中特效 | `GC_FLY_TARGETING` / `GC_ADD_FLY_TARGETING` 保存 shooter 的目标队列和 VID / 坐标;`GC_CREATE_FLY` 可消费队列并生成 tween 发光球 | 对齐 `FlyingObjectManager`:真实技能弹道模型、命中时机 / 命中特效、消失条件、声音与 EffectLib 特效 |
| P1 | 🟡 | 任务 / NPC | 支持 `[ENTER]``[CLEAR]``[NEXT]``[DONE]`、基础 `[QUESTION]``[INPUT]` 文本框和任务日志;`M2Client.script_select_item()` / `quest_cancel()` 已可发送原协议应答,Escape 会走取消包;真服单会话已发送 `CG_SCRIPT_SELECT_ITEM`(8B) 与 `CG_QUEST_CANCEL`(4B),取消触发 1 条 quest-change 且无断连 / unknown | 补选道具网格、原版 CJK IME / 输入校验、QuestButton、立绘、完整 EventManager 标签、世界箭头和屏幕边缘指示;选道具包本轮未出现脚本业务回包 |
| P1 | 🟡 | 商店 / 交易 / 仓库 | 普通 NPC 商店、交易状态和仓库列表已有首版;**`SHOP_GC_START_EX` 多货架已解析(`m_shop_tabs``get_shop()`),`shop_ui` 带货架按钮 + `tabIdx*40+slot` 买位置** | 补图标格网格、数量输入、交易回滚提示、仓库密码 / 改密码、完整拖放规则、`coin_type` 非金币货币显示 |
| P1 | 🟡 | 私人商店 / 道具商城 | **首版**`CG_MYSHOP`(0x0802) `CGMyShopHead`(38) + `MyShopItem`(13)×N 有尺寸断言 + `GameClient::send_open_private_shop` / `send_close_private_shop`(=`SHOP_CG_END`) + loopback 字节级;`GC_MALL_OPEN`(0x0841)/`GC_MALL_SET`(0x0842)/`GC_MALL_DEL`(0x0843) 路由进 `EntityStore``m_mall[135]` + `mall_open/size/slot/dirty`),`CG_MALL_CHECKOUT`(0x0840) `CGMallCheckout`(8) 发送;`M2Client` `open/close_private_shop``get_mall_items`/`mall_checkout` + `mall_opened`/`mall_changed` 信号;`ui/private_shop_ui.gd`**已重做成 `PrivateShopBuilder.itemStock` 模型:40 格 grid + 拿起 / 落位 / 逐件价格弹窗 / 点占用格撤下 / 개설按格号排序 + display_pos + 上限 39**)、`ui/mall_ui.gd`(列表 + 取出)。**真机验证(2026-08-31`net_e2e MT_E2E_MYSHOP=1`**`CG_MYSHOP` 38B head + 13B item×1 被真服接受,无断连 / 无 shop error`SHOP_CG_END` 关店正常。`CG_MALL_CHECKOUT` 送出不断连,但 mall 需点 NPC 才 open。他人开店 `SHOP_GC_START_EX` 多货架已解析(见增量 34)。⬜ `SYMBOL_DATA`/`GC_SHOP_SIGN` 头顶招牌 3D、真 `.sub``privateshopbuilder.py` uiscript、鼠标真拖放物品态 |
| — | ❌ | 拍卖行 / auction house | 不做 | m2dev-client 无此系统(`0x08xx` 段只有 shop/myshop/safebox/mall,全源码 / 协议 / 资产零 `auction` 命中)。需自定义协议 + 服务端支持才能实现,**标注为不做**,除非后续明确提需求 |
| P1 | 🟡 | Cube (제작) / 能量系统 | **首版**Cube 走 `GC_CHAT`/`CHAT_TYPE_COMMAND` 文本总线(对齐 `ServerCommand()`),`EntityStore::apply_server_command` 解析 `cube open/close/info/success/fail/r_list/m_info`(配方 `v,c` 列表 + `@`/`&`/`|`/`/` 分层材料 + 金币)→ `CubeState` + `drain_cube_events()``GameClient::send_cube_make/material_info/result_list``/cube make|mInfo|rList`);`M2Client` `get_cube`/`cube_make`/`cube_request_*` + `cube_opened`/`cube_closed`/`cube_changed`/`cube_result` 信号;`ui/cube_ui.gd`(配方列表 + 材料/金币 + 제작)。`net.entity_store` + `net.loopback_flow` 覆盖。真机(2026-08-31`/cube rList` 送出不断连,但服务器只在玩家站到 cube NPC 前才回应 → `cube open=0`,需 NPC 交互完整验。⬜ 能量条(`POINT_ENERGY` / affect)、原 `uiCube.py` 材料格 3D 图标 / 拖放 / 결과 애니、NPC 前完整验 send 命令字 |
| P1 | 🟡 | 组队 | 邀请、接受、离队;**成员信息板(`party_ui.gd`1:1 迁移 `uiparty.PartyMemberInfoBoard`):每员角色状态按钮(队长弹菜单 → `CG_PARTY_SET_STATE` 0x0704 分配 攻/坦/狂/辅/宗/防 或踢人)+ 名字(★队长) + HP gauge + `affects[7]` 附加效果 chip;顶部 EXP 分配开关(`party_set_distribute`+ 组队治疗(`party_use_skill(1,0)`**;真服单会话 `CG_PARTY_USE_SKILL`(9B) 后仍在线;小地图 / Atlas 绿色队员点 + 队长环 | 补角色菜单按党技能等级门控(Tanker≥10…Defender≥40)、`affects[7]` 精确槽位语义、真版 `.sub` 图标 / 颜色、队伍技能冷却 / 权限、离线颜色语义 |
| P1 | 🟡 | 好友 / 情侣 | 好友列表、上线状态和私聊入口已有;`GC_LOVER_INFO`(70B) / `GC_LOVE_POINT_UPDATE`(5B) 已有尺寸断言、`EntityStore::LoverInfo` 状态、`M2Client.get_lover()` / `lover_changed``love_ui` 常驻显示伴侣名和 0–100 爱意值;`net.entity_store``love_ui_test.gd` 覆盖 | 补好友分组和完整 messenger 行为;情侣仍需完整交互 / 状态图标与 affect 表现、断线 / 切图生命周期、原版文案和真服务器端到端验收 |
| P1 | 🟡 | 公会 | 信息、成员、技能、公会战、会徽上 / 下载和部分操作已有首版;`GC_REQUEST_MAKE_GUILD`(0x0731) 已解析并以 `M2Client.guild_make_requested` 通知;`CG_SYMBOL_CRC` / `GC_SYMBOL_DATA` 已支持原始符号文件下载 | 补完整权限 / 等级页、公告与日志、成员管理、创建公会 UI / 应答流程、公会战应答、积分板、领地和公会建筑 |
| P1 | 🟡 | 世界玩法 | 有 `CG_WARP` 主动请求、`GC_WARP` 同服 / 跨服处理、`CG_FISHING` / `GC_FISHING` 钓鱼方向与状态事件、`NetWorld` / `NetPlay` 钓鱼动作分发、`ChatUI` 鱼获 / 失败日志、`CG_DUNGEON` / `GC_DUNGEON` 副本请求与状态事件、`GC_DIG_MOTION` 挖矿动作广播、`GC_LAND_LIST` 领地状态、`GC_OBSERVER_ADD/REMOVE/MOVE` 观战者状态、频道、时间、NPC marker、天气粒子和 mount_vnum 字段;真服登录洪流已收到并解析 `GC_LAND_LIST`(436B),单会话发送 `CG_FISHING`(5B) 后收到“Please choose a Fishing Pole.”业务拒绝且连接保持;同服 `GC_WARP` 直接挪玩家,跨服 `addr≠0``M2Client` 复用 login key 直连目标 game server,断点重连也走该快路径 | 补完整钓鱼 UI / 鱼获背包落地、副本计时 / 入口 UI、坐骑真模型 / 骑乘动作 / 移速、地形贴花、DungeonBlock 和地图天气配置;`CG_DUNGEON` / `CG_WARP` 本轮为避免副作用未发送,跨服换图仍需真服端到端验收与场景生命周期清理 |
| P1 | 🟡 | 小地图 / Atlas | 可画玩家、实体、NPC、任务标记、观战者紫色菱形和 `GC_LAND_LIST` 领地边框;支持 `set_scale()` / 鼠标滚轮缩放;Atlas 支持底图、地图名称、玩家坐标、拖动和坐标查询 | 补原 minimap / Atlas 贴图细节、提示、队员 / NPC 分类、世界箭头、地图驱动天气配置和原版窗口视觉 |
| P1 | 🟡 | EffectLib | `.mse` Particle 已映射到 `GPUParticles3D` | 粒子 `.dds` 目前被程序化径向渐变替代,`.mde` 用 box 占位;需补 EffectMesh、SimpleLight、TexAni、MovingType、骨骼挂点和完整时间轴 |
| P1 | 🟡 | SpeedTree | `.spt` 只嗅探真实 bark / composite 贴图,几何为程序化 proxy | 迁移或离线转换真实树干、叶片、frond、LOD、billboard、风和阴影;当前不能作为 1:1 树木 |
| P1 | 🟡 | 地图 / 材质渲染 | A1 地形、静态物、水和 `.msenv` 环境已有首版;splat 已支持每区块最多 16 个活动层、原客户端 UV / offset 与最大源分辨率,地形拆 patch 做视锥 / 距离剔除;水使用原 30 帧 DDS 序列、水深 alpha 和轻微高度动画;静态物 Y-up 旋转共轭已修正。GR2 blend / two-sided 优先读材质信息,球面高光管线已接 `item_proto.specular`;角色有实时投影 + 接触阴影 | 补其余固定功能材质与多贴图分支、精确 alpha-test / blend / cull、透明排序、逐 patch 几何 LOD、baked / realtime 阴影 mask、血迹 / 施法阵等 Decal、云层和 lens flare;按受控参考帧校准水深、LOD、阴影、光照和材质 |
| P1 | 🟡 | UI 控件语义 | 80 个 `uiscript` 可解析和构建,覆盖约 20 类基础节点 | 解析成功不等于窗口完成;需补动画图片、slot 拖放、scrollbar/listbox 行为、tooltip、radio group、候选窗、原层级 / 锚点 / 焦点 / modal 规则 |
| P1 | 🟡 | UI 窗口行为 | 背包、技能、任务、聊天、社交和商店等有简化窗口;腰带背包、扩展装备和装备查看已有兼容窗口;**选人页已按 Metin2 风重做**;**角色状态窗(`char_status_ui.gd`)已按 `characterwindow.py` 真布局 + `uicharacter.py.RefreshStatus` 逐字绑定 + 原 `/stat` 加点命令做成,作为 1:1 窗口迁移样板** | **选魔石窗** `select_item_ui.gd`、**系统设置窗** `system_option_ui.gd`、**游戏设置窗** `game_option_ui.gd``/setblockmode` + `/pkmode` 真命令 + 显示开关持久化)、**ESC 系统菜单** `system_menu_ui.gd``systemdialog.py` + `/phase_select`/`/logout`/`/in_game_mall`)、**私人商店窗** `private_shop_ui.gd`、**组队成员板** `party_ui.gd` 均已按样板做成 | 按样板逐个复现原 `Interface.MakeInterface()` 装配与视觉 / 交互细节:帮助窗、Atlas、商城 / Cube 真 uiscript、Guild Building、game_option 的名字颜色 / 伤害数字等渲染侧钩子(拍卖行参考端无此窗口)|
| P2 | 🟡 | 输入 / 快捷键 | WASD、点地、触屏、右键拖拽环绕、滚轮缩放、双指捏合、I/K/J/O/G/L/Z、F1F4 快捷栏切页和数字键 1–9 激活已接;实体点选从固定半径提升为优先使用子 mesh 合并世界 AABB,无码型实体才回退胶囊近似 | 对齐空格连续攻击、显示名字、鼠标物品态、PK 模式、正式客户端截图、相机模式与灵敏度配置持久化、键位配置和窗口冲突规则 |
| P2 | 🟡 | CJK 输入与本地化 | 支持 KEY→VALUE 查表和基础字体注入 | 补 `PythonIME` 中 / 日 / 韩候选窗、复数 / 性别规则、字体自动切换、完整 item / mob 名称与 tooltip 本地化 |
| P2 | 🟡 | 音频 | 有 BGM crossfade、UI 音效池和 3D 音效 | 补地图 BGM 自动选择、`.msenv` 环境循环音、武器 / 技能 / 怪物 / NPC 声音事件和原距离衰减规则 |
| P2 | 🟡 | 客户端外壳 | Godot 工程已有 macOS / iOS / Android 构建脚本 | 补 `config.exe` 对应的分辨率、显示、音量、阴影、语言和鼠标配置;Movie / Web / Logo、补丁器约束、Discord RPC 按发布目标取舍 |
| P0 发布 | 🟡 | Pack 资产统一读取 | `PackMount` / `AssetSource` 已实现并有 roundtrip 测试 | `Metin2World` / `Metin2Model` 等仍有直接 `AssetResolver::resolve()` 的散文件路径;必须统一切到 `AssetSource::read/to_path()`,否则 `.epk/.eix` 打包客户端不等价 |
## 参考模块覆盖索引
下表以 `m2dev-client-src-main/src/UserInterface/` 的模块职责为索引,补充上表未展开的基础能力。
“已有”只表示当前有可运行首版;仍需满足本文末的 1:1 完成判据。
| 参考模块 / 能力域 | 当前实现 | 与 m2dev-client 的差距 | 优先级 |
|---|---|---|---|
| `AccountConnector` / `PythonNetworkStreamPhase*` 登录与相位 | auth → game → 选人 → LOADING → PHASE_GAME 已打通;`LoadingScreen``ReconnectUI`、跨服 `GC_WARP` 重连和频道状态探针均有首版 | 登录 / 选服 / 选频道的原版错误码、表单校验、超时与会话失效文案,服务器列表刷新和所有相位边界清理仍未逐项对齐 | P1 |
| `PythonChat` / `PythonTextTail` / `PythonPlayer` | 聊天四标签、私聊 / 组队 / 公会 / 喊话路由,实体气泡、名字、HP 条、伤害飘字和本地气泡已有 | 缺称号、帮会名、名字颜色和目标高亮框;`GC_DAMAGE_INFO`、经验 / 金钱变化的原版日志行、notice / big-notice 排版、历史滚动和 `InsultChecker` 过滤未完整迁移 | P1 |
| `PythonPlayer` HUD / affect / target | HP、SP、经验、等级、目标血条、死亡复活窗和 affect 色块条已绑定 | 原版状态栏布局、图标贴图、持续时间倒计时、目标板按钮 / 交互、所有 point 类型和战斗日志格式仍是简化版 | P1 |
| `PythonPlayerInput*` / `CameraProcedure` / `PythonApplicationCursor` | WASD、点地、触屏点选 / 单指相机拖动 / 双指捏合、桌面右键拖拽与滚轮缩放、网格 AABB 点选、相机遮挡淡出和碰撞已有;`GC_CHANGE_SPEED` 会校准本地移动速度 | 缺原版光标状态(talk / attack / buy / sell / pick 等)、相机模式与灵敏度配置持久化、键位配置、窗口冲突和空格连续攻击语义 | P1 |
| `EterPythonLib` / `PythonApplicationModule` UI 运行时 | 80 个真实 `uiscript` 可解析;约 20 类控件、窗口栈、modal、拖动标题栏和通用弹窗已有 | `ani_image``mark`、真实 `grid_table` / scrollbar / list 行为、`.dds` UI 贴图、鼠标物品态、焦点 / 锚点 / 层级和原版九宫格视觉仍未完全对齐 | P1 |
| `PythonEffect` / `PythonFly` / `EffectLib` | `.mse` Particle → `GPUParticles3D`、技能 / 服务器特效触发、基础飞行球和动作事件已有 | `.dds` 粒子纹理、`.mde` mesh、SimpleLight、MovingType / TexAni 时间轴、骨骼挂点、真实弹道 / 命中事件和声音仍缺 | P1 |
| `PythonMiniMap` / `PythonBackground` | 圆形小地图、实体 / NPC / 任务点、观战者、领地边框、队员 / 队长标记、鼠标滚轮缩放;Atlas 已有区块底图、拖动、坐标查询、地图名称和玩家 / 队员标记;地形 / 建筑 / 水体 / 天气和昼夜首版已有 | 缺原 minimap / Atlas 贴图细节、世界箭头、地图驱动天气配置和原版窗口视觉 | P1 |
| `PythonSystem` / `MovieMan` / `PythonApplicationWebPage` / `Discord` | Godot macOS / iOS / Android 构建脚本、基础窗口和音频设置入口已有 | 缺 `config.exe` 的分辨率 / 阴影 / 语言 / 鼠标配置持久化、Movie / Logo / Web 页面、补丁器约束及按发布目标取舍的 Discord RPC | P2 |
| `ProcessCRC` / `ProcessScanner` / `PythonProfiler` | 反作弊明确列为不做 | 若服务端未来强制校验,只补最小协议上报;性能采样、崩溃上报和诊断工具不属于当前 1:1 交付范围 | — |
## 协议覆盖审计
`m2dev-client-src-main/src/UserInterface/Packet.h` 为基准(`namespace CG` 68 个 +
`namespace GC` 109 个 = **177** 个协议键)。增量 2/3 已补 `CG_CHARACTER_CREATE` /
`CG_CHARACTER_DELETE` / `GC_PLAYER_CREATE_SUCCESS` / `GC_PLAYER_CREATE_FAILURE` /
`GC_PLAYER_DELETE_SUCCESS` / `GC_PLAYER_DELETE_WRONG_SOCIAL_ID` / `CG_MYSHOP` /
`CG_MALL_CHECKOUT` / `GC_MALL_OPEN` / `GC_MALL_SET` / `GC_MALL_DEL` 共 11 个,并把已声明的
`GC_EMPIRE` 接到 `M2Client.get_empire()`。增量 5 又把此前仅声明的 `GC_LOVER_INFO` /
`GC_LOVE_POINT_UPDATE` 接入状态、信号和 UI;增量 6 又补了
`CG_CHARACTER_POSITION` / `CG_SYNC_POSITION` 的上行构造和 API。这两项不再计入缺失清单,
故当前总数从 24 降为 22 个 header;增量 7 再补任务选择 / 取消上行,增量 8 补队伍技能上行,
增量 9 补飞行目标广播,增量 10 补技能组切换,增量 11 补角色改名,增量 12 补公会创建请求,
增量 13 补主动传送,增量 14 补挖矿动作,增量 15 补会徽符号下载,增量 16 补钓鱼协议,
增量 17 补副本协议,增量 18 补领地与观战者,当前为 **1 个 header**
排除仅命名不同、功能已覆盖的别名(`GC_CHAR_ADDITIONAL_INFO`=`GC_CHAR_ADD_INFO`
`GC_TARGET`=`GC_TARGET_INFO``GC_TARGET_CREATE_NEW`=`GC_TARGET_CREATE`
`GC_CHARACTER_UPDATE2`=`GC_CHARACTER_UPDATE``GC_OWNERSHIP`=`GC_ITEM_OWNERSHIP`
`GC_SEPCIAL_EFFECT`=`GC_SPECIAL_EFFECT``GC_REFINE_INFORMATION_NEW`=`GC_REFINE_INFO_NEW`),
以及 `CG_STATE_CHECKER` / `GC_RESPOND_CHANNELSTATUS``project/net/channel_status.gd` 单独实现)
`CG_HACK`(反作弊,见「不做清单」),
**当前主网络层仍缺 1 个 header**(脚本核对 `wire.h` vs `Packet.h`):
### 账号 / 命令通道
| header | 值 | 说明 |
|---|---|---|
| `CG_TEXT` | 0x0011 | 简易命令通道(Cube 现借 `CG_CHAT`/`CHAT_TYPE_COMMAND` 实现,非原路径) |
### 已声明但待核(不计入上面 1 个)
- `CG_CHARACTER_POSITION`(0x0A60) / `CG_SYNC_POSITION`(0x0303)wire、发送接口和
loopback 字节测试已覆盖;真服单会话已分别发送 5B / 16B,均保持在线且无 unknown;仍缺原版
自动触发语义、姿态动作链和服务端位置纠偏回包验收。
- `CG_SCRIPT_SELECT_ITEM`(0x0903) / `CG_QUEST_CANCEL`(0x0906)wire、发送接口和
loopback 字节测试已覆盖;真服单会话已分别发送 8B / 4B,取消触发 1 条 quest-change 且无
unknown;基础 `[INPUT]` / Escape 取消已接入,仍缺选道具网格、原版输入校验和有脚本时的任务
流程验收。
- `CG_PARTY_USE_SKILL`(0x0705)wire、发送接口和 loopback 字节测试已覆盖;真服单会话已发送
9B 且保持在线 / 无 unknown,但无队伍技能业务回包;仍缺冷却 / 权限、UI 入口和有效队伍真服
验收。
- `GC_FLY_TARGETING`(0x0411) / `GC_ADD_FLY_TARGETING`(0x0412)wire、EntityStore 状态、
`M2Client.fly_targeting` 信号和 `NetWorld` 目标队列消费已覆盖;本轮发现并修复
`GameClient` 未分派这两个 GC header 的 bug;真服本轮未主动推送,仍缺真实弹道模型、命中时机
和服务端广播验收。
- `GC_CHANGE_SKILL_GROUP`(0x021D)wire、EntityStore 状态、dirty 失效和
`M2Client.skill_group_changed` 已覆盖;本轮发现并修复 `GameClient` 未分派该 header 的 bug
仍缺技能组选择 UI、服务端权限和真服推送验收。
- `CG_CHANGE_NAME`(0x010B) / `GC_CHANGE_NAME`(0x010C)wire、角色槽位更新、
`M2Client.change_name()``char_name_changed` 已覆盖;仍缺改名 UI、改名卡消耗 / 重名错误
文案和真服验收。
- `GC_REQUEST_MAKE_GUILD`(0x0731):无负载 wire 解析和 `guild_make_requested` 信号已覆盖;仍缺
创建公会窗口、名称校验 / 应答和真服验收。
- `CG_SYMBOL_CRC`(0x0723) / `GC_SYMBOL_DATA`(0x0732)16B / 8B wire、`MarkClient` CRC 请求、
变长数据接收和 `M2Client.guild_symbol_ready` 已覆盖;仍缺本地符号缓存失效策略、UI 预览和
真服验收。
- `CG_FISHING`(0x0B01) / `GC_FISHING`(0x0B10)5B / 10B wire、旋转方向换算、
`EntityStore` 事件、`M2Client.fishing_event`、远端 / 本地主角动作分发和 ChatUI 结果日志已
覆盖;真服单会话已发送 5B,收到“Please choose a Fishing Pole.”业务拒绝且无断连 / unknown,
证明包布局和错误路径可达;仍缺持竿后的完整钓鱼 UI、鱼获背包落地和成功流程验收。
- `CG_DUNGEON`(0x0B02) / `GC_DUNGEON`(0x0B11)4B / 5B wire、目的地坐标变长 body、
`EntityStore` 事件和 `M2Client.dungeon_event` 已覆盖;仍缺副本计时 / 入口 UI、地图生命周期
清理和真服验收。
- `GC_LAND_LIST`(0x0B12)4B 固定头 + 24B `LandPacketElement` 变长解析、领地状态、
`M2Client.get_land_areas()` / `land_areas_changed` 和小地图领地矩形边框已覆盖;真服登录洪流
已收到并解析 436B;仍缺领地贴图 / 建筑和有实际领地变更时的推送验收。
- `GC_OBSERVER_ADD/REMOVE/MOVE`(0x0B200x0B22)12B / 8B wire、持久观战者状态、
`M2Client.get_observers()` / `observer_event` 和小地图紫色菱形标记已覆盖;仍缺真服验收。
- `CG_WARP`(0x0305)4B wire、`GameClient.send_warp()``M2Client.request_warp()` 和回环
请求 / `GC_WARP` 回传测试已覆盖;仍缺传送确认 UI、冷却 / 失败提示和真服验收。
- `GC_DIG_MOTION`(0x0308)13B wire、`EntityStore` 事件、`M2Client.dig_motion`
`NetWorld` 朝向 / 动画元数据处理已覆盖;仍缺真实挖矿动作资源、音效 / 特效和真服验收。
- `GC_ITEM_DROP`(0x0513):疑似被 `GC_ITEM_GROUND_ADD`(0x0515) 取代,暂按等价处理,未逐字节核。
- `GC_LOGIN_KEY`(0x0107)auth 握手用;`net_e2e` 真机 auth→PHASE_GAME 全程已打通,功能等价
(可能走 `GC_AUTH_SUCCESS` 0x0108),未单独立项。
“header 已声明”也不能直接视为完成:还需检查封包尺寸、变长 body、所有 sub-header、
`EntityStore` 状态、GDScript 信号、UI 消费和真服务器端到端行为。
## 建议迁移顺序
### M1:服务器语义正确
1. 把远程 `SHOOT` 时机、范围目标与技能类型冷却接入现有技能协议;
2. 快捷栏的命令 / 表情 / 物品拖放语义与特殊冷却规则;
3. 细化 24 wear / 腰带 / 装备查看的原版窗口表现,并完成完整物品操作;
4. 移动同步、PVP / 决斗和 Actor 战斗状态机;
5. 用 loopback + 真服抓包为每类协议建立双向验收。
### M2:主要玩法完整
1. 任务完整标签 / 输入 / 选道具 / 指引;
2. 商店 START_EX、仓库密码、商城、私人商店、Cube(拍卖行 m2dev-client 无,除非另起协议);
3. 组队角色、公会完整页面、情侣完整交互、领地与公会建筑;
4. 钓鱼、副本、坐骑、观战者和 Atlas。
### M3UI 与表现 1:1
1. 以原 `interfacemodule.py` 为窗口清单,逐窗口迁移行为;
2. 完成 tooltip、拖放、光标、IME、快捷键和系统设置;
3. 完成 EffectLib、SpeedTree、材质、地图表现和声音事件;
4. 固定原客户端参考机位、角色、地图、时间和 UI 状态,建立截图 / 录屏签核。
### M4:发布闭环
1. 所有资产调用方统一使用 `AssetSource`,验证纯 pack 运行;
2. 覆盖目标平台构建、前后台、输入和性能;
3. 按服务器要求决定 Discord / Web / Movie 等桌面能力是否迁移(**反作弊 `ProcessCRC` /
`ProcessScanner` 已标注 ❌ 不做**,除非服务端强制校验)。
## 1:1 完成判据
一个功能只有同时满足以下条件才标记为完成:
1. 原客户端涉及的 CG / GC 包和 sub-header 全部覆盖,并有尺寸断言;
2. 数据状态能正确恢复、重连、切图和清理;
3. UI 操作、错误提示、拖放、快捷键和窗口互斥与原客户端一致;
4. 模型、动画、特效、声音和名字 / tooltip 表现一致;
5. loopback、真服务器端到端、受控截图或交互录屏至少有一种自动门禁和一种人工签核。
在达到以上判据前,应继续使用“首版”“部分完成”描述,避免把“能解析布局”“有一个按钮”或
“能播放动作”写成 1:1 完成。
+243
View File
@@ -0,0 +1,243 @@
# 客户端移植 —— 从素材渲染器到能联机的客户端
> 起点:`mtgodot-poc` 的渲染纵切「差不多能用」(见 [`PARITY-GAP.md`](./PARITY-GAP.md))。
> 本文是把它变成**能连服务端玩**的客户端的分层计划。分水岭是**网络层**——别的玩家、
> 物品、战斗、聊天都以封包为数据源。
>
> 玩法 / 客户端系统层面「原始客户端有、这里还没有」的完整差距清单见
> [`CLIENT-GAP.md`](./CLIENT-GAP.md)。
## 0. 目标服务端
- LAN`192.168.21.203`(这台 Mac 能路由到,但握手端口目前 **connection refused** ——
服务端进程没起 / 端口和默认不同,需确认)。
- 默认端口(`assets/root/serverinfo.py`):auth `11000`,频道/游戏 `11011/11021/11031/11041`
- 测试账号:`admin` / `123456789`
## 1. 协议(本 m2dev fork**不是老 Metin2**
### 1.1 帧格式
每个包 = `[uint16 header LE][uint16 length LE][payload...]``length` = 含 4 字节前缀的整包长度
(≥ 4)。包间的 0 字节是加密对齐 padding,跳过。header 是 `CG::`/`GC::` 命名空间常量
`UserInterface/Packet.h` + `EterLib/ControlPackets.h`)。
### 1.2 加密握手(libsodium
`EterBase/SecureCipher` —— X25519 密钥交换(`crypto_kx`+ 每方向固定 nonce 的 XChaCha20
流加密(带运行字节计数,**顺序敏感**)+ `crypto_auth` HMAC 挑战应答 + XChaCha20-Poly1305
AEAD 加密 session token。
握手序列(连上 auth server 后):
| 方向 | 包 | header | 动作 |
|---|---|---|---|
| S→C | `GCPhase{phase=PHASE_HANDSHAKE}` | `GC_PHASE` 0x0008 | 进握手态 |
| S→C | `GCKeyChallenge{server_pk[32], challenge[32], server_time}` | `GC_KEY_CHALLENGE` 0x000B | `Initialize()``compute_client_keys(server_pk)` |
| C→S | `CGKeyResponse{client_pk[32], challenge_response[32]}` | `CG_KEY_RESPONSE` 0x000A | `get_public_key()``compute_challenge_response(challenge)` |
| S→C | `GCKeyComplete{encrypted_token[48], nonce[24]}` | `GC_KEY_COMPLETE` 0x000C | `decrypt_token()`→token`set_activated(true)`;解密已缓冲的剩余字节 |
| S→C | `GCPhase{phase=PHASE_AUTH}` | `GC_PHASE` | 进认证态 |
| C→S | `CGLogin3{name[31], pwd[17]}` | `CG_LOGIN3` 0x0102 | 明文账号密码(**此时流已加密**)|
| S→C | `GCAuthSuccess{login_key, result}` | `GC_AUTH_SUCCESS` 0x0108 / 失败 `GC_LOGIN_FAILURE` 0x0106 | `result` 真→拿 `login_key` |
拿到 `login_key` 后断开 auth,连**游戏服**`:11011`),同样握手,然后 `CGLogin2{name, login_key}`
`CG_LOGIN2` 0x0101)→ 选人 → 加载 → 进游戏相位。
> S→C 流 nonce `[0]=0x01`C→S `[0]=0x02`。`ping`(`GC_PING` 0x0007)→`pong`(`CG_PONG` 0x0006) 全程要回。
## 2. 进度
| 模块 | 状态 | 位置 |
|---|---|---|
| `SecureCipher`KX + 流 + AEAD token | ✅ 端口完成,`net.cipher_roundtrip` CTestKX / 挑战应答 + 篡改拒绝 / token 往返 / 跨块流加解密 / 字节计数) | `extension/src/net/secure_cipher.{h,cpp}` |
| `wire.h`(帧 + 控制 + KX + auth 结构体) | ✅ `#pragma pack(1)` 尺寸断言 | `extension/src/net/wire.h` |
| `ByteBuffer`(收发暂存) | ✅ header-only | `extension/src/net/byte_buffer.h` |
| libsodium 接入 | ✅ 从源码 vendor`extension/third_party/libsodium-cmake` + zstd + miniLZO),macOS/iOS 都编过,不再依赖 Homebrew。详见 `docs/THIRD-PARTY.md` | `extension/third_party/CMakeLists.txt` |
| `NetStream`(非阻塞 POSIX socket + `process()` 循环 + 帧解析 + 内建控制面 handler:`GC_PHASE` / `GC_PING``CG_PONG` / KX 握手 / 激活后解密已缓冲字节) | ✅ 编译通过 | `extension/src/net/net_stream.{h,cpp}` |
| `AuthClient`auth 相位:`PHASE_AUTH`→发 `CGLogin3`→收 `GCAuthSuccess`/`GCLoginFailure`) | ✅ 编译通过,等可达服务端端到端测 | `extension/src/net/auth_client.h` |
| `GameClient`(游戏服:`PHASE_LOGIN`→发 `CGLogin2{name, login_key}`→收 `GCLoginSuccess3` 角色列表→`select_character()``PHASE_SELECT/LOADING/GAME` | ✅ 编译通过。角色列表按 `TSimplePlayerInformation`pack(1)103B)解析成 `{index,id,name,job,level,x,y}`;游戏相位包暂只回 PING、记未知 header | `extension/src/net/game_client.h` |
| `net_probe` 诊断工具 | ✅ `net_probe <host> <port> <id> <pw>`,打印 state / 握手 / 登录结果 / login_key | `extension/tools/net_probe.cpp` |
| **`M2Client` GDExtension 节点** | ✅ 已注册可用。`connect_to_server(...)` 编排 Auth→(`auth_ok`)→GameConnect→游戏相位;`select_character(index)`。信号 `stage_changed` / `phase_changed` / `auth_ok` / `login_failed` / `char_list` / `entered_game` / `disconnected`。生命周期(F5):`suspend()`/`resume()`/`reconnect()` + `suspended`/`resumed` 信号,`NOTIFICATION_APPLICATION_PAUSED/RESUMED` 自动触发。烟测:正确报 `auth_connect → login_failed(Connection refused) → failed` | `extension/src/net/m2_client.{h,cpp}` |
| **`M2Client` 游戏内 API** | ✅ 出站 `move(func,arg,rot_deg,x,y)`rot `/5``time` 取帧钟)/ `attack(motion,vid)` / `set_target(vid)` / `say(type,text)`,未入局返回 false。快照 `get_entities()` / `get_entity(vid)`(含 hp/sp/level/dead/stunned,坐标转 Godot 米)/ `get_main_vid()` / `get_points()`HP/SP/level/exp/gold/ `get_target()`vid + hp%)。`pump_game()` 每帧 `EntityStore::set_now/tick` 并抽信号:`entity_spawned/despawned/moved/main_set``chat``vitals_changed(vid)``entity_dead(vid)`(首次去重)、`damage(vid,amount,flag)``motion(vid,victim,motion)``points_changed(Dict)``target_info(vid,hp%)``net.loopback_flow` 扩展:出站 `CG_MOVE/ATTACK/TARGET/CHAT` 经加密信道字节级校验 | `extension/src/net/m2_client.{h,cpp}` |
| **游戏相位封包结构** | ✅ `wire.h``GC_MAIN_CHARACTER` / `GC_CHARACTER_ADD[2]` / `GC_CHARACTER_DEL` / `GC_CHARACTER_UPDATE` / `GC_MOVE` / `GC_CHAT` + `CG_MOVE` / `CG_ATTACK` / `CG_TARGET` / `CG_CHAT`。**战斗/状态**(对 `Packet.h`):`GC_PLAYER_POINTS`(0x0214, int32[255] 1024B) / `GC_PLAYER_POINT_CHANGE`(0x0215) / `GC_STUN`(0x0216) / `GC_DEAD`(0x0217) / `GC_MOTION`(0x0307) / `GC_DAMAGE_INFO`(0x0410) / `GC_TARGET_INFO`(0x0A10) + `EPointTypes`HP/MAX_HP/SP/MAX_SP/LEVEL/EXP/GOLD…)+ `EDamageFlag`NORMAL/POISON/DODGE/BLOCK/PENETRATE/CRITICAL)。全部 `static_assert` 尺寸 | `extension/src/net/wire.h` |
| **`EntityStore`(封包→世界模型)** | ✅ headless。`apply()` 消化上述包 → `vid → Entity{race,name,x,y,z,angle,parts,func,moving, hp,max_hp,sp,max_sp,level,dead,stunned}``GC_MOVE` FUNC_MOVE 插值/其他 func 吸附;`tick()``now_ms` 插值。战斗:`GC_PLAYER_POINTS``points()` 全量属性 + 镜像到主角实体;`GC_PLAYER_POINT_CHANGE`→按 `EPointTypes` 更新单实体 vital`GC_DEAD`/`GC_STUN`→标记;拉取口 `drain_changes` / `drain_chat` / `drain_vitals` / `drain_damage` / `drain_motions` / `take_points_dirty` / `take_target_dirty`。单测 `net.entity_store` 扩展覆盖全部战斗包 | `extension/src/net/entity_store.{h,cpp}` |
| **`CG_*` 出站构造** | ✅ `GameClient::send_move/send_attack/send_target/send_chat``CG_CHAT` 变长) | `extension/src/net/game_client.h` |
| **环回集成测试** | ✅ `net.loopback_flow` CTest:进程内 `MockServer`(真协议:帧 + libsodium KX + 脚本相位包)驱动 `AuthClient``GameClient` 走完 auth→角色列表→选人→`PHASE_GAME`→游戏包进 `EntityStore`→NPC 移动插值。服务端一通改个 IP 即可 | `extension/tests/net_loopback_test.cpp` |
| **`EntityStore` 单测** | ✅ `net.entity_store` CTest:合成包 → spawn / 移动插值(半程/到达/func 复位)/ 吸附 / 变长 chat / despawn | `extension/tests/net_entity_test.cpp` |
| **`NetWorld` 场景桥(GDScript** | ✅ `net_world.gd`:消费 M2Client 实体信号,每 vid 一个子节点,位置向 `get_entity(vid).pos` 平滑逼近(超 `snap_dist` 瞬移)、`rotation.y` 取自 `angle_deg`、动画状态取自 `func`。默认「占位胶囊 + 名字牌 + HP 条」;`set_model_factory(cb)` 换真模型。**战斗**`vitals_changed`→缩放 HP 条 + emit `vitals(vid,hp,max_hp,dead)``damage`→实体头顶飘字(crit 放大变金 / dodge=MISStween 上浮淡出)+ emit `damage_number``entity_dead`→倒地 + `set_anim_state("dead")``main_entity_ready` / `chat_line` 信号。headless 自检 `netbridge_test.gd`spawn/main/远移吸附/近移插值/chat/伤害数字/HP 条/暴击/死亡/despawn)通过 | `project/net_world.gd` |
| **P0 闭环胶水 + 场景组装** | ✅ `net_play.gd``player_controller``M2Client` —— 点地→`move(FUNC_MOVE)`(节流)/ 停→`move(FUNC_WAIT)` / 点实体→`set_target` + 进 2.5m 自动 `attack` / 收自己 `GC_MOVE`→位置校正 / `points_changed`+`vitals_changed`+`target_info`→HUD。`hud.gd``set_vitals/set_exp/set_level/set_target/clear_target``game_scene.gd`(简版)把 world+player+camera+pc+net_world+hud+net_play+audio+lifecycle 拼起来,`login.gd``entered_game``--auto` 自动选槽)实例化。`netplay_test.gd` + `gamescene_test.gd` headless 全过 | `project/{net_play,game_scene,hud,login}.gd` |
| **P1 UI 工具层** | ✅ `ui/uiscript.gd` 解析原始 `uiscript/*.py`(子集解释器,**80/80 真实文件通过**)+ `ui/ui_build.gd` dict→Control 树(~20 种 type+ `ui/ui_assets.gd``.sub`/tga/png+ `ui/ui_manager.gd`(窗口栈 / ESC / modal / 拖动)+ `ui/dialogs.gd``ui_test.gd` 全过 | `project/ui/*.gd` |
| **P2 物品数据层** | 🟡 `wire.h``GC_ITEM_SET/DEL/UPDATE/USE/GROUND_ADD/GROUND_DEL/GET` + `CG_ITEM_MOVE/USE/DROP/PICKUP`(全 static_assert)。`EntityStore``inventory[90]` / `equipment[11]` / ground map + `drain_inv/ground/item_events``M2Client``get_inventory/equipment/item/ground_items` + `move_item/use_item/drop_item/pickup_item` + 5 信号。新 `Metin2Proto` 节点(`item(vnum)`/`mob(vnum)`,载 5748 真实 item)。⬜ 背包 / 装备窗 UI + 穿脱换模型 | `extension/src/{net/wire.h,net/entity_store.*,net/game_client.h,net/m2_client.*,proto/proto_node.*}` |
| **P3 聊天** | ✅ `wire.h` `GC_WHISPER`/`CG_WHISPER` + `EntityStore` whisper 解析(`ChatMsg` 加 from/sub+ `M2Client.whisper()` + `whisper_received` 信号。`ui/chat_ui.gd`4 标签 + 前缀 `/w`/`/g`/`/p`/`/s` + 系统/战斗日志。`net_world._bubble` 头顶气泡 + `main_bubble``chat_test.gd` 全过 | `extension/src/net/{wire.h,entity_store.*,game_client.h,m2_client.*}` · `project/ui/chat_ui.gd` |
| **P4 战斗表现** | 🟡 `wire.h` `GC_AFFECT_ADD/REMOVE` + `EntityStore` affect map + `M2Client.get_affects()` / `affect_added`·`affect_removed`。受击硬直(`net_play` + `pc.frozen`)· combo(motion 递增)· 相机抖动(`game_camera.shake`)· 死亡窗(`ui/death_ui.gd``/restart_here`·`/restart_town`)· `hud.set_affects` 图标条。`combat_fx_test.gd` 全过。⬜ 武器拖尾 / 相机锁定 | `extension/src/net/*` · `project/{net_play,game_camera,hud}.gd` · `project/ui/death_ui.gd` |
| **P5 特效引擎(首版)** | 🟡 `fx/mse.gd``.mse` 花括号树解析,真文件 6 particle)+ `fx/effect_player.gd`Particle→`GPUParticles3D`:发射形状/速率/寿命/曲线/颜色渐变/billboard/加法混合)+ `fx/effect_registry.gd`(名字→`.mse` + 缓存 + spawn)。`wire.h` `GC_SPECIAL/SPECIFIC_EFFECT` + `M2Client.effect_cue` 信号 → `game_scene` 在实体上播。`fx_test.gd` 全过。⬜ `.dds` 纹理 / `.mde` mesh / 绑骨骼 | `extension/src/net/*` · `project/fx/*.gd` |
| **P6 技能系统(首版)** | 🟢 `wire.h` `GC_SKILL_LEVEL/COOLTIME_END/CREATE_FLY` + `EntityStore` skill 表 + `M2Client.get_skills`/`cast_skill`/`skill_up` + `skills_changed`/`skill_cooldown_end`/`fly_cue` 信号。`ui/skill_table.gd`skilldesc.txt+ `ui/skill_ui.gd`K 键,加点)+ `ui/quickbar.gd`1-8 施放 + 冷却)+ `net_world` 飞行球。`skill_test.gd` 全过 | `extension/src/net/*` · `project/ui/{skill_table,skill_ui,quickbar}.gd` |
| **P7 任务 / NPC(首版)** | 🟡 `wire.h` `GC_SCRIPT`/`GC_QUEST_CONFIRM`/`GC_QUEST_INFO` + `CG_ON_CLICK`/`SCRIPT_ANSWER`/`SCRIPT_BUTTON`/`QUEST_INPUT_STRING`/`QUEST_CONFIRM`(全 static_assert)。`EntityStore``ScriptCue`/`ConfirmCue`/`QuestInfo` map + `drain_scripts/confirms/quest_changes` + `quest(idx)``Entity``ch_type``M2Client``click_npc`/`script_answer`/`script_button`/`quest_input`/`quest_confirm`/`get_quests` + `script_dialog`/`quest_confirm_ask`/`quest_info` 信号。`ui/quest_dialog.gd`EventManager 脚本渲染:`[ENTER]`/`[QUESTION]`/`[DONE]` → 文本+选项+确认)+ `ui/quest_log.gd`J 键)。`net_play._on_pick` NPC 分流。quest 逻辑纯服务器驱动(选型②)。`quest_test.gd` 全过。⬜ `[INPUT]` 输入 UI / 选道具 / 立绘 / 世界任务箭头 | `extension/src/net/*` · `project/ui/{quest_dialog,quest_log}.gd` · `project/net_play.gd` |
| charselect 串场 | ⬜ P10 | — |
| **P8 社交 / 商店 / 仓库(首版)** | 🟡 `wire.h` `GC_PARTY_*`(0x0710-16) / `GC_MESSENGER`(0x0741) / `GC_SHOP`(0x0810) / `GC_EXCHANGE`(0x051C) / `GC_SAFEBOX_*`(0x0830-34) + 对应 CG(全 static_assert`ShopItem`/`GCExchange`/`GCSubHeader` 等)。`EntityStore``PartyMember` map / `Friend` map / `ShopEntry` 表 / `ExchangeState` / `m_safebox[135]` + dirty 标志 + `drain_party_invites`/`drain_shop_errors``M2Client``party_invite/answer/leave` · `add/remove_friend` · `shop_buy/sell/close` · `exchange_start/add_item/add_gold/accept/cancel` · `safebox_checkin/checkout/move` + `get_party/friends/shop_items/exchange/safebox_items` + 8 信号。`ui/{party_ui,friend_ui,shop_ui,exchange_ui,safebox_ui}.gd`(队员面板常驻 / O 键好友 / 商店·交易·仓库随信号自动开)。`inventory_ui` 右键分流(商店卖 / 交易放 / 仓库存)。`p8_test.gd` + `net.entity_store` / `net.loopback_flow` 扩测全过。⬜ 公会(`PythonGuild` 40 法 + Mark 下载)、精炼 / 强化 / 龙魂、私人商店 / 拍卖、交易道具图标网格 | `extension/src/net/*` · `project/ui/{party,friend,shop,exchange,safebox}_ui.gd` · `project/game_scene.gd` |
| 公会 / 精炼 / 龙魂精炼 / 会徽下载(首版) | 🟡 `GC_GUILD`(0x0730) INFO/LIST/GRADE + `ui/guild_ui.gd`G,真机 `[GM-TEAM]`);`GC_REFINE_INFO`(0x051D) + `ui/refine_ui.gd``refine_ask``refine(pos,type)`);`GC_DRAGON_SOUL_REFINE`(0x051F)/`CG`(0x050D `grid[15]`) + `DS_SUB_*` + `EntityStore.DragonSoulCue`/`m_dragon_soul[180]` + `M2Client.ds_refine(mode,cells)`/`ds_window_open`/`ds_refine_result` + `ui/dragon_soul_ui.gd`(L / 自动弹,3 模式 + 15 格背包右键填)。**会徽**:`MARK_*` 独立连接(`GC_MARK_IDXLIST`/`BLOCK` 0x0C1x、`GC_MARK_UPDATE` 0x0B15、`CG_MARK_LOGIN/IDXLIST/CRCLIST` 0x0C0x);`NetStream``on_cipher_active()`/`on_raw()`u32 `buf_size` 分帧);`mark_image.{h,cpp}` `MarkImageSet`LZO1X → 512×512 → `mark_pixels`+ `mark_client.h` `MarkClient``M2Client.download_guild_marks`/`get_guild_mark_image``Image``serverinfo` `mark_port` 第 8 列;`app_flow` 进游戏连 + `guild_mark_updated` 冷却重连。**公会战 + 技能页**:`GUILD_GC_SKILL_INFO`(12) `GuildSkillInfoBody`(17)、`WAR`(15) `GCGuildWar`(10)、`GUILD_WAR_LIST`/`END_LIST`(17/18) `GuildWarPair`(8)、`WAR_POINT`(19) `GuildWarPoint`(12)、`NAME`(16) `GuildNameEntry`(16)`EntityStore` `GuildSkillState`/`GuildWarStatus`/`m_guild_wars`/`m_guild_names` + `drain_guild_war_events`/`_scores``GameClient.send_guild_use_skill``M2Client` `get_guild_skill`/`get_guild_wars`/`get_guild_war`/`use_guild_skill`/`declare_guild_war`(`/war` 命令) + `guild_skill_changed`/`guild_war_changed`/`guild_war_event`/`guild_war_point` 信号;`ui/guild_ui.gd` 改三页 Tab(成员/技能/公会战)。**会徽上传**:`CG_MARK_UPLOAD`(0x0C03) `CGMarkUpload`(776)、`CG_GUILD_SYMBOL_UPLOAD`(0x0722) `CGSymbolUpload`(8)+文件字节;`NetStream.send_pending()``MarkClient` `Mode`(Download/UploadMark/UploadSymbol) + `upload_done()``M2Client.upload_guild_mark`(转 RGBA8/缩放)/`upload_guild_symbol`/`get_mark_server` + `guild_mark_uploaded``guild_ui` 成员页「上传会徽」按钮。`net.loopback_flow``MockServer::Mode::Mark` 真 socket 端到端校验(KX → CG_MARK_LOGIN → CG_MARK_UPLOAD 768 字节校验和 → upload_done)。`guild_refine_test.gd` + `dragon_soul_test.gd` + `guild_mark_test.gd` + `guild_war_skill_test.gd` + `net.entity_store` + `net.guild_mark` + `net.loopback_flow`(CTest) 全过。**真机验证(2026-08-31`net_e2e`**`GUILD_GC_SKILL_INFO`(sub 12) 服务器进游戏自动推送,17B body 解析正确(skill_point=19 / guild_point 0/2300 / 12 等级);`GUILD_GC_WAR`(sub 15) 无战状态解析正确;trace 显示 `GC_GUILD`(0x0730) 多个子包 len=171/40/83/22/21/9/5 均无 unknown / parse 错误。会徽独立连接 + 会徽上传仍未接真机(联调服未开会徽服端口)。⬜ 龙魂属性表、公会等级/日志页 | `extension/src/net/*` · `project/ui/{guild,refine,dragon_soul}_ui.gd` · `project/{net/serverinfo,app_flow}.gd` |
| **私人商店 / 道具商城 / Cube(首版)** | 🟡 `wire.h``CG_MYSHOP`(0x0802) `CGMyShopHead`(38`char sign[33]+u8 count`) + `MyShopItem`(13`u32 vnum,u8 count,ItemPos,u32 price,u8 display_pos`)×N`CG_MALL_CHECKOUT`(0x0840) `CGMallCheckout`(8)`GC_MALL_OPEN`(0x0841) `GCMallOpen`(5)、`GC_MALL_SET`(0x0842)/`GC_MALL_DEL`(0x0843) 复用 `GCItemSet`/`GCItemDel`,全 static_assert。`GameClient``send_open_private_shop(sign, vector<MyShopItem>)`(打包头+N项)/ `send_close_private_shop`(=`SHOP_CG_END`) / `send_mall_checkout` / `send_cube_make|material_info|result_list``/cube make|mInfo|rList``send_chat(CHAT_TYPE_COMMAND,…)`)。`EntityStore``m_mall[135]` + `mall_open/size/slot/dirty``GC_CHAT``type==CHAT_TYPE_COMMAND` 不进聊天,转 `apply_server_command()` 解析 `cube open/close/info/success/fail/r_list/m_info``v,c` 结果列表 + `@`配方 `&``|``/`金币)→ `CubeState{open,npc,recipes[],results[],need_*}` + `drain_cube_events()``M2Client``open/close_private_shop``get_mall_items`/`mall_checkout``get_cube`/`cube_make`/`cube_request_result_list`/`cube_request_materials` + `mall_opened`/`mall_changed`/`cube_opened`/`cube_closed`/`cube_changed`/`cube_result` 信号。`ui/private_shop_ui.gd`(招牌 + 背包挑件 + 定价 + 개설/철수)、`ui/mall_ui.gd`(列表 + 取出到背包空格)、`ui/cube_ui.gd`(配方列表 + 材料/金币 + 제작);`game_scene` 装配三窗。`net.entity_store`(合成 GC_MALL_* + `GC_CHAT/COMMAND` cube 全家桶)+ `net.loopback_flow`(真 socketserver 推 mall+cubeclient 发 `CG_MYSHOP`/`CG_MALL_CHECKOUT`/`/cube` 字节校验)+ `shop_cube_mall_test.gd`(三窗 FakeClient)全过。**真机验证(2026-08-31`net_e2e MT_E2E_MYSHOP=1`**`CG_MYSHOP`(0x0802) 38B head + 13B item×1vnum 149price 99999999)被真服接受,无断连 / 无 shop error`SHOP_CG_END` 关店正常。Cube `/cube rList``CG_MALL_CHECKOUT` 送出路径不断连,但服务器只在玩家站到对应 NPC 前才回应,`mall_open=0` / `cube open=0` —— 需 NPC 交互才能完整验。⬜ `GC_SHOP_SIGN`/`SYMBOL_DATA` 头顶招牌、他人开店 `START_EX` 多标签、39 格拖放网格 / 鼠标物品态、能量条、NPC 前完整验 cube/mall。**拍卖行**m2dev-client 无此系统(`0x08xx` 只有 shop/myshop/safebox/mall),非移植项,需自定义协议 + 服务端 | `extension/src/net/{wire.h,game_client.h,entity_store.{h,cpp},m2_client.{h,cpp}}` · `project/ui/{private_shop,mall,cube}_ui.gd` |
| **P9 世界系统(首版)** | 🟡 `wire.h` `GCWarp`(18) / `GCTime`(12) / `GCChannel`(5) / `GCNPCPosition`(6+78×n) / `GCTargetCreate/Update/Delete`(46/16/8) / `GCMount`(21),全 static_assert。`EntityStore``WarpCue` + `drain_warps``server_time`/`channel` + dirty、`m_npc_marks` / `m_markers` map、`Entity.mount_vnum` + `drain_mount_changes``M2Client``get_channel/get_server_time/get_npc_marks/get_world_markers` + `warp`/`time_changed`/`channel_changed`/`npc_marks_changed`/`world_markers_changed`/`mount_changed` 信号。`ui/minimap.gd`(右上圆形,北朝上,实体/NPC/任务标记 + 玩家箭头 + CH 角标)+ `world/world_time.gd`epoch → 太阳角度/光色/ambient 昼夜)+ `fx/weather.gd`(雪/雨 GPUParticles 跟相机)。`game_scene._on_warp` 同服挪人。`p9_test.gd` + `net.entity_store` 扩测全过 | `extension/src/net/*` · `project/ui/minimap.gd` · `project/world/world_time.gd` · `project/fx/weather.gd` · `project/game_scene.gd` |
| **P10 网络补全(首版)** | 🟢 `wire.h` `CGClientVersion`(0x000D70B)。`GameClient``GC_MAIN_CHARACTER` → 自动发 `CG_CLIENT_VERSION` = **打通 LOADING→PHASE_GAME**(真机验证进游戏 + 121 实体)。`net/serverinfo.gd``class_name ServerInfo`,TSV 服务器表 + 频道端口偏移)+ `ui/loading_screen.gd`(相位驱动读取遮罩)+ `ui/reconnect_ui.gd`(断线倒计时 + 自动/手动重连)+ `app_flow.gd`LOGIN→SELECT→GAME 串场状态机,单 `M2Client` 贯穿)。attack CRC 全 0 实测服务器接受。`net_e2e` 加 move+attack poke`net.loopback_flow` 断言自动版本报文;`p10_test.gd` 全过 | `extension/src/net/{wire.h,game_client.h}` · `extension/tools/net_e2e.cpp` · `project/{app_flow,net/serverinfo}.gd` · `project/ui/{loading_screen,reconnect_ui}.gd` |
| 大地图窗 / 地形贴花 / 钓鱼 / 副本 / 坐骑换模型 / 跨服换图 / 频道人数轮询 / 真机字节校准 | ⬜ 见 [`CLIENT-ROADMAP.md`](./CLIENT-ROADMAP.md) P9·P10 剩余 | — |
| 物品 / 装备(`item_proto` + 背包封包 + 装备挂模型 → 解锁 §2.7 高光 / §2.3 时装) | ⬜ | — |
## 3. 连通性(2026-08-30:真机端到端打通到 PHASE_GAME
服务端 = **m2dev-fork 协议**`net_e2e``extension/tools/net_e2e.cpp``build/extension/net_e2e`
驱动 auth→game→选人→进游戏全程;`MT_NET_TRACE=1` 打印每包 header/length/consumed。
**真机结果 —— 全程打通**
- ✅ auth 握手(libsodium KX+ `CG_LOGIN3``GC_AUTH_SUCCESS`
- ✅ game `CG_LOGIN2`**`GC_LOGIN_SUCCESS4`(0x01054 槽)** 角色列表(`[SA]Admin` lv105
-`CG_CHARACTER_SELECT``GC_PHASE(LOADING)` → 整包洪流全部按正确字节解析
-**`GC_MAIN_CHARACTER` 后自动发 `CG_CLIENT_VERSION`(0x000D)** → `GC_PHASE(GAME)` / **IN GAME**
- ✅ 进游戏后:121 个实体、HP 14990/15178、level 105、20 格背包、好友列表 1 人
-`CG_MOVE` + `CG_ATTACK`CRC 全 0)保持连接不被踢 → **不需要占位 CRC**
**修的 3 个真 bug(对任何真服务器都必需)**
1. `GC_LOGIN_SUCCESS4`(0x0105) 没处理(只认 3 槽的)→ 加 `GCLoginSuccess4`(492B) + handler。
2. 帧分发器 "did not consume" 误判:连续同 header 的包(一串 `GC_PLAYER_POINT_CHANGE`)会误杀连接
→ 改成按**实际消费字节数**判定(`net_stream.cpp` dispatch())。
3. **缺 `CG_CLIENT_VERSION`**:服务器收到 `GC_MAIN_CHARACTER` 后等客户端回版本报文,缺它就在
phase 4 断线 → `GameClient` 收到 `GC_MAIN_CHARACTER` 时自动发(fork 固定 timestamp `1215955205`)。
**真机字节校准(2026-08-30,已做)** —— 用 `MT_NET_DUMP=1 net_e2e` 抓真实封包逐字节对:
- **实体名 / 等级 / 公会 / 骑乘** ✅ 走 `GC_CHAR_ADD_INFO`(0x0207`TPacketGCCharacterAdditionalInfo`
97B),之前根本没处理。加 `GCCharAddInfo` struct + handlerNPC「Old Man」(race 20009) 名字正确出。
- **`GC_CHARACTER_UPDATE`**(0x020938B) ✅ 之前只粗解 parts;现按 `TPacketGCCharacterUpdate` 全解
parts / guild / alignment / pk_mode / mount`GCCharacterUpdate` struct)。
- **`ch_type`**`GC_CHARACTER_ADD.bType` 对 NPC/怪都是 0(服务器不填)。改在 GDScript 按 `race`
`mob_proto.type` 分类(`net_play._entity_kind`0 怪 / 1 NPC / 2 石头 / 3 warp),`_entity_name`
怪名回退 `mob_proto.locale_name``game_scene` 现在也载 `mob_proto`
- **`GC_PLAYER_POINTS`**(0x02141024B) —— **本来就对的**。真机 gold=13.5 亿(GM 号)、exp=0、
next_exp=int32 溢出为负都是服务器真实值。`M2Client.get_points` 把 exp/next_exp 按 unsigned 输出。
**第二轮校准(2026-08-30,已做)** —— 又抓了 in-game 洪流里没对过的包:
- **`GC_SKILL_LEVEL_NEW`**(0x021B1534B) ✅ 这才是本服发的技能包(**没发老的 0x021A**),之前
`get_skills()` 永远空、K 键技能窗没内容、快捷栏没法施放。加 `PlayerSkill{u8 master, u8 level,
u32 next_read}`6B/条,fork 的 `time_t` 是 32 位)+ `GCSkillLevelNew` + handler。真机实测:33 个
技能全 lvl 40 master 3GM 号)。`get_skills()``master`0 普/1 M/2 G/3 P)。
- **`GC_QUICKSLOT_ADD/DEL/SWAP`**(0x0519/1A/1B) ✅ 之前没处理 → 快捷栏进游戏是空的。加
`QuickSlot{u8 type,u8 position}` + 3 个 handler + `M2Client.get_quickslots()` + `quickslots_changed`
`ui/quickbar.gd.restore_from_server()` 按服务器内容填 1-8 号格(type 1 道具 / 2 技能)。真机 10 格。
- **`GC_NPC_POSITION`**(0x0A50) —— struct **本来就对**6 + 78×nname[65])。真机 49 条 NPC,名字对
(服务器不 zero-pad name buffer`strnlen` 已兜住)。
- **`GC_ITEM_UPDATE`**(0x051441B) / **`GC_CHAT`**(0x060310B 头) / **`GC_AFFECT_ADD`**(0x0A2025B)
/ **`GC_SPECIAL_EFFECT`**(0x0A309B) / **`GC_QUEST_INFO`**(0x09127B) —— 逐字节核对,**全部已对**。
- **`GC_GUILD`**(0x0730) sub-header 包(grade/info/list)已解析(见 M4 行);会徽走 `MARK_*` 独立连接。
**校准后续(2026-08-30,已做)**
- **3D 名字标签刷新** ✅ `EntityStore``ChangeKind::Info``GC_CHAR_ADD_INFO` 到达时对已存在实体
`Info` 变更 → `M2Client.entity_info(vid, dict)` 信号 → `net_world._on_info` 刷 Label3D。
`net_world.name_resolver``game_scene` 设为 `net_play._entity_name`)让怪名走 `mob_proto`
- **跨服换图 `GC_WARP` addr≠0** ✅ `M2Client``WarpCue.addr` 是网络序 IPv4,转点分十进制后
`warp_to_game_server(host, port)` —— 拆掉当前 game 连接、复用存下的 `login_key` 直连新 game
server(跳过 auth)。同服 warp 仍只挪玩家。
- **断点快连** ✅ `M2Client.reconnect()` 现在优先用 `last_login_key` 直连 game server(省一次
auth 往返),失败再走完整 auth→game。服务器重发 spawn = 断点续连。
- **频道负载查询** ✅ `net/channel_status.gd`(对齐 `CServerStateChecker`):明文 TCP 连任一频道口
→ 发 `CG_STATE_CHECKER`(0x000Flen 4) → 跳过杂包到 `GC_RESPOND_CHANNELSTATUS`(0x0010) →
`int32 count` + count×`{i16 port, u8 status}`。status0 关 / 1 正常 / 2 拥挤 / 3 爆满
(Metin2 协议里就是 4 态,不是在线人数)。`app_flow._probe_channels` 先查状态、没答上的频道回退
`_probe_tcp`,下拉项标 ●/◐/✕。**真机实测**:`192.168.21.203:11011``{11011:1, 11012:1,
11013:1, 11991:1}` —— 也据此把 `serverinfo` 的频道端口步长从 +10 改成 **+1**(可在 serverlist.txt
第 7 列 `port_step` 按服覆盖)。`channel_status_test`(进程内 mock 服务器:请求字节 + 跳杂包 +
解析 + 不可达返回空)。
- **怪 / NPC 真 3D 模型** ✅ `ui/mob_view.gd`(对齐 `player_view.gd`):`race` = `mob_proto` vnum →
**`root/npclist.txt`**`<vnum>\t<代号>\t…`,如 `113→bear_brown``185→tiger_big``102→wolf`
翻译版 `mob_proto``szName` 也本地化成 "Brown Bear" 了,解不出目录,必须靠这张表,`mob_proto`
名仅作兜底)→ 目录按代号逐段回退(`bear_brown → bear`),网格 / 贴图用完整代号
(换肤网格都在基础目录里:`bear/bear_brown.gr2` + `bear/bear_brown.dds`)→ 进 `Metin2Model`
texture_dir = 目录)+ 解 `motlist.txt``Metin2AnimPlayer`。扫
`<assets>/*/ymir work/{monster,monster2,npc,npc2}/<folder>/`。npclist 解析一次进 `static` 缓存。
`set_anim_state("wait/run/attack/damage/dead/skill")` 按 motlist 动作名映射。
`game_scene._make_entity_model` 设为 `net_world` 的默认工厂(非主角 + 有 mob_proto 时);race→是否有模型
缓存,失败回退占位胶囊。`net_world._attach_nameplate` 抽出来,真模型也挂头顶名字 + HP 条。
`mob_view_test`race 102 Wolf → `wolf/` 目录、Metin2Model40 骨 762 顶点)、motlist ≥3 动作、状态切换。
实测进游戏「Alpha Grey Wolf」「Hungry Black Bear」等出真模型 + 贴图 + 动作 + 名字牌 / 血条。
- **重连 / 已在局内时补拉实体** ✅ `game_scene.setup()` 现在是协程(分帧避免阻塞漏 PONG),
`net_world.setup()``entity_spawned` 已晚于 `M2Client` 的初始 spawn 洪流 → `_on_spawn` 0 次触发。
修:`net_world.catch_up()` 遍历 `client.get_entities()` 补 spawn 未镜像的;`game_scene` 已在局内分支里
**先** `net_world.set_local_vid(get_main_vid())` + `net_play._on_main_set(get_main_vid())`
`catch_up()`,否则本地玩家会被画成一个压在身上的大蓝胶囊。
**剩的都不是代码问题**:字面「在线人数」(数字)Metin2 协议里没有 —— `bStatus` 就是 4 态负载指示,
要真数字得改服务器;本地 `assets/` 只有 ~85 个 monster 目录(不是全量),没目录的 race
仍是占位胶囊 —— 换全量资产包即可全部出真模型。
**联调路上修的两个真 bug**(对任何真实服务器都必需):
1. **`GC_LOGIN_SUCCESS4`(0x0105) 没处理** —— 只认 3 槽的 `GC_LOGIN_SUCCESS3`。已加 `GCLoginSuccess4`
结构(492B) + handler。
2. **帧分发器误杀连接** —— 连续同 header 的封包(如一串 `GC_PLAYER_POINT_CHANGE`)会触发
"on_packet did not consume" 误判并断线。改成按**实际消费字节数**判定
`net_stream.cpp` dispatch())。此 bug 之前会让 P0–P8 对任何真服务器的端到端全挂。
**当前状态**:P0–P8 的封包层仍以合成封包 CTest + FakeClient GDScript 测试离线验证(14 套 GDScript +
9 CTest 全绿)。真机端到端到 PHASE_GAME 的最后一跳留给 P10。
## 3b. B 轨(不依赖服务端的客户端系统)
| 模块 | 状态 | 位置 |
|---|---|---|
| **eterpack 读写器**G1| ✅ 本 fork = `PackLib` 单文件包(header + XChaCha20 加密索引 + zstd 数据 + 可选逐文件加密,硬编码 `PACK_KEY`)。`EterPack`(读,name-field 从 header 反推兼容真实包)+ `write_pack` + `packtool` CLI。`pack.roundtrip` CTest + 真实 `textureset/` 45 文件明文/加密往返 `diff` 一致 | `extension/src/pack/eterpack.{h,cpp}``extension/tools/packtool.cpp` |
| **登录 / 选人界面**(首版)| ✅ `charselect.gd`3 旋转台 × 真实职业模型(`<class>_novice.gr2` + `wait.msa`+ 真实 `board` 九宫格面板(槽位按钮 + 进入游戏)。数据来自 `M2Client.char_list`(联机)或 `--mock`(离线开发)。`login.gd`:服务器/账号/密码 + 连接,接全部 `M2Client` 信号(`--auto` 测试)。`ui_kit.gd`:从 `hud.gd` 抽出的九宫格助手。⬜ 创建/删除角色、旋转台微调、login→charselect→world 串场 | `project/{login,charselect,ui_kit}.gd` |
| **正式选人页(Metin2 风)** | ✅ `ui/char_select_screen.gd``app_flow._build_char_list` 用它替掉原来的裸按钮列表。全屏背景 `d:/ymir work/ui/intro/select/select.sub`=select.jpg+ 右侧 `SubViewport``own_world_3d`)里 `PlayerView` 真模型(正面朝相机 + `wait` + 缓摆)+ 左上职业名(locale `name_<class>.sub``select.dds`DDS 走 `Metin2World.load_dds`;解不出退中文描边字 猛将/刺客/术士/巫女)+ 左侧 `board` 信息板(帮会徽 `get_guild_mark_image` / **国家名 `get_empire()`→`EMPIRE_A/B/C` locale + 国旗 `empireflag_<a/b/c>.sub`** / 帮会名 / 名称 / 等级 / 游戏时间 / 体力·智力·力量·敏捷 条)+ 开始 / 创建 / 删除 / 退出 + 多角色 ◀ ▶ 槽位切换(`_pad_slots``get_slot_count()` 补空槽,◀▶ 能走到空槽建号)。**建号 / 删号(1:1 对齐 `PythonNetworkStreamPhaseSelect.cpp`**`CG_CHARACTER_CREATE`(0x0201) `CGCreateCharacter`(77job 是 **u16**)、`CG_CHARACTER_DELETE`(0x0202) `CGDeleteCharacter`(13`private_code[8]`)、`GC_PLAYER_CREATE_SUCCESS`(0x020C) `GCPlayerCreateSuccess`(108=`{u8 slot, SimplePlayerInfo}`)、`GC_PLAYER_CREATE_FAILURE`(0x020D) `{u8 type}``GC_PLAYER_DELETE_SUCCESS`(0x020E) `{u8 slot}``GC_PLAYER_DELETE_WRONG_SOCIAL_ID`(0x020F)。`GameClient``create_character`/`delete_character` + `drain_char_events()``CharEvent{CreateOk/CreateFail/DeleteOk/DeleteFail}`+ `replace_slot`/`clear_slot`(服务器只补丁槽位不重发列表)+ `slot_count()``M2Client``create_character(slot,name,job,shape,con,int,str,dex)`/`delete_character(slot,code)`/`get_empire`/`get_slot_count` + `char_created`/`char_create_failed`/`char_deleted`/`char_delete_failed` 信号(建 / 删成功后重发 `char_list`)。选人页建号弹窗(职业下拉 + 名称 + 各职业起始四维只读)、删号弹窗(secret 删除码);`app_flow._on_char_list` 在 SELECT 态只 `set_chars()` 不整屏重建。会徽:`app_flow._goto_select` 也发 `download_guild_marks``guild_marks_ready`(one-shot) → `refresh_crest()`。相机改按**包围球** `r/sin(fovV/2)` 自适应 + 沿 -X 推 `r*0.5`(横向视锥更宽,不切远侧手),蒙皮定型后多 fit 4 帧。C++:`CharSlot` + `char_list` dict 补 `play_minutes/st/ht/dx/iq/main_part/hair_part``ui_assets.gd``load_dds_image()``net.loopback_flow` MockServer 加 `CG_CHARACTER_CREATE/DELETE` 真 socket 端到端(名称字节校验 + 成功补丁槽位 + 名称冲突 fail + 错误删除码拒绝);`char_create_delete_test.gd`(弹窗 → 假 client 收 `create_character(slot1,"Newbie",job1,con3/int3/str4/dex6)` + 短名拒绝 + `char_created` 关窗 + 删号码 + 失败清框 + `set_chars` 保持选中);`p10_test` 选人断言改点「开始」 | `project/ui/char_select_screen.gd` · `project/ui/ui_assets.gd` · `project/app_flow.gd` · `extension/src/net/{wire.h,game_client.h,m2_client.{h,cpp}}` · `extension/tests/net_loopback_test.cpp` |
| **角色状态窗(1:1 窗口迁移样板)** | ✅ `project/ui/char_status_ui.gd``UiScript`+`UiBuild` 直接装载 `assets/uiscript/uiscript/characterwindow.py` 真布局(4 页 tabSTATUS/SKILL/EMOTICON/QUEST`SetState` 切页;技能 / 表情 / 任务的数据仍走各自专用窗)。STATUS 页数值逐字对照 `assets/root/uicharacter.py.RefreshStatus``Level_Value`=`points[LEVEL]``Exp_Value`=`u32(exp)``RestExp_Value`=`u32(next_exp)-u32(exp)``HP/SP_Value`=`cur/max``STR/DEX/HTH/INT_Value`=`points[ST/DX/HT/IQ]``ATT_Value`=`(MIN_ATK|MAX_ATK)+ATT_GRADE_BONUS+PARTY_ATT_GRADE`min==max 取单值,全 0 退 `ATT_POWER`)、`DEF_Value`=`DEF_GRADE(+DEF_GRADE_BONUS 若非 0)``MATT_Value`=`MAGIC_ATT_GRADE+(MIN|MAX)_MAGIC_WEP``MDEF_Value`=`MAGIC_DEF_GRADE``ASPD/MSPD/CSPD/ER_Value`=`points[ATT_SPEED/MOV_SPEED/CASTING_SPEED/EVADE_RATE]`EPointTypes 索引取自 m2dev `src/UserInterface/Packet.h`)。加点:`HTH/INT/STR/DEX``_Plus`/`_Minus` 按钮 → `client.say(0, "/stat ht"|"/stat- ht" …)`,与原 `statusPlusCommandDict`/`statusMinusCommandDict` 完全一致;`Status_Plus_Value`=`points[STAT]``>0` 才显示 `_Plus` 按钮和 `Status_Plus_Label`。名称 / 帮会 / 职业头像取 `get_entity(get_main_vid())``race%4``face_warrior/assassin/sura/shaman.sub`)。`points_changed` 信号刷新。`game_scene``char_status_ui` 节点 + V / C 热键。`char_status_ui_test.gd`:假 client 灌 points → 断言全部 Value label 文本 + `/stat` 命令 + `STAT==0` 隐藏加点按钮 + tab 切换页可见性。后续选道具网格 / 商店 START_EX / 私人商店 39 格照抄这套(uiscript 装载 + 逐字数值绑定 + 原聊天命令 / 封包)| `project/ui/char_status_ui.gd` · `project/game_scene.gd` · `project/char_status_ui_test.gd` |
| **组队成员信息板(`uiparty.PartyMemberInfoBoard`** | ✅ `wire.h``CGPartySetState{u16 hdr,u16 len,u32 pid,u8 role,u8 on}`static_assert 10+ `EPartyRole` 枚举(NORMAL 0 / LEADER 1 / ATTACKER 2 / TANKER 3 / BUFFER 4 / SKILL_MASTER 5 / BERSERKER 6 / DEFENDER 7PythonPlayer.h)。`game_client.send_party_set_state(pid,role,on)``CG_PARTY_SET_STATE` 0x0704,注意结构字段名 `dwVID` 实际传 PID —— 对齐 `uiparty.py OnSelectState``net.SendPartySetStatePacket(self.pid,...)`)。`M2Client.party_set_state(pid,role,on)` + bind`get_party()` dict 补 `state`(完整角色字节,bit0=leader+ `affects[7]`int×7)。`project/ui/party_ui.gd` 从「名字+一条 HP」重做成 `partymemberinfoboard.py` 布局(每员 106×36 条:StateButton 22px 角色图标 + NameSlot + Gauge + 附加图标行):`_strip(m)` → 角色状态按钮(`_local_is_leader()` 才可点 → `_open_role_popup` 弹 普通/攻击/坦克/狂战/辅助/宗师/防御 + 踢出;普通=`party_set_state(pid, cur_role, false)` 清角色;踢出=`party_leave(pid)`=`SendPartyRemovePacket`+ 名字(★队长) + HP `ProgressBar` + `affects[i]!=0` → chip`AFFECT_LABEL` 暂定 经验/攻击/防御/辅助/宗师/时间/回复,tooltip 带值)。点名字 → `set_target(vid)``OnMouseLeftButtonDown` 选中)。header:EXP 分配开关 `_toggle_distribute``party_set_distribute``EPartyExpDistributionType` 0 NON_PARITY / 1 PARITY+ 组队治疗 `_party_heal``party_use_skill(PARTY_SKILL_HEAL=1, 0)``_local_is_leader()` = `get_party()``vid == get_main_vid()` 那条的 `leader`。角色菜单按党技能等级门控(`uiparty.__ShowStateButton` Tanker≥10…Defender≥40)暂略。`p8_test` 组队段扩:affect chip 渲染 + 队长点 StateButton 弹菜单 + 「攻击」→ `party_set_state(pid, 2, true)` + 踢出 → `party_leave` + 分配切换 → `party_set_distribute(1)` + 治疗 → `party_use_skill(1,0)` + 点名字 → `set_target` + 非队长 StateButton disabled。**顺带修 `p8_test._init``await _run()` 的隐患**(第一个 `await` 之后所有断言从未生效,含增量 34 `shop_ex`)→ 改 `await` 后暴露 `shop_ui.refresh()` `queue_free` 延迟 bug(旧 row 当帧未消失),改 `remove_child`+`queue_free` | `extension/src/net/{wire.h,game_client.h,m2_client.{h,cpp}}` · `project/ui/party_ui.gd` · `project/ui/shop_ui.gd` · `project/p8_test.gd` |
| **ESC 系统菜单 + 游戏设置窗(`uisystem.SystemDialog` + `uigameoption.OptionDialog`** | ✅ `project/ui/system_menu_ui.gd``systemdialog.py` 真 uiscriptthinboard 8 键)= `uisystem.py __LoadSystemMenu_Default` 1:1`system_option_button``system_option_ui.open()``game_option_button``game_option_ui.open()``change_button``client.say(0,"/phase_select")``net.ExitGame()` 在有 ping 时发 `SendChatPacket("/phase_select")`),`logout_button``"/logout"``net.LogOutGame()`),`mall_button``"/in_game_mall"``exit_button``get_tree().quit()``help_button`→占位 `toast``cancel_button`/标题栏→关。modal 打开。`game_scene`ESC 从直接 `system_option_ui.toggle()` 改成 `system_menu_ui.toggle()`。<br>`project/ui/game_option_ui.gd``gameoptiondialog.py` 真 uiscript= `uigameoption.py``BLOCK_BITS` = `block_{exchange,party,guild,whisper,friend,party_request}_button`(toggle_button) → `_toggle_block(bit)``_block_mode ^= bit` + `client.say(0, "/setblockmode %d" % _block_mode)``Packet.h EBlockAction``1<<0..1<<5`,逐字对照 `__OnClickBlock*``net.SendChatPacket("/setblockmode " + str(blockMode ^ player.BLOCK_*))`+ 持久化 `[gameopt] block_mode`(服务器回包同步待补)。`PK_CMD` = `pvp_{peace,revenge,free,guild}`(radio) → `client.say(0,"/pkmode %d")``{0,1,2,4}``__OnClickPvPMode*`guild 是 `/pkmode 4``PK_MODE_PROTECT`=3 跳过)。`DISPLAY_RADIOS``name_color`/`target_board`/`view_chat`/`always_show_name`/`show_damage`/`salestext` 的 on/off/normal/empire radio → `_display[key]` + 持久化 `user://system_option.cfg [gameopt]`(等价 `systemSetting.Set*Flag`;渲染侧钩子——头顶名字配色 / 伤害数字 / 聊天显隐——待补)。`_bind_radio` 处理 radio 배타성,`_sync`/`_sync_block` 开窗回填,`_relabel` 中文兜底(uiScriptLocale 未接全局 Locale)。`system_menu_ui_test.gd`:两窗装载 + `/setblockmode 1``3``2`(位 XOR+ `_block_mode` 跟踪 + `/pkmode 1`/`4` + radio 배타 + 显示开关落 cfg + 二次实例读 `block_mode` 回填按钮态 + menu 各键 `/in_game_mall`/`/phase_select`/`/logout` + `system_option_button`/`game_option_button` 开子窗关菜单 + `cancel` 关。C++ 无改动 | `project/ui/system_menu_ui.gd` · `project/ui/game_option_ui.gd` · `project/game_scene.gd` · `project/system_menu_ui_test.gd` |
| **系统设置窗(`uisystemoption.OptionDialog`** | ✅ `project/ui/system_option_ui.gd``UiScript`+`UiBuild``assets/uiscript/uiscript/systemoptiondialog.py`board + titlebar + `music/sound_volume_controller` sliderbar + `camera_short/long` + `fog_level0/1/2` + `tiling_cpu/gpu` + `tiling_apply` + `bgm_button`)。控件绑定逐字对照 `assets/root/uisystemoption.py``OnChangeMusicVolume``snd.SetMusicVolume`+`systemSetting.SetMusicVolume`(接 `Audio.master_bgm` + 即时改在播 `_bgm[_bgm_cur].volume_db`),`OnChangeSoundVolume``Audio.master_sfx``play_ui`/`play_at` 每次读),`camera_short/long``game_camera.max_dist``CAMERA_MAX=[11,20]`,并 `dist=min(dist,max)`),`fog_level0/1/2`(浓/중/淡)→`__SetFogLevel` = `Environment.fog_enabled=true`+`fog_density=[0.055,0.018,0.004][i]``tiling_*`/`tiling_apply`(原 CPU/GPU 分块渲染 + `net.ExitGame` 重启)Godot 渲染器无对应 → 保留占位。`_bind_radio` 处理 radio 배타성。持久化 `user://system_option.cfg`section `audio`/`video`= `systemSetting` 配置文件);`setup()` 读回并 `_apply_all()` 即时生效;`game_scene._on_main_set``.msenv``Environment` 后再 `_apply_all()``game_scene`ESC 呼出(`ui_manager._unhandled_input``close_top()` 吃事件,无窗时才落到 `game_scene`)。uiScriptLocale 未接全局 Locale → `LABELS` 中文兜底。`system_option_ui_test.gd`:装载 + `music/sound` slider `value_changed` 已连 + `_on_*_volume` 应用到 `master_bgm/sfx` + camera radio→`max_dist`+dist 夹 + fog radio→`fog_density` + cfg 落盘 + 二次实例读 cfg 即时应用。C++ 无改动 | `project/ui/system_option_ui.gd` · `project/game_scene.gd` · `project/system_option_ui_test.gd` |
| **私人商店开设窗(`PrivateShopBuilder` 模型)** | ✅ `project/ui/private_shop_ui.gd` 从「前 5 行 checkbox」重做成 1:1 复刻 `assets/root/uiprivateshopbuilder.py`(**增量 36:布局改走真 uiscript `privateshopbuilder.py` —— board + TitleBar + `NameLine`(盖 LineEdit) + `ItemSlot` 5×8=40 grid + Ok/Close;背包候选面板挂窗右侧**):`_stock` = `itemStock``{格号 -> {cell,vnum,count,price}}`)。左侧 `_inv_list` 背包候选(排除已上货的 cell),点一件 → `_pick``_picked`= `mouseModule` attached item);点右侧 40 格(`GRID_SLOTS=40` = `shop.SHOP_SLOT_COUNT`5×8)空格 → `_ask_price``uiCommon.MoneyInputDialog``SpinBox`)→ `_place(slot, price)``_stock`= `AddPrivateShopItemStock` + `itemStock[targetSlotPos]=(src)`);点已占用格 → `_stock.erase` = `OnSelectItemSlot` / `DelPrivateShopItemStock`)。`_ok()`OkButton → `BuildPrivateShop`):`_stock` 按格号 `sort()``display_pos = 格号``items.size() >= PRIVATE_SHOP_ITEM_MAX(39)` 截断 → `client.open_private_shop(sign, [{vnum,count,inv_cell,price,display_pos}])``MyShopItem`/`TShopItemTable` 字段 1:1`{u32 vnum, u8 count, ItemPos pos, u32 price, u8 display_pos}`)。`_close_shop()` = `close_private_shop()` = `SHOP_CG_END`。测试拆成独立 `private_shop_ui_test.gd`(真 uiscript,缺资产跳过):`_cells` 40 格 / `NameLine` 盖 LineEdit / 2 候选 / 空 stock 不发 / 拿起→价格弹窗→`_place` / 개설按格号排序(格 0 在格 3 前)+ `display_pos` / 点占用格撤下 / 철수;`shop_cube_mall_test` 私人商店段移除。C++ 无改动(`open_private_shop` / `MyShopItem` 早已就位)| `project/ui/private_shop_ui.gd` · `project/game_scene.gd` · `project/private_shop_ui_test.gd` · `project/shop_cube_mall_test.gd` |
| **`SHOP_GC_START_EX` 多货架商店** | ✅ `wire.h``ShopTabHead{char name[SHOP_TAB_NAME_MAX=32]; u8 coin_type}`static_assert 33+ `SHOP_TAB_COUNT_MAX=3``entity_store``SHOP_GC_START_EX`(10) 解析 `TPacketGCShopStartEx{u32 owner_vid; u8 tab_count}` + `tab_count` × `{ShopTabHead, ShopItem[SHOP_HOST_ITEM_MAX_NUM=40]}`(对齐 `PythonNetworkStreamPhaseGame.cpp RecvShopSub_StartEx`)。`ShopEntry``u8 pos`(货架内槽位);`ShopTab{name, coin_type, items}` + `m_shop_tabs``SHOP_GC_START` 也改为按固定 40-槽数组填 `pos`(空槽计入,与参考 `SetItemData(j,...)` 一致);`SHOP_GC_END``m_shop_tabs``shop_items()` 保持是 `m_shop_tabs[0].items` 的镜像,旧 `get_shop_items()` 不动。`M2Client.get_shop()``{vid, open, tabs:[{name, coin_type, items:[{pos,vnum,price,count}]}]}``shop_ui.gd``tabs.size()>1` 时显示货架 `Button` 行,切货架重建列表;买位置 = `_active_tab * SHOP_SLOT_COUNT(40) + it.pos`1:1 对齐 `uishop.py GetIndexFromSlotPos` = `tabIdx * shop.SHOP_SLOT_COUNT + slotPos``shop_buy``u8 pos` 容得下 3×40)。`net_entity_test`:双货架合成包 → 断言名称 / coin_type / 槽位保留 / tab0 镜像 / END 清空;`p8_test`:多货架 UI + 切页 + `buy pos 5`tab0 slot5/ `buy pos 43`tab1 slot3 | `extension/src/net/{wire.h,entity_store.{h,cpp},m2_client.{h,cpp}}` · `project/ui/shop_ui.gd` · `extension/tests/net_entity_test.cpp` · `project/p8_test.gd` |
| **选魔石窗(样板第 2 例)** | ✅ `project/ui/select_item_ui.gd``UiScript`+`UiBuild``assets/uiscript/uiscript/selectitemwindow.py``ItemSlot` = 5×8 `grid_table``ui_build._slot_cells``x_count/start_index/x_step/y_step` 生成 `slot_0..39`)。填格逐字照 `assets/root/uiselectitem.py.RefreshSlot`:遍历背包 `range(INVENTORY_PAGE_SIZE*2=90)` 格,`_is_metin``proto.item(vnum).type == 10 = ITEM_TYPE_METIN`+ `_item_grade(vnum) <= 2`= `proto.item(vnum).name` **内部名**最后一位数字,非数字按 0,复刻 `PythonPlayerModule.GetItemGrade`),`slotPos > 54` 截断,按背包格号升序映射到选择窗格 `_slot_to_inv[slotPos]=invCell`。点格 → `client.script_select_item(invCell)``send_script_select_item` → `CGScriptSelectItem{CG_SCRIPT_SELECT_ITEM 0x0903, len, selection}`= `net.SendSelectItemPacket`+ 关窗;`ExitButton` / 标题栏关闭 → `script_select_item(0)``uiselectitem.Close`),`_sent` 标记避免选完再补发 0。**触发链 1:1**`quest_dialog.gd.parse_script` 新识别 EventManager `[SELECT_ITEM]` token → `has_select_item``_on_script` 发新 `select_item_requested` 信号(对应 `PythonEventManager EVENT_TYPE_SELECT_ITEM``interfacemodule.BINARY_OpenSelectItemWindow``wndItemSelect.Open()`);`game_scene``proto`/`item_list` 就绪后建 `select_item_ui` 并把该信号接到 `.open``select_item_ui_test.gd``[SELECT_ITEM]` token 解析 + 非 metin / grade>2 过滤 + 升序映射 + 点格发 `script_select_item(7)` + 关窗不补发 + 空手关窗发 `script_select_item(0)` | `project/ui/select_item_ui.gd` · `project/ui/quest_dialog.gd` · `project/game_scene.gd` · `project/select_item_ui_test.gd` |
| **`item_proto` / `mob_proto` 读取器** | ✅ `mtproto::load_proto` —— 外层 `MIPX`(item`[fourcc][ver=1][stride][elements][datasize]`) / `MMPT`(mob`[fourcc][elements][datasize]`);内层 `MCOZ` CLZO 容器 = **XChaCha20**(本 fork 把 TEA 换成 libsodiumkey=BLAKE2b(key16,"M2DevPackEncrypt")nonce=BLAKE2b(key16,"M2DevNonce")[:24])解密 + **LZO1X** 解压。两把硬编码 4-DWORD key`ItemManager.cpp` / `PythonNonPlayer.cpp`)。`ItemRecord`/`MobRecord` 解前导字段(`bSpecular`@234 → §2.7**`alValues[6]`@191** —— aLimits[2]×5 @166 + aApplies[3]×5 @176 后,`bSpecular`@234 锚定整链)。armor 的 `alValues[3]` = race `.msm` 的 body shape index(对齐客户端 `__ArmorVnumToShape` `SHAPE_VALUE_SLOT_INDEX=3`)—— 实测 "Monk Plate Armour" 11200-09 全 = shape 3specular 随强化 0→100 递增。`proto.item_mob` CTest 断言 11209 `values[3]==3` + `specular==100`。5748 itemsstride 236+ 1338 mobsstride 335 | `extension/src/proto/proto.{h,cpp}` |
| **`AssetResolver` 接包**(首版)| ✅ `mtpack::PackMount` —— 挂载多个 `.epk`,按虚拟路径(盘符 + 反斜杠 + 大小写不敏感 + `ymir work/` 后缀)resolve/read`metin2_patch_*` 包后挂覆盖。`mtpack::AssetSource` —— 散文件 + 包统一:`read()` 取字节;`to_path()` 把包内文件解到缓存目录一次(路径型调用方不用改)。散文件优先于包(dev overlay,对齐客户端 `pack/` 覆盖)。`pack.roundtrip` CTest 扩:2 包挂载 + 覆盖 + 虚拟路径 + 非 ymir 路径。⬜ 把 `Metin2World`/`Metin2Model` 等 ~6 处 `resolver->resolve()` 换成 `AssetSource::to_path()`(机械改动)| `extension/src/pack/{pack_mount,asset_source}.{h,cpp}` |
| **输入 & 游戏相机**(首版)| ✅ `game_camera.gd` = `GameCamera`:右键拖拽环绕(yaw/pitch clamp+ 滚轮缩放(3.5–20m)+ 目标平滑跟随 + 地形防穿。`player_controller.gd` = `PlayerController`:**点地移动**(射线求地表交点 → 走过去,沿途 `is_blocked``attr.atr` bit0)判可行走,贴墙磨蹭按实际位移切回 wait —— 客户端预测,无服务端纠正)+ WASD 覆盖 + **选目标**(射线取最近 pickable → `target_selected` 信号)+ `anim_state`/`moved` 信号。已换进 `world_demo.gd`(去掉 ~70 行临时相机/输入)。⬜ 相机撞建筑/树淡出(§8.2)、格子 A* 寻路、NPC 交互/拾取 | `project/{game_camera,player_controller}.gd` |
| **本地化**(首版)| ✅ `locale.gd` = `Locale`:加载 `locale/locale/<lang>/{locale_interface,locale_game,itemdesc,skilldesc}.txt``KEY\tVALUE`UTF-8)。`t(key, args)` —— `%` 格式化 + 缺失回 `<KEY>`(开发期可见);`set_lang()``apply_font()` 挂 CJK ttf。已接 `charselect``JOB_*` 职业名 / `SELECT_LEVEL` / `SELECT_SELECT`=Start / `SELECT_CREATE`+ `login``LOGIN_ID`/`LOGIN_PASSWORD`/`LOGIN_CONNECT`)。18 语言(拉丁/西里尔/希腊/阿拉伯,Godot 原生,无需打包字体;CJK 需外挂 ttf)| `project/locale.gd` |
| **声音**(首版)| ✅ `audio.gd` = `Audio` 节点:**BGM 交叉淡**2× `AudioStreamPlayer``bgm/*.mp3`+ **UI 音效池**8×,`**/sound/ui/*.wav`+ **3D 定位音效**`AudioStreamPlayer3D``**/sound/<rel>`)。路径解析走 11 个声音根 + `bgm/``.mss` 不用,Godot 原生解 mp3/wav/ogg)。已接:`charselect``characterselect` BGM + `<class>_select` 选人音;`login``loginok`/`loginfail``world_demo``Metin2AnimPlayer.motion_event``sound` 字段 → `play_at(model_pos)`(脚步/挥击)。⬜ 按地图 BGM(服务端 `GCMainCharacter.szBGMName`)、`.msenv` 环境音循环、战斗/怪物/NPC 语音 | `project/audio.gd` |
| 登录 / 选人界面 | 🟡 首版 | `project/{login,charselect,ui_kit}.gd` |
| 声音(`AudioLib`→Godot audio| 🟡 首版 | `project/audio.gd` |
| `item_proto` / `mob_proto` 读取器 | ✅ | `extension/src/proto/proto.{h,cpp}` |
| 本地化(`EterLocale`| ✅ 首版 | `project/locale.gd` |
| 输入 & 游戏相机 | 🟡 首版 | `project/{game_camera,player_controller}.gd` |
| `AssetResolver` 从包读取(接 eterpack| 🟡 | `extension/src/pack/{pack_mount,asset_source}.{h,cpp}` |
## 3c. C 轨(渲染打磨,PARITY-GAP 不卡 §0 门禁的项)
| 项 | 状态 | 位置 |
|---|---|---|
| §8.2 相机遮挡淡出 + §8.3 相机撞建筑防穿 | ✅ 首版 | `extension/src/metin2_world.cpp`(层 2 盒碰撞)+ `project/game_camera.gd` |
| §2.2 GPU 路径发型 | ✅ | `extension/src/metin2_model.{h,cpp}` |
| §2.10 角色 LOD 距离切换 | ✅ 首版 | `extension/src/metin2_model.{h,cpp}` |
| §3.5 地形 patch 裁剪 | ✅ 首版(拆 4×4 patch + 逐 patch 视锥剔除 + 远景剔除)| `extension/src/metin2_world.{h,cpp}` |
| §2.6 顶点色 | ✅ 排查=不适用(全资产 gr2 无顶点色成员;逐顶点色是地形雾运行时 VB + `.mse` colorfactor| — |
| §1.2 `.mse` EffectLib 端口 | ⬜(大工程)| — |
## 4. 下一步
1. 打通到 `.203` 的连通性(见上)。可达后立即能测:
- `./build/extension/net_probe 192.168.21.203 11000 admin 123456789` → 完整握手 + `CGLogin3``login_key`
- GDScript 里 `M2Client.connect_to_server(...)` → 一路到 `char_list` / `entered_game`
2. **`GCLoginSuccess3` 布局对真实字节校准**`net_probe` / `M2Client` 收到后 dump 原始字节,
核对 `TSimplePlayerInformation`(现假设 pack(1)、字段顺序照 `Packet.h:1161`)。size 不符会走
`login_failed` 兜底。
3. 游戏相位包解码:`GC_CHARACTER_ADD/DEL` / `GC_MOVE` / `GC_CHARACTER_UPDATE`
`NetworkActorManager` 端口 → 每实体一个 `Metin2Model`+`Metin2AnimPlayer`,用现有
`Metin2World` 当场景。里程碑「角色连上、站在地图里、别的实体在动」。
4. 输入(点地移动 → `CG_MOVE`)、聊天(`CG_CHAT`/`GC_CHAT` 接 HUD)。
## 4. 风险
- **协议版本漂移**`Packet.h` 结构体要和服务端 build 一致。冻结一对 client/server commit。
- **端到端测试阻塞**:目前握手端口 refused,走不到 `CGLogin3` 之前的验证。
- **移动端 libsodium**Android/iOS 要静态 vendor(本身可移植,非阻塞)。
- 自用范围可跳过:商城 / GM 工具 / 部分社交系统。**反作弊(`CG_HACK` / `ProcessCRC` /
`ProcessScanner`)已在 `CLIENT-GAP.md`「不做清单」标注 ❌ 不做**,除非服务端强制校验。
## 5. 移植面(客户端源码)
`EterBase/SecureCipher` · `EterLib/{NetStream,ControlPackets,NetPacketHeaderMap}` ·
`UserInterface/{Packet.h, AccountConnector, PythonNetworkStream, PythonNetworkStreamPhase*}` ·
`UserInterface/NetworkActorManager`(实体) · `GameLib`(玩法)。
+352
View File
@@ -0,0 +1,352 @@
# 客户端玩法开发计划
> 把 [`CLIENT-GAP.md`](./CLIENT-GAP.md) 的缺口排成可执行的阶段计划。
> 网络栈进度见 [`CLIENT-PORT.md`](./CLIENT-PORT.md),渲染见 [`PARITY-GAP.md`](./PARITY-GAP.md) / [`BACKLOG.md`](./BACKLOG.md)。
>
> 规模:S ≤ 3 天 · M ≈ 1 周 · L ≈ 2–3 周 · XL ≈ 1 月+
> 标记:🌐 需服务器端到端 · 💻 离线可做(mock server / 合成封包 / 假 client
> 更新日期:2026-08-30 · 自用,无发布门禁
> **P0P10 首版全部完成**`192.168.21.203` 已联通,`net_e2e` 端到端到 PHASE_GAME。里程碑见文末。
## 已就绪的地基(这些不重复做)
- net 栈:握手 / auth / login / select / game 相位,`M2Client` 编排 + 生命周期
- `EntityStore`:实体模型 + 插值 + 战斗 / 状态封包(points / vitals / damage / motion / stun / dead / target
- `NetWorld``net_world.gd`):实体→场景桥,占位胶囊 + HP 条 + 伤害飘字 + 死亡倒地
- `M2Client` GDScript 面:`move/attack/set_target/say` + `get_entities/get_entity/get_points/get_target` + 12 个信号
- 渲染节点:`Metin2Model`(骨骼 + crossfade + 武器挂点 + LOD + 换肤)、`Metin2World``Metin2AnimPlayer`
- 资产:`mtproto`item/mob_proto)、`mtpack`eterpack)、`locale.gd``audio.gd`
## 依赖关系
```mermaid
graph TD
P0["P0 闭环骨架 💻"] --> P1["P1 UI 工具层 💻"]
P0 --> P4["P4 战斗表现 🌐"]
P1 --> P2["P2 物品/背包/装备 🌐"]
P1 --> P3["P3 聊天 + TextTail 💻"]
P1 --> P8["P8 社交/商店/仓库 🌐"]
P5["P5 EffectLib 特效引擎 💻→🌐"] --> P4
P5 --> P6["P6 技能系统 🌐"]
P2 --> P6
P1 --> P6
P1 --> P7["P7 任务/NPC 🌐"]
P2 --> P9["P9 世界系统 🌐"]
P0 --> P10["P10 网络补全 🌐"]
```
P1UI 工具层)是 P2/P3/P6/P7/P8 的共同前置。P5(特效)可**任何时候并行起**
`.mse` 解析器纯离线)。
---
## P0 — 闭环骨架(M,💻)—— ✅ 离线部分完成,待服务器端到端
**目标**:现有骨架变成「点怪→走过去→挥→掉血→切目标」的可玩手感,无新系统。
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| 输入接线 | S | ✅ | `net_play.gd``pc.moved``M2Client.move(FUNC_MOVE)`(节流 >1m / >0.2s),`anim_state("wait")``move(FUNC_WAIT)`(一次),`pc.target_selected``set_target(vid)`vid 取节点 `vid` meta |
| 本地预测 + 服务器校正 | S | ✅ | `net_play._on_net_moved`:收到自己 vid 的 `entity_moved` → 差 >6m 瞬移、>0.3m lerp 0.25,高度本地贴地不信服务器 z |
| HUD 数据绑定 | S | ✅ | `hud.gd``set_vitals/set_exp/set_level/set_target/clear_target``net_play``points_changed`/`vitals_changed`。血条 / 蓝条 / 经验细条 / 等级 label |
| 目标血条 | S | ✅ | `hud._build_target_panel`(顶部居中,名字 + 180px 血条);`net_play``target_info(vid,hp%)` + 目标 `vitals_changed` 刷新;目标死亡 / despawn → `clear_target` |
| 攻击动作驱动 | S | 🟡 | `net_play`:进 2.5m 距离按 `attack_period`(0.6s) 自动 `attack(0, vid)` + 面向目标。真模型动作切换靠 `net_world``set_model_factory` + `set_anim_state("attack")`P4 细化 motion index / 攻速) |
| 坐标 / 朝向换算 | S | ✅ | Godot 米 ↔ 服务器 cm`position_to_godot` 逆)、Godot yaw ↔ Metin2 罗盘度(`net_world` 逆)|
| net_world 去重主角节点 | S | ✅ | `net_world.set_local_vid(vid)`:本地玩家由 `player_controller` 的节点代表,`net_world` 不再画一个 |
| 场景组装 | S | ✅ | `game_scene.gd`(简版):`setup(client, assets_root, map_path?)``Metin2World`(load 失败则平地兜底)+ 占位玩家 + `GameCamera` + `PlayerController` + `NetWorld` + `HUD` + `NetPlay` + `Audio` + `AppLifecycle``entity_main_set` → 主角定位出生点。`login.gd``entered_game` → 隐藏登录层 + 实例化(`--auto` 自动选槽 0)。`set_player_model()` / `set_entity_model_factory()` 留给 P2 |
**已验证**
- `netplay_test.gd` —— move 带正确 server cm + 节流、FUNC_WAIT 单发、set_target、攻击距离内自动 attack + 节流、points→HUD、远程校正瞬移、target_info→HUD、死亡清目标。
- `gamescene_test.gd` —— 装配 + 子系统接线 + 实体镜像(主角不重复画)+ 出生点定位(无地图兜底)。
- 项目导入无错,`netbridge_test` 仍过。
**未完成(仅剩服务器相关)**
- **端到端**:走真 `GC_MOVE`/`GC_PLAYER_POINTS`/`GC_TARGET_INFO``192.168.21.203` 可达。net 栈就绪,改 IP 即测。
- 攻击动作用真 `Metin2Model``set_player_model` / `model_factory` 把 race→gr2,P2 装备系统会带出来)。
- `login → charselect → game` 正式串场(现在 `--auto` 直接选槽 0`charselect.gd` 已存在,接一下即可,属 P10)。
## P1 — UI 工具层(L,💻)—— 🟡 核心完成(装载器打通全量语料)
**目标**:一层 Godot `Control` 封装对齐 `EterPythonLib`,让 P2+ 的窗口可批量搬。
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| **uiscript 装载器** | M | ✅ | `ui/uiscript.gd`:解析原始 `assets/uiscript/*.py``window={...}` dict(子集解释器:str/int/float、NAME、`uiScriptLocale.X`、算术 `+ - * /``%` 格式化、相邻字符串拼接、`SCREEN_WIDTH/HEIGHT` + 文件顶层常量、`#`/`##` 注释)。防跑飞 guard。**实测 80/80 个真实 uiscript 文件解析 + 构建全绿** |
| 控件封装 | M | 🟡 | `ui/ui_build.gd`dict → Control 树,覆盖 ~20 种 typewindow/board/thinboard/board_with_titlebar/titlebar/text/button/toggle_button/radio_button/image/expanded_image/editline/line/listbox/gauge/grid_table/slotbar/sliderbar/scrollbar+ `horizontal_align`/`vertical_align`/`text_color`(0xAARRGGBB)/按钮 default/over/down 贴图。`ui/ui_assets.gd``.sub`/`.tga`/`.png`/`.jpg``.dds` 暂不支持→降级主题)。九宫格复用 `ui_kit.board`。⬜ 少见 typeani_image 动画、mark、grid_table 内容填充、真 scrollbar 行为)按窗口逐个细化 |
| 窗口管理器 | S | ✅ | `ui/ui_manager.gd``CanvasLayer` layer=10):窗口栈、`open/close/close_top/top`、ESC 关顶层、modal 变暗层、标题栏(`titlebar` / `board_with_titlebar` / meta `is_titlebar`)拖动、点击置顶。`open_script(path, assets)` 一步解析+构建+开 |
| 通用弹窗 | S | ✅ | `ui/dialogs.gd``confirm(text,on_ok,on_cancel)` / `alert(text)` / `input(prompt,on_submit,default)`,走 `UiManager.open(modal=true)` |
| 拖拽层(物品图标跟光标 + 落点命中 slot) | S | ⬜ | 归 P2(背包才用到)|
**已验证**`ui_test.gd` —— 解析器(算术 710、hex 色、locale 解析、常量、坏输入→{})、
构建器(节点类型 / 尺寸 / 位置 / center 对齐数学 / board 标题)、窗口管理器(栈 / 置顶 /
close_top / modal dim)、弹窗(confirm 打开 + modal + on_ok + 关闭)、真实文件
`popupdialog.py` name/children/.sub 按钮 + 构建)。全过。全量语料 sweep 80/80。
## P2 — 物品 / 背包 / 装备(L,🌐 解析+UI 💻)—— 🟢 主体完成(离线部分),端到端待服务器
**目标**:捡东西、开背包、穿脱装备(穿装备换模型 → 解锁 `PARITY §2.7` 高光 / `§2.3` 时装)。
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| `GC_ITEM_*` 封包 + 模型 | S | ✅ | `wire.h``ItemPos`(3) / `ItemAttr`(3) + `GC_ITEM_SET`(54) / `DEL`(7) / `UPDATE`(41) / `USE`(19) / `GROUND_ADD`(24) / `GROUND_DEL`(8) / `GET`(75) + `CG_ITEM_MOVE`(11) / `USE`(7) / `DROP`(11) / `PICKUP`(8),全 `static_assert``EWindows` 常量。`EntityStore``m_inventory[90]` / `m_equipment[11]` / `m_ground` map + `apply()` 全部 7 包 + `drain_inv/drain_ground/drain_item_events``game_client.h` 路由 + `send_item_move/use/drop/pickup` |
| `Metin2Proto` 节点 | S | ✅ | 新 GDExtension 类:`load_item_proto/load_mob_proto` → vnum→index map`item(vnum)` / `mob(vnum)` → Dictname / locale_name / type / sub_type / wear_flags / prices / specular …)。实测载入 5748 真实 item,`item(1)`=Yang、`item(19)`=Sword+9 |
| `M2Client` 物品面 | S | ✅ | `get_inventory()` / `get_equipment()` / `get_item(win,cell)` / `get_ground_items()`(坐标转 Godot 米)+ `move_item/use_item/drop_item/pickup_item`;信号 `inventory_changed(win,cell)` / `ground_item_added(Dict)` / `ground_item_removed(vid)` / `item_picked_up(vnum,count,from)` / `item_used(vnum)` |
| 背包 / 装备窗 | M | ✅ | `ui/inventory_ui.gd``ui_manager.open_script("inventorywindow.py")`P1 装载器)→ 索引格子 → `M2Client.get_inventory/get_equipment` 填(icon `icon/item/<vnum>.tga` 或名字文本 + 数量 + tooltip 用 `Metin2Proto` 名字)→ `inventory_changed` 刷新 → 右键 `use_item` / 拖到别格 `move_item``ui/ui_build.gd` 加 slot-cell 构建(`grid_table`/`slot`/`slotbar``start_index`/`x_count`/`y_count`/`x_step`/`y_step` 网格 + 显式 `"slot"` 元组)。接进 `game_scene.gd`(I 键开关)。UI 格子编号:0..89 背包 / 90+ 装备(wear=idx-90) |
| item_list.txt 读取 | S | ✅ | `ui/item_list.gd`:解析 `locale/locale/common/item_list.txt`TSVvnum→type/icon/model)。用于背包图标(替代 vnum 猜路径)+ 武器模型解析 |
| 本地玩家真模型 | M | ✅ | `ui/player_view.gd`PlayerViewNode3D):`build(assets, race)` —— race 0..7 → class + 性别 → `pc(2)/<class>/<class>_novice.gr2` 身体 + `hair/` + `Metin2AnimPlayer``set_anim_state("wait/walk/run/attack/dead")``general/<s>.msa`crossfade)。`_set` 转发 `weapon_gr2`/`gr2_path`/`hair_*` 给内部 `Metin2Model``game_scene._on_main_set` 首次拿到 race 时 `set_player_model(pv)` + 接 `pc.anim_state` |
| 穿脱 → 换模型 | M | ✅ | `ui/equip_model.gd``inventory_changed(EQUIPMENT)``get_equipment()`**WEAR_WEAPON(4)** → `item_list.model(vnum)``player.set("weapon_gr2", …)`**WEAR_SHIELD(10)** → `shield_gr2``Metin2Model` 新增第二个刚体挂点,默认骨 `Bip01 L Hand` —— PC 骨架没 `equip_left_hand`,共用 `_load_attach` 抽出的加载器);**WEAR_BODY(0)** → `ui/race_spec.gd``<race>.msm``ShapeData`/`HairData` 组 → model + SourceSkin/TargetSkin)→ `armor_shape_of(vnum)`(默认 =vnum,可覆写)→ `gr2_path` 换整模 + 换肤;**WEAR_HEAD(1)** 有模型的头盔 → 覆盖 `hair_gr2` 槽(Metin2 约定:头防替发型),拆下回 `parts[CHR_EQUIPPART_HAIR]``race_spec.hair(idx)`。头发也随 `entity_info`GC_CHARACTER_UPDATE 改 parts)刷新。`equip_model_test`:武器 / 盾装拆、`armor_model_map` 注入、头盔覆盖 hair 槽 / 拆回 parts 发型;`race_spec_test`:真 `warrior.msm` 26 shape、shape 9 → `warrior_cheongrin.gr2` + 换肤 |
| 地面掉落物 + 拾取 | S | ✅ | `ui/ground_items.gd``ground_item_added(Dict)` → 旋转小方块 + billboard 名条(`Metin2Proto` locale 名);`_process` 近距离名条变绿;`try_pickup()`Z 键)→ 范围内最近的 → `pickup_item(vid)``ground_item_removed` → free。接进 `game_scene.gd` |
| belt inventory / 快捷栏 | S | ⬜ | `beltinventorywindow.py` `taskbar.py` |
**已验证**8 个 GDScript 测试套件全绿 + `ctest 9/9` + uiscript sweep 80/80 + iOS 编过):
`netbridge_test`(物品面 + `Metin2Proto` 5748 item)·
`inventory_ui_test`(真 `inventorywindow.py` 开窗 / 填充 / tooltip / 刷新 / use / move)·
`equip_model_test``item_list.txt` + 武器 vnum→真实 gr2)·
`p2b_test`(地面掉落 spawn/名条/拾取 + PlayerView 真 warrior 模型 + `set_anim_state`)·
`race_spec_test``warrior.msm` 解析 26 shape + 装 shape 9→真实 cheongrin.gr2 + 换肤)。
**未完成**belt / 快捷栏、`.epk` 内 icon(需 `AssetSource` → GDScript)、
拖动图标跟手视觉、`armor_shape_of` 用真 `item_proto` value[3](现 =vnum)、
**端到端**(真服务器 `192.168.21.203`)。
## P3 — 聊天 + TextTail(M,💻)—— 🟢 主体完成(离线部分)
**目标**:完整聊天窗 + 头顶信息。
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| `GC_WHISPER` / `CG_WHISPER` | S | ✅ | `wire.h``GCWhisper`(70) / `CGWhisper`(69) + `EChatType` / `WHISPER_TYPE_*` 常量,`static_assert``EntityStore::apply(GC_WHISPER)``ChatMsg{type=WHISPER, from, sub, text}``ChatMsg``from`/`sub` 字段)。`game_client.h` 路由 + `send_whisper(name,text)``M2Client``whisper(to,text)` 方法 + `whisper_received(sub,from,text)` 信号(与方法不同名,避 GDScript 冲突)|
| 聊天窗 | M | ✅ | `ui/chat_ui.gd`:底部面板,4 标签(全部 / 私聊 / 系统 / 战斗)+ `RichTextLabel` 滚动 + `LineEdit` 输入。前缀 `/w 名 话``whisper` / `/g`→GUILD / `/p`→PARTY / `/s`→SHOUT / 其它→TALKING。消费 `chat`(按 type 分标签,名字取 `get_entity(vid).name`/ `whisper_received` / `item_picked_up`。每类型配色(bbcode)。接进 `game_scene.gd`Enter 聚焦输入) |
| 聊天气泡 | S | ✅ | `net_world.gd._bubble`:普通 / 队伍 / 公会 / 喊话 → 实体头顶 `Label3D`(3s + 1s 淡出);本地玩家 vid → `main_bubble` 信号 → `game_scene._player_bubble``player` 头顶 |
| 头顶信息完善 | S | 🟡 | 名字 + HP 条 + 伤害飘字已有(P2);⬜ 称号 / 公会名 / 目标高亮框 |
| 系统消息 / 战斗日志 | S | 🟡 | 拾取 → 系统 + 战斗标签;⬜ `GC_DAMAGE_INFO` / 经验 / 金钱变化的文本行(signal 已到位,接一下即可) |
**已验证**`net.entity_store` 扩展(whisper 包解析);`chat_test.gd`(4 标签路由:talking→全部带名 / info→系统+全部 / whisper→私聊+全部 / 系统 whisper→`[系统]` / 拾取→系统+战斗;前缀解析 `/w` `/g` `/s` + 本地回显;气泡:实体头顶节点 + 本地 vid→`main_bubble`)。9 个 GDScript 测试套件全绿,ctest 9/9iOS 编过。
## P4 — 战斗表现完善(L,🌐 表现 💻)—— 🟡 大部分完成(离线部分)
**目标**:战斗「有肉感」——拖尾、硬直、连段、状态、死亡复活、镜头。
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| affect 封包 + 图标条 | M | ✅ | `wire.h``AffectElement`(21) / `GCAffectAdd`(25) / `GCAffectRemove`(9)`static_assert``EntityStore``m_affects` map + `drain_affects()` + `affects()``M2Client``get_affects()` + `affect_added(Dict)` / `affect_removed(type)` 信号。`hud.set_affects(array)` —— 顶部图标条(色块 by type + tooltip 显 type/point/value/duration)。接进 `game_scene` |
| 受击硬直 / 锁输入 | S | ✅ | `net_play._on_damage`:自己是 victim 且非 `DAMAGE_DODGE``_hitstun_until = now + 0.32s``is_stunned()` 期间 `_process` 不发攻击 + `pc.frozen = true``player_controller._process` 见 frozen 即 `anim_state("wait")` 早退)+ `player_view.set_anim_state("damage")` |
| combo 连段 | S | ✅ | `net_play._process` 攻击:`COMBO_WINDOW`(0.9s) 内连续攻击 → `_combo` 0→1→2 循环,`attack(attack_motion + _combo, vid)` + `set_anim_state("combo"/"attack")` |
| 相机抖动 | S | ✅ | `game_camera.shake(strength, decay)` —— `_process` 里加随机偏移,指数衰减。`net_play._on_damage` 命中 0.04m / 暴击 0.08m |
| 死亡 / 复活流程 | S | ✅ | `ui/death_ui.gd``entity_dead(main)``phase_changed("dead")` → 灰屏 + 窗(在此复活 / 回城复活 → `say(0, "/restart_here"/"/restart_town")`,同客户端走 quest 命令);`vitals_changed` hp>0 → 自动关。接进 `game_scene` |
| 相机锁定目标视角 | S | ⬜ | `game_camera` 锁定 target 时 yaw 跟随;低优先 |
| 武器挥砍拖尾 | S | ⬜ | 需 `Metin2Model` 暴露每帧武器骨骼世界变换 → `ImmediateMesh` 条带。纯视觉,延后 |
**已验证**`net.entity_store` 扩展(affect add/remove);`combat_fx_test.gd`(受击→硬直+抖屏+damage 动作,闪避不硬直,他人受击不影响;combo 0→1→2 循环;`hud.set_affects`;死亡窗弹出 + 在此复活→`say("/restart_here")` + 复活自动关)。10 个 GDScript 测试套件全绿,ctest 9/9iOS 编过。
## P5 — EffectLib 特效引擎(XL,💻 起 → 🌐 用)⏭ 独立立项 —— 🟡 首版打通
**目标**`.mse` 特效脚本 → Godot。见 `PARITY-GAP.md §1.2`
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| `.mse` 解析器 | M | ✅ | `fx/mse.gd`CTextFileLoader 花括号树 + `List` 行表解析。`parse_file``{bsphere_r, bsphere_pos, particles:[{start_time, position, emitter, particle}], meshes:[...]}`。**实测真 `geompung_3_sword.mse` → 6 particle**。`.mse` 全资产集只有 3 种 groupParticle(514)/Mesh(22)/MeshElement,无 SimpleLight |
| Particle → GPUParticles3D | M | ✅ | `fx/effect_player.gd`:每 Particle 组 → `GPUParticles3D` + `ParticleProcessMaterial`。映射:`EmitterShape`(0点/3球+`EmittingRadius`)、`MaxEmissionCount`→amount、`TimeEventLifeTime`→lifetime、`TimeEventEmittingVelocity`→速度、`TimeEventGravity`→重力、`TimeEventSizeX`→scale、`TimeEventScaleX``scale_curve`(Curve)、`TimeEventColorRGB`+`TimeEventAlpha``color`+`color_ramp`(Gradient)、`RotationSpeed`→angular_velocity。绘制:`QuadMesh` + `StandardMaterial3D``BillboardType`→billboard_mode、`Src/DestBlendType`→blend_modedst=2→ADD)、程序化径向渐变代 `.dds`|
| EffectMesh / Light / 时间轴 | M | 🟡 | 时间轴:每组按 `StartTime` 延迟 `create_timer` 起 emitting`one_shot` 到寿命自动 `queue_free`。Mesh 组占位小方块(`.mde` 解码器待做)。Light:本资产集未用 |
| 挂点系统 | S | 🟡 | `spawn(name, parent_node)` 挂到任意节点下(跟随变换);`spawn_at(name, world, pos)` 世界定点。绑骨骼需 `Metin2Model` 暴露骨骼节点,待做 |
| 特效索引 + 服务器触发 | S | ✅ | `fx/effect_registry.gd`:名字 / 相对路径 / 盘符路径 → `.mse`(散包扫 + 裸名递归找 depth 7)+ 解析缓存。`wire.h` `GC_SPECIAL_EFFECT`(9) / `GC_SPECIFIC_EFFECT`(136) + `EntityStore::drain_effect_cues()` + `M2Client.effect_cue(vid, name, special)` 信号 → `game_scene` 在实体上 `fx.spawn(name)` |
**已验证**`net.entity_store` 扩展(specific/special effect cue 解析);`fx_test.gd``.mse` 解析:bsphere / 2 particle / List 行表 / 引号枚举 / 嵌套 groupEffectPlayer2 GPUParticles3D + amount + 球形发射 + 加法混合 + start_time meta + play()→emittingregistry:解析真 `.mse` 6 particle + spawn 挂载)。11 个 GDScript 测试套件全绿,ctest 9/9iOS 编过。
**未完成**`.dds` 粒子纹理(GDScript 不解 DDS,需暴露 `Metin2Image.load_dds` 静态方法)、`.mde` mesh 解码、SimpleLight(本资产集未用)、绑骨骼挂点、`GC_SPECIAL_EFFECT` 的 id→路径表、时间轴的 `MovingType` 位置动画、TexAni 帧动画。
## P6 — 技能系统(L,🌐)—— 🟢 主体完成(离线部分)
依赖 P1(技能窗)+ P2(技能书物品)+ P5(技能特效)。
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| 技能表 | S | ✅ | `ui/skill_table.gd`:解析 `skilldesc.txt`(**列偏移修正** —— 0-idxid0 job1 name2-4 desc5-8 (9空) **attrs10** weapon11 **motion_name12** **motion_idx13** grades14;旧代码整体差 1 列,`motion_idx` 一直是 0)。`entry`/`name_of`/`motion_idx_of`/`is_attack`/`is_passive`/`is_toggle`/`can_level_up`/`category_of`/`for_category``for_job` = 别名)。JOB 列即分类:WARRIOR/ASSASSIN/SURA/SHAMAN(主动) · SUPPORT(辅助/被动) · HORSE · GUILD |
| 技能封包 | S | ✅ | `wire.h``GCSkillLevel`(259flat u8[255]) / `GCSkillCooltimeEnd`(5) / `GCCreateFly`(13)`static_assert``EntityStore``m_skills[255]` + `skills_dirty()` + `drain_cooldown_ends()` + `drain_fly_cues()``M2Client``get_skills()`[{id,level}]+ `skills_changed` / `skill_cooldown_end(skill)` / `fly_cue(type,start,end)` 信号 |
| 技能窗 + 加点 | M | ✅ | `ui/skill_ui.gd`K 键):**三个分类页** `[主动][辅助][坐骑]`(→ `for_category(job/SUPPORT/HORSE)`)。每行 = 名 + `Lv X`(+ `M`/`G`/`P``master_type`) + `[被动]`/`[切换]` 标签 + `[]``is_passive``CANNOT_LEVEL_UP` 时无 +)。```M2Client.skill_up(id)`。点名 → `drag_skill_id``skills_changed` 刷新等级 + master |
| 快捷栏施放 + 冷却 | S | ✅ | `ui/quickbar.gd`(数字键 1-8):`assign(slot, "skill"/"item", id)`。技能 → `M2Client.cast_skill(motion_idx, heading, x, y)` + 本地 2s 冷却占位;`GC_SKILL_COOLTIME_END` 提前解锁。冷却遮罩自上而下扫。`restore_from_server()``get_quickslots()` 恢复。施放时 emit `skill_activated(id)``game_scene` 播技能特效 |
| 技能特效表 | S | ✅ | `fx/skill_fx.gd``skill_effect_name(id, master)` = `<skilldesc.motion_name>_<clamp(2+master,2,4)>``PC/ymir work/pc/<cls>/effect/*.mse``_blow`/`_head`/… 子特效存在就一起播);`GC_SPECIAL_EFFECT` 内建 id → `SPECIAL_FX` 表(`drugup_red/blue/green``firecracker_1``fail` …)。`game_scene``effect_cue``special` 分支 + `quickbar.skill_activated` |
| 飞行道具 | S | 🟡 | `net_world._on_fly``GC_CREATE_FLY` → 从 start 实体飞向 end 实体的小发光球(tween,按距离算时长)。⬜ 真弹道 mesh / 命中特效 / `GC_FLY_TARGETING` 追踪 |
**已验证**`net.entity_store` 扩展(skill level / cooltime end / fly cue);`skill_test.gd`(真 `skilldesc.txt` >30 skill + WARRIOR 列 + skill 1 job/name/motion_idxskill 窗行数 = 职业技能数 + +→`skill_up(1)` + Lv 5 显示;quickbar assign→activate→`cast_skill` 用正确 motion_idx + 冷却中不重发 + `cooldown_end` 后可再发)。12 个 GDScript 测试套件全绿,ctest 9/9iOS 编过。
**未完成**:真弹道、加点等级公式 / 消耗校验(服务器管)。
被动 / 支援技能树 ✅(skilldesc 列偏移修 + skill_ui 三分类页 + `is_passive`/`master_type` 显示)。
技能特效表 ✅:`fx/skill_fx.gd` —— skill id + master → `<motion_name>_<2+master夹2..4>``skilldesc` col13 的
motion_name 就是特效基名,上一轮列偏移修好后自然接上);子特效 `_blow`/`_head`/`_foot`/`_yong` 存在就一起播。
`GC_SPECIAL_EFFECT` 内建 id → `SPECIAL_FX` 小表(`SE_HPUP_RED`=1→`drugup_red``SE_AUTO_HPUP`=19、
`SE_FAIL`=12→`fail` …)。`quickbar.skill_activated` 信号 → `game_scene` 播;`effect_cue``special` 分支
接上。实测真资产:skill 1/5/17/31/92/93 + SE 1/2/3/9/12/19/20 全部 resolve 到真 `.mse`
## P7 — 任务 / NPCL,🌐)
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| NPC 交互 | S | ✅ | `wire.h``CGOnClick`(8)、`CG_ON_CLICK`(0x0A02)。`M2Client.click_npc(vid)``send_on_click``net_play._on_pick``ch_type ∈ {1,3,4}`NPC/石头/warp)→ `click_npc(vid)` 而非攻击 |
| 对话框 | 🟡 | 🟡 | `wire.h``GCScript`(7`skin`+`src_size`)、`GCQuestConfirm`(77)、`CGScriptAnswer`(5) / `CGScriptButton`(8) / `CGQuestInputString`(69) / `CGQuestConfirm`(9)`static_assert``EntityStore``ScriptCue{skin,text}` / `ConfirmCue{msg,timeout,pid}` + `drain_scripts()` / `drain_confirms()``ui/quest_dialog.gd``parse_script``[ENTER]`/`[CLEAR]`/`[NEXT]`/`[DONE]`/`[QUESTION arg(..)]`/`[LETTER]` → 文本 + 选项 + 继续钮;选项 → `script_answer(idx)`,无选项 → `script_answer(255)``GC_QUEST_CONFIRM` → 接受/拒绝 → `quest_confirm(yes,pid)`。⬜ `[INPUT]` 输入框、`SCRIPT_SELECT_ITEM` 选道具、左右立绘 |
| 任务日志 + 指引 | 🟡 | 🟡 | `wire.h``GCQuestInfo`(7 + flag 驱动变长尾)、`QUEST_SEND_*` flag 枚举。`EntityStore``QuestInfo{index,flag,begin,title,clock_*,counter_*,icon}` + `m_quests` map + `drain_quest_changes()` / `quest(idx)` / `quest_indices()``ui/quest_log.gd`J 键):列 `◆ title` + `counter_name: value` + `clock_name: value``quest_info` 信号刷新。⬜ 地图 / 世界箭头(`TARGET_CREATE` 位置目标)、`[QUESTBUTTON]``script_button` |
| quest 脚本执行 | — | ✅(选型②) | 采纳推荐方案:**客户端纯渲染 `GC_SCRIPT`,quest 逻辑全在服务器**。不内嵌 Python 解释器 |
**已验证**`net.entity_store` 扩展(`GC_SCRIPT``[QUESTION]``ScriptCue``GC_QUEST_CONFIRM``ConfirmCue``GC_QUEST_INFO` 带 TITLE+COUNTER flag → `QuestInfo` 变长尾解析,`length` 字段回填);`quest_test.gd``parse_script`:纯文本保留 / `[ENTER]`→换行 / `[QUESTION]`→2 选项 / `[DONE]`→继续钮;`script_dialog` 信号 → 弹窗 + 3 选项钮 + 选 B → `script_answer(1)` + 关窗;无选项 → 继续 → `script_answer(255)``quest_confirm_ask` → 接受 → `quest_confirm(true,999)`quest_log 只显示有标题的 quest + `quest_info` 刷新不炸)。13 个 GDScript 测试套件全绿,ctest 9/9iOS 编过。
**未完成**`[INPUT]` 文本输入 UI`CGQuestInputString` 封包已备)、`SCRIPT_SELECT_ITEM` 选道具、NPC 对话左右立绘、`[QUESTBUTTON]``script_button`、地图 / 世界任务箭头(`TARGET_CREATE_TYPE_LOCATION`)、端到端(见 `CLIENT-PORT.md` §3:握手 + 认证已联通,进游戏串场归 P10)。
## P8 — 社交 / 商店 / 仓库(XL,🌐)
依赖 P1。窗口多,首版覆盖组队 / 好友 / 商店 / 交易 / 仓库五件;公会 / 精炼 / 私人商店留后续(拍卖行 m2dev-client 无)。
| 子系统 | 规模 | 状态 | 落点 |
|---|---|---|---|
| 组队 | M | ✅ | `wire.h``GCPartyInvite/Add/Update/Remove/Link/Unlink/Parameter`(0x0710-16) + `CGPartyInvite/InviteAnswer/Remove/Parameter`(0x0701-06)`static_assert``EntityStore``m_party`(pid→`PartyMember{vid,name,state,hp_pct,affects[7]}`) + `party_dirty()` + `drain_party_invites()` + `party_distribute_mode()``M2Client``party_invite/party_answer/party_leave/party_set_distribute` + `get_party()` + `party_changed`/`party_invite_ask(leader_pid)` 信号。`ui/party_ui.gd`:左侧常驻队员面板(名字 + ★队长 + HP 条),收邀请 → `Dialogs.confirm``party_answer` |
| 私聊 / 好友 | M | ✅ | `wire.h``GC_MESSENGER`(0x0741) / `CG_MESSENGER`(0x0740) sub-header 包(`GCSubHeader`/`CGSubHeader`+ `MESSENGER_GC/CG_*` 子头枚举。`EntityStore``m_friends`(name→`Friend{online}`) + `friends_dirty()`,解 `LIST``{u8 connected,u8 len,name}` 循环)/ `LOGIN`/`LOGOUT`/`REMOVE_FRIEND``M2Client``add_friend/remove_friend``ADD_BY_NAME`/`REMOVE` 变长包)+ `get_friends()` + `friends_changed``ui/friend_ui.gd`(O 键):在线点 + 名字 + [×],点名 → `whisper_to``chat.start_whisper("/w 名 ")` |
| NPC 商店 | M | 🟡 | `wire.h``GC_SHOP`(0x0810) / `CG_SHOP`(0x0801) sub-header + `SHOP_GC/CG_*` 子头 + `ShopItem`(43`TShopItemData`)。`EntityStore``SHOP_GC_START``u32 owner_vid` + `ShopItem[]`)→ `m_shop_items` + `m_shop_open`/`m_shop_vid``END` 清;错误 → `drain_shop_errors()``M2Client``shop_buy(pos,cnt)``BUY` = `[head][u8 cnt][u8 pos]`/ `shop_sell(cell,cnt)``SELL2`/ `shop_close` + `get_shop_items()` + `shop_opened(vid)`/`shop_closed`/`shop_error(kind)``ui/shop_ui.gd`:名字 + 价格 + [买],背包右键 → `sell(cell)`,错误红字。⬜ `START_EX` 多标签商店、图标、批量 |
| 交易 | M | 🟡 | `wire.h``GC_EXCHANGE`/`CG_EXCHANGE`(0x051C/0x0508) + `EXCHANGE_SUB_*`START/ITEM_ADD/ITEM_DEL/ELK_ADD/ACCEPT/CANCELGC 侧 END/ALREADY/LESS_ELK+ `GCExchange`(50`is_me`+`arg1/2/3`+sockets+attrs)/`CGExchange`(13)。`EntityStore``ExchangeState{active,partner_vid,self/peer_items[12],self/peer_gold,self/peer_accept}` + `exchange_dirty()``M2Client``exchange_start/add_item/add_gold/accept/cancel` + `get_exchange()` + `exchange_changed``ui/exchange_ui.gd`:我方 / 对方两栏 + 金币 + [接受]/[取消],背包右键 → `offer(win,cell)`display pos 0..11 轮转)。⬜ 道具图标格、拒绝时回滚提示 |
| 仓库 | M | 🟡 | `wire.h``GC_SAFEBOX_SET/DEL`(0x0830-31body 复用 `GCItemSet`/`GCItemDel`) / `SIZE`(0x0833) / `MONEY_CHANGE`(0x0834) / `WRONG_PASSWORD`(0x0832)`CG_SAFEBOX_CHECKIN/CHECKOUT`(0x0820-21`{u8 safe_pos, ItemPos inv}`) / `ITEM_MOVE`(0x0822,复用 `CGItemMove`)。`EntityStore``m_safebox[135]` + `m_safebox_size/gold/open` + `safebox_dirty()``M2Client``safebox_checkin/checkout/move` + `get_safebox_items()`/`get_safebox_size()`/`get_safebox_gold()` + `safebox_changed``ui/safebox_ui.gd`:金币 + 道具行 + [取出],背包右键 → `deposit(win,cell)`(自动挑空位)。⬜ 密码框、商城页(`MALL_*`)、拖拽网格 |
| 公会 | L | 🟡 | `wire.h``GC_GUILD`(0x0730) sub-header + `GUILD_GC_*`/`GUILD_CG_*` 枚举 + `GuildInfoBody`(35)/`GuildSubMember`(13`name_flag` 后跟 name[65])/`GuildSubGrade`(10)`static_assert``EntityStore``GuildState` + `m_guild_members` map + `m_guild_grades[16]` + `guild_dirty()`,解 INFO/LIST/GRADE/GRADE_NAME/CHANGE_EXP/MONEY_CHANGE。`M2Client``get_guild`/`get_guild_members`/`get_guild_grades` + `guild_add_member`/`guild_remove_member`/`guild_offer`/`guild_answer_make` + `guild_changed` 信号。`ui/guild_ui.gd`(G 键):信息行 + 成员列表(按 grade 排序,grade 名解析,★官员)+ 16×12 会徽图(放大 3×,`NEAREST`)。**真机实测**`[GM-TEAM]` lvl 20 members 1/70。**会徽下载(独立连接)**:`wire.h` `GC_MARK_IDXLIST`(0x0C11)/`GC_MARK_BLOCK`(0x0C10)/`GC_MARK_UPDATE`(0x0B15) + `CG_MARK_LOGIN`(0x0C01)/`CG_MARK_IDXLIST`(0x0C04)/`CG_MARK_CRCLIST`(0x0C02)`GC_MARK_*` body 用 u32 `buf_size` 而非 u16 帧长。`NetStream``on_cipher_active()` + `on_raw()`(子类自定义分帧)钩子。`mark_image.{h,cpp}``MarkImageSet`guild_id→mark_id、LZO1X 解压 64×48 块拼进 512×512、`rect_of`/`mark_pixels`+ `parse_mark_idxlist`/`parse_mark_block``mark_client.h``MarkClient : NetStream`KX→`CG_MARK_LOGIN{handle,random_key}``CG_MARK_IDXLIST`→逐图 `CG_MARK_CRCLIST`(全 0 CRC = 整图)→`GC_MARK_BLOCK`)。`GameClient``mark_handle`/`mark_random_key`(来自 `GC_LOGIN_SUCCESS3/4`+ per-slot `guild_id`/`guild_name` + `drain_mark_updates()``M2Client``download_guild_marks(host,port)` / `get_guild_mark(gid)` / `get_guild_mark_image(gid)``Image` + `guild_marks_ready` / `guild_mark_updated` 信号。`serverinfo.gd` 第 8 列 `mark_port`0=跳过);`app_flow` 进游戏时按配置连、`guild_mark_updated` 1s 冷却重连。**公会战 + 公会技能页**:`wire.h` `GUILD_GC_SKILL_INFO`(12) `GuildSkillInfoBody`(17=`{u8 point, u8 lvl[12], u16 gp, u16 max_gp}`)、`GUILD_GC_WAR`(15) `GCGuildWar`(10)、`GUILD_GC_GUILD_WAR_LIST/END_LIST`(17/18) `GuildWarPair`(8)、`GUILD_GC_WAR_POINT`(19) `GuildWarPoint`(12)、`GUILD_GC_NAME`(16) `GuildNameEntry`(16,无 NUL)`EGuildWarState` 枚举;`GUILD_CG_USE_SKILL`(9)。`EntityStore``GuildSkillState` / `GuildWarStatus` / `m_guild_wars` / `m_guild_names` map + `guild_skill_dirty()`/`guild_war_dirty()` + `drain_guild_war_events()`/`drain_guild_war_scores()``GameClient.send_guild_use_skill(vnum, target_vid)``M2Client``get_guild_skill`/`get_guild_wars`/`get_guild_war`/`get_guild_name` + `use_guild_skill(vnum, target)` + `declare_guild_war(name)`(发 `/war <name>` 聊天命令)+ `guild_skill_changed`/`guild_war_changed`/`guild_war_event`/`guild_war_point` 信号。`ui/guild_ui.gd` 改三页 Tab(成员 / 技能 / 公会战):技能页显示技能点 + 公会点 + 12 技能等级(名字取 `skill_table.for_category("GUILD")`)+ 已学技能「施放」按钮;公会战页显示当前战状态 + 进行中 GvG 列表(我方标记)+ 宣战输入框。**会徽上传**:`wire.h` `CG_MARK_UPLOAD`(0x0C03) `CGMarkUpload`(776=`{gid, u8 image[16*12*4]}`)、`CG_GUILD_SYMBOL_UPLOAD`(0x0722) `CGSymbolUpload`(8) + 追加原始文件字节;`NetStream.send_pending()``MarkClient``Mode`Download / UploadMark / UploadSymbol+ `set_upload_mark/set_upload_symbol` + `upload_done()`(发缓冲排空即完成)。`M2Client``upload_guild_mark(host, port, gid, Image)`(自动转 RGBA8 / 缩放到 16×12/ `upload_guild_symbol(host, port, gid, PackedByteArray)` / `get_mark_server()` + `guild_mark_uploaded(ok)` 信号。`guild_ui` 成员页「上传会徽」按钮(配了 `mark_port` + 图源才出),`game_scene._guild_mark_upload_image()` 优先 `res://ui/default_guild_mark.png` 否则现造占位。⬜ 等级/日志页、公会战应答(`/war` 系聊天命令) |
| 精炼 / 强化 | M | 🟡 | `wire.h``GC_REFINE_INFO`(0x051D)/`_NEW`(0x051E) `GCRefineInfo`(63) = type+pos+`RefineTable`(57src/result vnum + cost + prob + `RefineMaterial[5]`)`CG_REFINE`(0x050C) `CGRefine`(6)=`{pos,type}``EntityStore``RefineCue` + `drain_refine_cues()``M2Client``refine(pos,type)` + `refine_ask(dict)` 信号。`ui/refine_ui.gd``refine_ask` → 弹框(src→result / 成功率 / 费用 / 材料)→ [精炼]→`refine(pos,type)` / [取消]。⬜ 精炼结果动画 |
| 龙魂精炼 | M | 🟡 | `wire.h``GC_DRAGON_SOUL_REFINE`(0x051F) `GCDragonSoulRefine`(8)=`{sub_type, ItemPos}``CG_DRAGON_SOUL_REFINE`(0x050D) `CGDragonSoulRefine`(50)=`{sub_type, ItemPos grid[15]}``DS_SUB_*` 枚举(OPEN / DO_UPGRADE·IMPROVEMENT·REFINE / REFINE_SUCCEED / REFINE_FAIL_*);`DRAGON_SOUL_MAX_NUM`(180) + `WINDOW_DRAGON_SOUL`(5) 走 `mut_slot``EntityStore``DragonSoulCue` + `drain_ds_cues()` + `m_dragon_soul[180]` + `dragon_soul_slot()``M2Client``ds_refine(mode, cells)`mode 0/1/2 → DO_UPGRADE/IMPROVEMENT/REFINEcells 为背包格,grid[0]=龙魂)+ `get_dragon_souls()` + `ds_window_open` / `ds_refine_result(ok, sub_type, cell)` 信号。`ui/dragon_soul_ui.gd`L 键 / `ds_window_open` 自动弹):3 模式按钮 + 15 格(背包右键填/取)+ [执行]→`ds_refine` + 成功/失败提示。⬜ 龙魂属性表 / 结果动画 |
| 私人商店 / 道具商城 / Cube | M | 🟡 | **首版**`CG_MYSHOP`(0x0802) `CGMyShopHead`(38)+`MyShopItem`(13)×N`GC_MALL_OPEN`(0x0841)/`SET`(0x0842)/`DEL`(0x0843) 路由进 `EntityStore.m_mall[135]``CG_MALL_CHECKOUT`(0x0840)。Cube 走 `GC_CHAT`/`CHAT_TYPE_COMMAND` 文本总线(`EntityStore.apply_server_command``cube open/close/info/success/fail/r_list/m_info`)→ `CubeState` + `drain_cube_events()``send_cube_make/material_info/result_list``/cube …``M2Client``open/close_private_shop``get_mall_items`/`mall_checkout``get_cube`/`cube_make`/`cube_request_*` + `mall_opened`/`mall_changed`/`cube_opened`/`cube_closed`/`cube_changed`/`cube_result``ui/{private_shop,mall,cube}_ui.gd``net.entity_store`+`net.loopback_flow`+`shop_cube_mall_test.gd` 覆盖。⬜ 能量条、头顶招牌 3D、`START_EX` 多标签、39 格拖放、send 命令字对真服校准 |
**已验证**`net.entity_store` 扩测(party ADD/UPDATE/LINK/PARAMETER/REMOVE + INVITE 入队;messenger LIST 2 好友 + 在线位 + LOGIN 翻转;shop START owner+item / 错误 / ENDexchange START/ITEM_ADD/ELK_ADD/ACCEPT/ENDsafebox SIZE/SET/MONEY/DEL);`net.loopback_flow` MockServer 断言 `CG_PARTY_INVITE{vid}` / `CG_SHOP BUY{cnt,pos}` / `CG_EXCHANGE START{vid}` / `CG_SAFEBOX_CHECKIN{safe,inv}` 字节到位;`p8_test.gd`5 个 UI 节点 + FakeClient:队员面板 2 行 + 自动接受邀请、好友行 + 点名 whisper + 添加、商店 2 行 + 买 + 卖 + 错误本地化 + 关、交易两栏 + offer + 放金币 + 非 active 自动关、仓库开 + 金币 + 取出 + 存入挑空位)。13 套 GDScript 测试全绿,ctest 9/9,项目导入干净,iOS 编过。
**已验证(公会 + 精炼)**`net.entity_store` 扩测(GC_GUILD INFO 名/等级/资金/容量 + LIST `name_flag` 变长成员 + GRADE `[count][idx][GuildSubGrade]` 名/权限;GC_REFINE_INFO src/result/cost/prob + materials[5]);`guild_refine_test.gd`(公会窗无公会提示 / 信息行 / 成员按 grade 排序 + grade 名 / ★官员;精炼框 `refine_ask` 弹出 + 成功率 + 费用 + 材料行 + [精炼]→`refine(7,1)` + [取消] 不发包)。真机 `net_e2e``[GM-TEAM]` 公会数据解析正确。
**已验证(龙魂精炼)**`net.entity_store` 扩测(GC_DRAGON_SOUL_REFINE OPEN cue / REFINE_SUCCEED 带 cell+window / REFINE_FAIL_* / drain 清空);`dragon_soul_test.gd``ds_window_open` 弹出 + 默认精炼模式 + 切模式禁用当前按钮 + 背包右键按序填 15 格 + 非背包窗忽略 + 再次右键取出/末尾重放 + [执行]→`ds_refine(mode, [cells])` + 成功清空/失败留原因码 + 清空/关闭按钮)。
**已验证(会徽下载)**:新 CTest `net.guild_mark``parse_mark_idxlist` 2 条 → guild→mark_id + `needed_images` 升序去重;`rect_of``mark_id/1280` 选图、`%1280` 定位;合成 LZO1X 压缩块 → `parse_mark_block``apply_block` 拼图 → `mark_pixels` 逐像素回读;坏压缩数据 / 越界索引 / 截断 body 全部安全拒绝);`guild_mark_test.gd`guild_ui 无图时 TextureRect 隐藏 → `guild_marks_ready` 出图 16×12 → 离开公会收起;`app_flow` `mark_port=0` 不下载 / 配置后 `download_guild_marks(host,11002)` / `guild_mark_updated` 冷却外重下、1s 内不重下)。**独立连接 KX + `MarkClient` 分帧逻辑离线覆盖,未接真机**(联调服未开会徽服端口)。附带修复:`game_scene.gd``_skill_master()` 之前被误插进 `setup()` 中段,截断了后半段(导致 `assets_root` 等未声明)——已把它移到 `setup()` 之后,多个依赖 `game_scene` 的 GDScript 套件恢复真绿。
**已验证(会徽上传)**`net.loopback_flow``MockServer::Mode::Mark`:真 loopback socket 跑完整 KX → `CG_MARK_LOGIN{handle,random_key}` 字节到位 → `CG_MARK_UPLOAD{gid=77, image[768]}` 服务端逐字节校验和一致 → `upload_done()`;同法验 `CG_GUILD_SYMBOL_UPLOAD{gid=88}` + 2000 字节原始附加。`guild_mark_test.gd`:未配 mark server 无「上传会徽」按钮 → 配好后按钮出现 → `upload_guild_mark("1.2.3.4",11002,88,img)` + `guild_mark_uploaded(true)` → 状态更新。23 套 GDScript 全绿,ctest 10/10,导入干净,iOS 编过。
**已验证(公会战 + 公会技能)**`net.entity_store` 扩测(GUILD_GC_NAME id→名注册、SKILL_INFO 点数/12 等级/公会点、WAR ON_WAR 状态 + 事件 drain、WAR_LIST 2 对 → WAR_END_LIST 删 1、WAR_POINT drain);`guild_war_skill_test.gd`(技能页无数据提示 → `guild_skill_changed` 出点数行 + GUILD 技能名 + 等级 + 第 12 技能配 `levels[11]`;已学技能有「施放」→ `use_guild_skill(4051,0)`;公会战页无战提示 + 宣战 → `declare_guild_war("Tigers")` + `guild_war_changed` → 「交战中」+ GvG 行「◀我方」)。23 套 GDScript 全绿,ctest 10/10,导入干净,iOS 编过。**真机(2026-08-31`net_e2e`**`[GM-TEAM]` 账号进游戏后服务器自动推 `GUILD_GC_SKILL_INFO`17B body 解析正确(skill_point=19 / guild_point 0/2300 / 12 等级);`GUILD_GC_WAR` 无战状态解析正确;trace `GC_GUILD` 子包 len=171/40/83/22/21/9/5 无 unknown。
**未完成**:私人商店打磨;商店 `START_EX`;交易 / 仓库道具图标网格;仓库密码框 + 商城页;公会战应答聊天命令;公会等级/日志页;龙魂属性表 + 精炼结果动画。
## P9 — 世界系统(L,🌐,分散)
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| 换图 / 传送 | S | 🟡 | `wire.h` `GCWarp`(18x/y/addr/port)。`EntityStore` `WarpCue` + `drain_warps()``same_server()` = addr==0)。`M2Client` `warp(pos, same_server)` 信号(pos 已转 Godot 米)。`game_scene._on_warp`:同服 → 直接挪玩家 + 相机 snap;跨服(addr≠0)→ warning,归 P10 重连串场 |
| 频道 | S | ✅ | `wire.h` `GCChannel`(5)。`M2Client` `get_channel()` + `channel_changed(ch)` 信号。小地图角标显示 `CH n``hud.set_channel` 若有也调) |
| 游戏内时间 / 昼夜 | S | ✅ | `wire.h` `GCTime`(12unix 秒 i64)。`M2Client` `get_server_time()` + `time_changed(epoch)``world/world_time.gd`:epoch → 日内秒 → 太阳 pitch/azimuth + 光强/光色(夜黑冷蓝 → 黄昏橙 → 正午暖白)+ `Environment` ambient/背景色。无 GC_TIME 时退回系统本地时间 |
| 小地图 | M | 🟡 | `ui/minimap.gd`:右上角圆形,北朝上,`_draw` 画底圆 + 外环 + 玩家朝向箭头 + 实体点(怪红 / 其它蓝)+ NPC 点(传送青 / 其它黄)+ 任务标记橙方。世界→图 = `(d.x, d.z)*SCALE` 夹到半径。⬜ 缩放 / 拖动 / 大地图窗 / atlas 底图 |
| NPC 位置 / 世界任务标记 | M | 🟡 | `wire.h` `GCNPCPosition`(6 + `count`×`NPCPositionEntry`78) / `GCTargetCreate`(46) / `GCTargetUpdate`(16) / `GCTargetDelete`(8) + `CREATE_TARGET_TYPE_*``EntityStore` `m_npc_marks` + `m_markers` map + `npc_marks()`/`markers()` + dirty。`M2Client` `get_npc_marks()`/`get_world_markers()` + `npc_marks_changed`/`world_markers_changed`。小地图消费。⬜ 世界空间 3D 箭头 / 屏幕边缘指示 |
| 天气 / 环境粒子 | S | 🟡 | `fx/weather.gd`:跟随相机上方的 `GPUParticles3D` 盒发射,`set_weather("snow"/"rain"/"none")` 切重力 / 速度 / 尺寸 / 颜色 / 湍流。⬜ 由地图 `.msenv` 属性驱动、闪电 / 雾 |
| 坐骑 | S | 🟡 | `wire.h` `GCMount`(21)。`Entity.mount_vnum` + `drain_mount_changes()``M2Client` `mount_changed(vid)` 信号 + `entity_dict``mount_vnum`。⬜ 换骑乘模型 / 坐骑动作集 / 移速 |
| 地形贴花 | S | ⬜ | `GameLib/TerrainDecal.cpp` —— 血迹 / 施法阵 / 脚印(Godot `Decal` 节点) |
| 钓鱼 / 副本 | M | ⬜ | `GC_FISHING`(0x0B10) `DUNGEON`(0x0B11) |
**已验证**`net.entity_store` 扩测(warp 入队 + same_servertime set + dirty oncechannel set`GC_NPC_POSITION` 2 条 markmarker create→update→deletemount → `Entity.mount_vnum` + change);`p9_test.gd`(小地图:view + `CH` 角标 + `_to_map` 北朝上/东朝右/夹半径;world_time:正午 `day_fraction≈0.5` + 太阳亮,午夜 ≈0 + 太阳暗;weatheroff→snow→rain(雨落更快)→offwarp 同服信号)。14 套 GDScript 测试全绿,ctest 9/9,项目导入干净。
**未完成**:大地图窗 + atlas 底图、地形贴花、钓鱼 / 副本、坐骑换模型、世界空间任务箭头、天气按地图驱动、跨服换图(P10)。
## P10 — 网络补全(M,🌐)
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| **LOADING → PHASE_GAME**(真机端到端的最后一跳)| S | ✅ | `wire.h` `CGClientVersion`(0x000D70Bfilename+timestamp)。`GameClient`:收到 `GC_MAIN_CHARACTER` 后**自动发 `CG_CLIENT_VERSION`**fork 用固定 timestamp `1215955205`)—— 这是服务器放行到 PHASE_GAME 的门槛,之前缺它就在 phase 4 断线。`net_e2e` 真机验证:`phase = 5 / IN GAME`,收到 121 个实体 + HP/SP/level + 20 格背包 + 好友列表 |
| Loading 相位 | S | ✅ | `ui/loading_screen.gd``CanvasLayer` layer 60):`phase_changed` → "login/select→连接中/载入角色"、"loading→载入地图" 显示转轮 + 阶段文案 + 可选进度条;"game" 隐藏。断线也收起 |
| 选服 / 选频道 | S | 🟡 | `net/serverinfo.gd``class_name ServerInfo`):解析 TSV 服务器表(`name / auth_host / auth_port / game_host / game_port / channels`),内置当前联调服默认项。`address(server, channel)` → 频道 N 的 game 端口 = base + (N-1)×10(经典 Metin2 约定)。`app_flow.gd` 登录表单里做服务器 / 频道下拉。⬜ 频道在线人数轮询(`ServerStateChecker` UDP 探针) |
| Offline / 断线重连 | S | ✅ | `ui/reconnect_ui.gd`layer 70):`disconnected(reason)` → 遮罩 + "N 秒后自动重连(次数/上限)" 倒计时 → `M2Client.reconnect()`(已有);[立即重连] / [停止] 按钮;`entered_game` 或非 offline 的 `phase_changed` 自动收起。最多重试 5 次 |
| charselect 串场 | M | ✅ | `app_flow.gd``Node` 场景根):一个 `M2Client` 贯穿全程,状态机 `LOGIN → SELECT → GAME``char_list` → 选人列表;点角色 → `select_character``entered_game` → 实例化 `game_scene``setup`LOGIN/SELECT 期间断线 → 回登录,GAME 期间断线 → 保留场景交给 `ReconnectUI``LoadingScreen` + `ReconnectUI` 常驻。`build_game_scene` 开关便于 headless 测状态机 |
| Metin2 风选人页 | M | ✅ | `ui/char_select_screen.gd` 取代裸按钮列表:全屏 `select.jpg` 背景 + 右侧 `SubViewport``PlayerView` 真模型(正面 + `wait` + 缓摆,**包围球** `r/sin(fovV/2)` 自适应机位,不再切远侧手)+ 左上职业名(locale `name_<class>.sub``select.dds`DDS 走 `Metin2World.load_dds`;退中文描边字)+ 左侧 `board` 板(帮会徽 / **国家名+国旗** `get_empire()` / 帮会名 / 名称 / 等级 / 游戏时间 / 体力·智力·力量·敏捷)+ 开始/创建/删除/退出 + ◀▶ 槽位切换(`_pad_slots` 补空槽)。**建号/删号 1:1**:`CG_CHARACTER_CREATE`(0x0201)/`CG_CHARACTER_DELETE`(0x0202) + `GC_PLAYER_CREATE_SUCCESS/FAILURE`(0x020C/D) + `GC_PLAYER_DELETE_SUCCESS`(0x020E)/`WRONG_SOCIAL_ID`(0x020F)`GameClient.create/delete_character` + `drain_char_events` + `replace_slot/clear_slot`(服务器补丁槽位不重发);`M2Client` 同名方法 + `char_created/char_create_failed/char_deleted/char_delete_failed` 信号 + `get_empire/get_slot_count`;选人页建号弹窗(职业+名称+各职业起始四维)、删号弹窗(secret 删除码);`app_flow._on_char_list` 在 SELECT 只 `set_chars` 不整屏重建。`net.loopback_flow` MockServer + `char_create_delete_test.gd` 覆盖。C++ `CharSlot`+dict 补 `play_minutes/st/ht/dx/iq/main_part/hair_part``ui_assets` 加运行时 DDS 解码 |
| attack CRC | S | ✅ | `net_e2e` 真机实测:进游戏后发 `CG_MOVE` + `CG_ATTACK``crc_proc=0 crc_file=0`),保持连接 10s 不被踢。**结论:0 CRC 服务器接受,不需要占位 CRC** |
| ESC 系统菜单 + 游戏设置窗(`uisystem.SystemDialog` + `uigameoption.OptionDialog`| M | ✅ | `system_menu_ui.gd``systemdialog.py`8 键:system_option / game_option 开子窗、`change_button``/phase_select``logout_button``/logout``mall_button``/in_game_mall``exit_button`→quit。ESC 改开此菜单。`game_option_ui.gd``gameoptiondialog.py`):`/setblockmode <mask^bit>`EBlockAction 位 1<<0..1<<5,本地 `_block_mode` + cfg 持久化)+ `/pkmode {0,1,2,4}` PK radio + 6 组显示开关持久化 `user://system_option.cfg [gameopt]`(渲染钩子待补)。`system_menu_ui_test.gd` 覆盖。C++ 无改动 |
| 组队成员信息板(`uiparty.PartyMemberInfoBoard`| M | ✅ | `wire.h` `CGPartySetState`(10) + `EPartyRole``send_party_set_state` / `M2Client.party_set_state``get_party()``state`+`affects[7]``party_ui.gd` 每员 strip = 角色状态按钮(队长弹菜单 `CG_PARTY_SET_STATE` 攻/坦/狂/辅/宗/防 + 踢人)+ 名字(★) + HP gauge + affect chip;顶部 EXP 分配开关 + 组队治疗。点名字 → `set_target``p8_test` 组队段扩覆盖;顺带修 `p8_test._init` 未 await 的隐患 + `shop_ui.refresh` queue_free 延迟 bug |
| 系统设置窗(`uisystemoption.OptionDialog`| S | ✅ | `ui/system_option_ui.gd`:装 `systemoptiondialog.py` 真 uiscript,绑定照 `uisystemoption.py`:音乐/音效音量滑条→`Audio.master_bgm/sfx`(含在播 BGM 即时)、`camera_short/long``game_camera.max_dist`(近 11/远 20)、`fog_level0/1/2`(浓/中/淡)→`Environment.fog_density``tiling_*` Godot 无对应保留占位。持久化 `user://system_option.cfg`= `systemSetting` 配置),setup 读回即时生效,`.msenv` 换 env 后重贴。`game_scene` ESC 呼出。`system_option_ui_test.gd` 覆盖 |
| 私人商店开설창(`PrivateShopBuilder`)| S | ✅ | 增量 36:布局改走真 uiscript `privateshopbuilder.py`40 格 grid + NameLine 盖 LineEdit + Ok/Close,候选面板挂窗右侧)。`itemStock` 交互(拿起/落位/价格弹窗/排序+display_pos/撤下/개설/철수)不变。测试拆出 `private_shop_ui_test.gd`。以下为原 | `private_shop_ui.gd` 重做成 `uiprivateshopbuilder.py``itemStock` 模型:左侧背包候选「拿起」+ 右侧 40 格(`shop.SHOP_SLOT_COUNT`);拿件点空格 → 价格弹窗(`MoneyInputDialog`)→ 落位(`AddPrivateShopItemStock`),点占用格 → 撤下(`DelPrivateShopItemStock`)。개설 把 stock 按格号排序、`display_pos=格号`、上限 39`PRIVATE_SHOP_ITEM_MAX_NUM`)→ `open_private_shop(sign, [{vnum,count,inv_cell,price,display_pos}])``TPacketCGMyShop`+`TShopItemTable`×N)。`shop_cube_mall_test` 重写覆盖。C++ 无改动 |
| 商店 `SHOP_GC_START_EX`(多货架)| S | ✅ | `entity_store` 解析 `SHOP_GC_START_EX`(10) = `{u32 owner_vid, u8 tab_count}` + `tab_count`×`{ShopTabHead(name[32]+coin_type), ShopItem[40]}``ShopEntry.pos` 保槽位,`m_shop_tabs``ShopTab{name,coin_type,items}`),`SHOP_GC_START` 也按 40-槽填 pos,END 清货架。`M2Client.get_shop()``{vid,open,tabs:[…]}``shop_ui.gd` 多货架按钮 + 买位置 `tabIdx*40+slot`1:1 `uishop.py GetIndexFromSlotPos`)。`net_entity_test` 双货架合成包 + `p8_test` 切页 / `buy pos 5` / `buy pos 43` |
| 选魔石窗(样板第 2 例)| S | ✅ | `ui/select_item_ui.gd`:装 `selectitemwindow.py`5×8 grid),填格照 `uiselectitem.py.RefreshSlot`(只 `ITEM_TYPE_METIN`=10 + `GetItemGrade`(内部名末位数字)≤2,≤54 个,升序映射背包格)。点格 → `client.script_select_item(invCell)``CG_SCRIPT_SELECT_ITEM` 0x0903 = `net.SendSelectItemPacket`+ 关窗;关闭 → `script_select_item(0)`。触发链:`quest_dialog.parse_script` 识别 `[SELECT_ITEM]` token → `select_item_requested` 信号(= `PythonEventManager EVENT_TYPE_SELECT_ITEM``BINARY_OpenSelectItemWindow`)→ `game_scene``select_item_ui.open``select_item_ui_test.gd` 覆盖 |
| 角色状态窗(1:1 窗口样板)| M | ✅ | `ui/char_status_ui.gd``UiScript`+`UiBuild` 装载 `characterwindow.py` 真布局,数值逐字对照 `uicharacter.py.RefreshStatus`Level/Exp/RestExp、HP/SP、STR/DEX/HTH/INT、ATT=`(min|max)+ATT_BONUS+ATTACKER_BONUS`、DEF、MATT=`MAG_ATT+(min|max)MAGIC_WEP`、MDEF、ASPD/MSPD/CSPD/EREPointTypes 索引取 m2dev `Packet.h`)。加点 /`client.say(0,"/stat ht"|"/stat- ht")`(同原 `statusPlusCommandDict`);`POINT_STAT>0` 才显加点按钮。4 页 tab `SetState` 切换。名称/帮会/头像取 `get_entity(get_main_vid())``game_scene` V/C 热键。`char_status_ui_test.gd` 覆盖。**后续窗口(选道具网格 / 商店 START_EX / 私人商店 39 格 / 系统设置 …)照抄此样板:uiscript 装载 + 逐字数值绑定 + 原聊天命令 / 封包** |
**已验证**`net.loopback_flow` MockServer 断言「`GC_MAIN_CHARACTER` 后客户端自动发 `CG_CLIENT_VERSION`」;`p10_test.gd`serverinfo 内置默认 + 文件解析 + 频道端口偏移;loading 遮罩 phase=loading 显示 / progress 条 / phase=game 隐藏;reconnect 断线 → 倒计时耗尽调 `reconnect()` + 手动重连 + `entered_game` 收起;app_flow 状态机 LOGIN→连接用 serverinfo 地址→`char_list`→SELECT→点角色→`select_character``entered_game`→GAMESELECT 断线→LOGIN)。15 套 GDScript 测试全绿,ctest 9/9。**`net_e2e` 真机:auth → game → 选人 → LOADING → PHASE_GAME 全程打通。**
**真机字节校准 + 收尾(2026-08-30,已做)**
- `GC_CHAR_ADD_INFO`(0x020797B) 补上 → 实体名/等级/公会/骑乘出(NPC「Old Man」实测正确);
`GC_CHARACTER_UPDATE`(0x0209) 按 `TPacketGCCharacterUpdate` 全解(parts/guild/alignment/pk/mount)。
- `ch_type` 改在 GDScript 按 race 查 `mob_proto.type` 分类(`net_play._entity_kind`/`_entity_name`),
`game_scene``mob_proto``GC_PLAYER_POINTS` 本来就对(gold 13.5 亿 = GM 号真实值,
exp/next_exp 按 unsigned 输出)。
- **3D 名字标签刷新**`ChangeKind::Info` + `entity_info` 信号 → `net_world._on_info` 刷 Label3D
`name_resolver` 让怪名走 `mob_proto`
- **跨服换图**`GC_WARP` addr≠0):`M2Client.warp_to_game_server` 拆连接、复用 `login_key` 直连新
game server。**断点快连**`reconnect()` 优先直连 game server 省一次 auth 往返。
- **频道负载查询**`net/channel_status.gd` = `CServerStateChecker` 端口(明文 TCP`CG_STATE_CHECKER`
0x000F → `GC_RESPOND_CHANNELSTATUS` 0x0010 → `{port: 0关/1正常/2拥挤/3爆满}`)。真机 `11011`
`{11011:1,11012:1,11013:1,11991:1}``serverinfo` 频道口步长改 +1`port_step` 可按服覆盖)。
- **怪 / NPC 真 3D 模型**`ui/mob_view.gd`race → `root/npclist.txt` 代号 → 目录逐段回退
`bear_brown→bear`)→ `Metin2Model`(网格 / 贴图用完整代号)+ `motlist.txt` 动作),
`game_scene._make_entity_model``net_world` 默认工厂,失败回退占位胶囊。
`net_world._attach_nameplate` 抽出,真模型也挂名字 + HP 条。翻译版 `mob_proto` `szName` 被本地化
"Brown Bear")解不出目录,故必须走 `npclist.txt`
- **已在局内 / 重连补拉实体**`net_world.catch_up()` 遍历 `client.get_entities()` 补 spawn
(协程化的 `setup()``entity_spawned` 接得晚,会漏初始 spawn 洪流);`game_scene`
`set_local_vid` + `net_play._on_main_set``catch_up`,避免本地玩家被画成压身的蓝胶囊。
详见 `CLIENT-PORT.md` §3。**23 套 GDScript + 10 CTest + 项目导入 + macOS/iOS 扩展全绿。**
**剩的不是代码问题**:字面在线人数(Metin2 协议只有 4 态负载,要数字得改服务器)、全量 monster 资产。
---
## 里程碑
**P0P10 首版全部完成**2026-08-30)。真机端到端(`net_e2e`)从 auth 握手一路打通到
PHASE_GAME —— 收到 121 个实体、HP/SP/等级、20 格背包、好友列表。18 套 GDScript + 9 CTest +
项目导入 + macOS/iOS 扩展全绿。
| 里程碑 | 含 | 首版状态 | 距「打磨完成」还差 |
|---|---|---|---|
| **M1 可打** | P0 闭环 + P4 硬直 / 连击 / 相机抖 / 死亡窗 | ✅ 首版 | 攻速 / motion index 按 `POINT_ATT_SPEED` 细化、武器拖尾、相机锁定 |
| **M2 角色养成** | P1 UI 层 + P2 物品 / 装备(武器 / 盾 / 身体 / 头盔 / 头发 / armor shape / 时装)+ P6 技能(分类页 + 技能特效表 + 内建特效)+ P3 聊天 | ✅ | 加点等级公式(服务器管)、真弹道 |
| **M3 世界** | P7 任务 / NPC + P9 世界系统 + P10 网络补全 | ✅ 首版 | `[INPUT]` / 选道具 / 立绘、大地图窗 + atlas、地形贴花、跨服换图、断点续连 |
| **M4 社交** | P8 组队 / 好友 / 商店 / 交易 / 仓库 / **公会**(技能页 + 公会战 + 会徽上/下载)/ **精炼** / **龙魂精炼** | 🟡 首版(12 件) | 龙魂属性表 + 结果动画、私人商店打磨、公会等级/日志页 |
| **特效** | P5 EffectLib`.mse` → GPUParticles | 🟡 首版 | `.dds` 纹理、`.mde` mesh、绑骨骼、skill / 命中特效表 |
### 下一步优先级(都是「首版 → 打磨」)
1. ~~真机字节校准第二轮~~ ✅ 已做(2026-08-30):`GC_SKILL_LEVEL_NEW`(0x021B,本服才发这个,
之前技能窗全空) + `GC_QUICKSLOT_*`(0x0519/1A/1B) 补上;`GC_NPC_POSITION` / `GC_ITEM_UPDATE` /
`GC_CHAT` / `GC_AFFECT_ADD` / `GC_SPECIAL_EFFECT` / `GC_QUEST_INFO` 逐字节确认全对。详见 `CLIENT-PORT.md` §3。
2. ~~换装完整~~ ✅ 已做(2026-08-30):`Metin2Model` 加第二刚体挂点(盾,`Bip01 L Hand``_load_attach`
抽公共加载器);`equip_model` 补 WEAR_SHIELD / WEAR_HEAD(头盔覆盖 hair 槽)/ 头发随 `parts[HAIR]`
**armor→shape 真表**proto 读器加 `alValues[6]`@191`equip_model._armor_shape_default` = `item_proto
values[3]`(非 0)否则 =vnum(对齐 `__ArmorVnumToShape`+ armor `specular``model.specular_power`
(§2.7)。**时装**Metin2 不发独立 costume 槽包,服务器把 costume vnum 写进主角 `parts[ARMOR/HAIR]`
`equip_model` 每槽改成 `_eff()``parts[]` 非 0 优先,否则回退 `get_equipment()`),大编号 `parts[HAIR]`
`item_list.model`(假发)。`GC_CHARACTER_UPDATE` parts 变 → 推 `Info` 变更触发全刷。也是渲染
远端玩家外观的同一条路。
3. ~~被动 / 支援技能树~~ ✅ 已做(2026-08-30):`skilldesc.txt` 列偏移修正(旧代码差 1 列,`motion_idx`
一直读成 0);`skill_ui` 三分类页 `[主动][辅助][坐骑]``for_category`),被动技能无 `[]``master_type`
显示为 `Lv X M/G/P`
4. ~~技能特效表~~ ✅ 已做(2026-08-30):`fx/skill_fx.gd`skill id + master → `<motion_name>_<N>``skilldesc`
col13),`GC_SPECIAL_EFFECT` id → `SPECIAL_FX` 小表;`quickbar.skill_activated` + `effect_cue.special` 接上。
5. **全量资产** —— 换完整 monster / npc / effect 包,`mob_view` / `effect_registry` / `skill_fx` 立刻全量。
6. ~~公会 + 精炼~~ ✅ 已做(2026-08-30):`GC_GUILD`(0x0730) INFO/LIST/GRADE 解析 + `ui/guild_ui.gd`G 键,
真机 `[GM-TEAM]` 验证);`GC_REFINE_INFO`(0x051D) + `ui/refine_ui.gd``refine_ask` 弹框 → `refine(pos,type)`)。
~~公会战 / 技能页~~ ✅ 已做(2026-08-30):`GUILD_GC_SKILL_INFO`(12)/`WAR`(15)/`GUILD_WAR_LIST`·`END_LIST`(17/18)/
`WAR_POINT`(19)/`NAME`(16) 解析 + `GUILD_CG_USE_SKILL`(9)`M2Client` `get_guild_skill`/`get_guild_wars`/
`use_guild_skill`/`declare_guild_war`(`/war` 命令) + 4 信号;`guild_ui` 改三页 Tab(成员/技能/公会战)。
9. ~~会徽上传~~ ✅ 已做(2026-08-30):`CG_MARK_UPLOAD`(0x0C03`{gid, u8 image[768]}`) / `CG_GUILD_SYMBOL_UPLOAD`
(0x0722) + 追加文件字节。`MarkClient``Mode`Download/UploadMark/UploadSymbol+ `set_upload_mark/_symbol`
+ `upload_done()``NetStream.send_pending()` 归零即完成)。`M2Client.upload_guild_mark(host,port,gid,Image)`(自动转
RGBA8 + 缩放 16×12/ `upload_guild_symbol` / `get_mark_server` + `guild_mark_uploaded(ok)``guild_ui` 成员页
「上传会徽」按钮。`net.loopback_flow``MockServer::Mode::Mark` 真 socket 校验字节。
7. ~~龙魂精炼~~ ✅ 已做(2026-08-30):`GC_DRAGON_SOUL_REFINE`(0x051F) / `CG_DRAGON_SOUL_REFINE`(0x050D
`grid[15]`) + `DS_SUB_*` 枚举;`EntityStore.DragonSoulCue` + `m_dragon_soul[180]``WINDOW_DRAGON_SOUL`
`mut_slot`);`M2Client.ds_refine(mode, cells)` + `ds_window_open` / `ds_refine_result``ui/dragon_soul_ui.gd`
(L 键 / 自动弹,3 模式 + 15 格背包右键填 + [执行])。剩龙魂属性表、精炼结果动画。
8. ~~会徽下载~~ ✅ 已做(2026-08-30):`MARK_*`0x0C0x/0x0B15)独立连接。`NetStream``on_cipher_active()`
+ `on_raw()` 钩子让子类自定义分帧(`GC_MARK_*` 用 u32 `buf_size`)。`mark_image.{h,cpp}` `MarkImageSet`
LZO1X 解 64×48 块 → 512×512 → `mark_pixels`+ `mark_client.h` `MarkClient`KX → `CG_MARK_LOGIN{handle,
random_key}``CG_MARK_IDXLIST` → 逐图 `CG_MARK_CRCLIST`(全 0) → `GC_MARK_BLOCK`)。`M2Client.download_guild_marks`
/ `get_guild_mark_image``Image``guild_ui` 显示 16×12 会徽;`serverinfo` `mark_port` 第 8 列(0=跳过);
`app_flow` 进游戏连、`guild_mark_updated` 冷却重连。CTest `net.guild_mark` + `guild_mark_test.gd`。**未接真机**
(联调服未开会徽服端口)。
9. **私人商店打磨**、商店 `START_EX`、道具图标网格 —— 按需。(拍卖行不在 m2dev-client 内)
## 通用做法
1. **每个 P 先做 💻 部分**`wire.h` 加封包 + `EntityStore` 解析 + 合成封包单测(`net_entity_test` 风格)+ 假 client 驱动 UI`netbridge_test` 风格)。
2. 🌐 端到端用 `build/extension/net_e2e`(真机跑 auth→game→选人→进游戏);`MT_NET_TRACE=1` 逐包 header/len/consumed、`MT_NET_DUMP=1` 逐包 hex,对字节用。
3. UI 一律走 P1 的 uiscript 装载器,不手搓布局。
4. 封包号 / 结构以 `../m2dev-client-src-main/src/UserInterface/Packet.h` 为准,`static_assert` 尺寸;**真机字节和参考头文件不符时以真机为准**(已踩:`GC_LOGIN_SUCCESS4``bType``CG_CLIENT_VERSION`)。
5. 玩法逻辑尽量**服务器驱动、客户端只渲染**(尤其 quest / 商店 / 公会),少搬业务规则。
+39 -34
View File
@@ -1,15 +1,20 @@
# Godot 渲染 Demo — 开发计划
> 内部研究方案,不对外公开。
> 与 [`PLAN.md`](./PLAN.md) 是**两条并行的跨平台基座候选**:本文件评估「Godot 4 + 自研资源 loader」,`PLAN.md` 评估「自研引擎 + bgfx RHI」。
> 复用 [`../libgr2`](../libgr2)、[`../formats`](../formats) 与 [`../oracle`](../oracle) 的真值数据。
>
> **2026-08-29 更新**:并行的 bgfx 自研引擎方案(`xrender-poc` / [`reference/PLAN.md`](./reference/PLAN.md)
> 经中期评审([`MIDREVIEW.md`](./MIDREVIEW.md)**停止开发**,本方案成为唯一在研路线。
> `libgr2` / `formats` / `oracle` / `tools` 已从 xrender-poc **vendored 进本仓库**
> `../libgr2`、`../formats`、`../oracle`、`../tools`),不再是 submodule / sibling 引用。
> 下文中出现的 `xrender-poc/xxx` 路径按 [`reference/README.md`](./reference/README.md) 的映射表读。
> bgfx demo 截图作为交叉核对基线留在 `../test/bgfx-reference/`。
| | |
|---|---|
| 状态 | PoC 规划 |
| 技术栈 | Godot **4.7.1** stable · GDExtension`godot-cpp` `4.7` 分支)· C++20 |
| 目标平台 | **Phase 1macOS**(当前)→ Phase 2iOS · Android |
| 判定周期 | Phase 1 ≈ 3.5–5 周(单人)→ **中期评审**(不下结论)Phase 2 后 → **go / no-go** |
| 状态 | Phase 1macOS)基本完成,见 [`MIDREVIEW.md`](./MIDREVIEW.md);未完成工作分层清单见 [`BACKLOG.md`](./BACKLOG.md) |
| 技术栈 | Godot **4.7.1** stable · GDExtension`godot-cpp` 精确提交 `101ae38`4.7 API)· C++20 |
| 目标平台 | **Phase 1macOS**(当前)→ Phase 2Android(一加 13 / Vulkan)· iOSiPhone 16 / Metal)。自用,三台现代设备,不涉及 GLES3 / Compatibility |
| 判定周期 | Phase 1 ≈ 3.5–5 周(单人)→ **中期评审**Phase 2 = 三设备各一次 bring-up + 性能留档(自用,无对外 go/no-go) |
| 复用 | `libgr2`gr2 v6 读取器)· `formats/`textscript · msa · msm)· `oracle/` golden · bgfx demo 截图 |
| 自研 | `Metin2Model` GDExtension 节点(gr2 → `Skeleton3D` + `ArrayMesh` + 材质) |
@@ -20,11 +25,11 @@
| 阶段 | 平台 | 里程碑 | 回答什么 | 产出 |
|---|---|---|---|---|
| **Phase 1** | macOS | M0' · M1 · M2 · **M2.5** | ① gr2 → Godot 场景对象能否正确映射;② 动画运行时(采样 / retarget / 事件)能否移植;③ **Metin2 观感能否在 Godot 里还原** | 中期评审报告:桥接层工作量实测 + 观感对比 + 剩余风险 |
| **Phase 2** | iOS · Android | M3(·M4 可选) | ④ 跨平台三端一致;⑤ 移动端性能 / 生命周期 | **go / no-go 结论** |
| **Phase 2** | Android(一加 13)· iOSiPhone 16 | M3(·M4 可选) | ④ 三端渲染一致;⑤ 两台移动设备性能 / 生命周期 | 三设备 bring-up 报告 + 性能留档 |
**关键澄清(写给评审)**Godot 在 macOS 上渲染一个蒙皮 gr2 **几乎不可能失败**。Phase 1 不是用来判断"该不该押 Godot"的——那个只有 Phase 2 能答。Phase 1 的价值是把**平台无关**的 GDExtension 工程(占本方案工程量大头)做完并确认视觉保真度可达,从而让 Phase 2 只剩"移动端 + 性能"这一个真未知
**关键澄清**Godot 在 macOS 上渲染一个蒙皮 gr2 **几乎不可能失败**Phase 1 的价值是把**平台无关**的 GDExtension 工程(占工程量大头)做完并确认视觉保真度可达。bgfx 自研引擎方案已于 2026-08-29 停止开发,Godot 是唯一在研路线;本项目自用、不对外发布,**没有"押哪条路线"的产品决策**。Phase 2 只剩"三台目标设备(Mac / 一加 13 / iPhone 16)上能不能跑 + 性能够不够自己用"
**进入 Phase 2 的前置**M2.5 门禁通过,且中期评审判定"桥接层工作量与观感可接受"。否则在此转回 bgfx route,损失仅为 GDExtension 桥接层(≈ glTF importer 量级),`libgr2` 完整保留
**进入 Phase 2 的前置**M2.5 门禁通过中期评审确认桥接层与观感可接受即可推进。三台目标设备都是现代硬件,全程不涉及 GLES3 / Compatibility 渲染器,不做中低端 / 老机器
---
@@ -38,8 +43,8 @@
### Phase 2 要证明的两件事(移动端,本轮不做)
4. **跨平台一致** — 同一 GDExtension + Godot 工程在 iOS 真机 + Android 真机(Vulkan + Compatibility)渲染与桌面一致。
5. **性能 / 生命周期达标**中端机单角色 / 多角色帧率达标,后台 / 恢复稳,Godot 节点开销可控。
4. **三端一致** — 同一 GDExtension + Godot 工程在 iPhone 16Metal+ 一加 13Vulkan)渲染与 Mac 一致。
5. **性能 / 生命周期够自用**两台设备单角色 / 多角色帧率可接受(旗舰基线,非发布门槛),后台 / 恢复稳,Godot 节点开销可控。
### 明确排除(两个阶段都不做)
@@ -49,13 +54,11 @@ SpeedTree 植被、地形(Metin2 户外地图)、UI / Python 脚本层、ete
### 判定口径
- **M2.5 门禁通过 + 中期评审"可接受"** = 进 Phase 2。
- **门禁失败区分两类**(同 `PLAN.md` §01 口径):
- **工程性延期** — 材质差色、某曲线子类型没实现、某 API 用错。有明确修法,记下继续
- **方案性死路** — 例如 Godot 节点 / 骨骼模型无法表达 Metin2 的多部件挂点体系、或多 stage 固定管线在 `ShaderMaterial` 里无法近似到可接受、或每帧写骨骼在合理规模下 Mac 上就已经卡。这才触发"转回 bgfx route"。
- **最终交付(Phase 2 末)** 一页纸对比结论:与 bgfx route 在 **工作量 / 性能 / 视觉保真度 / 长期维护成本** 四轴上的取舍。
- **M2.5 门禁通过 + 中期评审"可接受"** = 进 Phase 2。中期评审已判定桥接层工作量与观感均可接受([`MIDREVIEW.md`](./MIDREVIEW.md));bgfx route 已停,无「转回」分支。
- **Phase 2 若撞上方案性死路**(自写蒙皮着色器在 iPhone 16 / 一加 13 上跑不出、Godot 节点开销在自用规模下就卡)——记录清楚、就地想办法(回落 CPU LBS / `RenderingServer` 直调 / uniform 传骨骼),不再有另一条引擎路线兜底。
- **Phase 2 末交付**:三设备 bring-up + 性能留档报告(自用参考,不做对外选型结论)
### 与 bgfx route 的关系
### 与 bgfx route 的关系(历史对照,bgfx route 已于 2026-08-29 停止开发)
| | bgfx route`PLAN.md` | Godot route(本文件) |
|---|---|---|
@@ -82,8 +85,7 @@ mtgodot-poc/
gr2_bridge.{h,cpp} libgr2 POD 视图 → Godot 数组打包;basis + 单位换算(集中一处)
dds_loader.{h,cpp} DDS → Godot Image(散文件路径用;res:// 里的 .dds 交 Godot 原生导入)
m2_material.{h,cpp} texture-stage 描述 → ShaderMaterialM2.5
libgr2/ submodule → ../xrender-poc/libgr2
formats/ submodule → ../xrender-poc/formatsmsa/msm
(libgr2 / formats 在仓库根,顶层 CMake add_subdirectory 进来)
SConstruct / CMakeLists godot-cpp 构建(Phase 1 只出 macOS arm64
project/ Godot 4.7.1 demo 工程
main.tscn orbit 相机 + DirectionalLight3D(+shadow) + WorldEnvironment(sky) + Metin2Model
@@ -93,7 +95,7 @@ mtgodot-poc/
export_presets.cfg macOSPhase 1
test/
golden/ godot-macos-*.png
compare.py 与 ../xrender-poc bgfx demo / oracle 对拍
compare.py 与 test/bgfx-reference/ 参考截图 + oracle 对拍
docs/
GODOT-POC-PLAN.md 本文件
steps/ 按需拆
@@ -122,16 +124,16 @@ mtgodot-poc/
| 项 | 选择 | 理由 |
|---|---|---|
| 引擎版本 | **Godot 4.7.1 stable**(本机已装),锁死不随手升 | GDExtension ABI 绑 minor;升级作独立任务 |
| 扩展机制 | `godot-cpp` **`4.7` 分支**GDExtension(非模块编译) | 用官方导出模板,不自定义 Godot 构建 |
| 扩展机制 | `godot-cpp` **`101ae38034304346a46ea9ea84ae156d3e860496`**GDExtension(非模块编译) | gitlink 精确锁定,目标 API 4.7;升级作独立任务 |
| 构建工具 | `godot-cpp`**CMake 路径**(本机已有 cmake;不装 SCons)或 `brew install scons` 二选一 | Phase 1 只出 macOS arm64,摩擦最小 |
| gr2 解析 | `xrender-poc/libgr2` 静态库嵌入 extension | 与 bgfx route 同一份,已过 9166 fuzz + oracle |
| 动画方案 | **B(每帧写骨骼)**A(烘 `Animation`**决策推迟到 Phase 2** | B 直接复用 `libgr2` 采样、最保真;A 的动机(多角色 CPU)在 Mac 上压不出来 |
| 骨骼权重 | `ArrayMesh` 4 权重优先;>4 影响则 `ARRAY_FLAG_USE_8_BONE_WEIGHTS` | M1 T1.2 早验,三种骨架都查 |
| 贴图 | 见 §02 实操约定(`.dds` 原生导入 vs 运行时 `dds_loader`T1.3 定) | 两条都留 |
| 材质 | **自定义 `ShaderMaterial`**`light()` 自定义着色贴 Metin2 观感);M1 先 `StandardMaterial3D` 上屏,M2.5 换 | 多 stage 只做近似,列差距清单 |
| 渲染器 | Forward+macOS | Phase 1 不涉及 Compatibility |
| 渲染器 | Forward+ / Mobile renderer | 三台目标设备(Mac / 一加 13 / iPhone 16)都是现代硬件,全程不涉及 Compatibility(GLES3) |
| 阴影 / sky | Godot 内置 `DirectionalLight3D` shadow + `WorldEnvironment` procedural skyM2.5 接入 | 成本近零,Mac 观感必要 |
| 资产来源 | 从 `m2dev-client-main/assets` **warrior + 另 2 种骨架** 的 gr2/dds 到 `project/assets/`,散文件 | 不接 eterpackPhase 2 / M4 |
| 资产来源 | 从 `assets/` **warrior + 另 2 种骨架** 的 gr2/dds 到 `project/assets/`,散文件 | 不接 eterpackPhase 2 / M4 |
| 真值源 | `xrender-poc/oracle` golden + bgfx demo 同机位 / pose / t 截图 | 复用现成对拍基建 |
| 仓库 | 新建 `mtgodot-poc/``libgr2` / `formats` submodule 指向 `xrender-poc` | 隔离,不污染 bgfx route |
@@ -210,9 +212,9 @@ mtgodot-poc/
---
### M3 ·(Phase 2,本轮不做)iOS + Android + 性能
### M3 ·(Phase 2,本轮不做)Android + iOS bring-up + 性能
移动端真机、三端一致、性能、生命周期、Compatibility 渲染器、每帧写骨骼的 A/B 决策 —— **go / no-go 结论在此产生**。详见后续 `steps/M3-mobile.md`(届时再写)。
一加 13Vulkan+ iPhone 16Metal)真机跑起来、三端一致、性能留档、生命周期。iOS 需把 GDExtension 按 arm64 **静态库**编出来(链进 app 二进制)。不涉及 Compatibility 渲染器。详见后续 `steps/M3-mobile.md`(届时再写)。
### M4 ·(Phase 2 可选拉伸)
@@ -244,7 +246,7 @@ mtgodot-poc/
| `.gr2` 被 Godot import 流程扫到报错 | 打开工程时一堆 import 错误 | `project/assets/.gdignore``.gr2` 走运行时绝对路径 / `user://`T1 前定死 |
| gr2 basis / 单位换算到 Godot(左手 → 右手 Y-up + 缩放) | M1 模型躺 / 镜像 / 骨骼反向 / 大小离谱 | `gr2_bridge` 一次性处理,定 `M2_TO_GODOT`,用朝向明确资产手校,写注释 |
| `Skeleton3D` 骨骼数上限 / 8 权重路径行为 | M1 T1.2 蒙皮爆开 / 部位错位 | 三种骨架都查 `gr2dump`>4 权重走 8-bone flag |
| **多 stage 固定管线材质无法在 `ShaderMaterial` 近似到可接受** | M2.5 T2.5.2/3 关键装备观感明显错 | **这是 Phase 1 主要方案性风险**;差距清单区分"可延期"vs"死路",后者触发转回 bgfx route |
| **多 stage 固定管线材质无法在 `ShaderMaterial` 近似到可接受** | M2.5 T2.5.2/3 关键装备观感明显错 | **这是 Phase 1 主要方案性风险**;差距清单区分"可延期"vs"死路"。中期评审已判定为可延期(观感打磨,见 `MIDREVIEW.md`);bgfx route 已停,无「转回」分支 |
| 半透明 / additive 排序 corner case | M2.5 T2.5.3 发光部件穿插 | 记入差距清单;Godot 的 `render_priority` / `depth_draw` 调,不追求 100% |
| motion event 语义(`.msa`/`.msm` 帧事件类型) | M2 T2.3 事件类型没覆盖全 | Phase 1 只需打点回调,实际特效 Phase 2;未覆盖类型列清单 |
@@ -252,11 +254,11 @@ mtgodot-poc/
| 风险 | 缓解 / 退路 |
|---|---|
| 每帧 GDExtension 写骨骼的 CPU 成本(多角色) | M2 T2.7 已留档;转 A 方案(烘 `Animation` + `AnimationMixer` |
| Godot Compatibility(GLES3) 老安卓 shader 兼容 | M2.5 的 `ShaderMaterial` 写保守,避开 Compatibility 不支持特性 |
| 每帧 CPU LBS 全量重建 mesh 的成本(多角色) | `MTGODOT_GPUSKIN=1` 自写蒙皮着色器(已实现);多线程构建 + eterpack 共享资产 |
| 自写蒙皮着色器(`texelFetch` + RGBAF + `BONE_INDICES`)在 iPhone 16 Metal / 一加 13 Vulkan 上的行为 | Phase 2 各跑一次;出问题回落 CPU LBS 或改 uniform 数组传骨骼。三台都是现代设备,无 GLES3 约束 |
| Godot 节点开销(大场景规模) | `MultiMesh` / `RenderingServer` 直调兜底 |
| GDExtension ABI 绑 Godot minor | 锁 4.7.1,升级作独立任务 |
| iOS 交叉编译 `libgr2` 静态库 | 提前出 iOS `.a` 预编译产物 |
| iOSGDExtension 须静态链接进 app 二进制(`libgr2` 出 iOS arm64 `.a`),签名 / 模板脾气大 | 自用免费 provisioning 够用;预留 23 天 bring-up |
---
@@ -286,25 +288,28 @@ mtgodot-poc/
| 产物 |
|---|
| iOS `.app` + Android APK/AAB + 真机性能 / 生命周期报告 |
| **go/no-go 对比结论(一页纸)**:与 bgfx route 在工作量 / 性能 / 视觉保真度 / 长期维护四轴对比,给出客户端跨平台基座选型建议 |
| iOS `.ipa`iPhone 16+ Android APK(一加 13+ 两台真机性能 / 生命周期报告 |
| 三设备 bring-up 报告:三端渲染一致性截图 + 各设备帧率留档(自用参考,不做对外选型结论) |
---
## 选型变更记录
- 2026-08-29 · **设备范围定死(自用)**:目标 = MacmacOS / Metal+ 一加 13Android arm64 / Vulkan+ iPhone 16iOS arm64 / Metal),各一台。取消对外 go/no-go 门禁(bgfx route 已停、项目自用);Phase 2 收敛为「三设备各一次 bring-up + 性能留档」。**全程不涉及 GLES3 / Compatibility 渲染器**,不做中低端 / 老机器。SHINSOO 的 W0→W1+ 因此无外部门禁。
- 2026-08-29 · 范围收敛为 **Phase 1 = macOS only**;新增 **M2.5 材质与观感保真** 作为 Phase 1 核心门禁;动画 A/B 决策推迟到 Phase 2Godot 版本钉死 4.7.1 / godot-cpp 4.7。
- 2026-08-29 · **M1 T1.3**:贴图走 **运行时 `dds_loader`**`src/dxt.cpp`,端口自 xrender-poc)。理由:Godot 4 的 `Image.load()` 不支持 `.dds`,且资产从 `res://` 外的绝对路径加载(`.gr2``.gdignore` 同理)。DXT1/3/5 + BGRA8level 0 + `generate_mipmaps()`
- 2026-08-29 · **动画方案**确认走 **B 方案** —— `_process``gr2::sample_pose``Skeleton3D.set_bone_global_pose(i, gr2_to_godot(world[i]))`Godot GPU 蒙皮 `skin_matrix = global_pose(i) * bind_pose(i)` 恒等于 gr2 deformer 矩阵(`gr2_bridge.h` 有推导)。A 方案(烘 `Animation`)仍推迟到 Phase 2 按多角色 CPU 采样决定
- 2026-08-29 · **动画方案**B 方案(每帧 `gr2::sample_pose` 驱动)。**两条蒙皮路径**,都用 libgr2 的 `skin` 矩阵完整仿射(shear 保留):默认 **CPU LBS**`metin2_anim.cpp``Metin2Model::cpu_skin`,每帧重建 `ArrayMesh`);`MTGODOT_GPUSKIN=1`**自写蒙皮顶点着色器**`m2_material` `SRC_SKIN`,逐骨 4×3 矩阵存 RGBAF 纹理,`Metin2Model::gpu_skin` 每帧更新,绕过 `Skeleton3D`)。原因:Godot 内置 GPU 蒙皮 / `set_bone_pose` 会正交化骨骼矩阵、**丢掉 Granny 烘在 rig 骨(`bone_front_*`/`Bone_shoulder_*`warrior 7 / sura 16)上的 shear**`tools/anim_probe` 定位,非 libgr2 —— `world[]` 对拍 Granny ≤4.1e-5)。旧 `Skeleton3D::set_bone_pose` 路径已删。A 方案(烘 `Animation`)走 `Skeleton3D` 骨骼、同样丢 shear,不用
- 2026-08-29 · **坐标/单位**`gr2_to_godot()` = gr2 行主序 Mat4 的 4×4 转置 → Godot `Transform3D`Z-up cm → Y-up m`rotate(-90°,X)` + `scale(0.01)`)放在 `Metin2Model` 节点自身 transform`make_conv`)。warrior/warrior_lord/assassin/shaman_lord 实测 `flip_z=false``flip_winding=false``unit_scale=0.01` 正立、朝 +Z、无镜像。
### M2.5 T2.5.6 — 材质无法 1:1 还原 / 待办项清单
> **不再单独维护** —— 全部并入 [`BACKLOG.md`](./BACKLOG.md) B 段(`B1``B11`)。下表为历史快照。
| 项 | 现状 | 影响 | 出路 |
|---|---|---|---|
| **libgr2 POD API 不暴露材质数据** —— 只有 `material_count`/`texture_count`,无材质名、无贴图文件名、无 texture-stage op、无 `D3DBLEND_*`/alpha-test 标志 | 贴图靠**文件名约定 + 目录大小写不敏感扫描**(`_resolve_texture`);blend 靠**表面名关键字启发式**(`guess_blend`hair→alpha-test、cape/skirt→alpha、effect/glow→add,其余 opaque | warrior 5/5、assassin 17/17、shaman 1/1 表面命中贴图,但**多材质槽 → 具体贴图/混合的精确映射做不到**;换皮/多贴图变体、精确 alpha-test 阈值、MODULATE2X/ADDSIGNED 等 stage op 无法复现 | **需要给 libgr2 加材质 API**(新文件 `gr2_material.cpp`,走类型树取 `Materials[].Name` + `Maps[].Texture.FromFileName` + shader/blend hint)。这也正是 xrender-poc M1 T5「多 stage 材质对拍待 oracle」的同一缺口,两条 route 共用修法。 |
| **顶点色** | `gr2::Vertex` POD 无 color 字段;shader 已留 `COLOR` 通道,默认白 | Metin2 PC 模型基本不用顶点色,影响小;特效/地形会用 | libgr2 若暴露则 `build_mesh``ARRAY_COLOR` |
| **`TriGroup.material_index` 未接线** | libgr2 已解析每个三角组的 material_index,但 `build_mesh` 目前一个 gr2 mesh = 一个 surface(未按 material_index 再拆分) | 单 mesh 内多材质的模型会用同一张贴图 | `build_mesh``tri_groups` 的 material_index 分 surface(有材质 API 后一起做) |
| ~~`TriGroup.material_index` 未接线~~ **已做(2026-08-29** | `gr2_bridge::build_parts()` 一个 gr2 mesh `tri_groups` 拆成 N 个 `RenderPart` → N 个 Godot surface,贴图取 `Mesh::material_textures[matidx]`mesh-local,非全局 `Materials[]`)。GPU 和 CPU 蒙皮路径都走 `RenderPart`。 | — |
| **ShaderMaterial 静态 `Shader` 退出泄漏** | `shader_for()` 用 function-static `Ref<Shader>`Godot 退出时报 "1 shader never freed" | 仅退出时一条 warning,无功能影响 | 改成每材质自持 Shader,或注册 cleanup |
| **alpha 排序 corner case** | 半透明/additive 用 `render_priority` 粗排,未做逐三角/OIT | 多层半透明部件可能穿插 | 记录,不追求 100%Phase 2 M2.5 打磨) |
| **法线未做逆转置** | 走 Godot GPU 蒙皮,其法线处理与 Granny runtime 的「上 3×3 不逆转置」近似不完全一致 | 非均匀缩放骨骼下法线略有差异;warrior 类资产无明显问题 | Phase 2 逐帧对拍时量化 |
| **法线变换** | CPU 与 GPU`SRC_SKIN` 着色器)两条路径都用混合矩阵的**上 3×3、不逆转置** —— 与 Granny runtime 一致(见 `docs/reference/steps/M2-anim-skinning.md`)。非均匀缩放骨骼下这是 Granny 自己的近似,我们与之一致。 | 已一致 | — |
+45 -29
View File
@@ -1,7 +1,8 @@
# Phase 1 中期评审 — mtgodot-poc
> 日期:2026-08-29 · 平台:macOSApple M2)· Godot 4.7.1 · 对标 [`GODOT-POC-PLAN.md`](./GODOT-POC-PLAN.md) §00 Phase 1
> 结论先行:**桥接层工程量小、风险已基本出清,建议进入 Phase 2(移动端)**。M2.5 门禁的"材质精确映射"仍有一处需要 oracle 的开口,不阻塞。
> 日期:2026-08-29(评审后又做了一轮收尾,见 §4/§5 的「已做」标注)· 平台:macOSApple M2)· Godot 4.7.1
> · 对标 [`GODOT-POC-PLAN.md`](./GODOT-POC-PLAN.md) §00 Phase 1
> 结论先行:**桥接层工程量小、风险已基本出清,建议进入 Phase 2(一加 13 / iPhone 16 三设备 bring-up,自用)**。M2.5 材质门禁已闭(贴图走 gr2 material 绑定 + 逐 tri_group 拆 surface);仅剩逐材质 oracle 纸面对拍这一可选项。
---
@@ -11,54 +12,69 @@
|---|---|---|
| **M0'** 脚手架 | ✅ | GDExtension 加载、`Metin2Model`/`Metin2AnimPlayer` 注册、`libgr2` 静态链接、Godot 4.7 工程 Metal Forward+ 出画 |
| **M1** 静态渲染 | ✅ | `warrior_cheongrin`75 骨 / 5 表面 / 3324 顶点,正立、朝 +Z、`unit_scale=0.01``flip_z/flip_winding=false`、贴图正确(`test/golden/m1-bindpose.png`)。多骨架:`warrior_lord`(v6,74)、`assassin`(v6,95,17 表面)、`shaman_lord`(**v7**,93) 均加载渲染正确 |
| **M2** 骨骼动画 | ✅(渲染路径)/ ⚠️(一条 upstream bug | `general/wait`2.67s / 75 track)经 `gr2::sample_pose`(跨文件)→ 逐骨 Godot **局部 pose**`set_bone_pose`)→ GPU 蒙皮,头 / 护甲对齐正确(`test/golden/m2-wait.png`)。**数值验证**`get_bone_global_pose(i)` vs `conv(world[i])` 全 75 骨 ≤ **2.6e-5**CPU-LBS 参考路径(`MTGODOT_CPUSKIN=1`,与 xrender `skin_mesh` 同式)与 GPU 蒙皮同结果。`selfcheck` 0 NaN。**但**`dance_1` 等表情动作会让头 / 颈塌陷 —— **libgr2 曲线解码 bug,在 xrender-poc bgfx demo 里同样复现**见 §4 |
| **M2.5** 材质与观感 | 🟡 门禁未闭 | `ShaderMaterial`modulate tex×COLOR×lightopaque/alpha/alpha-test/add 四模式)+ 目录扫描贴图解析(warrior 5/5、assassin 17/17、shaman 1/1 表面命中)+ 方向光阴影 + procedural sky + 引擎雾(`test/golden/m25-*.png`)。`libgr2::dump_materials()` 已加(见 §4 |
| **M2** 骨骼动画 | ✅ | `gr2::sample_pose`(跨文件)→ 蒙皮,**两条路径都用 libgr2 的 `skin` 矩阵完整仿射(shear 保留)**:默认 **CPU LBS**(每帧重建 mesh);`MTGODOT_GPUSKIN=1` 走**自写蒙皮顶点着色器**`m2_material` `SRC_SKIN`,逐骨 4×3 矩阵存 RGBAF 纹理,绕过 `Skeleton3D`)。`dance_1` 两条路径头/护甲/躯干都正确、与 xrender-poc bgfx demo 一致。`selfcheck` 0 NaN。~~旧 `Skeleton3D::set_bone_pose` 路径~~ 已删(Godot 内置蒙皮正交化丢 shear见 §4 |
| **M2.5** 材质与观感 | 门禁已闭(材质精确映射);观感打磨留 Phase 2 | 4 个 `ShaderMaterial` shader`SRC_MIX`opaque / alpha / alpha-test)· `SRC_SKIN`(同 + GPU 蒙皮 vertex)· `SRC_ADD`additive)· `SRC_ADD_SKIN`。贴图 = **gr2 material 绑定**`Mesh::material_textures[matidx]`mesh-local)→ 找不到才 `_resolve_texture` 文件名启发式;`build_parts()``tri_group.material_index` 把一个 gr2 mesh 拆成 N 个 surfaceshaman 1→2、assassin 17→18、warrior 5→5,实测各面贴图正确)。blend/cull/alpha 靠 `guess_blend()` 按表面名启发式(effect→add、hair→alpha-test、cloak/cape/skirt→alpha)。方向光阴影 + procedural sky + `MTGODOT_FOG=1` 引擎雾(`test/golden/m25-*.png`)。 |
## 2. 桥接层工程量(实测)
新增 C++**1100**(含端口自 xrender-poc 的 DXT 解码 160 行)
`extension/src`**1997**不含 godot-cpp含端口自 xrender-poc 的 DXT 解码 ~180 行)
中期评审时约 1100 行,评审后收尾又加了 ~700(`build_parts` 拆 surface、`SRC_SKIN` GPU 蒙皮着色器、
`.msa`/`.msm` 解析、路径解析、逐 surface 材质缓存)。
| 文件 | 行 | 职责 |
|---|---|---|
| `gr2_bridge.{h,cpp}` | ~260 | gr2 Mat4→`Transform3D`4×4 转置)、`build_skeleton`/`build_skin`/`build_mesh`、Z-up→Y-up `make_conv` |
| `metin2_model.{h,cpp}` | ~470 | `Metin2Model` 节点、`load_gr2`、贴图解析、材质、属性 |
| `metin2_anim.{h,cpp}` | ~230 | `Metin2AnimPlayer`、每帧 `sample_pose`→骨骼、`selfcheck` |
| `m2_material.{h,cpp}` | ~130 | Metin2 风格 `ShaderMaterial`(两个 shaderblend_mix / blend_add |
| `dxt.{h,cpp}` | ~160 | DDS DXT1/3/5 软解(端口) |
| libgr2 `gr2_material.cpp`xrender-poc | ~110 | `dump_materials()` 类型树遍历(附加) |
| `metin2_model.{h,cpp}` | ~870 | `Metin2Model` 节点、`.gr2`/`.msm` 加载、`build_parts` 拆 surface、贴图解析、材质、CPU/GPU 蒙皮、属性 |
| `metin2_anim.{h,cpp}` | ~420 | `Metin2AnimPlayer`、每帧 `sample_pose` → CPU/GPU 蒙皮、`.msa` 解析 + motion event 派发、`selfcheck` |
| `gr2_bridge.{h,cpp}` | ~290 | gr2 Mat4→`Transform3D`4×4 转置)、`build_skeleton`/`build_skin``build_parts`+`build_mesh`、Z-up→Y-up `make_conv` |
| `m2_material.{h,cpp}` | ~235 | 4 个 `ShaderMaterial` shader`SRC_MIX` / `SRC_SKIN` / `SRC_ADD` / `SRC_ADD_SKIN`+ 退出前 `cleanup_material_shaders` |
| `dxt.{h,cpp}` | ~180 | DDS DXT1/3/5 软解(端口level 0 |
| `register_types.{h,cpp}` | ~40 | GDExtension 入口 |
| libgr2vendored)新增 `gr2_material.cpp` + `Mesh::material_textures` | ~150 | `dump_materials()` + 逐 mesh MaterialBindings→贴图名 |
**耗时**M0'M2.5 一次会话跑通。与计划 §05 估的 Phase 1「3.55 周单人」相比**核心桥接远比预估轻** —— 因为 `libgr2` 已完成、Godot 的 `Skeleton3D`/`ArrayMesh`/`Skin`/GPU 蒙皮直接可用,且关键恒等式(`skin_matrix = global_pose(i)·bind_pose(i) == gr2 deformer 矩阵`)成立,不需要自研蒙皮。剩余预估工作量集中在 M2.5 材质精修 + 对拍。
**耗时**M0'M2.5 一次会话跑通,评审后收尾一次会话。与计划 §05 估的「3.5–5 周单人」相比 **核心桥接远比预估轻** ——
`libgr2` 已完成、Godot 的 `Skeleton3D`/`ArrayMesh`/`Skin` 直接可用;蒙皮因 Godot 内置路径丢 shear(见 §4
改为自己做(CPU LBS 默认 + 自写顶点着色器),也只是 ~200 行。
## 3. 关键技术结论
- **坐标/单位**gr2 行主序 Mat4 → Godot `Transform3D` 就是一次 **4×4 转置**gr2 平移在第 4 行 = Godot `.origin`)。Z-up cm → Y-up m`rotate(-90°,X)` + `scale(0.01)`)放在 `Metin2Model` 节点自身 transform。四个测试骨架全部 `flip_z=false``flip_winding=false` 正确 —— **Metin2/Granny 内容对 Godot 右手系不需要额外镜像**
- **动画走 B 方案**`_process``sample_pose``world[]``skel.set_bone_global_pose(i, conv(world[i]))`Skin bind = `conv(inverse_world[i])`。Godot 的 GPU 蒙皮矩阵 `global_pose(i)·bind(i)` 恒等于 gr2 deformer 矩阵。**不需要烘焙 `Animation` 资源** 即可保真(A 方案仅在移动端多角色 CPU 压不住时才需要)。
- **贴图**Godot 4 `Image.load()` 不支持 `.dds` → 运行时软解 DXT`dxt.cpp`)。`.gr2``.gdignore` + 绝对路径加载,绕开 Godot import。
- **移动端预判更好**M2.5 的 `ShaderMaterial` + `ArrayMesh` + `Skeleton3D` 都在 Godot Compatibility(GLES3) 支持范围内 —— Phase 2 老安卓机兜底比"Godot 里塞自研渲染器"那条路好
- **动画走 B 方案 + 自做蒙皮**`_process``sample_pose``world[]` / `skin[]``skin[i] = inverse_world[i]·world[i]`)。**Godot 内置蒙皮(`Skeleton3D` + `set_bone_pose`)不能用** —— 它把每骨 transform 分解成 position + 单位四元数 + 逐轴 scale,丢掉 Granny 烘在 rig 骨上的 shear(§4)。改为:默认 **CPU LBS**`Metin2Model::cpu_skin`,每帧把 `skin[]` 逐顶点作用、重建 `ArrayMesh`);`MTGODOT_GPUSKIN=1` 走**自写蒙皮顶点着色器**`SRC_SKIN`,逐骨完整 4×3 矩阵存 RGBAF 纹理)。`set_bone_global_pose` / `Skeleton3D` 蒙皮路径已删。不烘 `Animation` 资源(A 方案走 `Skeleton3D` 骨骼、同样丢 shear,不用)。
- **贴图**Godot 4 `Image.load()` 不支持 `.dds` → 运行时软解 DXT`dxt.cpp`level 0 + `generate_mipmaps()`)。`.gr2``.gdignore` + 绝对路径加载,绕开 Godot import。
- **移动端预判**目标设备定为 MacMetal+ 一加 13Vulkan+ iPhone 16Metal),均现代硬件,**不涉及 Compatibility(GLES3)**。CPU LBS 路径(默认)+ `ShaderMaterial` + `ArrayMesh` 无平台特性风险。自写蒙皮着色器用了 `texelFetch` + RGBAF 纹理 + `BONE_INDICES` —— Vulkan / Metal 都支持,Phase 2 在一加 13 / iPhone 16 各跑一次确认(出问题则人群场景回落 CPU 或改 uniform 数组传骨骼)
## 4. 剩余风险 / 门禁开口
| 项 | 影响 | 出路 |
|---|---|---|
| **libgr2 曲线解码 bug —— 部分动画头 / 颈塌陷**`dance_1` 等表情,`rot:Old(d=2,dim=4)` 二次四元数 B 样条)。**这不是 Godot route 的问题** —— 已用 xrender-poc 的 bgfx demo 在同一动画 / 同一 t 复现完全一致的塌陷(`/tmp/xr-*.png`),且 mtgodot 的 CPU-LBS 参考路径(与 xrender 同式)也复现。`wait` / `run` 等动画正常。xrender-poc M2「对拍 Granny ≤4.6e-5」的 23 用例未覆盖到这个可视化用例。 | 表情动作不可用;idle / 移动动画正常。当前 demo 默认 `general/wait` | libgr2 `gr2_anim.cpp``Curve::eval` degree-2 分支 vs Granny `OldCurve` B 样条基对拍修正(需 Granny oracle)。两条 route 共用修法。**属 xrender-poc/libgr2 的活,不阻塞 Godot route 结论** |
| **M2.5 材质精确映射未闭**M2.5 门禁开口)| `libgr2::dump_materials()` 已能取材质名 + `FromFileName`warrior 4 材质 / 2 有 diffuse 名),但 material_index→贴图 的映射**未对拍 Granny**,实测驱动 warrior 时 body 表面误取 face 贴图。当前默认走文件名+目录扫描启发式(观感正确),material 路径 `use_gr2_materials` 开关默认关 | 需要 Granny oracle 逐材质对拍(与 xrender-poc M1 T5 同一缺口,共用修法)。这是 Phase 1 收尾的最后一项 |
| **对拍未接线** | 与 xrender-poc bgfx demo / oracle 的逐帧几何/骨骼矩阵对拍脚本(`test/compare.py`)未写 | 需先构建 xrender-poc bgfx demo(首次 ~10 min)。中期评审后、进 Phase 2 前补 |
| **性能数字被节流** | 后台窗口下 Godot 限 30fps1–50 角色帧时间都贴在 33ms 天花板;100 角色破顶到 52ms。资产构建 **~165ms/角色**(单线程,无跨模型缓存),100 角色加载 16.5s(`test/godot-macos-stress.json`) | 真机 / 前台窗口下重测(Phase 2)。构建成本需 Phase 2 做多线程 + eterpack 共享贴图 |
| **多材质槽内拆分** | `build_mesh` 目前一个 gr2 mesh = 一个 surface,未按 `tri_groups[].material_index` 再拆 | 有 material API 对拍后一起做 |
| ShaderMaterial 静态 Shader 退出泄漏、alpha 排序 corner case、法线未逆转置 | 均为已知小项,见 `GODOT-POC-PLAN.md` §M2.5 T2.5.6 | 不阻塞 |
| ~~libgr2 曲线解码 bug —— 头 / 颈塌陷~~ **→ 已定位并修复(2026-08-29,误判)**。不是 libgr2`tools/anim_probe` 全时间轴扫 `dance_1` 240 帧,libgr2 的 `world[]` 全部良态(0 奇异 / 0 NaN,Head/Neck 是干净刚体),且 `oracle/run-diff-suite.sh` 已对拍真 Granny @ t=0/4.7/11.3/20 ≤4.1e-5(含 `d=2` 四元数曲线)。真因:**Granny 给每个 Metin2 模型的 ~7 根骨(`bone_front_01..04``Bone_shoulder_01..04` —— 前襟条 + 护肩片)烘了 shear + 非均匀缩放**world det 低到 0.065),而 Godot `Skeleton3D::set_bone_pose` 把 transform 分解成 position + 单位四元数 + 逐轴 scale**shear 被静默丢弃**TRS 往返误差 0.07–0.94)→ 那几根骨塌。`wait` 同样有这 7 根坏骨,只是动得少不明显(不是 `dance_1` 专属)。 | **已消除**:① CPU 线性混合蒙皮转默认路径(`Metin2Model::cpu_skin`),直接把 libgr2 的 `skin[]` 逐顶点作用、绕过 `Skeleton3D`;② `MTGODOT_GPUSKIN=1` 走自写蒙皮顶点着色器(`SRC_SKIN` / `SRC_ADD_SKIN`,逐骨完整 4×3 矩阵存 RGBAF 纹理),不正交化、shear 保留。旧 `set_bone_pose` 路径已删。headless 对拍:`dance_1` @ t=7/19 两条路径头/颈/躯干与 xrender-poc bgfx demo 一致。 | 已完成。代价:CPU 路径每帧变顶点 + 重建 mesh(人群切 GPU 着色器路径)。`anim_probe` 扫全 racewarrior 7 根、**sura 16 根** shear 骨(`test/shear-bones-survey.md`),故自写着色器是必需、非优化。 |
| ~~M2.5 材质精确映射未闭~~ **→ 基本闭合(2026-08-29**。`tri_group.material_index`**mesh-local** 索引(进 `Mesh::MaterialBindings`),不是全局 `Materials[]` —— 原来用 `dump_materials()[matidx]` 才把 body/face 搞混。改用 libgr2 的 `Mesh::material_textures[matidx]`(与 `MaterialBindings` 平行、xrender-poc session 里已按渲染结果验过 warrior/sura/assassin/shaman 全对)。`use_gr2_materials` 默认开、文件名启发式降为回退。 | 已消除。剩:跑一次 `oracle/run-diff-suite.sh` 级别的逐材质对拍做纸面确认(可选,观感已对);MODULATE2X/ADDSIGNED 等 stage op 仍只近似 |
| ~~多材质槽内拆分~~ **→ 已做**`gr2_bridge.cpp``build_parts()`,一个 gr2 mesh 有 N>1 个 `tri_groups` 就拆成 N 个 Godot surface(各自 material + 索引子区间)。`build_mesh` 和 CPU 蒙皮路径都按 `RenderPart` 走。shaman1 mesh→2 surface)、assassin17→18)实测各面贴图正确。 | 已做 |
| ~~对拍未接线~~ **→ 已写 `test/compare.py`2026-08-29** | oracle gate(读 `test/m2-numeric.json`libgr2 vs Granny 23/23 ≤6.5e-5+ 每 case headless 跑 mtgodot**判据**:进程退出码 0、截图存在、`surfaces>0`、动画 selfcheck `NaN/Inf=0`(CPU 与 GPU 两条路径各一遍)。产出 `.cpu.png`/`.gpu.png` + `test/compare-report.json`。渲染器 / 相机不同,不做与 bgfx-reference 的像素比。**缺**:带容差的自动视觉回归门禁(BACKLOG H3)。 | 基本完成 |
| **性能** | 前台窗口 M2 重测(`test/godot-macos-stress.json`,默认 CPU LBS):1 角色 46fps、10 角色 36fps、30 角色 24fps。CPU 路径瓶颈是每帧 `cpu_mesh` 全量重建(不是 LBS 数学)。构建仍 ~170ms/角色(单线程无缓存)。**GPU 蒙皮着色器已实现**(`MTGODOT_GPUSKIN=1`,每帧只更新一张骨骼纹理),人群场景应切它 —— stress harness 未用 GPU 路径重跑。 | Phase 2GPU 路径跑人群 bench + 多线程构建 + eterpack 共享资产 + 一加 13 / iPhone 16 实测。 |
| 剩余小项 | ~~ShaderMaterial 静态 Shader 退出泄漏~~ 已修(`cleanup_material_shaders``register_types` 卸载时调)· alpha 排序 corner case`render_priority` 粗排)· 背面剔除写死 `cull_back`Metin2 有 `CULL_NONE` 面)· `_frame_model` 对披风类框选偏 | 均不阻塞,见 [`BACKLOG.md`](./BACKLOG.md)`A3` `A6` `B10` `E5`)。法线用混合矩阵上 3×3 不逆转置 —— **与 Granny runtime 一致,是正确行为不是风险** |
## 5. 建议
**进入 Phase 2iOS + Android)。**
**进入 Phase 2一加 13 / iPhone 16 三设备 bring-up,自用)。**
理由:Phase 1 要证明的三件事里,①gr2→Godot 场景对象、②动画运行时移植,**已用可运行代码 + 截图 + 0-NaN 自检证明可行且工程量小**;③观感还原的基础设施(自定义 shader、阴影、sky、fog、多骨架已就位,只差"材质精确映射"这一处需 oracle 的收尾,不构成方案性风险。
理由:Phase 1 要证明的三件事里,①gr2→Godot 场景对象、②动画运行时移植,**已用可运行代码 + 截图 + 0-NaN 自检证明可行且工程量小**;③观感还原自定义 shader、逐 tri_group 材质、阴影、sky、fog、多骨架已就位,M2.5 门禁(材质精确映射)已闭,剩余是观感打磨(多 stage op / cull / 阴影调优),不构成方案性风险。
Phase 2 前的收尾清单1.52 周)
1. `test/compare.py`,与 xrender-poc bgfx demo 同机位 / 同 t 对拍几何 + 骨骼矩阵
2. `libgr2::dump_materials` 的 Granny oracle 对拍,闭合 M2.5 门禁
3. **修 libgr2 `Curve::eval` degree-2 四元数 B 样条**(对拍 Granny oracle)—— 这条其实是 xrender-poc/libgr2 的活,两条 route 共享收益;修好后表情动作即可用
4. 前台窗口 / 真机重测性能,给 Phase 2 的动画 A/B 一个可信基线
Phase 2 前的收尾清单 —— 大部分已在 2026-08-29 做完
1. `test/compare.py` —— oracle gate + 每 case 退出码 / 截图 / `surfaces>0` / `NaN=0` 判据(CPU + GPU 两路)。`compare-report.json`
2. ✅ M2.5 材质门禁 —— `build_parts()``tri_group.material_index` 拆 surface + 用 `Mesh::material_textures`mesh-local)取贴图。shaman/assassin 多材质面正确。逐材质 oracle 纸面对拍可选
3. ~~修 libgr2 曲线~~ 误判 —— CPU 蒙皮转默认(见 §4)
4. ✅ 前台性能重测(`test/godot-macos-stress.json`):30 角色 24fps;瓶颈是每帧 mesh 全量重建
5.`anim_probe` 全 race 扫(`test/shear-bones-survey.md`):warrior 7 根、**sura 16 根** shear 骨;assassin/shaman 干净。据此**已实现自写 GPU 蒙皮顶点着色器**(`SRC_SKIN`,逐骨完整 4×3 矩阵)—— `MTGODOT_GPUSKIN=1` 现在走它、不再走坏的 `Skeleton3D`。warrior `dance_1` GPU 路径与 CPU 一致(`test/compare-shots/*.gpu.png`)。
6.`.msa` 接线:`anim_path``.msa``MotionFileName` + `Accumulation``get_accumulation`+ `MotionEventData``get_events()` + `motion_event` 信号,带循环回绕)。`throw.msa` 事件 t≈0.83 触发。
7. 🟡 `.msm` 接线:`gr2_path``.msm` → 解析 + 解析 `BaseModelFileName`(含 eterpack 散包目录扫描)自动加载 base model + `get_hair_options()` 发型目录(75 项)。`warrior_w.msm` 实测。**发型 mesh 实际挂接仍 ⬜** —— 需 base 骨架的逐帧矩阵(与 anim player 共享),属 Phase 2 模型组合。
> Godot route 本身的桥接层已验证正确`wait` 渲染与 xrender bgfx demo 一致,骨骼矩阵 ≤2.6e-5CPU/GPU 蒙皮一致)。#3 是把已知的 upstream 曲线 bug 补上,不是 Godot route 的缺陷。
> Godot route 桥接层已验证正确:数值真值 = `oracle_diff`libgr2 vs 真 Granny 2.9.1223 用例 ≤6.5e-5),
> 两条蒙皮路径都逐顶点作用这批 `skin[]` 矩阵;CPU 与 GPU 渲染 `wait` / `dance_1` 与 xrender bgfx demo 抽帧一致。
> 原 #3「libgr2 曲线 bug」是误判,真因是 Godot 内置蒙皮(`set_bone_pose` / `Skeleton3D`)正交化丢 shear,已用自做蒙皮规避。
> (旧文里的「`get_bone_global_pose` vs `conv(world)` ≤2.6e-5」是已删的 `Skeleton3D` 校验路径的数字,不再适用。)
不建议现在做的:地形、`.mse` 特效、eterpack、UI/Python —— 均按计划留到正式移植。
> 「离一个可用客户端还差什么」的完整分层清单见 [`BACKLOG.md`](./BACKLOG.md)A Phase-1 收尾 /
> B 材质保真 / C 动画运行时 / D 模型组合 / E 渲染功能 / F 平台 / G 资产管线 / H 测试工程 /
> I libgr2 覆盖面),含条目 ID、粗估规模、排期。
+248
View File
@@ -0,0 +1,248 @@
# 渲染一致性差距清单 —— 距 `ingame-shinsoo.png` 还差什么
> 更新:2026-08-29 · 配套 [`SHINSOO-WORLD-RENDERING.md`](./SHINSOO-WORLD-RENDERING.md)(实施规格)、
> [`BACKLOG.md`](./BACKLOG.md)。
>
> **本文回答一个问题**:现在 `test/golden/world-a1-{persp,r2}.png` 到「与游戏内截图目视等价」之间,
> 还有哪些工作。不是 SHINSOO 的重复 —— SHINSOO 是「怎么把世界渲染出来」,本文是「怎么把观感做到
> 参考图那样」。当前已落地 W0–W6 的可运行纵切,以及 W7/W8 的部分首版;必要 `.mse`、真实 UI、
> 完整流式性能路径和移动端验证仍未完成。本文同时列出**正确性缺口**与**保真化缺口**。
---
## 0. 「一致」的定义与现状
**目标**:在同一全局坐标 + 同一 yaw/pitch/FOV + 同一时间/天气下,Godot 输出与原 D3D 客户端截图,
经 ROI 直方图 + 边缘结构(SSIM)+ 人工签核判为「同一场景、同一观感」。不要求逐像素(渲染器不同)。
**现状**`world-a1-persp.png` / `world-a1-r2.png`):
- ✅ 地貌 / 道路网 / 建筑布局 / 水域形状 = **认得出是神兽国 A1**
- ✅ 第三人称角色(带发型)在地形上跑、HUD 布局在位、`.msenv` 驱动的天空/光/雾
- ❌ 观感**不等价**:树仍是 proxy(虽已用真实 bark/composite atlas)、无 `.mse` 特效、水面已用真 30 帧序列但深度系数未标定、HUD 已用真实九宫格贴图但布局未按受控帧对齐、光照/色调未对受控帧校准、机位随手放、角色材质 sphere-map 高光管线已端口但默认休眠(无装备层驱动 power)
**粗估**:把下面全部做到「目视等价」,单人 **≈ 36 个月**(不含 Phase 2 移动端 bring-up
需在地形/水面正确性修复和 `.spt` 转换可行性验证后重估)。`.spt` 当前缺少可分发的跨平台 reader;
`.mse` 是 ASCII 文本且原客户端带 parser,不属于不可读资产,但完整 EffectLib 运行时移植仍是大工程。
### 0.1 `ingame-shinsoo.png` 受控参考契约
本文的具体目标帧不是泛指「某张神兽国截图」。实施前必须创建
`test/maps/ingame-shinsoo.json`,把下列状态冻结;无法从截图可靠反推的值一律写成
`待采集`,不能凭观感猜一个值后继续调材质。
| 域 | 必须冻结的字段 |
|---|---|
| 输出 | 1920×1080、Godot 版本、renderer、MSAA/TAA、色彩空间、随机种子 |
| 地图 | map id、全局坐标、加载半径、可见区块 |
| 相机 | yaw、pitch、roll、与角色距离/高度、FOV、near/far |
| 角色 | race、sex、body/hair、武器/装备、动作名、动画时间/帧 |
| NPC | race、装备、全局坐标、动作帧、名字/标签状态 |
| 环境 | `.msenv`、时间/天气、太阳方向、雾、曝光、后处理参数 |
| UI | locale=`es`、背包打开、圆形小地图、聊天/状态栏/快捷栏以及顶部三行消息内容 |
**门禁**:上表未落盘前,只允许修格式/管线正确性问题,不接受「为匹配这张图」进行的相机、光照、
色调或材质调参。参考图本身应进入仓库为 `test/reference/ingame-shinsoo.png`,避免依赖个人目录路径。
### 0.2 单帧范围与完成定义
对这张目标图,阻塞交付的可见项按顺序是:固定机位 → 地形全部 splat/UV → 建筑与材质 →
非占位树 → 玩家/NPC/装备/阴影 → 光照与色调 → 真实 HUD。水体、完整 `.mse`、移动端流式和远离
机位的地图内容,只有在目标帧或其验收 ROI 内可见时才阻塞该单帧;被排除项必须在 preset 中记录
原因和对应 ROI,不能把「当前看不到」写成「已经支持」。
单帧完成同时要求:
- 世界层能辨认为同一位置、同一构图,主要建筑、道路、树冠和角色轮廓对齐;
- UI 层在 1920×1080 下使用真实贴图/字体,窗口边界、锚点、层级与参考图一致;
- 通过 §9 的世界 ROI、UI 几何门禁与人工签核;不同渲染器导致的逐像素差异不作为失败条件。
### 0.3 精确资产清单
另建 `test/maps/ingame-shinsoo-assets.json`,逐个登记目标帧里**实际可见**的资源,而不是只写资源类别:
| 可见对象 | 清单必须记录 |
|---|---|
| 地形 | map/区块、`height.raw``tile.raw`、TextureSet 图层及 shadow/minimap |
| 建筑/道具 | property CRC、buildingfile、实例变换、GR2、每个材质贴图 |
| 树 | property CRC、treefile、实例变换、最终使用的 mesh/LOD/impostor 与树皮/叶片贴图 |
| 玩家/NPC | MSM/MSA/GR2、body/hair、武器装备、动作和实例位置 |
| UI | 每个窗口的脚本/贴图/locale 图、字体、图标和九宫格来源 |
| 环境 | `.msenv`、天空/云/flare 贴图以及所有颜色、雾、曝光输入 |
每条资源保存原虚拟路径、`AssetResolver` 最终命中的磁盘路径、pack 优先级和内容 hash。这样 assets
目录内容变化后,golden 偏差能够定位到具体资源,而不是重新肉眼猜测。
---
## 1. 特殊资产与专项管线
| # | 项 | 为什么卡 | 实施路径 | 规模 |
|---|---|---|---|---|
| 1.1 | **`.spt`SpeedTree 树)** | 几何解析在闭源 `CSpeedTreeRT::LoadTree()`;仓库静态库实测为带符号的 **COFF x86-64**,不能链进 macOS/移动端且分发需确认授权。`formats/spt` 已能嗅探 bark/composite atlas,真实资产共 **118** 个 | (a) 先用随库 `SpeedTreeRT.h` + `.lib` 验证经授权的 Windows x64 离线 exporter,调用 `LoadTree/Compute/GetGeometry``.glb`;(b) 当前运行时 proxy 使用真实 bark/composite DDS(c) exporter 不可用时继续用授权清晰的替代树 | L(exporter spike 约 1 周;成功后转换与校形 2–3 周) |
| 1.2 | **`.mse`(特效 / 粒子)** 🟡 首版打通(见 `CLIENT-ROADMAP.md` P5| 资产是 ASCII Group/List 文本;`EffectLib/EffectData.cpp``ParticleSystemData.cpp``EffectMesh.cpp``SimpleLightData.cpp` 已包含 parser 和运行时真值 | ✅ `project/fx/``mse.gd` 文本树解析(真文件 6 particle)· `effect_player.gd` Particle→`GPUParticles3D`(发射形状/曲线/颜色渐变/billboard/加法混合)· `effect_registry.gd` 名字→`.mse`+缓存 · `wire.h` `GC_SPECIAL/SPECIFIC_EFFECT``M2Client.effect_cue`。⬜ `.dds` 粒子纹理、`.mde` mesh 解码、SimpleLight(本资产集未用)、绑骨骼挂点、时间轴位置动画 | L–XL(parser 端口 + 运行时/材质/挂点,独立子项目) |
| 1.3 | **lens flare** | `.msenv``LensFlare` 块(已解析),但需屏幕空间实现 | 太阳屏幕投影 + flare billboard;遮挡先用相机到太阳方向的物理射线,或在后处理里读深度。若使用低层 occlusion query,必须基于锁定的 Godot 4.7 API 实证,不使用不存在的 `get_1d_probe` | SM |
### 1.4 树木方案门禁
A1 已确认 **368 棵 / 14 树种**,全资产树库共 118 个 `.spt`;现有 R2 proxy 已按 treefile 分组为
`MultiMeshInstance3D`,从 `.spt` 嗅探真实树皮/composite atlas,并生成确定性枝干与多组交叉叶簇。
它仍没有真实 SpeedTree 几何、leaf table、LOD/impostor。批量开发前先从最终机位按屏幕投影面积选 3 个代表树种,
分别验证以下来源:
1. 具备授权且能实际读取这些旧 `.spt` 的 SDK/离线 converter
2. 保留原 treefile → 替代 mesh 映射的人工/半自动重建;
3. 改良程序化树,仅作为 converter 和替代 mesh 都不可行时的降级路径。
每条路径都必须产出同一份 `TreeDescriptor`(树干/叶片材质、LOD0/1、远景 impostor、包围盒、风参数),
并以树冠轮廓、树干比例、叶片 alpha、环境着色、阴影、LOD 跳变、批次数和授权可分发性签核。
未通过单树 spike 前不批量处理 118 个 `.spt`,运行时也不得直接依赖 Windows x64 专有 `.lib`
**实例语义修正(已落地)**:旧 `Metin2World` 会给每棵占位树额外生成随机 yaw 和 scale jitter;原客户端
`CArea::TObjectInstance::SetTree``CSpeedTreeForest::CreateInstance` 在这条地图放置路径上只传
position、property CRC 和 treefile,实例本身只调用 `SetPosition``LoadTree` 也没有从这里收到
TreeSize/TreeVariance。当前实现已移除这些随机变换;若替代树为避免重复确实需要
随机旋转,应作为明确的 fallback/增强开关,而不能污染基线。
---
## 2. 角色 / 模型保真
| # | 项 | 现状 | 目标 | 工作 | 规模 | BACKLOG |
|---|---|---|---|---|---|---|
| 2.1 | 武器 mesh | 🟡 **首版已做 + 2026-08-31 复核**`Metin2Model.weapon_gr2` / `weapon_bone`(默认 `equip_right_hand`warrior bone[10])。加载武器 gr2 → `build_mesh`rigid)→ 子 `MeshInstance3D`;每帧 `update_weapon_pose(world_pose[bone])`。**隔离探针实测**`warrior_novice`/`warrior_cheongrin` × 8 个武器文件,weapon MI 全局原点 == `equip_right_hand` 骨骼全局原点,Δ = 0.000.06 mworld_demo 目视剑握在手里。之前记忆里「武器偏 ~10 m」是 cpu_skin `>=` / MapCoord 修复前的旧状态,已失效。新增 **grip pre-transform**`weapon_pre = mul4x3(weaponInvWorld[0], weaponLocal[0])`,对齐客户端 `weaponComposite[0]` = `ModelInstanceUpdate.cpp:171`),`update_weapon_pose``mul4x3(weapon_pre, world_pose[hand])`;标准武器多校准 ~4–6 cm 握点。个别非标准文件(`02010`/`03010`/`03150`)骨骼带米级偏移 → 30 cm 夹断退回原始手部位姿(不回退)。`mul4x3` 提到 `gr2_bridge.h` 与 anim 共用 | 右手单持跟手 1:1;`03150` 类网格在文件里就离骨骼原点 ~3 m(资产缺陷,非变换问题)—— 需 per-weapon 偏移数据或修 .gr2。⬜ 左手/双持/盾、`_lod` 变体 | M | C5 / D2 |
| 2.2 | GPU 蒙皮路径的发型 | ✅ **已做**`_build_gpu_mesh()` —— `enable_gpu_skin` 带发型时,把发型 mesh 以 bind pose 并入 GPU mesh,每个 `ARRAY_BONES` 值经 `hair_bone_remap`(发型骨 idx → base 骨 idx)重映射。**无需改 shader / 第二骨索引区**warrior 74/74 全匹配)—— `SRC_SKIN` + 共享 `bones_tex` 直接形变。`_apply_materials` 给追加的发型 surface 上「skinned + alpha-test + hair 贴图」材质。实测 5 base + 1 hair surface,红发(TargetSkin)贴头、镂空干净、跟动画 | M(完成)| A1 |
| 2.3 | 换装 / 时装 / 翅膀 | 无 | 多 `MeshInstance3D` 绑同一骨架 + 部位遮挡 / 隐藏规则 | `.msm` + 物品系统的上层 | L | D1 / D4 |
| 2.4 | 贴图换色 `SourceSkin→TargetSkin` | ✅ **首版已做**`Metin2Model.hair_skin` —— 设了就用该 dds 当发型 albedo(代替 gr2 sibling 的 SourceSkin),对齐客户端 `SetMaterialImagePointer(part, SourceSkin, load(TargetSkin))``ActorInstanceData.cpp:152/225`)。`get_hair_options()` 现返回解析成绝对路径的 `source_skin` / `target_skin``formats.msm_hair` CTest 钉了 HairData 解析(同 gr2+SourceSkin、不同 TargetSkin)。demo 用红色变体 | ⬜ 非发型部位(护甲/时装)的 skin 表;`warrior_cheongrin` 头盔挡住发型,换色需换模型目视 | S(首版完成) | D3 |
| 2.5 | 固定功能材质状态覆盖 | 单 stage `MODULATE(TEX,DIFFUSE)` 近似;**sphere-map 分支已按源码端口**(见 §2.7)。**blend / two-sided 改读 `gr2::MaterialInfo`**`metin2_model::decide_blend()` 优先 `alpha_blend``dump_materials` 端口 `Material.cpp:233` = Name `Blend*` + MapCount>1),`decide_two_sided()``two_sided`+ hair/cloak 名兜底),`m2_material``cull_disabled` shader 变体。仅 hair 镂空 / effect 加法这两类 GR2 没编码的仍按表面名 | 复现最终机位实际触发的 alpha-test ref、多贴图组合 | 逐分支盘点 `EterGrnLib/Material.cpp`GR2 不承载任意 D3D stage 状态 | M | B2/B4 |
| 2.6 | 顶点色 | ✅ **排查完毕:不适用**。全资产 `.gr2`PC / monster2 / effect**零** `DiffuseColor`/`Color0` 顶点成员(grep 整个 assets 目录 = 0)。Metin2 的「逐顶点颜色」实际是:① 地形雾 —— `MapOutdoorRenderSTP` 运行时动态 VB 里 `dwDiffuse`/`dwSpecular` 存雾色/雾系数(非资产,已由 Godot `Environment` 雾覆盖);② `.mse` 特效 `colorfactor` / 粒子 `TimeEventColor`(属 §1.2 `.mse` 端口)。libgr2 无需改,`build_mesh` 无需补 `ARRAY_COLOR` | — | B7 |
| 2.7 | 镜面 / 球面反射(护甲金属光) | 🟡 **管线已端口**`SRC_MIX` / `SRC_SKIN``spec_map`/`spec_power`/`spec_enable`。按 `EterGrnLib/Material.cpp:305 __ApplySpecularRenderState` 复现:仅不透明面(客户端 alpha-blend 时回退纯 diffuse)、相机空间 `reflect(VERTEX,NORMAL).xy` 查 sphere map、`MODULATEALPHA_ADDCOLOR` = `EMISSION += sphere.rgb * tex.a * power``tex.a` = 护甲高光遮罩,`power` = `D3DRS_TEXTUREFACTOR.a`)。sphere map = 全局 `ETC/ymir work/special/spheremap.jpg`(客户端 index 0),`Metin2Model::_load_sphere_map()` 沿模型目录上溯解析。`Metin2Model.specular_power` 属性。**默认 0 = 完全休眠**,与之前逐字节一致 | 端口 `__ApplySpecularRenderState`(✅);接装备层用 `item_proto bSpecular/100` 逐部位驱动(⬜,无物品系统);受控帧标定 power(⬜,§0 门禁) | M | B9 |
| 2.8 | 角色专属光(`DirectionalLight.Character` | 只用 `Background` 光 | 角色颜色与原客户端 Character light 接近 | 给 `SRC_MIX`/`SRC_SKIN` 加 character-light uniform,或用 light `cull_mask` 分层 | SM | E2 |
| 2.9 | 动画混合 / crossfade | ✅ **首版已做**`Metin2AnimPlayer.blend_time`(默认 0.15s)。切 `anim_path` 时保留旧 clip 冻结在当前时间,每帧对旧/新 `sample_pose``world_pose` 逐骨 slerp(rot)+lerp(pos/scale)smoothstep 权重),再 `skin = invWorld · world_blended` 重建;武器挂点跟混合后的手。shear 骨在 ≤blend_time 过渡内退化为无 shear(文档许可)。目视 wait→run 连续无 T-pose 闪 | ⬜ 受控帧核对过渡时长 | M(首版完成) | C2 |
| 2.10 | LOD | ✅ **首版已做**`Metin2Model` reload 时自动加载 `<base>_lod_01/02/03.gr2`(同 75 骨骼、抽面 mesh),`_process` 按相机距离切换渲染 mesh`lod_distances` 属性,默认 `[18,42,90]`m)。切换时重建 `cpu_mesh` + 重解材质 + 重蒙皮;骨架共享故蒙皮不受影响。烟测:`d=12→L1 d=21→L2 d=30→L3``wait.msa` 播放中不崩),4 级渲染均干净。⬜ 淡入淡出过渡、GPU 蒙皮路径的 LOD | M(首版完成)| C9 |
| 2.11 | `ypr_basis` 符号终核 | ✅ **发现并修复真实 bug**:物体旋转 `yaw#pitch#roll` 之前**未做** Z-up→Y-up 轴共轭就塞进 Godot Basis,导致 66% 的 A1 物体(roll≠0 = 朝向)绕 Godot Z 翻滚而非绕 Y 转向。新增 `m2_coord::object_basis_godot()` = `ypr_basis()` 共轭 `rotate(-90°,X)``metin2_world` 放置改用它。已核对 `ypr_basis` 本身忠实移植客户端 `D3DXMatrixRotationYawPitchRoll``Area.cpp:520/619` + `GrpObjectInstance.cpp:165`;单值旋转=roll=朝向,`Area.cpp:820`)。`formats.map_formats` 加断言(identity / roll=90→绕 Godot+Y / 正交 det=+1)。目视:A1 建筑竖直、门楼与桥沿路对齐 | ⬜ 对原客户端截图的逐建筑朝向复核(§0 受控帧) | S(首版完成) | — |
---
## 3. 地形 / splat 保真
| # | 项 | 现状 | 目标 | 工作 | 规模 |
|---|---|---|---|---|---|
| 3.1 | 活动图层上限 | ✅ **首版已修**shader 支持 **16 层**4 张 RGBA8 权重贴图),`min(8)``min(16)`A1 单区块最多 12 层(`000004`/`003004`)不再静默丢 | 每区块全部活动图层按原顺序混合 | ⬜ 按 patch 分批省带宽;真实 A1 层数断言 | M(P1,首版完成) |
| 3.2 | 每图层平铺密度 | ✅ **首版已修**`layer_uv = (8·UScale, -8·VScale, UOffset, -VOffset)`,来自 `TextureSet.cpp:185` + `TexCoordBase = 1/(PATCH_XSIZE·CELLSCALE) = 1/3200`。⬜ 对 `minimap.dds` / 受控参考帧的视觉验收 | 精确移植原客户端 UV 变换 | — | S–M(P1,首版完成) |
| 3.3 | `shadowmap.dds` 调制 | 🟡 **首版已分层**:树 `MultiMeshInstance3D` + `shadowflag≠1` 的建筑/地牢块 → `SHADOW_CASTING_SETTING_OFF``shadowflag=1` 建筑 + 玩家/NPC → 实时投影。terrain shader 仍 `col *= shadowmap.rgb`。**+ 角色接触阴影**`project/ui/char_shadow.gd``CharShadow.attach`)—— `player_view`/`mob_view` 建完模型后强制所有 `MeshInstance3D` `cast_shadow=ON`(防被 world 的 OFF 波及)+ 脚下一张径向渐变 `Decal` 投影到地形/物体(对齐 `GameLib/MapOutdoorCharacterShadow.cpp` 的结果,不复刻 RT)。太阳接近正午实时投影很短时也有接触暗影。`eterngrn_polish_test.gd` 覆盖 | ⬜ baked/realtime 精确不叠加的 mask、Decal 参数受控帧标定 | M(首版完成) |
| 3.4 | 近景清晰度 | ✅ **首版已修**:删掉程序化 `macro_detail`Texture2DArray 尺寸取「用到的图层里最大源边长(≤1024)」,只放大不硬降 512² 地表贴图 | 严格 parity 基线不叠加现代化增强;确认需要后再加可开关 detail map | — | SM(首版完成) |
| 3.5 | 地形 LOD / patch 裁剪 | 🟡 **首版已做**`Metin2World.terrain_patches`(默认 4)—— 每区块地形拆成 N×N 个 `MeshInstance3D` patch16 个,各 32×32 quad),共享 splat 材质。逐 patch **视锥剔除**Godot 自动,主要增益)+ `visibility_range_end`(默认 3500m 硬剔)远景整片剔除。顶点/法线/UV 从整块 `TerrainMesh` 拷贝 → **无裂缝无接缝**(顶视 + 斜视验证)。`terrain_patches=1` = 旧单 mesh | ⬜ 逐 patch 几何 LOD(抽稀索引集 + skirt 防裂);碰撞体也按 patch | M(首版完成)|
| 3.6 | 草(instanced grass) | 无;当前参考截图不能明确证明存在独立草片几何 | 只有原客户端源码、属性或受控近景帧确认后,才在对应区域撒 `MultiMesh` 草片 + 风摆 | 先取证,避免把现代化增强混入 parity 基线 | 可选 M |
| 3.7 | 相邻区块共享边断言 | ✅ 已加 `formats.map_formats` seam 断言:`000000` col-128 高度 == `001000` col-0(边界样本)+ 生成顶点位置一致,实测无接缝 | — | — | S(完成) |
---
## 4. 水面
| # | 项 | 现状 | 目标 | 工作 | 规模 |
|---|---|---|---|---|---|
| 4.1 | 原客户端水纹序列 | ✅ **首版已做**`special/water/01..30.dds``Texture2DArray`shader `frame = (TIME·1000/70)%30``MapOutdoorWater.cpp:43` 实测 70ms/帧),UV 平铺 = 1/8m`m_fWaterTexCoordBase = 1/(CELLSCALE·4)`)。⬜ wrap/filter 细调、原客户端叠加 blend 状态 | — | S–MP1,首版完成) |
| 4.2 | 水深透明与高度动画 | ✅ **首版已做**:逐顶点 `COLOR.a``waterHeight - terrain_height_at()` 算(60cm 深→近不透明),vertex shader 轻微高度浮动(近似 `MapOutdoorWater` 0..-15cm 的 0.6Hz 摆动)。⬜ 深度衰减系数按受控参考帧标定(现 A1 浅水偏透明) | — | M(P1,首版完成) |
| 4.3 | 反射 | 无真实反射 | 若受控参考帧证明必要,使用镜像相机 + `SubViewport` 做平面反射,或以 SSR 近似 | `ReflectionProbe` 是 cubemap,不作为平面反射实现 | 可选 M |
| 4.4 | 折射 | 无 | Godot 4 sampler `hint_screen_texture` + `SCREEN_UV` 扰动,结合 depth sampler 衰减 | 属于增强项;原客户端水面 parity 完成后再做 | 可选 SM |
| 4.5 | 岸线泡沫 / 法线贴图 | 无 | 仅在参考帧明确需要时增加;不宣称原客户端使用 normal map | 与严格 parity 材质做开关和 golden 对照 | 可选 S |
| 4.6 | 网格合并 | 同水层/区块已经是一个 mesh surface,内部按行生成 span quad | 行列矩形合并只减少顶点/索引;要减少 draw call 需跨区块/水层合批或统一 surface | 分别记录 vertex/index 与 draw-call 指标,避免把几何优化写成 draw-call 优化 | S |
---
## 5. 环境 / 光照 / 色调
| # | 项 | 现状 | 目标 | 规模 |
|---|---|---|---|---|
| 5.1 | 云层 | `.msenv` 有 cloud texture/scale/height/speed(已解析),未渲染 | 天空 shader 加滚动云层 + `List CloudColor` 着色 | M |
| 5.2 | 光照对受控参考帧校准 | `.msenv` → Godot 近似,肉眼调 | 在原 D3D 客户端到已知机位截图 → Godot 复现 → ROI 亮度/饱和/主色迭代到匹配(SHINSOO §11.4) | M(含反复采集) |
| 5.3 | 阴影质量 | 4-splitcascade 500mbias 调过 | 逐场景标定 cascade split / normal bias / peter-panning;角色脚下清晰接触阴影 | S–M |
| 5.4 | 昼夜 / 天气 | 无 | 若参考图之外还要,`.msenv` 多套 + 插值 | M(可选) |
| 5.5 | 色调校准 / LUT | `adjustment` 饱和/对比 | 先用同机位、同曝光的多组 ROI/校准帧拟合曝光、曲线、饱和度;必要时再固化为 3D LUT。单张场景截图不足以唯一反推出 LUT | S–M |
---
## 6. HUD / UI
目标截图的 UI 不是「有相似窗口即可」。建立 `test/maps/ingame-shinsoo-ui.json`,为圆形小地图、右侧已打开
背包、底部聊天/HP/MP/快捷栏以及顶部 objective/action/绿色提示逐项保存:1920×1080 像素 rect、anchor、
pivot、z-index、九宫格边距、贴图/字体路径、字号、描边、颜色、文本和 visible 状态。布局比对直接读取
该文件,禁止在测试里散落第二套魔法数字。
| # | 项 | 现状 | 目标 | 规模 |
|---|---|---|---|---|
| 6.1 | UI 美术 | 🟡 **机制已做**`hud.gd` 用真实 `ETC/ymir work/ui/pattern/` 贴图 —— `board`/`thinboard` 九宫格(4 角 + 4 边 + `*_base` 合成 atlas → `NinePatchRect`margin=32/16),HP/MP 用 `gauge_slot_{left,center,right}` 框 + `gauge_red`/`gauge_blue` 填充,快捷栏 8 格 `thinboard`,小地图套 `board` 框。贴图按资产根 + 各 pack `ymir work/ui/pattern/` 解析(`.tga``Image.load`)。⬜ 精确布局(rect/anchor/字体/描边)待 §0 `ingame-shinsoo-ui.json``locale/<lang>/ui/` 本地化图;`windows.dds` atlas 子图 | M(机制完成) |
| 6.2 | 小地图 | `minimap.dds` 拼图 + 玩家点 | 先保持地图 north-up、按玩家 heading 旋转箭头;若原客户端配置/受控帧证明整图旋转,再实现地图旋转。补缩放、NPC/怪/出口图标和交互 | M |
| 6.3 | 状态栏 / 快捷栏 / 背包 | 静态占位 | 真实布局 + 数值绑定 + 拖拽 + tooltip(不接服务端物品逻辑) | M–L |
| 6.4 | 头顶文字 / 血条 | 无 | `Label3D` 名字 + 屏幕空间血条跟随 | S–M |
| 6.5 | 聊天框 / 系统消息 | 无 | `RichTextLabel` + 频道 tab(参考图里通常有) | S |
| 6.6 | 1920×1080 布局 | 1152 测试分辨率 | 目标分辨率像素对齐 + 缩放策略 | S |
---
## 7. 后处理
| # | 项 | 现状 | 目标 | 规模 |
|---|---|---|---|---|
| 7.1 | Bloom / glow | 🟡 已收敛(intensity 0.35→0.12hdr 阈值 1.1→1.5,bloom→0;曾让角色发糊)。参考帧未落盘前保守 | 按参考图高光溢出量标定 | S |
| 7.2 | SSAO | 🟡 已收敛(intensity 1.4→0.7)。参考帧未落盘前保守 | 标定强度 / 半径 | S |
| 7.3 | 抗锯齿 | MSAA 4× | + TAA 或 FXAA(移动端权衡) | S |
| 7.4 | 景深 | 无 | 参考图若有远景虚化则加 | S(可选) |
| 7.5 | 暗角 / 色差 | 无 | 参考图若有则轻加,禁过度 | S |
---
## 8. 相机 / 构图
| # | 项 | 现状 | 目标 | 规模 |
|---|---|---|---|---|
| 8.1 | 机位匹配 | 🟡 渲染分辨率已提到 1920×1080(`project.godot`);机位仍是脚本里随手放(fov 60) | 精确复现参考图高度/距离/pitch/FOV,存 `test/maps/` 确定性 preset | S |
| 8.2 | 相机遮挡淡出 | ✅ **首版已做**`Metin2World` 给每个静态物体加层 2 盒碰撞(mesh AABB → Y-up 米,非缩放 shape`occ_mesh` meta 指回 MeshInstance3DA1 601 个)。`game_camera.gd::_fade_occluders` 每帧 cam→player 射线(ray exclude 逐个穿,最多 4 层)→ 命中建筑 `GeometryInstance3D.transparency = 0.72`,不挡时恢复 0。⬜ 树(MultiMesh 无逐实例透明)、渐变淡入淡出 | M(首版完成) |
| 8.3 | 相机碰撞 | ✅ **首版已做**`game_camera.gd::_desired_pos` head→want 射线打层 2,命中建筑就把相机拉到撞点前 0.3m(叠加已有地形采样防穿)。静态物体碰撞体作为 8.2 的副产品一并落地(`player_controller` 仍用 `attr.atr` 网格,可后续接物理)。⬜ `SpringArm3D` 平滑、`.mdatr` 精确碰撞体 | M(首版完成) |
---
## 9. 验证方法论(做「一致」的前提)
| # | 项 | 说明 | 规模 |
|---|---|---|---|
| 9.1 | 受控参考帧采集 | 在原 D3D 客户端脚本化到同一「已知全局坐标 + yaw/pitch + FOV」连续采两帧:关 HUD 的世界帧与开 HUD 的合成帧;参数存进 `test/maps/`。原客户端不能脚本化则「同地标 + 人工对齐 + 只比 ROI」 | M |
| 9.2 | ROI 直方图 + SSIM 门禁 | `test/compare.py` 扩:世界帧按天空 / 道路 / 草地 / 建筑 / 树 / 角色分区,比均亮 / 饱和 / 主色 / 边缘结构;合成帧另做 UI rect 与文本基线比对 | M |
| 9.3 | golden 锁定项 | Godot 版本 / renderer / 窗口 / 相机 / 时间 / 动画帧 / 随机种子 / 曝光 / 天气 / 资源优先级 全锁 | S |
| 9.4 | `world-a1-r1/r2.png` 进 CI | `formats/tests` 已进 CTestgolden 视觉门禁待接 | S |
| 9.5 | 目标帧验收配置 | 在 `ingame-shinsoo.json` 为天空/草地/道路/建筑/树/角色分别保存 ROI、metric、thresholdUI 窗口 rect/锚点初始容差为 1 px | S |
| 9.6 | 阈值冻结规则 | 同一机器连续渲染 5 次测噪声底;世界 ROI 阈值必须高于噪声并经一次人工签核后冻结。不得在回归失败时临时放宽阈值 | S |
---
## 10. 平台(SHINSOO 的前置,不是观感项但影响「能不能发」)
- Android(一加 13 / Vulkan+ iOSiPhone 16 / Metal,静态链接)bring-up**一次都没跑过**。见 `BACKLOG` F1F7。
- 所有新 shader`SRC_TERRAIN` / `SRC_WATER` / `SRC_LEAF` / `SRC_SKIN`)在 Mobile renderer 下未验。
- 移动端性能预算(流式 + `MTGODOT_GPUSKIN` 人群)未测真机。
---
## 11. 建议推进顺序
**第 1 梯队(性价比最高,1–1.5 月)**
1. 0.10.3 目标帧/资产/UI 契约 + 8.1 机位 preset + 9.1/9.5 受控参考与验收配置 —— 没有基准就没法谈「一致」
2. 3.1 全活动 splat 图层 + 3.2 原始 UV 公式 —— 先修复会丢内容/尺度错误的地表正确性问题
3. 1.4 最终机位可见树种清单 + 单树 spike;先用替代 mesh 达到轮廓,再并行验证授权 converter
4. 5.2 光照校准 + 5.5 色调拟合 —— 全画面观感一次性拉近
5. ✅ 2.1 武器(右手单持)+ ✅ 2.11 ypr 终核(修了漏轴转换 bug+ ✅ 2.9 crossfade + ✅ 2.4 换色 —— 角色补齐(首版)
6. 🟡 6.1 HUD 真美术 —— 九宫格机制已上,布局待 §0;✅ 3.3 阴影分层首版
**第 2 梯队(保真化,1.5–2 月)**
7. 1.1 `.spt` reader/converter 通过授权与技术验证后批量离线转 Mesh,并接入统一 `TreeDescriptor`
8. 4.1–4.2 原客户端 30 帧水纹 + 深度 alpha;4.3–4.5 只按受控参考帧选择性增强
9. 5.1 云层 + 1.3 lens flare
10. ✅ 2.7 sphere-map 高光(管线已端口,默认休眠);⬜ 2.5 其余固定功能材质分支 / 2.8 角色专属光
11. 3.6 instanced grass(仅在取证确认后)
**第 3 梯队(大工程 / 可并行 / 部分可选)**
12. 1.2 `.mse` parser/EffectLib 语义到 Godot 的端口 —— 独立子项目,L–XL
13. 2.3 换装系统、6.26.5 完整 HUD
14. 10. 移动端 bring-up
**贯穿**:每个梯队做完过一次 9.2 的 ROI 门禁 + 人工签核,用数据说话,别靠感觉。
+201
View File
@@ -0,0 +1,201 @@
# Platform builds (macOS / iOS / Android)
Phase 1 shipped the GDExtension for macOS only. As of 2026-08-30 the build system
is opened up for the two Phase 2 devices (BACKLOG F1/F2): **iPhone 16** (iOS,
Metal) and **OnePlus 13** (Android arm64, Vulkan). The native deps that used to
come from Homebrew are now vendored and cross-compile from the same tree — see
`docs/THIRD-PARTY.md`.
## What each script does
| Script | Target | Output |
|---------------------|---------------------------|---------------------------------------------------------|
| `./build.sh` | macOS (host) | `project/bin/libmtgodot.macos.template_{debug,release}.dylib` |
| `./build-ios.sh` | iOS arm64 device (`OS64`) | `project/bin/libmtgodot.ios.template_{debug,release}.a` + staged deps in `project/bin/ios/` |
| `./build-android.sh`| Android `arm64-v8a` | `project/bin/libmtgodot.android.template_{debug,release}.arm64.so` |
| `./export-android.sh [Debug\|Release] [--install]` | Android APK (wraps build-android + Godot export) | `build/export/mtgodot-poc[-release].apk` |
| `./gen-debug-keystore.sh` | Android debug keystore (once) | `~/Library/Application Support/Godot/keystores/debug.keystore` |
| `./pack-assets.sh` | Metin2 assets → mountable zip | `build/export/assets.zip` (`zip -0`, ~2.2 GB) |
All three run `git submodule update --init --recursive` if needed. The host tools
(`packtool`, `net_probe`) and CTest suite build only on a host build
(`CMAKE_SYSTEM_NAME == CMAKE_HOST_SYSTEM_NAME`), never when cross-compiling.
`project/bin/mtgodot.gdextension` has the `[libraries]` entries for all three
platforms and an `[dependencies]` block for iOS (see below).
## Status
### macOS — ✅ done
Unchanged. `./build.sh` → dylib, `ctest` 9/9.
### iOS — 🟡 compiles; export/sign/device pending (F1)
**Verified here (Xcode 26.4.1 + iPhoneOS 26.4 SDK):**
- `./build-ios.sh Debug` cross-compiles the whole surface to **arm64, minos 15.0**:
libsodium, libzstd, miniLZO, libgr2, xr_formats, mtnet, godot-cpp, and the
extension itself → `libmtgodot.ios.template_debug.a` (valid `ar` archive).
- iOS forces the extension to **STATIC** (`MT_LIB_KIND` in `extension/CMakeLists.txt`)
because the platform can't `dlopen`. Godot links the archive + its deps into the
app at export time, which is why `.gdextension` carries an `[dependencies]`
`ios.debug` / `ios.release` map listing every archive `build-ios.sh` stages
into `project/bin/ios/`.
**Not done (needs the device + Godot iOS export templates, ~23 days):**
- Install matching Godot 4.7 iOS export templates.
- Godot editor → Export → iOS preset; add `project/bin/ios/*.a` as extra link
libraries (or fold into an `.xcframework`).
- Free personal provisioning profile + signing for on-device install.
- First run on the iPhone 16; capture a frame for F3.
- Release build (`./build-ios.sh Release`) and size/strip pass.
### Android — ✅ APK builds; on-device run pending USB debugging (F2/F3)
**Done (2026-08-31):**
- `extension/CMakeLists.txt` handles `CMAKE_SYSTEM_NAME == Android` (SHARED `.so`,
`arm64` output-name suffix). `./build-android.sh` drives it through the NDK's own
`build/cmake/android.toolchain.cmake` (`ANDROID_ABI=arm64-v8a`, `android-24`).
- Toolchain on this machine: `sdkmanager "ndk;27.2.12479018" "platforms;android-35"
"build-tools;35.0.0"`; `brew install openjdk@21`; Godot 4.7.1 export templates
installed; `./gen-debug-keystore.sh`.
- `editor_settings-4.7.tres`: `export/android/android_sdk_path` = `~/Library/Android/sdk`,
`java_sdk_path` = `/opt/homebrew/opt/openjdk@21/.../Home`, `debug_keystore` = the
generated one (pass `android`).
- `project/export_presets.cfg` `[preset.1]` "Android": `arm64-v8a` only,
`gradle_build/use_gradle_build=false` (prebuilt template APK — no Gradle/JDK17 build),
`permissions/internet=true`, pkg `org.internal.mtgodotpoc`, minSdk 24.
- `./export-android.sh Debug` → `build/export/mtgodot-poc.apk` (~73 MB), signed with the
debug keystore via `build-tools/28.0.3/apksigner`. Contains
`lib/arm64-v8a/{libgodot_android.so, libmtgodot.android.template_debug.arm64.so, libc++_shared.so}`.
- Renderer stays `forward_plus` (OnePlus 13 = flagship Vulkan). No GLES3 / Compatibility
fallback — all target devices are modern.
**Asset IO portability (A1, 2026-08-31):** the extension used to read every asset with raw
`std::fopen` / `std::ifstream`, which cannot read a Godot PCK — so a bundled-assets APK
would launch to nothing. Fixed by routing all *running-extension* reads through
`godot::FileAccess` (works for `res://` PCK, `user://`, and absolute OS paths):
- `extension/src/asset_io.{h,cpp}` — `read_file()` / `dds_from_file()` / `gr2_from_file()`.
All `gr2::File::load_path` and `load_dds_path` call sites (model / anim / weapon / hair /
static objects / trees / terrain splat / water / shadowmap) now go through it.
- `formats/` funnels every read through `fmt::read_file`; added `fmt::set_file_reader()` and
`register_types.cpp` installs a `FileAccess`-backed reader → `height.raw` / `tile.raw` /
`water.wtr` / `.msenv` / `areadata.txt` / `map_setting` / `property` / `.spt` /
`texture_set` / `.msm` / `.msa` (textscript) all portable.
- `mtproto`: added `load_proto_bytes()`; `Metin2Proto` reads via `asset_io`.
- The standalone-lib `*_path()` functions are untouched, so the four non-Godot CTests
(`proto.item_mob`, `pack.roundtrip`, `formats.map_formats`, `libgr2.loader_errors`) stay green.
- **Phase 2b done (2026-08-31):** `fmt::AssetResolver` can no longer `std::filesystem`-scan a
PCK, so it's now a **baked index**: `AssetResolver::save_index()` / `load_index()` (line format
`MTIDX1` + `Y|R\t<key>\t<rel>`, paths relative to assets_root), and
`build_or_load(root)` → loads `<root>/asset_index.txt` via `fmt::read_file` if present, else
scans (desktop dev). `resolve()` now returns `assets_root + "/" + rel` so it's portable
(`res://assets/...` on device). Generate the index with
`godot --headless --path project --script bake_asset_index.gd` (→ `assets/asset_index.txt`,
~8 MB, 54k files) — `export-android.sh` does this automatically. `metin2_world.cpp` calls
`build_or_load`; `Metin2World.bake_asset_index(out)` is the bound builder method.
- **`eterpack` / `asset_source`** — unused while the dev asset tree is fully loose (no `.epk`).
### Bundling the assets (mobile) — the mount-a-zip approach
The APK/IPA is **code-only** (73 MB): `assets/` (2.1 GB) + `bgm/` (80 MB) are gitignored and
sit beside `project/`, not under `res://`. Getting them into `res://` via the exporter fails:
`.gdignore` blocks `include_filter` too, and without `.gdignore` Godot imports the ~10 k
`.dds`/`.tga`/`.jpg`/`.wav` → converts them to `.ctex`/etc → `FileAccess("res://.../x.dds")`
no longer yields raw DXT bytes → the C++ decoder breaks. (It also litters the asset tree with
`.import`/`.uid` sidecars and bloats `.godot/` to ~190 MB.)
Instead: **ship a plain `zip -0` and mount it at runtime.** All C++ asset IO now goes through
`godot::FileAccess` (see A1 above), so once the zip is mounted at `res://` everything reads
from it transparently.
- `./pack-assets.sh` → `build/export/assets.zip` (bakes `asset_index.txt` first; `zip -0`
store, no compression — DXT/gr2 don't compress and we want fast random access; paths stay
`assets/…` `bgm/…` so they mount as `res://assets/…` `res://bgm/…`).
- `project/asset_pack.gd` (`class_name AssetPack`) — `ensure()` finds the zip
(`MT_ASSETS_ZIP` env → `user://assets.zip` → `OS.get_user_data_dir()/assets.zip` → exe-dir →
`res://../assets.zip`) and `ProjectSettings.load_resource_pack()`s it. `client_main.gd`
calls it in `_ready()` before `AppFlow.start()`.
- `project/asset_root.gd` — adds `res://assets` as the first candidate **iff**
`res://assets/asset_index.txt` exists (the "pack mounted" sentinel; the empty
`project/assets/.gdignore` stub doesn't count).
- Deploy: `./export-android.sh Debug --install` pushes both the APK and (if present)
`assets.zip` → `/sdcard/Android/data/org.internal.mtgodotpoc/files/assets.zip` over USB.
iOS later: `ios-deploy --bundle_id … --upload assets.zip --to Documents/`. Same mount code.
- **Future (download instead of push):** only `AssetPack._find_local_zip()`'s failure branch
changes — HTTPRequest `ASSET_PACK_URL` → `user://assets.zip` (resume + sha256 + progress UI)
→ same `load_resource_pack`. Structurally additive.
- One more `std::filesystem` scan turned up: `fmt::PropertyRegistry::scan()` walks
`<root>/Property/` for `.pr*` CRC files (drives building/tree placement). Added
`PropertyRegistry::scan_list(root, rel_paths)` + `AssetResolver::all_rel()`; `metin2_world.cpp`
feeds it the resolver's file list (both index-loaded and dir-scanned modes). `scan()` kept
for the `formats.map_formats` CTest.
- **Verified end to end**: force-mount the real `assets.zip` (2.1 GB, 58183 entries) with the
loose tree hidden → `Metin2World.load_map("OutdoorA1/metin2_map_a1")` →
20/20 chunks, 20 splatted, **601 objects (0 missing), 368 trees, 1330 property CRCs**, water,
env — byte-for-byte the same result as the loose tree. Desktop unaffected. ctest 10/10,
GDScript 34/34. Android `.so` + iOS `.a` rebuilt.
**Pending:** the OnePlus 13 is on USB and macOS sees it (`ioreg` shows vendor "OnePlus"),
but `adb devices` is empty → enable **Developer options → USB debugging** on the phone and
accept the "Allow USB debugging?" RSA prompt (USB mode: File transfer / MTP). Then:
- `./export-android.sh Debug --install` (or `adb install -r build/export/mtgodot-poc.apk`
then `adb shell am start -n org.internal.mtgodotpoc/com.godot.game.GodotApp`).
- `adb logcat -s godot GodotError Godot` — first run WILL need shader / GPU-skin fixes
under Godot's mobile Vulkan path (never exercised); capture a frame for F3.
- adb note: a stale `sdk/platform-tools/adb` daemon can hold `:5037` ("Address already in
use"); `pkill -9 -f adb` then use one adb consistently (`/opt/homebrew/bin/adb`).
## Notes / gotchas
- The `ranlib: ... has no symbols` spam during a libsodium build is harmless: the
x86 SIMD translation units compile to empty objects on arm64 (their bodies are
guarded by CPU-feature macros). libsodium-cmake compiles the full file list on
every arch by design.
- The staged `project/bin/ios/libgodot-cpp.*.a` is ~430 MB in Debug (all classes,
unstripped). Fine for a dev link; Release + dead-strip shrinks it hard.
`project/bin/ios/` and `project/bin/android/` are gitignored.
- Shaders (`SRC_TERRAIN/WATER/LEAF/SKIN/MIX` + the §2.7 spec branch) and the
GPU-skin bone-texture path have **not** been exercised under Godot's Mobile
renderer yet — that's a separate verification pass once a device boots.
- `net_stream.cpp` is pure POSIX sockets → fine on iOS/Android/Linux/macOS; only
a Windows target would need a Winsock shim.
## GPU texture compression (F4)
`extension/src/texture_util.{h,cpp}` — `make_color_texture(w,h,rgba,len,mipmaps)`
is the single funnel for "decoded RGBA8 → `ImageTexture`". On a mobile OS (or
`MTGODOT_TEXCOMP=1`) it runs the decoded colour image through runtime **ASTC 8x8**
(~2 bpp vs 32 for RGBA8), falling back to RGBA8 if the encoder is unavailable.
Desktop default is **off** and byte-identical to the old per-site code.
Wired: character skins (`metin2_model::_load_dds`), building albedo
(`static_object`), tree bark / leaf-composite (`tree_placeholder`). **Not** wired
(exact texel values matter): splat control maps, `shadowmap`, water, HUD
`load_dds` / minimap.
`texcomp_set_enabled(bool)` overrides at runtime. Verified: macOS + iOS compile,
`ctest` 9/9, desktop character render unchanged, `MTGODOT_TEXCOMP=1` render OK.
Remaining: on-device quality check + VRAM/bandwidth numbers (needs F1/F2).
## App lifecycle (F5)
`project/app_lifecycle.gd` — an `AppLifecycle` node. Add it early (or as an
autoload); `bind(m2client, audio)` wires the common consumers. It turns the
SceneTree-forwarded MainLoop notifications into signals
(`paused` / `resumed` / `focus_changed(bool)` / `memory_warning` /
`back_requested` / `close_requested`) and, by default, on background: pauses the
tree, stops BGM, drops `Engine.max_fps` to 8; restores on foreground.
`pause_tree_on_background` / `background_max_fps` are tunable (login screen sets
`pause_tree_on_background = false`).
`M2Client` (C++) handles `NOTIFICATION_APPLICATION_PAUSED/RESUMED` itself →
`suspend()` / `resume()`. While suspended `_process()` doesn't pump the socket.
A mobile OS drops the TCP connection within ~30 s of backgrounding, so the first
pump after `resume()` surfaces `disconnected`; `reconnect()` redoes the full
auth→game login from stored credentials. `login.gd` auto-calls it on `resumed`
when the stage was in-game. New signals: `suspended`, `resumed`.
Rendering-context loss (Android Vulkan surface) is handled by Godot; every GPU
resource we hold (bone textures, MultiMesh, ShaderMaterials, ImageTextures) is
RenderingServer-managed and survives — nothing for the extension to do.
Verified: headless smoke (`suspend`/`resume`/`reconnect` bound, idempotent,
signals fire once), `ctest` 9/9, iOS compile. Remaining: on-device background/
resume/reconnect cycle and a real iOS memory-warning.
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
# Third-party native dependencies
The GDExtension links three native libraries that are **not** part of Godot or
godot-cpp. Phase 1 (macOS) pulled them from Homebrew; that stops working the
moment we cross-compile for the Android NDK or the iOS SDK (BACKLOG F1/F2), so as
of 2026-08-30 all three are built from source as part of our own CMake build.
Everything lives under `extension/third_party/` and is wired by
`extension/third_party/CMakeLists.txt`, which exposes three aliases:
| Alias | Backing target | Consumed by |
|-----------------|------------------|------------------------|
| `mt3p::sodium` | `sodium` | `mtnet`, `mtpack`, `mtproto` |
| `mt3p::zstd` | `libzstd_static` | `mtpack` |
| `mt3p::minilzo` | `minilzo` | `mtproto` |
No source file changed: `#include <sodium.h>`, `#include <zstd.h>` and
`#include <lzo/lzo1x.h>` all still resolve (the last via a 1-line shim header,
see below).
## libsodium — via `robinlinden/libsodium-cmake`
* **Submodule:** `extension/third_party/libsodium-cmake` @ `9b2848d`
* itself carries `jedisct1/libsodium` @ `93a7d0d` (libsodium 1.0.20 line) as a
**nested** submodule — clones need `--recursive`.
* **Why the wrapper:** upstream libsodium is autotools-only. This MIT-licensed
wrapper is a pure CMakeLists over an untouched libsodium checkout; it generates
`version.h`, sets `CONFIGURED`, and builds clean for macOS / iOS / Android /
Windows. Used by many mobile projects.
* **Build options we force:** `SODIUM_DISABLE_TESTS=ON`, `SODIUM_MINIMAL=OFF`
(we need `crypto_kx`, `crypto_auth`, `crypto_aead_xchacha20poly1305`,
`crypto_stream_xchacha20`, `crypto_generichash`/BLAKE2b — all outside the
"minimal" set).
* **License:** ISC (libsodium) + MIT (wrapper). Ship-safe.
## libzstd — `facebook/zstd`
* **Submodule:** `extension/third_party/zstd` @ tag **v1.5.6** (`794ea1b0`).
* Built through upstream `build/cmake` with:
`ZSTD_BUILD_PROGRAMS/SHARED/TESTS/CONTRIB=OFF`, `ZSTD_BUILD_STATIC=ON`,
`ZSTD_LEGACY_SUPPORT=OFF`, `ZSTD_MULTITHREAD_SUPPORT=OFF` (the eterpack path
only does single-shot `ZSTD_compress`/`ZSTD_decompress`).
* Target consumed: `libzstd_static` (carries its own public include dir).
* **License:** BSD-3-Clause / GPLv2 dual. Ship-safe under BSD.
## miniLZO — vendored source (not a submodule)
* **Files:** `extension/third_party/minilzo/{minilzo.c,minilzo.h,lzoconf.h,lzodefs.h}`
copied verbatim from **lzo-2.10** (`minilzo.c` sha1 `019debb3…`), plus
`README.LZO`, `COPYING`, `AUTHORS` as required by the LZO license.
* Upstream LZO has no git repo (oberhumer.com tarball only), and miniLZO is a
4-file amalgamation, so it is copied in rather than submoduled.
* **Shim:** `minilzo/lzo/lzo1x.h` is a 1-line `#include "../minilzo.h"` so
`extension/src/proto/proto.cpp` keeps `#include <lzo/lzo1x.h>` unchanged.
miniLZO's API (`lzo_init`, `lzo1x_decompress_safe`, `LZO_E_OK`, `lzo_uint`) is a
strict subset of full LZO and covers everything the CLZO path uses.
* **License:** **GPLv2**. Acceptable for this internal, non-published,
non-commercial project on the same footing as `libgr2/src/oodle1.c` — but LZO
must be removed, replaced, or commercially licensed before any public release
or commercial use. (item_proto/mob_proto are the only LZO consumers; a
clean-room LZO1X decompressor is the eventual fix.)
## Rebuilding / fetching
`./build.sh` auto-runs `git submodule update --init --recursive` if any of the
three (godot-cpp, zstd, libsodium-cmake+libsodium) is missing. miniLZO needs no
fetch.
Homebrew `libsodium` / `zstd` / `lzo` are no longer referenced by the build and
can be uninstalled.
+345
View File
@@ -0,0 +1,345 @@
# Demo 开发方案 —— bgfx 渲染 assets 下的 gr2 资源
> 本文件把 [`PLAN.md`](./PLAN.md) 的 M0 + M1+ M2 拉伸目标)落成一个**可演示的 demo** 的文件级施工图。
> demo 只做 macOSMetal),跑通后 iOS/Android 是同一套代码换 toolchain。
> 前置事实、坐标/格式细节、风险都以 `PLAN.md` 为准,这里不重复论证。
---
## 1 · Demo 目标与演示脚本
**一句话**:一个原生 macOS 窗口,直接从 `m2dev-client-main/assets/` 读一个 `.gr2` + 它的 `.dds`,用 bgfx/Metal 把这个带蒙皮的角色渲染出来,轨道相机可转,能切线框 / 法线可视化;拉伸目标是让它播一个 `.msa` 动作。
**演示脚本(给人看的顺序)**
| 步骤 | 屏幕上 | 证明了 |
|---|---|---|
| 1 | 终端跑 `gr2dump warrior_cheongrin.gr2`,打印骨架树(骨骼名 + 父索引 + 层级缩进)、网格摘要(顶点数 / 索引数 / 三角组)、动画列表 | libgr2 能脱离 Granny 读结构 |
| 2 | 窗口出现,warrior 网格以**绑定姿势**显示,白模,轨道相机可拖转、滚轮缩放 | gr2 顶点 / 索引 / 骨架 → bgfx,交接成立 |
| 3 | 按 `T` 贴上 `warrior_cheongrin.dds`(DXT3 软解),材质正确 | DXT 解码 + texture-stage + UV 正确 |
| 4 | 按 `N` 切法线可视化、按 `W` 切线框 | 自检工具,抓法线翻转 / 拓扑错 |
| 5(拉伸)| 按 `Space``action/dance_1.gr2` + `dance_1.msa`,角色跳舞,CPU 蒙皮 | 曲线采样 + LBS 成立,M2 的核心 |
| 6 | 角标 HUD 显示 FPS / draw call / 三角数(bgfx 自带 `showStats` | 有性能可读数 |
**不做**:地形、特效、UI、多部件装配、eterpackdemo 用散装文件)、iOS/Android(同代码后续换 toolchain)。
---
## 2 · 演示用资产(具体文件,散装,无需解包)
| 用途 | 路径(相对 `m2dev-client-main/` | 事实 |
|---|---|---|
| 主模型 | `assets/PC/ymir work/pc/warrior/warrior_cheongrin.gr2` | 92 770 Bgr2 格式 v68 section**section 全 Oodle1 压缩**(实测)|
| 主贴图 | `assets/PC/ymir work/pc/warrior/warrior_cheongrin.dds` | **512×512 DXT35 mip** |
| LOD 对照 | `warrior_cheongrin_lod_01/02/03.gr2` | 同骨架、减面,用于 M2 的 LOD 一致性检查 |
| 动作(拉伸)| `assets/PC/ymir work/pc/warrior/action/dance_1.gr2` + `dance_1.msa` | 动画单独一个 gr2 + 文本 msa |
| 刚体静态件(对照)| `assets/Zone/ymir work/zone/oxevent/ox_01.gr2` | 无骨骼,验证 rigid 分支 |
> demo 起步**不碰 `.msm`**`warrior_m.msm` 引用 `warrior_novice.GR2` + 61 组头发 + 一堆动作,太复杂)。直接 `warrior_cheongrin.gr2` + 同名 `.dds` 是自足的 mesh+texture 对。`.msm` 解析留到 M2 多部件装配。
**资产接入方式**CMake 里配一个 `XRENDER_ASSET_ROOT` 指向 `../m2dev-client-main/assets`demo 直接 `fopen` / mmap 读,不复制。
---
## 3 · 依赖与仓库骨架
```
xrender-poc/
third_party/
bgfx.cmake/ submodule → github.com/bkaradzic/bgfx.cmake(拉 bx/bimg/bgfx
⚠ pin 到与 shaders.rar 的 .sc 草稿相近的 bgfx 版本
sokol/ 只放 sokol_app.h(单文件,手动 vendor
glm/ submodule 或单目录 vendor
cgltf/ 单头文件(gr2dump 导出 glTF 用)
reuse/
EterImageLib/ 从 ../MobileSource 冻结拷入:DXTCImage.{h,cpp} + StdAfx + 依赖的最小集
EterBase/ (拉伸)CFileBase / CMappedFile —— demo 初期可先用裸 fopen
libgr2/
include/gr2.h
src/gr2_file.cpp header + section table + fixup 重定位
src/gr2_typetree.cpp 自描述 data_type_definition 遍历器
src/gr2_fileinfo.cpp FileInfo 根对象 → skeleton/mesh/material/animation 视图
src/gr2_skeleton.cpp 骨骼数组 + bind pose 自洽检查
src/gr2_mesh.cpp 顶点/索引/三角组/BoneBindings 提取
src/gr2_anim.cpp TrackGroup → 每骨曲线;曲线子类型解码(demo 子集)
src/gr2_decompress.cpp section 解压分派
src/oodle1.c Granny Oodle1 解码(已实现,端口自泄露 SDK;9166/9166 验证通过)
engine/
rhi.h/.cpp ≈150 行 bgfx 薄封装
camera.h/.cpp 轨道相机
skinning.h/.cpp CPU LBS(拉伸)
animation.h/.cpp 曲线采样 + 世界姿势累积(拉伸)
scene.h/.cpp 把 libgr2 视图 → GPU buffer + draw item
formats/
msa.cpp (拉伸)文本 msa 解析
app/
main.cpp sokol_app 回调 + bgfx init + 输入 + demo 状态机
shaders/
varying.def.sc 从 shaders.rar 拷 + 加 a_indices/a_weight
vs_pnt.sc fs_pnt.sc 从 shaders.rar 拷改
vs_pnt_skinned.sc 新写(骨骼矩阵 uniform 数组)
texture_stage.sh 从 shaders.rar 原样拷
compile.cmake shaderc → metal + bin2c
tools/
gr2dump/main.cpp CLI:结构化 dump + glTF 导出
oracle/ Windows-only,见 PLAN §07demo 阶段可先跳,用 Blender io_scene_gr2 当参照)
cmake/
macos.cmake
CMakeLists.txt
```
---
## 4 · 构建(macOS 优先)
```bash
# 1. 拉依赖
git submodule add https://github.com/bkaradzic/bgfx.cmake third_party/bgfx.cmake
cd third_party/bgfx.cmake && git submodule update --init && cd -
# 固定 bgfx 版本(示例):cd third_party/bgfx.cmake/bgfx && git checkout <pinned-tag>
# 2. 配置 + 编
cmake -B build -DXRENDER_ASSET_ROOT=../m2dev-client-main/assets -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j
# 3. 跑
./build/tools/gr2dump/gr2dump "$XRENDER_ASSET_ROOT/PC/ymir work/pc/warrior/warrior_cheongrin.gr2"
./build/app/xrender-demo
```
**`CMakeLists.txt` 要点**
- `add_subdirectory(third_party/bgfx.cmake)` → 得到 `bgfx` / `bx` / `bimg` / `shaderc` 目标。
- 着色器编译:`app/shaders/compile.cmake` 里对每个 `.sc``shaderc -p metal --platform osx`,产物过 `bin2c` 生成 `*.sc.bin.h`,作为 `xrender-demo` 的生成依赖。
- `reuse/EterImageLib` 编成静态库 `xr_eterimage``-Wno-*` 压掉老代码告警;可能要 `-DXR_STANDALONE` 剥掉 `StdAfx.h` 里的 Windows include。
- macOS`-framework Cocoa -framework Metal -framework QuartzCore``main.cpp` 编成 `.mm`sokol_app 的 macOS 后端要 ObjC)。
---
## 5 · 模块与文件清单(职责 + 估行)
### libgr2demo 子集,只读)
| 文件 | 职责 | 估行 | demo 边界 |
|---|---|---|---|
| `gr2_file.cpp` | magic`GRNFileMV_Old`+ `grn_file_header` + section 表(**已实现**9166/9166 跑通)+ fixup 重定位 | 250 | v6 / 32-bit LE |
| `gr2_decompress.cpp` + `oodle1.c` | section 解压分派 + Granny Oodle1 解码器 | 40 + 450 | **已实现**:从泄露 SDK 端口解码路径,9166/9166 展开到精确 `ExpandedDataSize`,内嵌串验证 |
| `gr2_typetree.cpp` | 按 `granny_data_type_definition`(文件内嵌,自描述)递归走类型树,把裸内存映射成可访问的字段 | 300 | 通用遍历器,不硬编码结构;只需支持 gr2 实际用到的成员类型(Real32 / Int32 / Ref / ReferenceToArray / Inline / String |
| `gr2_fileinfo.cpp` | 定位 `FileInfo` 根对象,暴露 `Skeletons[] / VertexDatas[] / TriTopologies[] / Meshes[] / Materials[] / Textures[] / Models[] / Animations[]` 的 span 视图 | 150 | — |
| `gr2_skeleton.cpp` | 骨骼数组(`Name / ParentIndex / LocalTransform(SRT) / InverseWorld4x4`);**bind pose 自洽检查**:重建 `world_bind`,验 `world_bind[i]·InverseWorld4x4[i]≈I` | 180 | — |
| `gr2_mesh.cpp` | 顶点(识别 `PNT332` / `PNT3322` / 带 `BoneWeights+BoneIndices` 的蒙皮变体)、索引、`TriGroups`(按材质分段)、`BoneBindings`mesh→skeleton 骨骼名映射) | 220 | 只支持上述三种顶点布局 |
| `gr2_anim.cpp` | `Animation → TrackGroups → TransformTracks`;每骨 position/orientation/scaleshear 曲线;曲线解码 + `Animation::sample_local(t)` | 320 | **已实现(全覆盖)**M0 实测 Metin2 曲线**全部**是 `OldCurveType`Granny 2.4`{Degree; Knots[]; Controls[]}`,无压缩变体)。degree 0(常量)/ 1(线性)/ 2(二次 B 样条)+ 四元数归一。**degree 3 全样本 0 个**,无需烘焙退路。精度待 oracle 层① 对拍 |
`include/gr2.h` 对外暴露纯 POD 视图(`gr2::Skeleton` / `gr2::Mesh` / `gr2::Animation`),不泄露内部指针 —— 上层只依赖这个头。
### reuse/EterImageLib(冻结拷入)
- `DXTCImage.{h,cpp}``LoadHeaderFromMemory` + `LoadFromMemory` + `Decompress(level, DWORD* out)` → RGBA8。demo 用 DXT3 分支。
- 依赖裁剪:把 `StdAfx.h` 换成一个最小 shim`typedef uint8_t BYTE` 等),去掉 `windows.h`
- 备选:DXT3 解码就 ~150 行,嫌 EterImageLib 依赖脏可以自己写一个 `dxt.cpp`
### engine/rhi.{h,cpp}(≈150 行)
对齐 `StateManager` 语义的 bgfx 薄封装:
```cpp
namespace rhi {
void init(void* nativeWindowHandle, int w, int h); // bgfx::PlatformData + bgfx::init
void resize(int w, int h);
void beginFrame(const glm::mat4& view, const glm::mat4& proj);
Handle createVB(const void* data, uint32_t size, const bgfx::VertexLayout&);
Handle createIB(const void* data, uint32_t size, bool i32);
Handle createTex2D(const void* rgba, uint16_t w, uint16_t h, uint16_t mips);
Handle createProgram(const uint8_t* vs, uint32_t vsLen, const uint8_t* fs, uint32_t fsLen);
void setModel(const glm::mat4&);
void setBones(const glm::mat4* mtx, uint16_t n); // uniform 数组,vs_pnt_skinned 用
void setTexture(Handle);
void setStageUniforms(const StageDesc&); // texture_stage.sh 的 uniform
void submit(Handle vb, Handle ib, Handle prog, uint64_t state);
void endFrame();
}
```
### app/main.cpp
- `sokol_app` 描述:`.window_title``.high_dpi=true`、macOS 后端;**不让 sokol_app 建 GL/Metal 设备** —— 用 `sapp_metal_get_layer()` / `sapp_macos_get_window()` 取原生 handle 传给 `rhi::init`(这就是 PLAN §03 的交接点,D2 的验收)。
- `frame_cb`:更新相机 → `rhi::beginFrame` → 遍历 scene draw items → `rhi::submit``bgfx::frame()`
- `event_cb`:鼠标拖 = 轨道、滚轮 = 缩放、键 `T/N/W/Space`
- demo 状态机:`enum { BindPose, Textured, NormalViz, Wireframe, Animating }`
### app/shaders
| 文件 | 来源 | 改动 |
|---|---|---|
| `varying.def.sc` | shaders.rar 拷 | 加 `int4 a_indices : BLENDINDICES;` `vec4 a_weight : BLENDWEIGHT;` |
| `vs_pnt.sc` / `fs_pnt.sc` | shaders.rar 拷 | `vs_pnt``a_color0``fs_pnt` 加 fog(demo 可先关);确认 `#include <bgfx_shader.sh>` 对上 pin 的 bgfx 版本 |
| `vs_pnt_skinned.sc` | 新写 | `mat4 skin = u_bones[a_indices.x]*a_weight.x + …`4 权重);其余同 `vs_pnt` |
| `texture_stage.sh` | shaders.rar 原样 | 不改;demo 里给它喂"单 stage MODULATE(TEXTURE, DIFFUSE)"的 uniform,等价于 `tex * vertexColor` |
| `fs_normalviz.sc` | 新写(10 行) | `gl_FragColor = vec4(v_normal*0.5+0.5, 1)` |
### tools/gr2dump/main.cpp
- 参数:`gr2dump <file.gr2> [--gltf out.glb]`
- stdout:骨架树(缩进)、每 mesh 的顶点/索引/三角组/顶点布局、每 animation 的时长 + track 数 + 曲线子类型直方图、bind pose 自洽检查结果(PASS/FAIL + max 偏差)。
- `--gltf`:用 cgltf 写骨架 + 第一个 mesh 的 bind pose + (若实现了)第一个 animation。**仅供 Blender 目视**,动画正确性不认它(PLAN §06 M0)。
---
## 6 · 实现顺序(每步一个可演示切片)
| 步 | 交付 | 演示点 | 验收 |
|---|---|---|---|
| **D0** | CMake 骨架编过:空 `xrender-demo` 开一个 bgfx 清屏窗口(纯色)+ `bgfx::showStats` | 窗口出现、FPS 角标在跳 | bgfx 经 sokol_app handle 起来了(**PLAN §03 交接子门禁的最小版**) |
| **D1** | `gr2_file` + `gr2_typetree` + `gr2_fileinfo` + `gr2_skeleton``gr2dump` 能打印 `warrior_cheongrin.gr2` 骨架树 + mesh 摘要 | 终端 dump 输出 | 骨骼数 / 名称 / 父索引与 Blender `io_scene_gr2` 导入一致;bind pose 自洽 PASS |
| **D2** | `gr2_mesh` + `engine/scene` + `rhi` + `vs_pnt/fs_pnt`warrior **白模绑定姿势**上屏,轨道相机 | 能转的白模 | 轮廓 = Blender 里同模型;无背面/法线翻转(配合 D4 的法线可视化确认) |
| **D3** | `reuse/EterImageLib` DXT3 解码 → `rhi::createTex2D``texture_stage.sh`;按 `T` 贴图 | 有材质的 warrior | UV 无错位、无镜像;和客户端截图目视一致 |
| **D4** | `fs_normalviz` + 线框 state`BGFX_STATE_PT_LINES``BGFX_DEBUG_WIREFRAME`);`N` / `W` 切换 | 法线彩图 / 线框 | 法线朝外;三角组分段正确 |
| **D5(拉伸)** | `gr2_anim`(子集)+ `formats/msa` + `animation` + `skinning`CPU LBS+ `vs_pnt_skinned``Space``dance_1` | 角色跳舞 | 与 Blender 导入的同一动画逐帧目视一致;无肢体飞出/顶点塌陷。数值对拍要 oracle(见 §8) |
**D0D4 = 可交付的 demo**(静态带贴图 + 自检工具)。D5 是加分项,卡住不影响 demo 成立。
---
## 7 · 关键实现细节
### 7.1 gr2 v6 文件结构(`gr2_file.cpp`
```
0x00 BYTE magic[16] // B8 67 B0 CA F8 6D B1 0F 84 72 8C 7E 5E 19 00 1E ← 认版本/字节序
0x10 u32 headerSize // 0x1B8
0x14 u32 headerFormat // 0(这不是 section 压缩!section 压缩看每段 grn_section.Format
0x18 u32 reserved[2]
--- GrannyFileHeader ---
0x20 u32 version // 6
0x24 u32 totalSize // == 文件大小,用来自检解析对齐
0x28 u32 crc32
0x2C u32 sectionArrayOffset // 0x38
0x30 u32 sectionArrayCount
0x34 u32 rootObjectTypeSection / rootObjectTypeOffset / rootObjectSection / rootObjectOffset
...
--- Section[sectionArrayCount] --- 每项 ~44 B
u32 Format // 0=none 1=Oodle0 2=Oodle1Metin2 实测全 2
u32 dataOffset, dataSize
u32 expandedDataSize
u32 alignment
u32 first16Bit / first8Bit // marshalling 边界
u32 pointerFixupArrayOffset, pointerFixupArrayCount
u32 mixedMarshallingFixupArrayOffset, mixedMarshallingFixupArrayCount
```
**流程**:读 header → 逐 section`gr2_decompress` 展开到 `expandedDataSize` 的缓冲 → 应用 pointer fixup(把文件内偏移改写成进程内指针)→ (小端机上 mixed-marshalling fixup 可跳过,big-endian 才需要)→ 得到一组可随机访问的 section 内存块。root object 在 `(rootObjectSection, rootObjectOffset)`,其类型定义在 `(rootObjectTypeSection, rootObjectTypeOffset)`
### 7.2 自描述类型树(`gr2_typetree.cpp`
`granny_data_type_definition` 是数组,每项:`{ MemberType(u32), Name(char*), ReferenceType(def*), ArrayWidth(i32), Extra[3], Ignored }`,以 `MemberType==0`End)结尾。`MemberType` 枚举含 `Inline / Reference / ReferenceToArray / ArrayOfReferences / Real32 / Int32 / UInt32 / String / Transform / …`
写一个 `walk(void* obj, const TypeDef* type, Visitor&)`:按成员类型算 stride、递归 `Reference`/`ReferenceToArray`。**不要硬编码 struct 布局** —— 不同 Granny 小版本字段顺序会变。上层 `gr2_fileinfo` 按**成员名**`"Skeletons"`, `"Meshes"` …)取字段,用 `GrannyFindMatchingMember` 式的按名查找。
### 7.3 顶点布局 → `bgfx::VertexLayout``gr2_mesh.cpp` + `scene.cpp`
gr2 mesh 的顶点类型在文件里(`GrannyGetMeshVertexType`)。demo 支持:
| gr2 顶点类型 | 成员 | bgfx layout |
|---|---|---|
| `PNT332` | Pos3f, Norm3f, UV2f | Position/Normal/TexCoord0 |
| `PNT3322` | Pos3f, Norm3f, UV2f, UV2f | + TexCoord1 |
| 蒙皮变体 | + BoneWeights(4×u8 归一) + BoneIndices(4×u8) | + Weight/Indices`bgfx::Attrib::Weight` + `Indices``AttribType::Uint8`, normalized=weight true / indices false|
`GrannyCopyMeshVertices(mesh, dstType, dstBuf)` 的效果自己实现:按源类型逐顶点拷到一个 demo 统一的打包结构,再 `bgfx::createVertexBuffer`。索引:gr2 是 u16 或 u32`GrannyCopyMeshIndices` 同理。三角组 `TriGroups``(materialIndex, triFirst, triCount)`,每组一次 `rhi::submit`
### 7.4 DXT3 → bgfx 纹理(`scene.cpp`
demo 走**运行时软解**PLAN §08:移动端无 S3TC,且要和 oracle 对齐时贴图要能预解码):
```
CDXTCImage img;
img.LoadHeaderFromMemory(ddsBytes); // 认 512x512 DXT3 5mip
img.LoadFromMemory(ddsBytes);
std::vector<uint32_t> rgba(w*h);
img.Decompress(0, rgba.data()); // level 0mip 链 demo 可先不传,让 bgfx 不采样 mip
rhi::createTex2D(rgba.data(), 512, 512, 1);
```
`bgfx::createTexture2D` + `BGFX_SAMPLER_MIN_POINT` 之类先关 mip,D3 通过后再补全 mip 链。)
### 7.5 坐标系(`scene.cpp` / `camera.cpp`
PLAN §07**约定写死**。demo 取"左手 Y-up、单位 = gr2 原始单位、根变换已 apply"。gr2 里骨架/网格是 Granny 约定(通常右手 Z-up 或文件指定的 art tool basis)。demo 先加一个固定 `basisFix`(可能是绕 X -90° + Z 翻转,M0 用朝向明确的资产标定),乘进 model 矩阵。**左右手系**bgfx 用 `bx::mtxLookAt` / `bx::mtxProj``bx::Handedness` 参数统一,和 `basisFix` 一起调到"warrior 正着站、面朝 +Z"。
### 7.6 bgfx 提交循环(`main.cpp` / `rhi.cpp`
```cpp
bgfx::setViewRect(0, 0,0, w,h);
bgfx::setViewClear(0, BGFX_CLEAR_COLOR|BGFX_CLEAR_DEPTH, 0x303030ff, 1.0f);
bgfx::setViewTransform(0, &view, &proj);
// per draw item:
bgfx::setTransform(&model);
bgfx::setVertexBuffer(0, vb);
bgfx::setIndexBuffer(ib);
bgfx::setTexture(0, s_texColor, tex);
bgfx::setUniform(u_stageColor, &stage, 1);
if (skinned) bgfx::setUniform(u_bones, bones, boneCount);
bgfx::setState(BGFX_STATE_WRITE_RGB|BGFX_STATE_WRITE_A|BGFX_STATE_WRITE_Z
|BGFX_STATE_DEPTH_TEST_LESS|BGFX_STATE_CULL_CW
|BGFX_STATE_MSAA);
bgfx::submit(0, prog);
// end:
bgfx::frame();
```
深度范围 / Y 翻转:用 `bgfx::getCaps()->homogeneousDepth``originBottomLeft` 决定 `bx::mtxProj` 参数,别手写。
### 7.7 sokol_app ↔ bgfx 交接(`main.cpp`D0 的验收)
```cpp
// sokol_app 里关掉它自己的渲染循环意图,只要窗口 + 事件
bgfx::PlatformData pd{};
pd.nwh = sapp_macos_get_window(); // NSWindow*
pd.ndt = nullptr;
// Metal: 也可以 pd.nwh = (__bridge void*)sapp_metal_get_layer(); // CAMetalLayer*
bgfx::Init init;
init.type = bgfx::RendererType::Metal;
init.platformData = pd;
init.resolution.width = sapp_width();
init.resolution.height = sapp_height();
bgfx::init(init);
```
若这条在 macOS 上出不了画面(sokol_app 和 bgfx 抢 layer)——按 PLAN §03 退路:`app/` 换成 SDL2 窗口,`SDL_GetWindowWMInfo``NSWindow*`,其余不变。**D0 就是来验证这个的**,别拖。
---
## 8 · demo 阶段相关的坑(从 PLAN §08 摘)
| 坑 | 在 demo 里的表现 | demo 阶段怎么办 |
|---|---|---|
| sokol_app + bgfx 抢 context | D0 黑屏 / 崩 | D0 卡死就切 SDL2,别硬啃 |
| `.sc` 草稿针对某 bgfx 版本 | shaderc 编不过 / uniform 名对不上 | submodule pin 到相近版本;编不过就照 `bgfx_shader.sh` 手改宏 |
| DXT 软解 vs 客户端 GPU S3TC | D3 贴图和客户端截图有细微色差 | demo 目视够了;要数值对拍时两侧都用软解 RGBA |
| 坐标系 basis | D2 warrior 躺着 / 镜像 / 巨大 | 用朝向明确的资产手调 `basisFix`,记进 `scene.cpp` 注释 |
| ~~曲线子类型超出实现~~ | — | **已消除**M0 实测全 `OldCurveType`degree ≤ 2`gr2_anim.cpp` 全覆盖 |
| `InverseWorld4x4` 读错但一致 | D2 白模看着对,D5 蒙皮炸 | **已兜住**M0 self-check 全量 9166 跑过,`warrior_cheongrin` 6.1e-5 PASS |
| root 偏移放法两种(`InitialPlacement` vs 烘进 root local | 世界姿势整体偏移 ~100 单位 | **已处理**`world[root] = Composite(local[root]) · model.InitialPlacement`M0 gr2_fileinfo 已关联 model→skeleton|
| mediump 精度(移动端才有) | macOS demo 无此问题 | 到 iOS demo 再管,骨骼矩阵用 highp |
---
## 9 · 怎么跑 / 演示检查点
```bash
export XRENDER_ASSET_ROOT="$PWD/../m2dev-client-main/assets"
cmake -B build -DXRENDER_ASSET_ROOT="$XRENDER_ASSET_ROOT" && cmake --build build -j
# 结构(D1
./build/tools/gr2dump/gr2dump "$XRENDER_ASSET_ROOT/PC/ymir work/pc/warrior/warrior_cheongrin.gr2" --gltf /tmp/warrior.glb
# → 期望:骨架树打印、mesh 摘要、"bind pose self-check: PASS (max 3.1e-6)"
# → 把 /tmp/warrior.glb 拖进 Blender,骨架和 T-pose 网格应正常
# 渲染(D2D4
./build/app/xrender-demo
# 拖拽转视角;T 贴图;N 法线;W 线框;Space 播 dance_1D5
```
**演示成立的判据**D0D4 全绿 = "bgfx 能从 assets 的散装 gr2 + dds 渲出正确的带贴图 warrior,且有自检工具" —— 这就把 PLAN 的 M1 用一个能给人看的东西证了。D5 绿 = M2 的核心(曲线采样 + LBS)也站得住。
**下一步**demo 之后):接 oracleWindows dump)做 D5 的数值对拍 → 换 iOS/Android toolchain 跑 D2D4 → 按 PLAN M3 补性能采集。
+558
View File
@@ -0,0 +1,558 @@
# 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/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 骨骼 + 顶点 + 单帧),移植阶段扩成整机录制回放 —— 是整个项目最值的一笔投资。
+32
View File
@@ -0,0 +1,32 @@
# reference/ — archived from xrender-poc
These docs come from **`xrender-poc`**, the bgfx-based rendering POC that was
**retired on 2026-08-29** in favour of the Godot route (this repo). See
[`../MIDREVIEW.md`](../MIDREVIEW.md) for the decision.
They are kept because the reverse-engineering knowledge is route-independent and
still authoritative:
| file | what's still useful |
|---|---|
| `steps/M0-gr2-reader.md` | the entire `.gr2` v6/v7 format breakdown that `libgr2` implements — header, sections, Oodle1, self-describing type tree, `granny_transform`, `OldCurveType` B-splines, the "明确不做" table. **`libgr2` is vendored here now** (`../../libgr2`). |
| `steps/00-oracle.md` | how the Wine + MinGW Granny oracle works — the ground truth for `libgr2` numeric verification. Oracle harness vendored at `../../oracle/`. |
| `steps/M1-static-render.md` | material-binding chain (`granny_material``.Maps[].Map``granny_texture.FromFileName`), DDS/DXT decode, the bgfx-era gotchas. The material logic maps 1:1 onto `m2_material.cpp` here. |
| `steps/M2-anim-skinning.md` | CPU LBS formula, `sample_pose` retarget-by-bone-name, the Granny normal-transform (plain 3×3, **no** inverse-transpose) — same math the GDExtension uses. Numeric gates vs Granny (≤6.5e-5). |
| `steps/M3-mobile.md` | iOS/Android notes from the bgfx shells — historical; Godot handles platform now. |
| `steps/M4-realistic-load.md` | eterpack / full-load plan — deferred in both routes. |
| `PLAN.md` | the bgfx-route master plan. §01 (go/no-go criteria, "工程性延期 vs 方案性死路"), §05 (format findings), §07 (visual-diff layering) are still the shared vocabulary. |
| `DEMO-PLAN.md` | bgfx RHI demo design record. Mostly superseded; kept for the shader/material-stage analysis. |
**Relative paths inside these files** (`../libgr2`, `oracle/`, `test/golden/…`,
`engine/…`, `app/…`) refer to the old xrender-poc layout. The mapping:
| old (xrender-poc) | now (mtgodot-poc) |
|---|---|
| `libgr2/` | `libgr2/` (vendored, unchanged) |
| `formats/` | `formats/` (vendored) |
| `oracle/` | `oracle/` (vendored; `oracle.exe` + `granny2_x64.dll` rebuilt locally) |
| `tools/{gr2dump,gr2fuzz,oracle_diff}` | `tools/` (build with `-DMTGODOT_BUILD_TOOLS=ON`) |
| `engine/`, `app/`, `platform/`, `third_party/` | **dropped** — Godot + the GDExtension in `extension/` replace them |
| `test/golden/*.png` (bgfx shots) | `test/bgfx-reference/` (cross-check images) |
| `test/{noise_floor,m2-numeric}.json`, `assets.list` | `test/` (vendored baselines) |
+184
View File
@@ -0,0 +1,184 @@
# 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-FullT2/T4)不在 demo 关键路径**`EterGrnLib` 拆不干净就整体延后。
## 交付物
| 产物 | 位置 | 轨 |
|---|---|---|
| `granny_probe.exe`(读 gr2 → 打 FileInfo | `oracle/` | Lite |
| `oracle` CLI`oracle dump <model.gr2> <anim.gr2> <t> -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.51 天 · **M** ≈ 24 天 · **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` 或 1rigid)、`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 蒙皮顶点 **[SM]**
- `GrannyNewMeshBinding(mesh, srcSkel, animSkel)` + `GrannyNewMeshDeformer(...)` + `GrannyDeformVertices(deformer, boneMatrixCount, worldPose4x4, vertexCount, srcVerts, dstVerts)`
- 另用 `GrannyCopyMeshVertices(mesh, PNT332Type, rawBuf)` 读原始顶点作对照。
- 输出 mesh 0 的 `vertexCount` + 每顶点 `float[3]`(要的话加法线)。
- **AC**
- [ ] bind poset=0 / identity)下,`GrannyDeformVertices` 的输出 == `GrannyCopyMeshVertices` 的原始顶点,`max‖Δ‖ < 1e-3`T8 后收紧到 noise_floor)。
- [ ] 顶点数与 [M0 T6](./M0-gr2-reader.md) / Blender 一致。
- [ ] 走路动画某帧的顶点与层① 世界矩阵手算 LBS 的结果一致(自洽)。
### T6 · 确定态截图 harness **[M]** —— M1 视觉验证依赖
- **独立最小 D3D9 渲染器**~200300 行,**不依赖 EterGrnLib**):`CreateDevice``CreateRenderTarget` 离屏 → 用 T3 的世界矩阵 + T5 的蒙皮顶点画三角 → `GetRenderTargetData` → 存 PNGPNG-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-FullT2/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 渲染器 | 独立写 ~200300 行离屏 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 <model.gr2> <anim.gr2> <t>` 可用;`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 状态记录:已做 / 延后 / 放弃
+292
View File
@@ -0,0 +1,292 @@
# M0 · gr2 解析器(libgr2 + gr2dump + fuzz
> 总纲:[`../PLAN.md`](../PLAN.md) §04 / §05 / §06。演示切片见 [`../DEMO-PLAN.md`](../DEMO-PLAN.md) D1。
> 本文件是 M0 的详细施工文档。
---
## 目标
自研 `libgr2`(只读子集),证明能脱离 Granny 从 Metin2 真实 `.gr2` 读出骨架 / 蒙皮网格 / 骨骼权重 / 动画轨道,
且结构与 [oracle](./00-oracle.md) 逐字段一致。产出后续所有里程碑用的测试资产集。
## 前置
- 无代码前置。
- **构建可全程无 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](./00-oracle.md) T8):T5 自洽检查的**收紧阈值**。未就绪前 T5 用硬编码 `1e-3`
- oracle 那条线([00-oracle](./00-oracle.md))并行,由会 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。容器 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](./M1-static-render.md) 渲染时的事。
- oracle 侧 dump 也声明同一 raw 约定(见 [00-oracle](./00-oracle.md) 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** ≈ 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==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`T2Oodle1)→ 遍历 `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):
- [x] magic 识别(`GRNFileMV_Old` → 32-bit LE);`version == 6`
- [x] `total_size` 字段 == 文件实际大小。
- [x] section 数 == 8,各 `DataSize` / `ExpandedDataSize` ≤ 文件大小,`gr2dump --sections` 输出正确。
- [x] 每个非空 section 成功 Oodle1 展开到 `ExpandedDataSize`T2 完成)。
- [x] 所有 `grn_pointer_fixup``From` / `To` 落在合法范围,0 越界(9166 全量)。
- [x] 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` → 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 区各一块,共享算术流)。
- **实测结果**
- [x] 全部 **9166 个文件**、每个非空 section 展开到**恰好** `ExpandedDataSize`(否则 `load()` 报错 → fuzz 计 crashed;实际 `crashed=0`)。
- [x] **内容锚(不依赖 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 就绪后)。
**顺带实测发现**(更新其它文档):
| 发现 | 影响 |
|---|---|
| 资产由 **Granny Standard Exporter SDK 2.4.0.7** 导出 | [00-oracle](./00-oracle.md) 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.gr2` root object):
- [x] 遍历不崩,无越界读(9166 全量)。
- [ ] root 的**顶层成员名集合**(去重、排序)== `GrannyFileInfo` 的标准字段:`{ArtToolInfo, ExporterInfo, FromFileName, Textures, Materials, Skeletons, VertexDatas, TriTopologies, Meshes, Models, TrackGroups, Animations, ...}`(以 `granny_file_info.h` 的实际 struct 为准)。
- [x] `gr2dump --members` 打印 `(name, MemberType, ArrayWidth)`,与 `granny_file_info.h` 一致。
- [x] 未知成员类型 0 个(全量)。
### T1+T3+T4 集成检查点
- [x] 从 root 跟指针无崩走到 `Skeletons[0].Bones[0].Name``warrior_cheongrin` 读出 "Bip01"。
### T4 · `gr2_fileinfo` **[S]** ✅(期望值待复核)
- 定位 `FileInfo` root,按名暴露 span 视图:`Skeletons[] / VertexDatas[] / TriTopologies[] / Meshes[] / Materials[] / Textures[] / Models[] / TrackGroups[] / Animations[]`。对外只给 POD 视图,不泄露内部指针。
- **AC**`warrior_cheongrin.gr2`,期望值先用 Blender 导入核对,oracle 到位后换 `GrannyGetFileInfo`):
- [ ] `Skeletons` count == 1`Models` 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` / `LocalTransform``granny_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 · 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.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` 的曲线子类型直方图,实现**占比最高的 1–2 种**(大概率 `granny_curve.cpp` 的未压缩关键帧 `DaKeyframes*` + 线性插值 / slerp)。
- 其余子类型 → 记入报告,走 [M2](./M2-anim-skinning.md) 的 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
- **主**`libgr2` dump 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_cheongrin` `6.1e-5` PASS。全量 2013/2017 < 1e-34 个 `>=1e-1` **经 oracle 证实 libgr2 正确**assassin 对 Granny 3.1e-5),是自洽不变量本身对这几个双-root 资产不成立,非 libgr2 bug。
- **fuzz**T9b 全量 9166 —— ✅ **零崩溃 + 零谓词失败**
- **变体可实现**:✅ 无 BitKnit / 无 Oodle0;曲线全 `OldCurveType`degree ≤ 2,T7b 全覆盖。不做的变体见 [`../../libgr2/README.md`](../../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 样条拟合等) | **已解**:全部 `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-34 个异常定位到辅助骨子树(见上)|
## DoD 清单
- [x] T1 header + section 表:`gr2dump --sections` 跑通全部 9166 个文件、0 崩溃、`total_size` 全对
- [x] T2 Oodle1 解压:9166/9166 全 section 展开 == `ExpandedDataSize`;内嵌路径串解压后干净可读(内容锚)
- [ ] T2 加强:与 `GrannyDecompressData` 逐字节对拍一次(oracle 就绪后)
- [x] T1 fixuppointer fixup 索引建立(`(section<<32|from)->Ref`),懒解引用,0 越界(9166 全量)
- [x] T3 类型树遍历器:root object 顶层成员名集合 == `granny_file_info` 标准字段(13 个),`gr2dump --members` 打印一致
- [x] T1+T3+T4 集成检查点:从 root 跟指针无崩走到 `Skeletons[0].Bones[0].Name` 读出字符串(`warrior_cheongrin` → "Bip01"
- [x] T4 FileInfo`warrior_cheongrin.gr2` → skeletons=1 / models=1 / animations=0 / materials=4 / textures=2 / meshes=5*期望值待 Blender/oracle 复核*
- [x] T5 骨架 + bind pose 自洽:`warrior_cheongrin` `max|Δ|=6.1e-5` PASS(阈值 1e-3);全量 2017 个多骨 skeleton 中 2013 个 < 1e-34 个异常见下)
- [x] T6 网格:`warrior_cheongrin` 5 mesh 全 `PNT332_Skinned`,顶点/索引/三角组自洽(`triFirst+triCount*3==indices`),bone-binding 0 悬空
- [x] T9a/b9166 个 `.gr2` **零崩溃** + **零谓词失败**(骨骼数/父索引/NaN/索引越界/权重和/bone-index 范围)
- [x] T9c`test/fuzz-report.json`6 类直方图)+ `test/assets.list`18 个)产出
- [x] 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 × 9155magic `GRNFileMV_Old`)、**v7 × 11**magic `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`,无未知类型** |
| 曲线格式 | **全部是 `OldCurveType`**Granny 2.4`{Int32 Degree; RefToArray Knots; RefToArray Controls}`,无压缩变体、无 `curve2`/`CurveData` variant)。子类型 = (degree, dim)<br>pos `d0·dim3`(322k) / `d0·dim0`(2.5k,恒等) / `d2·dim3`(41k)<br>rot `d0·dim0`(77k) / `d0·dim4`(79k,常量四元数) / `d2·dim4`(210k)<br>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。`assassin` bind pose 的 **骨骼世界矩阵 + 蒙皮顶点与真 Granny 2.9.12 逐字段一致(3.1e-5 / 4.6e-5**(见 [00-oracle](./00-oracle.md) / `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_track`Granny 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.InitialPlacement`root 骨 `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 用同一套。
+183
View File
@@ -0,0 +1,183 @@
# M1 · macOS 静态渲染
> 总纲:[`../PLAN.md`](../PLAN.md) §03 / §04 / §06。演示切片见 [`../DEMO-PLAN.md`](../DEMO-PLAN.md) D0D4。
> 本文件是 M1 的详细施工文档。
---
## 目标
`sokol_app`/SDL2 窗口 + bgfx(Metal),把 [M0](./M0-gr2-reader.md) 读出的 gr2 静态网格(绑定姿势)+ DDS 贴图渲染出来,
轨道相机可转。同时验证 **bgfx ↔ 窗口层的交接**PLAN §03 的高风险点)。
## 前置
- [M0](./M0-gr2-reader.md) 完成:`libgr2` 能出骨架 + 网格;`test/assets.list` 产出。
- [oracle](./00-oracle.md) 的 T6(确定态截图)—— M1 视觉对拍要它。
- `EterImageLib``CDXTCImage` 冻结拷进 `reuse/`
## 交付物
| 产物 | 位置 |
|---|---|
| `xrender-demo` 可执行(macOS | `build/app/` |
| `engine/rhi.{h,cpp}` bgfx 薄封装 | `engine/` |
| `engine/scene.{h,cpp}` `camera.{h,cpp}` | `engine/` |
| 着色器:`vs_pnt` / `fs_pnt` / `fs_normalviz` + `texture_stage.sh` | `app/shaders/` |
| 交接方案决议(sokol_app 还是 SDL2 | 记进 `app/README.md` |
---
## 任务分解(按 DEMO-PLAN 的 D0D4 切)
### T0a · toolchain 骨架(对应 D0a
- `CMakeLists.txt``add_subdirectory(third_party/bgfx.cmake)`pin bgfx 到与 `.sc` 草稿相近版本。
- 窗口层**默认用 bgfx 自带 `entry` 或 SDL2**(零风险,bgfx 所有 example 用的)——不是 sokol_app。
- 一个清屏 app`bgfx::setViewClear(0x303030ff)` + `bgfx::setDebug(BGFX_DEBUG_STATS)` + `bgfx::frame()`
- **编一个 dummy `.sc`**`vs_flat.sc` / `fs_flat.sc`),过 `shaderc -p metal --platform osx` + `bin2c`,断言 `*.sc.bin.h` 生成 —— 提前验证 shaderc 链路(否则要到 T2 才触发)。
- hi-dpiresize 事件里 `bgfx::reset(fbWidth, fbHeight)`retina 上用 framebuffer 像素,不是点)。
- **完成判据**:窗口出现纯色背景 + stats 角标;`*.sc.bin.h` 已生成。
### T0b · sokol_app ↔ bgfx 交接实验(对应 D0b,**不阻塞**)
- 另开一个 target,用 `sokol_app.h``sapp_metal_get_layer()`CAMetalLayer*)填 `bgfx::PlatformData::nwh``init.type = Metal`
- 已知障碍:sokol_app 会自建 MTKView + 自己 present,和 bgfx 抢 layerPLAN §03)。
- **成了** → M1 之后统一切 sokol_app(为移动端生命周期铺路)。**没成** → demo 全程用 T0a 的 SDL2/entrysokol_app 留到 [M3](./M3-mobile.md) 再单独攻。
- **完成判据**:二选一有明确结论并记进 `app/README.md`
### T1 · `engine/rhi` —— bgfx 薄封装(≈150 行)
- 接口签名见 DEMO-PLAN §5「engine/rhi」。语义对齐 `StateManager`
- 关键:`init(nativeHandle,w,h)``createVB/IB/Tex2D/Program``setModel/setBones/setTexture/setStageUniforms``submit``beginFrame/endFrame`
- 深度范围 / Y 翻转:用 `bgfx::getCaps()->homogeneousDepth` / `originBottomLeft` 决定 `bx::mtxProj` 参数,别手写。
### T2 · `engine/scene` + `vs_pnt/fs_pnt` —— 白模上屏(对应 D2)
- `scene``libgr2::Mesh` → 打包顶点 → `bgfx::VertexLayout`(映射表见 DEMO-PLAN §7.3)→ `createVB`;索引 → `createIB`;每 `TriGroup` 一个 draw item。
- `vs_pnt.sc` / `fs_pnt.sc`:从 `shaders.rar` 拷改。`vs_pnt``a_color0`fog 先关。
- `camera.{h,cpp}`:轨道相机(鼠标拖 = 绕轨道、滚轮 = 距离)。
- 坐标系:加固定 `basisFix`Granny art-tool basis → 左手 Y-up),用朝向明确的资产手调到"warrior 正着站、面朝 +Z",写进 `scene.cpp` 注释。
- **完成判据**:可转的白模,轮廓 = Blender 里同模型。
### T3 · DXT 解码 + 贴图(对应 D3)
- `reuse/EterImageLib``CDXTCImage``LoadHeaderFromMemory` + `LoadFromMemory` + `Decompress(0, rgba)`
- `warrior_cheongrin.dds` 实测 **512×512 DXT3 5mip** —— 先只传 level 0`BGFX_SAMPLER_MIN_POINT` 关 mip;T3 过后再补全 mip 链。
- `texture_stage.sh``shaders.rar` 原样拷,喂"单 stage MODULATE(TEXTURE, DIFFUSE)"的 uniform(等价 `tex * vertexColor`)。
-`T` 切贴图 / 白模。
- **完成判据**:UV 无错位、无镜像;和 oracle 确定态截图目视一致。
### T4 · 自检工具(对应 D4)
- `fs_normalviz.sc`10 行):`gl_FragColor = vec4(v_normal*0.5+0.5, 1)`。按 `N` 切。
- 线框:`BGFX_STATE_PT_LINES``bgfx::setDebug(BGFX_DEBUG_WIREFRAME)`。按 `W` 切。
- **完成判据**:法线朝外;三角组分段可见且正确。
### T5 · 多 stage 材质专项(PLAN §04 要求)
-`test/assets.list` 挑一个用了 `D3DTOP_MODULATE2X``ADDSIGNED` 的材质,单独渲一帧和 oracle 确定态截图对拍。
- 不能只靠整场景 SSIM 兜 `texture_stage.sh` 的正确性。
- **完成判据**:该材质的着色结果与 oracle 一致(差异分类内)。
---
## 门禁(go / no-go
- **子门禁 1(交接)**bgfx 经窗口层 handle 在 macOS 出画面 + 输入可用。sokol_app 啃不下 → 用 SDL2/entry,本门禁照样算过(方案已决议)。
- **子门禁 2(几何)**:网格拓扑、UV 正确;无背面剔除错误 / 法线翻转。
- **子门禁 3(材质)**:一个多 stage 材质的着色结果与 oracle 对上。
## 验证
- **对比场景两侧都喂同一张预解码 RGBA**(绕开 DXT),截图 diff 只反映几何 / 光照 / texture-stage,不被"软解 vs 硬件 S3TC"的 bit 级差异污染。
- oracle 侧进入确定态(T6:注入相机矩阵、固定光、绑定姿势、无程序化摇摆)。
- 与 oracle 截图像素 diff + SSIM;差异分类见 PLAN §07 视觉层。
- 法线可视化模式自检。
## 本步风险(从 PLAN §08 筛)
| 风险 | 状态 / 缓解 |
|---|---|
| sokol_app + bgfx 抢 context | **规避**M1 用 GLFW`GLFW_NO_API` + `glfwGetCocoaWindow``PlatformData.nwh`),零冲突。sokol_app 留到 M3。 |
| `.sc` 草稿针对某 bgfx 版本,shaderc 编不过 | **已解**`vs_pnt`/`fs_pnt``shaders.rar` 改写对上当前 pin 的 `bgfx_shader.sh``shaderc -p metal` 编过。 |
| `reuse/EterImageLib` 从没链接过 | **规避**DXT1/3/5 自研 `engine/dxt.cpp`~180 行),完全不碰 EterImageLib。 |
| 坐标系 basiswarrior 躺着 / 镜像 / 巨大 | **已解**`basis_fix = rotX(-90°)·scale(0.01)`warrior 正着站、比例约 1.7m。写死在 `scene.cpp`。 |
| DXT 软解 vs 客户端 GPU S3TC 色差 | 对拍时两侧都用软解 RGBA(`engine/dxt.cpp` 产出,喂给 oracle 侧同一张)。 |
| hi-dpi 视口 | `on_fb_size``glfwGetFramebufferSize``rhi::reset`framebuffer 像素)。 |
## DoD 清单
- [x] `xrender-demo` 在 macOS 起窗口(GLFW+ bgfx Metal init 成功(`renderer: Metal`)(T0a
- [x] 交接方案定为 **GLFW**,记进 `app/README.md`T0b)。sokol_app 不用(抢 layer),留到 M3。
- [x] warrior 绑定姿势上屏,正着站、`basis_fix` = rotX(-90°)·scale(0.01)Granny Z-up cm → Y-up m),轨道相机 + 键位 T/N/W/RT2
- [x] DXT1 / DXT3 软解(`engine/dxt.cpp`,绕开 `reuse/EterImageLib`),贴图 + UV 正确、无镜像(`warrior_cheongrin.dds` 512² DXT3、`warrior_face.dds` 256² DXT1)(T3
- [x] `N` 法线可视化(朝外、平滑)/ `W` 线框(拓扑正确)自检(T4
- [ ] 一个多 stage 材质与 oracle 对拍通过(T5)—— **待 oracle**`texture_stage.sh` 单独走 `fs_stage.sc`M1 主路径用固定 MODULATE(TEXTURE,DIFFUSE)
- [ ] 与 oracle 确定态截图 SSIM ≥ 0.98 或差异分类通过 —— **待 oracle**
## M1 实现结果
| 项 | 结果 |
|---|---|
| 依赖 | bgfx.cmake + bx/bimg/bgfx + glm + glfw`third_party/`pin 见 `VERSIONS.md`)。`tools/bootstrap-submodules.sh` 拉。 |
| 构建 | `cmake -B build -DXRENDER_BUILD_DEMO=ON -DXRENDER_BUILD_TOOLS=OFF``cmake --build build --target xrender-demo`。着色器经 bgfx 的 `shaderc` 编成 `metal` `.bin``cmake/xrender-shaders.cmake`),运行时加载。 |
| 无头验证 | `XR_HIDDEN=1`(隐藏窗口)+ `XR_SCREENSHOT=<path>`bgfx `requestScreenShot` → TGA`rhi` 自带 `CallbackI`+ `XR_MODE=normals|wireframe|bindpose` + `XR_FRAMES=n`。CI / 无显示器也能出确定态截图。 |
| 参考截图 | `test/golden/m1-warrior_cheongrin-bindpose-textured.png``-normals.png` |
| 几何 | 5 meshObject16 / face / Object03 / Object09 / body),顶点/索引数与 M0 一致;bind pose = Granny T-pose;朝向、比例正确 |
| 坑 | ① 近期 bgfx 把 `platform.h` 并进 `bgfx.h`;② `bgfx::setUniform` 要在每次 `submit` 前调(frame 头单调一次不可靠)→ `rhi` 缓存 light/stage 每 draw 重设;③ 截图 TGA 带 alpha 通道,shader 输出非 1 的 alpha 会让 PNG 查看器合成成白 → 不透明物体 shader 固定 `gl_FragColor.a = 1`,截图 writer 也强制 alpha 255 |
## 画质改进 pass(档 1 + 档 2)
用户反馈"渲染毛糙"后做的一轮画质提升。基线:无 MSAA / 无 mip / 平光 / 贴图靠文件名瞎猜 / 无 sRGB。
### 档 1 —— 采样与着色正确性(`engine/` + `app/shaders/`
| 项 | 做法 |
|---|---|
| MSAA | `rhi.cpp` `init`/`reset``BGFX_RESET_MSAA_X4`state 加 `BGFX_STATE_MSAA` |
| mip 链 | `engine/dxt.{h,cpp}` 重写:`decode()``dwMipMapCount`(off 28) 循环解每一级;`Image.mips` 存 level0..N。`scene.cpp` 把各级拼成一块传 `rhi::create_tex2d(packed, …, mips)`bgfx `hasMips = mips>1` |
| sRGB 正确性 | `create_tex2d``BGFX_TEXTURE_SRGB`(采样自动 sRGB→linear);`fs_pnt.sc` 线性空间着色,末尾 `pow(col, 1/2.2)` 编回显示空间 |
| 各向异性过滤 | `create_tex2d``BGFX_SAMPLER_{MIN,MAG}_ANISOTROPIC` |
| 半球环境光 | `fs_pnt.sc``mix(u_ambientGround, u_ambientSky, n.y*0.5+0.5)` 代替常数 ambient |
| 3 盏方向光 | `LightDesc``dir[3]`/`color[3]`key/fill/顶光),`.w` = 强度;`u_lightDir/u_lightColor``Vec4,3` uniform |
| 双面光照 | `fs_pnt.sc``abs(dot(n,l))`(薄片/头发/飘带两面都受光,和 Metin2 一致) |
| 法线 renormalize | vs 输出前 + fs `normalize(v_normal)` |
### 档 2 —— 材质绑定(`libgr2` + `engine/`
**这是"毛糙"的最大来源**:原来整个模型套一张 `<gr2 名>.dds``sura_lord` 根本没有 `sura_lord.dds` → 纯黑。
| 项 | 做法 |
|---|---|
| libgr2 解材质 | `gr2_mesh.cpp` `material_texture_name()``granny_material` → 直接 `.Texture.FromFileName`,或递归 `.Maps[].Map`**注意**:本版 Granny 里 `granny_material_map` 的子材质成员名是 `Map` 不是 `Material`)。`gr2_fileinfo.cpp``FileInfo.materials`(顶层表,调试用)。 |
| 每网格贴图表 | `Mesh.material_textures`(与 `granny_mesh.MaterialBindings` 平行);`tri_group.material_index` 索引它 |
| 逐 tri_group 上贴图 | `scene.cpp``SubMesh.ranges``DrawRange{ib,index_count,tex,alpha_cutout}`),一个网格按 tri_group 切多段,**每段切出独立 IB**,各自贴图。贴图按 `FromFileName` 的 basename 在 gr2 同目录里大小写不敏感查找,带缓存。找不到时回落老的 stem 猜测。 |
| bind pose 也要蒙皮 | 很多 Metin2 模型(shaman_lord 等)的 raw 顶点不在 bind 空间,直接上 raw 会整块错位。`Scene::set_bind_pose()` 有骨架时走 `File::bind_pose(0)` 的蒙皮矩阵(= `set_pose`);bounds/相机框选也按 bind 姿势的顶点算。 |
| alpha-test(镂空) | 解贴图时看 mip0 的 alpha 分布:**同时**有 >5% 近 0 且 >20% 近 255(双峰)才判 cutout(头发/飘带/树叶)。只看"低 alpha 比例"会误伤 alpha 平面全 0 的不透明 DXT3(整块被 discard)。`rhi::set_alpha_test` 逐 draw 开,`fs_pnt.sc``texColor.a < ref → discard`。 |
### 排障中发现并修掉的两个真 bug
| bug | 现象 | 根因 / 修复 |
|---|---|---|
| **多材质网格第 2 段起整块飞出视锥** | shaman_lord(1 网格 2 材质组)只渲出头顶一小撮;任何多组网格丢掉第一组之后的内容 | `Scene::draw``rhi::set_model()`(→ `bgfx::setTransform`)每网格只调一次,但 **bgfx 每次 `submit` 消费一次 transform**。第 2 个 draw range 没设 transform → 用单位阵 → 顶点(~120 单位的 skin 空间坐标)画在 basis_fix 之外。**修复**`set_model` 移进 range 循环,逐 submit 重设。 |
| **bind pose 直接上 raw 顶点** | shaman_lord 等模型整体错位、相机框选发飞(模型变成一个远处的点) | raw 顶点不在 bind 空间。**修复**:`set_bind_pose` 走蒙皮矩阵(见上表)。 |
调试加了 `XR_CAM_YAW` / `XR_CAM_PITCH` / `XR_CAM_DIST`(乘子)env 覆盖初始相机,`XR_VERBOSE` 打 bounds / 相机参数,方便无头抽查各角度。
### 结果
- `warrior_cheongrin` / `warrior_novice` / `sura_lord` / `assassin` / `shaman_lord` / `snakeman` 等正/背/动画各角度截图:贴图正确、比例朝向对、边缘平滑、明暗有层次(见 `test/render-samples/`
- **无回归**`gr2fuzz` 9166/9166、`render_fuzz` 9166/91660 crash/empty/nan)、oracle 逐字段对拍 23/23(≤6.5e-5)、iOS 交叉编译通过
### 明确不做(本 POC 范围外,记档)
| 项 | 原因 |
|---|---|
| 多 stage 材质混合(`fs_stage.sc` | Metin2 角色基本是单 stage MODULATE(TEXTURE,DIFFUSE);多 stage 主要用于地形。`texture_stage.sh` 已备,未接主路径 |
| alpha blend(半透明排序) | cutoutalpha-test)已覆盖头发/飘带主要场景;真半透明要 OIT 或按深度排序,收益低 |
| 顶点色 | 语料里角色网格全是 PNT332(无 color 分量) |
| `.msm` 装配(换发型/换肤) | `formats/msm.cpp` 已能解析,装配是模型组合子系统,非渲染画质 |
| 法线贴图 / 切线帧 | PNT332 无切线;Metin2 资产也没有法线贴图 |
| 阴影 / IBL / FXAA / LOD 选择 | 已有 MSAA x4;阴影/IBL 是独立子系统,env map 资产缺失;FXAA 在 MSAA 之上边际收益小 |
| 特效 / 粒子 / 地形 / 水 / SpeedTree | 独立子系统,POC(读+渲+动 `.gr2` 骨骼模型)范围外 |
+135
View File
@@ -0,0 +1,135 @@
# M2 · 骨骼动画 + CPU 蒙皮
> 总纲:[`../PLAN.md`](../PLAN.md) §04 / §06 / §07。演示切片见 [`../DEMO-PLAN.md`](../DEMO-PLAN.md) D5(拉伸)。
> 本文件是 M2 的详细施工文档。
---
## 目标
解析 `.msm` / `.msa`,把 [M0](./M0-gr2-reader.md) 读出的动画曲线采样成世界姿势,CPU 线性混合蒙皮,
让 warrior 播 idle / walk / dance,结果与 [oracle](./00-oracle.md) 的**层①(裸 Granny)**逐帧数值一致。
装配一个多部件角色 + 挂点武器,验 LOD 一致性。
## 前置
- [M1](./M1-static-render.md) 完成:静态网格 + 贴图上屏。
- [M0](./M0-gr2-reader.md) T7`gr2_anim` 子集)+ scaleshear 用量已知。
- [oracle](./00-oracle.md) T3(层① dump+ T5(蒙皮顶点 dump)+ T8(噪声地板 → ε)。
## 交付物
| 产物 | 位置 |
|---|---|
| `engine/animation.{h,cpp}` 曲线采样 + 世界姿势累积 | `engine/` |
| `engine/skinning.{h,cpp}` CPU LBS | `engine/` |
| `formats/msa.cpp`+ 视需要 `msm.cpp` | `formats/` |
| `app/shaders/vs_pnt_skinned.sc` | `app/shaders/` |
| `tools/anim_bake/`(曲线退路,视 M0 结论决定是否要) | `tools/` |
| 数值对拍报告 | `test/m2-numeric.json` |
---
## 任务分解
### T1 · `formats/msa` + `msm` 解析 + 验证门
- `.msa`(文本):引用的动画 `.gr2`、混合参数(blend time / ease)、事件。逻辑照 `RaceManager.cpp` / `EterGrnLib/Util.cpp`
- `.msm`(文本,仅多部件时需要):base model gr2、挂点、材质类型。
- **验证门(PLAN §04 要求)**:dump 解析结果(挂点名、动作列表、混合参数),和源文本逐项目视核对。
- **完成判据**`dance_1.msa` 解析出的动画路径 + blend 参数与文本一致。
### T2 · `engine/animation` —— 曲线采样 + 世界姿势累积
- 按局部时钟 `t` 对每骨 position / orientation / scaleshear 曲线插值 → 局部 `granny_transform` → 沿父链累积 → `world[bone]`
- 对应 `GrannyBuildWorldPose` / `GrannyGetWorldPoseComposite4x4Array`
- ease-in/out 曲线、loop count、raw local clock 语义:照 `EterGrnLib/Motion*` + `ModelInstanceMotion.cpp` 调用序列复刻。
- **非单位 scaleshear**M0 若报告普遍存在,`world[bone]` 要保留完整仿射(4×4 或 4×3),不能退化成刚体。
- **完成判据**`gr2_anim` 子集覆盖的动画能采样出每帧世界矩阵。
### T3 · `engine/skinning` —— CPU LBS
- `skinMatrix[bone] = world[bone] · InverseWorld4x4[bone]`
- 逐顶点:`v' = Σ weight[i] · skinMatrix[boneIndex[i]] · v`4 权重),法线用 `skinMatrix` 的 3×3scaleshear 时要用逆转置)。
- `GrannyMeshIsRigid` 为真的网格不蒙皮,只按挂载骨骼刚体变换 —— 分支照搬。
- **完成判据**bind poseidentity 动画)下 CPU 蒙皮结果 == 原始顶点。
### T4 · `vs_pnt_skinned.sc`GPU 版,D5 演示用)
- `mat4 skin = u_bones[a_indices.x]*a_weight.x + u_bones[a_indices.y]*a_weight.y + ...`
- CPU 端 `rhi::setBones(skinMatrix[], boneCount)` 上传 uniform 数组。
- **注意**M2 的**数值门禁走 CPU 蒙皮**(可 dump 顶点对拍);GPU shader 只是演示。CPU/GPU 一致性放 [M4](./M4-realistic-load.md)。
### T5 · 多部件装配 + 挂点武器
- `.msm` → base gr2 + 额外 gr2(头发 / 时装);各自 mesh binding 到**同一骨架**。
- 武器:`GrannyFindBoneByName("Bip01 R Hand"(或对应名))` → 取该骨世界矩阵 × 武器局部挂点矩阵 → 武器 gr2 的 model 变换。
- **完成判据**:挂点武器的世界变换与 oracle 一致。
### T6 · LOD 一致性
- 加载同模型 LOD 03`warrior_cheongrin_lod_01/02/03.gr2`),确认它们绑**同一骨架**、骨骼索引一致。
- 在某帧切 LOD,测顶点位移(对应骨骼的顶点,切换前后位置差)。
- **完成判据**:切换帧顶点位移 < 阈值(无肉眼可见跳变)。
### T7 · 曲线退路(`tools/anim_bake`,条件性)
- **仅当** M0 fuzz 报告曲线子类型超出"1–2 种可实现"范围时启用。
- `anim_bake`Windows 侧用 oracle 把动画按固定帧率(如 60fps)烘焙成密集关键帧(每骨每帧一个 TRS),存自有格式。
- `libgr2` / `animation` 增加"读烘焙格式"分支,PoC 完全绕开曲线解码。
- **完成判据**:烘焙动画在 M2 数值对拍中照常通过。
---
## 门禁(分级,按序)
0. `.msm` / `.msa` 解析子检查通过(T1)。
1. **骨骼世界矩阵**与 oracle **层①(裸 Granny`GrannyGetWorldPoseComposite4x4Array` 直出)** 逐帧对拍,`max|Δ| < ε_mat`noise_floor × 余量)—— 隔离曲线采样 + 姿势累积,**先过**。
- **M2 只对层①**。层②(`ActorInstanceBlend` + LOD 骨骼裁剪)不在 PoC 范围,留正式移植。
2. **蒙皮顶点坐标**与 oracle 逐帧对拍,`‖Δ‖ < ε_vtx`(在矩阵已对上的前提下)—— 隔离蒙皮 + 顶点格式。
3. 挂点武器世界变换与 oracle 一致(T5)。
4. LOD 0–3 共享骨骼绑定,切换无跳变(T6)。
## 验证
- 数值层对拍:N 帧 × M 顶点、**全部测试资产集**,不是单文件。
- **必须按序**:先骨骼矩阵,再顶点。否则动画错 + 蒙皮错相互抵消、最终顶点却"对",掩盖两个 bug。
- **tie-break**libgr2 与 oracle 分歧又都合理时,用 Blender `io_scene_gr2` 在同 `t` 算的世界矩阵仲裁(也能抓 oracle 自己的 bug)。
- 视觉层:3 个确定姿势(idle 第 0 帧、走路中段、旋转量大的姿势),差异分类见 PLAN §07。
## 本步风险(从 PLAN §08 筛)
| 风险 | 状态 / 缓解 |
|---|---|
| Granny 曲线压缩格式多样 | **消除**M0 实测全 `OldCurveType`degree ≤ 2`gr2_anim` 全覆盖,无烘焙退路依赖 |
| ease/loop/局部时钟语义 | demo 用简单 loop`fmod(t, duration)`);ease / blend / accumulation 是正式移植照 `EterGrnLib/Motion*` 复刻,PoC 不需要 |
| 骨骼绑定顺序 / mesh binding 重映射 | `gr2::sample_pose` 按骨骼名 retarget181 warrior 动画 NaN=0);skin 矩阵按 `mesh.bone_bindings` → skeleton 索引 |
| rigid + deformable mesh 混合 | `engine/skinning.cpp``mesh.rigid` 分支(单骨刚体 vs 4 权重混合)|
| 非单位 scaleshear 让 `world·invBind` 近似失效 | 全程完整仿射 4x4`mul4x3` 保 3x3 + 平移);法线用 skin 3x3scaleshear 严格应逆转置,M2 先近似,记 M4 收紧)|
| ε 拍脑袋 | 待 oracle T8 的 noise_floor × 10 |
| ~~数值门禁未跑~~ | **已跑**macOS + Wine + MinGW 交叉编译的 `oracle.exe`Granny 2.9.12vs libgr2,骨骼矩阵 + 蒙皮顶点全 ≤ 6.5e-5(float 累积误差量级)。`oracle/RUNBOOK.md` / `tools/oracle_diff` |
## DoD 清单
- [x] `.msm`/`.msa` 解析 dump 与源文本核对通过(`formats/textscript` token 树 + `msa`/`msm`
- [x] **骨骼世界矩阵 vs oracle 层① `max|Δ| ≤ 4.6e-5`** —— `oracle/run-diff-suite.sh`**23 用例**warrior_cheongrin/lord ×4 LOD、assassin 双 root、shaman_lord **v7**、redthief2、snakeman、rabbit_backup、ox_01dance_1/attack/run/wait @ 多个 t)。`test/m2-numeric.json`
- [x] **蒙皮顶点 vs oracle `‖Δ‖ ≤ 6.5e-5`**(同上,vs `GrannyDeformVertices`;法线用同 3x3,Granny 也不做逆转置 —— `engine/skinning.cpp`
- [x] `test/noise_floor.json`:同-DLL 确定性 0,跨实现地板 mat 4.6e-5 / vtx 6.5e-5,ε=1e-3
- [x] **`app/render_fuzz` 全量渲染烟测**9166 个 `.gr2` 全过真管线(bgfx + `Scene::build` + DXT 解码 + CPU 蒙皮 + `submit`skinned 4055 / anim-only 5109 / rigid 2)——**0 崩溃 / 0 空场景 / 0 NaN 姿势**`test/render-fuzz.json`)。
- **顺带修的 libgr2 bug**`redthief_general/{back,front}_damage.gr2` 的 finger track 控制点在文件里就是 `NaN``read_old_curve` 现在遇到非有限 knot/control 整条曲线弃(回退 bind),不把 NaN 灌进蒙皮链。
- 抽样截图里「模型扭曲」的都是 **render_fuzz 把某物种的动画 retarget 到别的 rig**(如怪物动画播到 warrior 身上,或 warrior dance 播到怪物身上)—— 按名 retarget 到 bind pose 比例不同的骨架,肢体会拉长。**不是 libgr2/engine bug**(同 rig 的 warrior + dance_1/attack/run/wait 已对拍 Granny 1e-5)。render_fuzz 已改成只在 rig 匹配时才应用动画,其余画 bind pose。
- [~] 挂点武器变换 —— `gr2::sample_pose` 出每骨世界矩阵,武器 = `world[handBone]·mount`;未接进 demo(无 .msm 武器数据),逻辑就绪
- [x] LOD 03 共享骨架:`warrior_cheongrin` lod_01/02/03 与 base 均 75 骨、骨骼名 + ParentIndex 逐项一致 → 切 LOD 骨骼索引不变、无跳变
- [x] `xrender-demo``Space``dance_1`CPU LBS 每帧更新 dynamic VB,姿势连贯(`test/golden/m2-*.png`
- [ ] (条件)曲线退路 `anim_bake` —— **不需要**M0 实测曲线全 `OldCurveType` degree ≤ 2`gr2_anim` 全覆盖
## M2 实现结果
| 项 | 结果 |
|---|---|
| T1 `.msa`/`.msm` | `formats/textscript.cpp` 通用 token 树 + `msa.cpp`/`msm.cpp``dance_1.msa`→duration 28.333334 / accum 0`throw.msa`→1 event(type10, t=0.824)`warrior_w.msm`→base + hair_path + 75 hairs。dump 与源文本逐项一致。 |
| T2 世界姿势 | `gr2::sample_pose(skeleton, animation, t, world, skin)`libgr2,跨文件:model gr2 的 skeleton + anim gr2 的 tracks,按骨骼名 retarget)。`world[i]=Composite(local[i])·(parent<0?InitialPlacement:world[parent])``skin[i]=InverseWorld4x4[i]·world[i]`。bind pose 时 `skin ≈ I`6.1e-5)。 |
| T3 CPU LBS | `engine/skinning.cpp``v'=Σ wᵢ·skin[bᵢ]·v`4 权重归一),rigid mesh 走单骨刚体分支。`skin_bind_pose_residual` 自检 = 0(skin 全单位 → 输出 == 输入)。 |
| T4 GPU 蒙皮 shader | 跳过:M2 数值门禁走 CPU(可 dump 对拍),demo 也用 CPU + dynamic VB。`vs_pnt_skinned.sc` 留到 M4 CPU/GPU 一致性。 |
| 无 oracle 抽检 | 全部 181 个 `pc/warrior/**.gr2`:139 个带动画,各在 t=0/⅓/⅔/1 采样 —— **NaN=0**56 个 track 全名匹配 skeleton(其余部分匹配,未匹配的骨退回 bind,安全)。 |
| demo 用法 | `XR_ANIM=<anim.gr2\|.msa>` `XR_ANIM_T=<0..1\|秒>` + 交互 `Space` 播放/暂停。 |
+128
View File
@@ -0,0 +1,128 @@
# M3 · iOS + Android 真机
> 总纲:[`../PLAN.md`](../PLAN.md) §04 / §06 / §07。
> 本文件是 M3 的详细施工文档。与 [M2](./M2-anim-skinning.md) 尾段并行。
---
## 目标
把 [M2](./M2-anim-skinning.md) 的工程原样交叉编译到 iOS + Android 真机跑基准场景,
证明三端渲染一致、移动端性能达标、生命周期(context loss / 后台)稳。
## 前置
- [M2](./M2-anim-skinning.md) 门禁 1–2 通过(骨骼矩阵 + 蒙皮顶点对拍)。
- [M1](./M1-static-render.md) T0b 的交接结论(决定移动端窗口壳用什么)。
- **资产上真机的方案**(见下 T1)—— 不是"原样交叉编译"就完事。
## 交付物
| 产物 | 位置 |
|---|---|
| iOS `.app`Xcode / CMake iOS toolchain | `platform/ios/` |
| Android APKNDK / Gradle 或纯 CMake + `native_app_glue` | `platform/android/` |
| 三端 CImacOS 原生 + iOS 模拟器 + Android 模拟器) | `.github/` 或等价 |
| 真机性能采集报告 | `test/m3-perf.json` |
| 三端快照互拍报告 | `test/m3-snapshot.json` |
---
## 任务分解
### T1 · 资产上真机方案(PLAN §04 / §08
- 9166 个散装 `.gr2` + `.dds` 不能直接堆进 APK / `.bundle`(体积、iOS 限制)。二选一:
- **A(推荐)**M3 前提前接 `EterPack`(现排 M4),资产走 `.eix/.epk``CMappedFile` 的 mmap / `AAsset_getBuffer` 路径。
- **B(最小)**:只把 `test/assets.list` 里那 ~15 个 + 依赖贴图打进去。
- **完成判据**:真机上 `libgr2` 能加载 warrior + dance_1,路径与桌面散文件结果一致。
### T2 · 平台壳
- **窗口 / 生命周期**:按 M1 T0b 结论——sokol_app(若交接成了)或各平台最小原生壳(iOS `MTKView` + `CADisplayLink`Android `NativeActivity` / `GLSurfaceView`)。
- **iOS**:全静态链接(无 `dlopen`);`.bundle` 打包资产;`Info.plist` / 签名走开发证书(不涉及上架)。
- **Android**NDK `arm64-v8a` + `armeabi-v7a``AAssetManager` 经 JNI 注入(复用 MobileSource `AndroidMain.cpp` 形态)。
- bgfx `init.type`iOS = `Metal`Android = `OpenGLES`(或 `Vulkan`,先 GLES3 稳)。
### T3 · Android context lossPLAN §08 高危,确定项)
- 切后台 → GL context 连同 GPU 资源可能失效。
-`SUSPENDED` / `RESUMED`(或 `onSurfaceDestroyed/Created`)里按 bgfx 的重置流程:`bgfx::reset` + 必要时重建 `sg_*` / bgfx 资源句柄。
- **测试**:切后台 ×100`adb shell input keyevent KEYCODE_HOME` + 回前台脚本循环)。
- **完成判据**:100 次无崩、无黑屏、无资源泄漏(`adb shell dumpsys meminfo` 稳定)。
### T4 · iOS 后台 / drawable 生命周期(PLAN §10.1
- 切后台 / 锁屏 / 来电 → 丢 `CAMetalDrawable`
- `applicationWillResignActive` 暂停渲染循环;`didBecomeActive` 恢复;`nextDrawable` 返回 nil 时跳过该帧不崩。
- **完成判据**:后台 / 锁屏 / 来电各 ×20 恢复正常。
### T5 · mediump 精度专项(PLAN §08 / §10.1
- 移动 GPU 上 `mediump` 存不下 60 骨链的骨骼矩阵 / 大坐标 → 抖动 / 爆顶点。
- 顶点着色器里骨骼矩阵与位置强制 `highp`;必要时把模型原点归一(减去包围盒中心)。
- **测试**:highp 前后对比截图,确认抖动 / 爆顶点消失。
- **完成判据**:highp 版无可见抖动。
### T6 · 性能采集(PLAN §07 性能层)
- **基准硬件写死具体机型**iPhone 11A13+ Pixel 6Mali-G78+ 一台 Adreno 机。
- **两种角色**:① 简化(≈5k 三角、≈60 骨、单 draw call);② **满配**(多部件 + 武器 + 时装 + 挂点特效,1–2 万三角、多 draw call)。每种 1 → 8 → 20 → 50 个。
- 指标:帧时间 p50/p99、FPS、draw call、GPU 显存(Xcode GPU report / Android GPU Inspector / `adb dumpsys meminfo`)、冷启动到首帧、单 `.gr2` 加载耗时、峰值 RSS。
- 写进 `test/m3-perf.json`,逐项标注是否越线。
### T7 · 三端快照互拍 + CI
- 确定性场景在 macOS / iOS / Android 各渲一帧,两两 SSIM。
- 三端之间应比各自与 oracle 更接近(同 `shaderc` 源、同逻辑)。
- CIiOS 模拟器 + Android 模拟器构建并启动,断言"到达首帧 + 连续 N 帧无 bgfx / Metal / GLES 验证层报错"(开 `BGFX_DEBUG_*`、Metal API validation、GLES `KHR_debug`)。
---
## 门禁(go / no-go
- 三端渲染**差异分类通过**(不是"逐像素一致",见 PLAN §07 视觉层:SSIM ≥ 0.98 直接过;0.950.98 进分类;< 0.95 fail)。
- 性能:简化角色 1 个 ≥ 60fps、20 个 ≥ 30fps**满配角色 1 个 ≥ 60fps、8 个 ≥ 30fps**;冷启动 < 3s;基准 RSS < 300MB;单角色加载 < 30ms。
- 无 bgfx / 图形验证层报错。
- **Android 切后台 ×100 恢复正常**;**iOS 后台 / 锁屏 / 来电恢复正常**。
- **mediump 精度专项通过**highp 后无抖动)。
## 验证
- 三端快照互拍矩阵(T7)。
- 真机性能逐项对通过线(T6)。
- 生命周期压力测试脚本可复现(T3 / T4)。
## 本步风险(从 PLAN §08 筛)
| 风险 | 缓解 |
|---|---|
| Android GL context 丢失后 GPU 资源需重建(确定项) | T3 在 suspend/resume 走 bgfx resetM3 就做别拖 |
| iOS 全静态 + 后台丢 drawable | T2/T4 全静态链接 + drawable nil 跳帧 |
| 9166 散装资产上真机方式没定 | T1 二选一,M3 交付显式包含 |
| 移动 GPU mediump 精度不足 | T5 骨骼矩阵 / 位置用 highp |
| `shaderc` 跨编译 metal / 300_es 行为差异 | 单一 `.sc` 源;开验证层;T7 三端快照对拍 |
| 深度范围 / Y 翻转后端差异 | `bgfx::getCaps()->homogeneousDepth` / `originBottomLeft` 抹平 |
| sokol_app 交接在 iOS/Android 也不成 | 用各平台最小原生壳(T2 备选) |
## DoD 清单
- [~] iOS **模拟器**跑起来,播 dance_1CPU 蒙皮 + bgfx Metal,输出与 macOS 同 t **像素级一致**(同 `.bin` 着色器、同 `demo_core`)。`test/golden/m3-ios-*.png`。**真机**(差异分类、后台/锁屏/来电、mediump、perf)待设备。
- [ ] Android 真机 —— **脚手架就绪(`platform/android/`)未构建**:本机无 NDK。
- [ ] `test/m3-perf.json` —— 待真机
- [ ] Android 切后台 ×100 —— 待设备(`android_main.cpp``APP_CMD_TERM/INIT_WINDOW` 分支已写 shutdown/reinit
- [ ] iOS 后台 / 锁屏 / 来电 —— 待设备(`ios_main.mm``applicationWillResignActive`/`DidBecomeActive` 暂停/恢复 CADisplayLink 已写)
- [ ] mediump → highp 专项 —— `vs_pnt_skinned.sc` 已写(骨骼矩阵 + 位置全 `highp`);对比测试待真机 GPU
- [ ] CI 三端 job —— 待
- [ ] **M3 全绿 = PLAN §01 判定"方案可行"** —— iOS 一端已验证(编译 + 运行 + 渲染一致);Android 端 + 真机压测待设备
## M3 实现结果(本轮)
| 项 | 结果 |
|---|---|
| **平台无关核心** | `app/demo_core.{h,cpp}` —— 加载 gr2 + 建场景 + 采样姿势 + 画,无窗口/输入依赖。桌面壳(`app/main.cpp` GLFW+ iOS 壳(`platform/ios/ios_main.mm` UIKit+ Android 壳(`platform/android/android_main.cpp` native_app_glue)都调它。 |
| **iOS 壳(T2** | `ios_main.mm``UIApplicationMain``MetalView``+layerClass = CAMetalLayer`)→ `CADisplayLink``demo::frame`。bgfx `nwh = CAMetalLayer*``init.type = Metal`。生命周期回调(T4 骨架)已接。 |
| **iOS 构建 + 运行** | `cmake -G Xcode -DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_SYSROOT=iphonesimulator`**BUILD SUCCEEDED**`xcrun simctl` 装 + 跑 iPhone 16 Pro 模拟器:libgr2 加载 warrior + dance_1、DXT3/DXT1 解码、bgfx Metal init、CADisplayLink 跑 120 帧无崩、`requestScreenShot` 出图。渲染结果与桌面同 t 一致。 |
| **交叉编译坑** | ① `BGFX_CONFIG_VIDEO` 默认 ON`video_mtl.cpp` 在 iOS 模拟器编不过(`CVMetalTextureCache` 不可用)→ 关掉。② shaderc 跑不了目标平台 → `xrender_compile_shaders(... PREBUILT <dir>)` 用宿主机预编译的 `metal .bin`Metal 字节码 macOS/iOS 通用)。③ iOS 不建 GLFW`third_party/CMakeLists.txt``NOT IOS` 守卫)。 |
| **Android 壳(T2** | `platform/android/``android_main.cpp`native_app_glue + `ANativeWindow` → bgfx GLES nwh + `AAssetManager` 解资产)、`CMakeLists.txt`NDK,链 `xr_democore` + bgfx GLES)、`build.gradle` + `AndroidManifest.xml``NativeActivity`)。**未构建**(本机无 NDK`platform/android/README.md` 记了装 NDK 后的步骤)。 |
| **T5 GPU 蒙皮 shader** | `app/shaders/vs_pnt_skinned.sc``u_bones[64]` 混合,骨骼矩阵 + 位置 + 法线全 `highp`。**未接进 demo**demo 走 CPU 蒙皮 = M2 数值门禁路径),留 M4 CPU/GPU 一致性 + 真机 mediump 对比。 |
+92
View File
@@ -0,0 +1,92 @@
# M4 · 逼近真实负载(可选)
> 总纲:[`../PLAN.md`](../PLAN.md) §06。
> 本文件是 M4 的详细施工文档。**可选** —— M3 全绿已经能给出 go/no-go 结论,M4 只是把结论从"骨骼网格能跑"推到"接近真实负载也能跑"。
---
## 目标
在 M3 已验证的基础上,加 GPU 蒙皮、eterpack 加载、多角色 + 地面 + 简单光照、LOD 切换,
把性能结论从简化场景推到接近真实的负载。
## 前置
- [M3](./M3-mobile.md) 全部门禁通过。
- [M2](./M2-anim-skinning.md) 的 CPU 蒙皮作为 GPU 蒙皮的对拍基准。
## 交付物
| 产物 | 位置 |
|---|---|
| GPU 蒙皮路径(`vs_pnt_skinned` 全量启用 + 骨骼矩阵 texture 上传) | `engine/skinning.cpp` `app/shaders/` |
| eterpack 加载路径接入 | `reuse/EterPack/` + `engine/scene.cpp` |
| 多角色场景(1 / 8 / 20 / 50 + 地面 + 方向光 | `app/` |
| CPU vs GPU 蒙皮对拍报告 | `test/m4-skin-cmp.json` |
| 扩展性能曲线 | `test/m4-perf-curve.json` |
---
## 任务分解
### T1 · GPU 蒙皮
- 骨骼矩阵改走 GPU:小骨架用 `bgfx::setUniform(u_bones, mtx, boneCount)`uniform 数组,上限 ~128);大骨架 / 多实例用**骨骼矩阵 texture**`bgfx::createTexture2D(RGBA32F)` 每帧 `updateTexture2D`VS 里 `texelFetch`)。
- `vs_pnt_skinned.sc` 从 M2 的演示版转正。
- **完成判据(门禁)**:GPU 蒙皮输出顶点与 M2 的 CPU LBS 结果一致(离屏 transform feedback 或渲到 RT 读回对拍),`‖Δ‖ < ε_vtx`
### T2 · eterpack 加载路径
- `reuse/EterPack`(冻结拷入):`CEterPackManager::RegisterPack` + `Get(mappedFile, "d:/ymir work/.../xxx.gr2", &data)` → LZO 解压 + 解密 → 裸字节喂 `libgr2`
- 需要 `assets/` 打包成 `.eix/.epk``m2dev-client-main/assets/PackMaker.exe``pack.py`Windows 侧一次性做)。
- **完成判据(门禁)**:同一 gr2 经 eterpack 路径 vs 散文件路径,`libgr2` 产出的骨架 / 网格 byte-identical。
### T3 · 多角色场景
- N 个 warrior 实例(各自动画时钟错开),一块带贴图的地面(用一个 zone 静态 gr2 或程序化 quad + terrain 贴图),一个方向光。
- 实例化:bgfx `instanceDataBuffer`(每实例 model 矩阵);蒙皮 texture 每实例一段。
- **完成判据**:50 个角色可交互帧率(真机上 ≥ 20fps 作为下限参考,非硬门禁)。
### T4 · LOD 切换(运行时)
- 按相机距离在 LOD 0–3 间切(用 M2 T6 已验的 LOD 一致性)。
- 切换要平滑:同帧不要让顶点跳(M2 已验位移 < 阈值),或加一帧 cross-fade。
- **完成判据**:镜头拉远/拉近,LOD 切换无肉眼可见 pop。
### T5 · 扩展性能曲线
- 简化 + 满配角色,各 1 / 8 / 20 / 50 个,三台基准机各跑一遍。
- 画帧时间 vs 角色数曲线,标出 CPU 蒙皮 vs GPU 蒙皮两条线。
- 写进 `test/m4-perf-curve.json`
---
## 门禁(go / no-go
- **GPU 蒙皮结果与 CPU 一致**`‖Δ‖ < ε_vtx`)。
- **eterpack 路径与散文件结果一致**byte-identical 骨架 / 网格)。
- 50 角色可交互帧率(参考线,非硬门禁)。
- LOD 运行时切换无 pop。
## 验证
- CPU / GPU 蒙皮对拍(T1)。
- eterpack vs 散文件对拍(T2)。
- 扩展性能曲线覆盖 1 / 8 / 20 / 50 角色 × 两种复杂度 × 三台机(T5)。
## 本步风险
| 风险 | 缓解 |
|---|---|
| GPU 蒙皮骨骼矩阵上传带宽(每帧每实例 60 骨 × 64B) | 用骨骼 texture + `texelFetch`,只 update 变化实例;half-float 视精度 |
| eterpack 解密 key / IV(客户端从服务器拿 `RetrieveHybridCryptPackKeys`) | demo 用离线打包的非加密 pack,或把 key 硬编进 `test/`(自用不公开)|
| 实例化 + 蒙皮 texture 在 GLES3 的上限 | GLES3 保证 `texelFetch` + `RGBA32F` sampledAdreno 老驱动测一下 |
| 50 角色 draw call 爆 | 按材质 / 程序批;满配角色本来就多 draw call,接受较低帧率 |
## DoD 清单
- [ ] GPU 蒙皮输出 == CPU LBS`test/m4-skin-cmp.json`
- [ ] eterpack 路径 == 散文件路径(骨架 / 网格 byte-identical
- [ ] 多角色场景(含地面 + 光)可跑,LOD 运行时切换无 pop
- [ ] `test/m4-perf-curve.json`1/8/20/50 × 简化/满配 × 三机,CPU/GPU 两条线
- [ ] 结论并入 PLAN §10.1 反证清单 + 一页纸结论的"正式移植还缺什么"部分